From eb97dcae1b02724968279fb5ce1ba8c6bbcfb598 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:06:58 -0500 Subject: [PATCH 01/20] Delete past coach conversations from the history sheet, and keep late replies on their own thread --- PulseLoop/Coach/History/CoachHistory.swift | 121 ++++++++++++ .../Coach/ViewModels/CoachViewModel.swift | 9 +- PulseLoop/Views/CoachHistorySheet.swift | 99 ++++++++++ PulseLoop/Views/CoachView.swift | 88 +++------ PulseLoopTests/CoachHistoryTests.swift | 185 ++++++++++++++++++ 5 files changed, 441 insertions(+), 61 deletions(-) create mode 100644 PulseLoop/Coach/History/CoachHistory.swift create mode 100644 PulseLoop/Views/CoachHistorySheet.swift create mode 100644 PulseLoopTests/CoachHistoryTests.swift diff --git a/PulseLoop/Coach/History/CoachHistory.swift b/PulseLoop/Coach/History/CoachHistory.swift new file mode 100644 index 00000000..b2f636ff --- /dev/null +++ b/PulseLoop/Coach/History/CoachHistory.swift @@ -0,0 +1,121 @@ +import Foundation +import SwiftData + +/// One row of the coach history sheet: a past conversation, its title text and +/// how much is in it. Built from the SwiftData rows by `CoachHistory.summaries`. +struct CoachConversationSummary: Identifiable, Equatable { + let id: UUID + let updatedAt: Date + /// The first thing the user asked; nil when the thread holds no typed question + /// (a notification / summary thread, or an image-only first message). + let firstQuestion: String? + /// The stored conversation title, used when there's no question to show. + let storedTitle: String + let messageCount: Int + + var title: String { CoachHistoryText.title(firstQuestion: firstQuestion, storedTitle: storedTitle) } +} + +/// Listing and deleting past coach conversations. +@MainActor +enum CoachHistory { + /// Past conversations, newest first. Threads without a message are left out: + /// "+" persists its thread up front, so an unused one would list as a blank row. + static func summaries(conversations: [CoachConversation], messages: [CoachMessage]) -> [CoachConversationSummary] { + let byConversation = Dictionary(grouping: messages, by: \.conversationId) + return conversations.compactMap { convo -> CoachConversationSummary? in + guard let rows = byConversation[convo.id], !rows.isEmpty else { return nil } + let firstQuestion = rows + .filter { $0.role == "user" && !$0.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .min { $0.createdAt < $1.createdAt }? + .body + return CoachConversationSummary( + id: convo.id, updatedAt: convo.updatedAt, firstQuestion: firstQuestion, + storedTitle: convo.title, messageCount: rows.count) + } + .sorted { $0.updatedAt > $1.updatedAt } + } + + /// Delete one conversation with its messages, tool-call trace and attached + /// photo files. `CoachMessage`/`CoachToolCall` only hold the conversation's id + /// (no relationship), so each is cleared explicitly. + static func delete(_ id: UUID, context: ModelContext) { + let messages = (try? context.fetch(FetchDescriptor( + predicate: #Predicate { $0.conversationId == id }))) ?? [] + let toolCalls = (try? context.fetch(FetchDescriptor( + predicate: #Predicate { $0.conversationId == id }))) ?? [] + let conversations = (try? context.fetch(FetchDescriptor( + predicate: #Predicate { $0.id == id }))) ?? [] + remove(messages: messages, toolCalls: toolCalls, conversations: conversations, context: context) + } + + /// Delete every conversation, message, tool-call record and attached photo. + static func deleteAll(context: ModelContext) { + remove( + messages: (try? context.fetch(FetchDescriptor())) ?? [], + toolCalls: (try? context.fetch(FetchDescriptor())) ?? [], + conversations: (try? context.fetch(FetchDescriptor())) ?? [], + context: context) + } + + private static func remove( + messages: [CoachMessage], toolCalls: [CoachToolCall], conversations: [CoachConversation], context: ModelContext + ) { + let attachments = messages.flatMap { CoachAttachmentRef.decode(fromJSON: $0.attachmentsJSON) } + toolCalls.forEach(context.delete) + messages.forEach(context.delete) + conversations.forEach(context.delete) + try? context.save() + // Files go last, once the rows that point at them are gone. + attachments.forEach(CoachAttachmentStore.delete) + } +} + +/// The history row's text, kept out of the view so it can be unit-tested. +enum CoachHistoryText { + static let untitled = "Untitled conversation" + /// Placeholder titles a thread gets before its first question. + private static let placeholderTitles: Set = ["New chat", "Today check-in"] + + /// The first question on one line; else the stored title (notification and + /// summary threads are titled meaningfully); else a placeholder. + static func title(firstQuestion: String?, storedTitle: String? = nil) -> String { + if let question = oneLine(firstQuestion) { return question } + if let stored = oneLine(storedTitle), !placeholderTitles.contains(stored) { return stored } + return untitled + } + + /// "Today 14:05 · 4 messages". + static func subtitle( + _ convo: CoachConversationSummary, now: Date = Date(), calendar: Calendar = .current, locale: Locale = .current + ) -> String { + let count = convo.messageCount == 1 ? "1 message" : "\(convo.messageCount) messages" + return "\(whenText(convo.updatedAt, now: now, calendar: calendar, locale: locale)) · \(count)" + } + + /// "Today 14:05", "Yesterday 09:15", "Oct 12", or "Dec 30, 2025" when the year differs. + static func whenText(_ date: Date, now: Date = Date(), calendar: Calendar = .current, locale: Locale = .current) -> String { + let formatter = DateFormatter() + formatter.locale = locale + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + if calendar.isDate(date, inSameDayAs: now) { + formatter.timeStyle = .short + return "Today " + formatter.string(from: date) + } + if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), + calendar.isDate(date, inSameDayAs: yesterday) { + formatter.timeStyle = .short + return "Yesterday " + formatter.string(from: date) + } + let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now) + formatter.setLocalizedDateFormatFromTemplate(sameYear ? "MMMd" : "MMMdyyyy") + return formatter.string(from: date) + } + + private static func oneLine(_ text: String?) -> String? { + guard let text else { return nil } + let collapsed = text.split(whereSeparator: \.isWhitespace).joined(separator: " ") + return collapsed.isEmpty ? nil : collapsed + } +} diff --git a/PulseLoop/Coach/ViewModels/CoachViewModel.swift b/PulseLoop/Coach/ViewModels/CoachViewModel.swift index d722a03c..97fc6e23 100644 --- a/PulseLoop/Coach/ViewModels/CoachViewModel.swift +++ b/PulseLoop/Coach/ViewModels/CoachViewModel.swift @@ -12,6 +12,9 @@ final class CoachViewModel { var traceEvents: [CoachTraceEvent] = [] var isSending = false + /// The conversation the in-flight turn belongs to, so the live trace shows only + /// on that thread — not on one the user switched to (or started) mid-turn. + var sendingConversationId: UUID? var errorBanner: String? private let keyStore: APIKeyStore @@ -48,9 +51,10 @@ final class CoachViewModel { // Allow image-only sends: require either text or an attachment. guard !(trimmed.isEmpty && attachments.isEmpty), !isSending else { return } isSending = true + sendingConversationId = conversationId traceEvents = [] errorBanner = nil - defer { isSending = false } + defer { isSending = false; sendingConversationId = nil } // Optimistically persist the user message so the UI shows it immediately. let userMessage = CoachMessage( @@ -136,6 +140,9 @@ final class CoachViewModel { // MARK: - Persistence private func persist(_ result: CoachOrchestrator.TurnResult, conversationId: UUID, context: ModelContext, flags: CoachFeatureFlags) { + // Deleted from history while the provider was answering: storing the reply + // would leave orphan rows (and half a turn) behind for a thread that's gone. + guard fetchConversation(conversationId, context: context) != nil else { return } // A failed turn surfaces as a red error bubble (role "error") carrying the // code + reason, instead of the generic assistant fallback. Failed turns // still burned tokens, so usage rides on the error message too. diff --git a/PulseLoop/Views/CoachHistorySheet.swift b/PulseLoop/Views/CoachHistorySheet.swift new file mode 100644 index 00000000..28dbc79c --- /dev/null +++ b/PulseLoop/Views/CoachHistorySheet.swift @@ -0,0 +1,99 @@ +import SwiftUI + +/// Past conversations: tap one to resume it, swipe one to delete it, or delete +/// them all. Every delete asks first — it takes the messages, the tool-call trace +/// and any attached photos with it, and there's no undo. +struct CoachHistorySheet: View { + let conversations: [CoachConversationSummary] + let activeId: UUID? + let onSelect: (UUID) -> Void + let onDelete: (UUID) -> Void + let onDeleteAll: () -> Void + @Environment(\.dismiss) private var dismiss + @State private var pendingDelete: CoachConversationSummary? + @State private var confirmDeleteAll = false + + var body: some View { + NavigationStack { + Group { + if conversations.isEmpty { + Text("No conversations yet. Ask the coach something and it will show up here.") + .font(PulseFont.subheadline.weight(.regular)).foregroundStyle(PulseColors.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + Section { + ForEach(conversations) { convo in + row(convo) + .listRowBackground(PulseColors.card) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + // Not `role: .destructive`: that removes the row before the + // dialog answers, so a cancel would leave it looking deleted. + Button { pendingDelete = convo } label: { + Label("Delete", systemImage: "trash") + } + .tint(PulseColors.danger) + } + } + } footer: { + Text("Swipe a conversation to delete it.") + } + Section { + Button("Delete all conversations", role: .destructive) { confirmDeleteAll = true } + .frame(maxWidth: .infinity) + .listRowBackground(PulseColors.card) + } + } + .scrollContentBackground(.hidden) + } + } + .background(PulseColors.background) + .navigationTitle("Conversations") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } + } + .alert( + "Delete this conversation?", + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }), + presenting: pendingDelete + ) { convo in + Button("Delete", role: .destructive) { onDelete(convo.id); pendingDelete = nil } + Button("Cancel", role: .cancel) { pendingDelete = nil } + } message: { convo in + Text("“\(convo.title)” and its replies will be deleted. This can't be undone.") + } + .alert("Delete all conversations?", isPresented: $confirmDeleteAll) { + Button("Delete all", role: .destructive) { onDeleteAll() } + Button("Cancel", role: .cancel) {} + } message: { + Text(conversations.count == 1 + ? "Your conversation and its replies will be deleted. This can't be undone." + : "All \(conversations.count) conversations and their replies will be deleted. This can't be undone.") + } + } + } + + private func row(_ convo: CoachConversationSummary) -> some View { + Button { onSelect(convo.id); dismiss() } label: { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(convo.title) + .font(PulseFont.callout) + .foregroundStyle(PulseColors.textPrimary) + .lineLimit(2) + Text(CoachHistoryText.subtitle(convo)) + .font(PulseFont.caption2.weight(.regular)) + .foregroundStyle(PulseColors.textMuted) + } + Spacer() + if convo.id == activeId { + Image(systemName: "checkmark").foregroundStyle(PulseColors.accent) + .accessibilityLabel("Open now") + } + } + } + } +} diff --git a/PulseLoop/Views/CoachView.swift b/PulseLoop/Views/CoachView.swift index 704bf2a8..46e3c499 100644 --- a/PulseLoop/Views/CoachView.swift +++ b/PulseLoop/Views/CoachView.swift @@ -74,6 +74,12 @@ struct CoachView: View { !viewModel.isSending && (messages.isEmpty || messages.last?.role == "assistant") } + /// A turn is running on the thread on screen. One started on another thread + /// keeps going and lands there; its live trace doesn't follow the user here. + private var isSendingHere: Bool { + viewModel.isSending && viewModel.sendingConversationId == activeConversationId + } + var body: some View { VStack(spacing: 0) { header @@ -91,7 +97,7 @@ struct CoachView: View { onOpenMeal: { openMeal($0) } ).id(message.id) } - if viewModel.isSending { + if isSendingHere { CoachTraceStrip(events: viewModel.traceEvents).id("trace") } } @@ -160,9 +166,13 @@ struct CoachView: View { if id != nil { openRequestedConversation() } } .sheet(isPresented: $showHistory) { - CoachHistorySheet(conversations: conversations, activeId: activeConversationId) { id in - activeConversationId = id - } + CoachHistorySheet( + conversations: CoachHistory.summaries(conversations: conversations, messages: allMessages), + activeId: activeConversationId, + onSelect: { activeConversationId = $0 }, + onDelete: deleteConversation, + onDeleteAll: deleteAllConversations + ) } .sheet(isPresented: $showUsage) { CoachUsageSheet( @@ -344,10 +354,12 @@ struct CoachView: View { Task { await viewModel.send(trimmed, conversationId: conversationId, context: modelContext, attachments: attachments, coordinator: coordinator) } } - /// The active conversation, creating one on first use. + /// The active conversation, creating one on first use. An id whose row is gone + /// (deleted from history, or a stale notification link) gets a fresh thread — + /// a reply to a missing conversation is dropped rather than resurrecting it. private func resolveConversationId() -> UUID { - if let id = activeConversationId { return id } - if let existing = conversations.first { + if let id = activeConversationId, conversations.contains(where: { $0.id == id }) { return id } + if activeConversationId == nil, let existing = conversations.first { activeConversationId = existing.id return existing.id } @@ -378,61 +390,17 @@ struct CoachView: View { try? modelContext.save() activeConversationId = conversation.id } -} -/// Conversation history sheet — pick a past conversation to resume. -struct CoachHistorySheet: View { - let conversations: [CoachConversation] - let activeId: UUID? - let onSelect: (UUID) -> Void - @Environment(\.dismiss) private var dismiss - - private static let dateFormatter: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .medium - f.timeStyle = .short - return f - }() + /// Delete one past conversation; deleting the one on screen starts a new thread. + private func deleteConversation(_ id: UUID) { + if id == activeConversationId { newConversation() } + CoachHistory.delete(id, context: modelContext) + } - var body: some View { - NavigationStack { - Group { - if conversations.isEmpty { - Text("No conversations yet.") - .font(PulseFont.subheadline.weight(.regular)).foregroundStyle(PulseColors.textMuted) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List { - ForEach(conversations) { convo in - Button { onSelect(convo.id); dismiss() } label: { - HStack { - VStack(alignment: .leading, spacing: 3) { - Text(convo.title) - .font(PulseFont.callout) - .foregroundStyle(PulseColors.textPrimary) - Text(Self.dateFormatter.string(from: convo.updatedAt)) - .font(PulseFont.caption2.weight(.regular)) - .foregroundStyle(PulseColors.textMuted) - } - Spacer() - if convo.id == activeId { - Image(systemName: "checkmark").foregroundStyle(PulseColors.accent) - } - } - } - .listRowBackground(PulseColors.card) - } - } - .scrollContentBackground(.hidden) - } - } - .background(PulseColors.background) - .navigationTitle("Conversations") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } - } - } + /// Delete every conversation and start a new thread. + private func deleteAllConversations() { + CoachHistory.deleteAll(context: modelContext) + newConversation() } } diff --git a/PulseLoopTests/CoachHistoryTests.swift b/PulseLoopTests/CoachHistoryTests.swift new file mode 100644 index 00000000..647e945f --- /dev/null +++ b/PulseLoopTests/CoachHistoryTests.swift @@ -0,0 +1,185 @@ +import Foundation +import SwiftData +import UIKit +import XCTest +@testable import PulseLoop + +// MARK: - Row text (port of Android CoachHistoryTextTest) + +final class CoachHistoryTextTests: XCTestCase { + private var calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Paris")! + return c + }() + private let locale = Locale(identifier: "en_US") + private lazy var now = date(2026, 10, 21, 18, 0) + + private func date(_ y: Int, _ mo: Int, _ d: Int, _ h: Int = 9, _ mi: Int = 15) -> Date { + calendar.date(from: DateComponents(year: y, month: mo, day: d, hour: h, minute: mi))! + } + + private func when(_ d: Date) -> String { + CoachHistoryText.whenText(d, now: now, calendar: calendar, locale: locale) + } + + private func convo(_ updatedAt: Date, count: Int = 4) -> CoachConversationSummary { + CoachConversationSummary(id: UUID(), updatedAt: updatedAt, firstQuestion: "How did I sleep?", + storedTitle: "New chat", messageCount: count) + } + + func testTitleIsTheFirstQuestionOnOneLine() { + XCTAssertEqual(CoachHistoryText.title(firstQuestion: " How did I sleep\n\nlast night? "), + "How did I sleep last night?") + } + + func testMissingOrBlankQuestionGetsAPlaceholder() { + XCTAssertEqual(CoachHistoryText.title(firstQuestion: nil), "Untitled conversation") + XCTAssertEqual(CoachHistoryText.title(firstQuestion: " \n "), "Untitled conversation") + XCTAssertEqual(CoachHistoryText.title(firstQuestion: nil, storedTitle: "New chat"), "Untitled conversation") + } + + func testThreadWithoutAQuestionFallsBackToItsStoredTitle() { + XCTAssertEqual(CoachHistoryText.title(firstQuestion: nil, storedTitle: "Sleep recap"), "Sleep recap") + XCTAssertEqual(CoachHistoryText.title(firstQuestion: "Why?", storedTitle: "Sleep recap"), "Why?") + } + + func testTodayAndYesterdayAreNamedWithTheTime() { + XCTAssertTrue(when(date(2026, 10, 21, 14, 5)).hasPrefix("Today ")) + XCTAssertTrue(when(date(2026, 10, 21, 14, 5)).contains("2:05")) + XCTAssertTrue(when(date(2026, 10, 20)).hasPrefix("Yesterday ")) + } + + func testOlderDatesShowTheYearOnlyWhenItDiffers() { + XCTAssertEqual(when(date(2026, 10, 12)), "Oct 12") + XCTAssertFalse(when(date(2026, 10, 12)).contains("2026")) + XCTAssertTrue(when(date(2025, 12, 30)).contains("2025")) + } + + func testSubtitleCountsMessages() { + let at = date(2026, 10, 12) + let d = when(at) + XCTAssertEqual(CoachHistoryText.subtitle(convo(at, count: 1), now: now, calendar: calendar, locale: locale), + "\(d) · 1 message") + XCTAssertEqual(CoachHistoryText.subtitle(convo(at), now: now, calendar: calendar, locale: locale), + "\(d) · 4 messages") + } +} + +// MARK: - Listing + deleting + +private struct HistoryKeyStore: APIKeyStore { + func readKey() throws -> String? { "sk-test" } + func saveKey(_ key: String) throws {} + func deleteKey() throws {} +} + +/// Deletes the conversation while the "provider" is answering, then replies. +private actor DeletingMidTurnClient: ResponsesClient { + let onSend: @MainActor () -> Void + init(onSend: @escaping @MainActor () -> Void) { self.onSend = onSend } + func send(requestBody: Data) async throws -> OpenAIResponse { + await onSend() + return OpenAIResponse(id: "r1", outputItems: [.message(text: """ + {"response_type":"insight","title":"Late","summary":"Too late.","bullets":[],\ + "chart":null,"safety_note":null,"data_quality_note":null,"sources":[],\ + "follow_up_chips":[],"actions_taken":[],"confidence":"high"} + """)]) + } +} + +@MainActor +final class CoachHistoryTests: XCTestCase { + private func message(_ convo: UUID, _ role: String, _ body: String, at: Date, attachments: String? = nil) -> CoachMessage { + CoachMessage(conversationId: convo, role: role, body: body, attachmentsJSON: attachments, createdAt: at) + } + + func testSummariesHideEmptyThreadsAndListNewestFirst() throws { + let c = try TestSupport.makeContext() + let old = CoachConversation(title: "New chat") + old.updatedAt = Date(timeIntervalSince1970: 1_000) + let recent = CoachConversation(title: "Sleep recap") + recent.updatedAt = Date(timeIntervalSince1970: 3_000) + let empty = CoachConversation(title: "New chat") + empty.updatedAt = Date(timeIntervalSince1970: 5_000) + [old, recent, empty].forEach(c.insert) + let t0 = Date(timeIntervalSince1970: 100) + let messages = [ + message(old.id, "user", "Second?", at: t0.addingTimeInterval(20)), + message(old.id, "user", "First\nquestion", at: t0), + message(old.id, "assistant", "Answer", at: t0.addingTimeInterval(10)), + message(recent.id, "assistant", "Sleep recap body", at: t0), + ] + + let rows = CoachHistory.summaries(conversations: [old, recent, empty], messages: messages) + + XCTAssertEqual(rows.map(\.id), [recent.id, old.id]) + XCTAssertEqual(rows[0].title, "Sleep recap") + XCTAssertEqual(rows[0].messageCount, 1) + XCTAssertEqual(rows[1].title, "First question") + XCTAssertEqual(rows[1].messageCount, 3) + } + + func testDeleteRemovesMessagesToolCallsAndPhotosOfThatConversationOnly() throws { + let c = try TestSupport.makeContext() + let gone = CoachConversation(title: "a"), kept = CoachConversation(title: "b") + c.insert(gone); c.insert(kept) + let ref = try XCTUnwrap(CoachAttachmentStore.save(Self.image())) + c.insert(message(gone.id, "user", "", at: Date(), attachments: CoachAttachmentRef.encode([ref]))) + c.insert(CoachToolCall(conversationId: gone.id, toolName: "get_daily_summary")) + c.insert(message(kept.id, "user", "Hi", at: Date())) + c.insert(CoachToolCall(conversationId: kept.id, toolName: "get_daily_summary")) + try c.save() + XCTAssertNotNil(CoachAttachmentStore.data(for: ref)) + + CoachHistory.delete(gone.id, context: c) + + XCTAssertEqual(try c.fetch(FetchDescriptor()).map(\.id), [kept.id]) + XCTAssertEqual(try c.fetch(FetchDescriptor()).map(\.conversationId), [kept.id]) + XCTAssertEqual(try c.fetch(FetchDescriptor()).map(\.conversationId), [kept.id]) + XCTAssertNil(CoachAttachmentStore.data(for: ref)) + } + + func testDeleteAllClearsEverything() throws { + let c = try TestSupport.makeContext() + let a = CoachConversation(title: "a"), b = CoachConversation(title: "b") + c.insert(a); c.insert(b) + let ref = try XCTUnwrap(CoachAttachmentStore.save(Self.image())) + c.insert(message(a.id, "user", "Hi", at: Date(), attachments: CoachAttachmentRef.encode([ref]))) + c.insert(message(b.id, "user", "Yo", at: Date())) + c.insert(CoachToolCall(conversationId: b.id, toolName: "get_daily_summary")) + try c.save() + + CoachHistory.deleteAll(context: c) + + XCTAssertTrue(try c.fetch(FetchDescriptor()).isEmpty) + XCTAssertTrue(try c.fetch(FetchDescriptor()).isEmpty) + XCTAssertTrue(try c.fetch(FetchDescriptor()).isEmpty) + XCTAssertNil(CoachAttachmentStore.data(for: ref)) + } + + func testReplyToAConversationDeletedMidTurnDoesNotResurrectIt() async throws { + let c = try TestSupport.makeContext() + let convo = CoachConversation(title: "New chat") + c.insert(convo) + try c.save() + let convoId = convo.id + let settings = CoachSettingsStore(defaults: UserDefaults(suiteName: "coach-history-\(UUID())")!) + settings.settings = TestSupport.enabledCoachSettings() + let client = DeletingMidTurnClient { CoachHistory.delete(convoId, context: c) } + let vm = CoachViewModel(keyStore: HistoryKeyStore(), settingsStore: settings, clientFactory: { _ in client }) + + await vm.send("How did I sleep?", conversationId: convoId, context: c) + + XCTAssertTrue(try c.fetch(FetchDescriptor()).isEmpty) + XCTAssertTrue(try c.fetch(FetchDescriptor()).isEmpty, "the late reply must be dropped") + XCTAssertTrue(try c.fetch(FetchDescriptor()).isEmpty) + XCTAssertNil(vm.sendingConversationId) + } + + private static func image() -> UIImage { + UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { ctx in + UIColor.red.setFill(); ctx.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) + } + } +} From cf5278ff592e47e2986e00d45b679c3d5becb739 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:11:13 -0500 Subject: [PATCH 02/20] Answer without web search when Gemini refuses grounding, and retry Gemini 503s with backoff --- .../Coach/Config/CoachSettingsSection.swift | 9 +- PulseLoop/Coach/Gemini/GeminiClient.swift | 112 ++++++++- .../DesignSystem/SettingsFormComponents.swift | 13 +- PulseLoopTests/GeminiClientRetryTests.swift | 229 ++++++++++++++++++ 4 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 PulseLoopTests/GeminiClientRetryTests.swift diff --git a/PulseLoop/Coach/Config/CoachSettingsSection.swift b/PulseLoop/Coach/Config/CoachSettingsSection.swift index 6415371e..a3e6c18f 100644 --- a/PulseLoop/Coach/Config/CoachSettingsSection.swift +++ b/PulseLoop/Coach/Config/CoachSettingsSection.swift @@ -299,7 +299,14 @@ struct CoachSettingsSection: View { // spec defensively, and offering the switch would only promise something the // server can't do. store.settings.providerMode != .localOpenAICompat { - FormToggleRow(title: "Web search", isOn: webSearchBinding) + FormToggleRow( + title: "Web search", isOn: webSearchBinding, + // Free-tier Gemini keys can be refused Google Search grounding (HTTP 429) + // while every other call works; the coach then answers without it. + hint: store.settings.providerMode == .userGeminiKey + ? "Google Search grounding may need a paid Gemini plan. Free-tier keys can be refused, " + + "and the coach then answers without web results." + : nil) } FormToggleRow(title: "AI actions (set goals, log, edit)", isOn: writeToolsBinding) diff --git a/PulseLoop/Coach/Gemini/GeminiClient.swift b/PulseLoop/Coach/Gemini/GeminiClient.swift index 32e0ce14..db47b0bb 100644 --- a/PulseLoop/Coach/Gemini/GeminiClient.swift +++ b/PulseLoop/Coach/Gemini/GeminiClient.swift @@ -1,4 +1,5 @@ import Foundation +import os /// Adapts the app's `ResponsesClient` protocol to Google Gemini's /// `generateContent` API. Translates OpenAI Responses-API request bodies into @@ -34,11 +35,33 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { // Sources cited by a google_search turn, queued to prepend to the next user // message so the schema turn can fill the response `sources` array. private var pendingSourcesNote: String? - - init(apiKey: String, model: String = GeminiModel.default.rawValue, session: URLSession = .shared) { + // Inputs of the last built body, so a refused grounding turn can be rebuilt + // as the plain schema turn it would otherwise have been. + private var lastTextFormat: [String: Any]? + private var lastThinkingLevel: String? + /// First wait before re-sending a 503; doubled for the second retry (2 s, 4 s). + private let overloadBackoff: Duration + + private static let maxOverloadRetries = 2 + + static let webSearchSkippedNote = "Web search was not available for this answer " + + "(Google refused Search grounding for this API key; free-tier keys often can't use it). " + + "Answer from the health data alone, leave `sources` empty, and say briefly in " + + "`data_quality_note` that web search was skipped." + + /// Keys whose grounding turn was refused with a 429 while the same turn went + /// through without it. Process-wide because each agent turn gets a fresh client; + /// an app restart (e.g. after upgrading the plan) tries grounding again. + private static let groundingRefusedKeys = OSAllocatedUnfairLock(initialState: Set()) + + init( + apiKey: String, model: String = GeminiModel.default.rawValue, session: URLSession = .shared, + overloadBackoff: Duration = .seconds(2) + ) { self.apiKey = apiKey self.model = model self.session = session + self.overloadBackoff = overloadBackoff } func send(requestBody: Data) async throws -> OpenAIResponse { @@ -66,9 +89,49 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { appendContinuation(previousId: previousResponseId!, input: input) } + lastTextFormat = textFormat + lastThinkingLevel = thinkingLevel let geminiBody = buildGeminiBody(tools: convertTools(tools), textFormat: textFormat, thinkingLevel: thinkingLevel) - let geminiData = try JSONSerialization.data(withJSONObject: geminiBody) + let data: Data + do { + data = try await post(geminiBody) + } catch ResponsesError.http(status: 429, body: _) where Self.isGroundingBody(geminiBody) { + // A free-tier key can be refused `google_search` grounding outright (429 + // RESOURCE_EXHAUSTED) while every other call on the same key succeeds. + // Re-send this turn once without it — as the schema turn it would otherwise + // have been — so the answer still arrives. The rebuilt body carries no + // google_search, so this can't loop; a 429 on it is a real quota refusal. + data = try await post(ungroundedRetryBody()) + // Only now is it proven that grounding, not the key, was refused: skip it + // on this key for the rest of the process so later questions don't pay + // the extra round trip. + _ = Self.groundingRefusedKeys.withLock { $0.insert(apiKey) } + } + + return try parseGeminiResponse(data) + } + + // MARK: - Transport + + /// POST with a bounded retry on Gemini's 503 "high demand". A 503 means the + /// request was never served — nothing generated, nothing billed — so a retry + /// can't double-charge. Waits 2 s then 4 s (cancellable). A 429 is never + /// retried: quota buckets refill daily, not in seconds. + private func post(_ geminiBody: [String: Any]) async throws -> Data { + let bodyData = try JSONSerialization.data(withJSONObject: geminiBody) + var attempt = 0 + while true { + do { + return try await postOnce(bodyData) + } catch ResponsesError.http(status: 503, body: _) where attempt < Self.maxOverloadRetries { + try await Task.sleep(for: overloadBackoff * (1 << attempt)) + attempt += 1 + } + } + } + + private func postOnce(_ bodyData: Data) async throws -> Data { let urlStr = "\(baseURL)/\(model):generateContent?key=\(apiKey)" guard let url = URL(string: urlStr) else { throw ResponsesError.decoding("GeminiClient: could not build endpoint URL") @@ -77,7 +140,7 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = geminiData + request.httpBody = bodyData request.timeoutInterval = 60 let data: Data @@ -92,8 +155,34 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { let body = String(data: data, encoding: .utf8) ?? "" throw ResponsesError.http(status: http.statusCode, body: body) } + return data + } - return try parseGeminiResponse(data) + // MARK: - Refused grounding + + private static func isGroundingBody(_ body: [String: Any]) -> Bool { + (body["tools"] as? [[String: Any]])?.contains { $0["google_search"] != nil } == true + } + + /// The refused grounding turn rebuilt without `google_search`: same conversation, + /// the response schema applied, and a note so the answer says web search was + /// skipped. `webSearchGrounded` is already set, so it never re-grounds. + private func ungroundedRetryBody() -> [String: Any] { + noteWebSearchSkipped() + return buildGeminiBody(tools: [], textFormat: lastTextFormat, thinkingLevel: lastThinkingLevel) + } + + /// Tells the model, on the current user turn, that web search didn't run — so it + /// leaves `sources` empty and says so in `data_quality_note`, which the chat + /// shows under the answer. + private func noteWebSearchSkipped() { + let note: [String: Any] = ["text": Self.webSearchSkippedNote] + if var last = contents.last, last["role"] as? String == "user" { + last["parts"] = (last["parts"] as? [[String: Any]] ?? []) + [note] + contents[contents.count - 1] = last + } else { + contents.append(["role": "user", "parts": [note]]) + } } // MARK: - Conversation setup @@ -288,10 +377,19 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { // and skip the schema. The grounded prose + cited sources land in history; // the orchestrator's subsequent tool-less repair turn then enforces the // coach_response schema (and can fill the `sources` array from them). - let groundingTurn = tools.isEmpty && webSearchRequested && !webSearchGrounded + // + // A key already known to be refused grounding skips it here and gets the + // plain schema turn with a "web search skipped" note instead. + var groundingTurn = tools.isEmpty && webSearchRequested && !webSearchGrounded if groundingTurn { - body["tools"] = [["google_search": [:]]] webSearchGrounded = true + if Self.groundingRefusedKeys.withLock({ $0.contains(apiKey) }) { + groundingTurn = false + noteWebSearchSkipped() + body["contents"] = contents + } else { + body["tools"] = [["google_search": [:]]] + } } // Gemini rejects function declarations combined with a JSON response diff --git a/PulseLoop/DesignSystem/SettingsFormComponents.swift b/PulseLoop/DesignSystem/SettingsFormComponents.swift index 3b699e1e..89f0a357 100644 --- a/PulseLoop/DesignSystem/SettingsFormComponents.swift +++ b/PulseLoop/DesignSystem/SettingsFormComponents.swift @@ -131,13 +131,24 @@ private struct GroupedRowLayout: _VariadicView.MultiViewRoot { struct FormToggleRow: View { let title: String @Binding var isOn: Bool + /// Optional muted hint under the title (e.g. a provider caveat). + var hint: String? = nil var body: some View { Toggle(isOn: $isOn) { - Text(title).font(PulseFont.body).foregroundStyle(PulseColors.textPrimary) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(PulseFont.body).foregroundStyle(PulseColors.textPrimary) + if let hint { + Text(hint) + .font(PulseFont.caption.weight(.regular)) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + } } .tint(PulseColors.accent) .padding(.horizontal, 16) + .padding(.vertical, hint == nil ? 0 : 10) .frame(minHeight: 50) } } diff --git a/PulseLoopTests/GeminiClientRetryTests.swift b/PulseLoopTests/GeminiClientRetryTests.swift new file mode 100644 index 00000000..3e9067ed --- /dev/null +++ b/PulseLoopTests/GeminiClientRetryTests.swift @@ -0,0 +1,229 @@ +import Foundation +import XCTest +@testable import PulseLoop + +/// URLProtocol that answers each request through a handler, so a test can fail +/// some requests (e.g. only the grounding turn) and record every body sent. +final class GeminiRoutingURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: (([String: Any]) -> (status: Int, body: String))? + nonisolated(unsafe) static var sentBodies: [[String: Any]] = [] + + static func reset() { handler = nil; sentBodies = [] } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let data = request.httpBody ?? request.httpBodyStream.map(Self.read) ?? Data() + let json = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + Self.sentBodies.append(json) + let (status, body) = Self.handler?(json) ?? (200, Self.text("ok")) + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + static func text(_ text: String) -> String { + let escaped = String(data: try! JSONSerialization.data(withJSONObject: [text]), encoding: .utf8)!.dropFirst().dropLast() + return #"{"candidates":[{"content":{"parts":[{"text":"# + escaped + #"}]}}]}"# + } + + nonisolated private static func read(_ stream: InputStream) -> Data { + stream.open(); defer { stream.close() } + var data = Data() + let buffer = UnsafeMutablePointer.allocate(capacity: 4096) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let n = stream.read(buffer, maxLength: 4096) + if n <= 0 { break } + data.append(buffer, count: n) + } + return data + } +} + +/// Port of Android GeminiClientTest's overload-retry (503) and refused-grounding (429) cases. +final class GeminiClientRetryTests: XCTestCase { + override func setUp() { super.setUp(); GeminiRoutingURLProtocol.reset() } + override func tearDown() { GeminiRoutingURLProtocol.reset(); super.tearDown() } + + private var sent: [[String: Any]] { GeminiRoutingURLProtocol.sentBodies } + + private func client(_ key: String) -> GeminiClient { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GeminiRoutingURLProtocol.self] + return GeminiClient(apiKey: key, session: URLSession(configuration: config), overloadBackoff: .milliseconds(1)) + } + + private func respond(_ handler: @escaping ([String: Any]) -> (status: Int, body: String)) { + GeminiRoutingURLProtocol.handler = handler + } + + private static func isGrounding(_ body: [String: Any]) -> Bool { + (body["tools"] as? [[String: Any]])?.contains { $0["google_search"] != nil } == true + } + + private static func lastUserText(_ body: [String: Any]) -> String { + let last = (body["contents"] as? [[String: Any]])?.last + return ((last?["parts"] as? [[String: Any]]) ?? []).compactMap { $0["text"] as? String }.joined(separator: " ") + } + + private static func responseSchema(_ body: [String: Any]) -> Any? { + (body["generationConfig"] as? [String: Any])?["responseSchema"] + } + + private let fnTool: [String: Any] = ["type": "function", "name": "t", + "parameters": ["type": "object", "properties": [:]]] + + /// Tool turn (web search requested) answered with prose; returns its response id. + private func toolTurn(_ client: GeminiClient) async throws -> String { + try await client.send(requestBody: OpenAIRequestBuilder.data( + model: "gemini-2.5-flash", input: [OpenAIRequestBuilder.message(role: "user", content: "hi")], + tools: [WebSearchTool.spec, fnTool], textFormat: CoachResponseSchema.textFormat, + previousResponseId: nil, reasoningEffort: nil)).id + } + + private func repairTurn(_ client: GeminiClient, _ previousId: String) async throws -> OpenAIResponse { + try await client.send(requestBody: OpenAIRequestBuilder.data( + model: "gemini-2.5-flash", input: [OpenAIRequestBuilder.message(role: "user", content: "format it")], + tools: [], textFormat: CoachResponseSchema.textFormat, previousResponseId: previousId, reasoningEffort: nil)) + } + + private func plainTurn(_ client: GeminiClient) async throws -> OpenAIResponse { + try await client.send(requestBody: OpenAIRequestBuilder.data( + model: "gemini-2.5-flash", input: [], tools: [], textFormat: nil, + previousResponseId: nil, reasoningEffort: nil)) + } + + // MARK: Overload retry (503) + + func testA503IsRetriedAndTheTurnSurvives() async throws { + var attempts = 0 + respond { _ in + attempts += 1 + return attempts <= 2 ? (503, "This model is currently experiencing high demand.") : (200, GeminiRoutingURLProtocol.text("ok")) + } + let response = try await plainTurn(client("key-503-ok")) + XCTAssertEqual(response.outputText, "ok") + XCTAssertEqual(attempts, 3) + } + + func testTheLast503StillSurfaces() async throws { + respond { _ in (503, "still overloaded") } + do { + _ = try await plainTurn(client("key-503-down")) + XCTFail("a persistently overloaded model must surface, not hang") + } catch ResponsesError.http(let status, _) { + XCTAssertEqual(status, 503) + } + XCTAssertEqual(sent.count, 3) + } + + func testA429IsNeverRetriedOnAPlainTurn() async throws { + respond { _ in (429, "You exceeded your current quota.") } + do { + _ = try await toolTurn(client("key-429-plain")) + XCTFail("a quota refusal on a normal turn must surface") + } catch ResponsesError.http(let status, _) { + XCTAssertEqual(status, 429) + } + XCTAssertEqual(sent.count, 1) + } + + func testOverloadBackoffIsCancellable() async throws { + respond { _ in (503, "busy") } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GeminiRoutingURLProtocol.self] + let slow = GeminiClient(apiKey: "key-503-cancel", session: URLSession(configuration: config), + overloadBackoff: .seconds(30)) + let task = Task { try await self.plainTurn(slow) } + try await Task.sleep(for: .milliseconds(200)) + task.cancel() + let started = Date() + do { + _ = try await task.value + XCTFail("a cancelled turn must not complete") + } catch is CancellationError { + // expected + } + XCTAssertLessThan(Date().timeIntervalSince(started), 5) + XCTAssertEqual(sent.count, 1) + } + + // MARK: Refused grounding turn (429) + + func testA429OnTheGroundingTurnIsRetriedOnceWithoutGoogleSearch() async throws { + respond { body in + if Self.isGrounding(body) { return (429, "You exceeded your current quota") } + return (200, GeminiRoutingURLProtocol.text(GeminiRoutingURLProtocol.sentBodies.count == 1 ? "prose" : #"{"title":"ok"}"#)) + } + let c = client("key-123-retry") + let answer = try await repairTurn(c, try await toolTurn(c)) + + XCTAssertEqual(answer.outputText, #"{"title":"ok"}"#) + XCTAssertEqual(sent.count, 3) + XCTAssertTrue(Self.isGrounding(sent[1])) + let retry = sent[2] + XCTAssertFalse(Self.isGrounding(retry)) + XCTAssertNil(retry["tools"]) + // The retry is the schema turn it would otherwise have been, and says search was skipped. + XCTAssertNotNil(Self.responseSchema(retry)) + XCTAssertTrue(Self.lastUserText(retry).contains("Web search was not available")) + } + + func testARefusedKeySkipsGroundingOnLaterTurnsWithoutTheExtraRoundTrip() async throws { + let key = "key-123-remembered" + var refusals = 0 + respond { body in + if Self.isGrounding(body) { refusals += 1; return (429, "quota") } + return (200, GeminiRoutingURLProtocol.text("prose")) + } + let first = client(key) + _ = try await repairTurn(first, try await toolTurn(first)) + XCTAssertEqual(refusals, 1) + + // Next agent turn: a fresh client (the orchestrator builds one per turn). + GeminiRoutingURLProtocol.sentBodies = [] + let second = client(key) + _ = try await repairTurn(second, try await toolTurn(second)) + XCTAssertEqual(refusals, 1) + XCTAssertEqual(sent.count, 2) + XCTAssertFalse(Self.isGrounding(sent[1])) + XCTAssertNotNil(Self.responseSchema(sent[1])) + XCTAssertTrue(Self.lastUserText(sent[1]).contains("Web search was not available")) + + // A different key is unaffected. + respond { _ in (200, GeminiRoutingURLProtocol.text("prose")) } + let other = client("key-123-other") + _ = try await repairTurn(other, try await toolTurn(other)) + XCTAssertTrue(Self.isGrounding(try XCTUnwrap(sent.last))) + } + + func testA429OnTheUngroundedRetrySurfacesAndIsNotRemembered() async throws { + // The key itself is out of quota: the retry fails too, and grounding is not blamed. + let key = "key-123-exhausted" + var calls = 0 + respond { _ in + calls += 1 + return calls == 1 ? (200, GeminiRoutingURLProtocol.text("prose")) : (429, "You exceeded your current quota") + } + let c = client(key) + let id = try await toolTurn(c) + do { + _ = try await repairTurn(c, id) + XCTFail("a 429 on the retry must surface, not loop") + } catch ResponsesError.http(let status, _) { + XCTAssertEqual(status, 429) + } + XCTAssertEqual(calls, 3) // tool turn, grounding turn, one retry + + // Not marked as grounding-refused: a later turn on the same key still grounds. + respond { _ in (200, GeminiRoutingURLProtocol.text("prose")) } + let next = client(key) + _ = try await repairTurn(next, try await toolTurn(next)) + XCTAssertTrue(Self.isGrounding(try XCTUnwrap(sent.last))) + } +} From 9f00a029ea2222afff4a2a139680092793fe8926 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:18:25 -0500 Subject: [PATCH 03/20] Show each cloud provider's live model list in coach settings, with retired-model warnings and OpenRouter's real reasoning levels --- .../Coach/Config/CoachModelCatalog.swift | 286 ++++++++++++++++++ PulseLoop/Coach/Config/CoachModelPicker.swift | 218 +++++++++++++ PulseLoop/Coach/Config/CoachSettings.swift | 8 +- .../Coach/Config/CoachSettingsSection.swift | 19 ++ PulseLoopTests/CoachModelCatalogTests.swift | 224 ++++++++++++++ 5 files changed, 753 insertions(+), 2 deletions(-) create mode 100644 PulseLoop/Coach/Config/CoachModelCatalog.swift create mode 100644 PulseLoop/Coach/Config/CoachModelPicker.swift create mode 100644 PulseLoopTests/CoachModelCatalogTests.swift diff --git a/PulseLoop/Coach/Config/CoachModelCatalog.swift b/PulseLoop/Coach/Config/CoachModelCatalog.swift new file mode 100644 index 00000000..521fb2b5 --- /dev/null +++ b/PulseLoop/Coach/Config/CoachModelCatalog.swift @@ -0,0 +1,286 @@ +import Foundation +import os + +/// The models a cloud provider serves right now, read from its own model-list endpoint +/// once the provider is selected and its key saved — so Settings stops depending on +/// preset slugs that go stale. The presets (`CoachModel`, `GeminiModel`, …) stay the +/// curated picks shown first, and the fallback when the list can't be fetched. +/// +/// Advisory, like `LocalModelCatalog`: the stored model stays a free string, so a model +/// the list omits still works if the provider serves it. +/// +/// | Provider | Endpoint | What it adds | +/// |------------|-----------------------------------|--------------------------------------| +/// | OpenAI | `GET api.openai.com/v1/models` | ids only — filtered to chat models | +/// | Gemini | `GET …/v1beta/models` (paged) | display name, `generateContent` only | +/// | OpenRouter | `GET openrouter.ai/api/v1/models` | name, reasoning levels, expiry date | +/// | MiniMax | `GET api.minimax.io/v1/models` | ids only | +/// +/// Keys always travel in a header, never in the URL. OpenRouter is the only listing +/// that says which reasoning levels a model accepts (`reasoning.supported_efforts`) +/// or when it's being retired (`expiration_date`). +enum CoachModelCatalog { + + /// One listed model. `reasoningEfforts` nil = the listing doesn't say; empty = the + /// model takes no effort hint. `expiresOn` is an ISO date (`2026-11-18`) when the + /// provider has announced a retirement. + struct Entry: Sendable, Equatable { + let id: String + var name: String? + var reasoningEfforts: [ReasoningEffort]? + var expiresOn: String? + } + + struct Listing: Sendable, Equatable { + let entries: [Entry] + let fetchedAt: Date + + func entry(for model: String) -> Entry? { + let slug = model.trimmingCharacters(in: .whitespacesAndNewlines) + return entries.first { $0.id == slug } + } + } + + enum Result: Sendable, Equatable { + case success(Listing) + /// Already user-facing. + case failure(String) + + var listing: Listing? { + if case .success(let listing) = self { return listing } + return nil + } + } + + /// A refresh sits behind a provider switch, so it fails fast rather than hanging the picker. + static let refreshTimeout: TimeInterval = 15 + /// Long enough that reopening Settings doesn't refetch; short enough to notice a new model the same day. + static let cacheLifetime: TimeInterval = 6 * 60 * 60 + private static let maxGeminiPages = 5 + + /// The providers with a model-list endpoint. Local has its own (`LocalModelCatalog`). + static func supports(_ provider: CoachProviderMode) -> Bool { endpoint(for: provider) != nil } + + static func endpoint(for provider: CoachProviderMode) -> URL? { + switch provider { + case .userOpenAIKey: return URL(string: "https://api.openai.com/v1/models") + case .userGeminiKey: return URL(string: "https://generativelanguage.googleapis.com/v1beta/models") + case .userOpenRouterKey: return URL(string: "https://openrouter.ai/api/v1/models") + // Global host, as MiniMaxClient uses. + case .userMiniMaxKey: return URL(string: "https://api.minimax.io/v1/models") + default: return nil + } + } + + // MARK: - Cache + + private struct Cached { + let keyHash: Int + let listing: Listing + } + + /// In memory only, per provider; a different key misses. The raw key isn't kept. + private static let cache = OSAllocatedUnfairLock(initialState: [CoachProviderMode: Cached]()) + + /// The last successful listing for `provider`, when it was fetched with the same key and is fresh. + static func cached(_ provider: CoachProviderMode, apiKey: String, now: Date = Date()) -> Listing? { + cache.withLock { $0[provider] }.flatMap { cached in + cached.keyHash == apiKey.hashValue && now.timeIntervalSince(cached.listing.fetchedAt) < cacheLifetime + ? cached.listing : nil + } + } + + /// The listed entry for `model` under `provider`, from the cache only (no network). + static func cachedEntry(_ provider: CoachProviderMode, model: String) -> Entry? { + cache.withLock { $0[provider] }?.listing.entry(for: model) + } + + /// Test seam: seed or clear the cache. + static func store(_ listing: Listing?, for provider: CoachProviderMode, apiKey: String) { + cache.withLock { $0[provider] = listing.map { Cached(keyHash: apiKey.hashValue, listing: $0) } } + } + + // MARK: - Fetch + + /// Fetches the listing, or returns the cached one when `force` is false and it's still + /// fresh. Never throws: a failure comes back as data for Settings to show inline. + static func fetch( + _ provider: CoachProviderMode, apiKey: String, force: Bool = false, session: URLSession = .shared + ) async -> Result { + let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + if !force, let listing = cached(provider, apiKey: key) { return .success(listing) } + guard let url = endpoint(for: provider) else { return .failure("This provider has no model list.") } + guard !key.isEmpty else { return .failure("Save an API key to load the model list.") } + do { + let entries: [Entry] + switch provider { + case .userGeminiKey: + entries = try await fetchGemini(url, apiKey: key, session: session) + case .userOpenAIKey: + entries = try parseOpenAI(try await get(url, bearer: key, session: session)) + case .userOpenRouterKey: + entries = try parseOpenRouter(try await get(url, bearer: key, session: session)) + default: + entries = try parseIds(try await get(url, bearer: key, session: session)) + } + guard !entries.isEmpty else { return .failure("The provider listed no usable models.") } + let listing = Listing(entries: entries, fetchedAt: Date()) + store(listing, for: provider, apiKey: key) + return .success(listing) + } catch ResponsesError.http(let status, _) { + return .failure(status == 401 || status == 403 + ? "The provider rejected the key (HTTP \(status))." + : "The provider answered HTTP \(status) for its model list.") + } catch ResponsesError.transport(let underlying) { + return .failure("Couldn't load the model list — \(underlying.localizedDescription)") + } catch ResponsesError.decoding(let message) { + return .failure(message) + } catch { + return .failure(error.localizedDescription) + } + } + + /// True when a fetched listing doesn't include `model` — most likely retired, or (OpenRouter) + /// no longer tool-capable. Not flagged: Gemini's rolling `-latest` aliases and OpenAI dated + /// snapshots, which the listing never shows by design, and MiniMax presets (unverified that + /// MiniMax lists every variant, so only a slug we no longer preset is flagged there). + static func isUnlisted(_ provider: CoachProviderMode, listing: Listing, model: String) -> Bool { + let slug = model.trimmingCharacters(in: .whitespacesAndNewlines) + if slug.isEmpty || listing.entry(for: slug) != nil { return false } + switch provider { + case .userGeminiKey where slug.hasSuffix("-latest"): return false + case .userOpenAIKey where isDatedSnapshot(slug): return false + case .userMiniMaxKey where MiniMaxModel(rawValue: slug) != nil: return false + default: return true + } + } + + // MARK: - Parsing + + /// OpenAI lists every model the key can use — embeddings, speech, image, moderation and + /// realtime ones included — with nothing but an id. Keep the text models the coach can + /// drive through the Responses API, and drop dated snapshots (each is also listed under + /// its alias) and retired slugs. + static func parseOpenAI(_ data: Data) throws -> [Entry] { + let ids = try dataArray(data).compactMap { ($0 as? [String: Any])?.nonBlank("id") } + return Set(ids) + .filter { id in + (id.hasPrefix("gpt-") || id.range(of: #"^o\d+(-.*)?$"#, options: .regularExpression) != nil) + && !openAINonChat.contains { id.contains($0) } + && !isDatedSnapshot(id) + && !openAIRetired.contains(id) + } + .sorted() + .map { Entry(id: $0) } + } + + /// One page of `models.list`; ids come as `models/gemini-…` and are stored without the prefix. + static func parseGeminiPage(_ data: Data) throws -> (entries: [Entry], nextPageToken: String?) { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ResponsesError.decoding("The Gemini model list was not a JSON object.") + } + let entries = ((root["models"] as? [Any]) ?? []).compactMap { $0 as? [String: Any] }.compactMap { m -> Entry? in + guard let name = m.nonBlank("name") else { return nil } + let id = name.hasPrefix("models/") ? String(name.dropFirst("models/".count)) : name + let methods = (m["supportedGenerationMethods"] as? [String]) ?? [] + guard id.hasPrefix("gemini"), methods.contains("generateContent"), + !geminiNonChat.contains(where: { id.contains($0) }) else { return nil } + return Entry(id: id, name: m.nonBlank("displayName")) + } + return (entries, root.nonBlank("nextPageToken")) + } + + /// OpenRouter's listing is the richest: a name, `supported_parameters`, the reasoning + /// levels the model accepts and an expiry date. The coach drives a tool loop, so a model + /// without `tools` is left out; so are `:batch` variants, which aren't interactive. + static func parseOpenRouter(_ data: Data) throws -> [Entry] { + var seen = Set() + return try dataArray(data).compactMap { $0 as? [String: Any] }.compactMap { m -> Entry? in + guard let id = m.nonBlank("id"), seen.insert(id).inserted else { return nil } + let params = (m["supported_parameters"] as? [String]) ?? [] + guard params.contains("tools"), !id.hasSuffix(":batch") else { return nil } + let listed = ((m["reasoning"] as? [String: Any])?["supported_efforts"] as? [String]) + .map { raw in ReasoningEffort.allCases.filter { raw.contains($0.rawValue) } } + return Entry( + id: id, + name: m.nonBlank("name"), + // No `reasoning` parameter at all: the model takes no effort hint. + reasoningEfforts: listed ?? (params.contains("reasoning") ? nil : []), + expiresOn: m.nonBlank("expiration_date")) + } + .sorted { $0.id < $1.id } + } + + /// The bare OpenAI-style `{"data":[{"id":…}]}` envelope (MiniMax). + static func parseIds(_ data: Data) throws -> [Entry] { + Set(try dataArray(data).compactMap { ($0 as? [String: Any])?.nonBlank("id") }).sorted().map { Entry(id: $0) } + } + + // MARK: - Internals + + private static func fetchGemini(_ url: URL, apiKey: String, session: URLSession) async throws -> [Entry] { + var all: [Entry] = [] + var token: String? + // The listing is a few dozen models; the page cap only guards against a looping token. + for _ in 0..() + return all.filter { seen.insert($0.id).inserted }.sorted { $0.id < $1.id } + } + + private static func get(_ url: URL, bearer key: String, session: URLSession) async throws -> Data { + try await get(url, headers: ["Authorization": "Bearer \(key)"], session: session) + } + + private static func get(_ url: URL, headers: [String: String], session: URLSession) async throws -> Data { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = refreshTimeout + for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) } + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw ResponsesError.transport(error) + } + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + throw ResponsesError.http(status: http.statusCode, body: String(data: data, encoding: .utf8) ?? "") + } + return data + } + + private static func dataArray(_ data: Data) throws -> [Any] { + let root = try? JSONSerialization.jsonObject(with: data) + if let object = root as? [String: Any], let list = object["data"] as? [Any] { return list } + if let list = root as? [Any] { return list } + throw ResponsesError.decoding("No `data` array in the model list.") + } + + private static func isDatedSnapshot(_ slug: String) -> Bool { + slug.range(of: #"-\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) != nil + } + + private static let openAINonChat = [ + "embedding", "tts", "whisper", "transcribe", "audio", "realtime", "image", "dall-e", + "moderation", "search", "instruct", "computer-use", "sora", + ] + /// Slugs the picker retired; still listed by OpenAI for older keys. + private static let openAIRetired: Set = ["gpt-4o", "gpt-4o-mini", "o4-mini"] + private static let geminiNonChat = ["embedding", "tts", "image", "native-audio", "live", "aqa"] +} + +private extension Dictionary where Key == String, Value == Any { + func nonBlank(_ key: String) -> String? { + guard let value = self[key] as? String, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return value + } +} diff --git a/PulseLoop/Coach/Config/CoachModelPicker.swift b/PulseLoop/Coach/Config/CoachModelPicker.swift new file mode 100644 index 00000000..a204bce4 --- /dev/null +++ b/PulseLoop/Coach/Config/CoachModelPicker.swift @@ -0,0 +1,218 @@ +import SwiftUI + +/// Loads a cloud provider's live model list (`CoachModelCatalog`) for Settings. One +/// load at a time: a refresh still in flight when the provider or key changes is +/// superseded, so an older answer can't land on top of the current one. +@MainActor +@Observable +final class CoachModelListLoader { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private(set) var provider: CoachProviderMode? + private(set) var result: CoachModelCatalog.Result? + private(set) var isLoading = false + private(set) var hasKey = false + private var generation = 0 + private var task: Task? + + var listing: CoachModelCatalog.Listing? { result?.listing } + + /// The saved Keychain key for a cloud provider with a model list. + static func storedKey(for provider: CoachProviderMode) -> String? { + let keyStore: APIKeyStore? = switch provider { + case .userOpenAIKey: OpenAIKeychainStore() + case .userGeminiKey: GeminiKeychainStore() + case .userOpenRouterKey: OpenRouterKeychainStore() + case .userMiniMaxKey: MiniMaxKeychainStore() + default: nil + } + return try? keyStore?.readKey() + } + + func load(_ provider: CoachProviderMode, apiKey: String?, force: Bool = false) { + task?.cancel() + generation += 1 + if provider != self.provider { result = nil } + self.provider = provider + let key = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + hasKey = !key.isEmpty + guard CoachModelCatalog.supports(provider), hasKey else { + result = nil + isLoading = false + return + } + let current = generation + isLoading = true + task = Task { [weak self] in + let fetched = await CoachModelCatalog.fetch(provider, apiKey: key, force: force) + guard let self, current == self.generation else { return } + self.result = fetched + self.isLoading = false + } + } +} + +/// The live listing's status under the Model row: how many models the provider lists +/// (or why it couldn't say), a Refresh/Retry button, and what the listing says about +/// the stored model — not listed any more, or announced for retirement. Also owns the +/// load (as soon as the provider is picked with a saved key, and again when the key +/// changes) and the all-models sheet the Model menu opens. +struct CoachModelListStatus: View { + let loader: CoachModelListLoader + let provider: CoachProviderMode + let selected: String + /// Changes whenever Settings re-reads the stored keys, so a new key reloads the list. + let keyRevision: Int + @Binding var showAll: Bool + let onPick: (String) -> Void + + private var providerName: String { provider.label } + + var body: some View { + FormField(padding: 14) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if loader.isLoading { ProgressView().controlSize(.small) } + Text(status.text) + .font(.caption) + .foregroundStyle(status.isError ? PulseColors.danger : PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + if loader.hasKey, !loader.isLoading { + Button(isFailure ? "Retry" : "Refresh") { load(force: true) } + .font(.caption.weight(.semibold)) + .tint(PulseColors.accent) + } + } + if let listing = loader.listing { + if CoachModelCatalog.isUnlisted(provider, listing: listing, model: selected) { + Text("\(providerName) doesn't list “\(selected)” any more — it may have been retired. " + + "Pick another model.") + .font(.caption).foregroundStyle(PulseColors.danger) + } else if let expiry = listing.entry(for: selected)?.expiresOn { + Text("\(providerName) is retiring this model on \(expiry).") + .font(.caption).foregroundStyle(PulseColors.warning) + } + } + } + } + .task(id: "\(provider.rawValue)#\(keyRevision)") { load(force: false) } + .sheet(isPresented: $showAll) { + CoachModelPickerSheet( + providerName: providerName, + presets: CoachModelPickerSheet.Preset.curated(for: provider), + listing: loader.provider == provider ? loader.listing : nil, + selected: selected, + onPick: onPick) + } + } + + private func load(force: Bool) { + loader.load(provider, apiKey: CoachModelListLoader.storedKey(for: provider), force: force) + } + + private var isFailure: Bool { + if case .failure = loader.result { return true } + return false + } + + private var status: (text: String, isError: Bool) { + if !loader.hasKey { return ("Save your key to see every model \(providerName) offers.", false) } + if loader.isLoading { return ("Loading models from \(providerName)…", false) } + switch loader.result { + case .failure(let message): return (message, true) + case .success(let listing): return ("\(listing.entries.count) models available from \(providerName)", false) + case nil: return ("", false) + } + } +} + +/// Every model the provider lists, with the curated presets first. A sheet with a +/// filter, because OpenRouter lists hundreds. Picking a model stores its slug; the +/// listing is advisory, so a slug it omits keeps working if the provider serves it. +struct CoachModelPickerSheet: View { + struct Preset: Identifiable { + let id: String + let blurb: String + + /// The provider's curated picks, shown first. + static func curated(for provider: CoachProviderMode) -> [Preset] { + switch provider { + case .userGeminiKey: return GeminiModel.allCases.map { Preset(id: $0.rawValue, blurb: $0.blurb) } + case .userOpenRouterKey: return OpenRouterModel.allCases.map { Preset(id: $0.rawValue, blurb: $0.blurb) } + case .userMiniMaxKey: return MiniMaxModel.allCases.map { Preset(id: $0.rawValue, blurb: $0.blurb) } + default: return CoachModel.allCases.map { Preset(id: $0.rawValue, blurb: $0.blurb) } + } + } + } + + let providerName: String + let presets: [Preset] + let listing: CoachModelCatalog.Listing? + let selected: String + let onPick: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var filter = "" + + private var others: [CoachModelCatalog.Entry] { + let presetIds = Set(presets.map(\.id)) + let all = (listing?.entries ?? []).filter { !presetIds.contains($0.id) } + let query = filter.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return all } + return all.filter { $0.id.localizedCaseInsensitiveContains(query) || ($0.name?.localizedCaseInsensitiveContains(query) ?? false) } + } + + private var allCount: Int { + let presetIds = Set(presets.map(\.id)) + return (listing?.entries ?? []).filter { !presetIds.contains($0.id) }.count + } + + var body: some View { + NavigationStack { + List { + if filter.isEmpty, !presets.isEmpty { + Section("Recommended") { + ForEach(presets) { preset in row(preset.id, detail: preset.blurb) } + } + } + Section("All models from \(providerName) (\(allCount))") { + ForEach(others, id: \.id) { entry in + row(entry.id, detail: [ + entry.name.flatMap { $0 == entry.id ? nil : $0 }, + entry.expiresOn.map { "retiring \($0)" }, + ].compactMap { $0 }.joined(separator: " · ")) + } + } + } + .scrollContentBackground(.hidden) + .background(PulseColors.background) + .searchable(text: $filter, placement: .navigationBarDrawer(displayMode: .always), prompt: "Filter, e.g. claude") + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .navigationTitle("Model") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } + } + } + } + + private func row(_ slug: String, detail: String) -> some View { + Button { onPick(slug); dismiss() } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(slug) + .font(PulseFont.callout.monospaced()) + .foregroundStyle(PulseColors.textPrimary) + if !detail.isEmpty { + Text(detail).font(.caption).foregroundStyle(PulseColors.textMuted) + } + } + Spacer() + if slug == selected.trimmingCharacters(in: .whitespacesAndNewlines) { + Image(systemName: "checkmark").foregroundStyle(PulseColors.accent) + } + } + } + .listRowBackground(PulseColors.card) + } +} diff --git a/PulseLoop/Coach/Config/CoachSettings.swift b/PulseLoop/Coach/Config/CoachSettings.swift index 178c83b2..8f4a6e25 100644 --- a/PulseLoop/Coach/Config/CoachSettings.swift +++ b/PulseLoop/Coach/Config/CoachSettings.swift @@ -321,12 +321,16 @@ struct CoachSettings: Codable, Equatable { return trimmed.isEmpty ? MiniMaxModel.default.rawValue : trimmed } - /// The effort levels the selected preset accepts; `nil` when unknown (a Custom + /// The effort levels the selected model accepts; `nil` when unknown (a Custom /// slug, a non-preset stored model) or when the provider takes no effort hint. + /// A non-preset OpenRouter model uses the levels OpenRouter's live listing gives + /// it (`CoachModelCatalog`), once that has been fetched. var knownReasoningEfforts: [ReasoningEffort]? { switch providerMode { case .userOpenAIKey: return CoachModel(rawValue: model)?.reasoningEfforts - case .userOpenRouterKey: return OpenRouterModel(rawValue: model)?.reasoningEfforts + case .userOpenRouterKey: + return OpenRouterModel(rawValue: model)?.reasoningEfforts + ?? CoachModelCatalog.cachedEntry(.userOpenRouterKey, model: model)?.reasoningEfforts // A non-preset Gemini model may be a 2.5 model, which rejects `thinkingLevel`. case .userGeminiKey: return GeminiModel(rawValue: model)?.reasoningEfforts ?? [] default: return nil diff --git a/PulseLoop/Coach/Config/CoachSettingsSection.swift b/PulseLoop/Coach/Config/CoachSettingsSection.swift index a3e6c18f..aebd02f6 100644 --- a/PulseLoop/Coach/Config/CoachSettingsSection.swift +++ b/PulseLoop/Coach/Config/CoachSettingsSection.swift @@ -49,6 +49,12 @@ struct CoachSettingsSection: View { } } + /// The active cloud provider's live model list (advisory; the stored model stays free text). + @State private var modelList = CoachModelListLoader() + @State private var showAllModels = false + /// Bumped whenever the stored keys are re-read, so the model list reloads for a new key. + @State private var keyRevision = 0 + // OpenAI key state @State private var keyDraft: String = "" @State private var hasSavedKey: Bool = false @@ -181,6 +187,12 @@ struct CoachSettingsSection: View { } } } + if let listing = modelList.listing, modelList.provider == store.settings.providerMode { + Divider() + Button("All models from \(store.settings.providerMode.label) (\(listing.entries.count))…") { + showAllModels = true + } + } } } @@ -188,6 +200,12 @@ struct CoachSettingsSection: View { customModelField } + if CoachModelCatalog.supports(store.settings.providerMode) { + CoachModelListStatus( + loader: modelList, provider: store.settings.providerMode, selected: store.settings.model, + keyRevision: keyRevision, showAll: $showAllModels, onPick: { modelPickerBinding.wrappedValue = $0 }) + } + // Reasoning effort / Gemini thinking level. The levels differ per model // (GPT-6 Sol rejects None, DeepSeek has no Medium), so the picker lists // only what the selected model accepts. @@ -612,6 +630,7 @@ struct CoachSettingsSection: View { // MARK: - Key actions private func refreshKeyState() { + keyRevision += 1 hasSavedKey = ((try? openAIKeyStore.readKey()) ?? nil) != nil hasGeminiKey = ((try? geminiKeyStore.readKey()) ?? nil) != nil hasOpenRouterKey = ((try? openRouterKeyStore.readKey()) ?? nil) != nil diff --git a/PulseLoopTests/CoachModelCatalogTests.swift b/PulseLoopTests/CoachModelCatalogTests.swift new file mode 100644 index 00000000..18e6ede6 --- /dev/null +++ b/PulseLoopTests/CoachModelCatalogTests.swift @@ -0,0 +1,224 @@ +import Foundation +import XCTest +@testable import PulseLoop + +/// Answers model-list requests through a handler and records each request. +final class CatalogStubProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) -> (status: Int, body: String))? + nonisolated(unsafe) static var requests: [URLRequest] = [] + + static func reset() { handler = nil; requests = [] } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.requests.append(request) + let (status, body) = Self.handler?(request) ?? (200, "{}") + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +/// Port of Android CoachModelCatalogTest: the live model listings (OpenAI / Gemini / +/// OpenRouter / MiniMax), what counts as unlisted, and live OpenRouter reasoning levels. +final class CoachModelCatalogTests: XCTestCase { + override func setUp() { + super.setUp() + CatalogStubProtocol.reset() + for provider in [CoachProviderMode.userOpenAIKey, .userGeminiKey, .userOpenRouterKey, .userMiniMaxKey] { + CoachModelCatalog.store(nil, for: provider, apiKey: "") + } + } + + override func tearDown() { + CatalogStubProtocol.reset() + CoachModelCatalog.store(nil, for: .userOpenRouterKey, apiKey: "") + super.tearDown() + } + + private func session() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [CatalogStubProtocol.self] + return URLSession(configuration: config) + } + + private func listing(_ ids: [String]) -> CoachModelCatalog.Listing { + CoachModelCatalog.Listing(entries: ids.map { CoachModelCatalog.Entry(id: $0) }, fetchedAt: Date()) + } + + // MARK: OpenAI + + func testOpenAIKeepsChatModelsAndDropsEverythingElse() throws { + let body = """ + {"object":"list","data":[ + {"id":"gpt-6.1-sol"},{"id":"gpt-6-luna"},{"id":"o5"},{"id":"o5-mini"}, + {"id":"gpt-6.1-sol-2026-09-30"},{"id":"text-embedding-4-large"},{"id":"gpt-6-realtime"}, + {"id":"gpt-image-2"},{"id":"gpt-6-mini-tts"},{"id":"whisper-1"},{"id":"omni-moderation-latest"}, + {"id":"gpt-4o"},{"id":"dall-e-3"},{"id":"gpt-6-luna"} + ]} + """ + XCTAssertEqual(try CoachModelCatalog.parseOpenAI(Data(body.utf8)).map(\.id), + ["gpt-6-luna", "gpt-6.1-sol", "o5", "o5-mini"]) + } + + // MARK: Gemini + + func testGeminiKeepsGenerateContentModelsAndStripsTheModelsPrefix() throws { + let body = """ + {"models":[ + {"name":"models/gemini-3.8-flash","displayName":"Gemini 3.8 Flash","supportedGenerationMethods":["generateContent","countTokens"]}, + {"name":"models/gemini-flash-latest","displayName":"Gemini Flash Latest","supportedGenerationMethods":["generateContent"]}, + {"name":"models/gemini-embedding-002","supportedGenerationMethods":["embedContent"]}, + {"name":"models/gemini-3.8-flash-preview-tts","supportedGenerationMethods":["generateContent"]}, + {"name":"models/imagen-5","supportedGenerationMethods":["predict"]} + ],"nextPageToken":"abc"} + """ + let page = try CoachModelCatalog.parseGeminiPage(Data(body.utf8)) + XCTAssertEqual(page.entries.map(\.id), ["gemini-3.8-flash", "gemini-flash-latest"]) + XCTAssertEqual(page.entries.first?.name, "Gemini 3.8 Flash") + XCTAssertEqual(page.nextPageToken, "abc") + XCTAssertNil(try CoachModelCatalog.parseGeminiPage(Data(#"{"models":[]}"#.utf8)).nextPageToken) + } + + func testGeminiFetchPagesWithTheKeyInAHeaderNotTheURL() async throws { + CatalogStubProtocol.handler = { request in + let token = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)? + .queryItems?.first { $0.name == "pageToken" }?.value + if token == nil { + return (200, #"{"models":[{"name":"models/gemini-3.8-flash","supportedGenerationMethods":["generateContent"]}],"nextPageToken":"p 2"}"#) + } + return (200, #"{"models":[{"name":"models/gemini-4-argon","supportedGenerationMethods":["generateContent"]}]}"#) + } + let result = await CoachModelCatalog.fetch(.userGeminiKey, apiKey: "AIza-secret", session: session()) + + XCTAssertEqual(result.listing?.entries.map(\.id), ["gemini-3.8-flash", "gemini-4-argon"]) + XCTAssertEqual(CatalogStubProtocol.requests.count, 2) + for request in CatalogStubProtocol.requests { + XCTAssertFalse(request.url!.absoluteString.contains("AIza-secret"), "the key must not be in the URL") + XCTAssertEqual(request.value(forHTTPHeaderField: "x-goog-api-key"), "AIza-secret") + } + XCTAssertTrue(CatalogStubProtocol.requests[1].url!.absoluteString.contains("pageToken=p%202")) + } + + // MARK: OpenRouter + + /// Trimmed from openrouter.ai/api/v1/models on 2026-10-14. + private let openRouterBody = """ + {"data":[ + {"id":"anthropic/claude-haiku-5.5","name":"Anthropic: Claude Haiku 5.5", + "supported_parameters":["reasoning","reasoning_effort","tools","structured_outputs"], + "reasoning":{"mandatory":false,"supported_efforts":["max","xhigh","high","medium","low"],"default_effort":"medium"}, + "expiration_date":null}, + {"id":"anthropic/claude-haiku-5.5:batch","name":"batch","supported_parameters":["tools"]}, + {"id":"deepseek/deepseek-v4.1-flash","name":"DeepSeek V4.1 Flash","supported_parameters":["reasoning","tools"], + "reasoning":{"supported_efforts":["max","high","low"]}}, + {"id":"xiaomi/mimo-v2.5","name":"MiMo V2.5","supported_parameters":["tools"],"expiration_date":"2026-10-21"}, + {"id":"some/vision-only","name":"No tools","supported_parameters":["max_tokens"]} + ]} + """ + + func testOpenRouterKeepsToolCapableModelsWithTheirLevelsAndExpiry() throws { + let entries = Dictionary(uniqueKeysWithValues: + try CoachModelCatalog.parseOpenRouter(Data(openRouterBody.utf8)).map { ($0.id, $0) }) + XCTAssertEqual(Set(entries.keys), ["anthropic/claude-haiku-5.5", "deepseek/deepseek-v4.1-flash", "xiaomi/mimo-v2.5"]) + XCTAssertEqual(entries["anthropic/claude-haiku-5.5"]?.reasoningEfforts, ReasoningEffort.lowToMax) + XCTAssertEqual(entries["deepseek/deepseek-v4.1-flash"]?.reasoningEfforts, [.low, .high, .max]) + // No `reasoning` parameter at all: the model takes no effort hint. + XCTAssertEqual(entries["xiaomi/mimo-v2.5"]?.reasoningEfforts, []) + XCTAssertEqual(entries["xiaomi/mimo-v2.5"]?.expiresOn, "2026-10-21") + XCTAssertNil(entries["anthropic/claude-haiku-5.5"]?.expiresOn) + } + + func testOpenRouterUsesTheLiveLevelsForAModelOutsideThePresets() async throws { + CatalogStubProtocol.handler = { _ in (200, self.openRouterBody) } + let result = await CoachModelCatalog.fetch(.userOpenRouterKey, apiKey: "sk-or-test", session: session()) + XCTAssertNotNil(result.listing) + XCTAssertEqual(CatalogStubProtocol.requests.first?.value(forHTTPHeaderField: "Authorization"), "Bearer sk-or-test") + + var settings = CoachSettings() + settings.providerMode = .userOpenRouterKey + settings.model = "xiaomi/mimo-v2.5" + settings.reasoningEffort = "high" + XCTAssertEqual(settings.reasoningEffortOptions, [], "a model without reasoning hides the picker") + XCTAssertNil(settings.resolvedReasoningEffort) + + settings.model = "some/unlisted-model" + XCTAssertEqual(settings.reasoningEffortOptions, ReasoningEffort.common) + XCTAssertEqual(settings.resolvedReasoningEffort, "high", "an unknown model passes the hint through") + + // Presets keep their own levels: DeepSeek has no `medium`. + settings.model = OpenRouterModel.deepseek.rawValue + settings.reasoningEffort = "medium" + XCTAssertNil(settings.resolvedReasoningEffort) + } + + // MARK: MiniMax + + func testMiniMaxListingReadsTheOpenAIEnvelope() throws { + XCTAssertEqual(try CoachModelCatalog.parseIds(Data(#"{"data":[{"id":"MiniMax-M3"},{"id":"MiniMax-M2.1"}]}"#.utf8)).map(\.id), + ["MiniMax-M2.1", "MiniMax-M3"]) + } + + // MARK: Unlisted (retired) models + + func testAStoredModelTheListingOmitsIsFlaggedExceptAliasesAndSnapshots() throws { + let openAI = CoachModelCatalog.Listing( + entries: try CoachModelCatalog.parseOpenAI(Data(#"{"data":[{"id":"gpt-6.1-sol"}]}"#.utf8)), fetchedAt: Date()) + XCTAssertTrue(CoachModelCatalog.isUnlisted(.userOpenAIKey, listing: openAI, model: "gpt-5.4")) + XCTAssertFalse(CoachModelCatalog.isUnlisted(.userOpenAIKey, listing: openAI, model: "gpt-6.1-sol")) + XCTAssertFalse(CoachModelCatalog.isUnlisted(.userOpenAIKey, listing: openAI, model: "gpt-6.1-sol-2026-09-30")) + let gemini = listing(["gemini-3.8-flash"]) + XCTAssertFalse(CoachModelCatalog.isUnlisted(.userGeminiKey, listing: gemini, model: "gemini-flash-latest")) + XCTAssertTrue(CoachModelCatalog.isUnlisted(.userGeminiKey, listing: gemini, model: "gemini-2.0-flash")) + } + + func testMiniMaxPresetsAreNeverFlaggedARetiredFamilyIs() { + let minimax = listing(["MiniMax-M3"]) + XCTAssertFalse(CoachModelCatalog.isUnlisted(.userMiniMaxKey, listing: minimax, model: "MiniMax-M2.1-highspeed")) + XCTAssertTrue(CoachModelCatalog.isUnlisted(.userMiniMaxKey, listing: minimax, model: "MiniMax-M2.7")) + } + + // MARK: Fetch, cache and failures + + func testListingIsCachedPerProviderAndKeyUntilForcedOrStale() async throws { + CatalogStubProtocol.handler = { _ in (200, #"{"data":[{"id":"gpt-6.1-sol"}]}"#) } + _ = await CoachModelCatalog.fetch(.userOpenAIKey, apiKey: "sk-a", session: session()) + _ = await CoachModelCatalog.fetch(.userOpenAIKey, apiKey: "sk-a", session: session()) + XCTAssertEqual(CatalogStubProtocol.requests.count, 1, "a fresh listing is served from the cache") + + _ = await CoachModelCatalog.fetch(.userOpenAIKey, apiKey: "sk-b", session: session()) + XCTAssertEqual(CatalogStubProtocol.requests.count, 2, "a different key misses the cache") + + _ = await CoachModelCatalog.fetch(.userOpenAIKey, apiKey: "sk-b", force: true, session: session()) + XCTAssertEqual(CatalogStubProtocol.requests.count, 3, "Refresh refetches") + + XCTAssertNotNil(CoachModelCatalog.cached(.userOpenAIKey, apiKey: "sk-b")) + XCTAssertNil(CoachModelCatalog.cached(.userOpenAIKey, apiKey: "sk-b", now: Date().addingTimeInterval(6 * 3600 + 1))) + XCTAssertNil(CoachModelCatalog.cached(.userGeminiKey, apiKey: "sk-b")) + } + + func testFailuresComeBackAsUserFacingText() async { + CatalogStubProtocol.handler = { _ in (401, #"{"error":"bad key"}"#) } + guard case .failure(let rejected) = await CoachModelCatalog.fetch(.userOpenAIKey, apiKey: "sk-x", session: session()) else { + return XCTFail("expected a failure") + } + XCTAssertEqual(rejected, "The provider rejected the key (HTTP 401).") + + CatalogStubProtocol.handler = { _ in (200, #"{"data":[{"id":"text-embedding-4-large"}]}"#) } + guard case .failure(let empty) = await CoachModelCatalog.fetch(.userOpenAIKey, apiKey: "sk-x", session: session()) else { + return XCTFail("expected a failure") + } + XCTAssertEqual(empty, "The provider listed no usable models.") + + guard case .failure(let noKey) = await CoachModelCatalog.fetch(.userMiniMaxKey, apiKey: " ", session: session()) else { + return XCTFail("expected a failure") + } + XCTAssertEqual(noKey, "Save an API key to load the model list.") + XCTAssertFalse(CoachModelCatalog.supports(.localOpenAICompat)) + } +} From fa4b62ca191b62516aae2e67343cccf25f5f5a61 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:08:55 -0500 Subject: [PATCH 04/20] Label hourly SpO2 a ring derives from heart rate as estimated --- PulseLoop/Services/EstimatedSpO2.swift | 158 +++++++++++++++ PulseLoop/Services/PulseServices.swift | 23 +++ PulseLoop/ViewModels/TodayStore.swift | 4 +- PulseLoop/ViewModels/VitalsCardFactory.swift | 5 +- PulseLoop/ViewModels/VitalsStore.swift | 11 +- PulseLoop/Views/MetricDetailView.swift | 11 +- PulseLoop/Views/TodayTiles.swift | 6 + PulseLoop/Views/VitalsCards.swift | 8 + PulseLoopTests/EstimatedSpO2Tests.swift | 194 +++++++++++++++++++ docs/hardware/colmi.md | 15 ++ docs/hardware/r10m.md | 2 +- docs/hardware/tk5.md | 2 +- 12 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 PulseLoop/Services/EstimatedSpO2.swift create mode 100644 PulseLoopTests/EstimatedSpO2Tests.swift diff --git a/PulseLoop/Services/EstimatedSpO2.swift b/PulseLoop/Services/EstimatedSpO2.swift new file mode 100644 index 00000000..a2467789 --- /dev/null +++ b/PulseLoop/Services/EstimatedSpO2.swift @@ -0,0 +1,158 @@ +import Foundation +import SwiftData + +/// Hourly SpO₂ that a ring computes from heart rate rather than measures (Android issue #103). +/// +/// On a YCBT ring (`Ale-Hop2211`, a SmartHealth Colmi) the reporter read all 304 hourly combined +/// ("All", `05 09`) records out of the ring and the same heart rate always came with the same SpO₂, without +/// one exception: HR 48 → 88, 57 → 94, 67–92 → 98, 105 → 96, never 99. That is a fixed function of +/// heart rate, not a second sensor, and the night "dips" were just a low sleeping heart rate. A spot +/// SpO₂ on the same ring is a real measurement (red LED, values up to 99), and the ring logs it in the +/// same combined record with heart rate and respiration both 0. +/// +/// **Decided from the data, not from the family.** A capability set is a per-family constant and says +/// nothing about one ring; another ring in the family may measure hourly SpO₂ for real, and calling +/// that "estimated" would be a false statement about the user's hardware. So the verdict is earned per +/// ring, from what it actually logged. +/// +/// ## What is stored, and why the rule has this shape +/// +/// `YCBTHealthRecords.combinedVitals` writes a combined record's SpO₂ (byte 9) and respiratory rate +/// (byte 10) as two history rows at the record's own timestamp. It does **not** store the record's +/// heart rate (byte 6) — HR history comes from `05 15`, at that log's own timestamps. Hence: +/// +/// - **Hourly vs spot** is decided by the respiratory row: an SpO₂ history row with a respiratory +/// history row at the *identical* timestamp came from a combined record that carried more than SpO₂. +/// A spot record logs respiration 0, which the decoder never stores, so a spot reading can never +/// qualify — not by threshold, by construction. +/// - **"A function of heart rate"** is tested by pairing each hourly row with the nearest HR history +/// row within `pairWindow`, and asking whether any heart rate ever came with two different SpO₂ +/// values. A real oximeter sampled twice at the same heart rate disagrees with itself sooner or later; +/// this ring never did across 304 records, so the test allows no exceptions. +/// +/// Nothing is labelled until the evidence is there: an unearned label on a health screen is a claim +/// about the hardware. Stored rows are never changed — this only decides how they are shown/exported. +enum EstimatedSpO2 { + /// How far an HR history row may sit from an hourly SpO₂ row and still be the same measurement. + /// The combined records land ~35 s after the hour (the end of the ring's ~35 s HR run), and the ring + /// runs one measurement at a time, so a neighbour inside two minutes is that measurement. + static let pairWindow: TimeInterval = 2 * 60 + + /// A day of hourly records — fewer, and a constant-looking stretch is a coincidence. + static let minPairs = 24 + + /// Pairs whose heart rate had already been seen. Only these *test* the mapping; a heart rate seen + /// once is trivially consistent with itself. + static let minRepeats = 12 + + /// How much history the verdict is drawn from: wider than a chart's 24 h so one quiet day doesn't + /// decide it, and bounded so a previously paired ring ages out of it. + static let lookback: TimeInterval = 7 * 86_400 + + /// The Vitals card's footnote, shared with the detail disclaimer so the two never drift. + static let note = "Hourly readings are estimated from heart rate — this ring's hourly SpO₂ isn't a " + + "separate measurement. Spot readings you take are measured." + + /// The detail chart's disclaimer. + static let disclaimer = note + " On this ring the same heart rate always comes with the same hourly " + + "SpO₂, so a night-time dip here follows a low sleeping heart rate rather than measuring oxygen." + + /// One row reduced to what the rule reads. + struct Sample: Equatable { + let timestamp: Date + let value: Double + } + + /// One hourly SpO₂ row and the heart rate it was paired with, as whole numbers. + struct Pair: Equatable { + let heartRate: Int + let spo2: Int + } + + // MARK: - Pure rule + + /// The SpO₂ rows that came from an hourly combined record: those with a respiratory-rate row at + /// the identical timestamp. Spot records (respiration 0) are excluded by construction. + static func hourly(_ spo2: [Sample], respiratoryTimestamps: Set) -> [Sample] { + spo2.filter { respiratoryTimestamps.contains($0.timestamp) } + } + + /// Each hourly row paired with the nearest heart-rate sample within `pairWindow`. Rows with no + /// heart rate close enough are left out. + static func pairs(hourly: [Sample], heartRate: [Sample]) -> [Pair] { + guard !hourly.isEmpty, !heartRate.isEmpty else { return [] } + let hr = heartRate.sorted { $0.timestamp < $1.timestamp } + return hourly.compactMap { s in + // First HR at or after the SpO₂ row; its predecessor is the only other candidate. + var lo = 0, hi = hr.count + while lo < hi { + let mid = (lo + hi) / 2 + if hr[mid].timestamp < s.timestamp { lo = mid + 1 } else { hi = mid } + } + let candidates = [lo - 1, lo].filter { hr.indices.contains($0) }.map { hr[$0] } + guard let nearest = candidates.min(by: { + abs($0.timestamp.timeIntervalSince(s.timestamp)) < abs($1.timestamp.timeIntervalSince(s.timestamp)) + }), abs(nearest.timestamp.timeIntervalSince(s.timestamp)) <= pairWindow else { return nil } + return Pair(heartRate: Int(nearest.value), spo2: Int(s.value)) + } + } + + /// Whether `pairs` show SpO₂ as a fixed function of heart rate: enough pairs, enough repeated + /// heart rates to actually test the mapping, more than one SpO₂ value (a constant reading is a + /// function of anything and says nothing about where it came from), and no heart rate ever paired + /// with two different SpO₂ values. + static func derivesFromHeartRate(_ pairs: [Pair]) -> Bool { + guard pairs.count >= minPairs else { return false } + let byHeartRate = Dictionary(grouping: pairs, by: \.heartRate).mapValues { Set($0.map(\.spo2)) } + guard pairs.count - byHeartRate.count >= minRepeats else { return false } + guard Set(pairs.map(\.spo2)).count >= 2 else { return false } + return byHeartRate.values.allSatisfy { $0.count == 1 } + } + + /// The timestamps of the `spo2` rows to label as estimated: the hourly ones, and only when the + /// hourly rows in `evidence` (with `evidenceHeartRate`) show the ring deriving them from heart rate. + static func estimated(spo2: [Sample], respiratoryTimestamps: Set, + evidence: [Sample], evidenceRespiratoryTimestamps: Set, + evidenceHeartRate: [Sample]) -> Set { + let evidencePairs = pairs(hourly: hourly(evidence, respiratoryTimestamps: evidenceRespiratoryTimestamps), + heartRate: evidenceHeartRate) + guard derivesFromHeartRate(evidencePairs) else { return [] } + return Set(hourly(spo2, respiratoryTimestamps: respiratoryTimestamps).map(\.timestamp)) + } + + // MARK: - Over SwiftData + + /// `estimated` over the store: the SpO₂ history rows in `from...to` this ring computed from heart + /// rate, judged on the last `lookback` of its history. Reads only — nothing stored is changed. + /// + /// Cheap on rings that never log respiration (every non-YCBT family): the respiratory probe comes + /// back empty and the heart-rate history is never fetched. + @MainActor + static func estimatedTimestamps(from: Date, to: Date, now: Date = Date(), context: ModelContext) -> Set { + let spo2 = historySamples(.spo2, from: from, to: to, context: context) + guard !spo2.isEmpty else { return [] } + let since = now.addingTimeInterval(-lookback) + let respiratory = Set(historySamples(.respiratoryRate, from: min(from, since), to: max(to, now), + context: context).map(\.timestamp)) + guard !respiratory.isEmpty else { return [] } + let evidence = historySamples(.spo2, from: since, to: now, context: context) + let heartRate = historySamples(.heartRate, from: since.addingTimeInterval(-pairWindow), + to: now.addingTimeInterval(pairWindow), context: context) + return estimated(spo2: spo2, respiratoryTimestamps: respiratory, + evidence: evidence, evidenceRespiratoryTimestamps: respiratory, + evidenceHeartRate: heartRate) + } + + /// The ring's own log only: a live or spot row is the app's reading, never a combined record. + @MainActor + private static func historySamples(_ kind: MeasurementKind, from: Date, to: Date, + context: ModelContext) -> [Sample] { + let raw = kind.rawValue + let history = MeasurementSource.history.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.kindRaw == raw && $0.sourceRaw == history && $0.timestamp >= from && $0.timestamp <= to }, + sortBy: [SortDescriptor(\.timestamp)] + ) + return ((try? context.fetch(descriptor)) ?? []).map { Sample(timestamp: $0.timestamp, value: $0.value) } + } +} diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 06c1d978..aca64358 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -120,6 +120,29 @@ enum MetricsService { return displaySamples(raw, range: range, scope: scope) } + /// An SpO₂ window plus whether it holds hourly readings the ring computed from heart rate rather + /// than measured (`EstimatedSpO2`). Carried with the series so no surface can show those rows + /// unlabelled by re-deriving the samples on its own. + struct SpO2Series { + let samples: [MetricSample] + let isEstimated: Bool + } + + /// `metricRange(.spo2, …)` with the estimated-hourly verdict attached. The verdict reads the raw + /// rows, before display bucketing averages their timestamps away. + static func spo2Series(range: MetricRange, context: ModelContext, scope: MetricScope = .vitals) -> SpO2Series { + let raw = rangeSamples(kind: .spo2, range: range, context: context) + let estimated = estimatedSpO2Timestamps(in: raw, context: context) + return SpO2Series(samples: displaySamples(raw, range: range, scope: scope), + isEstimated: raw.contains { estimated.contains($0.timestamp) }) + } + + /// The estimated-hourly SpO₂ timestamps across the span of `samples` (empty for an empty window). + private static func estimatedSpO2Timestamps(in samples: [MetricSample], context: ModelContext) -> Set { + guard let first = samples.first?.timestamp, let last = samples.last?.timestamp else { return [] } + return EstimatedSpO2.estimatedTimestamps(from: min(first, last), to: max(first, last), context: context) + } + /// The display transform applied to already-fetched, windowed samples: bucket-average per the /// user's graph-resolution preference (`.full`/targetBuckets 0 is identity). This is the SINGLE /// source of truth for the vitals sparkline/chart shape, so any caller that runs it over the same diff --git a/PulseLoop/ViewModels/TodayStore.swift b/PulseLoop/ViewModels/TodayStore.swift index 97ba4189..ed154a1c 100644 --- a/PulseLoop/ViewModels/TodayStore.swift +++ b/PulseLoop/ViewModels/TodayStore.swift @@ -98,7 +98,8 @@ final class TodayStore { MetricsService.metricRange(metric: metric, range: .twentyFourHours, context: modelContext, scope: .today) } let hr = series(.heartRate) - let spo2 = series(.spo2) + let spo2Series = MetricsService.spo2Series(range: .twentyFourHours, context: modelContext, scope: .today) + let spo2 = spo2Series.samples let hrv = series(.hrv) let stress = series(.stress) let fatigue = series(.fatigue) @@ -118,6 +119,7 @@ final class TodayStore { hr: hr, spo2: spo2, hrv: hrv, stress: stress, fatigue: fatigue, temperature: temperature, systolic: systolic, diastolic: diastolic, glucose: glucose, + spo2Estimated: spo2Series.isEstimated, summary: summary, range: .twentyFourHours, units: profile?.units ?? .metric ) var result: [MetricKind: VitalCardViewModel] = [:] diff --git a/PulseLoop/ViewModels/VitalsCardFactory.swift b/PulseLoop/ViewModels/VitalsCardFactory.swift index c84d6bb1..2d9ec784 100644 --- a/PulseLoop/ViewModels/VitalsCardFactory.swift +++ b/PulseLoop/ViewModels/VitalsCardFactory.swift @@ -16,6 +16,9 @@ enum VitalsCardFactory { var systolic: [MetricSample] = [] var diastolic: [MetricSample] = [] var glucose: [MetricSample] = [] + /// The SpO₂ series holds hourly readings this ring computed from heart rate (`EstimatedSpO2`). + /// Spot readings never set it. + var spo2Estimated: Bool = false var summary: TodaySummary? var range: MetricRange = .twentyFourHours var now: Date = Date() @@ -86,7 +89,7 @@ enum VitalsCardFactory { referenceBands: [ReferenceBand(lower: 95, upper: 100, colorToken: .cyan)], dashedRules: [92], trend: TrendSummary.compute(samples: chart, metric: .spo2, unitLabel: "%"), - sourceQuality: quality, isEstimated: false, confidenceLabel: nil, + sourceQuality: quality, isEstimated: inputs.spo2Estimated, confidenceLabel: nil, lastUpdatedText: lastUpdated(samples.last?.timestamp, now: inputs.now), isEmpty: samples.count < 2 ) diff --git a/PulseLoop/ViewModels/VitalsStore.swift b/PulseLoop/ViewModels/VitalsStore.swift index 314852dd..6f102319 100644 --- a/PulseLoop/ViewModels/VitalsStore.swift +++ b/PulseLoop/ViewModels/VitalsStore.swift @@ -12,6 +12,8 @@ final class VitalsStore { private(set) var summary: TodaySummary private(set) var hrSamples: [MetricSample] private(set) var spo2Samples: [MetricSample] + /// The 24h SpO₂ series holds hourly readings computed from heart rate (`EstimatedSpO2`). + private(set) var spo2IsEstimated = false private(set) var stressSamples: [MetricSample] private(set) var hrvSamples: [MetricSample] private(set) var tempSamples: [MetricSample] @@ -45,7 +47,9 @@ final class VitalsStore { self.profile = profile self.summary = MetricsService.buildTodaySummary(context: modelContext) self.hrSamples = MetricsService.metricRange(metric: .heartRate, range: .twentyFourHours, context: modelContext) - self.spo2Samples = MetricsService.metricRange(metric: .spo2, range: .twentyFourHours, context: modelContext) + let spo2 = MetricsService.spo2Series(range: .twentyFourHours, context: modelContext) + self.spo2Samples = spo2.samples + self.spo2IsEstimated = spo2.isEstimated self.stressSamples = MetricsService.metricRange(metric: .stress, range: .twentyFourHours, context: modelContext) self.hrvSamples = MetricsService.metricRange(metric: .hrv, range: .twentyFourHours, context: modelContext) self.tempSamples = MetricsService.metricRange(metric: .temperature, range: .twentyFourHours, context: modelContext) @@ -87,7 +91,9 @@ final class VitalsStore { private func rebuild(signature sig: String) { summary = MetricsService.buildTodaySummary(context: modelContext) hrSamples = MetricsService.metricRange(metric: .heartRate, range: .twentyFourHours, context: modelContext) - spo2Samples = MetricsService.metricRange(metric: .spo2, range: .twentyFourHours, context: modelContext) + let spo2 = MetricsService.spo2Series(range: .twentyFourHours, context: modelContext) + spo2Samples = spo2.samples + spo2IsEstimated = spo2.isEstimated stressSamples = MetricsService.metricRange(metric: .stress, range: .twentyFourHours, context: modelContext) hrvSamples = MetricsService.metricRange(metric: .hrv, range: .twentyFourHours, context: modelContext) tempSamples = MetricsService.metricRange(metric: .temperature, range: .twentyFourHours, context: modelContext) @@ -109,6 +115,7 @@ final class VitalsStore { hr: hrSamples, spo2: spo2Samples, hrv: hrvSamples, stress: stressSamples, fatigue: fatigueSamples, temperature: tempSamples, systolic: systolicSamples, diastolic: diastolicSamples, glucose: bloodSugarSamples, + spo2Estimated: spo2IsEstimated, summary: summary, range: .twentyFourHours, units: profile?.units ?? .metric ) var result: [MetricKind: VitalCardViewModel] = [:] diff --git a/PulseLoop/Views/MetricDetailView.swift b/PulseLoop/Views/MetricDetailView.swift index 0928f37c..01341b8f 100644 --- a/PulseLoop/Views/MetricDetailView.swift +++ b/PulseLoop/Views/MetricDetailView.swift @@ -16,6 +16,8 @@ struct MetricDetailView: View { @State private var period: DetailPeriod = .today @State private var primary: [MetricSample] = [] @State private var secondary: [MetricSample] = [] // diastolic, for BP + /// The SpO₂ window holds hourly readings this ring computed from heart rate (`EstimatedSpO2`). + @State private var spo2Estimated = false /// Observed so the detail chart/tiles re-fetch when a background sync lands while this is open. @State private var dataChange = PulseDataChange.shared @@ -258,6 +260,11 @@ struct MetricDetailView: View { if metric == .bloodPressure { primary = MetricsService.metricRange(metric: .bloodPressureSystolic, range: period.range, context: modelContext) secondary = MetricsService.metricRange(metric: .bloodPressureDiastolic, range: period.range, context: modelContext) + } else if metric == .spo2 { + let series = MetricsService.spo2Series(range: period.range, context: modelContext) + primary = series.samples + secondary = [] + spo2Estimated = series.isEstimated } else { primary = MetricsService.metricRange(metric: metric.metricKey, range: period.range, context: modelContext) secondary = [] @@ -315,7 +322,7 @@ struct MetricDetailView: View { } private var isEstimatedMetric: Bool { - metric == .glucose + metric == .glucose || (metric == .spo2 && spo2Estimated) } private var explainerText: String { @@ -353,6 +360,8 @@ struct MetricDetailView: View { case .glucose: return "Estimated wellness metric — not for dosing or diabetes decisions. No smart ring or watch is " + "FDA-authorized to measure or estimate glucose on its own." + case .spo2: + return EstimatedSpO2.disclaimer case .bloodPressure: return "Ring blood pressure is an estimate. Calibrate against a validated cuff in Settings → Calibration, " + "and talk to a clinician about persistent high or low readings." diff --git a/PulseLoop/Views/TodayTiles.swift b/PulseLoop/Views/TodayTiles.swift index e6686886..4cef5335 100644 --- a/PulseLoop/Views/TodayTiles.swift +++ b/PulseLoop/Views/TodayTiles.swift @@ -298,6 +298,12 @@ struct TodayChartTile: View { .lineLimit(1) } } + // Hourly SpO₂ computed from heart rate is labelled on every surface it reaches. + if model.metric == .spo2 && model.isEstimated { + Text("Estimated from heart rate") + .font(PulseFont.caption2).foregroundStyle(PulseColors.warning) + .lineLimit(1).truncationMode(.tail) + } Spacer(minLength: 0) if model.isEmpty { Text(model.statusText) diff --git a/PulseLoop/Views/VitalsCards.swift b/PulseLoop/Views/VitalsCards.swift index 2d9059f7..3028f422 100644 --- a/PulseLoop/Views/VitalsCards.swift +++ b/PulseLoop/Views/VitalsCards.swift @@ -38,6 +38,14 @@ struct VitalChartCard: View { ).color } ) + // The ESTIMATED chip says *that*; this says what, and that a spot reading is still a + // measurement (`EstimatedSpO2`). + if model.metric == .spo2 && model.isEstimated { + Text(EstimatedSpO2.note) + .font(PulseFont.caption.weight(.regular)) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + } } } } diff --git a/PulseLoopTests/EstimatedSpO2Tests.swift b/PulseLoopTests/EstimatedSpO2Tests.swift new file mode 100644 index 00000000..3920b797 --- /dev/null +++ b/PulseLoopTests/EstimatedSpO2Tests.swift @@ -0,0 +1,194 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Android issue #103: hourly SpO₂ that a YCBT ring computes from heart rate is labelled as estimated. +/// Ports Android's `EstimatedSpo2Test` (11 cases) plus one store-backed case. +final class EstimatedSpO2Tests: XCTestCase { + private typealias Sample = EstimatedSpO2.Sample + + private let hour: TimeInterval = 3600 + private let start = Date(timeIntervalSince1970: 1_757_000_000) + + /// The reporter's mapping, read off 304 combined (`05 09`) records: HR 48 → 88, 57 → 94, 67–92 → 98, 105 → 96. + private func ringSpO2(_ hr: Int) -> Int { + switch hr { + case ...48: return 88 + case ...57: return 94 + case ...92: return 98 + default: return 96 + } + } + + private let heartRates = [48, 57, 67, 75, 82, 92, 105, 70] + + /// `hours` hourly combined records, each logged ~34 s past the hour (the end of the ring's HR run). + private func hourlyTimes(_ hours: Int) -> [Date] { + (0.. + let hr: [Sample] + } + + private func ring(hours: Int, spo2For: ((Int, Int) -> Int)? = nil) -> Ring { + let times = hourlyTimes(hours) + let hrs = times.indices.map { heartRates[$0 % heartRates.count] } + let spo2For = spo2For ?? { _, hr in self.ringSpO2(hr) } + return Ring( + spo2: times.enumerated().map { i, t in Sample(timestamp: t, value: Double(spo2For(i, hrs[i]))) }, + respiratory: Set(times), + // `05 15` logs the same hourly HR run; a few seconds' skew proves pairing is by proximity, + // not by identical timestamps. + hr: times.enumerated().map { i, t in Sample(timestamp: t.addingTimeInterval(-5), value: Double(hrs[i])) } + ) + } + + private func estimated(_ r: Ring, spo2: [Sample]? = nil, hr: [Sample]? = nil) -> Set { + let spo2 = spo2 ?? r.spo2 + return EstimatedSpO2.estimated(spo2: spo2, respiratoryTimestamps: r.respiratory, + evidence: spo2, evidenceRespiratoryTimestamps: r.respiratory, + evidenceHeartRate: hr ?? r.hr) + } + + func testHourlySpO2ThatIsAFixedFunctionOfHeartRateIsLabelledEstimated() { + let r = ring(hours: 48) + XCTAssertEqual(estimated(r), Set(r.spo2.map(\.timestamp))) + } + + func testSpotReadingIsNeverLabelledEvenBesideAnHourlyHRRow() { + let r = ring(hours: 48) + // Spot SpO₂ logged in the combined record with HR and respiration 0: an SpO₂ row and no respiratory row. + // 90 s after an hourly HR row — inside the pairing window — so the respiratory sibling, not HR + // proximity, is what decides hourly vs spot. + let spot = Sample(timestamp: start.addingTimeInterval(10 * hour + 124), value: 99) + let labelled = estimated(r, spo2: r.spo2 + [spot]) + XCTAssertFalse(labelled.contains(spot.timestamp)) + XCTAssertEqual(labelled.count, r.spo2.count) + } + + func testRealOximeterDisagreeingWithItselfAtOneHeartRateLabelsNothing() { + let r = ring(hours: 48) { i, _ in [97, 98, 99, 96, 98][i % 5] } + XCTAssertTrue(estimated(r).isEmpty) + } + + func testSingleExceptionToTheMappingWithholdsTheLabel() { + let r = ring(hours: 48) { i, hr in i == 40 ? self.ringSpO2(hr) - 1 : self.ringSpO2(hr) } + XCTAssertTrue(estimated(r).isEmpty) + } + + func testTooLittleHistoryEarnsNoVerdict() { + let r = ring(hours: EstimatedSpO2.minPairs - 1) + XCTAssertTrue(estimated(r).isEmpty) + } + + func testConstantReadingSaysNothingAboutWhereItCameFrom() { + let r = ring(hours: 48) { _, _ in 98 } + XCTAssertTrue(estimated(r).isEmpty) + } + + func testHeartRatesThatNeverRepeatCannotTestTheMapping() { + let times = hourlyTimes(30) + let hrs = times.indices.map { 45 + $0 * 2 } // 30 distinct heart rates + let spo2 = times.enumerated().map { i, t in Sample(timestamp: t, value: Double(ringSpO2(hrs[i]))) } + let hr = times.enumerated().map { i, t in Sample(timestamp: t, value: Double(hrs[i])) } + let respiratory = Set(times) + + XCTAssertFalse(EstimatedSpO2.derivesFromHeartRate(EstimatedSpO2.pairs(hourly: spo2, heartRate: hr))) + XCTAssertTrue(EstimatedSpO2.estimated(spo2: spo2, respiratoryTimestamps: respiratory, + evidence: spo2, evidenceRespiratoryTimestamps: respiratory, + evidenceHeartRate: hr).isEmpty) + } + + func testWithoutHeartRateHistoryNothingIsLabelled() { + let r = ring(hours: 48) + XCTAssertTrue(estimated(r, hr: []).isEmpty) + } + + func testPairingTakesTheNearestHeartRateAndIgnoresOneOutsideTheWindow() { + let at = start.addingTimeInterval(34) + let hourly = [Sample(timestamp: at, value: 94)] + let hr = [ + Sample(timestamp: at.addingTimeInterval(-90), value: 70), + Sample(timestamp: at.addingTimeInterval(20), value: 57), + Sample(timestamp: at.addingTimeInterval(EstimatedSpO2.pairWindow + 60), value: 48), + ] + XCTAssertEqual(EstimatedSpO2.pairs(hourly: hourly, heartRate: hr), [EstimatedSpO2.Pair(heartRate: 57, spo2: 94)]) + let justOutside = [Sample(timestamp: at.addingTimeInterval(EstimatedSpO2.pairWindow + 0.001), value: 57)] + XCTAssertTrue(EstimatedSpO2.pairs(hourly: hourly, heartRate: justOutside).isEmpty) + } + + func testOnlyRowsWithARespiratorySiblingAtTheIdenticalTimestampAreHourly() { + let a = Sample(timestamp: start, value: 98) + let b = Sample(timestamp: start.addingTimeInterval(hour), value: 99) + let respiratory: Set = [start, start.addingTimeInterval(hour + 0.001)] + XCTAssertEqual(EstimatedSpO2.hourly([a, b], respiratoryTimestamps: respiratory), [a]) + } + + func testLabelsRowsOutsideTheEvidenceWindowOnceTheRingIsJudged() { + let r = ring(hours: 48) + // A month-view chart: an older hourly row the 7-day evidence doesn't include still gets the + // label, because the verdict is about the ring — and a spot row beside it still doesn't. + let oldHourly = Sample(timestamp: start.addingTimeInterval(-10 * 86_400), value: 94) + let oldSpot = Sample(timestamp: oldHourly.timestamp.addingTimeInterval(600), value: 99) + let labelled = EstimatedSpO2.estimated( + spo2: [oldHourly, oldSpot], respiratoryTimestamps: [oldHourly.timestamp], + evidence: r.spo2, evidenceRespiratoryTimestamps: r.respiratory, evidenceHeartRate: r.hr + ) + XCTAssertEqual(labelled, [oldHourly.timestamp]) + } + + // MARK: - Over SwiftData + + /// The store glue reads only the ring's own history rows: a derived ring's hourly rows are labelled, + /// a live spot reading beside them is not, and the 24h series reports itself as estimated. + @MainActor + func testStoreBackedVerdictLabelsHistoryHourlyRowsOnly() throws { + let context = try TestSupport.makeContext() + let now = Date() + let hours = 30 + var hourlyTimes: [Date] = [] + for i in 0.. Date: Mon, 9 Nov 2026 19:13:03 -0500 Subject: [PATCH 05/20] Hide estimated SpO2 by default and keep estimates out of Apple Health --- PulseLoop/Health/HealthKitTypeMappings.swift | 24 ++++ PulseLoop/Health/HealthSyncService.swift | 18 ++- PulseLoop/Services/PulseServices.swift | 43 +++++-- PulseLoop/Settings/MetricPrefsStore.swift | 5 + PulseLoop/ViewModels/TodayStore.swift | 2 +- PulseLoop/ViewModels/VitalsStore.swift | 2 +- .../Settings/AppleHealthSettingsView.swift | 26 ++++- .../Views/Settings/VitalsSettingsView.swift | 22 ++++ PulseLoop/Wearables/WearableCoordinator.swift | 12 ++ PulseLoopTests/EstimatedSpO2Tests.swift | 105 ++++++++++++++++-- .../HealthExportFilteringTests.swift | 55 +++++++++ docs/hardware/colmi.md | 6 + docs/hardware/r10m.md | 2 +- docs/hardware/tk5.md | 2 +- 14 files changed, 300 insertions(+), 24 deletions(-) create mode 100644 PulseLoopTests/HealthExportFilteringTests.swift diff --git a/PulseLoop/Health/HealthKitTypeMappings.swift b/PulseLoop/Health/HealthKitTypeMappings.swift index d3899468..4a716c6d 100644 --- a/PulseLoop/Health/HealthKitTypeMappings.swift +++ b/PulseLoop/Health/HealthKitTypeMappings.swift @@ -85,6 +85,30 @@ enum HealthKitTypeMappings { (60...250).contains(systolic) && (30...160).contains(diastolic) && systolic > diastolic } + // MARK: - Export filtering + + /// Rings whose respiratory rate is a function of heart rate (about 0.2 × HR), not a measurement: + /// the YCBT families fill the combined record's respiration byte that way. Exporting it would put a + /// heart-rate restatement into Health as breathing data, so it is never written for them. + static func derivesRespiratoryRate(_ deviceType: RingDeviceType?) -> Bool { + deviceType?.speaksYCBT == true + } + + /// Whether one stored vital belongs in Apple Health: plausible after conversion, not an hourly SpO₂ + /// the ring computed from heart rate (`estimatedSpO2`, from `EstimatedSpO2`), and not a respiratory + /// rate from a ring that derives it. Estimates stay in PulseLoop — labelled — and never leave it. + static func shouldExport(kind: MeasurementKind, value: Double, timestamp: Date, + estimatedSpO2: Set = [], respiratoryRateDerived: Bool = false) -> Bool { + guard let mapping = quantityMapping(for: kind) else { return false } + let converted = mapping.convert(value) + guard converted.isFinite, mapping.isPlausible(converted) else { return false } + switch kind { + case .spo2: return !estimatedSpO2.contains(timestamp) + case .respiratoryRate: return !respiratoryRateDerived + default: return true + } + } + // MARK: - Workouts /// Maps a PulseLoop activity type onto the closest `HKWorkoutActivityType`. diff --git a/PulseLoop/Health/HealthSyncService.swift b/PulseLoop/Health/HealthSyncService.swift index ea72b3de..7ad15318 100644 --- a/PulseLoop/Health/HealthSyncService.swift +++ b/PulseLoop/Health/HealthSyncService.swift @@ -216,10 +216,26 @@ final class HealthSyncService { let rows = (try? context.fetch(descriptor)) ?? [] guard !rows.isEmpty else { return } + // Estimates never leave PulseLoop: hourly SpO₂ the ring computed from heart rate, and the + // respiratory rate a YCBT ring derives from it. Skipped rows still advance the watermark — they + // are a verdict, not a failure, so there is nothing to retry. + var estimatedSpO2: Set = [] + if kind == .spo2, let first = rows.map(\.timestamp).min(), let last = rows.map(\.timestamp).max() { + estimatedSpO2 = EstimatedSpO2.estimatedTimestamps(from: first, to: last, now: now, context: context) + } + let respiratoryRateDerived = kind == .respiratoryRate + && HealthKitTypeMappings.derivesRespiratoryRate(DeviceRepository.current(context: context)?.deviceType) + // Chunk of 1,000 per save; advance the watermark after each successful chunk so an interrupted // backfill resumes and re-runs stay harmless (upsert). for chunk in rows.chunked(into: 1000) { - let samples = chunk.compactMap { vitalSample(row: $0, mapping: mapping, kindRaw: raw, now: now, device: device) } + let samples = chunk + .filter { + HealthKitTypeMappings.shouldExport(kind: kind, value: $0.value, timestamp: $0.timestamp, + estimatedSpO2: estimatedSpO2, + respiratoryRateDerived: respiratoryRateDerived) + } + .compactMap { vitalSample(row: $0, mapping: mapping, kindRaw: raw, now: now, device: device) } if !samples.isEmpty { try await save(samples) counts.vitals += samples.count diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index aca64358..b87b3db8 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -22,7 +22,8 @@ enum MetricsService { // per-kind to match Vitals when a database mixes real + mock across different metrics.) This is // one fetch per kind — same fetch count as the previous inline query. let hrSamples = rangeSamples(kind: .heartRate, range: .twentyFourHours, context: context) - let spo2Samples = rangeSamples(kind: .spo2, range: .twentyFourHours, context: context) + // SpO₂ goes through the same estimated-hourly filter `metricRange` applies (`EstimatedSpO2`). + let spo2Samples = spo2Window(range: .twentyFourHours, context: context).samples // Display copies go through the same `displaySamples` transform Vitals applies (resolution // downsampling). Byte-identical to `metricRange`'s output, so the sparkline shape matches too. let hrSamplesDisplay = displaySamples(hrSamples, range: .twentyFourHours, scope: scope) @@ -33,7 +34,7 @@ enum MetricsService { // Flatten the latest live readings to value snapshots immediately so nothing downstream // (or the cached TodaySummary) holds a live SwiftData object. let latestHR = LatestReading(MetricsRepository.latestMeasurement(kind: .heartRate, context: context)) - let latestSpO2 = LatestReading(MetricsRepository.latestMeasurement(kind: .spo2, context: context)) + let latestSpO2 = latestShownSpO2(context: context) let calibration = calibrationState(device: device, activityRows: activityRows, isDemo: isDemo, context: context) let hrFreshness = freshness(lastUpdatedAt: latestHR?.timestamp, isDemo: isDemo) let spo2Freshness = freshness(lastUpdatedAt: latestSpO2?.timestamp, isDemo: isDemo) @@ -97,7 +98,7 @@ enum MetricsService { case .heartRate: raw = rangeSamples(kind: .heartRate, range: range, context: context) case .spo2: - raw = rangeSamples(kind: .spo2, range: range, context: context) + return spo2Series(range: range, context: context, scope: scope).samples case .stress: raw = rangeSamples(kind: .stress, range: range, context: context) case .hrv: @@ -125,16 +126,44 @@ enum MetricsService { /// unlabelled by re-deriving the samples on its own. struct SpO2Series { let samples: [MetricSample] + /// True only when estimated rows are *shown* — with "Hide estimated SpO₂" on they are filtered + /// out instead, so nothing on screen needs the label. let isEstimated: Bool } - /// `metricRange(.spo2, …)` with the estimated-hourly verdict attached. The verdict reads the raw - /// rows, before display bucketing averages their timestamps away. + /// `metricRange(.spo2, …)` with the estimated-hourly verdict applied: hidden when the user hides + /// estimates (the default), otherwise flagged. The verdict reads the raw rows, before display + /// bucketing averages their timestamps away. static func spo2Series(range: MetricRange, context: ModelContext, scope: MetricScope = .vitals) -> SpO2Series { + let window = spo2Window(range: range, context: context) + return SpO2Series(samples: displaySamples(window.samples, range: range, scope: scope), + isEstimated: window.isEstimated) + } + + /// The raw (un-bucketed) SpO₂ rows of a window after the hide-estimated preference. + private static func spo2Window(range: MetricRange, context: ModelContext) -> (samples: [MetricSample], isEstimated: Bool) { let raw = rangeSamples(kind: .spo2, range: range, context: context) let estimated = estimatedSpO2Timestamps(in: raw, context: context) - return SpO2Series(samples: displaySamples(raw, range: range, scope: scope), - isEstimated: raw.contains { estimated.contains($0.timestamp) }) + guard !estimated.isEmpty else { return (raw, false) } + if MetricPrefsStore.shared.settings.hideEstimatedSpO2 { + return (raw.filter { !estimated.contains($0.timestamp) }, false) + } + return (raw, raw.contains { estimated.contains($0.timestamp) }) + } + + /// The newest SpO₂ reading to present as "latest". With estimates hidden, an hourly row the ring + /// computed from heart rate never surfaces here either — the newest *measured* reading does, from + /// the last 500 rows (weeks of hourly history), or none. + static func latestShownSpO2(context: ModelContext) -> LatestReading? { + let latest = MetricsRepository.latestMeasurement(kind: .spo2, context: context) + guard MetricPrefsStore.shared.settings.hideEstimatedSpO2, let at = latest?.timestamp, + EstimatedSpO2.estimatedTimestamps(from: at, to: at, context: context).contains(at) + else { return LatestReading(latest) } + let recent = MetricsRepository.measurements(kind: .spo2, start: .distantPast, end: .distantFuture, + limit: 500, context: context) // newest first + guard let newest = recent.first?.timestamp, let oldest = recent.last?.timestamp else { return nil } + let estimated = EstimatedSpO2.estimatedTimestamps(from: oldest, to: newest, context: context) + return LatestReading(recent.first { !estimated.contains($0.timestamp) }) } /// The estimated-hourly SpO₂ timestamps across the span of `samples` (empty for an empty window). diff --git a/PulseLoop/Settings/MetricPrefsStore.swift b/PulseLoop/Settings/MetricPrefsStore.swift index 8bf3ffec..b8adb2ef 100644 --- a/PulseLoop/Settings/MetricPrefsStore.swift +++ b/PulseLoop/Settings/MetricPrefsStore.swift @@ -77,6 +77,10 @@ struct MetricPrefs: Codable, Equatable { /// default position, so a newly supported metric appears without any migration. var vitalsOrder: [String] = [] var todayOrder: [String] = [] + /// Hide hourly SpO₂ a ring computes from heart rate (`EstimatedSpO2`) so charts, cards and the + /// latest value show only real spot readings. Applies to every page — it is about what the ring + /// measured, not about one screen's layout. On by default; stored data is never touched. + var hideEstimatedSpO2: Bool = true static let `default` = MetricPrefs() @@ -92,6 +96,7 @@ struct MetricPrefs: Codable, Equatable { todayResolution = try c.decodeIfPresent(GraphResolution.self, forKey: .todayResolution) ?? d.todayResolution vitalsOrder = try c.decodeIfPresent([String].self, forKey: .vitalsOrder) ?? d.vitalsOrder todayOrder = try c.decodeIfPresent([String].self, forKey: .todayOrder) ?? d.todayOrder + hideEstimatedSpO2 = try c.decodeIfPresent(Bool.self, forKey: .hideEstimatedSpO2) ?? d.hideEstimatedSpO2 } } diff --git a/PulseLoop/ViewModels/TodayStore.swift b/PulseLoop/ViewModels/TodayStore.swift index ed154a1c..3ae04a5c 100644 --- a/PulseLoop/ViewModels/TodayStore.swift +++ b/PulseLoop/ViewModels/TodayStore.swift @@ -164,7 +164,7 @@ final class TodayStore { // signature and the next refresh rebuilds — otherwise a hide/show or chart-detail change // wouldn't take effect on the tab until an unrelated sync bumped the signature. let p = MetricPrefsStore.shared.settings - let prefSig = "\(p.todayHiddenMetrics.sorted().joined(separator: ","))/\(p.todayResolution.rawValue)" + let prefSig = "\(p.todayHiddenMetrics.sorted().joined(separator: ","))/\(p.todayResolution.rawValue)/\(p.hideEstimatedSpO2)" // The daily goal targets feed the rings tile (via `goalsSummary`) but aren't otherwise // observed, so a goal edit must change the signature or the rings keep filling against the diff --git a/PulseLoop/ViewModels/VitalsStore.swift b/PulseLoop/ViewModels/VitalsStore.swift index 6f102319..a9b06f35 100644 --- a/PulseLoop/ViewModels/VitalsStore.swift +++ b/PulseLoop/ViewModels/VitalsStore.swift @@ -153,7 +153,7 @@ final class VitalsStore { // signature and the next refresh rebuilds — otherwise it wouldn't take effect on the tab until // an unrelated sync bumped the signature. let p = MetricPrefsStore.shared.settings - let prefSig = "\(p.hiddenMetrics.sorted().joined(separator: ","))/\(p.resolution.rawValue)" + let prefSig = "\(p.hiddenMetrics.sorted().joined(separator: ","))/\(p.resolution.rawValue)/\(p.hideEstimatedSpO2)" return [ latest(.heartRate), latest(.spo2), latest(.stress), latest(.hrv), latest(.temperature), diff --git a/PulseLoop/Views/Settings/AppleHealthSettingsView.swift b/PulseLoop/Views/Settings/AppleHealthSettingsView.swift index 7d1ae217..b24e578d 100644 --- a/PulseLoop/Views/Settings/AppleHealthSettingsView.swift +++ b/PulseLoop/Views/Settings/AppleHealthSettingsView.swift @@ -104,14 +104,29 @@ struct AppleHealthSettingsView: View { SettingsGroup( header: "Data types", footer: "Stress and fatigue have no Apple Health equivalent — Health has no type for a device " - + "wellness score — so they can't be synced. Rows appear only for metrics your ring can produce." + + "wellness score — so they can't be synced. Rows appear only for metrics your ring can produce. " + + "Hourly blood oxygen a ring estimates from heart rate is never exported; spot readings are." ) { FormToggleRow(title: "Heart rate", isOn: prefBinding(\.syncHeartRate)) FormToggleRow(title: "Blood oxygen", isOn: prefBinding(\.syncSpO2)) FormToggleRow(title: "Heart rate variability", isOn: prefBinding(\.syncHRV)) FormToggleRow(title: "Temperature", isOn: prefBinding(\.syncTemperature)) if shows(.respiratoryRate) { - FormToggleRow(title: "Respiratory rate", isOn: prefBinding(\.syncRespiratoryRate)) + if respiratoryRateDerived { + // The toggle can't do anything for this ring, so it reads as off and says why. + VStack(alignment: .leading, spacing: 2) { + FormToggleRow(title: "Respiratory rate", isOn: .constant(false)) + .disabled(true) + Text("Derived from heart rate by this ring — not exported.") + .font(.caption) + .foregroundStyle(PulseColors.textMuted) + .padding(.horizontal, 16) + .padding(.bottom, 10) + } + .opacity(0.6) + } else { + FormToggleRow(title: "Respiratory rate", isOn: prefBinding(\.syncRespiratoryRate)) + } } if shows(.vo2max) { FormToggleRow(title: "Cardio fitness (VO₂max)", isOn: prefBinding(\.syncVO2Max)) @@ -127,6 +142,13 @@ struct AppleHealthSettingsView: View { } } + /// YCBT rings compute respiratory rate from heart rate, so the export skips it for them. + private var respiratoryRateDerived: Bool { + HealthKitTypeMappings.derivesRespiratoryRate( + ble.activeDeviceType ?? DeviceRepository.current(context: modelContext)?.deviceType + ) + } + /// Whether to offer a toggle for a ring-dependent metric. /// /// Shown when the connected ring declares the capability **or** the store already holds a reading diff --git a/PulseLoop/Views/Settings/VitalsSettingsView.swift b/PulseLoop/Views/Settings/VitalsSettingsView.swift index 985da812..a2037bd7 100644 --- a/PulseLoop/Views/Settings/VitalsSettingsView.swift +++ b/PulseLoop/Views/Settings/VitalsSettingsView.swift @@ -27,6 +27,13 @@ struct MetricPrefsSettingsView: View { } } + /// Only YCBT rings log hourly SpO₂ in a record that can be told apart from a spot reading (see + /// `EstimatedSpO2`), so the toggle is offered where it can ever change something. + private var offersEstimatedSpO2Toggle: Bool { + MetricsService.supports(.spo2, context: modelContext) + && DeviceRepository.current(context: modelContext)?.deviceType.speaksYCBT == true + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 22) { @@ -36,6 +43,21 @@ struct MetricPrefsSettingsView: View { } } + if offersEstimatedSpO2Toggle { + SettingsGroup( + header: "Blood oxygen", + footer: "Some rings fill their hourly SpO₂ from heart rate instead of measuring it. When PulseLoop " + + "detects that, hiding those estimates shows only real spot readings on charts, cards and " + + "the latest value — on every page. Your stored data is unchanged, and estimates are never " + + "exported to Apple Health." + ) { + FormToggleRow(title: "Hide estimated SpO₂", isOn: Binding( + get: { store.settings.hideEstimatedSpO2 }, + set: { store.settings.hideEstimatedSpO2 = $0 } + )) + } + } + SettingsGroup( header: "Chart detail", footer: "When the ring measures often, charts can look busy. Smoother levels average nearby points into a cleaner line — " diff --git a/PulseLoop/Wearables/WearableCoordinator.swift b/PulseLoop/Wearables/WearableCoordinator.swift index 9617a548..b64641fa 100644 --- a/PulseLoop/Wearables/WearableCoordinator.swift +++ b/PulseLoop/Wearables/WearableCoordinator.swift @@ -48,6 +48,18 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { } } +extension RingDeviceType { + /// The families that speak the Yucheng YCBT protocol and share the whole `YCBT*` stack (driver, + /// encoder, decoder, history transfer). A protocol fact rather than a capability: what the ring's + /// settings commands accept, and how it fills its history records, follow from it. + var speaksYCBT: Bool { + switch self { + case .tk5, .colmiSmartHealth, .ycbt: return true + case .jring, .colmiR02, .luckRing, .rwfit, .crp: return false + } + } +} + /// The advertisement facts a coordinator needs to claim a discovered peripheral, wrapped so /// `matches` doesn't depend on CoreBluetooth's raw `[String: Any]` advertisement dictionary. struct AdvertisementInfo { diff --git a/PulseLoopTests/EstimatedSpO2Tests.swift b/PulseLoopTests/EstimatedSpO2Tests.swift index 3920b797..f01ef4b3 100644 --- a/PulseLoopTests/EstimatedSpO2Tests.swift +++ b/PulseLoopTests/EstimatedSpO2Tests.swift @@ -142,12 +142,10 @@ final class EstimatedSpO2Tests: XCTestCase { // MARK: - Over SwiftData - /// The store glue reads only the ring's own history rows: a derived ring's hourly rows are labelled, - /// a live spot reading beside them is not, and the 24h series reports itself as estimated. + /// A derived ring's last 30 hours in the store (hourly SpO₂ + respiration + HR history) plus one + /// live spot reading ten minutes ago. @MainActor - func testStoreBackedVerdictLabelsHistoryHourlyRowsOnly() throws { - let context = try TestSupport.makeContext() - let now = Date() + private func seedDerivedRing(_ context: ModelContext, now: Date) throws -> (hourly: [Date], spot: Date) { let hours = 30 var hourlyTimes: [Date] = [] for i in 0.. Void) rethrows { + let saved = MetricPrefsStore.shared.settings + defer { MetricPrefsStore.shared.settings = saved } + var prefs = saved + prefs.resolution = .full + prefs.todayResolution = .full + prefs.hideEstimatedSpO2 = hideEstimated + MetricPrefsStore.shared.settings = prefs + try body() + } + + /// The store glue reads only the ring's own history rows: a derived ring's hourly rows are labelled, + /// a live spot reading beside them is not. + @MainActor + func testStoreBackedVerdictLabelsHistoryHourlyRowsOnly() throws { + let context = try TestSupport.makeContext() + let now = Date() + let seeded = try seedDerivedRing(context, now: now) let labelled = EstimatedSpO2.estimatedTimestamps(from: now.addingTimeInterval(-86_400 * 2), to: now, now: now, context: context) - XCTAssertEqual(labelled, Set(hourlyTimes)) - XCTAssertFalse(labelled.contains(spot)) - XCTAssertTrue(MetricsService.spo2Series(range: .twentyFourHours, context: context).isEstimated) + XCTAssertEqual(labelled, Set(seeded.hourly)) + XCTAssertFalse(labelled.contains(seeded.spot)) } @MainActor @@ -174,7 +195,7 @@ final class EstimatedSpO2Tests: XCTestCase { let context = try TestSupport.makeContext() let now = Date() for i in 0..<30 { - let t = now.addingTimeInterval(-Double(30 - i) * hour) + let t = now.addingTimeInterval(-Double(30 - i) * hour + 34) let hr = heartRates[i % heartRates.count] insert(.spo2, Double(ringSpO2(hr)), t, .history, context) insert(.heartRate, Double(hr), t, .history, context) @@ -182,7 +203,71 @@ final class EstimatedSpO2Tests: XCTestCase { try context.save() XCTAssertTrue(EstimatedSpO2.estimatedTimestamps(from: now.addingTimeInterval(-86_400 * 2), to: now, now: now, context: context).isEmpty) - XCTAssertFalse(MetricsService.spo2Series(range: .twentyFourHours, context: context).isEstimated) + try withPrefs(hideEstimated: true) { + let series = MetricsService.spo2Series(range: .twentyFourHours, context: context) + XCTAssertFalse(series.isEstimated) + XCTAssertEqual(series.samples.count, 24, "nothing judged, so nothing hidden") + } + } + + // MARK: - Hide estimated SpO₂ (Android #134) + + func testHideEstimatedDefaultsOnAndSurvivesAnOlderSavedBlob() throws { + XCTAssertTrue(MetricPrefs.default.hideEstimatedSpO2) + let legacy = try JSONDecoder().decode(MetricPrefs.self, from: Data(#"{"hiddenMetrics":["hrv"]}"#.utf8)) + XCTAssertTrue(legacy.hideEstimatedSpO2, "a blob saved before the setting existed gets the default") + XCTAssertEqual(legacy.hiddenMetrics, ["hrv"]) + } + + /// Hidden (the default): only the real spot reading is charted, the series needs no label, and the + /// latest value is the spot reading even though an hourly estimate is newer than nothing else. + @MainActor + func testHidingEstimatesLeavesOnlySpotReadings() throws { + let context = try TestSupport.makeContext() + let seeded = try seedDerivedRing(context, now: Date()) + try withPrefs(hideEstimated: true) { + let series = MetricsService.spo2Series(range: .twentyFourHours, context: context) + XCTAssertFalse(series.isEstimated) + XCTAssertEqual(series.samples.map(\.timestamp), [seeded.spot]) + XCTAssertEqual(MetricsService.metricRange(metric: .spo2, range: .twentyFourHours, context: context) + .map(\.timestamp), [seeded.spot]) + let summary = MetricsService.buildTodaySummary(context: context) + XCTAssertEqual(summary.trends.spo2Samples24h.map(\.timestamp), [seeded.spot]) + } + } + + /// With the newest row an hourly estimate, "latest" falls back to the newest measured reading. + @MainActor + func testLatestSkipsANewerEstimateWhenHidden() throws { + let context = try TestSupport.makeContext() + let now = Date() + let seeded = try seedDerivedRing(context, now: now.addingTimeInterval(-1800)) + // One more hourly record, newer than the spot reading. + let newest = now.addingTimeInterval(-60) + insert(.spo2, 98, newest, .history, context) + insert(.respiratoryRate, 15, newest, .history, context) + insert(.heartRate, 75, newest.addingTimeInterval(-5), .history, context) + try context.save() + + try withPrefs(hideEstimated: true) { + XCTAssertEqual(MetricsService.latestShownSpO2(context: context)?.timestamp, seeded.spot) + } + try withPrefs(hideEstimated: false) { + XCTAssertEqual(MetricsService.latestShownSpO2(context: context)?.timestamp, newest) + } + } + + /// Shown: every row is charted and the series carries the estimated flag for the chip/caption. + @MainActor + func testShowingEstimatesKeepsThemLabelled() throws { + let context = try TestSupport.makeContext() + let seeded = try seedDerivedRing(context, now: Date()) + try withPrefs(hideEstimated: false) { + let series = MetricsService.spo2Series(range: .twentyFourHours, context: context) + XCTAssertTrue(series.isEstimated) + let inWindow = seeded.hourly.filter { $0 >= Date().addingTimeInterval(-86_400) } + XCTAssertEqual(series.samples.count, inWindow.count + 1) + } } @MainActor diff --git a/PulseLoopTests/HealthExportFilteringTests.swift b/PulseLoopTests/HealthExportFilteringTests.swift new file mode 100644 index 00000000..426d3f93 --- /dev/null +++ b/PulseLoopTests/HealthExportFilteringTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import PulseLoop + +/// Android issue #103 / PR #134: estimated hourly SpO₂ and heart-rate-derived respiratory rate never +/// reach Apple Health. Ports Android's `VitalsExporterFilteringTest`. +final class HealthExportFilteringTests: XCTestCase { + private let spot = Date(timeIntervalSince1970: 1_729_000_000) + private let hourly = Date(timeIntervalSince1970: 1_729_003_600) + + func testGenuineSpotSpO2IsExported() { + XCTAssertTrue(HealthKitTypeMappings.shouldExport(kind: .spo2, value: 98, timestamp: spot, + estimatedSpO2: [hourly, hourly.addingTimeInterval(3600)])) + } + + func testHourlySpO2EstimatedFromHeartRateIsExcluded() { + XCTAssertFalse(HealthKitTypeMappings.shouldExport(kind: .spo2, value: 98, timestamp: hourly, + estimatedSpO2: [hourly, hourly.addingTimeInterval(3600)])) + } + + func testImplausibleSpO2IsExcludedEvenIfNotEstimated() { + XCTAssertFalse(HealthKitTypeMappings.shouldExport(kind: .spo2, value: 105, timestamp: spot)) + XCTAssertFalse(HealthKitTypeMappings.shouldExport(kind: .spo2, value: 0, timestamp: spot)) + } + + func testRespiratoryRateFromAYCBTRingIsExcluded() { + // YCBT rings derive respiratory rate as ~0.2 × HR without a real sensor. + XCTAssertFalse(HealthKitTypeMappings.shouldExport(kind: .respiratoryRate, value: 16, timestamp: spot, + respiratoryRateDerived: true)) + } + + func testRespiratoryRateFromARingWithoutThatDerivationIsExported() { + XCTAssertTrue(HealthKitTypeMappings.shouldExport(kind: .respiratoryRate, value: 16, timestamp: spot, + respiratoryRateDerived: false)) + } + + func testImplausibleRespiratoryRateIsExcludedOnAnyRing() { + XCTAssertFalse(HealthKitTypeMappings.shouldExport(kind: .respiratoryRate, value: 0, timestamp: spot)) + XCTAssertFalse(HealthKitTypeMappings.shouldExport(kind: .respiratoryRate, value: 100, timestamp: spot)) + } + + /// Every YCBT family derives it; no other family does, and an unknown device is not assumed to. + func testOnlyYCBTFamiliesDeriveRespiratoryRate() { + for type in RingDeviceType.allCases { + XCTAssertEqual(HealthKitTypeMappings.derivesRespiratoryRate(type), + [.tk5, .colmiSmartHealth, .ycbt].contains(type), "\(type)") + } + XCTAssertFalse(HealthKitTypeMappings.derivesRespiratoryRate(nil)) + } + + /// The estimate set only ever gates SpO₂; another kind at the same instant is unaffected. + func testEstimatedSetDoesNotGateOtherKinds() { + XCTAssertTrue(HealthKitTypeMappings.shouldExport(kind: .heartRate, value: 70, timestamp: hourly, + estimatedSpO2: [hourly], respiratoryRateDerived: true)) + } +} diff --git a/docs/hardware/colmi.md b/docs/hardware/colmi.md index 82258fe6..205c151b 100644 --- a/docs/hardware/colmi.md +++ b/docs/hardware/colmi.md @@ -276,6 +276,12 @@ least 24 pairs, at least 12 repeated heart rates, more than one SpO₂ value, an paired with two SpO₂ values. The SpO₂ card then carries an ESTIMATED chip and a note, the Today tile an amber "Estimated from heart rate" caption, and the detail chart a disclaimer. Stored data is unchanged. +**Hide estimated SpO₂** (Settings → Vitals or Today, on by default for YCBT rings) drops those hourly +estimates from every chart, card and "latest" value, leaving only real spot readings; turn it off to see +them, labelled. Either way they are **never exported to Apple Health**. Neither is **respiratory rate** +from any YCBT ring: the combined record's respiration byte is about 0.2 × heart rate, not a breathing +measurement, so its Apple Health toggle is greyed out with that explanation. + ### 🧪 Still unconfirmed **Sleep** is the one significant path no SmartHealth-Colmi has exercised yet. Everything else above is diff --git a/docs/hardware/r10m.md b/docs/hardware/r10m.md index 2a53f71d..db3a1996 100644 --- a/docs/hardware/r10m.md +++ b/docs/hardware/r10m.md @@ -124,7 +124,7 @@ pressure. They stay listed because "R10M" is a model, not a firmware, and the ga | Stress | 🔓 | ring-declared (`IS_HAS_PRESSURE`, byte 22 bit 6), from the body-data record (`05 33`) | | Fatigue | 🔓 | ring-declared on the **same bit as stress** — one record, one bit, two fields | | Blood sugar | 🔓 | ring-declared (`ISHASBLOODSUGAR`, byte 17 bit 3) | -| Respiratory rate | ✅ | combined record @10 | +| Respiratory rate | ✅ | combined record @10 — about 0.2 × heart rate, so it is shown in PulseLoop but never exported to Apple Health | | Battery level | ✅ | in-band: `02 00` reply payload[5], plus the unprompted `06 15` push | | Find device | 🔓 | ring-declared (`ISHASFINDDEVICE`, byte 6 bit 4) — not declared by FW 2.32 | | Measurement intervals | ✅ | the `01 xx {enable, interval}` monitors, **filtered to the sensors this ring declares**; floored at the firmware's 30-min minimum | diff --git a/docs/hardware/tk5.md b/docs/hardware/tk5.md index cc927a5f..369aef10 100644 --- a/docs/hardware/tk5.md +++ b/docs/hardware/tk5.md @@ -96,7 +96,7 @@ promise — the app claims it unconditionally, and the bitmap can only ever *add | Stress | 🔓 | — | ring-declared (`IS_HAS_PRESSURE`, byte 22 bit 6). **Ring-stored**, from the body-data record (`05 33`) — the SDK's `pressure` field | | Fatigue | 🔓 | — | ring-declared on the **same bit as stress**: no `ISHASFATIGUE` exists, but the vendor app gates the whole `05 33` query on `IS_HAS_PRESSURE`, and fatigue (the SDK's `body` field) is a field of that one record. No body data ⇒ neither score | | Blood sugar | 🔓 | **one real reading — the scale is inferred, not observed** | ring-declared (`ISHASBLOODSUGAR`, byte 17 bit 3). All record @17 and the comprehensive record (`05 2F`); tenths of mmol/L → mg/dL | -| Respiratory rate | 🧪 | — | All record @10 | +| Respiratory rate | 🧪 | — | All record @10 — about 0.2 × heart rate, so it is shown in PulseLoop but never exported to Apple Health | | VO₂max | 🧪 | raw byte taken unscaled | body-data record @16 | | Battery level | 🧪 | — | in-band: `02 00` reply payload[5], plus the unprompted `06 15` push | | Find device | 🧪 | **the command's three payload bytes** | `03 00 {01, 05, 02}` — the app's literal arguments; the SDK never names them | From db1f85a8fe4a0f2056fca558f1e932b24f55c358 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:16:08 -0500 Subject: [PATCH 06/20] Allow 30-120 min heart-rate and separate SpO2 intervals on YCBT rings --- PulseLoop/Models/PulseModels.swift | 7 +- PulseLoop/Persistence/DataArchive.swift | 4 + PulseLoop/RingProtocol/CRPSyncEngine.swift | 8 +- .../RingProtocol/YCBTSettingsEncoder.swift | 19 ++++- .../Settings/MeasurementSettingsView.swift | 81 +++++++++++++++++-- PulseLoop/Wearables/WearableCoordinator.swift | 18 +++++ PulseLoop/Wearables/WearableDriver.swift | 4 + PulseLoopTests/CRPSyncEngineTests.swift | 16 ++++ PulseLoopTests/MeasurementSettingsTests.swift | 41 ++++++++++ PulseLoopTests/YCBTEncoderTests.swift | 36 +++++++++ docs/hardware/colmi.md | 8 ++ docs/hardware/r10m.md | 2 +- docs/hardware/tk5.md | 2 +- 13 files changed, 229 insertions(+), 17 deletions(-) diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index f33b14fd..a4717148 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -534,10 +534,14 @@ final class UserGoal { @Model final class DeviceMeasurementConfig { @Attribute(.unique) var deviceId: UUID - /// All-day HR sampling interval, minutes. Colmi accepts 5…60 in 5-minute steps. + /// All-day HR sampling interval, minutes. Colmi accepts 5…60 in 5-minute steps; YCBT rings 30…120 + /// (`RingDeviceType.heartRateIntervalRange`). var hrIntervalMinutes: Int = 5 var hrEnabled: Bool = true var spo2Enabled: Bool = true + /// All-day SpO₂ interval, minutes; `0` = follow the heart-rate interval (what every row did before + /// this existed, so the additive default is lossless). + var spo2IntervalMinutes: Int = 0 var stressEnabled: Bool = true var hrvEnabled: Bool = true var temperatureEnabled: Bool = true @@ -553,6 +557,7 @@ final class DeviceMeasurementConfig { hrEnabled: hrEnabled, hrIntervalMinutes: hrIntervalMinutes, spo2Enabled: spo2Enabled, + spo2IntervalMinutes: spo2IntervalMinutes, stressEnabled: stressEnabled, hrvEnabled: hrvEnabled, temperatureEnabled: temperatureEnabled diff --git a/PulseLoop/Persistence/DataArchive.swift b/PulseLoop/Persistence/DataArchive.swift index 8dddac34..5b543012 100644 --- a/PulseLoop/Persistence/DataArchive.swift +++ b/PulseLoop/Persistence/DataArchive.swift @@ -509,6 +509,8 @@ nonisolated struct ArchiveDeviceMeasurementConfig: Codable, Sendable { var hrIntervalMinutes: Int var hrEnabled: Bool var spo2Enabled: Bool + /// Optional so archives written before the SpO₂ interval existed still decode (as "follow HR"). + var spo2IntervalMinutes: Int? var stressEnabled: Bool var hrvEnabled: Bool var temperatureEnabled: Bool @@ -519,6 +521,7 @@ nonisolated struct ArchiveDeviceMeasurementConfig: Codable, Sendable { hrIntervalMinutes = m.hrIntervalMinutes hrEnabled = m.hrEnabled spo2Enabled = m.spo2Enabled + spo2IntervalMinutes = m.spo2IntervalMinutes stressEnabled = m.stressEnabled hrvEnabled = m.hrvEnabled temperatureEnabled = m.temperatureEnabled @@ -530,6 +533,7 @@ nonisolated struct ArchiveDeviceMeasurementConfig: Codable, Sendable { m.hrIntervalMinutes = hrIntervalMinutes m.hrEnabled = hrEnabled m.spo2Enabled = spo2Enabled + m.spo2IntervalMinutes = spo2IntervalMinutes ?? 0 m.stressEnabled = stressEnabled m.hrvEnabled = hrvEnabled m.temperatureEnabled = temperatureEnabled diff --git a/PulseLoop/RingProtocol/CRPSyncEngine.swift b/PulseLoop/RingProtocol/CRPSyncEngine.swift index dca22c1d..59b65828 100644 --- a/PulseLoop/RingProtocol/CRPSyncEngine.swift +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -380,8 +380,8 @@ final class CRPSyncEngine: RingSyncEngine { } /// Send the all-day enable/disable command for every vital. The CRP protocol takes a single - /// interval byte per enable, and `MeasurementSettings` carries only `hrIntervalMinutes` (no - /// per-vital cadence), so the HR interval is shared across the board. Disabled vitals are + /// interval byte per enable, and `MeasurementSettings` carries a per-vital cadence only for blood + /// oxygen (Android #66) — HRV and stress still share the heart-rate interval. Disabled vitals are /// explicitly turned off so a reconnect can't leave a previously-enabled monitor running. private func applyTimingSettings(_ settings: MeasurementSettings) { if settings.hrEnabled { @@ -400,7 +400,9 @@ final class CRPSyncEngine: RingSyncEngine { send(CRPProtocol.disableTimingStress()) } if settings.spo2Enabled { - send(CRPProtocol.enableTimingSpO2(intervalMinutes: settings.hrIntervalMinutes)) + // 0 means "follow heart rate", which is what this leg did unconditionally before. + let interval = settings.spo2IntervalMinutes > 0 ? settings.spo2IntervalMinutes : settings.hrIntervalMinutes + send(CRPProtocol.enableTimingSpO2(intervalMinutes: interval)) } else { send(CRPProtocol.disableTimingSpO2()) } diff --git a/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift b/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift index 26ad88fb..d9182e52 100644 --- a/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift +++ b/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift @@ -22,6 +22,11 @@ struct YCBTSettingsEncoder { /// for rings: `if (isRing() && interval < 30) interval = 30`). The vendor default is 60. static let minimumIntervalMinutes = 30 static let defaultIntervalMinutes = 60 + /// Every monitor but heart rate stays at most hourly. 120 minutes is evidenced on the heart-rate key + /// alone (a ring ACKed `01 0c 01 78` and read at 10:00 and 12:00, Android #103); the other samplers + /// keep the 60-minute ceiling the settings screen always had, so their on-air bytes are unchanged + /// by a longer heart-rate interval. + static let monitorIntervalCeilingMinutes = 60 /// Clamp a user-chosen cadence into what the firmware will actually accept. PulseLoop's shared /// `MeasurementSettings` default is 5 minutes (a Colmi cadence), which this floors to 30 rather @@ -88,13 +93,21 @@ struct YCBTSettingsEncoder { /// the ring derives BP from the same PPG sweep as heart rate, so BP rides the HR toggle. /// `stressEnabled` has no YCBT monitor command — the ring stores stress in the body-data history /// record (`05 33`), it doesn't sample it on its own schedule. + /// + /// Heart rate carries its own interval (up to 120 min); the rest are capped at + /// `monitorIntervalCeilingMinutes`. Blood oxygen carries `spo2IntervalMinutes` where the user set one + /// (Android #66) — `0` follows the (capped) heart-rate interval, which is what it always did. func monitorCommands(_ settings: MeasurementSettings) -> [[UInt8]] { - let interval = Self.clampInterval(settings.hrIntervalMinutes) + let heartRateInterval = Self.clampInterval(settings.hrIntervalMinutes) + let interval = min(heartRateInterval, UInt8(Self.monitorIntervalCeilingMinutes)) + let spo2Interval = settings.spo2IntervalMinutes > 0 + ? min(Self.clampInterval(settings.spo2IntervalMinutes), UInt8(Self.monitorIntervalCeilingMinutes)) + : interval return [ - heartMonitor(enabled: settings.hrEnabled, intervalMinutes: interval), + heartMonitor(enabled: settings.hrEnabled, intervalMinutes: heartRateInterval), bloodPressureMonitor(enabled: settings.hrEnabled, intervalMinutes: interval), temperatureMonitor(enabled: settings.temperatureEnabled, intervalMinutes: interval), - bloodOxygenMonitor(enabled: settings.spo2Enabled, intervalMinutes: interval), + bloodOxygenMonitor(enabled: settings.spo2Enabled, intervalMinutes: spo2Interval), hrvMonitor(enabled: settings.hrvEnabled, intervalMinutes: interval), ] } diff --git a/PulseLoop/Views/Settings/MeasurementSettingsView.swift b/PulseLoop/Views/Settings/MeasurementSettingsView.swift index 39d27114..0a6b69d3 100644 --- a/PulseLoop/Views/Settings/MeasurementSettingsView.swift +++ b/PulseLoop/Views/Settings/MeasurementSettingsView.swift @@ -2,7 +2,8 @@ import SwiftUI import SwiftData /// Measurement Frequency detail screen. Lets the user tune how often the ring measures HR (a real -/// interval, 5–60 min) and which background vitals it records. Persisted per device and pushed to the +/// interval: 5–60 min, or 30–120 min on YCBT rings — `RingDeviceType.heartRateIntervalRange`), how often +/// it measures SpO₂ where the ring takes a separate interval, and which background vitals it records. Persisted per device and pushed to the /// ring on Save. Only shown for devices that declare `.measurementInterval` (Colmi, jring); a /// defensive empty-state covers the case of arriving here without that capability. /// @@ -10,7 +11,7 @@ import SwiftData /// combined-sensor packet and its background log, with no per-metric opcode to switch them off, so /// those toggles are inert there. On the TK5 the same is true of **stress alone**: the ring derives it /// from the HRV sweep and files it in the body-data record, and the YCBT setting catalog has no stress -/// monitor key to toggle (SpO₂/HRV/temperature each do have one). +/// monitor key to toggle (SpO₂/HRV/temperature each do have one) — so YCBT rings don't offer it. struct MeasurementSettingsView: View { @Environment(\.modelContext) private var modelContext @Environment(RingBLEClient.self) private var ble @@ -20,6 +21,8 @@ struct MeasurementSettingsView: View { @State private var hrEnabled = true @State private var hrIntervalMinutes = 5 @State private var spo2Enabled = true + /// `0` = follow the heart-rate interval. + @State private var spo2IntervalMinutes = 0 @State private var stressEnabled = true @State private var hrvEnabled = true @State private var temperatureEnabled = true @@ -32,6 +35,20 @@ struct MeasurementSettingsView: View { private var supportsInterval: Bool { capabilities.contains(.measurementInterval) } + private var deviceType: RingDeviceType? { + ble.activeDeviceType ?? DeviceRepository.current(context: modelContext)?.deviceType + } + + private var intervalRange: ClosedRange { deviceType?.heartRateIntervalRange ?? 5...60 } + + private var supportsSpO2Interval: Bool { deviceType?.supportsSeparateSpO2Interval == true } + + /// YCBT has no stress monitor command (stress is filed in the body-data record), so a toggle there + /// would be a switch the ring never sees. + private var offersStressToggle: Bool { + capabilities.contains(.stress) && deviceType?.speaksYCBT != true + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 22) { @@ -63,10 +80,15 @@ struct MeasurementSettingsView: View { SettingsGroup( header: "Other vitals", - footer: "These vitals are recorded in the background throughout the day. The ring doesn't expose a separate interval for them, so each is a simple on/off." + footer: supportsSpO2Interval + ? "These vitals are recorded in the background throughout the day. Blood oxygen can run on its own interval; the rest follow heart rate, at most hourly." + : "These vitals are recorded in the background throughout the day. The ring doesn't expose a separate interval for them, so each is a simple on/off." ) { - if capabilities.contains(.spo2) { FormToggleRow(title: "Blood oxygen (SpO₂)", isOn: $spo2Enabled) } - if capabilities.contains(.stress) { FormToggleRow(title: "Stress", isOn: $stressEnabled) } + if capabilities.contains(.spo2) { + FormToggleRow(title: "Blood oxygen (SpO₂)", isOn: $spo2Enabled) + if spo2Enabled && supportsSpO2Interval { spo2IntervalCard } + } + if offersStressToggle { FormToggleRow(title: "Stress", isOn: $stressEnabled) } if capabilities.contains(.hrv) { FormToggleRow(title: "HRV", isOn: $hrvEnabled) } if capabilities.contains(.temperature) { FormToggleRow(title: "Skin temperature", isOn: $temperatureEnabled) } } @@ -97,7 +119,7 @@ struct MeasurementSettingsView: View { get: { Double(hrIntervalMinutes) }, set: { hrIntervalMinutes = Int(($0 / 5).rounded()) * 5 } ), - in: 5...60, + in: Double(intervalRange.lowerBound)...Double(intervalRange.upperBound), step: 5 ) .tint(PulseColors.accent) @@ -108,6 +130,40 @@ struct MeasurementSettingsView: View { } } + /// Blood oxygen's own interval. The bottom stop of the track is "follow heart rate" rather than an + /// interval shorter than the ring honours; the top is 60 — no evidence past an hour on that key. + private var spo2IntervalCard: some View { + let followStop = intervalRange.lowerBound - 5 + return FormField { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Measure SpO₂").font(PulseFont.subheadline).foregroundStyle(PulseColors.textPrimary) + Spacer() + Text(spo2IntervalMinutes <= 0 ? "With heart rate" : "Every \(spo2IntervalMinutes) min") + .font(PulseFont.callout.weight(.semibold)).monospacedDigit() + .foregroundStyle(PulseColors.accent) + } + Slider( + value: Binding( + get: { Double(spo2IntervalMinutes <= 0 ? followStop : spo2IntervalMinutes) }, + set: { + let stepped = Int(($0 / 5).rounded()) * 5 + spo2IntervalMinutes = stepped < intervalRange.lowerBound ? 0 : min(60, stepped) + } + ), + in: Double(followStop)...60, + step: 5 + ) + .tint(PulseColors.accent) + Text(spo2IntervalMinutes <= 0 + ? "Follows the heart-rate interval, at most every 60 min. Each SpO₂ reading runs the red light, so a longer interval saves the most battery." + : "Each SpO₂ reading runs the red light, so a longer interval saves the most battery. Some rings keep SpO₂ on the heart-rate schedule regardless.") + .font(.caption).foregroundStyle(PulseColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + // MARK: - Load / save private func loadIfNeeded() { @@ -116,8 +172,10 @@ struct MeasurementSettingsView: View { guard let device = DeviceRepository.current(context: modelContext) else { return } let config = MeasurementConfigRepository.configOrDefault(deviceId: device.id, context: modelContext) hrEnabled = config.hrEnabled - hrIntervalMinutes = config.hrIntervalMinutes + hrIntervalMinutes = Self.clamped(config.hrIntervalMinutes, to: intervalRange) spo2Enabled = config.spo2Enabled + spo2IntervalMinutes = config.spo2IntervalMinutes <= 0 + ? 0 : Self.clamped(config.spo2IntervalMinutes, to: intervalRange.lowerBound...60) stressEnabled = config.stressEnabled hrvEnabled = config.hrvEnabled temperatureEnabled = config.temperatureEnabled @@ -130,8 +188,10 @@ struct MeasurementSettingsView: View { } let config = MeasurementConfigRepository.configOrDefault(deviceId: device.id, context: modelContext) config.hrEnabled = hrEnabled - config.hrIntervalMinutes = min(60, max(5, (hrIntervalMinutes / 5) * 5)) + config.hrIntervalMinutes = Self.clamped(hrIntervalMinutes, to: intervalRange) config.spo2Enabled = spo2Enabled + config.spo2IntervalMinutes = supportsSpO2Interval && spo2IntervalMinutes > 0 + ? Self.clamped(spo2IntervalMinutes, to: intervalRange.lowerBound...60) : 0 config.stressEnabled = stressEnabled config.hrvEnabled = hrvEnabled config.temperatureEnabled = temperatureEnabled @@ -139,4 +199,9 @@ struct MeasurementSettingsView: View { coordinator.applyMeasurementSettings() saveStatus = ble.state == .connected ? "Saved and sent to your ring." : "Saved — will apply on next sync." } + + /// Snap down to the 5-minute grid, then into `range`. + static func clamped(_ minutes: Int, to range: ClosedRange) -> Int { + min(range.upperBound, max(range.lowerBound, (minutes / 5) * 5)) + } } diff --git a/PulseLoop/Wearables/WearableCoordinator.swift b/PulseLoop/Wearables/WearableCoordinator.swift index b64641fa..f19e0280 100644 --- a/PulseLoop/Wearables/WearableCoordinator.swift +++ b/PulseLoop/Wearables/WearableCoordinator.swift @@ -58,6 +58,24 @@ extension RingDeviceType { case .jring, .colmiR02, .luckRing, .rwfit, .crp: return false } } + + /// The all-day heart-rate interval this family's ring accepts, in minutes (5-minute steps). + /// + /// YCBT rings floor at 30 (the firmware rejects faster, and SmartHealth clamps the same way) and + /// reach 120: past the vendor app's own 60-minute menu, but hardware-verified — a ring ACKed + /// `01 0c 01 78` and read at 10:00 and 12:00 (Android #103). Nothing longer until someone tries it. + /// Every other family keeps the 5…60 it always had. + var heartRateIntervalRange: ClosedRange { + speaksYCBT ? 30...120 : 5...60 + } + + /// Whether this family's all-day SpO₂ monitor takes an interval of its own rather than riding the + /// heart-rate one: YCBT's `01 26 {enable, interval}` and CRP's SpO₂ timing command each carry an + /// interval byte (Android #66). Note one YCBT ring was seen to ACK the byte and keep sampling SpO₂ + /// on the heart-rate cadence anyway; it is correct on the wire, and harmless where ignored. + var supportsSeparateSpO2Interval: Bool { + speaksYCBT || self == .crp + } } /// The advertisement facts a coordinator needs to claim a discovered peripheral, wrapped so diff --git a/PulseLoop/Wearables/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index 7a8344b1..f4ef115f 100644 --- a/PulseLoop/Wearables/WearableDriver.swift +++ b/PulseLoop/Wearables/WearableDriver.swift @@ -135,6 +135,10 @@ struct MeasurementSettings: Sendable, Equatable { /// All-day HR sampling interval in minutes (Colmi clamps to 5…60 in 5-min steps). var hrIntervalMinutes: Int var spo2Enabled: Bool + /// All-day SpO₂ interval in minutes; `0` follows `hrIntervalMinutes`, which is what every family + /// did before this existed. Only rings whose SpO₂ monitor takes its own interval byte act on it + /// (`RingDeviceType.supportsSeparateSpO2Interval`). + var spo2IntervalMinutes: Int = 0 var stressEnabled: Bool var hrvEnabled: Bool var temperatureEnabled: Bool diff --git a/PulseLoopTests/CRPSyncEngineTests.swift b/PulseLoopTests/CRPSyncEngineTests.swift index 2d034cda..abe55061 100644 --- a/PulseLoopTests/CRPSyncEngineTests.swift +++ b/PulseLoopTests/CRPSyncEngineTests.swift @@ -165,6 +165,22 @@ final class CRPSyncEngineTests: XCTestCase { XCTAssertEqual(w.payloadByte(index, 6), 0, "a disabled vital sends interval 0, not the default") } + /// The SpO₂ timing command carries blood oxygen's own interval when one is set (Android #66), and + /// the heart-rate interval when it is 0. + func testSpO2TimingCarriesItsOwnIntervalWhenSet() { + func spo2Interval(_ settings: MeasurementSettings) -> Int? { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.applyMeasurementSettings(settings) + return w.opcodes.firstIndex(of: [1, 8]).map { w.payloadByte($0, 6) } + } + var settings = MeasurementSettings.allOnDefault + settings.hrIntervalMinutes = 10 + XCTAssertEqual(spo2Interval(settings), 10) + settings.spo2IntervalMinutes = 30 + XCTAssertEqual(spo2Interval(settings), 30) + } + /// The history pull uses the group-2 opcodes. Temperature is cmd **22** (`b1/i0.b`) — cmd 48 is /// the vendor's `querySleepState`, which is why the ring never answered the old query. func testRunStartupQueriesHistoryOnGroupTwo() { diff --git a/PulseLoopTests/MeasurementSettingsTests.swift b/PulseLoopTests/MeasurementSettingsTests.swift index 1c03dde7..6e1b7595 100644 --- a/PulseLoopTests/MeasurementSettingsTests.swift +++ b/PulseLoopTests/MeasurementSettingsTests.swift @@ -41,6 +41,47 @@ final class MeasurementSettingsTests: XCTestCase { XCTAssertTrue(settings.hrvEnabled) } + /// The SpO₂ interval defaults to 0 ("follow heart rate") and rides the projection to the engine. + func testSpO2IntervalDefaultsToFollowHeartRateAndProjects() { + let config = DeviceMeasurementConfig(deviceId: UUID()) + XCTAssertEqual(config.spo2IntervalMinutes, 0) + XCTAssertEqual(MeasurementSettings.allOnDefault.spo2IntervalMinutes, 0) + config.spo2IntervalMinutes = 45 + XCTAssertEqual(config.asSettings.spo2IntervalMinutes, 45) + } + + /// YCBT rings take 30–120 min heart-rate intervals (Android #103); every other family keeps 5–60. + /// The separate SpO₂ interval is offered where the ring's SpO₂ command carries its own byte. + func testIntervalBoundsFollowTheRingProtocol() { + for type in RingDeviceType.allCases { + let ycbt = [.tk5, .colmiSmartHealth, .ycbt].contains(type) + XCTAssertEqual(type.speaksYCBT, ycbt, "\(type)") + XCTAssertEqual(type.heartRateIntervalRange, ycbt ? 30...120 : 5...60, "\(type)") + XCTAssertEqual(type.supportsSeparateSpO2Interval, ycbt || type == .crp, "\(type)") + } + } + + func testSettingsScreenClampSnapsToTheFiveMinuteGridInsideTheRange() { + XCTAssertEqual(MeasurementSettingsView.clamped(5, to: 30...120), 30) + XCTAssertEqual(MeasurementSettingsView.clamped(120, to: 30...120), 120) + XCTAssertEqual(MeasurementSettingsView.clamped(118, to: 30...120), 115) + XCTAssertEqual(MeasurementSettingsView.clamped(120, to: 5...60), 60) + } + + func testArchivedConfigRoundTripsTheSpO2IntervalAndToleratesItsAbsence() throws { + let config = DeviceMeasurementConfig(deviceId: UUID()) + config.spo2IntervalMinutes = 45 + let data = try JSONEncoder().encode(ArchiveDeviceMeasurementConfig(config)) + let decoded = try JSONDecoder().decode(ArchiveDeviceMeasurementConfig.self, from: data) + XCTAssertEqual(decoded.spo2IntervalMinutes, 45) + + var legacy = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + legacy.removeValue(forKey: "spo2IntervalMinutes") + let old = try JSONDecoder().decode(ArchiveDeviceMeasurementConfig.self, + from: JSONSerialization.data(withJSONObject: legacy)) + XCTAssertNil(old.spo2IntervalMinutes) + } + func testConfigRepositoryUpsertsOnce() throws { let context = try TestSupport.makeContext() let deviceId = UUID() diff --git a/PulseLoopTests/YCBTEncoderTests.swift b/PulseLoopTests/YCBTEncoderTests.swift index 8c5bc8a0..5630d3d5 100644 --- a/PulseLoopTests/YCBTEncoderTests.swift +++ b/PulseLoopTests/YCBTEncoderTests.swift @@ -73,6 +73,42 @@ final class YCBTEncoderTests: XCTestCase { ]) } + /// 120 minutes is evidenced on the heart-rate key only (Android #103: the ring ACKed `01 0c 01 78` + /// and read at 10:00 and 12:00). Every other sampler keeps the 60-minute ceiling, so its bytes are + /// what the settings screen's old 60-minute cap always produced. + func testHeartRateCarriesOneHundredTwentyMinutesWhileTheOtherMonitorsKeepTheHourlyCeiling() { + let settings = MeasurementSettings( + hrEnabled: true, hrIntervalMinutes: 120, + spo2Enabled: true, stressEnabled: true, hrvEnabled: true, temperatureEnabled: true + ) + XCTAssertEqual(encoder.monitorCommands(settings), [ + [0x01, 0x0c, 0x01, 0x78], + [0x01, 0x1c, 0x01, 60], + [0x01, 0x20, 0x01, 60], + [0x01, 0x26, 0x01, 60], + [0x01, 0x45, 0x01, 60, 0, 0, 0], + ]) + } + + /// Blood oxygen carries its own interval when one is set (Android #66), floored at the firmware's + /// 30 and capped at the hourly ceiling; 0 keeps following heart rate. + func testBloodOxygenCarriesItsOwnIntervalWhenSet() { + var settings = MeasurementSettings( + hrEnabled: true, hrIntervalMinutes: 30, + spo2Enabled: true, stressEnabled: true, hrvEnabled: true, temperatureEnabled: true + ) + settings.spo2IntervalMinutes = 45 + XCTAssertEqual(encoder.monitorCommands(settings)[3], [0x01, 0x26, 0x01, 45]) + XCTAssertEqual(encoder.monitorCommands(settings)[0], [0x01, 0x0c, 0x01, 30], "heart rate is unaffected") + + settings.spo2IntervalMinutes = 10 + XCTAssertEqual(encoder.monitorCommands(settings)[3], [0x01, 0x26, 0x01, 30]) + settings.spo2IntervalMinutes = 120 + XCTAssertEqual(encoder.monitorCommands(settings)[3], [0x01, 0x26, 0x01, 60]) + settings.spo2IntervalMinutes = 0 + XCTAssertEqual(encoder.monitorCommands(settings)[3], [0x01, 0x26, 0x01, 30]) + } + // MARK: User profile /// The old encoder replayed the capture's `01 03 aa 40 00 2b` blob — 170 cm / 64 kg / 43 y — for diff --git a/docs/hardware/colmi.md b/docs/hardware/colmi.md index 205c151b..042bb77e 100644 --- a/docs/hardware/colmi.md +++ b/docs/hardware/colmi.md @@ -261,6 +261,14 @@ SmartHealth itself never requests. The R99 never answers that query at all — n PulseLoop gives up after its 10-second timeout and doesn't ask again until the ring reconnects. Each connection costs one 10-second wait, not one per sync. +### Measurement intervals + +The SmartHealth-Colmi takes the same five `01 xx {enable, interval}` monitor writes as the TK5: heart rate +every **30–120 minutes** (120 is past SmartHealth's own 60-minute menu, but a ring ACKed `01 0c 01 78` and +logged at 10:00 and 12:00), every other monitor at most hourly. Blood oxygen can run on its **own** 30–60 +minute interval or follow heart rate — on at least one ring the interval byte is accepted and ignored, so +SpO₂ stays on the heart-rate cadence. There is no stress toggle: YCBT has no stress monitor command. + ### Hourly SpO₂ estimated from heart rate On at least one SmartHealth-Colmi the **hourly** SpO₂ in the combined record is not a measurement: across diff --git a/docs/hardware/r10m.md b/docs/hardware/r10m.md index db3a1996..0c4ab7e1 100644 --- a/docs/hardware/r10m.md +++ b/docs/hardware/r10m.md @@ -127,7 +127,7 @@ pressure. They stay listed because "R10M" is a model, not a firmware, and the ga | Respiratory rate | ✅ | combined record @10 — about 0.2 × heart rate, so it is shown in PulseLoop but never exported to Apple Health | | Battery level | ✅ | in-band: `02 00` reply payload[5], plus the unprompted `06 15` push | | Find device | 🔓 | ring-declared (`ISHASFINDDEVICE`, byte 6 bit 4) — not declared by FW 2.32 | -| Measurement intervals | ✅ | the `01 xx {enable, interval}` monitors, **filtered to the sensors this ring declares**; floored at the firmware's 30-min minimum | +| Measurement intervals | ✅ | the `01 xx {enable, interval}` monitors, **filtered to the sensors this ring declares**; floored at the firmware's 30-min minimum; heart rate runs 30–120 min (`01 0c 01 78` = 120 min, hardware-verified on a SmartHealth ring), every other monitor at most hourly. Blood oxygen can take its own 30–60 min interval (`01 26`) or follow heart rate; one ring ACKed the byte and kept SpO₂ on the heart-rate cadence anyway. No stress toggle — YCBT has no stress monitor command | | Periodic re-sync while connected | ✅ | every 30 min, plus a post-workout vitals pass | | Continuous background sync | ❌ | the ring is only read while connected | | FW update via app | ❌ | not implemented | diff --git a/docs/hardware/tk5.md b/docs/hardware/tk5.md index 369aef10..0da73a2d 100644 --- a/docs/hardware/tk5.md +++ b/docs/hardware/tk5.md @@ -100,7 +100,7 @@ promise — the app claims it unconditionally, and the bitmap can only ever *add | VO₂max | 🧪 | raw byte taken unscaled | body-data record @16 | | Battery level | 🧪 | — | in-band: `02 00` reply payload[5], plus the unprompted `06 15` push | | Find device | 🧪 | **the command's three payload bytes** | `03 00 {01, 05, 02}` — the app's literal arguments; the SDK never names them | -| Measurement intervals | 🧪 | — | five `01 xx {enable, interval}` monitors; floored at the firmware's 30-min minimum | +| Measurement intervals | 🧪 | — | five `01 xx {enable, interval}` monitors; floored at the firmware's 30-min minimum; heart rate runs 30–120 min (`01 0c 01 78` = 120 min, hardware-verified on a SmartHealth ring), every other monitor at most hourly. Blood oxygen can take its own 30–60 min interval (`01 26`) or follow heart rate; one ring ACKed the byte and kept SpO₂ on the heart-rate cadence anyway. No stress toggle — YCBT has no stress monitor command | | Periodic re-sync while connected | ✅ | — | every 30 min (SmartHealth's own cadence), plus a post-workout vitals pass | | Continuous background sync | ❌ | — | the ring is only read while connected | | FW update via app | ❌ | — | out of scope — would require the JieLi RCSP auth (see below) | From 5131754bda71d12ad4a2224ebc71f2cc6f2d6316 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:18:28 -0500 Subject: [PATCH 07/20] Grid the battery history chart and estimate remaining runtime from the current discharge --- PulseLoop/DesignSystem/VitalsCharts.swift | 7 + PulseLoop/Services/BatteryProjection.swift | 121 ++++++++++++++++++ .../Views/Settings/WearableSettingsView.swift | 47 +++++-- PulseLoopTests/BatteryProjectionTests.swift | 109 ++++++++++++++++ 4 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 PulseLoop/Services/BatteryProjection.swift create mode 100644 PulseLoopTests/BatteryProjectionTests.swift diff --git a/PulseLoop/DesignSystem/VitalsCharts.swift b/PulseLoop/DesignSystem/VitalsCharts.swift index fdb0805f..4fd99673 100644 --- a/PulseLoop/DesignSystem/VitalsCharts.swift +++ b/PulseLoop/DesignSystem/VitalsCharts.swift @@ -102,6 +102,8 @@ struct ZoneLineChart: View { /// Zone boundaries (y-values) where the line color may change. The line is split at each crossing /// so a piece never spans two zones. Empty = no splitting (whole-segment color). var thresholds: [Double] = [] + /// Faint vertical rules at these instants (e.g. day boundaries on a week of battery history). + var verticalGridlines: [Date] = [] /// Injected from the threshold engine: maps a value to the color its segment should take. let colorForValue: (Double) -> Color @@ -112,6 +114,11 @@ struct ZoneLineChart: View { var body: some View { Chart { + ForEach(verticalGridlines, id: \.self) { mark in + RuleMark(x: .value("Grid", mark)) + .lineStyle(StrokeStyle(lineWidth: 1)) + .foregroundStyle(PulseColors.borderSubtle) + } ForEach(referenceBands) { band in RectangleMark( yStart: .value("Band low", band.lower), diff --git a/PulseLoop/Services/BatteryProjection.swift b/PulseLoop/Services/BatteryProjection.swift new file mode 100644 index 00000000..13ddadf1 --- /dev/null +++ b/PulseLoop/Services/BatteryProjection.swift @@ -0,0 +1,121 @@ +import Foundation + +/// How long the ring has left, from its own battery history (Android issue #65). +/// +/// A least-squares fit — but over the **current discharge run only**, not the whole window. A 7-day +/// window almost always contains a charge, and a line through "40 % falling, then 100 %, then falling +/// again" has a slope that is an artefact of when the user plugged in: it can come out flat or positive, +/// and either way describes nothing. The run since the last charge is the only stretch whose slope is a +/// drain rate. +/// +/// Deliberately conservative about saying anything at all. A number on screen is read as a promise, and +/// battery life is something people plan a trip around, so the estimate is withheld unless the fit has +/// enough samples, enough elapsed time and enough of a fall to mean something. +enum BatteryProjection { + /// One battery reading. + struct Sample: Equatable { + let timestamp: Date + let percent: Double + } + + struct Estimate: Equatable { + /// How fast it is falling now (positive = draining). + let percentPerHour: Double + /// Time until empty at that rate, from the last reading. + let hoursRemaining: Double + + /// "2d 6h" / "18h" / "45m" — the shape a runtime is actually read in. + var label: String { + let totalMinutes = max(0, Int(hoursRemaining * 60)) + let days = totalMinutes / (24 * 60) + let hours = (totalMinutes % (24 * 60)) / 60 + let minutes = totalMinutes % 60 + if days > 0 { return "\(days)d \(hours)h" } + if hours > 0 { return "\(hours)h" } + return "\(minutes)m" + } + } + + /// A rise of this many points or more is a charge — the boundary of the current discharge run. Ring + /// battery reporting is coarse (whole percent; some firmware reports in 5 % steps), so a 1–2 point + /// wobble at a plateau is normal and must not split a run. + static let chargeRisePoints = 3.0 + + /// Below these the fit is describing noise rather than a trend. + static let minSamples = 4 + static let minSpanHours = 1.5 + static let minFallPoints = 2.0 + + /// Below this the slope is reporting granularity, not a discharge rate. The gates above are all about + /// the run's *shape* and none bounds how slow it may be: 5 %-step firmware read over a week clears + /// every one with 80 → 75 — 0.03 %/h, "about 104d left". 0.2 %/h is ~21 days from full, past anything + /// a ring claims, so a real slow drain still gets an answer. + static let minDrainPercentPerHour = 0.2 + + /// The samples since the ring was last charged, oldest first. The whole list when no charge is + /// visible — the common case for a 24 h window, and why 24 h usually has an estimate while 7 d may not. + static func currentDischargeRun(_ samples: [Sample]) -> [Sample] { + let ordered = samples.sorted { $0.timestamp < $1.timestamp } + guard ordered.count >= 2 else { return ordered } + var runStart = 0 + for i in 1..= chargeRisePoints { + runStart = i + } + return Array(ordered[runStart...]) + } + + /// The remaining runtime, or nil when the data can't support one — "not enough data yet" is honest, + /// and a wrong runtime is worse than none. + static func estimate(_ samples: [Sample]) -> Estimate? { + let run = currentDischargeRun(samples) + guard run.count >= minSamples, let first = run.first, let last = run.last else { return nil } + let spanHours = last.timestamp.timeIntervalSince(first.timestamp) / 3600 + guard spanHours >= minSpanHours, first.percent - last.percent >= minFallPoints else { return nil } + + // Least squares against hours since the run's start, so the slope is directly %/hour. + let xs = run.map { $0.timestamp.timeIntervalSince(first.timestamp) / 3600 } + let ys = run.map(\.percent) + let n = Double(run.count) + let meanX = xs.reduce(0, +) / n + let meanY = ys.reduce(0, +) / n + var sxy = 0.0, sxx = 0.0 + for (x, y) in zip(xs, ys) { + sxy += (x - meanX) * (y - meanY) + sxx += (x - meanX) * (x - meanX) + } + guard sxx > 0 else { return nil } + let slope = sxy / sxx + guard slope < 0 else { return nil } // flat or rising: no depletion to project + + let drain = -slope + guard drain >= minDrainPercentPerHour, last.percent > 0 else { return nil } + return Estimate(percentPerHour: drain, hoursRemaining: last.percent / drain) + } + + /// Gridline instants across `start...end` for the chart behind the line: local midnights when the + /// window spans more than two days, otherwise every `hourStep` hours on the hour (00/06/12/18). A grid + /// at arbitrary offsets answers neither "which day" nor "which hour". Every mark lies in `(start, end]`. + static func gridlines(start: Date, end: Date, calendar: Calendar = .current, hourStep: Int = 6) -> [Date] { + guard end > start else { return [] } + var marks: [Date] = [] + if end.timeIntervalSince(start) > 48 * 3600 { + var day = calendar.startOfDay(for: start) + while let next = calendar.date(byAdding: .day, value: 1, to: day), next <= end { + marks.append(next) + day = next + } + } else { + guard var mark = calendar.dateInterval(of: .hour, for: start)?.start else { return [] } + while calendar.component(.hour, from: mark) % hourStep != 0 || mark <= start { + guard let next = calendar.date(byAdding: .hour, value: 1, to: mark) else { return marks } + mark = next + } + while mark <= end { + marks.append(mark) + guard let next = calendar.date(byAdding: .hour, value: hourStep, to: mark) else { break } + mark = next + } + } + return marks + } +} diff --git a/PulseLoop/Views/Settings/WearableSettingsView.swift b/PulseLoop/Views/Settings/WearableSettingsView.swift index 7649b295..47156040 100644 --- a/PulseLoop/Views/Settings/WearableSettingsView.swift +++ b/PulseLoop/Views/Settings/WearableSettingsView.swift @@ -98,16 +98,21 @@ private struct BatteryHistorySection: View { .fixedSize() } FormField { - if samples.count >= 2 { - ZoneLineChart( - samples: samples, - metric: .heartRate, // axis styling only; battery uses its own domain + colors - yDomain: 0...100, - range: range, - showAxes: true, - height: 160, - colorForValue: color(for:) - ) + if samples.count >= 2, let first = samples.first, let last = samples.last { + VStack(alignment: .leading, spacing: 10) { + ZoneLineChart( + samples: samples, + metric: .heartRate, // axis styling only; battery uses its own domain + colors + yDomain: 0...100, + range: range, + showAxes: true, + height: 160, + // Day boundaries on 7d, six-hour marks on 24h (Android #65). + verticalGridlines: BatteryProjection.gridlines(start: first.timestamp, end: last.timestamp), + colorForValue: color(for:) + ) + runtimeEstimate + } } else { Text("Not enough data yet — battery history builds up as your ring reports its level.") .font(.system(size: 13)) @@ -122,6 +127,28 @@ private struct BatteryHistorySection: View { .onChange(of: dataChange.token) { _, _ in reload() } } + /// "About 1d 16h left", or an honest silence. Fitted over the current discharge run rather than the + /// window, because a week of history almost always contains a charge (`BatteryProjection`). Worded + /// as an estimate because it is one: a straight line through past drain, which knows nothing about a + /// day that measures more often than the last. + @ViewBuilder private var runtimeEstimate: some View { + if let estimate = BatteryProjection.estimate(samples.map { .init(timestamp: $0.timestamp, percent: $0.value) }) { + VStack(alignment: .leading, spacing: 2) { + Text("About \(estimate.label) left") + .font(PulseFont.subheadline.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + Text(String(format: "Estimated from the current discharge at %.1f %%/h.", estimate.percentPerHour)) + .font(.caption).foregroundStyle(PulseColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } else { + Text("Not enough of a trend to estimate runtime yet — it appears after a few hours of discharge.") + .font(.caption).foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + private func reload() { let end = Date() let start: Date diff --git a/PulseLoopTests/BatteryProjectionTests.swift b/PulseLoopTests/BatteryProjectionTests.swift new file mode 100644 index 00000000..6fd6f70f --- /dev/null +++ b/PulseLoopTests/BatteryProjectionTests.swift @@ -0,0 +1,109 @@ +import XCTest +@testable import PulseLoop + +/// Android issue #65: a runtime estimate from the ring's own battery history, and the chart grid behind +/// it. Ports Android's `BatteryProjectionTest`. +final class BatteryProjectionTests: XCTestCase { + private typealias Sample = BatteryProjection.Sample + + private let t0 = Date(timeIntervalSince1970: 1_725_000_000) + private func hours(_ h: Double) -> Date { t0.addingTimeInterval(h * 3600) } + + /// A steady drain: the slope is the drain rate and the projection is what's left over it. + func testSteadyDischargeProjectsTheTimeToEmpty() throws { + // 100 % falling 2 %/h for 10 hours → 80 % left, 40 h remaining. + let samples = (0...10).map { Sample(timestamp: hours(Double($0)), percent: 100 - 2 * Double($0)) } + let estimate = try XCTUnwrap(BatteryProjection.estimate(samples)) + XCTAssertEqual(estimate.percentPerHour, 2, accuracy: 0.001) + XCTAssertEqual(estimate.hoursRemaining, 40, accuracy: 0.01) + XCTAssertEqual(estimate.label, "1d 16h") + } + + /// The case that makes a whole-window fit useless: a week almost always contains a charge, and a + /// line through "falling, then 100 %, then falling" describes when the user plugged in. + func testChargeStartsANewRunAndOnlyTheRunAfterItIsFitted() throws { + let discharge = (0...10).map { Sample(timestamp: hours(Double($0)), percent: 60 - 2 * Double($0)) } + let recharged = (0...10).map { Sample(timestamp: hours(11 + Double($0)), percent: 100 - Double($0)) } + + let run = BatteryProjection.currentDischargeRun(discharge + recharged) + XCTAssertEqual(run.first?.percent, 100, "the run starts at the charge") + XCTAssertEqual(run.count, 11) + let estimate = try XCTUnwrap(BatteryProjection.estimate(discharge + recharged)) + XCTAssertEqual(estimate.percentPerHour, 1, accuracy: 0.001, "the post-charge rate, not the average of both") + } + + /// Coarse reporting wobbles by a point or two at a plateau; that is not a charge. + func testSmallWobbleDoesNotSplitTheDischargeRun() { + let samples = [ + Sample(timestamp: hours(0), percent: 80), + Sample(timestamp: hours(2), percent: 78), + Sample(timestamp: hours(4), percent: 79), // +1, noise + Sample(timestamp: hours(6), percent: 76), + Sample(timestamp: hours(8), percent: 74), + ] + XCTAssertEqual(BatteryProjection.currentDischargeRun(samples).count, 5) + XCTAssertNotNil(BatteryProjection.estimate(samples)) + } + + func testNoEstimateFromTooFewSamplesTooShortASpanOrAFlatLine() { + let tooFew = (0...2).map { Sample(timestamp: hours(Double($0)), percent: 90 - Double($0)) } + XCTAssertNil(BatteryProjection.estimate(tooFew)) + + let tooShort = (0...5).map { Sample(timestamp: hours(Double($0) * 0.1), percent: 90 - Double($0)) } + XCTAssertNil(BatteryProjection.estimate(tooShort), "half an hour of readings is not a trend") + + let flat = (0...10).map { Sample(timestamp: hours(Double($0)), percent: 90) } + XCTAssertNil(BatteryProjection.estimate(flat), "a flat line has nothing to project") + } + + /// The sample/span/fall gates are about the run's shape and none bounds how slow it may be. 5 %-step + /// firmware clears every one over a week and projects a runtime no ring has. + func testNoEstimateFromADrainTooSlowToBeOne() { + // 80 → 75 across a week: 4 samples, 168 h, a 5-point fall — and 0.03 %/h. + let coarse = [80.0, 79, 77, 75].enumerated().map { Sample(timestamp: hours(Double($0.offset) * 56), percent: $0.element) } + XCTAssertNil(BatteryProjection.estimate(coarse), "104 days of runtime is not an estimate") + } + + /// While charging there is no depletion to project, and guessing one would be a lie. + func testNoEstimateWhileTheBatteryIsRising() { + let charging = (0...10).map { Sample(timestamp: hours(Double($0) * 0.5), percent: 40 + 2 * Double($0)) } + XCTAssertNil(BatteryProjection.estimate(charging)) + } + + func testLabelShapes() { + XCTAssertEqual(BatteryProjection.Estimate(percentPerHour: 1, hoursRemaining: 54).label, "2d 6h") + XCTAssertEqual(BatteryProjection.Estimate(percentPerHour: 1, hoursRemaining: 18.5).label, "18h") + XCTAssertEqual(BatteryProjection.Estimate(percentPerHour: 1, hoursRemaining: 0.75).label, "45m") + } + + func testDayWindowGridsOnSixHourMarksAndWeekWindowOnMidnights() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "UTC")) + let dayStart = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 10, hour: 3, minute: 20))) + let dayEnd = dayStart.addingTimeInterval(24 * 3600) + + let hourly = BatteryProjection.gridlines(start: dayStart, end: dayEnd, calendar: calendar) + XCTAssertFalse(hourly.isEmpty) + for mark in hourly { + XCTAssertEqual(calendar.component(.minute, from: mark), 0, "on the hour") + XCTAssertEqual(calendar.component(.hour, from: mark) % 6, 0, "on a six-hour mark") + } + + let weekEnd = dayStart.addingTimeInterval(7 * 24 * 3600) + let daily = BatteryProjection.gridlines(start: dayStart, end: weekEnd, calendar: calendar) + XCTAssertEqual(daily.count, 7) + for mark in daily { + XCTAssertEqual(calendar.component(.hour, from: mark), 0, "local midnight") + XCTAssertEqual(calendar.component(.minute, from: mark), 0) + } + } + + /// Every mark must sit inside the plotted window, or it draws off the chart. + func testGridlinesStayInsideTheWindow() { + let end = t0.addingTimeInterval(24 * 3600) + for mark in BatteryProjection.gridlines(start: t0, end: end) { + XCTAssertTrue(mark > t0 && mark <= end) + } + XCTAssertEqual(BatteryProjection.gridlines(start: end, end: t0), []) + } +} From 0f7467ce1cfe527b4d032df238380ac728296c33 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:06:56 -0500 Subject: [PATCH 08/20] Count LuckRing sport records as the day's running total and repair days already summed as buckets --- PulseLoop/PulseLoopApp.swift | 4 + PulseLoop/RingProtocol/LuckRingDecoder.swift | 23 +++- .../RingProtocol/LuckRingHistorySync.swift | 4 +- PulseLoop/RingProtocol/LuckRingProtocol.swift | 4 +- PulseLoop/Services/ActivityMigrations.swift | 59 +++++++++ .../LuckRingActivityRepairTests.swift | 112 ++++++++++++++++++ PulseLoopTests/LuckRingDecoderTests.swift | 25 +++- docs/hardware/luckring.md | 4 +- 8 files changed, 221 insertions(+), 14 deletions(-) create mode 100644 PulseLoopTests/LuckRingActivityRepairTests.swift diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index 2c97457c..12858cf0 100644 --- a/PulseLoop/PulseLoopApp.swift +++ b/PulseLoop/PulseLoopApp.swift @@ -66,6 +66,10 @@ struct PulseLoopApp: App { // future-dated rows that would otherwise permanently become "today"). ActivityService.migrateGarbageActivityIfNeeded(context: container.mainContext) + // One-time repair of LuckRing days whose running-total sport records were summed as buckets. + // Waits for an identified ring, so it retries on later launches until one is paired. + ActivityService.repairLuckRingActivityIfNeeded(context: container.mainContext) + // One-time merge of sleep sessions split across midnight by the old start-of-day grouping. SleepService.migrateSplitSleepSessionsIfNeeded(context: container.mainContext) diff --git a/PulseLoop/RingProtocol/LuckRingDecoder.swift b/PulseLoop/RingProtocol/LuckRingDecoder.swift index 02b759c6..f678f83e 100644 --- a/PulseLoop/RingProtocol/LuckRingDecoder.swift +++ b/PulseLoop/RingProtocol/LuckRingDecoder.swift @@ -113,17 +113,28 @@ struct LuckRingDecoder { return [.firmware(version: version)] } - /// Sport records — 20 bytes each (`K6_Sport`): `[start u32][steps u32][distance u24(+pad)]` - /// `[calories u24(+pad)][duration u24(+pad)]`. Emitted as `.activityBucket` (per-interval, summed into - /// the day and upserted by timestamp) for both the live (4) and history (5) streams, so a re-sync is - /// idempotent. Calories are intentionally dropped (`.activityBucket` carries none). + /// Sport records — 20 bytes each (`K6_Sport`): `[time u32][steps u32][distance u24(+pad)]` + /// `[calories u24(+pad)][duration u24(+pad)]`, the same layout for the live (4) and history (5) + /// streams (the vendor parses both in `ProcessDATA_TYPE_SPORT`). + /// + /// **Each record is the day's running total, not an interval.** The ring pushes a fresh one every few + /// seconds while the wearer walks, stamped with the time of that update: 537, 549, 584 … 668 steps + /// across one afternoon. The vendor files each under its half-hour and *sets* the day's steps to it + /// (`DevSportDataManager.handleBlueDevSport`); it never adds them up. Decoded as `.activityBucket`s, + /// whose day total is the sum of distinct buckets, every push counted the whole day again (~9,000 + /// steps for a real 668, miles of distance for a walk round the house). So they are + /// `.activityUpdate`s: the day row keeps the largest, which is the latest. Days already inflated are + /// restored once by `ActivityService.repairLuckRingActivityIfNeeded`. + /// + /// Calories stay 0 (unverified ring field), which leaves the on-device estimate in charge as before. private func decodeSport(_ p: [UInt8], dataType: UInt8) -> [RingDecodedEvent] { let recs = records(p, stride: 20) guard !recs.isEmpty else { return [.commandAck(commandId: dataType)] } return recs.map { r in - .activityBucket(timestamp: ringDate(r), + .activityUpdate(timestamp: ringDate(r), steps: Int(LuckRingBytes.u32(r, 4)), - distanceMeters: Double(LuckRingBytes.u24(r, 8))) + distanceMeters: Double(LuckRingBytes.u24(r, 8)), + calories: 0) } } diff --git a/PulseLoop/RingProtocol/LuckRingHistorySync.swift b/PulseLoop/RingProtocol/LuckRingHistorySync.swift index 0ab5395b..93cccf49 100644 --- a/PulseLoop/RingProtocol/LuckRingHistorySync.swift +++ b/PulseLoop/RingProtocol/LuckRingHistorySync.swift @@ -6,8 +6,8 @@ import Foundation /// sequential pager: request a type, advance when its data frames stop arriving (a short settle window), /// or skip it if none arrive at all (a stall timeout — an unsupported type answers with nothing). /// -/// Replays are safe: persistence upserts history by `(kind, timestamp)`, activity by bucket timestamp, -/// and sleep by night, so re-requesting a type it already saw never double-counts. The destructive +/// Replays are safe: persistence upserts history by `(kind, timestamp)`, activity as a per-day max of the +/// ring's running total, and sleep by night, so re-requesting a type it already saw never double-counts. The destructive /// `cleanData` (207) opcode is deliberately never sent. @MainActor final class LuckRingHistorySync { diff --git a/PulseLoop/RingProtocol/LuckRingProtocol.swift b/PulseLoop/RingProtocol/LuckRingProtocol.swift index 323bb300..bcd7e55b 100644 --- a/PulseLoop/RingProtocol/LuckRingProtocol.swift +++ b/PulseLoop/RingProtocol/LuckRingProtocol.swift @@ -52,8 +52,8 @@ enum LuckRingCmdType: UInt8, Sendable { enum LuckRingDataType { static let devInfo: UInt8 = 2 // 6B → firmware "customer.hardware.code.picture.font" static let battery: UInt8 = 3 // [percent][charging] - static let realSport: UInt8 = 4 // live step buckets - static let historySport: UInt8 = 5 // stored step buckets + static let realSport: UInt8 = 4 // live day running total (steps/distance) + static let historySport: UInt8 = 5 // stored day running-total snapshots static let sleep: UInt8 = 6 // paged sleep timeline static let realHeart: UInt8 = 7 // live HR (envelope + 5B records) static let historyHeart: UInt8 = 8 // stored HR diff --git a/PulseLoop/Services/ActivityMigrations.swift b/PulseLoop/Services/ActivityMigrations.swift index e72496b5..57b8c9e8 100644 --- a/PulseLoop/Services/ActivityMigrations.swift +++ b/PulseLoop/Services/ActivityMigrations.swift @@ -34,4 +34,63 @@ extension ActivityService { try? context.save() UserDefaults.standard.set(true, forKey: key) } + + /// One-time repair of LuckRing/TK18 days inflated before its sport records were decoded as day + /// totals (`LuckRingDecoder.decodeSport`): each running-total push was stored as an activity bucket, + /// and a day's total is the *sum* of its buckets, so every push added the whole day again (668 real + /// steps stored as ~9,000). The live path's max ratchet can never bring such a day back down. + /// + /// Only marked done once a ring has been identified: with no paired ring the family is unknown, so + /// it waits and retries on a later launch rather than skipping a wearer who still has those days. + static func repairLuckRingActivityIfNeeded(context: ModelContext) { + let key = "luckRingActivityRepair.v1" + guard !UserDefaults.standard.bool(forKey: key) else { return } + guard repairLuckRingActivity(context: context) != nil else { return } + UserDefaults.standard.set(true, forKey: key) + } + + /// Collapse every stored day whose buckets carry the running-total signature to its last snapshot, + /// if the paired ring is a LuckRing. Returns the number of days fixed (0 for any other family), or + /// nil when no ring has been identified yet. The `Device` row outlives Forget with its family intact, + /// so a wearer who forgot the ring is still repaired. + @discardableResult + static func repairLuckRingActivity(context: ModelContext) -> Int? { + guard let device = DeviceRepository.current(context: context), !device.capabilitiesRaw.isEmpty else { return nil } + guard device.deviceType == .luckRing else { return 0 } + let buckets = (try? context.fetch(FetchDescriptor())) ?? [] + var repairedDays: [Date] = [] + for (day, dayBuckets) in Dictionary(grouping: buckets, by: \.date) { + guard let total = luckRingRunningTotal(dayBuckets.map { + (timestamp: $0.timestamp, steps: $0.steps, distanceMeters: $0.distanceMeters) + }) else { continue } + // They were never intervals, so they leave the Records list as well as the sum. + dayBuckets.forEach(context.delete) + if let row = MetricsRepository.activity(on: day, context: context) { + row.steps = total.steps + row.distanceMeters = total.distanceMeters + row.updatedAt = Date() + } + repairedDays.append(day) + } + try? context.save() + // Each repaired day's calorie estimate was built on the inflated steps and buckets. + for day in repairedDays { DailyCalorieEstimator.recompute(day: day, context: context) } + try? context.save() + return repairedDays.count + } + + /// The day's true total if `buckets` are LuckRing running-total snapshots — two or more, with steps + /// and distance never falling from one to the next — else nil (leave the day alone). Interval buckets + /// from a bucketed ring rise and fall, so they don't match. + static func luckRingRunningTotal( + _ buckets: [(timestamp: Date, steps: Int, distanceMeters: Double)] + ) -> (steps: Int, distanceMeters: Double)? { + guard buckets.count >= 2 else { return nil } + let ordered = buckets.sorted { $0.timestamp < $1.timestamp } + let cumulative = zip(ordered, ordered.dropFirst()).allSatisfy { a, b in + b.steps >= a.steps && b.distanceMeters >= a.distanceMeters + } + guard cumulative, let last = ordered.last else { return nil } + return (last.steps, last.distanceMeters) + } } diff --git a/PulseLoopTests/LuckRingActivityRepairTests.swift b/PulseLoopTests/LuckRingActivityRepairTests.swift new file mode 100644 index 00000000..8d1ac60d --- /dev/null +++ b/PulseLoopTests/LuckRingActivityRepairTests.swift @@ -0,0 +1,112 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Days a LuckRing inflated by storing its running-total sport records as activity buckets. +@MainActor +final class LuckRingActivityRepairTests: XCTestCase { + private let key = "luckRingActivityRepair.v1" + /// Two days back at midnight, so the bucket path's today-only max guard doesn't apply. + private let day = Calendar.current.date(byAdding: .day, value: -2, to: Calendar.current.startOfDay(for: Date()))! + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: key) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: key) + super.tearDown() + } + + private func snapshot(_ offset: TimeInterval, _ steps: Int, _ distance: Double) -> (timestamp: Date, steps: Int, distanceMeters: Double) { + (day.addingTimeInterval(offset), steps, distance) + } + + /// The reporter's afternoon: 537 → 668 steps across the pushes, summed to thousands. + private var afternoon: [(timestamp: Date, steps: Int, distanceMeters: Double)] { + [snapshot(60_609, 537, 380), snapshot(63_473, 549, 389), snapshot(66_668, 584, 414), + snapshot(66_780, 594, 421), snapshot(66_809, 640, 453), snapshot(67_619, 668, 473)] + } + + private func storeAsBuckets(_ snapshots: [(timestamp: Date, steps: Int, distanceMeters: Double)], context: ModelContext) { + for s in snapshots { + ActivityService.applyActivityBucket(date: s.timestamp, steps: s.steps, distanceMeters: s.distanceMeters, context: context) + } + } + + private func pairRing(_ type: RingDeviceType, context: ModelContext) { + context.insert(Device(deviceType: type, capabilities: [.steps, .heartRate])) + try? context.save() + } + + // MARK: Plan + + func testRunningTotalSnapshotsCollapseToTheLastOne() { + let total = ActivityService.luckRingRunningTotal(afternoon.shuffled()) + XCTAssertEqual(total?.steps, 668) + XCTAssertEqual(total?.distanceMeters, 473) + } + + func testIntervalBucketsThatRiseAndFallAreLeftAlone() { + XCTAssertNil(ActivityService.luckRingRunningTotal([snapshot(0, 300, 200), snapshot(900, 120, 80), snapshot(1800, 450, 310)])) + } + + func testASingleBucketIsLeftAlone() { + XCTAssertNil(ActivityService.luckRingRunningTotal([snapshot(0, 300, 200)])) + } + + // MARK: Repair + + func testLuckRingDayIsRestoredAndItsSnapshotsRemoved() throws { + let context = try TestSupport.makeContext() + pairRing(.luckRing, context: context) + storeAsBuckets(afternoon, context: context) + XCTAssertEqual(MetricsRepository.activity(on: day, context: context)?.steps, 3572, "precondition: summed") + + ActivityService.repairLuckRingActivityIfNeeded(context: context) + + let row = try XCTUnwrap(MetricsRepository.activity(on: day, context: context)) + XCTAssertEqual(row.steps, 668) + XCTAssertEqual(row.distanceMeters, 473) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + XCTAssertTrue(UserDefaults.standard.bool(forKey: key)) + } + + func testOtherRingFamiliesAreNotTouchedButTheRepairIsDone() throws { + let context = try TestSupport.makeContext() + pairRing(.colmiR02, context: context) + storeAsBuckets(afternoon, context: context) + + ActivityService.repairLuckRingActivityIfNeeded(context: context) + + XCTAssertEqual(MetricsRepository.activity(on: day, context: context)?.steps, 3572) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), afternoon.count) + XCTAssertTrue(UserDefaults.standard.bool(forKey: key)) + } + + func testWaitsWithoutMarkingDoneWhileNoRingIsPaired() throws { + let context = try TestSupport.makeContext() + storeAsBuckets(afternoon, context: context) + + ActivityService.repairLuckRingActivityIfNeeded(context: context) + XCTAssertFalse(UserDefaults.standard.bool(forKey: key), "no ring yet: retry on a later launch") + XCTAssertEqual(MetricsRepository.activity(on: day, context: context)?.steps, 3572) + + pairRing(.luckRing, context: context) + ActivityService.repairLuckRingActivityIfNeeded(context: context) + XCTAssertEqual(MetricsRepository.activity(on: day, context: context)?.steps, 668) + XCTAssertTrue(UserDefaults.standard.bool(forKey: key)) + } + + func testLuckRingIntervalDaysAreLeftAlone() throws { + let context = try TestSupport.makeContext() + pairRing(.luckRing, context: context) + storeAsBuckets([snapshot(0, 300, 200), snapshot(900, 120, 80), snapshot(1800, 450, 310)], context: context) + + ActivityService.repairLuckRingActivityIfNeeded(context: context) + + XCTAssertEqual(MetricsRepository.activity(on: day, context: context)?.steps, 870) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 3) + } +} diff --git a/PulseLoopTests/LuckRingDecoderTests.swift b/PulseLoopTests/LuckRingDecoderTests.swift index b9a5a05c..9f2e9b2a 100644 --- a/PulseLoopTests/LuckRingDecoderTests.swift +++ b/PulseLoopTests/LuckRingDecoderTests.swift @@ -143,12 +143,33 @@ final class LuckRingDecoderTests: XCTestCase { let record = cat(LuckRingBytes.le32(base), LuckRingBytes.le32(1234), le24(5000), [0], le24(300), [0], le24(600), [0]) let events = decoder.decode(frame(5, envelope(items: 1, records: [record]))) - guard case let .activityBucket(ts, steps, distance) = events.first else { - return XCTFail("expected activityBucket, got \(events)") + guard case let .activityUpdate(ts, steps, distance, calories) = events.first else { + return XCTFail("expected activityUpdate, got \(events)") } XCTAssertEqual(ts, baseDate) XCTAssertEqual(steps, 1234) XCTAssertEqual(distance, 5000) + XCTAssertEqual(calories, 0, "the ring's calorie field is unverified; the estimate stays in charge") + } + + /// The TK18's live sport pushes during a walk (from a reporter's diagnostics): each is the day's + /// running total with a new timestamp. They must decode as day totals, never as buckets — summed as + /// buckets, a 30-second walk counted the whole day again for every push. + func testLiveSportPushesAreDayTotalsNotBuckets() { + let pushes: [(offset: UInt32, steps: UInt32)] = [(0, 584), (112, 594), (115, 599), (118, 604), (121, 610), (124, 616)] + let events = pushes.flatMap { push in + let record = cat(LuckRingBytes.le32(base + push.offset), LuckRingBytes.le32(push.steps), + le24(Int(push.steps) * 7 / 10), [0], le24(0), [0], le24(0), [0]) + return decoder.decode(frame(4, envelope(items: 1, records: [record]))) + } + let totals = events.compactMap { event -> Int? in + if case .activityBucket = event { XCTFail("sport record decoded as a bucket") } + guard case let .activityUpdate(_, steps, _, _) = event else { return nil } + return steps + } + XCTAssertEqual(totals.count, pushes.count) + // The day row keeps the largest of these — the latest — not their 3,607 sum. + XCTAssertEqual(totals.max(), 616) } // MARK: Sleep diff --git a/docs/hardware/luckring.md b/docs/hardware/luckring.md index d55b9242..891ad95c 100644 --- a/docs/hardware/luckring.md +++ b/docs/hardware/luckring.md @@ -104,7 +104,7 @@ art and a fallback name. | Heart rate — spot / live / history | ✅ | BPM | | SpO₂ — spot / history | ✅ | | | HRV — spot / history | ✅ | ms | -| Steps / distance | ✅ | Distance/calorie units unverified; calories dropped | +| Steps / distance | ✅ | Each sport record is the day's running total (the day keeps the latest), not an interval. Distance/calorie units unverified; calories dropped | | Sleep (light / deep / awake) | ✅ | No REM; "movement" entries render as light sleep | | Battery level | ✅ | In-band, with charging flag | | Find device | ✅ | | @@ -125,7 +125,7 @@ art and a fallback name. PulseLoop ACKs every completed frame the ring sends (including the SEND_NO_ACK frames history arrives in), which is how the ring learns its records were delivered. Any replay is still deduped app-side (upserts by - kind + timestamp / bucket / night), so re-syncs never duplicate. + kind + timestamp / day / night), so re-syncs never duplicate. ### What the TK18 CANNOT do From b19621fe1cb2fc512f14b6772ed5a767048c391e Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:09:20 -0500 Subject: [PATCH 09/20] Stop reading, storing and showing HRV for LuckRing rings, whose HRV byte is not HRV in milliseconds --- .../RingProtocol/LuckRingCoordinator.swift | 13 +++++-- PulseLoop/RingProtocol/LuckRingDriver.swift | 14 +++++++- .../RingProtocol/LuckRingHistorySync.swift | 4 +-- PulseLoopTests/CapabilityGatingTests.swift | 10 +++--- PulseLoopTests/LuckRingDriverTests.swift | 35 +++++++++++++++++++ PulseLoopTests/LuckRingHistorySyncTests.swift | 2 +- docs/hardware/luckring.md | 4 +-- 7 files changed, 69 insertions(+), 13 deletions(-) diff --git a/PulseLoop/RingProtocol/LuckRingCoordinator.swift b/PulseLoop/RingProtocol/LuckRingCoordinator.swift index ddcbad3e..a9401596 100644 --- a/PulseLoop/RingProtocol/LuckRingCoordinator.swift +++ b/PulseLoop/RingProtocol/LuckRingCoordinator.swift @@ -39,9 +39,19 @@ final class LuckRingCoordinator: WearableCoordinator { } /// The baseline the LuckRing driver can decode: live + history HR, live + history SpO₂ (with the all-day - /// log), day steps, sleep, HRV, temperature, blood pressure, stress, and the in-band battery, plus the + /// log), day steps, sleep, temperature, blood pressure, stress, and the in-band battery, plus the /// find-device buzz. All are unconditional promises — every metric maps onto a `LuckRing*` decoder path. /// + /// **HRV is deliberately omitted.** The ring does send one HRV byte per reading (types 45 live / 42 + /// history), and the decoder reads it exactly as the vendor SDK's `K6_HeartStruct` does — but nothing + /// shows it is HRV in milliseconds. A TK18 sent 107–168 across one evening (masking the top bit didn't + /// help), and no packet carries beat-to-beat intervals to compute HRV from. The official LuckRing app + /// (1.6.3) stores the byte without ever displaying it: every HRV screen there shows + /// `AppUtils.getRomHrv()`, a random number from the user's age band. So `LuckRingDriver` drops the byte, + /// the history pass no longer requests it, and without `.hrv` the HRV card and everything keyed on it + /// stay hidden for this family. Capabilities are re-stamped on every connect, so no migration is needed; + /// HRV rows already stored are kept. + /// /// **`bitmapGatedCapabilities` is empty on purpose.** The K6 `FUNCTION_CONTROL` (dataType 22) bitmap is /// obfuscated in the decompile, so no capability can yet be deferred to the connected unit; the whole /// baseline stands as a family promise. TK18 is the only hardware-tested unit — a `.limited` family — @@ -52,7 +62,6 @@ final class LuckRingCoordinator: WearableCoordinator { .spo2, .manualSpo2, .spo2History, .steps, .realtimeSteps, .sleep, .battery, - .hrv, .manualHrv, .temperature, .bloodPressure, .manualBloodPressure, .stress, diff --git a/PulseLoop/RingProtocol/LuckRingDriver.swift b/PulseLoop/RingProtocol/LuckRingDriver.swift index b4971f60..14079565 100644 --- a/PulseLoop/RingProtocol/LuckRingDriver.swift +++ b/PulseLoop/RingProtocol/LuckRingDriver.swift @@ -81,7 +81,19 @@ final class LuckRingDriver: WearableDriver { historySync.noteReceived(dataType: frame.dataType) } - return decoder.decode(frame) + return decoder.decode(frame).filter { !Self.isHrv($0) } + } + + /// The ring's HRV byte isn't kept (see `LuckRingCoordinator.capabilities`). The history pass no longer + /// asks for it, but the ring can still push a live HRV frame on its own; that frame is still ACKed above + /// and still shows in the raw-packet feed. The decoder keeps reading it, should the byte's meaning ever + /// be worked out. + private static func isHrv(_ event: RingDecodedEvent) -> Bool { + switch event { + case .hrvSample: return true + case let .historyMeasurement(kind, _, _): return kind == .hrv + default: return false + } } func makeSyncEngine() -> RingSyncEngine { diff --git a/PulseLoop/RingProtocol/LuckRingHistorySync.swift b/PulseLoop/RingProtocol/LuckRingHistorySync.swift index 93cccf49..0d40d1cc 100644 --- a/PulseLoop/RingProtocol/LuckRingHistorySync.swift +++ b/PulseLoop/RingProtocol/LuckRingHistorySync.swift @@ -13,14 +13,14 @@ import Foundation final class LuckRingHistorySync { nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) - /// The full catalog, in request order. `mixSport` (10 — workout records) is skipped in v1. + /// The full catalog, in request order. `mixSport` (10 — workout records) is skipped in v1, and HRV (42) + /// is never requested: its byte isn't HRV in any usable sense (see `LuckRingCoordinator.capabilities`). static let catalog: [UInt8] = [ LuckRingDataType.historySport, // 5 LuckRingDataType.sleep, // 6 LuckRingDataType.historyHeart, // 8 LuckRingDataType.historyO2, // 40 LuckRingDataType.historyBP, // 41 - LuckRingDataType.historyHRV, // 42 LuckRingDataType.historyTemp, // 47 LuckRingDataType.stressHistory, // 53 ] diff --git a/PulseLoopTests/CapabilityGatingTests.swift b/PulseLoopTests/CapabilityGatingTests.swift index 3fd29008..ce498623 100644 --- a/PulseLoopTests/CapabilityGatingTests.swift +++ b/PulseLoopTests/CapabilityGatingTests.swift @@ -23,18 +23,18 @@ final class CapabilityGatingTests: XCTestCase { /// The LuckRing / TK18 baseline surfaces its metric cards — but deliberately not blood sugar, REM /// staging, fatigue, or a combined-vitals sweep (the K6 protocol has no command or record for any - /// of them). + /// of them), nor HRV (the ring's byte isn't HRV in ms; see `LuckRingCoordinator.capabilities`). func testLuckRingSurfacesItsBaselineButNotTheExcludedMetrics() throws { let context = try TestSupport.makeContext() let ring = Device(deviceType: .luckRing, capabilities: LuckRingCoordinator().capabilities) context.insert(ring) try context.save() - for metric: MetricKey in [.heartRate, .spo2, .hrv, .temperature, .bloodPressureSystolic, .stress] { + for metric: MetricKey in [.heartRate, .spo2, .temperature, .bloodPressureSystolic, .stress] { XCTAssertTrue(MetricsService.supports(metric, context: context), metric.rawValue) } - // Blood sugar and fatigue are the two vital *metrics* the family deliberately does not claim. - for metric: MetricKey in [.bloodSugar, .fatigue] { + // Blood sugar, fatigue and HRV are the vital *metrics* the family deliberately does not claim. + for metric: MetricKey in [.bloodSugar, .fatigue, .hrv] { XCTAssertFalse(MetricsService.supports(metric, context: context), metric.rawValue) } } @@ -47,7 +47,7 @@ final class CapabilityGatingTests: XCTestCase { let coordinator = LuckRingCoordinator() XCTAssertTrue(coordinator.bitmapGatedCapabilities.isEmpty) XCTAssertTrue(coordinator.capabilities.contains(.measurementInterval)) - for cap: WearableCapability in [.bloodSugar, .remSleep, .fatigue, .combinedVitalsMeasurement, .powerOff, .factoryReset] { + for cap: WearableCapability in [.bloodSugar, .remSleep, .fatigue, .hrv, .manualHrv, .combinedVitalsMeasurement, .powerOff, .factoryReset] { XCTAssertFalse(coordinator.capabilities.contains(cap), cap.rawValue) } } diff --git a/PulseLoopTests/LuckRingDriverTests.swift b/PulseLoopTests/LuckRingDriverTests.swift index a2fc4cf2..2469d4ff 100644 --- a/PulseLoopTests/LuckRingDriverTests.swift +++ b/PulseLoopTests/LuckRingDriverTests.swift @@ -47,6 +47,41 @@ final class LuckRingDriverTests: XCTestCase { XCTAssertEqual(percent, 90) } + // MARK: HRV omitted + + /// The ring's HRV byte isn't HRV in any usable sense (`LuckRingCoordinator.capabilities`): live (45) + /// and history (42) frames are still ACKed and still decodable, but never surface as readings. + func testHrvFramesAreAckedButNeverSurfaceAsReadings() { + let writer = FakeWriter() + let driver = LuckRingDriver(writer: writer) + // One `[time u32 LE][value u8]` record behind the `[total u16][items u8]` envelope; 141 is one of + // the TK18 readings that prompted this. + let record: [UInt8] = [5, 0, 1, 0x80, 0x4F, 0x09, 0x69, 141] + + for (seq, type) in [LuckRingDataType.realHRV, LuckRingDataType.historyHRV].enumerated() { + let frame = LuckRingFrame(cmdType: .send, dataType: type, payload: record, seq: UInt8(seq), devType: 2) + let decoded = LuckRingDecoder().decode(frame, now: Date(timeIntervalSince1970: 1_762_217_000)) + XCTAssertTrue(decoded.contains { Self.isHrv($0) }, "the decoder still reads type \(type)") + let events = driver.ingest(packets(frame)[0], from: notify) + XCTAssertFalse(events.contains { Self.isHrv($0) }, "type \(type) must not yield HRV, got \(events)") + } + XCTAssertEqual(writer.sent.count, 2, "each HRV SEND is still ACKed") + } + + func testLuckRingDeclaresNoHrvCapability() { + let caps = LuckRingCoordinator().capabilities + XCTAssertFalse(caps.contains(.hrv)) + XCTAssertFalse(caps.contains(.manualHrv)) + } + + private static func isHrv(_ event: RingDecodedEvent) -> Bool { + switch event { + case .hrvSample: return true + case let .historyMeasurement(kind, _, _): return kind == .hrv + default: return false + } + } + func testDeviceAckIsNotAcked() { let writer = FakeWriter() let driver = LuckRingDriver(writer: writer) diff --git a/PulseLoopTests/LuckRingHistorySyncTests.swift b/PulseLoopTests/LuckRingHistorySyncTests.swift index 1c9e1847..f9c3fec7 100644 --- a/PulseLoopTests/LuckRingHistorySyncTests.swift +++ b/PulseLoopTests/LuckRingHistorySyncTests.swift @@ -85,7 +85,7 @@ final class LuckRingHistorySyncTests: XCTestCase { } func testCatalogAndVitalsSubsetsAreWhatWeExpect() { - XCTAssertEqual(LuckRingHistorySync.catalog, [5, 6, 8, 40, 41, 42, 47, 53]) + XCTAssertEqual(LuckRingHistorySync.catalog, [5, 6, 8, 40, 41, 47, 53], "HRV (42) is never requested") XCTAssertEqual(LuckRingHistorySync.vitalsTypes, [8, 40]) } } diff --git a/docs/hardware/luckring.md b/docs/hardware/luckring.md index 891ad95c..935afd66 100644 --- a/docs/hardware/luckring.md +++ b/docs/hardware/luckring.md @@ -3,7 +3,7 @@ title: TK18 / LuckRing description: >- The ~$10 LuckRing-app ring family (Shenzhen Coolwear/Kewo OEM, "K6" protocol, company ID 0xFF64), sold as TK18/TK18A and white-labeled under SIMSONLAB and - other brands. HR, SpO₂, HRV, temperature, BP trend, sleep, steps. + other brands. HR, SpO₂, temperature, BP trend, sleep, steps. --- # LuckRing / TK18 @@ -103,7 +103,6 @@ art and a fallback name. |---|:---:|---| | Heart rate — spot / live / history | ✅ | BPM | | SpO₂ — spot / history | ✅ | | -| HRV — spot / history | ✅ | ms | | Steps / distance | ✅ | Each sport record is the day's running total (the day keeps the latest), not an interval. Distance/calorie units unverified; calories dropped | | Sleep (light / deep / awake) | ✅ | No REM; "movement" entries render as light sleep | | Battery level | ✅ | In-band, with charging flag | @@ -112,6 +111,7 @@ art and a fallback name. | Skin temperature | 🧪 | `value/10 °C` — awaiting a confirmed on-device reading | | Blood pressure | 🧪 | The ring accepts the command but streamed no reading in testing — possibly unsupported on this SKU. No cuff calibration — treat as a trend | | Stress | 🧪 | Record layout unconfirmed; range-gated | +| HRV | ❌ | The ring sends an "HRV" byte (types 45 / 42), but it doesn't behave like HRV in ms (107–168 in one evening) and no packet carries beat intervals. The LuckRing app never shows it either (its HRV screens display a random age-band value), so PulseLoop doesn't request, store or show it | | REM sleep | ❌ | Protocol has no REM stage | | Blood sugar | ❌ | The vendor opcode is a placeholder — the app's value is an estimate, not a sensor | | Menstrual cycle | ❌ | User-entered calendar data in the vendor app, not a sensor | From 97b610ac6c116be4b3d529e30fed74a9b8e22d80 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:21:28 -0500 Subject: [PATCH 10/20] Date CRP sleep from the ring's 20:00 sleep day and skip sleep queries near the turnover --- PulseLoop/RingProtocol/CRPDecoder.swift | 38 ++++- PulseLoop/RingProtocol/CRPSyncEngine.swift | 20 ++- PulseLoopTests/CRPDecoderTests.swift | 16 +- PulseLoopTests/CRPSleepDayIndexTests.swift | 187 +++++++++++++++++++++ PulseLoopTests/CRPSyncEngineTests.swift | 31 +++- 5 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 PulseLoopTests/CRPSleepDayIndexTests.swift diff --git a/PulseLoop/RingProtocol/CRPDecoder.swift b/PulseLoop/RingProtocol/CRPDecoder.swift index fc13bab1..7cd46cb3 100644 --- a/PulseLoop/RingProtocol/CRPDecoder.swift +++ b/PulseLoop/RingProtocol/CRPDecoder.swift @@ -85,6 +85,22 @@ enum CRPDecoder { /// `CRPHistoryDay` tops out at 14 days ago; a wilder value is a corrupt reply, not a real day. private static let maxHistoryDay = 14 private static let maxSleepMinutes = 24 * 60 + /// Hour the ring's sleep day turns over to the coming night (vendor `f4/e.h`: `20 <= HOUR_OF_DAY`). + /// Not `Calendar.sleepEveningBoundaryHour`'s 19, which is our own key for grouping stored sessions by + /// waking day; this one is the ring's boundary, read from the vendor. + static let sleepDayRolloverHour = 20 + /// How close to `sleepDayRolloverHour` a sleep query is too close (see `nearSleepDayRollover`). + private static let rolloverGuardMinutes = 5 + + /// Whether `now` is within `rolloverGuardMinutes` of the 20:00 sleep-day turnover. A reply asked for + /// just before it and decoded just after (or from a ring clock a minute off the phone's) would be + /// dated against the wrong day and filed a day early; `CRPSyncEngine` skips sleep queries in this + /// window. + static func nearSleepDayRollover(_ now: Date, calendar: Calendar = .current) -> Bool { + let time = calendar.dateComponents([.hour, .minute], from: now) + let minute = (time.hour ?? 0) * 60 + (time.minute ?? 0) + return abs(minute - sleepDayRolloverHour * 60) < rolloverGuardMinutes + } /// `fdd1` push — little-endian 3-byte triples: [steps][distance][calories]. From `e1/k.b`. /// distance is metres, calories kcal (vendor units). @@ -380,13 +396,16 @@ enum CRPDecoder { /// (an in-progress sleep). We don't — a completed night always ends on an awake record, so the /// only case affected is a sync taken mid-sleep, where showing the night up to the last real /// transition beats inventing minutes up to "now". - /// - Session-start anchoring is ours (the vendor keeps minute-of-day only and lets the UI place - /// the date from `dayIndex`). We anchor the FIRST record on the wake day (`today − dayIndex`) - /// from the actual midnight rollover in the transition sequence, then place later bouts by + /// - Session-start anchoring is ours (the vendor keeps minute-of-day only). The wake day follows the + /// vendor's dating: `t3/n.onHistorySleepChange` stamps a reply with the sync time, `f4/e.e` moves + /// that to tomorrow from `sleepDayRolloverHour` on, and `j4/h.b` subtracts `dayIndex`. So after + /// 20:00 day 0 is the night about to begin and last night is day 1 — counting back from the + /// phone's date alone filed every night synced after 8 PM a day early. The FIRST record is + /// anchored on that wake day, rolled back to the evening before when the transition sequence + /// crosses midnight or when it starts at or after the rollover hour (such sleep belongs to the + /// next day's night, even in a reply that never crosses midnight). Later bouts are placed by /// elapsed offset. Looking only at the final record fails when an overnight bout is followed by /// another late-evening bout whose clock time is later than the first record. - /// NOTE: assumes `dayIndex` is the WAKE day; verified against a post-midnight capture, but an - /// evening-start night is not yet capture-confirmed. private static func decodeSleep(_ payload: [UInt8], now: Date, calendar: Calendar) -> [RingDecodedEvent] { // [dayIndex] + N*[state,hour,minute]; the vendor rejects any other shape outright. if payload.count < 4 || payload.count % 3 != 1 { return [] } @@ -426,9 +445,12 @@ enum CRPDecoder { if transitions.count < 2 { return [] } // Anchor the first record; every bout is then just an offset from it. - let startOffset = firstMinuteOfDay - (crossedMidnight ? 1440 : 0) - guard let wakeDayStart = calendar.date(byAdding: .day, value: -dayIndex, - to: calendar.startOfDay(for: now)) else { return [] } + let startsTheEveningBefore = crossedMidnight || firstMinuteOfDay >= sleepDayRolloverHour * 60 + let startOffset = firstMinuteOfDay - (startsTheEveningBefore ? 1440 : 0) + let today = calendar.startOfDay(for: now) + let ringDayOffset = calendar.component(.hour, from: now) >= sleepDayRolloverHour ? 1 : 0 + guard let wakeDayStart = calendar.date(byAdding: .day, value: ringDayOffset - dayIndex, + to: today) else { return [] } let anchor = wakeDayStart.addingTimeInterval(Double(startOffset) * 60) // Pass 2: each transition's state runs until the next; split bouts on a long awake gap. diff --git a/PulseLoop/RingProtocol/CRPSyncEngine.swift b/PulseLoop/RingProtocol/CRPSyncEngine.swift index 59b65828..c4e13c86 100644 --- a/PulseLoop/RingProtocol/CRPSyncEngine.swift +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -28,6 +28,8 @@ final class CRPSyncEngine: RingSyncEngine { private weak var writer: RingCommandWriter? private var profile: UserProfileValues? private let historyWatchdogNanos: UInt64 + /// Wall clock for the 20:00 sleep guard; injectable so tests don't depend on the time they run. + private let now: () -> Date private let eventSink: @MainActor (PulseEvent) -> Void /// The four framed timing streams that have a protocol-defined terminal cursor. Sleep and @@ -99,11 +101,13 @@ final class CRPSyncEngine: RingSyncEngine { // Below RingSyncCoordinator's 20-second stall deadline so this engine's explicit `done` wins. historyWatchdogNanos: UInt64 = 15_000_000_000, timingFirstFrameWindowNanos: UInt64 = 5_000_000_000, + now: @escaping () -> Date = Date.init, eventSink: @escaping @MainActor (PulseEvent) -> Void = { event in Task { PulseEventBus.shared.publish(event) } } ) { self.writer = writer + self.now = now self.historyWatchdogNanos = historyWatchdogNanos self.timingFirstFrameWindowNanos = timingFirstFrameWindowNanos self.eventSink = eventSink @@ -127,6 +131,7 @@ final class CRPSyncEngine: RingSyncEngine { cancelHistorySync() connectionQueriesSent = false sleepBackfillSent = false + sleepBackfillDeferred = false requestedTimingFrames.removeAll() } @@ -199,8 +204,16 @@ final class CRPSyncEngine: RingSyncEngine { send(CRPProtocol.queryTimingHrvHistory()) send(CRPProtocol.queryTimingStressHistory()) send(CRPProtocol.queryHistoryTemp()) + // The ring dates its sleep replies by its own clock against the 20:00 turnover, and the decoder + // by the phone's when the reply lands. Straddling 20:00 the two disagree by a day and the night + // is filed a day early. So a pass this close to 20:00 leaves sleep — the per-link backfill + // included — to the next one (~30 min later). + if CRPDecoder.nearSleepDayRollover(now()) { + if includeSleepBackfill { sleepBackfillDeferred = true } + return + } send(CRPProtocol.queryHistorySleep()) - if includeSleepBackfill { sendSleepBackfill() } + if includeSleepBackfill || sleepBackfillDeferred { sendSleepBackfill() } } /// Start one standalone history pass. Periodic BLE wakeups can race the foreground timer, so a @@ -267,6 +280,8 @@ final class CRPSyncEngine: RingSyncEngine { /// Whether older nights have already been backfilled on this GATT link. private var sleepBackfillSent = false + /// The startup pass fell inside the 20:00 guard, so the next periodic pass sends the backfill. + private var sleepBackfillDeferred = false /// Pull the nights *before* today, once per connection. /// @@ -277,6 +292,8 @@ final class CRPSyncEngine: RingSyncEngine { /// Safe to send blind. Each reply is self-describing: `payload[0]` is the ring's own day index, /// so `CRPDecoder.decodeSleep` dates a night from the reply rather than from what we asked for, /// and a day the ring has no record of simply produces no reply — the same nothing we get today. + /// That index counts back from the ring's sleep day, which turns over at 20:00, not midnight: + /// after 8 PM last night is day 1 (see `CRPDecoder.decodeSleep`). /// /// Once per connection, and deliberately short of the decoder's 14-day ceiling. This ring /// funnels the handshake, timing config, history pull @@ -285,6 +302,7 @@ final class CRPSyncEngine: RingSyncEngine { private func sendSleepBackfill() { if sleepBackfillSent { return } sleepBackfillSent = true + sleepBackfillDeferred = false // Half-open on purpose: `crpSleepBackfillDays` is documented as a knob to raise or lower, and // `1...0` would trap at runtime if it were ever turned down to "today only". for daysAgo in 1..<(crpSleepBackfillDays + 1) { diff --git a/PulseLoopTests/CRPDecoderTests.swift b/PulseLoopTests/CRPDecoderTests.swift index e6955b8b..8d5f5a5a 100644 --- a/PulseLoopTests/CRPDecoderTests.swift +++ b/PulseLoopTests/CRPDecoderTests.swift @@ -428,10 +428,14 @@ final class CRPDecoderTests: XCTestCase { // MARK: - Sleep (group 2 / cmd 14) + /// 2023-11-14 08:13:20 UTC: a morning sync, so day 0 is the night just ended. (From 20:00 the ring's + /// sleep day turns over and day 0 is the coming night; see `CRPSleepDayIndexTests`.) + private let sleepSyncMorning = Date(timeIntervalSince1970: 1_699_949_600) + /// Vendor `e1/j.b`: `[dayIndex]` then 3-byte `[state, hour, minute]` records, each marking the /// moment that state BEGINS and running until the next record. func testSleepDecodesStagesOnePerMinute() { - let now = Date(timeIntervalSince1970: 1_700_000_000) + let now = sleepSyncMorning let cal = utcCalendar // 01:00 light (60 min) → 02:00 deep (30 min) → 02:30 awake (ends the night) let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, @@ -450,7 +454,7 @@ final class CRPDecoderTests: XCTestCase { /// A day can hold a night plus a nap; an awake run of >= the session gap splits them. func testSleepSplitsBoutsOnALongAwakeGap() { - let now = Date(timeIntervalSince1970: 1_700_000_000) + let now = sleepSyncMorning // 01:00 light 60m → 02:00 awake 180m → 05:00 light 30m → 05:30 awake let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistorySleep, @@ -461,7 +465,7 @@ final class CRPDecoderTests: XCTestCase { /// A short mid-night wake stays inside its bout as awake minutes. func testSleepKeepsShortWakesInsideTheBout() { - let now = Date(timeIntervalSince1970: 1_700_000_000) + let now = sleepSyncMorning // 01:00 light 60m → 02:00 awake 10m → 02:10 light 30m → 02:40 awake let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistorySleep, @@ -595,7 +599,7 @@ final class CRPDecoderTests: XCTestCase { /// A night that starts before midnight: the first record reads later on the clock than the last, /// so the anchor rolls back a day rather than placing the night in the wrong evening. func testSleepAnchorsAnEveningStartBeforeMidnight() { - let now = Date(timeIntervalSince1970: 1_700_000_000) + let now = sleepSyncMorning let cal = utcCalendar // 23:00 light 120m → 01:00 deep 60m → 02:00 awake let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, @@ -611,7 +615,7 @@ final class CRPDecoderTests: XCTestCase { } func testSleepAnchorsFromMidnightRolloverWhenALaterBoutEndsAfterTheFirstClockTime() { - let now = Date(timeIntervalSince1970: 1_700_000_000) + let now = sleepSyncMorning let cal = utcCalendar // Prior 23:00 light → 07:00 awake, then 22:30 light → 23:30 awake on the wake day. // The final clock time is later than the first, but the 23:00→07:00 rollover still proves @@ -631,7 +635,7 @@ final class CRPDecoderTests: XCTestCase { } func testSleepNeverMovesAOneDayReplyBackMoreThanOneDay() { - let now = Date(timeIntervalSince1970: 1_700_000_000) + let now = sleepSyncMorning let cal = utcCalendar // Two backward clock transitions are malformed for one day reply. They must not shift the // first transition two days back; dayIndex identifies a single wake day. diff --git a/PulseLoopTests/CRPSleepDayIndexTests.swift b/PulseLoopTests/CRPSleepDayIndexTests.swift new file mode 100644 index 00000000..80643f43 --- /dev/null +++ b/PulseLoopTests/CRPSleepDayIndexTests.swift @@ -0,0 +1,187 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Which day `CRPDecoder` files a CRP sleep-history reply (`group 2 / cmd 14`) under, for each shape a +/// waking day's sleep can take around midnight, and on either side of the vendor's 20:00 rollover. +/// Ported from the Android app's `CRPSleepDayIndexTest.kt` and `CRPSleepRolloverGuardTest.kt`. +/// +/// Prompted by a Colmi R11 whose first night, waking Sun 2026-09-27, was stored correctly by a morning +/// sync and then again, exactly one day early, by an evening one. The vendor app dates a reply from the +/// moment it syncs: `t3/n.onHistorySleepChange` builds the entity from `new Date()`, `f4/e.e` moves that +/// to tomorrow when `f4/e.h` sees the hour at 20 or later, and `j4/h.b` subtracts the day index. From +/// 20:00 on, day 0 is the night about to begin and last night is day 1. A sleep starting at or after +/// 20:00 therefore belongs to the next waking day. +/// +/// Bouts are one minute long; a 60-minute awake run is the shortest gap `CRPDecoder` splits bouts on. +@MainActor +final class CRPSleepDayIndexTests: XCTestCase { + private let fdd3 = CRPUUIDs.cmdNotifyCBUUID + private let awake: UInt8 = 0 + private let light: UInt8 = 1 + + private var utc: Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + return cal + } + + private func instant(_ iso: String) -> Date { ISO8601DateFormatter().date(from: iso)! } + + private var sundayMorning: Date { instant("2026-09-27T09:00:00Z") } + private var sundayJustBefore8pm: Date { instant("2026-09-27T19:59:00Z") } + private var sundayEvening: Date { instant("2026-09-27T21:00:00Z") } + private var sundayLate: Date { instant("2026-09-27T23:30:00Z") } + private var mondayMorning: Date { instant("2026-09-28T09:00:00Z") } + + /// One vendor `[state, hour, minute]` record: `state` begins at hh:mm. + private func at(_ state: UInt8, _ hour: UInt8, _ minute: UInt8) -> [UInt8] { [state, hour, minute] } + + private func sleepFrame(_ dayIndex: UInt8, _ records: [UInt8]...) -> Data { + CRPProtocol.frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistorySleep, + payload: [dayIndex] + records.flatMap { $0 }) + } + + /// Decoded bout starts, checking none is dated after the sync that reported it. + private func decodeAt(_ now: Date, _ frame: Data, calendar: Calendar? = nil, + file: StaticString = #filePath, line: UInt = #line) -> [(start: Date, stages: [SleepStage])] { + let bouts = CRPDecoder.decode(frame, from: fdd3, now: now, calendar: calendar ?? utc).compactMap { event -> (Date, [SleepStage])? in + guard case let .sleepTimeline(start, stages) = event else { return nil } + return (start, stages) + } + for bout in bouts { + XCTAssertLessThanOrEqual(bout.0, now, "bout dated \(bout.0) starts after the \(now) sync that reported it", file: file, line: line) + } + return bouts + } + + private func assertBouts(_ expected: [String], _ bouts: [(start: Date, stages: [SleepStage])], + file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(bouts.map(\.start), expected.map(instant), file: file, line: line) + XCTAssertTrue(bouts.allSatisfy { $0.stages == [.light] }, file: file, line: line) + } + + /// One bout either side of midnight: light 23:00–23:01, then light 00:01–00:02. + private var acrossMidnight: [[UInt8]] { [at(light, 23, 0), at(awake, 23, 1), at(light, 0, 1), at(awake, 0, 2)] } + + private func sleepFrame(_ dayIndex: UInt8, records: [[UInt8]]) -> Data { + CRPProtocol.frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistorySleep, + payload: [dayIndex] + records.flatMap { $0 }) + } + + // MARK: - Day index + + func test1_OneBoutBeforeMidnightIsTheEveningBeforeTheWakingDay() { + assertBouts(["2026-09-26T23:00:00Z"], decodeAt(sundayMorning, sleepFrame(0, at(light, 23, 0), at(awake, 23, 1)))) + } + + func test2_OneBoutAfterMidnightIsOnTheWakingDay() { + assertBouts(["2026-09-27T01:00:00Z"], decodeAt(sundayMorning, sleepFrame(0, at(light, 1, 0), at(awake, 1, 1)))) + } + + func test3_TwoBoutsBeforeMidnightAreBothTheEveningBeforeTheWakingDay() { + let bouts = decodeAt(sundayMorning, sleepFrame(0, at(light, 22, 0), at(awake, 22, 1), at(light, 23, 1), at(awake, 23, 2))) + assertBouts(["2026-09-26T22:00:00Z", "2026-09-26T23:01:00Z"], bouts) + } + + func test4_TwoBoutsAfterMidnightAreBothOnTheWakingDay() { + let bouts = decodeAt(sundayMorning, sleepFrame(0, at(light, 1, 0), at(awake, 1, 1), at(light, 2, 1), at(awake, 2, 2))) + assertBouts(["2026-09-27T01:00:00Z", "2026-09-27T02:01:00Z"], bouts) + } + + func test5_ABoutBeforeMidnightAndOneAfterStraddleTheMidnightBetweenThem() { + assertBouts(["2026-09-26T23:00:00Z", "2026-09-27T00:01:00Z"], decodeAt(sundayMorning, sleepFrame(0, records: acrossMidnight))) + } + + /// The reported bug: after 20:00 the ring reports last night as day 1, and it must still be filed + /// under the day it was slept, not the day before. + func test6_LastNightSyncedAfter8pmAsDay1IsTheSameNightTheMorningSyncStoredAsDay0() { + let morning = decodeAt(sundayMorning, sleepFrame(0, records: acrossMidnight)) + let evening = decodeAt(sundayEvening, sleepFrame(1, records: acrossMidnight)) + assertBouts(["2026-09-26T23:00:00Z", "2026-09-27T00:01:00Z"], morning) + assertBouts(["2026-09-26T23:00:00Z", "2026-09-27T00:01:00Z"], evening) + } + + /// The rollover is at 20:00 exactly: a minute earlier, day 0 is still last night. + func test7_ASyncJustBefore8pmStillReadsDay0AsLastNight() { + assertBouts(["2026-09-26T23:00:00Z", "2026-09-27T00:01:00Z"], decodeAt(sundayJustBefore8pm, sleepFrame(0, records: acrossMidnight))) + } + + /// In an older day's reply, an evening record is still the evening before that day. Monday morning, + /// day 1 is Sunday's sleep day, which runs from Sat 20:00 to Sun 20:00. + func test8_AnEveningBoutAt2000InAnOlderDaysReplyIsTheEveningBeforeThatDay() { + assertBouts(["2026-09-26T20:00:00Z"], decodeAt(mondayMorning, sleepFrame(1, at(light, 20, 0), at(awake, 20, 1)))) + } + + /// One minute earlier, the record is still inside the same sleep day, so it stays on that day. + func test9_ABoutStartingAt1959StaysOnItsOwnDay() { + assertBouts(["2026-09-27T19:59:00Z"], decodeAt(mondayMorning, sleepFrame(1, at(light, 19, 59), at(awake, 20, 0)))) + } + + /// After 8 PM, day 0 is the night just beginning. + func test10_After8pmDay0IsTonightsSleep() { + assertBouts(["2026-09-27T23:00:00Z"], decodeAt(sundayLate, sleepFrame(0, at(light, 23, 0), at(awake, 23, 1)))) + } + + /// Four ordinary syncs, stored the way the app stores them (`EventPersistenceSubscriber`), leave each + /// night holding only its own sleep. Sunday's record is 02:00–02:01; Monday's starts earlier and ends + /// later, 01:59–02:02. The ring re-sends each night, by day index, on every sync. Before the 20:00 + /// rule the Monday-evening sync filed Monday's record on Sunday and Sunday's on Saturday. + func test11_FourSyncsLeaveEachNightHoldingOnlyItsOwnSleep() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + let cal = Calendar.current // persistence keys waking days on the device calendar + func local(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date { + cal.date(from: DateComponents(year: 2026, month: 9, day: day, hour: hour, minute: minute))! + } + let sundayRecord = [at(light, 2, 0), at(awake, 2, 1)] + let mondayRecord = [at(light, 1, 59), at(awake, 2, 2)] + func sync(_ now: Date, _ replies: Data...) { + for reply in replies { + for bout in decodeAt(now, reply, calendar: cal) { + subscriber.persist(.sleepTimeline(timestamp: bout.start, stages: bout.stages)) + } + } + } + + sync(local(27, 9), sleepFrame(0, records: sundayRecord)) + sync(local(28, 9), sleepFrame(0, records: mondayRecord), sleepFrame(1, records: sundayRecord)) + sync(local(28, 21), sleepFrame(1, records: mondayRecord), sleepFrame(2, records: sundayRecord)) + sync(local(29, 9), sleepFrame(1, records: mondayRecord), sleepFrame(2, records: sundayRecord)) + try context.save() + + func night(_ day: Int) -> [String] { + let dayStart = cal.startOfDay(for: local(day, 12)) + let ids = Set(SleepRepository.sessions(context: context).filter { cal.isDate($0.date, inSameDayAs: dayStart) }.map(\.id)) + let blocks = ((try? context.fetch(FetchDescriptor())) ?? []).filter { ids.contains($0.sessionId) } + return blocks.sorted { $0.startAt < $1.startAt }.map { + "\(cal.component(.hour, from: $0.startAt)):\(cal.component(.minute, from: $0.startAt)) \($0.durationMinutes)m \($0.stageRaw)" + } + } + XCTAssertEqual(night(26), [], "Sat, before the ring was worn") + XCTAssertEqual(night(27), ["2:0 1m \(SleepStage.light.rawValue)"]) + XCTAssertEqual(night(28), ["1:59 3m \(SleepStage.light.rawValue)"]) + } + + // MARK: - 20:00 sync guard + + private func pacific(_ hour: Int, _ minute: Int) -> (Date, Calendar) { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return (cal.date(from: DateComponents(year: 2026, month: 10, day: 12, hour: hour, minute: minute, second: 30))!, cal) + } + + func testTheMinutesAround2000AreGuarded() { + for (h, m) in [(19, 59), (20, 0), (20, 4), (19, 56)] { + let (now, cal) = pacific(h, m) + XCTAssertTrue(CRPDecoder.nearSleepDayRollover(now, calendar: cal), "\(h):\(m)") + } + } + + func testTheRestOfTheDayIsNotGuarded() { + for (h, m) in [(19, 55), (20, 5), (7, 30), (0, 0)] { + let (now, cal) = pacific(h, m) + XCTAssertFalse(CRPDecoder.nearSleepDayRollover(now, calendar: cal), "\(h):\(m)") + } + } +} diff --git a/PulseLoopTests/CRPSyncEngineTests.swift b/PulseLoopTests/CRPSyncEngineTests.swift index abe55061..4a22ed7d 100644 --- a/PulseLoopTests/CRPSyncEngineTests.swift +++ b/PulseLoopTests/CRPSyncEngineTests.swift @@ -15,6 +15,10 @@ final class CRPSyncEngineTests: XCTestCase { func payloadByte(_ frame: Int, _ index: Int) -> Int { Int([UInt8](sent[frame])[index]) } } + /// A sync time well clear of the 20:00 sleep-day guard, so tests that count sleep queries don't + /// depend on when they run. + private static let afternoon = Calendar.current.date(bySettingHour: 14, minute: 0, second: 0, of: Date())! + private func finishTimingHistory(_ engine: CRPSyncEngine) { engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingHR, day: 0, frameIndex: 1)) engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingSpO2, day: 0, frameIndex: 1)) @@ -93,7 +97,7 @@ final class CRPSyncEngineTests: XCTestCase { /// blind — a day the ring has no record of simply produces no reply. func testSleepBackfillPullsThePriorWeekOncePerConnection() { let w = FakeWriter() - let engine = CRPSyncEngine(writer: w) + let engine = CRPSyncEngine(writer: w, now: { Self.afternoon }) engine.runStartup() let sleepDays = w.opcodes.indices .filter { w.opcodes[$0] == [2, 14] } @@ -185,7 +189,7 @@ final class CRPSyncEngineTests: XCTestCase { /// the vendor's `querySleepState`, which is why the ring never answered the old query. func testRunStartupQueriesHistoryOnGroupTwo() { let w = FakeWriter() - let engine = CRPSyncEngine(writer: w) + let engine = CRPSyncEngine(writer: w, now: { Self.afternoon }) engine.runStartup() for cmd in [15, 17, 16, 47, 22, 14] { // HR, SpO2, HRV, stress, temp, sleep XCTAssertTrue(w.opcodes.contains([2, cmd]), "expected group2/cmd\(cmd) history query") @@ -284,12 +288,32 @@ final class CRPSyncEngineTests: XCTestCase { XCTAssertTrue(w.sent.isEmpty) } + /// A pass within 5 minutes of the ring's 20:00 sleep-day turnover sends no sleep query (its reply + /// would be dated against the wrong day); the per-link backfill waits for the next pass. + func testSleepQueriesWaitOutThe2000Turnover() { + let w = FakeWriter() + var clock = Calendar.current.date(bySettingHour: 19, minute: 58, second: 0, of: Date())! + let engine = CRPSyncEngine(writer: w, now: { clock }) + engine.runStartup() + XCTAssertFalse(w.opcodes.contains([2, 14]), "no sleep query at 19:58") + XCTAssertTrue(w.opcodes.contains([2, 15]), "the rest of the history pass still runs") + + finishTimingHistory(engine) + w.sent.removeAll() + clock = Calendar.current.date(bySettingHour: 20, minute: 30, second: 0, of: Date())! + engine.syncHistory() + let sleepDays = w.opcodes.indices + .filter { w.opcodes[$0] == [2, 14] } + .map { w.payloadByte($0, 6) } + XCTAssertEqual(sleepDays, [0, 1, 2, 3, 4, 5, 6], "the next pass sends sleep and the deferred backfill") + } + // MARK: - Sync lifecycle + completion func testPeriodicSyncHistoryIsStandaloneAndRejectsAConcurrentKick() { let w = FakeWriter() var stages: [String] = [] - let engine = CRPSyncEngine(writer: w, eventSink: { event in + let engine = CRPSyncEngine(writer: w, now: { Self.afternoon }, eventSink: { event in if case let .syncProgress(stage) = event { stages.append(stage) } }) @@ -437,6 +461,7 @@ final class CRPSyncEngineTests: XCTestCase { historyWatchdogNanos: 20_000_000, // Both deadlines expire inside the sleep below, so neither may survive the disconnect. timingFirstFrameWindowNanos: 10_000_000, + now: { Self.afternoon }, eventSink: { event in if case let .syncProgress(stage) = event { stages.append(stage) } } From 7da0aa153f84654a16b28dec1129a4f7eeab0ac9 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:27:42 -0500 Subject: [PATCH 11/20] End CRP spot heart rate and SpO2 on the ring's single result frame instead of the sample window --- PulseLoop/RingProtocol/CRPSyncEngine.swift | 21 +++ PulseLoop/Services/CRPSpotMeasurement.swift | 108 ++++++++++++++ PulseLoop/Services/RingSyncCoordinator.swift | 76 +++++++--- PulseLoop/Views/MeasurementModal.swift | 6 +- PulseLoopTests/CRPSpotMeasurementTests.swift | 142 +++++++++++++++++++ 5 files changed, 329 insertions(+), 24 deletions(-) create mode 100644 PulseLoop/Services/CRPSpotMeasurement.swift create mode 100644 PulseLoopTests/CRPSpotMeasurementTests.swift diff --git a/PulseLoop/RingProtocol/CRPSyncEngine.swift b/PulseLoop/RingProtocol/CRPSyncEngine.swift index c4e13c86..eebe9600 100644 --- a/PulseLoop/RingProtocol/CRPSyncEngine.swift +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -368,6 +368,27 @@ final class CRPSyncEngine: RingSyncEngine { func startSpO2() { send(CRPProtocol.measureSpO2(true)) } func stopSpO2() { send(CRPProtocol.measureSpO2(false)) } + // MARK: - Spot HR / SpO2 (see `CRPSpotMeasurement`) + + /// Start a spot measurement, reporting when the start frame is actually written (`true`) or can't + /// be (`false`): the ring can't begin measuring before then, so that is where the deadline starts. + func startSpot(_ kind: CRPSpotMeasurement.Kind, written: @escaping @MainActor (Bool) -> Void) { + guard let writer else { written(false); return } + writer.enqueueTracked(spotFrame(kind, enable: true)) { result in + if case .success = result { written(true) } else { written(false) } + } + } + + /// Stop a spot measurement. Sent even when the start may still be queued: the write queue is FIFO, + /// so the stop lands right behind it and the ring never runs a measurement nobody is waiting for. + func stopSpot(_ kind: CRPSpotMeasurement.Kind) { + send(spotFrame(kind, enable: false)) + } + + private func spotFrame(_ kind: CRPSpotMeasurement.Kind, enable: Bool) -> Data { + kind == .heartRate ? CRPProtocol.measureHeartRate(enable) : CRPProtocol.measureSpO2(enable) + } + func findDevice() { send(CRPProtocol.findDevice(true)) } func setGoal(steps: Int) { diff --git a/PulseLoop/Services/CRPSpotMeasurement.swift b/PulseLoop/Services/CRPSpotMeasurement.swift new file mode 100644 index 00000000..4e68fab7 --- /dev/null +++ b/PulseLoop/Services/CRPSpotMeasurement.swift @@ -0,0 +1,108 @@ +import Foundation + +/// One on-demand HR or SpO₂ measurement on a CRP ring (COLMI R11 / "Da Rings"), and the rule for when it +/// is over. +/// +/// ## Why CRP doesn't use `HRSampleWindow` +/// +/// The R11 answers a spot HR start (`group 1 / cmd 9`) or SpO₂ start (`cmd 11`) with a **single terminal +/// result frame** — ~15–17 s after start for HR, ~48 s for SpO₂ — and then sends nothing more. The +/// vendor treats every cmd-9/11 frame as the end of the measurement. Run through `HRSampleWindow` (six +/// samples, a 3 s contact gap), a valid reading was one sample and always failed. +/// +/// ## The rule +/// +/// - The first result frame decides: a value in band (HR 40–200, SpO₂ 70–100) is the reading; an empty +/// payload, 0, `0xFF` or anything out of band is the ring saying it got none. +/// - A not-worn push (`group 3 / cmd 7 [00]`, or the bridged wear state) fails it at once, as does a link +/// drop. +/// - The deadline counts from when the start command was **written**, not when it was queued: the ring +/// can't have started before then. A start that is never written within `dispatchTimeout` fails too, +/// so nothing waits on a write that never happens. +/// - History traffic (group 2) never ends a measurement; only the result frame for this kind does. +/// - The first verdict wins: a wear-state drop after a good reading can't turn it into a failure. +struct CRPSpotMeasurement { + enum Kind: Equatable { + case heartRate, spo2 + + /// The result frame's `group 1` command (the same number as the start command). + var resultCmd: Int { self == .heartRate ? CRPCommands.cmdResultHR : CRPCommands.cmdResultSpO2 } + var validRange: ClosedRange { self == .heartRate ? 40...200 : 70...100 } + /// Ceiling from the write, set around the R11's ~15–17 s (HR) and ~48 s (SpO₂). + var window: TimeInterval { self == .heartRate ? 30 : 60 } + } + + enum Failure: Equatable { + case notWorn, noReading, disconnected, timedOut, notDispatched + } + + enum Verdict: Equatable { + case reading(Int) + case failed(Failure) + } + + /// How long the start command may wait to be written before the measurement fails. + static let dispatchTimeout: TimeInterval = 15 + + let kind: Kind + /// Tells this measurement's late write callback apart from the next one's. + let id = UUID() + private let queuedAt: Date + private(set) var writtenAt: Date? + private(set) var verdict: Verdict? + + init(kind: Kind, queuedAt: Date = Date()) { + self.kind = kind + self.queuedAt = queuedAt + } + + /// The start command left the queue (`true`) or could not be written (`false`). + mutating func noteStartWritten(_ written: Bool, at now: Date = Date()) { + if written { + if writtenAt == nil { writtenAt = now } + } else { + decide(.failed(.notDispatched)) + } + } + + /// One incoming notification. Only a whole `group 1` result frame for this kind, or a not-worn push, + /// decides anything. + mutating func ingest(_ frame: Data) { + let b = [UInt8](frame) + guard b.count >= CRPProtocol.headerSize, CRPProtocol.isFrameStart(frame), + CRPProtocol.frameLength(frame) == b.count else { return } + let group = Int(b[4]), cmd = Int(b[5]) + let payload = b.dropFirst(CRPProtocol.headerSize) + if group == CRPCommands.groupPower, cmd == CRPCommands.cmdWearState, payload.first == 0 { + decide(.failed(.notWorn)) + } else if group == CRPCommands.groupDevice, cmd == kind.resultCmd { + if let value = payload.first.map(Int.init), kind.validRange.contains(value) { + decide(.reading(value)) + } else { + decide(.failed(.noReading)) + } + } + } + + mutating func noteWorn(_ worn: Bool) { + if !worn { decide(.failed(.notWorn)) } + } + + mutating func noteDisconnected() { + decide(.failed(.disconnected)) + } + + /// The verdict, applying the deadlines as of `now`. + mutating func check(at now: Date = Date()) -> Verdict? { + if let writtenAt { + if now.timeIntervalSince(writtenAt) >= kind.window { decide(.failed(.timedOut)) } + } else if now.timeIntervalSince(queuedAt) >= Self.dispatchTimeout { + decide(.failed(.notDispatched)) + } + return verdict + } + + private mutating func decide(_ verdict: Verdict) { + if self.verdict == nil { self.verdict = verdict } + } +} diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index 620c3a73..2691f0c9 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -237,9 +237,9 @@ final class RingSyncCoordinator { /// when the not-worn signal arrives *before* any reading, so a wear-state drop right after a good /// reading can't turn a success into a failure. private(set) var measureNotWorn = false - /// SpO2's counterpart to `hrNoReadingReported`: SpO2 has no "complete with no reading" reply, so - /// the wear-state push is the only thing that can abort it early. - private var spo2NotWornReported = false + /// The CRP spot HR / SpO₂ measurement in flight — see `CRPSpotMeasurement` for why CRP ends on the + /// ring's single result frame instead of `HRSampleWindow`. + private var crpSpot: CRPSpotMeasurement? /// The samples of the HR measurement in flight, and the rule for whether they settled — see /// `HRSampleWindow`, which owns the warm-up echo and the consistency gate. private var hrWindow = HRSampleWindow() @@ -505,6 +505,7 @@ final class RingSyncCoordinator { @discardableResult func measureHR() async -> Int? { guard hrState != .measuring else { return nil } + if client.activeDeviceType == .crp { return await measureCRPSpot(.heartRate) } if client.activeDeviceType == .rwfit, [hrState, spo2State, hrvState, bpState].contains(.measuring) { return nil } guard client.state == .connected else { hrState = .failed; return nil } @@ -554,6 +555,42 @@ final class RingSyncCoordinator { return result } + /// The CRP spot path for `measureHR` / `measureSpO2`: start, then end on the ring's one result frame, + /// a not-worn push, a link drop, or the deadline — see `CRPSpotMeasurement`. The reading itself is + /// persisted like any CRP result, by the decoder's `.heartRateSample` / `.spo2Result`. + private func measureCRPSpot(_ kind: CRPSpotMeasurement.Kind) async -> Int? { + // One channel carries both, so only one spot measurement at a time. + guard crpSpot == nil else { return nil } + let setState: (MeasureState) -> Void = { state in + if kind == .heartRate { self.hrState = state } else { self.spo2State = state } + } + guard client.state == .connected, let crp = engine as? CRPSyncEngine else { setState(.failed); return nil } + setState(.measuring) + measureNotWorn = false + if kind == .spo2 { latestSpO2Value = nil } + let measurement = CRPSpotMeasurement(kind: kind) + crpSpot = measurement + crp.startSpot(kind) { [weak self] written in + guard let self, self.crpSpot?.id == measurement.id else { return } + self.crpSpot?.noteStartWritten(written) + } + + var verdict: CRPSpotMeasurement.Verdict? + while !Task.isCancelled { + if client.state != .connected { crpSpot?.noteDisconnected() } + if let decided = crpSpot?.check() { verdict = decided; break } + try? await Task.sleep(nanoseconds: 250_000_000) + } + crpSpot = nil + if client.state == .connected { crp.stopSpot(kind) } + restartWorkoutHeartRateIfActive() + + if verdict == .failed(.notWorn) { measureNotWorn = true } + guard case let .reading(value) = verdict else { setState(.failed); return nil } + setState(.done(value)) + return value + } + /// Poll a value getter every 0.5s up to `window` seconds; returns as soon as it's non-nil, or when /// `abort` becomes true, else nil at timeout. private func pollForValue(window: UInt64, value: () -> Int?, abort: () -> Bool) async -> Int? { @@ -570,13 +607,13 @@ final class RingSyncCoordinator { @discardableResult func measureSpO2() async -> Int? { guard spo2State != .measuring else { return nil } + if client.activeDeviceType == .crp { return await measureCRPSpot(.spo2) } if client.activeDeviceType == .rwfit, [hrState, spo2State, hrvState, bpState].contains(.measuring) { return nil } guard client.state == .connected else { spo2State = .failed; return nil } spo2State = .measuring latestSpO2Value = nil measureNotWorn = false - spo2NotWornReported = false let token = spot.begin(mode: YCBTMeasurementMode.spo2) engine?.startSpO2() if let rwfit = engine as? RWfitSyncEngine, @@ -589,8 +626,8 @@ final class RingSyncCoordinator { let result = await pollForValue( window: spo2MeasureSeconds, value: { self.latestSpO2Value }, - // The ring refused the measurement, or told us it isn't on the finger. - abort: { self.spot.isRejected(token) || self.spo2NotWornReported } + // The ring refused the measurement. + abort: { self.spot.isRejected(token) } ) spot.end(token) engine?.stopSpO2() @@ -782,6 +819,13 @@ final class RingSyncCoordinator { private func handle(_ event: PulseEvent) { if handleRWfit(event) { return } + if crpSpot != nil { + switch event { + case let .rawPacket(.incoming, data, _): crpSpot?.ingest(data) + case let .deviceStateChanged(state, _) where state != .connected: crpSpot?.noteDisconnected() + default: break + } + } switch event { case let .heartRateSample(bpm, _): latestHRValue = bpm @@ -798,22 +842,10 @@ final class RingSyncCoordinator { // measurement hasn't already produced a real reading. if hrState == .measuring, !measurementReceivedReading { hrNoReadingReported = true } case let .wearState(worn): - // `worn == false` means no skin contact, so an optical spot measure can't read. Fast-fail - // the in-flight measure instead of idling out the full window, and flag *why* — but only - // if no reading landed first (a wear-state drop right after a good reading must not turn a - // success into a failure). Gated to CRP: other families' wear polarity is unverified. - if !worn, client.activeDeviceType == .crp { - var flagged = false - if hrState == .measuring, !measurementReceivedReading { - hrNoReadingReported = true - flagged = true - } - if spo2State == .measuring, latestSpO2Value == nil { - spo2NotWornReported = true - flagged = true - } - if flagged { measureNotWorn = true } - } + // `worn == false` means no skin contact, so an optical spot measure can't read: the CRP spot + // measurement fails at once (unless its reading already landed). Gated to CRP: other + // families' wear polarity is unverified. + if client.activeDeviceType == .crp { crpSpot?.noteWorn(worn) } case .deviceStateChanged(.connected, _): if client.activeDeviceType != .rwfit { lastSyncAt = Date() } // Ring came back mid-workout: the new connection's engine doesn't know a stream was diff --git a/PulseLoop/Views/MeasurementModal.swift b/PulseLoop/Views/MeasurementModal.swift index 0015d92a..3954a883 100644 --- a/PulseLoop/Views/MeasurementModal.swift +++ b/PulseLoop/Views/MeasurementModal.swift @@ -90,8 +90,10 @@ struct MeasurementSheet: View { /// Nil in demo mode too — no 30s window is running there, so a countdown would be pure theatre. private var countdownWindow: Double? { // RwFit first waits for a history page boundary and command acceptance, so total - // wall-clock duration is variable even though the sampling window remains fixed. - guard kind == .hr, ble.state == .connected, ble.activeDeviceType != .rwfit else { return nil } + // wall-clock duration is variable even though the sampling window remains fixed. A CRP ring + // ends on its one result frame (`CRPSpotMeasurement`), so its window is a ceiling, not a wait. + guard kind == .hr, ble.state == .connected, + ble.activeDeviceType != .rwfit, ble.activeDeviceType != .crp else { return nil } return Double(coordinator.hrMeasureSeconds) } diff --git a/PulseLoopTests/CRPSpotMeasurementTests.swift b/PulseLoopTests/CRPSpotMeasurementTests.swift new file mode 100644 index 00000000..2f3cf403 --- /dev/null +++ b/PulseLoopTests/CRPSpotMeasurementTests.swift @@ -0,0 +1,142 @@ +import XCTest +@testable import PulseLoop + +/// The CRP (COLMI R11) spot HR / SpO₂ rule: the ring answers with one terminal result frame, so the +/// first result decides. Ported from the Android app's `CRPSpotControllerTest.kt`. +@MainActor +final class CRPSpotMeasurementTests: XCTestCase { + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + private func result(_ kind: CRPSpotMeasurement.Kind, _ payload: [UInt8]) -> Data { + CRPProtocol.frame(group: CRPCommands.groupDevice, cmd: kind.resultCmd, payload: payload) + } + + private func written(_ kind: CRPSpotMeasurement.Kind) -> CRPSpotMeasurement { + var m = CRPSpotMeasurement(kind: kind, queuedAt: t0) + m.noteStartWritten(true, at: t0.addingTimeInterval(1)) + return m + } + + // MARK: Result frames + + func testValidHRResultCompletesImmediately() { + var m = written(.heartRate) + m.ingest(result(.heartRate, [72])) + XCTAssertEqual(m.check(at: t0.addingTimeInterval(17)), .reading(72)) + } + + func testValidSpO2ResultCompletesImmediately() { + var m = written(.spo2) + m.ingest(result(.spo2, [97])) + XCTAssertEqual(m.check(at: t0.addingTimeInterval(48)), .reading(97)) + } + + func testNoReadingResultsEndTheMeasurementAsFailure() { + let cases: [(CRPSpotMeasurement.Kind, [UInt8])] = [ + (.heartRate, []), (.heartRate, [0]), (.heartRate, [0xFF]), (.heartRate, [30]), (.heartRate, [201]), + (.spo2, []), (.spo2, [0]), (.spo2, [0xFF]), (.spo2, [69]), (.spo2, [101]), + ] + for (kind, payload) in cases { + var m = written(kind) + m.ingest(result(kind, payload)) + XCTAssertEqual(m.check(at: t0.addingTimeInterval(2)), .failed(.noReading), "\(kind) \(payload)") + } + } + + func testTheOtherKindsResultDoesNotDecide() { + var m = written(.spo2) + m.ingest(result(.heartRate, [72])) + XCTAssertNil(m.check(at: t0.addingTimeInterval(2))) + } + + // MARK: Wear state and link + + func testNotWornPushEndsTheMeasurement() { + var m = written(.heartRate) + m.ingest(CRPProtocol.frame(group: CRPCommands.groupPower, cmd: CRPCommands.cmdWearState, payload: [0])) + XCTAssertEqual(m.check(at: t0.addingTimeInterval(2)), .failed(.notWorn)) + + var bridged = written(.spo2) + bridged.noteWorn(false) + XCTAssertEqual(bridged.check(at: t0.addingTimeInterval(2)), .failed(.notWorn)) + } + + func testAWornPushDoesNothing() { + var m = written(.heartRate) + m.ingest(CRPProtocol.frame(group: CRPCommands.groupPower, cmd: CRPCommands.cmdWearState, payload: [1])) + m.noteWorn(true) + XCTAssertNil(m.check(at: t0.addingTimeInterval(2))) + } + + func testDisconnectFailsImmediately() { + var m = written(.spo2) + m.noteDisconnected() + XCTAssertEqual(m.check(at: t0.addingTimeInterval(2)), .failed(.disconnected)) + } + + func testTheFirstVerdictWins() { + var m = written(.heartRate) + m.ingest(result(.heartRate, [72])) + m.noteWorn(false) + m.noteDisconnected() + XCTAssertEqual(m.check(at: t0.addingTimeInterval(40)), .reading(72)) + } + + // MARK: Traffic that must not decide + + func testHistoryTrafficDoesNotCompleteASpotMeasurement() { + var m = written(.heartRate) + // Group 2 HR timeline frame (cmd 15) carrying plausible bpm values, and a sleep reply. + m.ingest(CRPProtocol.frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingHR, payload: [0, 0, 72, 74])) + m.ingest(CRPProtocol.frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistorySleep, payload: [0, 1, 1, 0])) + // A fragment that merely starts like a frame is not a whole result frame. + m.ingest(result(.heartRate, [72]).prefix(5)) + XCTAssertNil(m.check(at: t0.addingTimeInterval(10))) + } + + // MARK: Deadlines + + func testTheDeadlineCountsFromTheWriteNotTheQueue() { + var m = CRPSpotMeasurement(kind: .heartRate, queuedAt: t0) + m.noteStartWritten(true, at: t0.addingTimeInterval(10)) + XCTAssertNil(m.check(at: t0.addingTimeInterval(35)), "30 s from the queue, only 25 s from the write") + XCTAssertEqual(m.check(at: t0.addingTimeInterval(40)), .failed(.timedOut)) + } + + func testSpO2GetsItsLongerWindow() { + var m = written(.spo2) + XCTAssertNil(m.check(at: t0.addingTimeInterval(50)), "the R11 answers SpO2 ~48 s in") + XCTAssertEqual(m.check(at: t0.addingTimeInterval(61)), .failed(.timedOut)) + } + + func testAStartThatIsNeverWrittenFailsAfterTheDispatchTimeout() { + var m = CRPSpotMeasurement(kind: .spo2, queuedAt: t0) + XCTAssertNil(m.check(at: t0.addingTimeInterval(CRPSpotMeasurement.dispatchTimeout - 1))) + XCTAssertEqual(m.check(at: t0.addingTimeInterval(CRPSpotMeasurement.dispatchTimeout)), .failed(.notDispatched)) + } + + func testAFailedWriteFailsAtOnce() { + var m = CRPSpotMeasurement(kind: .heartRate, queuedAt: t0) + m.noteStartWritten(false, at: t0) + XCTAssertEqual(m.check(at: t0), .failed(.notDispatched)) + } + + // MARK: Engine frames + + private final class FakeWriter: RingCommandWriter { + nonisolated deinit {} + var sent: [Data] = [] + func enqueue(_ command: Data) { sent.append(command) } + } + + func testEngineWritesTheSpotStartTrackedAndTheStop() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + var writtenFlags: [Bool] = [] + engine.startSpot(.spo2) { writtenFlags.append($0) } + engine.stopSpot(.spo2) + engine.startSpot(.heartRate) { writtenFlags.append($0) } + XCTAssertEqual(w.sent, [CRPProtocol.measureSpO2(true), CRPProtocol.measureSpO2(false), CRPProtocol.measureHeartRate(true)]) + XCTAssertEqual(writtenFlags, [true, true]) + } +} From 7ef4c754e3af7f285053de3502c1f3f8e7065e20 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:12:13 -0500 Subject: [PATCH 12/20] Show each ring record of the night on the Sleep day view and let one be deleted for good --- PulseLoop/Events/PulseEventBus.swift | 66 +++--- PulseLoop/Models/PulseModels.swift | 50 ++++- PulseLoop/Persistence/DataArchive.swift | 30 +++ .../Persistence/DataArchiveService.swift | 14 +- .../Persistence/ModelContainerFactory.swift | 1 + PulseLoop/Persistence/SeedData.swift | 9 +- PulseLoop/Services/PulseServices.swift | 4 +- PulseLoop/Services/SleepRecordEditor.swift | 101 +++++++++ PulseLoop/Services/SleepRecords.swift | 107 +++++++++ PulseLoop/Views/SleepRecordsCard.swift | 101 +++++++++ PulseLoop/Views/SleepView.swift | 14 +- PulseLoopTests/DataArchiveTests.swift | 16 +- PulseLoopTests/SleepRecordDeletionTests.swift | 207 ++++++++++++++++++ 13 files changed, 672 insertions(+), 48 deletions(-) create mode 100644 PulseLoop/Services/SleepRecordEditor.swift create mode 100644 PulseLoop/Services/SleepRecords.swift create mode 100644 PulseLoop/Views/SleepRecordsCard.swift create mode 100644 PulseLoopTests/SleepRecordDeletionTests.swift diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index d9502629..fcbcb48e 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -232,7 +232,8 @@ final class EventPersistenceSubscriber { // a swallowed fetch on the destructive-import path. let sessions = try context.fetch(FetchDescriptor()) let blocks = try context.fetch(FetchDescriptor()) - persistSleepTimeline(start: timestamp, stages: stages, sessions: sessions, blocks: blocks) + let edits = try context.fetch(FetchDescriptor()) + persistSleepTimeline(start: timestamp, stages: stages, sessions: sessions, blocks: blocks, edits: edits) } else { applyPersist(event) } @@ -574,10 +575,24 @@ final class EventPersistenceSubscriber { /// recomputing session bounds. The ring streams ~20 timeline packets (15 samples each) per /// night, so blocks must accumulate into one session rather than spawning a session per /// packet. Mirrors `persistence._on_sleep_timeline`. + /// + /// Each packet is one ring record: its blocks are stamped with `start` as their + /// `recordStartAt` so the Day view can recover the records behind a merged night, and the + /// wearer's deletions (`SleepRecordEdit`) are consulted before anything is written. private func persistSleepTimeline(start: Date, stages: [SleepStage], - sessions: [SleepSession]? = nil, blocks: [SleepStageBlock]? = nil) { + sessions: [SleepSession]? = nil, blocks: [SleepStageBlock]? = nil, + edits: [SleepRecordEdit]? = nil) { let calendar = Calendar.current + // Deletion gate (Android #78/#80): a record the wearer deleted comes back with the same + // start and the same block starts on every re-send, so drop it here or the delete lasts + // exactly one sync. + let tombstones = SleepRecordTombstones(edits ?? ((try? context.fetch(FetchDescriptor())) ?? [])) + guard !tombstones.isRecordDeleted(start) else { return } + let recordBlocks = SleepRecords.stageBlocks(start: start, stages: stages) + .filter { !tombstones.isBlockDeleted($0.startAt) } + guard !recordBlocks.isEmpty else { return } + // Group packets by the waking-day boundary (sleep from 7 PM rolls to the next morning) so a // night that starts before midnight lands under the morning of waking. See // `Calendar.wakingDay(forSleepStart:)`. @@ -605,26 +620,25 @@ final class EventPersistenceSubscriber { if mergingRWfitSleep { // A recovered journal may extend or overlap an already-imported session. Normalize by // minute so a shorter previously saved block cannot suppress the rest of a replay. - var minuteStages: [Date: SleepStage] = [:] - for block in existingDayBlocks { + struct Minute: Equatable { let stage: SleepStage; let recordStartAt: Date? } + var minutes: [Date: Minute] = [:] + for block in existingDayBlocks + recordBlocks { for minute in 0.. Bool { func has(_ type: T.Type) -> Bool { @@ -296,6 +299,7 @@ enum DataArchiveService { } return has(Device.self) || has(ActivityDaily.self) || has(PulseLoop.Measurement.self) || has(BatterySample.self) || has(SleepSession.self) || has(SleepStageBlock.self) + || has(SleepRecordEdit.self) || has(RawPacketRow.self) || has(DerivedUpdateRow.self) || has(UserProfile.self) || has(UserGoal.self) || has(DeviceMeasurementConfig.self) || has(ActivitySession.self) || has(ActivitySample.self) || has(ActivityBucketSample.self) || has(ActivityGpsPoint.self) @@ -304,7 +308,7 @@ enum DataArchiveService { || has(CoachNotificationRecord.self) || has(CoachSummary.self) || has(WearableLog.self) } - /// Deletes every row of every model in the schema — all 24 types, unlike `SeedData.clearAll` + /// Deletes every row of every model in the schema — all 25 types, unlike `SeedData.clearAll` /// (which predates six of them). Tracked deletes, no save, and deliberately synchronous — see /// the atomicity note in `importArchive`. static func wipeAllData(context: ModelContext) throws { @@ -314,6 +318,7 @@ enum DataArchiveService { try deleteAll(BatterySample.self, context) try deleteAll(SleepSession.self, context) try deleteAll(SleepStageBlock.self, context) + try deleteAll(SleepRecordEdit.self, context) try deleteAll(RawPacketRow.self, context) try deleteAll(DerivedUpdateRow.self, context) try deleteAll(UserProfile.self, context) @@ -347,6 +352,7 @@ enum DataArchiveService { insert(archive.batterySamples, context) insert(archive.sleepSessions, context) insert(archive.sleepStageBlocks, context) + insert(archive.sleepRecordEdits ?? [], context) insert(archive.rawPackets, context) insert(archive.derivedUpdates, context) insert(archive.userProfiles, context) @@ -382,6 +388,7 @@ enum DataArchiveService { try requireUnique(archive.batterySamples.map(\.id), entity: "battery sample") try requireUnique(archive.sleepSessions.map(\.id), entity: "sleep session") try requireUnique(archive.sleepStageBlocks.map(\.id), entity: "sleep stage") + try requireUnique((archive.sleepRecordEdits ?? []).map(\.id), entity: "sleep record edit") try requireUnique(archive.rawPackets.map(\.id), entity: "raw packet") try requireUnique(archive.derivedUpdates.map(\.id), entity: "derived update") try requireUnique(archive.userProfiles.map(\.id), entity: "profile") @@ -473,7 +480,7 @@ enum DataArchiveService { } } -/// Shared shape of the 24 DTOs' model-restoring side, so `insertAll` can chunk generically. +/// Shared shape of the 25 DTOs' model-restoring side, so `insertAll` can chunk generically. @MainActor protocol ArchiveInsertable { func insert(into context: ModelContext) @@ -485,6 +492,7 @@ extension ArchiveMeasurement: ArchiveInsertable {} extension ArchiveBatterySample: ArchiveInsertable {} extension ArchiveSleepSession: ArchiveInsertable {} extension ArchiveSleepStageBlock: ArchiveInsertable {} +extension ArchiveSleepRecordEdit: ArchiveInsertable {} extension ArchiveRawPacket: ArchiveInsertable {} extension ArchiveDerivedUpdate: ArchiveInsertable {} extension ArchiveUserProfile: ArchiveInsertable {} diff --git a/PulseLoop/Persistence/ModelContainerFactory.swift b/PulseLoop/Persistence/ModelContainerFactory.swift index 6eef7a21..2f74e811 100644 --- a/PulseLoop/Persistence/ModelContainerFactory.swift +++ b/PulseLoop/Persistence/ModelContainerFactory.swift @@ -9,6 +9,7 @@ enum ModelContainerFactory { BatterySample.self, SleepSession.self, SleepStageBlock.self, + SleepRecordEdit.self, RawPacketRow.self, DerivedUpdateRow.self, UserProfile.self, diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index 7de63570..074040f8 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -98,8 +98,12 @@ enum SeedData { let score = SleepScore.calculate(summary) let session = SleepSession(date: dayDate, startAt: startAt, endAt: wake, totalMinutes: totalMin, score: score.score, syncedAt: wake) context.insert(session) + // Stamp each block with its ring record. Last night arrives as two records (the ring + // reopened one after the first waking) so the Day view's RECORDS card has rows to show. + let secondRecordStart = i == 0 && blocks.count > 5 ? blocks[5].startAt : nil for block in blocks { - context.insert(SleepStageBlock(sessionId: session.id, startAt: block.startAt, startMinute: block.startMinute, durationMinutes: block.durationMinutes, stage: block.stage)) + let recordStart = secondRecordStart.map { block.startAt >= $0 ? $0 : startAt } ?? startAt + context.insert(SleepStageBlock(sessionId: session.id, startAt: block.startAt, startMinute: block.startMinute, durationMinutes: block.durationMinutes, stage: block.stage, recordStartAt: recordStart)) } // A few recent days also get daytime nap(s) — separate sessions sharing the same waking @@ -119,7 +123,7 @@ enum SeedData { let napSession = SleepSession(date: dayDate, startAt: napStart, endAt: napEnd, totalMinutes: nap.minutes, score: napScore.score, syncedAt: napEnd) context.insert(napSession) for block in napBlocks { - context.insert(SleepStageBlock(sessionId: napSession.id, startAt: block.startAt, startMinute: block.startMinute, durationMinutes: block.durationMinutes, stage: block.stage)) + context.insert(SleepStageBlock(sessionId: napSession.id, startAt: block.startAt, startMinute: block.startMinute, durationMinutes: block.durationMinutes, stage: block.stage, recordStartAt: napStart)) } } } @@ -334,6 +338,7 @@ enum SeedData { deleteAll(Measurement.self, context) deleteAll(SleepSession.self, context) deleteAll(SleepStageBlock.self, context) + deleteAll(SleepRecordEdit.self, context) deleteAll(RawPacketRow.self, context) deleteAll(DerivedUpdateRow.self, context) deleteAll(UserProfile.self, context) diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index b87b3db8..bebbfaa5 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -688,7 +688,9 @@ enum SleepService { let blocks = SleepRepository.blocks(sessionId: session.id, context: context) let light = blocks.filter { $0.stage == .light }.reduce(0) { $0 + $1.durationMinutes } let deep = blocks.filter { $0.stage == .deep }.reduce(0) { $0 + $1.durationMinutes } - let awake = blocks.filter { $0.stage == .awake }.reduce(0) { $0 + $1.durationMinutes } + // Awake counts the gaps between the night's ring records too (Android #81) — the same + // minutes the Day view's RECORDS card labels "Awake Xm between". + let awake = SleepRecords.awakeMinutes(blocks) return SleepSummary( session: session, lightMinutes: light, diff --git a/PulseLoop/Services/SleepRecordEditor.swift b/PulseLoop/Services/SleepRecordEditor.swift new file mode 100644 index 00000000..4f7576a5 --- /dev/null +++ b/PulseLoop/Services/SleepRecordEditor.swift @@ -0,0 +1,101 @@ +import Foundation +import SwiftData + +/// The tombstones the sleep import consults, read once per timeline packet (Android #78/#80). +/// A re-sent record reproduces its declared start and its blocks' starts exactly, so those are the +/// keys: a deleted record is dropped whole, and any block of a deleted night that re-sends is +/// skipped even when the record's own start was only a guess (nights stored before stamping). +struct SleepRecordTombstones { + private let deletedRecords: Set + private let deletedBlocks: Set + + init(_ edits: [SleepRecordEdit]) { + var records = Set(), blocks = Set() + for edit in edits { + switch edit.kind { + case .recordDeleted: records.insert(SleepRecords.key(edit.recordStartAt)) + case .blockDeleted: blocks.insert(SleepRecords.key(edit.recordStartAt)) + case nil: break + } + } + deletedRecords = records + deletedBlocks = blocks + } + + func isRecordDeleted(_ recordStart: Date) -> Bool { deletedRecords.contains(SleepRecords.key(recordStart)) } + func isBlockDeleted(_ blockStart: Date) -> Bool { deletedBlocks.contains(SleepRecords.key(blockStart)) } +} + +/// The wearer's edits to one ring sleep record of a stored night (Android `SleepRecordDeletion`). +/// +/// Three rules, all of which live here rather than in a caller: +/// - **Remember the record, not the row.** The import re-derives the waking day from the stages +/// the ring re-sends, so deleting stored blocks alone would last one sync. A `SleepRecordEdit` +/// tombstone is written first and `persistSleepTimeline` consults it. +/// - **Re-derive, never hand-patch.** The day is re-segmented and every session's bounds recomputed +/// by `SleepService.reconcileWakingDay`, the same function the next sync runs, so what the UI +/// shows after an edit is what that sync agrees with. Nothing left means the row goes. +/// - **The merge reseals itself.** Records are recovered from block stamps on every read, so the +/// RECORDS card shows the shorter list with no "these were one record" state to maintain. +@MainActor +enum SleepRecordEditor { + /// Delete the ring record `recordStartAt` from session `sessionId`, remember it, and restate the + /// day from what remains. Returns true when a record was removed. + @discardableResult + static func delete(sessionId: UUID, recordStartAt: Date, context: ModelContext) -> Bool { + guard let session = session(sessionId, context: context) else { return false } + let blocks = SleepRepository.blocks(sessionId: sessionId, context: context) + guard let run = SleepRecords.runs(blocks).first(where: { + SleepRecords.key($0.recordStartAt) == SleepRecords.key(recordStartAt) + }) else { return false } + + let day = session.date + var known = Set(((try? context.fetch(FetchDescriptor())) ?? []) + .map { "\($0.kindRaw):\(SleepRecords.key($0.recordStartAt))" }) + func remember(_ kind: SleepRecordEditKind, _ at: Date) { + // One tombstone per key, so deleting the same record twice never grows the table. + guard known.insert("\(kind.rawValue):\(SleepRecords.key(at))").inserted else { return } + context.insert(SleepRecordEdit(wakingDay: day, recordStartAt: at, kind: kind)) + } + remember(.recordDeleted, run.recordStartAt) + for block in run.blocks { remember(.blockDeleted, block.startAt) } + + let removed = Set(run.blocks.map(\.id)) + for block in run.blocks { context.delete(block) } + restate(day: day, removing: removed, context: context) + return true + } + + /// Re-derive waking day `day` from its blocks less `removing`, then stamp the surviving rows as + /// changed (a deleted middle record leaves the bounds where they were, which reconcile alone + /// would treat as no change) and recompute any stored score. Saves and signals the UI. + static func restate(day: Date, removing: Set, context: ModelContext) { + let calendar = Calendar.current + let daySessions = ((try? context.fetch(FetchDescriptor())) ?? []) + .filter { calendar.isDate($0.date, inSameDayAs: day) } + let ids = Set(daySessions.map(\.id)) + let dayBlocks = ((try? context.fetch(FetchDescriptor())) ?? []) + .filter { ids.contains($0.sessionId) && !removing.contains($0.id) } + SleepService.reconcileWakingDay(dateKey: day, context: context, + daySessions: daySessions, dayBlocks: dayBlocks) + try? context.save() + + let now = Date() + let survivors = ((try? context.fetch(FetchDescriptor())) ?? []) + .filter { calendar.isDate($0.date, inSameDayAs: day) } + for row in survivors { + row.updatedAt = now + row.syncedAt = now + if row.score != nil { + row.score = SleepScore.calculate(SleepService.summary(for: row, context: context)).score + } + context.insert(DerivedUpdateRow(kind: "sleep_record_edit", entityType: "sleep_session", entityId: row.id.uuidString)) + } + try? context.save() + PulseDataChange.shared.notify() + } + + private static func session(_ id: UUID, context: ModelContext) -> SleepSession? { + try? context.fetch(FetchDescriptor(predicate: #Predicate { $0.id == id })).first + } +} diff --git a/PulseLoop/Services/SleepRecords.swift b/PulseLoop/Services/SleepRecords.swift new file mode 100644 index 00000000..08a4e48c --- /dev/null +++ b/PulseLoop/Services/SleepRecords.swift @@ -0,0 +1,107 @@ +import Foundation + +/// One ring sleep record inside a stored session (Android #68). +/// +/// A ring closes a record when the wearer gets up and opens a new one when they settle, so one +/// night can arrive as two or three records minutes apart. The night is merged into one stored +/// session (that is the night), but the individual records are real information the merge hides — +/// the Sleep day view lists them in its RECORDS card and lets the wearer delete one. +struct SleepRecordRun { + let startAt: Date + let endAt: Date + /// The run's blocks, sorted by `startAt`. + let blocks: [SleepStageBlock] + + /// The record's declared start: the stamp its blocks carry, else (a night stored before records + /// were stamped) the run's own first minute. This is the identity delete and edit key on. + var recordStartAt: Date { blocks.first { $0.recordStartAt != nil }?.recordStartAt ?? startAt } + var asleepMinutes: Int { SleepRecords.asleepMinutes(blocks) } +} + +/// Pure helpers over a session's stage blocks: splitting them into ring records and counting the +/// waking between records. Shared by the Day view, `SleepService.summary` and the record editor so +/// the Awake figure and the RECORDS card can never disagree about the same gap (Android #81). +enum SleepRecords { + /// Minutes asleep across `blocks`: every stage except awake. + static func asleepMinutes(_ blocks: [SleepStageBlock]) -> Int { + max(0, blocks.filter { $0.stage != .awake }.reduce(0) { $0 + $1.durationMinutes }) + } + + /// Split `blocks` into the ring records they came from. + /// + /// The boundary is read, not inferred: each block carries its record's declared start + /// (`recordStartAt`), so records are grouped by that. The gap between records is no substitute + /// — some firmware reopens a record a single minute after closing one, which is the same width + /// as the minute-grid seam inside one record. `minGapMinutes` is only the fallback for nights + /// stored before the stamp existed (all-or-nothing: a part-stamped night has no consistent + /// boundary to read). One run for an unsplit night; empty in, empty out. + static func runs(_ blocks: [SleepStageBlock], minGapMinutes: Int = 2) -> [SleepRecordRun] { + let ordered = blocks.sorted { $0.startAt < $1.startAt } + guard let first = ordered.first else { return [] } + + func run(_ group: [SleepStageBlock]) -> SleepRecordRun { + SleepRecordRun(startAt: group[0].startAt, endAt: group.map(\.endAt).max() ?? group[0].startAt, blocks: group) + } + + if ordered.allSatisfy({ $0.recordStartAt != nil }) { + let grouped = Dictionary(grouping: ordered) { key($0.recordStartAt!) } + return grouped.keys.sorted().map { run(grouped[$0]!) } + } + + var runs: [SleepRecordRun] = [] + var current = [first] + var currentEnd = first.endAt + for block in ordered.dropFirst() { + if block.startAt.timeIntervalSince(currentEnd) >= Double(minGapMinutes) * 60 { + runs.append(run(current)) + current = [block] + } else { + current.append(block) + } + currentEnd = max(currentEnd, block.endAt) + } + runs.append(run(current)) + return runs + } + + /// The waking between two consecutive records of one session, in minutes — 0 for the one-minute + /// reopen seam. The one rule the Awake figure and the RECORDS card's "Awake Xm between" line + /// both read (Android #81). + static func betweenRecordAwakeMinutes(_ earlier: SleepRecordRun, _ later: SleepRecordRun) -> Int { + let gapMinutes = Int(later.startAt.timeIntervalSince(earlier.endAt) / 60) + return gapMinutes >= 2 ? gapMinutes : 0 + } + + /// Awake minutes for a session: the stages the ring marked awake plus the gaps between its + /// records. A ring reports a brief night-waking either as an awake stage or by closing the record + /// and opening a new one; both are real not-asleep minutes. + static func awakeMinutes(_ blocks: [SleepStageBlock]) -> Int { + let staged = blocks.filter { $0.stage == .awake }.reduce(0) { $0 + $1.durationMinutes } + let runs = runs(blocks) + let between = zip(runs, runs.dropFirst()).reduce(0) { $0 + betweenRecordAwakeMinutes($1.0, $1.1) } + return staged + between + } + + /// One ring record's per-minute stages run-length encoded into blocks, each stamped with the + /// record's declared start. Unattached (placeholder `sessionId`): the caller decides which to + /// insert and re-keys them to the session they land in. + static func stageBlocks(start: Date, stages: [SleepStage], recordStartAt: Date? = nil) -> [SleepStageBlock] { + let placeholder = UUID() + var blocks: [SleepStageBlock] = [] + var offset = 0 + while offset < stages.count { + let stage = stages[offset] + var duration = 1 + while offset + duration < stages.count, stages[offset + duration] == stage { duration += 1 } + blocks.append(SleepStageBlock(sessionId: placeholder, startAt: start.addingTimeInterval(Double(offset) * 60), + startMinute: offset, durationMinutes: duration, stage: stage, + recordStartAt: recordStartAt ?? start)) + offset += duration + } + return blocks + } + + /// Whole-second identity for an instant, so a record or block re-sent by the ring matches its + /// stored twin regardless of how the store round-trips the `Date`. + static func key(_ date: Date) -> Int64 { Int64(date.timeIntervalSince1970.rounded()) } +} diff --git a/PulseLoop/Views/SleepRecordsCard.swift b/PulseLoop/Views/SleepRecordsCard.swift new file mode 100644 index 00000000..544ad4c9 --- /dev/null +++ b/PulseLoop/Views/SleepRecordsCard.swift @@ -0,0 +1,101 @@ +import SwiftUI +import SwiftData + +/// The ring's own sleep records behind a merged night (Android #68/#71, delete from #78/#80). +/// +/// A ring closes a record when the wearer gets up and opens a new one when they settle; merging +/// those is right — the headline stays the merged night — but the records are real information, +/// so they're listed underneath. Shown for single-record nights too, so any record can be managed. +/// Deleting one is the escape hatch for a ring that opened a record on a still wrist (an evening on +/// the sofa): the wearer knows which run is wrong, the sensor cannot. +struct SleepRecordsCard: View { + @Environment(\.modelContext) private var modelContext + let session: SleepSession + let runs: [SleepRecordRun] + + /// The run whose trash button armed the confirmation. + @State private var pendingDelete: SleepRecordRun? + @State private var failureMessage: String? + + var body: some View { + SleepVisualizationCard( + eyebrow: "Records", + // One run happens on a carousel page too, which may be a nap rather than a night. + title: runs.count == 1 ? "The ring recorded this as one record" + : "The ring recorded this night in \(runs.count) parts", + legend: false + ) { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(runs.enumerated()), id: \.offset) { index, run in + row(index: index, run: run) + let gap = index < runs.count - 1 ? SleepRecords.betweenRecordAwakeMinutes(run, runs[index + 1]) : 0 + if gap > 0 { + Text("Awake \(SleepFormat.duration(gap)) between") + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + .padding(.leading, 22) + } + } + } + } + .alert("Delete this record?", isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), presenting: pendingDelete) { run in + Button("Delete", role: .destructive) { delete(run) } + Button("Keep", role: .cancel) {} + } message: { run in + Text("\(Self.range(run)) leaves tonight's totals and the hypnogram, and it won't come back on re-sync.") + } + .alert(failureMessage ?? "", isPresented: Binding( + get: { failureMessage != nil }, + set: { if !$0 { failureMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } + } + + @ViewBuilder + private func row(index: Int, run: SleepRecordRun) -> some View { + HStack(alignment: .center, spacing: 4) { + Text("\(index + 1)") + .font(PulseFont.caption2) + .foregroundStyle(PulseColors.textMuted) + .frame(width: 18, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(Self.range(run)) + .font(PulseFont.subheadline) + .foregroundStyle(PulseColors.textPrimary) + Text("Asleep \(SleepFormat.duration(run.asleepMinutes))") + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + } + Spacer(minLength: 4) + iconButton("trash", label: "Delete this record") { pendingDelete = run } + } + .accessibilityElement(children: .contain) + } + + private func iconButton(_ systemName: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(PulseColors.textMuted) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } + + private func delete(_ run: SleepRecordRun) { + pendingDelete = nil + if !SleepRecordEditor.delete(sessionId: session.id, recordStartAt: run.recordStartAt, context: modelContext) { + failureMessage = "Couldn't delete this record" + } + } + + static func range(_ run: SleepRecordRun) -> String { + "\(SleepFormat.clockTime(run.startAt)) – \(SleepFormat.clockTime(run.endAt))" + } +} diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index d92ddcb5..4eb6a282 100644 --- a/PulseLoop/Views/SleepView.swift +++ b/PulseLoop/Views/SleepView.swift @@ -113,7 +113,7 @@ struct SleepView: View { // The "wear your ring" explainer lives in the architecture card ONLY — the hero and // stage cards just dash out, so the message isn't repeated all over one page. SleepHeroCardView(label: SleepInsights.rangeHeroLabel[.day] ?? "Last Sleep", value: "—", score: nil) - VisualizationCard(eyebrow: "Stages", title: "Sleep architecture", legend: false) { + SleepVisualizationCard(eyebrow: "Stages", title: "Sleep architecture", legend: false) { InlineEmptyState(title: "No sleep recorded", message: "Wear your ring overnight to see your hypnogram here.") .frame(height: 180) } @@ -147,10 +147,16 @@ struct SleepView: View { score: score.score, scoreLabel: score.label.rawValue ) - VisualizationCard(eyebrow: "Stages", title: "Sleep architecture", legend: true) { + SleepVisualizationCard(eyebrow: "Stages", title: "Sleep architecture", legend: true) { SleepHypnogramView(blocks: s.blocks, totalMin: s.session.totalMinutes, startTs: s.session.startAt, onScrubActiveChanged: { hypnogramScrubbing = $0 }) } + // The ring records behind the merged night, single-record nights included, so any record + // can be managed (Android #68/#71/#80). + let runs = SleepRecords.runs(s.blocks) + if !runs.isEmpty { + SleepRecordsCard(session: s.session, runs: runs) + } SleepStageSummaryCardsView( deep: SleepFormat.duration(s.deepMinutes), light: SleepFormat.duration(s.lightMinutes), @@ -437,7 +443,7 @@ struct SleepView: View { scoreLabel: enough ? avgScore.map { SleepScore.qualityLabel($0).rawValue } : nil, noData: !enough ) - VisualizationCard(eyebrow: "Duration", title: vizTitle, legend: false) { + SleepVisualizationCard(eyebrow: "Duration", title: vizTitle, legend: false) { SleepDurationHistogramChart(bars: bars, goalMin: goalMin, slim: range == .month, barWidth: range == .week ? 30 : nil, weekBars: range == .week) } SleepStageSummaryCardsView( @@ -451,7 +457,7 @@ struct SleepView: View { } /// Card wrapper for sleep visualizations (eyebrow + title + optional stage legend). -private struct VisualizationCard: View { +struct SleepVisualizationCard: View { let eyebrow: String let title: String let legend: Bool diff --git a/PulseLoopTests/DataArchiveTests.swift b/PulseLoopTests/DataArchiveTests.swift index a9b7ad31..516881c5 100644 --- a/PulseLoopTests/DataArchiveTests.swift +++ b/PulseLoopTests/DataArchiveTests.swift @@ -2,7 +2,7 @@ import XCTest import SwiftData @testable import PulseLoop -/// Locks down the full-app export/import archive: a complete round trip over all 24 models, +/// Locks down the full-app export/import archive: a complete round trip over all 25 models, /// wipe completeness, version/corruption rejection (without data loss), and the settings + /// attachment side channels. Hermetic — in-memory SwiftData, suite-scoped UserDefaults, temp dirs. @MainActor @@ -14,7 +14,7 @@ final class DataArchiveTests: XCTestCase { (try? context.fetchCount(FetchDescriptor())) ?? -1 } - /// One row of every model type `SeedData.seedDemo` does NOT create, so seed + these = all 24. + /// One row of every model type `SeedData.seedDemo` does NOT create, so seed + these = all 25. private func insertModelsMissingFromSeed(_ context: ModelContext, deviceId: UUID) { context.insert(BatterySample(percent: 57, timestamp: Date(timeIntervalSince1970: 1_750_000_000))) context.insert(DeviceMeasurementConfig(deviceId: deviceId)) @@ -34,6 +34,8 @@ final class DataArchiveTests: XCTestCase { context.insert(CoachNotificationRecord(slotRaw: "morning", dateKey: "2026-07-25", title: "Hi", body: "Check in")) context.insert(CoachSummary(kind: "today", scopeKey: "2026-07-25", title: "Today", body: "Solid", dataSignature: "sig1")) context.insert(WearableLog(category: .sync, level: .info, message: "sync done", metadataJSON: #"{"n":1}"#)) + context.insert(SleepRecordEdit(wakingDay: Date(timeIntervalSince1970: 1_750_000_000), + recordStartAt: Date(timeIntervalSince1970: 1_749_990_000), kind: .blockDeleted)) try? context.save() } @@ -43,7 +45,7 @@ final class DataArchiveTests: XCTestCase { XCTAssertGreaterThan(count(type, b), 0, "\(T.self) should have rows in this test", file: file, line: line) } check(Device.self); check(ActivityDaily.self); check(PulseLoop.Measurement.self) - check(BatterySample.self); check(SleepSession.self); check(SleepStageBlock.self) + check(BatterySample.self); check(SleepSession.self); check(SleepStageBlock.self); check(SleepRecordEdit.self) check(RawPacketRow.self); check(DerivedUpdateRow.self); check(UserProfile.self) check(UserGoal.self); check(DeviceMeasurementConfig.self); check(ActivitySession.self) check(ActivitySample.self); check(ActivityBucketSample.self); check(ActivityGpsPoint.self) @@ -116,6 +118,13 @@ final class DataArchiveTests: XCTestCase { XCTAssertEqual(importedBucket.startEpoch, refBucketEpoch) XCTAssertNotEqual(importedBucket.startEpoch, Int(importedBucket.timestamp.timeIntervalSince1970)) + // Sleep records survive: each block keeps its ring record, and the tombstone keeps its kind. + let sourceStamps = Set(try source.fetch(FetchDescriptor()).compactMap(\.recordStartAt)) + let importedStamps = Set(try target.fetch(FetchDescriptor()).compactMap(\.recordStartAt)) + XCTAssertFalse(sourceStamps.isEmpty) + XCTAssertEqual(importedStamps.count, sourceStamps.count) + XCTAssertEqual(try target.fetch(FetchDescriptor()).first?.kind, .blockDeleted) + // FK integrity: every child row still points at a parent that exists. let sessionIDs = Set(try target.fetch(FetchDescriptor()).map(\.id)) for block in try target.fetch(FetchDescriptor()) { @@ -143,6 +152,7 @@ final class DataArchiveTests: XCTestCase { } assertEmpty(Device.self); assertEmpty(ActivityDaily.self); assertEmpty(PulseLoop.Measurement.self) assertEmpty(BatterySample.self); assertEmpty(SleepSession.self); assertEmpty(SleepStageBlock.self) + assertEmpty(SleepRecordEdit.self) assertEmpty(RawPacketRow.self); assertEmpty(DerivedUpdateRow.self); assertEmpty(UserProfile.self) assertEmpty(UserGoal.self); assertEmpty(DeviceMeasurementConfig.self); assertEmpty(ActivitySession.self) assertEmpty(ActivitySample.self); assertEmpty(ActivityBucketSample.self); assertEmpty(ActivityGpsPoint.self) diff --git a/PulseLoopTests/SleepRecordDeletionTests.swift b/PulseLoopTests/SleepRecordDeletionTests.swift new file mode 100644 index 00000000..5ff14128 --- /dev/null +++ b/PulseLoopTests/SleepRecordDeletionTests.swift @@ -0,0 +1,207 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Deleting one ring sleep record from a night (Android #71/#78/#80, `SleepRecordDeletionTest`): +/// how records are recovered from stored blocks, the tombstone the import consults so a delete +/// survives re-sync, and the restate of what remains. +@MainActor +final class SleepRecordDeletionTests: XCTestCase { + private let calendar = Calendar.current + + /// Local wall-clock time on the morning of Oct 8 2026 (negative hours reach the evening before). + private func at(_ hour: Int, _ minute: Int, _ second: Int = 0) -> Date { + let day = calendar.date(from: DateComponents(year: 2026, month: 10, day: 8))! + return day.addingTimeInterval(TimeInterval((hour * 60 + minute) * 60 + second)) + } + + private func persist(_ subscriber: EventPersistenceSubscriber, _ start: Date, _ stages: [SleepStage]) { + subscriber.persist(.sleepTimeline(timestamp: start, stages: stages)) + } + + private func light(_ n: Int) -> [SleepStage] { Array(repeating: .light, count: n) } + + private func sessions(_ context: ModelContext) -> [SleepSession] { + SleepRepository.sessions(context: context).sorted { $0.startAt < $1.startAt } + } + + private func edits(_ context: ModelContext) -> [SleepRecordEdit] { + (try? context.fetch(FetchDescriptor())) ?? [] + } + + /// The reported night, three records: 23:08–00:05 (the sofa), 00:25–03:19, 03:26–07:08. + private func seedReportedNight(_ subscriber: EventPersistenceSubscriber) { + persist(subscriber, at(-1, 8), light(57)) + persist(subscriber, at(0, 25), light(174)) + persist(subscriber, at(3, 26), light(222)) + } + + // MARK: - Records recovered from stored blocks + + func testImportStampsEveryBlockWithItsRecordAndTheCardSplitsOnIt() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + seedReportedNight(subscriber) + + let night = try XCTUnwrap(sessions(context).first) + XCTAssertEqual(sessions(context).count, 1, "gaps under an hour keep the night one session") + let blocks = SleepRepository.blocks(sessionId: night.id, context: context) + XCTAssertTrue(blocks.allSatisfy { $0.recordStartAt != nil }) + let runs = SleepRecords.runs(blocks) + XCTAssertEqual(runs.map(\.recordStartAt), [at(-1, 8), at(0, 25), at(3, 26)]) + XCTAssertEqual(runs.map(\.asleepMinutes), [57, 174, 222]) + } + + /// A record reopened one minute after the last closed is still its own record: the stamp, not + /// the gap, decides — and the back-to-back packets of a Jring night are each a record too. + func testRunsFollowTheStampNotTheGap() { + let a = SleepRecords.stageBlocks(start: at(2, 0), stages: light(15)) + let b = SleepRecords.stageBlocks(start: at(2, 15), stages: light(15)) + XCTAssertEqual(SleepRecords.runs(a + b).count, 2) + XCTAssertEqual(SleepRecords.betweenRecordAwakeMinutes(SleepRecords.runs(a + b)[0], SleepRecords.runs(a + b)[1]), 0) + } + + /// Nights stored before records were stamped fall back to splitting on a 2-minute gap. + func testUnstampedNightsSplitOnTheGap() { + let a = SleepRecords.stageBlocks(start: at(0, 0), stages: light(60)) + let b = SleepRecords.stageBlocks(start: at(1, 1), stages: light(30)) + let c = SleepRecords.stageBlocks(start: at(1, 40), stages: light(30)) + for block in a + b + c { block.recordStartAt = nil } + let runs = SleepRecords.runs(a + b + c) + XCTAssertEqual(runs.count, 2, "the one-minute seam joins, the 9-minute gap splits") + XCTAssertEqual(runs[1].recordStartAt, at(1, 40), "an unstamped run is keyed by its first minute") + } + + /// The Awake figure counts the waking between records, the same minutes the card labels. + func testAwakeIncludesTheGapsBetweenRecords() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + seedReportedNight(subscriber) + let night = try XCTUnwrap(sessions(context).first) + let summary = SleepService.summary(for: night, context: context) + XCTAssertEqual(summary.awakeMinutes, 20 + 7) + } + + // MARK: - Delete and re-sync + + func testDeletingTheSofaRecordRestatesTheNightOnTheSameRow() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + seedReportedNight(subscriber) + let night = try XCTUnwrap(sessions(context).first) + + XCTAssertTrue(SleepRecordEditor.delete(sessionId: night.id, recordStartAt: at(-1, 8), context: context)) + + let after = sessions(context) + XCTAssertEqual(after.count, 1) + XCTAssertEqual(after[0].id, night.id, "the Day view stays on the same row") + XCTAssertEqual(after[0].startAt, at(0, 25)) + XCTAssertEqual(after[0].endAt, at(7, 8)) + XCTAssertEqual(SleepRecords.runs(SleepRepository.blocks(sessionId: night.id, context: context)).count, 2) + } + + /// The contract the feature hangs on: the next sync re-sends the record, and it stays gone — + /// while its neighbours, re-sent in the same sync, are untouched. + func testADeletedRecordDoesNotComeBackOnReSync() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + seedReportedNight(subscriber) + let night = try XCTUnwrap(sessions(context).first) + SleepRecordEditor.delete(sessionId: night.id, recordStartAt: at(-1, 8), context: context) + + seedReportedNight(subscriber) + subscriber.flush() + + let after = sessions(context) + XCTAssertEqual(after.count, 1) + XCTAssertEqual(after[0].startAt, at(0, 25), "the deleted sofa record must not be re-imported") + let runs = SleepRecords.runs(SleepRepository.blocks(sessionId: after[0].id, context: context)) + XCTAssertEqual(runs.map(\.recordStartAt), [at(0, 25), at(3, 26)]) + } + + /// A night stored before stamping: the record's start is a guess, but every block re-sends with + /// the same start, so the block tombstones hold the delete. + func testAnUnstampedRecordStaysDeletedThroughItsBlocks() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + persist(subscriber, at(0, 0), light(30) + Array(repeating: .deep, count: 30)) + persist(subscriber, at(1, 10), light(60)) + subscriber.flush() + for block in (try context.fetch(FetchDescriptor())) { block.recordStartAt = nil } + try context.save() + let night = try XCTUnwrap(sessions(context).first) + + XCTAssertTrue(SleepRecordEditor.delete(sessionId: night.id, recordStartAt: at(0, 0), context: context)) + XCTAssertEqual(edits(context).filter { $0.kind == .blockDeleted }.count, 2) + + persist(subscriber, at(0, 0), light(30) + Array(repeating: .deep, count: 30)) + subscriber.flush() + XCTAssertEqual(sessions(context).first?.startAt, at(1, 10)) + } + + /// Deleting the middle record leaves a 3 h 21 m hole: the restate splits there exactly as the + /// next sync would, so the hole never reads as hours awake. + func testDeletingAMiddleRecordPastTheSessionGapSplitsTheNight() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + seedReportedNight(subscriber) + let night = try XCTUnwrap(sessions(context).first) + + SleepRecordEditor.delete(sessionId: night.id, recordStartAt: at(0, 25), context: context) + + let after = sessions(context) + XCTAssertEqual(after.count, 2) + XCTAssertTrue(after.contains { $0.id == night.id }, "one segment keeps the row") + for row in after { + XCTAssertEqual(SleepService.summary(for: row, context: context).awakeMinutes, 0) + } + } + + func testDeletingEveryRecordLeavesTheDayUnslept() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + persist(subscriber, at(0, 0), light(90)) + let night = try XCTUnwrap(sessions(context).first) + + XCTAssertTrue(SleepRecordEditor.delete(sessionId: night.id, recordStartAt: at(0, 0), context: context)) + XCTAssertTrue(sessions(context).isEmpty) + XCTAssertTrue((try context.fetch(FetchDescriptor())).isEmpty) + } + + /// One tombstone per key: the table doesn't grow when the same night is deleted from again. + func testTombstonesAreNotDuplicated() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + seedReportedNight(subscriber) + let night = try XCTUnwrap(sessions(context).first) + SleepRecordEditor.delete(sessionId: night.id, recordStartAt: at(-1, 8), context: context) + let count = edits(context).count + XCTAssertFalse(SleepRecordEditor.delete(sessionId: night.id, recordStartAt: at(-1, 8), context: context), + "the record is already gone") + XCTAssertEqual(edits(context).count, count) + } + + /// The same clock minute on a different night is different data, not a resurrected record. + func testATombstoneDoesNotLeakToAnotherNight() { + let edit = SleepRecordEdit(wakingDay: at(0, 0), recordStartAt: at(-1, 8), kind: .recordDeleted) + let tombstones = SleepRecordTombstones([edit]) + XCTAssertTrue(tombstones.isRecordDeleted(at(-1, 8))) + XCTAssertFalse(tombstones.isRecordDeleted(at(-1, 8).addingTimeInterval(86_400))) + XCTAssertFalse(tombstones.isBlockDeleted(at(-1, 8)), "a record tombstone is not a block tombstone") + } + + func testDeletingAStoredScoreRecomputesIt() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + persist(subscriber, at(-1, 0), light(60)) + persist(subscriber, at(0, 5), Array(repeating: .deep, count: 120) + light(240)) + let night = try XCTUnwrap(sessions(context).first) + night.score = 1 + try context.save() + + SleepRecordEditor.delete(sessionId: night.id, recordStartAt: at(-1, 0), context: context) + let expected = SleepScore.calculate(SleepService.summary(for: night, context: context)).score + XCTAssertEqual(night.score, expected) + XCTAssertNotEqual(night.score, 1) + } +} From 9e781229743635b1a0c39b4d0b463065b1259a4f Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:21:28 -0500 Subject: [PATCH 13/20] Let a sleep record's start and end be edited, including extending it over time the ring missed --- PulseLoop/DesignSystem/Charts.swift | 49 ++- PulseLoop/Events/PulseEventBus.swift | 49 ++- PulseLoop/Health/HealthKitTypeMappings.swift | 5 +- PulseLoop/Models/PulseModels.swift | 14 +- PulseLoop/Persistence/DataArchive.swift | 7 +- PulseLoop/Services/SleepInsights.swift | 5 +- PulseLoop/Services/SleepRecordEditor.swift | 81 +++- PulseLoop/Services/SleepRecordExtension.swift | 250 ++++++++++++ PulseLoop/Services/SleepRecords.swift | 19 + PulseLoop/Views/SleepRecordsCard.swift | 109 +++++ .../SleepRecordExtensionTests.swift | 378 ++++++++++++++++++ 11 files changed, 943 insertions(+), 23 deletions(-) create mode 100644 PulseLoop/Services/SleepRecordExtension.swift create mode 100644 PulseLoopTests/SleepRecordExtensionTests.swift diff --git a/PulseLoop/DesignSystem/Charts.swift b/PulseLoop/DesignSystem/Charts.swift index e860eb9a..493c22e7 100644 --- a/PulseLoop/DesignSystem/Charts.swift +++ b/PulseLoop/DesignSystem/Charts.swift @@ -11,6 +11,9 @@ enum SleepStageColors { static let light = Color(hex: "#7C5CFF") static let rem = Color(hex: "#2DD4D8") static let awake = Color(hex: "#FFB86B") + /// Minutes the wearer added over time the ring missed: a neutral grey-violet, so the hatched + /// band never reads as one of the four measured stage colours. + static let added = Color(hex: "#9A97B5") static func color(for stage: SleepStage) -> Color { switch stage { @@ -19,6 +22,7 @@ enum SleepStageColors { case .rem: return rem case .awake: return awake case .unknown: return PulseColors.textMuted + case .added: return added } } } @@ -467,6 +471,7 @@ struct SleepHypnogramView: View { case .light: return 0.62 case .deep: return 0.85 case .unknown: return 0.62 + case .added: return 0.615 // the middle of the hatched REM-to-deep band } } @@ -506,10 +511,33 @@ struct SleepHypnogramView: View { let blocks = sortedBlocks guard !blocks.isEmpty else { return } + // Added-by-you bands first, under everything else (#106): muted fill and + // diagonal hatching from the REM lane to the deep lane. + let bandTop = size.height * Self.laneFraction(.rem) + let bandBottom = size.height * Self.laneFraction(.deep) + for block in blocks where block.stage == .added { + let startX = x(forMinute: block.startMinute, in: size.width) + let endX = max(startX + 1, x(forMinute: block.startMinute + block.durationMinutes, in: size.width)) + let band = Path(CGRect(x: startX, y: bandTop, width: endX - startX, height: bandBottom - bandTop)) + context.fill(band, with: .color(SleepStageColors.added.opacity(0.14))) + var hatch = Path() + var hx = startX - (bandBottom - bandTop) + while hx < endX { + hatch.move(to: CGPoint(x: hx, y: bandBottom)) + hatch.addLine(to: CGPoint(x: hx + (bandBottom - bandTop), y: bandTop)) + hx += 7 + } + context.drawLayer { layer in + layer.clip(to: band) + layer.stroke(hatch, with: .color(SleepStageColors.added.opacity(0.45)), lineWidth: 1) + } + } + // Dashed vertical transition connectors between consecutive blocks. for index in 1.. String { - let name = stage.rawValue.uppercased() + let name = stage == .added ? "Added by you" : stage.rawValue.uppercased() let endMinute = startMinute + durationMinutes guard let startTs else { func rel(_ m: Int) -> String { "\(m / 60):" + String(format: "%02d", m % 60) } @@ -737,9 +776,9 @@ struct SleepHypnogramView: View { var totals: [SleepStage: Int] = [:] for entry in stages { totals[entry.stage, default: 0] += entry.minutes } func plural(_ n: Int, _ unit: String) -> String { "\(n) \(unit)\(n == 1 ? "" : "s")" } - let parts: [String] = [SleepStage.deep, .light, .rem, .awake].compactMap { stage in + let parts: [String] = [SleepStage.deep, .light, .rem, .awake, .added].compactMap { stage in guard let minutes = totals[stage], minutes > 0 else { return nil } - let name = stage == .rem ? "REM" : stage.rawValue.capitalized + let name = stage == .rem ? "REM" : stage == .added ? "Added by you" : stage.rawValue.capitalized let h = minutes / 60, m = minutes % 60 if h > 0 && m > 0 { return "\(name) \(plural(h, "hour")) \(plural(m, "minute"))" } if h > 0 { return "\(name) \(plural(h, "hour"))" } diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index fcbcb48e..79729f64 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -578,7 +578,7 @@ final class EventPersistenceSubscriber { /// /// Each packet is one ring record: its blocks are stamped with `start` as their /// `recordStartAt` so the Day view can recover the records behind a merged night, and the - /// wearer's deletions (`SleepRecordEdit`) are consulted before anything is written. + /// wearer's deletions and edits (`SleepRecordEdit`) are applied before anything is written. private func persistSleepTimeline(start: Date, stages: [SleepStage], sessions: [SleepSession]? = nil, blocks: [SleepStageBlock]? = nil, edits: [SleepRecordEdit]? = nil) { @@ -588,10 +588,7 @@ final class EventPersistenceSubscriber { // start and the same block starts on every re-send, so drop it here or the delete lasts // exactly one sync. let tombstones = SleepRecordTombstones(edits ?? ((try? context.fetch(FetchDescriptor())) ?? [])) - guard !tombstones.isRecordDeleted(start) else { return } - let recordBlocks = SleepRecords.stageBlocks(start: start, stages: stages) - .filter { !tombstones.isBlockDeleted($0.startAt) } - guard !recordBlocks.isEmpty else { return } + guard !tombstones.isRecordDeleted(start), !stages.isEmpty else { return } // Group packets by the waking-day boundary (sleep from 7 PM rolls to the next morning) so a // night that starts before midnight lands under the morning of waking. See @@ -604,6 +601,29 @@ final class EventPersistenceSubscriber { // (main night vs. naps separated by a >= 60 min gap) and recomputes each session's bounds. let allSessions = sessions ?? ((try? context.fetch(FetchDescriptor())) ?? []) let daySessions = allSessions.filter { calendar.isDate($0.date, inSameDayAs: dateKey) } + let daySessionIds = Set(daySessions.map { $0.id }) + var existingDayBlocks = (blocks ?? ((try? context.fetch(FetchDescriptor())) ?? [])) + .filter { daySessionIds.contains($0.sessionId) } + + var recordBlocks = SleepRecords.stageBlocks(start: start, stages: stages) + if let edit = tombstones.editedBounds(start) { + // Edit gate (Android #92/#130): the wearer's bounds for this record, re-applied through + // the same function the edit used, so this sync reproduces the edit's blocks exactly — + // trimming, and re-creating any minutes they added. Added minutes give way to any other + // record already stored: the ring's measurement beats an extension. + let ringEnd = start.addingTimeInterval(Double(stages.count) * 60) + let bounds = SleepRecordExtension.clampToNeighbours( + editStart: edit.start, editEnd: edit.end, measuredStart: start, measuredEnd: ringEnd, + others: existingDayBlocks.filter { $0.recordStartAt.map(SleepRecords.key) != SleepRecords.key(start) }, + night: SleepRecordExtension.nightWindow(wakingDay: dateKey, calendar: calendar)) + guard let edited = SleepRecordExtension.editedBlocks(recordBlocks, gridOrigin: start, + editStart: bounds.start, editEnd: bounds.end) + else { return } + recordBlocks = edited + } + recordBlocks = recordBlocks.filter { !tombstones.isBlockDeleted($0.startAt) } + guard !recordBlocks.isEmpty else { return } + let container = daySessions.min { $0.startAt < $1.startAt } ?? { let new = SleepSession(date: dateKey, startAt: start, endAt: start, totalMinutes: 0, syncedAt: Date()) @@ -614,9 +634,22 @@ final class EventPersistenceSubscriber { // 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 = (blocks ?? ((try? context.fetch(FetchDescriptor())) ?? [])) - .filter { daySessionIds.contains($0.sessionId) } + // The other direction of "the ring beats an extension": minutes another record had added + // over time this record measured are trimmed back to its edge. + let measured = recordBlocks.filter { $0.stage != .added } + if let measuredStart = measured.map(\.startAt).min(), let measuredEnd = measured.map(\.endAt).max() { + for block in existingDayBlocks where block.stage == .added + && block.recordStartAt.map(SleepRecords.key) != SleepRecords.key(start) + && block.startAt < measuredEnd && block.endAt > measuredStart { + let remainder = SleepRecordExtension.addedRemainder(of: block, outside: measuredStart, measuredEnd) + context.delete(block) + existingDayBlocks.removeAll { $0.id == block.id } + for piece in remainder { + context.insert(piece) + existingDayBlocks.append(piece) + } + } + } if mergingRWfitSleep { // A recovered journal may extend or overlap an already-imported session. Normalize by // minute so a shorter previously saved block cannot suppress the rest of a replay. diff --git a/PulseLoop/Health/HealthKitTypeMappings.swift b/PulseLoop/Health/HealthKitTypeMappings.swift index 4a716c6d..93616045 100644 --- a/PulseLoop/Health/HealthKitTypeMappings.swift +++ b/PulseLoop/Health/HealthKitTypeMappings.swift @@ -139,14 +139,15 @@ enum HealthKitTypeMappings { // MARK: - Sleep /// Maps a ring sleep stage onto a HealthKit sleep-analysis category value. Light → `.asleepCore` - /// (Apple's core-sleep bucket), unknown → `.asleepUnspecified`. + /// (Apple's core-sleep bucket), unknown → `.asleepUnspecified`. Minutes the wearer added over + /// time the ring missed are asleep with no stage measured, so they're `.asleepUnspecified` too. static func sleepValue(_ stage: SleepStage) -> HKCategoryValueSleepAnalysis { switch stage { case .deep: return .asleepDeep case .light: return .asleepCore case .rem: return .asleepREM case .awake: return .awake - case .unknown: return .asleepUnspecified + case .unknown, .added: return .asleepUnspecified } } diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 798e3948..f6ce7753 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -61,6 +61,10 @@ enum SleepStage: String, Codable, CaseIterable { case awake case unknown case rem + /// Minutes the wearer added over time the ring missed (Android #106 `ADDED`). Asleep but + /// unstaged: counted in time asleep, left out of the stage breakdown and the score. No decoder + /// produces it — only `SleepRecordExtension.editedBlocks` does. Raw value persisted; append only. + case added } enum ActivitySessionStatus: String, Codable, CaseIterable { @@ -353,13 +357,19 @@ final class SleepRecordEdit { /// `.recordDeleted`: the record's declared start. `.blockDeleted`: one deleted block's start. var recordStartAt: Date var kindRaw: String + /// `.edited`: the bounds the wearer picked, re-applied on every re-sync of the record. + var newStartAt: Date? + var newEndAt: Date? var createdAt: Date - init(id: UUID = UUID(), wakingDay: Date, recordStartAt: Date, kind: SleepRecordEditKind) { + init(id: UUID = UUID(), wakingDay: Date, recordStartAt: Date, kind: SleepRecordEditKind, + newStartAt: Date? = nil, newEndAt: Date? = nil) { self.id = id self.wakingDay = wakingDay self.recordStartAt = recordStartAt self.kindRaw = kind.rawValue + self.newStartAt = newStartAt + self.newEndAt = newEndAt self.createdAt = Date() } @@ -372,6 +382,8 @@ enum SleepRecordEditKind: String, Codable, CaseIterable { /// One block of a deleted record. Covers nights stored before records were stamped, where the /// record's start is only a guess but every block start re-sends exactly. case blockDeleted = "block_deleted" + /// The record's bounds were moved — trimmed, or extended over time the ring missed (#92/#130). + case edited } @Model diff --git a/PulseLoop/Persistence/DataArchive.swift b/PulseLoop/Persistence/DataArchive.swift index 34c5bf52..3358e1af 100644 --- a/PulseLoop/Persistence/DataArchive.swift +++ b/PulseLoop/Persistence/DataArchive.swift @@ -350,6 +350,8 @@ nonisolated struct ArchiveSleepRecordEdit: Codable, Sendable { var wakingDay: Date var recordStartAt: Date var kindRaw: String + var newStartAt: Date? + var newEndAt: Date? var createdAt: Date @MainActor init(_ m: SleepRecordEdit) { @@ -357,11 +359,14 @@ nonisolated struct ArchiveSleepRecordEdit: Codable, Sendable { wakingDay = m.wakingDay recordStartAt = m.recordStartAt kindRaw = m.kindRaw + newStartAt = m.newStartAt + newEndAt = m.newEndAt createdAt = m.createdAt } @MainActor func insert(into context: ModelContext) { - let m = SleepRecordEdit(wakingDay: wakingDay, recordStartAt: recordStartAt, kind: .recordDeleted) + let m = SleepRecordEdit(wakingDay: wakingDay, recordStartAt: recordStartAt, kind: .recordDeleted, + newStartAt: newStartAt, newEndAt: newEndAt) m.id = id m.kindRaw = kindRaw m.createdAt = createdAt diff --git a/PulseLoop/Services/SleepInsights.swift b/PulseLoop/Services/SleepInsights.swift index 0a94bd95..cc1188d0 100644 --- a/PulseLoop/Services/SleepInsights.swift +++ b/PulseLoop/Services/SleepInsights.swift @@ -66,7 +66,10 @@ enum SleepScore { } static func calculate(_ sleep: SleepSummary) -> SleepScoreResult { - let total = sleep.session.totalMinutes > 0 ? Double(sleep.session.totalMinutes) : 0 + // Minutes the wearer added over time the ring missed are asleep but carry no stage, so the + // score judges the night the ring measured: they would dilute every stage share (#106). + let added = SleepRecords.addedMinutes(sleep.blocks) + let total = sleep.session.totalMinutes > 0 ? Double(max(0, sleep.session.totalMinutes - added)) : 0 let deep = Double(max(0, sleep.deepMinutes)) let light = Double(max(0, sleep.lightMinutes)) let awake = Double(max(0, sleep.awakeMinutes)) diff --git a/PulseLoop/Services/SleepRecordEditor.swift b/PulseLoop/Services/SleepRecordEditor.swift index 4f7576a5..82080a33 100644 --- a/PulseLoop/Services/SleepRecordEditor.swift +++ b/PulseLoop/Services/SleepRecordEditor.swift @@ -5,25 +5,35 @@ import SwiftData /// A re-sent record reproduces its declared start and its blocks' starts exactly, so those are the /// keys: a deleted record is dropped whole, and any block of a deleted night that re-sends is /// skipped even when the record's own start was only a guess (nights stored before stamping). +/// +/// It also carries the bounds of every edited record, which the import re-applies (Android #92/#130). struct SleepRecordTombstones { private let deletedRecords: Set private let deletedBlocks: Set + private let editedBounds: [Int64: (start: Date, end: Date)] init(_ edits: [SleepRecordEdit]) { - var records = Set(), blocks = Set() + var records = Set(), blocks = Set(), bounds: [Int64: (start: Date, end: Date)] = [:] for edit in edits { switch edit.kind { case .recordDeleted: records.insert(SleepRecords.key(edit.recordStartAt)) case .blockDeleted: blocks.insert(SleepRecords.key(edit.recordStartAt)) + case .edited: + if let start = edit.newStartAt, let end = edit.newEndAt { + bounds[SleepRecords.key(edit.recordStartAt)] = (start, end) + } case nil: break } } deletedRecords = records deletedBlocks = blocks + editedBounds = bounds } func isRecordDeleted(_ recordStart: Date) -> Bool { deletedRecords.contains(SleepRecords.key(recordStart)) } func isBlockDeleted(_ blockStart: Date) -> Bool { deletedBlocks.contains(SleepRecords.key(blockStart)) } + /// The bounds the wearer gave record `recordStart`, if they edited it. + func editedBounds(_ recordStart: Date) -> (start: Date, end: Date)? { editedBounds[SleepRecords.key(recordStart)] } } /// The wearer's edits to one ring sleep record of a stored night (Android `SleepRecordDeletion`). @@ -50,7 +60,10 @@ enum SleepRecordEditor { }) else { return false } let day = session.date - var known = Set(((try? context.fetch(FetchDescriptor())) ?? []) + let edits = (try? context.fetch(FetchDescriptor())) ?? [] + // The record goes whole, so an edit of it has nothing left to re-apply. + forgetEdit(of: run.recordStartAt, in: edits, context: context) + var known = Set(edits.filter { $0.kind != .edited } .map { "\($0.kindRaw):\(SleepRecords.key($0.recordStartAt))" }) func remember(_ kind: SleepRecordEditKind, _ at: Date) { // One tombstone per key, so deleting the same record twice never grows the table. @@ -66,16 +79,74 @@ enum SleepRecordEditor { return true } - /// Re-derive waking day `day` from its blocks less `removing`, then stamp the surviving rows as + /// Move the bounds of record `recordStartAt` in session `sessionId` to `newStart`...`newEnd` — + /// shorter, longer, or one side each (Android #92/#130). Stages outside the new range drop from + /// the night; time inside it the ring didn't measure becomes `.added` minutes. The bounds are + /// held to `SleepRecordExtension.limits` (the sheet's limits) and clear of records in the day's + /// other sessions, which the sheet doesn't see. The edit is remembered so every re-sync of the + /// record re-applies it. False when nothing changed or no ring-measured minute would remain. + @discardableResult + static func edit(sessionId: UUID, recordStartAt: Date, newStart: Date, newEnd: Date, + context: ModelContext, calendar: Calendar = .current) -> Bool { + guard newStart < newEnd, let session = session(sessionId, context: context) else { return false } + let blocks = SleepRepository.blocks(sessionId: sessionId, context: context) + let runs = SleepRecords.runs(blocks) + guard let index = runs.firstIndex(where: { + SleepRecords.key($0.recordStartAt) == SleepRecords.key(recordStartAt) + }) else { return false } + let run = runs[index] + let day = session.date + + let limits = SleepRecordExtension.limits(run: run, previous: index > 0 ? runs[index - 1] : nil, + next: index + 1 < runs.count ? runs[index + 1] : nil, + wakingDay: day, calendar: calendar) + let otherSessionIds = Set(((try? context.fetch(FetchDescriptor())) ?? []) + .filter { calendar.isDate($0.date, inSameDayAs: day) && $0.id != sessionId }.map(\.id)) + let others = ((try? context.fetch(FetchDescriptor())) ?? []) + .filter { otherSessionIds.contains($0.sessionId) } + let bounds = SleepRecordExtension.clampToNeighbours( + editStart: max(newStart, limits.earliestStart), editEnd: min(newEnd, limits.latestEnd), + measuredStart: limits.measuredStart, measuredEnd: limits.measuredEnd, + others: others, night: SleepRecordExtension.nightWindow(wakingDay: day, calendar: calendar)) + guard let edited = SleepRecordExtension.editedBlocks(run.blocks, gridOrigin: run.recordStartAt, + editStart: bounds.start, editEnd: bounds.end) + else { return false } + let shape = { (b: [SleepStageBlock]) in b.map { "\(SleepRecords.key($0.startAt)):\($0.durationMinutes):\($0.stageRaw)" } } + guard shape(edited) != shape(run.blocks.sorted { $0.startAt < $1.startAt }) else { return false } + + let edits = (try? context.fetch(FetchDescriptor())) ?? [] + forgetEdit(of: run.recordStartAt, in: edits, context: context) + context.insert(SleepRecordEdit(wakingDay: day, recordStartAt: run.recordStartAt, kind: .edited, + newStartAt: bounds.start, newEndAt: bounds.end)) + + let removed = Set(run.blocks.map(\.id)) + for block in run.blocks { context.delete(block) } + for block in edited { + block.sessionId = sessionId + context.insert(block) + } + restate(day: day, removing: removed, adding: edited, context: context) + return true + } + + private static func forgetEdit(of recordStart: Date, in edits: [SleepRecordEdit], context: ModelContext) { + for edit in edits where edit.kind == .edited && SleepRecords.key(edit.recordStartAt) == SleepRecords.key(recordStart) { + context.delete(edit) + } + } + + /// Re-derive waking day `day` from its blocks less `removing` (plus `adding`), then stamp the surviving rows as /// changed (a deleted middle record leaves the bounds where they were, which reconcile alone /// would treat as no change) and recompute any stored score. Saves and signals the UI. - static func restate(day: Date, removing: Set, context: ModelContext) { + static func restate(day: Date, removing: Set, adding: [SleepStageBlock] = [], context: ModelContext) { let calendar = Calendar.current let daySessions = ((try? context.fetch(FetchDescriptor())) ?? []) .filter { calendar.isDate($0.date, inSameDayAs: day) } let ids = Set(daySessions.map(\.id)) + // `adding` is passed explicitly: a fetch need not see blocks inserted since the last save. + let added = Set(adding.map(\.id)) let dayBlocks = ((try? context.fetch(FetchDescriptor())) ?? []) - .filter { ids.contains($0.sessionId) && !removing.contains($0.id) } + .filter { ids.contains($0.sessionId) && !removing.contains($0.id) && !added.contains($0.id) } + adding SleepService.reconcileWakingDay(dateKey: day, context: context, daySessions: daySessions, dayBlocks: dayBlocks) try? context.save() diff --git a/PulseLoop/Services/SleepRecordExtension.swift b/PulseLoop/Services/SleepRecordExtension.swift new file mode 100644 index 00000000..bf3ae23c --- /dev/null +++ b/PulseLoop/Services/SleepRecordExtension.swift @@ -0,0 +1,250 @@ +import Foundation + +/// Why an edit bound stops where it does — what the edit sheet names when a pick goes past it. +enum RecordEditLimit: Equatable { + /// The previous record of the same night ends here. + case previousRecord + /// The next record of the same night starts here. + case nextRecord + /// `maxOutwardExtensionMinutes` past the ring's own bounds, before the night's first record or + /// after its last. + case outwardCap + /// The waking day's 7 PM turnover: further would move the record into another night. + case nightBoundary + /// The record must keep at least one minute the ring measured. + case ringMinutes +} + +/// How far one record's bounds may move. The start may go back to the previous record's end (no +/// cap: that is the gap the ring missed) or, for the night's first record, two hours before the +/// ring's own start; the end likewise forward. Never across the 7 PM turnover. Shortening is +/// limited only by keeping one measured minute. +struct RecordEditLimits: Equatable { + let gridOrigin: Date + let earliestStart: Date + let earliestStartLimit: RecordEditLimit + let latestEnd: Date + let latestEndLimit: RecordEditLimit + /// The ring's own measured span inside the record, which a shortening must keep a minute of. + let measuredStart: Date + let measuredEnd: Date +} + +/// A picked bound after the limits: `at` is what to use; `limit` is set when the pick went past one. +struct ClampedEditPick: Equatable { + let at: Date + let limit: RecordEditLimit? +} + +/// Editing a ring sleep record's bounds, including **extending** it over time the ring missed +/// (Android #82/#92 and #106/#130). Pure: the edit path (`SleepRecordEditor.edit`) and the import +/// (`persistSleepTimeline`, which re-applies a stored edit on every re-sync of the night) both map +/// a record through `editedBlocks`, so a re-sync writes back exactly the blocks the edit did. +/// +/// Minutes an extension adds are `.added` blocks — asleep but unstaged: in time asleep, out of the +/// stage breakdown and the score, drawn as their own hatched band on the hypnogram. They carry the +/// record's `recordStartAt`, so they belong to that record, and a gap they cover stops counting as +/// a between-record waking with no further change. +enum SleepRecordExtension { + /// How far a record may be extended beyond the ring's own bounds where no neighbour limits it. + static let maxOutwardExtensionMinutes = 120 + + // MARK: Grid + + /// The first point on the record's minute grid at or after `t`. A record's stages sit on a grid + /// anchored at its declared start, and block starts are the identity tombstones and the import's + /// de-duplication key on, so an edit snaps to that grid rather than to wall-clock minutes. + static func gridCeil(_ t: Date, origin: Date) -> Date { + origin.addingTimeInterval((minutes(from: origin, to: t)).rounded(.up) * 60) + } + + /// The last point on the record's minute grid at or before `t`. See `gridCeil`. + static func gridFloor(_ t: Date, origin: Date) -> Date { + origin.addingTimeInterval((minutes(from: origin, to: t)).rounded(.down) * 60) + } + + /// Minutes from `a` to `b`, with sub-millisecond float noise removed so exact grid points stay + /// exact under rounding. + private static func minutes(from a: Date, to b: Date) -> Double { + (b.timeIntervalSince(a) * 1000).rounded() / 60_000 + } + + // MARK: The shared function + + /// One record's blocks after an edit to `editStart`...`editEnd`. + /// + /// `blocks` are the record's blocks: the import passes the ring's freshly built ones, the edit + /// path the stored ones. Any `.added` block among them is ignored — a previous extension is + /// re-derived from the new bounds, never kept — so editing back inside the ring's bounds + /// removes the added minutes and re-applying an edit to its own output is a no-op. + /// + /// The bounds snap inwards onto the grid anchored at `gridOrigin`: the start up, the end down, + /// so the result never reaches past a limit the caller clamped to. Measured blocks are trimmed + /// to the bounds; time outside the ring's measured span becomes one `.added` block per side. + /// + /// Nil when the bounds keep none of the ring's own minutes: a record made only of added time has + /// nothing to anchor it, so the edit is refused rather than inventing a night. The returned + /// blocks are new and unattached (placeholder session); the caller inserts and re-keys them. + static func editedBlocks(_ blocks: [SleepStageBlock], gridOrigin: Date, + editStart: Date, editEnd: Date) -> [SleepStageBlock]? { + let measured = blocks.filter { $0.stage != .added }.sorted { $0.startAt < $1.startAt } + guard let template = measured.first else { return nil } + let start = gridCeil(editStart, origin: gridOrigin) + let end = gridFloor(editEnd, origin: gridOrigin) + guard start < end else { return nil } + let placeholder = UUID() + + func block(_ from: Date, _ until: Date, _ stage: SleepStage) -> SleepStageBlock? { + let count = Int(minutes(from: from, to: until).rounded(.down)) + guard count > 0 else { return nil } + return SleepStageBlock(sessionId: placeholder, startAt: from, startMinute: 0, + durationMinutes: count, stage: stage, recordStartAt: template.recordStartAt) + } + + let kept = measured.compactMap { b in block(max(b.startAt, start), min(b.endAt, end), b.stage) } + guard !kept.isEmpty else { return nil } + + let measuredStart = template.startAt + let measuredEnd = measured.map(\.endAt).max() ?? measuredStart + let before = start < measuredStart ? block(start, measuredStart, .added) : nil + let after = end > measuredEnd ? block(measuredEnd, end, .added) : nil + return [before].compactMap { $0 } + kept + [after].compactMap { $0 } + } + + /// What is left of another record's `.added` block once a ring-measured span `start`...`end` + /// claims the time it covers: the parts outside it, snapped inwards to that record's own grid + /// so a later re-import of its edit reproduces the same block starts. New, unattached blocks on + /// the same session. + static func addedRemainder(of block: SleepStageBlock, outside start: Date, _ end: Date) -> [SleepStageBlock] { + let origin = block.recordStartAt ?? block.startAt + func piece(_ from: Date, _ until: Date) -> SleepStageBlock? { + let count = Int(minutes(from: from, to: until).rounded(.down)) + guard count > 0 else { return nil } + return SleepStageBlock(sessionId: block.sessionId, startAt: from, startMinute: 0, durationMinutes: count, + stage: .added, recordStartAt: block.recordStartAt) + } + let head = piece(block.startAt, min(block.endAt, gridFloor(start, origin: origin))) + let tail = piece(max(block.startAt, gridCeil(end, origin: origin)), block.endAt) + return [head, tail].compactMap { $0 } + } + + // MARK: Limits + + static func limits(run: SleepRecordRun, previous: SleepRecordRun?, next: SleepRecordRun?, + wakingDay: Date, calendar: Calendar = .current) -> RecordEditLimits { + let origin = run.recordStartAt + let cap = Double(maxOutwardExtensionMinutes) * 60 + let night = nightWindow(wakingDay: wakingDay, calendar: calendar) + + let startCandidates: [(Date, RecordEditLimit)] = [ + previous.map { (gridCeil($0.endAt, origin: origin), .previousRecord) } + ?? (gridCeil(run.measuredStartAt.addingTimeInterval(-cap), origin: origin), .outwardCap), + (gridCeil(night.start, origin: origin), .nightBoundary), + ] + let endCandidates: [(Date, RecordEditLimit)] = [ + next.map { (gridFloor($0.startAt, origin: origin), .nextRecord) } + ?? (gridFloor(run.measuredEndAt.addingTimeInterval(cap), origin: origin), .outwardCap), + (gridFloor(night.end, origin: origin), .nightBoundary), + ] + // `max`/`min` keep the first of equal candidates, so a tie names the neighbour or the cap. + let earliest = startCandidates.dropFirst().reduce(startCandidates[0]) { $1.0 > $0.0 ? $1 : $0 } + let latest = endCandidates.dropFirst().reduce(endCandidates[0]) { $1.0 < $0.0 ? $1 : $0 } + // A limit never cuts into the record as it already stands (overlapping legacy rows). + return RecordEditLimits( + gridOrigin: origin, + earliestStart: min(earliest.0, run.startAt), + earliestStartLimit: earliest.1, + latestEnd: max(latest.0, run.endAt), + latestEndLimit: latest.1, + measuredStart: run.measuredStartAt, + measuredEnd: run.measuredEndAt + ) + } + + /// A start picked in the sheet (a whole minute) placed on the record's grid and held inside + /// `limits`. The grid point inside the picked minute is the one the button then shows, so + /// picking the minute already displayed is never a change and never a message. + static func clampStartPick(_ picked: Date, limits: RecordEditLimits) -> ClampedEditPick { + let at = gridCeil(wholeMinute(picked), origin: limits.gridOrigin) + let lastStart = gridFloor(limits.measuredEnd.addingTimeInterval(-60), origin: limits.gridOrigin) + if at < limits.earliestStart { return ClampedEditPick(at: limits.earliestStart, limit: limits.earliestStartLimit) } + if at > lastStart { return ClampedEditPick(at: lastStart, limit: .ringMinutes) } + return ClampedEditPick(at: at, limit: nil) + } + + /// The end-side twin of `clampStartPick`. + static func clampEndPick(_ picked: Date, limits: RecordEditLimits) -> ClampedEditPick { + let at = gridCeil(wholeMinute(picked), origin: limits.gridOrigin) + let firstEnd = gridCeil(limits.measuredStart.addingTimeInterval(60), origin: limits.gridOrigin) + if at > limits.latestEnd { return ClampedEditPick(at: limits.latestEnd, limit: limits.latestEndLimit) } + if at < firstEnd { return ClampedEditPick(at: firstEnd, limit: .ringMinutes) } + return ClampedEditPick(at: at, limit: nil) + } + + private static func wholeMinute(_ date: Date) -> Date { + Date(timeIntervalSince1970: (date.timeIntervalSince1970 / 60).rounded(.down) * 60) + } + + /// The span of waking day `wakingDay` (local midnight): 7 PM the evening before to 7 PM on the + /// day — `Calendar.wakingDay(forSleepStart:)`'s turnover, so a record edited inside it keeps + /// its date. + static func nightWindow(wakingDay: Date, calendar: Calendar = .current) -> (start: Date, end: Date) { + let day = calendar.startOfDay(for: wakingDay) + let hour = Calendar.sleepEveningBoundaryHour + let end = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day + let eve = calendar.date(byAdding: .day, value: -1, to: day) ?? day + let start = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: eve) ?? eve + return (start, end) + } + + /// Hold edit bounds clear of every block that isn't this record's — the import's guard, and the + /// edit path's against records in *other* sessions of the day, which the sheet doesn't see. The + /// ring's measurement always beats an extension. + static func clampToNeighbours(editStart: Date, editEnd: Date, measuredStart: Date, measuredEnd: Date, + others: [SleepStageBlock], night: (start: Date, end: Date)) -> (start: Date, end: Date) { + let before = others.map(\.endAt).filter { $0 <= measuredStart }.max() + let after = others.map(\.startAt).filter { $0 >= measuredEnd }.min() + let lo = max(before ?? .distantPast, night.start) + let hi = min(after ?? .distantFuture, night.end) + return (max(editStart, min(lo, measuredStart)), min(editEnd, max(hi, measuredEnd))) + } + + // MARK: The sheet + + /// The instant for a picked `hour`:`minute` near a record spanning `recordStart`...`recordEnd`: + /// of the candidate dates around the record, the one inside or closest to it, ties broken by + /// closeness to `target` (the bound being edited). So 11:30 PM picked for a record that starts + /// 12:51 AM lands the evening before, and a morning time for an evening start lands next day. + static func resolveAdjustedTime(target: Date, recordStart: Date, recordEnd: Date, hour: Int, minute: Int, + calendar: Calendar = .current) -> Date { + let startDay = calendar.startOfDay(for: recordStart) + let endDay = calendar.startOfDay(for: recordEnd) + let days = [calendar.date(byAdding: .day, value: -1, to: startDay), startDay, endDay, + calendar.date(byAdding: .day, value: 1, to: endDay)].compactMap { $0 } + let candidates = days.compactMap { calendar.date(bySettingHour: hour, minute: minute, second: 0, of: $0) } + func distance(_ c: Date) -> TimeInterval { + c < recordStart ? recordStart.timeIntervalSince(c) : (c > recordEnd ? c.timeIntervalSince(recordEnd) : 0) + } + return candidates.min { + let (a, b) = (distance($0), distance($1)) + return a != b ? a < b : abs($0.timeIntervalSince(target)) < abs($1.timeIntervalSince(target)) + } ?? target + } + + /// The sheet's line for a pick that ran past `limit`, naming it. `at` is where it was held. + static func limitMessage(_ limit: RecordEditLimit, isStart: Bool, at: Date, limits: RecordEditLimits) -> String { + let time = SleepFormat.clockTime(at) + switch limit { + case .previousRecord: return "The previous record ends at \(time)." + case .nextRecord: return "The next record starts at \(time)." + case .outwardCap: + return "A record can be extended by up to \(maxOutwardExtensionMinutes / 60) hours, so " + + (isStart ? "it can start at \(time) at the earliest." : "it can end at \(time) at the latest.") + case .nightBoundary: + return "\(isStart ? "Starting" : "Ending") past \(time) would move it into another night." + case .ringMinutes: + return "Keep at least a minute of what the ring recorded " + + "(\(SleepFormat.clockTime(limits.measuredStart)) – \(SleepFormat.clockTime(limits.measuredEnd)))." + } + } +} diff --git a/PulseLoop/Services/SleepRecords.swift b/PulseLoop/Services/SleepRecords.swift index 08a4e48c..4f948073 100644 --- a/PulseLoop/Services/SleepRecords.swift +++ b/PulseLoop/Services/SleepRecords.swift @@ -16,6 +16,14 @@ struct SleepRecordRun { /// were stamped) the run's own first minute. This is the identity delete and edit key on. var recordStartAt: Date { blocks.first { $0.recordStartAt != nil }?.recordStartAt ?? startAt } var asleepMinutes: Int { SleepRecords.asleepMinutes(blocks) } + /// Minutes the wearer added over time the ring missed; part of `asleepMinutes`. + var addedMinutes: Int { SleepRecords.addedMinutes(blocks) } + + private var measured: [SleepStageBlock] { blocks.filter { $0.stage != .added } } + /// Where the ring's own recording starts and ends inside this run, without the added minutes — + /// the bounds an extension is measured from. The run's own when nothing is measured. + var measuredStartAt: Date { measured.map(\.startAt).min() ?? startAt } + var measuredEndAt: Date { measured.map(\.endAt).max() ?? endAt } } /// Pure helpers over a session's stage blocks: splitting them into ring records and counting the @@ -27,6 +35,17 @@ enum SleepRecords { max(0, blocks.filter { $0.stage != .awake }.reduce(0) { $0 + $1.durationMinutes }) } + /// Minutes the wearer added over time the ring missed (Android #106). Inside `asleepMinutes`, + /// outside everything that judges stages: subtract them wherever a stage share or score is taken. + static func addedMinutes(_ blocks: [SleepStageBlock]) -> Int { + blocks.filter { $0.stage == .added }.reduce(0) { $0 + $1.durationMinutes } + } + + /// The asleep minutes the ring itself measured: `asleepMinutes` less `addedMinutes`. + static func measuredAsleepMinutes(_ blocks: [SleepStageBlock]) -> Int { + max(0, asleepMinutes(blocks) - addedMinutes(blocks)) + } + /// Split `blocks` into the ring records they came from. /// /// The boundary is read, not inferred: each block carries its record's declared start diff --git a/PulseLoop/Views/SleepRecordsCard.swift b/PulseLoop/Views/SleepRecordsCard.swift index 544ad4c9..fa1b4223 100644 --- a/PulseLoop/Views/SleepRecordsCard.swift +++ b/PulseLoop/Views/SleepRecordsCard.swift @@ -15,6 +15,8 @@ struct SleepRecordsCard: View { /// The run whose trash button armed the confirmation. @State private var pendingDelete: SleepRecordRun? + /// The run whose times are being edited. + @State private var editing: SleepRecordEditTarget? @State private var failureMessage: String? var body: some View { @@ -47,6 +49,15 @@ struct SleepRecordsCard: View { } message: { run in Text("\(Self.range(run)) leaves tonight's totals and the hypnogram, and it won't come back on re-sync.") } + .sheet(item: $editing) { target in + SleepRecordEditSheet(target: target) { start, end in + editing = nil + if !SleepRecordEditor.edit(sessionId: session.id, recordStartAt: target.run.recordStartAt, + newStart: start, newEnd: end, context: modelContext) { + failureMessage = "Couldn't update this record" + } + } + } .alert(failureMessage ?? "", isPresented: Binding( get: { failureMessage != nil }, set: { if !$0 { failureMessage = nil } } @@ -69,8 +80,23 @@ struct SleepRecordsCard: View { Text("Asleep \(SleepFormat.duration(run.asleepMinutes))") .font(PulseFont.caption) .foregroundStyle(PulseColors.textMuted) + // Minutes the wearer added over time the ring missed (#106). + if run.addedMinutes > 0 { + Text("Includes \(SleepFormat.duration(run.addedMinutes)) added by you, not staged or scored") + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + } } Spacer(minLength: 4) + iconButton("pencil", label: "Edit this record's times") { + editing = SleepRecordEditTarget( + run: run, + limits: SleepRecordExtension.limits( + run: run, + previous: index > 0 ? runs[index - 1] : nil, + next: index + 1 < runs.count ? runs[index + 1] : nil, + wakingDay: session.date)) + } iconButton("trash", label: "Delete this record") { pendingDelete = run } } .accessibilityElement(children: .contain) @@ -99,3 +125,86 @@ struct SleepRecordsCard: View { "\(SleepFormat.clockTime(run.startAt)) – \(SleepFormat.clockTime(run.endAt))" } } + +/// The record a sheet is editing, with the limits it's held to. +struct SleepRecordEditTarget: Identifiable { + let run: SleepRecordRun + let limits: RecordEditLimits + var id: Int64 { SleepRecords.key(run.recordStartAt) } +} + +/// Edit one record's start and end (Android #92/#130): trim it, or extend it over time the ring +/// missed. A pick past a limit is held at the limit and the limit is named, never silently snapped. +struct SleepRecordEditSheet: View { + @Environment(\.dismiss) private var dismiss + let target: SleepRecordEditTarget + let onSave: (Date, Date) -> Void + + @State private var start: Date + @State private var end: Date + @State private var limitMessage: String? + + init(target: SleepRecordEditTarget, onSave: @escaping (Date, Date) -> Void) { + self.target = target + self.onSave = onSave + _start = State(initialValue: target.run.startAt) + _end = State(initialValue: target.run.endAt) + } + + private var changed: Bool { start != target.run.startAt || end != target.run.endAt } + + var body: some View { + NavigationStack { + Form { + Section { + DatePicker("Start", selection: pick(isStart: true), displayedComponents: .hourAndMinute) + DatePicker("End", selection: pick(isStart: false), displayedComponents: .hourAndMinute) + } footer: { + VStack(alignment: .leading, spacing: 6) { + if let limitMessage { + Text(limitMessage).foregroundStyle(PulseColors.danger) + } + if start >= end { + Text("Start time must be before end time.").foregroundStyle(PulseColors.danger) + } + Text("Trim this record, or extend it over time the ring missed. Added time counts " + + "as asleep but has no stage, so it stays out of the stage breakdown and score.") + } + } + } + .navigationTitle("Edit sleep record") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { onSave(start, end) } + .disabled(start >= end || !changed) + } + } + } + .presentationDetents([.medium, .large]) + } + + /// A picker binding that resolves the picked clock time to the right date around the record + /// (crossing midnight either way), then holds it to the record's limits. + private func pick(isStart: Bool) -> Binding { + Binding( + get: { isStart ? start : end }, + set: { picked in + let calendar = Calendar.current + let parts = calendar.dateComponents([.hour, .minute], from: picked) + let limits = target.limits + let resolved = SleepRecordExtension.resolveAdjustedTime( + target: isStart ? start : end, recordStart: limits.earliestStart, recordEnd: limits.latestEnd, + hour: parts.hour ?? 0, minute: parts.minute ?? 0, calendar: calendar) + let clamped = isStart + ? SleepRecordExtension.clampStartPick(resolved, limits: limits) + : SleepRecordExtension.clampEndPick(resolved, limits: limits) + limitMessage = clamped.limit.map { + SleepRecordExtension.limitMessage($0, isStart: isStart, at: clamped.at, limits: limits) + } + if isStart { start = clamped.at } else { end = clamped.at } + } + ) + } +} diff --git a/PulseLoopTests/SleepRecordExtensionTests.swift b/PulseLoopTests/SleepRecordExtensionTests.swift new file mode 100644 index 00000000..381d174f --- /dev/null +++ b/PulseLoopTests/SleepRecordExtensionTests.swift @@ -0,0 +1,378 @@ +import XCTest +import SwiftData +import HealthKit +@testable import PulseLoop + +/// Editing a ring sleep record's bounds and extending it over time the ring missed (Android #92 and +/// #130, `SleepRecordExtensionTest` + the edit cases of `SleepRecordDeletionTest`). +/// +/// The reporter's night: the ring recorded 12:51–5:44 and 6:56–8:07, they were asleep again from +/// about 5:46. Added minutes are asleep but unstaged — in time asleep, out of the breakdown and the +/// score — and the edit path and the import both go through `editedBlocks`, so a re-sync writes +/// back exactly what the edit did. +@MainActor +final class SleepRecordExtensionTests: XCTestCase { + private var utc: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + return c + }() + private var wakingDay: Date { utc.date(from: DateComponents(year: 2026, month: 10, day: 8))! } + private func at(_ h: Int, _ m: Int, _ s: Int = 0) -> Date { + wakingDay.addingTimeInterval(TimeInterval((h * 60 + m) * 60 + s)) + } + + // Records start on :23 s, as a real ring's do — so the grid is not the wall-clock minute. + private var aStart: Date { at(0, 51, 23) } + private var bStart: Date { at(6, 56, 23) } + private let aStages: [SleepStage] = Array(repeating: .light, count: 100) + Array(repeating: .deep, count: 80) + + Array(repeating: .light, count: 70) + Array(repeating: .rem, count: 43) + private let bStages: [SleepStage] = Array(repeating: .light, count: 30) + Array(repeating: .deep, count: 20) + + Array(repeating: .light, count: 21) + private var a: [SleepStageBlock] { SleepRecords.stageBlocks(start: aStart, stages: aStages) } + private var b: [SleepStageBlock] { SleepRecords.stageBlocks(start: bStart, stages: bStages) } + + private func end(_ blocks: [SleepStageBlock]) -> Date { blocks.map(\.endAt).max()! } + private func shape(_ blocks: [SleepStageBlock]) -> [String] { + blocks.sorted { $0.startAt < $1.startAt }.map { + "\(SleepRecords.key($0.startAt)):\($0.durationMinutes):\($0.stageRaw):\($0.recordStartAt.map(SleepRecords.key) ?? 0)" + } + } + private func edited(_ blocks: [SleepStageBlock], _ origin: Date, _ start: Date, _ end: Date) -> [SleepStageBlock]? { + SleepRecordExtension.editedBlocks(blocks, gridOrigin: origin, editStart: start, editEnd: end) + } + + // MARK: - The shared function + + func testExtendingTheStartAddsOneUnstagedBlockBeforeTheRingsOwn() throws { + let result = try XCTUnwrap(edited(b, bStart, at(5, 46, 23), end(b))) + let added = result[0] + XCTAssertEqual(added.stage, .added) + XCTAssertEqual(added.startAt, at(5, 46, 23)) + XCTAssertEqual(added.durationMinutes, 70) + XCTAssertEqual(added.recordStartAt, bStart, "added minutes belong to the ring record") + XCTAssertEqual(shape(Array(result.dropFirst())), shape(b), "the ring's blocks are untouched") + } + + func testExtendingTheEndAddsOneUnstagedBlockAfterTheRingsOwn() throws { + let result = try XCTUnwrap(edited(b, bStart, bStart, at(8, 30, 23))) + XCTAssertEqual(shape(Array(result.dropLast())), shape(b)) + XCTAssertEqual(result.last?.stage, .added) + XCTAssertEqual(result.last?.startAt, end(b)) + XCTAssertEqual(result.last?.durationMinutes, 23) + } + + func testOneEditCanShortenOneSideAndExtendTheOther() throws { + let result = try XCTUnwrap(edited(b, bStart, bStart.addingTimeInterval(600), at(8, 17, 23))) + XCTAssertEqual(result.first?.startAt, bStart.addingTimeInterval(600)) + XCTAssertEqual(result.first?.stage, .light) + XCTAssertEqual(result.first?.durationMinutes, 20) + XCTAssertEqual(result.last?.stage, .added) + XCTAssertEqual(result.last?.durationMinutes, 10) + XCTAssertEqual(SleepRecords.measuredAsleepMinutes(result), 61) + XCTAssertEqual(SleepRecords.asleepMinutes(result), 71) + } + + func testShorteningAloneAddsNothing() throws { + let result = try XCTUnwrap(edited(a, aStart, aStart.addingTimeInterval(30 * 60), end(a).addingTimeInterval(-13 * 60))) + XCTAssertEqual(SleepRecords.addedMinutes(result), 0) + XCTAssertEqual(SleepRecords.asleepMinutes(result), 293 - 30 - 13) + } + + func testAWholeMinutePickSnapsInwardsOntoTheRecordsGrid() throws { + let result = try XCTUnwrap(edited(b, bStart, at(5, 46), at(8, 30))) + XCTAssertEqual(result.first?.startAt, at(5, 46, 23), "start rounds up into the picked minute") + XCTAssertEqual(end(result), at(8, 29, 23), "end rounds down, never past the bound") + } + + func testAnEditKeepingNoneOfTheRingsMinutesIsRefused() { + XCTAssertNil(edited(b, bStart, at(5, 0, 23), bStart)) + XCTAssertNil(edited(b, bStart, end(b), at(8, 30, 23))) + } + + func testEditingBackInsideTheRingsBoundsRemovesTheAddedMinutes() throws { + let extended = try XCTUnwrap(edited(b, bStart, at(5, 46, 23), end(b))) + let undone = try XCTUnwrap(edited(extended, bStart, bStart, end(b))) + XCTAssertEqual(shape(undone), shape(b)) + } + + /// What the edit stores and what the next sync builds from the ring's raw stages are the same + /// blocks, and re-applying the edit to its own output changes nothing. + func testReImportingReCreatesTheEditsBlocksExactly() throws { + let editStart = at(5, 46, 23), editEnd = end(b).addingTimeInterval(-5 * 60) + let afterEdit = try XCTUnwrap(edited(b, bStart, editStart, editEnd)) + let reimported = try XCTUnwrap(edited(SleepRecords.stageBlocks(start: bStart, stages: bStages), bStart, editStart, editEnd)) + XCTAssertEqual(shape(afterEdit), shape(reimported)) + XCTAssertEqual(shape(try XCTUnwrap(edited(afterEdit, bStart, editStart, editEnd))), shape(afterEdit)) + } + + func testAStoredEditGivesWayToANeighbourRecordAtImport() { + let night = SleepRecordExtension.nightWindow(wakingDay: wakingDay, calendar: utc) + let bounds = SleepRecordExtension.clampToNeighbours( + editStart: at(5, 30, 23), editEnd: end(b), measuredStart: bStart, measuredEnd: end(b), others: a, night: night) + XCTAssertEqual(bounds.start, end(a)) + XCTAssertEqual(bounds.end, end(b)) + } + + // MARK: - Limits and the sheet's picks + + private func limitsForB() -> RecordEditLimits { + let runs = SleepRecords.runs(a + b) + return SleepRecordExtension.limits(run: runs[1], previous: runs[0], next: nil, wakingDay: wakingDay, calendar: utc) + } + + func testExtendingIntoTheGapStopsAtThePreviousRecordsEnd() { + let limits = limitsForB() + XCTAssertEqual(limits.earliestStart, end(a)) + XCTAssertEqual(limits.earliestStartLimit, .previousRecord) + + let pick = SleepRecordExtension.clampStartPick(at(5, 30), limits: limits) + XCTAssertEqual(pick, ClampedEditPick(at: end(a), limit: .previousRecord)) + + // The reporter's pick: 5:46, inside the gap, no message. + XCTAssertEqual(SleepRecordExtension.clampStartPick(at(5, 46), limits: limits), ClampedEditPick(at: at(5, 46, 23), limit: nil)) + } + + func testExtendingOutwardIsCappedAtTwoHoursPastTheRingsBounds() { + let limits = limitsForB() + XCTAssertEqual(limits.latestEndLimit, .outwardCap) + XCTAssertEqual(limits.latestEnd, end(b).addingTimeInterval(120 * 60)) + XCTAssertEqual(SleepRecordExtension.clampEndPick(at(11, 0), limits: limits), + ClampedEditPick(at: end(b).addingTimeInterval(120 * 60), limit: .outwardCap)) + + let runs = SleepRecords.runs(a + b) + let first = SleepRecordExtension.limits(run: runs[0], previous: nil, next: runs[1], wakingDay: wakingDay, calendar: utc) + XCTAssertEqual(first.earliestStart, aStart.addingTimeInterval(-120 * 60)) + XCTAssertEqual(first.earliestStartLimit, .outwardCap) + XCTAssertEqual(first.latestEnd, bStart) + XCTAssertEqual(first.latestEndLimit, .nextRecord) + } + + func testTheCapCountsFromTheRingsBoundsNotAPreviousExtension() throws { + let extended = try XCTUnwrap(edited(b, bStart, bStart, end(b).addingTimeInterval(60 * 60))) + let limits = SleepRecordExtension.limits(run: SleepRecords.runs(extended)[0], previous: nil, next: nil, + wakingDay: wakingDay, calendar: utc) + XCTAssertEqual(limits.latestEnd, end(b).addingTimeInterval(120 * 60)) + } + + func testAnExtensionNeverCrossesTheNights7PMTurnover() { + let start = at(-4, 10, 23) // 20:10:23 the evening before + let record = SleepRecords.stageBlocks(start: start, stages: Array(repeating: .light, count: 60)) + let limits = SleepRecordExtension.limits(run: SleepRecords.runs(record)[0], previous: nil, next: nil, + wakingDay: wakingDay, calendar: utc) + XCTAssertEqual(limits.earliestStartLimit, .nightBoundary) + XCTAssertEqual(limits.earliestStart, at(-5, 0, 23), "19:00:23, the first grid point inside") + } + + func testPickingTheMinuteTheButtonShowsIsNotAChange() { + let limits = limitsForB() + XCTAssertEqual(SleepRecordExtension.clampStartPick(at(6, 56), limits: limits), ClampedEditPick(at: bStart, limit: nil)) + XCTAssertEqual(SleepRecordExtension.clampEndPick(at(8, 7), limits: limits), ClampedEditPick(at: end(b), limit: nil)) + } + + func testShorteningMustKeepAMinuteTheRingMeasured() { + let pick = SleepRecordExtension.clampStartPick(at(9, 0), limits: limitsForB()) + XCTAssertEqual(pick, ClampedEditPick(at: end(b).addingTimeInterval(-60), limit: .ringMinutes)) + } + + func testTheLimitIsNamed() { + let limits = limitsForB() + let message = SleepRecordExtension.limitMessage(.outwardCap, isStart: false, at: limits.latestEnd, limits: limits) + XCTAssertTrue(message.hasPrefix("A record can be extended by up to 2 hours")) + XCTAssertTrue(SleepRecordExtension.limitMessage(.previousRecord, isStart: true, at: end(a), limits: limits) + .hasPrefix("The previous record ends at")) + } + + // MARK: - Picked times across midnight + + func testResolveAdjustedTimeHandlesMidnightCrossover() { + let start = utc.date(from: DateComponents(year: 2026, month: 10, day: 1, hour: 23, minute: 8))! + let end = utc.date(from: DateComponents(year: 2026, month: 10, day: 2, hour: 7, minute: 5))! + func resolve(_ target: Date, _ h: Int, _ m: Int) -> DateComponents { + utc.dateComponents([.day, .hour, .minute], from: SleepRecordExtension.resolveAdjustedTime( + target: target, recordStart: start, recordEnd: end, hour: h, minute: m, calendar: utc)) + } + XCTAssertEqual(resolve(start, 23, 30), DateComponents(day: 1, hour: 23, minute: 30)) + XCTAssertEqual(resolve(start, 0, 15), DateComponents(day: 2, hour: 0, minute: 15)) + XCTAssertEqual(resolve(end, 6, 45), DateComponents(day: 2, hour: 6, minute: 45)) + } + + func testResolveAdjustedTimeRollsAPreMidnightPickBackForAPostMidnightStart() { + let start = utc.date(from: DateComponents(year: 2026, month: 10, day: 2, hour: 0, minute: 15))! + let end = utc.date(from: DateComponents(year: 2026, month: 10, day: 2, hour: 6, minute: 30))! + let resolved = SleepRecordExtension.resolveAdjustedTime(target: start, recordStart: start, recordEnd: end, + hour: 23, minute: 45, calendar: utc) + XCTAssertEqual(utc.dateComponents([.day, .hour, .minute], from: resolved), DateComponents(day: 1, hour: 23, minute: 45)) + } + + func testResolveAdjustedTimePutsAMorningPickNextDayForAnEveningStart() { + let start = utc.date(from: DateComponents(year: 2026, month: 10, day: 1, hour: 19, minute: 30))! + let end = utc.date(from: DateComponents(year: 2026, month: 10, day: 2, hour: 8, minute: 0))! + let resolved = SleepRecordExtension.resolveAdjustedTime(target: start, recordStart: start, recordEnd: end, + hour: 7, minute: 45, calendar: utc) + XCTAssertEqual(utc.dateComponents([.day, .hour, .minute], from: resolved), DateComponents(day: 2, hour: 7, minute: 45)) + } + + // MARK: - Asleep, but unstaged and unscored + + private func summary(_ blocks: [SleepStageBlock], totalMinutes: Int, awake: Int? = nil) -> SleepSummary { + let session = SleepSession(date: wakingDay, startAt: blocks.map(\.startAt).min()!, endAt: end(blocks), totalMinutes: totalMinutes) + func sum(_ stage: SleepStage) -> Int { blocks.filter { $0.stage == stage }.reduce(0) { $0 + $1.durationMinutes } } + return SleepSummary(session: session, lightMinutes: sum(.light), deepMinutes: sum(.deep), + awakeMinutes: awake ?? SleepRecords.awakeMinutes(blocks), blocks: blocks) + } + + func testAddedMinutesCountAsAsleepButNotInTheScoreOrTheStageShares() throws { + let extended = a + (try XCTUnwrap(edited(b, bStart, end(a), end(b)))) + XCTAssertEqual(SleepRecords.asleepMinutes(extended), SleepRecords.asleepMinutes(a + b) + 72) + XCTAssertEqual(SleepRecords.measuredAsleepMinutes(extended), SleepRecords.asleepMinutes(a + b)) + XCTAssertEqual(SleepRecords.addedMinutes(extended), 72) + + let span = Int(end(extended).timeIntervalSince(aStart) / 60) + let withAdded = SleepScore.calculate(summary(extended, totalMinutes: span)) + // The same night as the ring measured it, judged over the same awake time. + let measuredOnly = SleepScore.calculate(summary(extended.filter { $0.stage != .added }, totalMinutes: span - 72, + awake: SleepRecords.awakeMinutes(extended))) + XCTAssertEqual(withAdded.deepPct, measuredOnly.deepPct) + XCTAssertEqual(withAdded.lightPct, measuredOnly.lightPct) + XCTAssertEqual(withAdded.score, measuredOnly.score) + } + + func testCoveringTheGapRemovesItFromAwake() throws { + XCTAssertEqual(SleepRecords.awakeMinutes(a + b), 72) + let extended = a + (try XCTUnwrap(edited(b, bStart, end(a), end(b)))) + XCTAssertEqual(SleepRecords.runs(extended).count, 2, "still two records") + XCTAssertEqual(SleepRecords.awakeMinutes(extended), 0) + + let partial = a + (try XCTUnwrap(edited(b, bStart, at(5, 46, 23), end(b)))) + XCTAssertEqual(SleepRecords.awakeMinutes(partial), 2) + } + + func testClosingAnHourPlusGapMakesTheTwoRecordsOneNight() throws { + XCTAssertEqual(SleepSegmentation.segment(a + b).count, 2) + let extended = a + (try XCTUnwrap(edited(b, bStart, end(a), end(b)))) + XCTAssertEqual(SleepSegmentation.segment(extended).count, 1) + } + + func testAddedMinutesExportToHealthKitAsAsleepUnspecified() { + XCTAssertEqual(HealthKitTypeMappings.sleepValue(.added), .asleepUnspecified) + } + + func testTheHypnogramReadoutNamesAddedMinutes() { + XCTAssertTrue(SleepHypnogramView.readoutText(stage: .added, startMinute: 0, durationMinutes: 10, startTs: nil) + .hasPrefix("Added by you · ")) + } + + // MARK: - The edit path and re-sync, end to end + + private let local = Calendar.current + private func localAt(_ h: Int, _ m: Int, _ s: Int = 0) -> Date { + local.date(from: DateComponents(year: 2026, month: 10, day: 8))!.addingTimeInterval(TimeInterval((h * 60 + m) * 60 + s)) + } + + private func night(_ context: ModelContext) throws -> SleepSession { + try XCTUnwrap(SleepRepository.sessions(context: context).first) + } + + /// The session holding the record that starts at `recordStart`. + private func session(holding recordStart: Date, _ context: ModelContext) throws -> SleepSession { + try XCTUnwrap(SleepRepository.sessions(context: context).first { row in + SleepRepository.blocks(sessionId: row.id, context: context).contains { $0.recordStartAt == recordStart } + }) + } + + private func dayBlocks(_ context: ModelContext) -> [SleepStageBlock] { + ((try? context.fetch(FetchDescriptor())) ?? []).sorted { $0.startAt < $1.startAt } + } + + private func seedReportedNight(_ subscriber: EventPersistenceSubscriber) { + subscriber.persist(.sleepTimeline(timestamp: localAt(0, 51, 23), stages: aStages)) + subscriber.persist(.sleepTimeline(timestamp: localAt(6, 56, 23), stages: bStages)) + subscriber.flush() + } + + func testExtendingIntoTheGapIsStoredAndSurvivesReSync() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + seedReportedNight(subscriber) + // Without a quiet-hours bridge the 72-minute hole splits the night in two sessions. + XCTAssertEqual(SleepRepository.sessions(context: context).count, 2) + let second = try session(holding: localAt(6, 56, 23), context) + + XCTAssertTrue(SleepRecordEditor.edit(sessionId: second.id, recordStartAt: localAt(6, 56, 23), + newStart: localAt(5, 46), newEnd: localAt(8, 7, 23), context: context)) + let afterEdit = shape(dayBlocks(context)) + XCTAssertEqual(SleepRecords.addedMinutes(dayBlocks(context)), 70) + // Closing all but 2 minutes of the hole makes the two records one night again. + XCTAssertEqual(SleepRepository.sessions(context: context).count, 1) + XCTAssertEqual(SleepService.summary(for: try night(context), context: context).awakeMinutes, 2) + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.kind, .edited) + + seedReportedNight(subscriber) + XCTAssertEqual(shape(dayBlocks(context)), afterEdit, "a re-sync writes back exactly the edit's blocks") + XCTAssertEqual(SleepRepository.sessions(context: context).count, 1) + } + + func testTrimmingIsStoredAndTrimmedMinutesDontComeBack() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + seedReportedNight(subscriber) + let first = try session(holding: localAt(0, 51, 23), context) + + XCTAssertTrue(SleepRecordEditor.edit(sessionId: first.id, recordStartAt: localAt(0, 51, 23), + newStart: localAt(1, 21, 23), newEnd: localAt(5, 44, 23), context: context)) + XCTAssertEqual(try session(holding: localAt(0, 51, 23), context).startAt, localAt(1, 21, 23)) + let afterEdit = shape(dayBlocks(context)) + + seedReportedNight(subscriber) + XCTAssertEqual(shape(dayBlocks(context)), afterEdit) + XCTAssertEqual(try session(holding: localAt(0, 51, 23), context).startAt, localAt(1, 21, 23)) + } + + func testAnUnchangedEditIsRefused() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + seedReportedNight(subscriber) + let second = try session(holding: localAt(6, 56, 23), context) + XCTAssertFalse(SleepRecordEditor.edit(sessionId: second.id, recordStartAt: localAt(6, 56, 23), + newStart: localAt(6, 56, 23), newEnd: localAt(8, 7, 23), context: context)) + XCTAssertTrue(try context.fetch(FetchDescriptor()).isEmpty) + } + + func testDeletingAnEditedRecordClearsTheEdit() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + seedReportedNight(subscriber) + XCTAssertTrue(SleepRecordEditor.edit(sessionId: try session(holding: localAt(6, 56, 23), context).id, + recordStartAt: localAt(6, 56, 23), + newStart: localAt(5, 46), newEnd: localAt(8, 7, 23), context: context)) + XCTAssertTrue(SleepRecordEditor.delete(sessionId: try night(context).id, recordStartAt: localAt(6, 56, 23), context: context)) + + let kinds = try context.fetch(FetchDescriptor()).compactMap(\.kind) + XCTAssertFalse(kinds.contains(.edited)) + XCTAssertTrue(kinds.contains(.recordDeleted)) + seedReportedNight(subscriber) + XCTAssertEqual(SleepRecords.runs(dayBlocks(context)).count, 1) + XCTAssertEqual(SleepRecords.addedMinutes(dayBlocks(context)), 0) + } + + /// The ring's measurement beats an extension whichever arrives first: a record extended back + /// over time a neighbour later turns out to have measured gives the neighbour its minutes. + func testANeighbourArrivingLaterTrimsTheAddedMinutes() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(.sleepTimeline(timestamp: localAt(6, 56, 23), stages: bStages)) + subscriber.flush() + let session = try night(context) + XCTAssertTrue(SleepRecordEditor.edit(sessionId: session.id, recordStartAt: localAt(6, 56, 23), + newStart: localAt(5, 0), newEnd: localAt(8, 7, 23), context: context)) + XCTAssertEqual(SleepRecords.addedMinutes(dayBlocks(context)), 116) + + seedReportedNight(subscriber) + let blocks = dayBlocks(context) + XCTAssertEqual(SleepRecords.addedMinutes(blocks), 72, "only the gap the ring missed stays added") + XCTAssertEqual(SleepRecords.asleepMinutes(blocks), 293 + 72 + 71) + seedReportedNight(subscriber) + XCTAssertEqual(shape(dayBlocks(context)), shape(blocks), "and re-syncing again changes nothing") + } +} From b578b75e9640a34d8aa23580a8a33cd2b63a7604 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:24:09 -0500 Subject: [PATCH 14/20] Close one-second seams in YCBT sleep, round the gaps between records, and draw them on the hypnogram --- .../RingProtocol/YCBTHealthRecords.swift | 96 +++++++-- PulseLoop/Services/SleepRecords.swift | 38 +++- PulseLoop/Views/SleepView.swift | 3 +- PulseLoopTests/SleepGapsTests.swift | 193 ++++++++++++++++++ PulseLoopTests/YCBTHealthRecordsTests.swift | 21 +- 5 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 PulseLoopTests/SleepGapsTests.swift diff --git a/PulseLoop/RingProtocol/YCBTHealthRecords.swift b/PulseLoop/RingProtocol/YCBTHealthRecords.swift index 69140486..caadff4a 100644 --- a/PulseLoop/RingProtocol/YCBTHealthRecords.swift +++ b/PulseLoop/RingProtocol/YCBTHealthRecords.swift @@ -241,6 +241,9 @@ enum YCBTHealthRecords { /// firmware repeats a segment inside one session; because the timeline is laid out *positionally* /// from the session start, a repeat would both inflate that stage's minutes and shift every later /// block by its duration — and the shifted copies re-insert as new blocks on the next re-sync. + /// + /// Each session's timeline is placed against the header's own `start`/`end` (`placeStages`), so a + /// record ends where the ring says it ended rather than where its rounded segments add up to. static func sleep(_ buffer: [UInt8]) -> [RingDecodedEvent] { let headerLength = 20 let segmentLength = 8 @@ -249,6 +252,10 @@ enum YCBTHealthRecords { var cursor = 0 while cursor + headerLength <= buffer.count { let recordLength = YCBTBytes.u16(buffer, cursor + 2) + // The vendor reads the session's own bounds out of the header (`DataUnpack`'s + // `startTime` at +4, `endTime` at +8) rather than inferring them from the segments. + let headerStart = YCBTBytes.u32(buffer, cursor + 4) + let headerEnd = YCBTBytes.u32(buffer, cursor + 8) let segmentsStart = cursor + headerLength // Trust the record length, but never past the bytes we actually hold (a truncated transfer // must not walk off the end — the SDK's own loop has no such guard). @@ -256,29 +263,25 @@ enum YCBTHealthRecords { let available = (buffer.count - segmentsStart) / segmentLength let segmentCount = min(declared, available) - var stages: [SleepStage] = [] - var sessionStart: Date? + var segments: [SleepSegment] = [] var seenStarts: Set = [] for index in 0.. 0 else { continue } + guard seenStarts.insert(segmentStart).inserted else { continue } // firmware repeat — count once + segments.append(SleepSegment(stage: stage, startSeconds: segmentStart, seconds: segmentSeconds)) } - if let start = sessionStart, !stages.isEmpty { - events.append(.sleepTimeline(timestamp: start, stages: stages)) + let stages = placeStages(segments, headerStart: headerStart, headerEnd: headerEnd) + if let first = segments.first, !stages.isEmpty { + let start = usableHeaderBounds(headerStart, headerEnd) ? headerStart : first.startSeconds + events.append(.sleepTimeline(timestamp: YCBTBytes.date(start), stages: stages)) } // Advance exactly as the SDK does — to the end of the segments we consumed. A bogus // `recordLen` therefore still moves the cursor by at least the header, so we can't spin. @@ -287,6 +290,69 @@ enum YCBTHealthRecords { return events } + /// One session's stage segment, kept with its own start. + private struct SleepSegment { + let stage: SleepStage + let startSeconds: Int + let seconds: Int + } + + /// A hole between segments shorter than this much real time is a seam, not a waking. + static let sleepSeamSeconds = 60 + + private static func usableHeaderBounds(_ start: Int, _ end: Int) -> Bool { + start > 0 && end > start && (end - start) / 60 <= maxSleepSessionMinutes + } + + /// A session's minute-by-minute stage timeline. + /// + /// Each segment is placed at its own start for its own length, and the run spans exactly the + /// header's `start`...`end`. Concatenating rounded segment minutes instead drifts from the + /// declared end (470 against 474 on the captured night), which can make one record of a split + /// night appear to abut the next. A minute no segment claims reads as awake — the ring reports + /// wake as its own segment type, so an unclaimed minute is one it did not call sleep. + /// + /// **A hole under a minute of real time is closed** (Android #111): the ring leaves a one-second + /// seam between segments, and rounding each side to the minute grid independently can strand a + /// whole AWAKE minute in it (a segment ending at minute 315.48 rounds to 315, the next starting at + /// 315.5 rounds to 316). So each segment runs on to the next one's start — the first from the + /// record's start, the last to its end — across any such hole. Holes of a minute or more stay awake. + /// + /// The timeline is expanded positionally — one entry per minute — so a bogus duration doesn't + /// just mis-size one stage, it allocates: the u24 field reaches 194 days. No real session exceeds + /// a day, so both paths cap there. A session with unusable header bounds (synthetic, truncated) + /// keeps the concatenation, since there is nothing better to place against. + private static func placeStages(_ segments: [SleepSegment], headerStart: Int, headerEnd: Int) -> [SleepStage] { + guard !segments.isEmpty else { return [] } + guard usableHeaderBounds(headerStart, headerEnd) else { + var stages: [SleepStage] = [] + for segment in segments { + let remaining = maxSleepSessionMinutes - stages.count + if remaining <= 0 { break } + let minutes = Int((Double(segment.seconds) / 60.0).rounded()) + stages.append(contentsOf: Array(repeating: segment.stage, count: min(max(1, minutes), remaining))) + } + return stages + } + let total = min(max(1, Int((Double(headerEnd - headerStart) / 60.0).rounded())), maxSleepSessionMinutes) + var timeline = Array(repeating: SleepStage.awake, count: total) + let ordered = segments.sorted { $0.startSeconds < $1.startSeconds } + for (index, segment) in ordered.enumerated() { + var start = segment.startSeconds + if index == 0, (0..= end }?.startSeconds ?? headerEnd + if (0.. SleepStage? { diff --git a/PulseLoop/Services/SleepRecords.swift b/PulseLoop/Services/SleepRecords.swift index 4f948073..45dbcbc2 100644 --- a/PulseLoop/Services/SleepRecords.swift +++ b/PulseLoop/Services/SleepRecords.swift @@ -83,12 +83,40 @@ enum SleepRecords { return runs } - /// The waking between two consecutive records of one session, in minutes — 0 for the one-minute - /// reopen seam. The one rule the Awake figure and the RECORDS card's "Awake Xm between" line - /// both read (Android #81). + /// The waking between two consecutive records of one session, in minutes. The one rule the + /// Awake figure, the RECORDS card's "Awake Xm between" line and the hypnogram's gap blocks all + /// read, so they can't disagree about the same gap (Android #81/#111). + /// + /// **Any gap is a waking, and counts at least one minute.** A reporter checked every + /// between-record gap on their ring over six weeks: the shortest (52 s, 88 s) were get-ups they + /// had logged, and none was a reopen without waking. The ring opens a record on a whole minute, + /// so how short a gap looks depends on where in the minute the last one ended. + /// + /// **Rounded, ties down, not truncated:** a record's stored end is its start plus whole minutes, + /// up to 30 s either side of the ring's own, so a 2m07 waking can be stored as 1m46. Records that + /// touch exactly (a Jring's back-to-back packets) count nothing. static func betweenRecordAwakeMinutes(_ earlier: SleepRecordRun, _ later: SleepRecordRun) -> Int { - let gapMinutes = Int(later.startAt.timeIntervalSince(earlier.endAt) / 60) - return gapMinutes >= 2 ? gapMinutes : 0 + let gap = later.startAt.timeIntervalSince(earlier.endAt) + guard gap > 0 else { return 0 } + return max(1, Int(((gap - 30) / 60).rounded(.up))) + } + + /// The blocks to draw on the hypnogram: the session's own, plus an awake block over each gap + /// between records that `betweenRecordAwakeMinutes` counts (Android #111). Without it the chart + /// draws two records back to back while the Awake and RECORDS cards count the minutes between. + /// Display only — never feed these back into `awakeMinutes`, which counts the gaps itself. + static func hypnogramBlocks(_ blocks: [SleepStageBlock]) -> [SleepStageBlock] { + let runs = runs(blocks) + guard runs.count > 1 else { return blocks } + let gaps: [SleepStageBlock] = zip(runs, runs.dropFirst()).compactMap { earlier, later in + guard betweenRecordAwakeMinutes(earlier, later) > 0 else { return nil } + let fromMinute = earlier.blocks.map { $0.startMinute + $0.durationMinutes }.max() ?? 0 + let untilMinute = later.blocks.map(\.startMinute).min() ?? fromMinute + guard untilMinute > fromMinute else { return nil } + return SleepStageBlock(sessionId: earlier.blocks[0].sessionId, startAt: earlier.endAt, startMinute: fromMinute, + durationMinutes: untilMinute - fromMinute, stage: .awake) + } + return blocks + gaps } /// Awake minutes for a session: the stages the ring marked awake plus the gaps between its diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index 4eb6a282..db23990f 100644 --- a/PulseLoop/Views/SleepView.swift +++ b/PulseLoop/Views/SleepView.swift @@ -148,7 +148,8 @@ struct SleepView: View { scoreLabel: score.label.rawValue ) SleepVisualizationCard(eyebrow: "Stages", title: "Sleep architecture", legend: true) { - SleepHypnogramView(blocks: s.blocks, totalMin: s.session.totalMinutes, startTs: s.session.startAt, + // Plus an awake block over each counted gap between records (display only). + SleepHypnogramView(blocks: SleepRecords.hypnogramBlocks(s.blocks), totalMin: s.session.totalMinutes, startTs: s.session.startAt, onScrubActiveChanged: { hypnogramScrubbing = $0 }) } // The ring records behind the merged night, single-record nights included, so any record diff --git a/PulseLoopTests/SleepGapsTests.swift b/PulseLoopTests/SleepGapsTests.swift new file mode 100644 index 00000000..d5ae9ef9 --- /dev/null +++ b/PulseLoopTests/SleepGapsTests.swift @@ -0,0 +1,193 @@ +import XCTest +@testable import PulseLoop + +/// The gaps inside and between ring sleep records (Android #111/#116, `SleepGapsTest` and the +/// placement cases of `YCBTHealthRecordsTest`): how a hole between stage segments is placed, how +/// long a gap between records is, and how the hypnogram draws it. +@MainActor +final class SleepGapsTests: XCTestCase { + private var utc: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + return c + }() + + /// Oct 11 2026 UTC wall-clock time. + private func at(_ hour: Int, _ minute: Int, _ second: Int = 0) -> Date { + utc.date(from: DateComponents(year: 2026, month: 10, day: 11, hour: hour, minute: minute, second: second))! + } + + private func record(_ start: Date, _ minutes: Int, _ stage: SleepStage = .light) -> [SleepStageBlock] { + SleepRecords.stageBlocks(start: start, stages: Array(repeating: stage, count: minutes)) + } + + /// Blocks as a stored session holds them: `startMinute` relative to the session's start. + private func session(_ blocks: [SleepStageBlock]) -> [SleepStageBlock] { + let start = blocks.map(\.startAt).min()! + for block in blocks { block.startMinute = Int(block.startAt.timeIntervalSince(start) / 60) } + return blocks + } + + // MARK: - The gap is rounded, not truncated + + /// The reporter's Oct 10–11 waking: the first record ended 07:08:53 and the next opened 07:11:00 + /// — 2m07. Its start (00:30:14) plus whole minutes stores the end at 07:09:14, leaving 1m46. + func testA2m07WakingIsNotLostToTheStoredEndsRounding() { + let blocks = record(at(0, 30, 14), 399) + record(at(7, 11), 60) + let runs = SleepRecords.runs(blocks) + XCTAssertEqual(SleepRecords.betweenRecordAwakeMinutes(runs[0], runs[1]), 2) + XCTAssertEqual(SleepRecords.awakeMinutes(blocks), 2) + } + + /// A 52 s get-up between records counts one awake minute: every gap is a waking. + func testA52SecondGetUpBetweenRecordsCountsOneMinute() { + let blocks = record(at(0, 30, 9), 243) + record(at(4, 34, 1), 60) // 04:33:09 → 04:34:01 + let runs = SleepRecords.runs(blocks) + XCTAssertEqual(SleepRecords.betweenRecordAwakeMinutes(runs[0], runs[1]), 1) + XCTAssertEqual(SleepRecords.awakeMinutes(blocks), 1) + } + + /// Ties round down: 90 s is one minute, 91 s two. + func testGapsRoundWithTiesDown() { + let a = SleepRecords.runs(record(at(1, 0), 30))[0] + func gap(_ seconds: Int) -> Int { + SleepRecords.betweenRecordAwakeMinutes(a, SleepRecords.runs(record(a.endAt.addingTimeInterval(TimeInterval(seconds)), 30))[0]) + } + XCTAssertEqual(gap(89), 1) + XCTAssertEqual(gap(90), 1) + XCTAssertEqual(gap(91), 2) + } + + /// Records that touch exactly, as a Jring's 15-minute packets do, have no waking between them. + func testBackToBackRecordsCountNoAwakeTime() { + let blocks = record(at(2, 0), 15) + record(at(2, 15), 15) + let runs = SleepRecords.runs(blocks) + XCTAssertEqual(SleepRecords.betweenRecordAwakeMinutes(runs[0], runs[1]), 0) + XCTAssertEqual(SleepRecords.awakeMinutes(blocks), 0) + } + + // MARK: - The hypnogram draws the counted gap + + func testTheHypnogramGetsAnAwakeBlockOverACountedGapForDisplayOnly() { + let blocks = session(record(at(0, 0), 60, .deep) + record(at(1, 15), 30)) + let drawn = SleepRecords.hypnogramBlocks(blocks) + let gaps = drawn.filter { block in !blocks.contains { $0 === block } } + XCTAssertEqual(gaps.count, 1) + XCTAssertEqual(gaps[0].stage, .awake) + XCTAssertEqual(gaps[0].startMinute, 60) + XCTAssertEqual(gaps[0].durationMinutes, 15) + XCTAssertEqual(SleepRecords.awakeMinutes(blocks), 15, "the Awake figure counts the gap itself") + } + + func testTheHypnogramDrawsNothingBetweenBackToBackRecords() { + let blocks = session(record(at(0, 0), 60) + record(at(1, 0), 30)) + XCTAssertEqual(SleepRecords.hypnogramBlocks(blocks).count, blocks.count) + } + + func testTheHypnogramDrawsAOneMinuteGap() { + let blocks = session(record(at(0, 0), 60) + record(at(1, 1), 30)) + let gaps = SleepRecords.hypnogramBlocks(blocks).filter { block in !blocks.contains { $0 === block } } + XCTAssertEqual(gaps.map(\.durationMinutes), [1]) + } + + // MARK: - YCBT: segments placed against the header's bounds + + private let base = 0x31def01c + + /// An `af fa` record with the header's start (+4) and end (+8) filled in; each segment carries its + /// own start: `(tag, startSeconds, seconds)`. + private func sleepRecord(_ start: Int, _ end: Int, _ segments: [(tag: UInt8, start: Int, seconds: Int)]) -> [UInt8] { + func le(_ value: Int, _ count: Int) -> [UInt8] { (0..> (8 * $0)) & 0xff) } } + var out: [UInt8] = [0xaf, 0xfa] + le(20 + segments.count * 8, 2) + le(start, 4) + le(end, 4) + out += [UInt8](repeating: 0, count: 8) + for segment in segments { out += [segment.tag] + le(segment.start, 4) + le(segment.seconds, 3) } + return out + } + + /// Contiguous segments from `start`, as a ring writes them. + private func contiguous(_ start: Int, _ segments: [(UInt8, Int)]) -> [(tag: UInt8, start: Int, seconds: Int)] { + var at = start + return segments.map { tag, seconds in defer { at += seconds }; return (tag, at, seconds) } + } + + private func timelines(_ buffer: [UInt8]) -> [(start: Date, stages: [SleepStage])] { + YCBTHealthRecords.sleep(buffer).compactMap { + if case let .sleepTimeline(start, stages) = $0 { return (start, stages) } else { return nil } + } + } + + /// The timeline spans exactly the header's declared start and end. + func testASessionSpansExactlyTheHeadersDeclaredBounds() { + let record = sleepRecord(base, base + 474 * 60 + 30, contiguous(base, [(0xf2, 200 * 60), (0xf1, 274 * 60)])) + let timeline = timelines(record)[0] + XCTAssertEqual(timeline.start, YCBTBytes.date(base)) + XCTAssertEqual(timeline.stages.count, 475) + } + + /// Two records a minute apart: the first's sub-minute segments round long under concatenation, + /// which would put its end on the second's start. Placed against the header, the gap survives. + func testARecordWhoseSegmentsRoundLongStillEndsAtItsDeclaredEnd() { + let firstEnd = base + 322 * 60 + let secondStart = firstEnd + 60 + let first = sleepRecord(base, firstEnd, contiguous(base, [(0xf2, 320 * 60)] + Array(repeating: (0xf1, 20), count: 4))) + let second = sleepRecord(secondStart, secondStart + 151 * 60, contiguous(secondStart, [(0xf2, 151 * 60)])) + let result = timelines(first + second) + XCTAssertEqual(result.count, 2) + XCTAssertEqual(result[0].stages.count, 322) + let firstEndDate = result[0].start.addingTimeInterval(322 * 60) + XCTAssertEqual(result[1].start.timeIntervalSince(firstEndDate), 60) + } + + func testMinutesNoSegmentClaimsReadAsAwake() { + let stages = timelines(sleepRecord(base, base + 60 * 60, contiguous(base, [(0xf2, 30 * 60)])))[0].stages + XCTAssertEqual(stages.count, 60) + XCTAssertEqual(stages.filter { $0 == .light }.count, 30) + XCTAssertEqual(stages.filter { $0 == .awake }.count, 30) + XCTAssertTrue(stages.prefix(30).allSatisfy { $0 == .light }) + } + + /// A zero-length segment sharing a real segment's start must not shadow it in the de-duplication. + func testAZeroLengthSegmentDoesNotShadowARealSegmentSharingItsStart() { + let record = sleepRecord(base, base + 10 * 60, [(0xf2, base, 5 * 60), (0xf1, base + 300, 0), (0xf3, base + 300, 5 * 60)]) + let stages = timelines(record)[0].stages + XCTAssertEqual(stages.filter { $0 == .light }.count, 5) + XCTAssertEqual(stages.filter { $0 == .rem }.count, 5) + XCTAssertFalse(stages.contains(.awake)) + } + + /// The reporter's Oct 8–9 night: LIGHT ends at minute 315.48 (rounds to 315), REM opens at 315.5 + /// (rounds to 316). Minute 315 used to keep the AWAKE filler. + func testAOneSecondSeamBetweenSegmentsLeavesNoAwakeMinute() { + let lightStart = base + 303 * 60 + 1 + let remStart = lightStart + 748 + 1 + let record = sleepRecord(base, remStart + 30 * 60, [ + (0xf1, base, 303 * 60 + 1), (0xf2, lightStart, 748), (0xf3, remStart, 30 * 60), + ]) + XCTAssertFalse(timelines(record)[0].stages.contains(.awake)) + } + + /// The last stage ends 06:58:33 and the record 06:58:34. + func testAOneSecondSeamBeforeTheRecordsEndLeavesNoAwakeMinute() { + let start = base + 20 + let record = sleepRecord(start, start + 120 * 60 + 31, [(0xf2, start, 120 * 60 + 30)]) + XCTAssertFalse(timelines(record)[0].stages.contains(.awake)) + } + + /// A hole the ring really left — a minute or more — still reads as wake. + func testAGapOfAMinuteOrMoreBetweenSegmentsStaysAwake() { + let record = sleepRecord(base, base + 63 * 60, [(0xf2, base, 30 * 60), (0xf1, base + 33 * 60, 30 * 60)]) + let stages = timelines(record)[0].stages + XCTAssertEqual(stages.count, 63) + XCTAssertEqual(stages.filter { $0 == .awake }.count, 3) + } + + /// A segment nested inside another must not hide the seam to the one that follows. + func testANestedSegmentDoesNotReopenTheSeamAfterItsContainer() { + let record = sleepRecord(base, base + 20 * 60, [ + (0xf2, base, 11 * 60 + 29), // ends at 11.48 min + (0xf1, base + 100, 100), // nested inside the first + (0xf3, base + 11 * 60 + 30, 8 * 60 + 30), + ]) + XCTAssertFalse(timelines(record)[0].stages.contains(.awake)) + } +} diff --git a/PulseLoopTests/YCBTHealthRecordsTests.swift b/PulseLoopTests/YCBTHealthRecordsTests.swift index 7ee63928..da8e2843 100644 --- a/PulseLoopTests/YCBTHealthRecordsTests.swift +++ b/PulseLoopTests/YCBTHealthRecordsTests.swift @@ -109,6 +109,17 @@ final class YCBTHealthRecordsTests: XCTestCase { XCTAssertFalse(stages.contains(.awake)) } + /// The captured night is placed against its header's own bounds: it starts at the declared start + /// and runs exactly to the declared end (474 min), where concatenating rounded segments drifted + /// to 470 — enough, on a split night, to make one record abut the next. + func testSleepSpansTheHeadersDeclaredStartAndEnd() { + guard case let .sleepTimeline(start, stages) = YCBTHealthRecords.sleep(capturedNight).first else { + return XCTFail("expected a sleepTimeline") + } + XCTAssertEqual(start, YCBTBytes.date(0x31dee99f)) + XCTAssertEqual(stages.count, (0x31df58bd - 0x31dee99f) / 60) + } + /// The buffer can hold several nights back to back — each session's 20-byte header carries its own /// length at [2..3], so the decoder walks them rather than assuming one. func testMultipleSessionsInOneBuffer() { @@ -296,8 +307,10 @@ final class YCBTHealthRecordsTests: XCTestCase { /// mis-size one stage, it allocates ~280 000 array entries for a single 8-byte record, and a buffer /// of them exhausts memory before anything gets a chance to reject the reading. func testSleepSegmentDurationIsClampedToOneDay() { - // header: recordLength 28 (20 header + 1 segment), then one DEEP segment of 0xFFFFFF seconds. - let record = bytes("00001c00" + "1cf0de31" + "a0f3de31" + "0000000000000000") + // header: recordLength 28 (20 header + 1 segment) and no start/end bounds — so the timeline is + // concatenated from the segments — then one DEEP segment of 0xFFFFFF seconds. (A record with + // header bounds is placed against them, which bounds it by construction.) + let record = bytes("00001c00" + "00000000" + "00000000" + "0000000000000000") + bytes("02" + "1cf0de31" + "ffffff") let events = YCBTHealthRecords.sleep(record) @@ -310,8 +323,8 @@ final class YCBTHealthRecordsTests: XCTestCase { /// A real night is nowhere near the cap, so the clamp must not touch it. func testRealisticSleepSegmentsAreNotClamped() { - // Two segments: 30 min deep, 45 min light. - let record = bytes("00002400" + "1cf0de31" + "a0f3de31" + "0000000000000000") + // Two segments: 30 min deep, 45 min light; no header bounds, so concatenated. + let record = bytes("00002400" + "00000000" + "00000000" + "0000000000000000") + bytes("01" + "1cf0de31" + "080700") // tag 1 = deep, 1800 s + bytes("02" + "44fdde31" + "8c0a00") // tag 2 = light, 2700 s guard case let .sleepTimeline(_, stages) = YCBTHealthRecords.sleep(record).first else { From 7189fb63e51cdd6a543530f73cbfee7a58e4bc92 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 23:52:34 -0500 Subject: [PATCH 15/20] Add an opt-in sleep quiet-hours schedule that trims imported sleep and keeps a night whole inside it --- PulseLoop/Events/PulseEventBus.swift | 22 +- .../Persistence/DataArchiveService.swift | 3 +- PulseLoop/Services/SleepRecordEditor.swift | 40 +-- PulseLoop/Services/SleepSegmentation.swift | 79 +++++- PulseLoop/Settings/SleepQuietHoursStore.swift | 121 +++++++++ .../Views/Settings/WearableSettingsView.swift | 45 ++++ PulseLoopTests/SleepQuietHoursTests.swift | 243 ++++++++++++++++++ 7 files changed, 521 insertions(+), 32 deletions(-) create mode 100644 PulseLoop/Settings/SleepQuietHoursStore.swift create mode 100644 PulseLoopTests/SleepQuietHoursTests.swift diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 79729f64..e70a3873 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -588,12 +588,22 @@ final class EventPersistenceSubscriber { // start and the same block starts on every re-send, so drop it here or the delete lasts // exactly one sync. let tombstones = SleepRecordTombstones(edits ?? ((try? context.fetch(FetchDescriptor())) ?? [])) - guard !tombstones.isRecordDeleted(start), !stages.isEmpty else { return } + guard !tombstones.isRecordDeleted(start) else { return } + + // Quiet-hours gate (Android #79/#80), opt-in and off by default: the record is trimmed to the + // minutes inside the wearer's clock window before any write — the still-wrist/sofa case — + // rather than judged by its start, since the ring often runs the sofa hour and the real night + // as one record. Read per record, so a settings change applies from the next packet. Trimmed + // minutes are never written. The record keeps its declared start as its identity. + guard let kept = SleepQuietHours.load().keptMinutes(start: start, count: stages.count, calendar: calendar) + else { return } + let keptStart = start.addingTimeInterval(Double(kept.lowerBound) * 60) + let keptStages = Array(stages[kept]) // Group packets by the waking-day boundary (sleep from 7 PM rolls to the next morning) so a // night that starts before midnight lands under the morning of waking. See // `Calendar.wakingDay(forSleepStart:)`. - let dateKey = calendar.wakingDay(forSleepStart: start) + let dateKey = calendar.wakingDay(forSleepStart: keptStart) // 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 @@ -605,15 +615,15 @@ final class EventPersistenceSubscriber { var existingDayBlocks = (blocks ?? ((try? context.fetch(FetchDescriptor())) ?? [])) .filter { daySessionIds.contains($0.sessionId) } - var recordBlocks = SleepRecords.stageBlocks(start: start, stages: stages) + var recordBlocks = SleepRecords.stageBlocks(start: keptStart, stages: keptStages, recordStartAt: start) if let edit = tombstones.editedBounds(start) { // Edit gate (Android #92/#130): the wearer's bounds for this record, re-applied through // the same function the edit used, so this sync reproduces the edit's blocks exactly — // trimming, and re-creating any minutes they added. Added minutes give way to any other // record already stored: the ring's measurement beats an extension. - let ringEnd = start.addingTimeInterval(Double(stages.count) * 60) + let ringEnd = keptStart.addingTimeInterval(Double(keptStages.count) * 60) let bounds = SleepRecordExtension.clampToNeighbours( - editStart: edit.start, editEnd: edit.end, measuredStart: start, measuredEnd: ringEnd, + editStart: edit.start, editEnd: edit.end, measuredStart: keptStart, measuredEnd: ringEnd, others: existingDayBlocks.filter { $0.recordStartAt.map(SleepRecords.key) != SleepRecords.key(start) }, night: SleepRecordExtension.nightWindow(wakingDay: dateKey, calendar: calendar)) guard let edited = SleepRecordExtension.editedBlocks(recordBlocks, gridOrigin: start, @@ -626,7 +636,7 @@ final class EventPersistenceSubscriber { let container = daySessions.min { $0.startAt < $1.startAt } ?? { - let new = SleepSession(date: dateKey, startAt: start, endAt: start, totalMinutes: 0, syncedAt: Date()) + let new = SleepSession(date: dateKey, startAt: keptStart, endAt: keptStart, totalMinutes: 0, syncedAt: Date()) context.insert(new) return new }() diff --git a/PulseLoop/Persistence/DataArchiveService.swift b/PulseLoop/Persistence/DataArchiveService.swift index a55fabd0..99760728 100644 --- a/PulseLoop/Persistence/DataArchiveService.swift +++ b/PulseLoop/Persistence/DataArchiveService.swift @@ -40,7 +40,8 @@ enum DataArchiveService { "pulseloop.workoutprefs.v1", "pulseloop.calibration.v1", "pulseloop.coach.settings.v1", - "pulseloop.applehealth.prefs.v1" + "pulseloop.applehealth.prefs.v1", + SleepQuietHours.storageKey ] /// Rows processed between `Task.yield()`s while mapping models to DTOs during export. diff --git a/PulseLoop/Services/SleepRecordEditor.swift b/PulseLoop/Services/SleepRecordEditor.swift index 82080a33..287bcaf6 100644 --- a/PulseLoop/Services/SleepRecordEditor.swift +++ b/PulseLoop/Services/SleepRecordEditor.swift @@ -73,9 +73,7 @@ enum SleepRecordEditor { remember(.recordDeleted, run.recordStartAt) for block in run.blocks { remember(.blockDeleted, block.startAt) } - let removed = Set(run.blocks.map(\.id)) - for block in run.blocks { context.delete(block) } - restate(day: day, removing: removed, context: context) + restate(day: day, removing: run.blocks, context: context) return true } @@ -119,13 +117,8 @@ enum SleepRecordEditor { context.insert(SleepRecordEdit(wakingDay: day, recordStartAt: run.recordStartAt, kind: .edited, newStartAt: bounds.start, newEndAt: bounds.end)) - let removed = Set(run.blocks.map(\.id)) - for block in run.blocks { context.delete(block) } - for block in edited { - block.sessionId = sessionId - context.insert(block) - } - restate(day: day, removing: removed, adding: edited, context: context) + for block in edited { block.sessionId = sessionId } + restate(day: day, removing: run.blocks, adding: edited, context: context) return true } @@ -135,20 +128,29 @@ enum SleepRecordEditor { } } - /// Re-derive waking day `day` from its blocks less `removing` (plus `adding`), then stamp the surviving rows as - /// changed (a deleted middle record leaves the bounds where they were, which reconcile alone - /// would treat as no change) and recompute any stored score. Saves and signals the UI. - static func restate(day: Date, removing: Set, adding: [SleepStageBlock] = [], context: ModelContext) { + /// Replace `removing` with `adding` and re-derive waking day `day` from the result, then stamp + /// the surviving rows as changed (a deleted middle record leaves the bounds where they were, + /// which reconcile alone would treat as no change) and recompute any stored score. Saves and + /// signals the UI. + static func restate(day: Date, removing: [SleepStageBlock], adding: [SleepStageBlock] = [], context: ModelContext) { let calendar = Calendar.current let daySessions = ((try? context.fetch(FetchDescriptor())) ?? []) .filter { calendar.isDate($0.date, inSameDayAs: day) } let ids = Set(daySessions.map(\.id)) - // `adding` is passed explicitly: a fetch need not see blocks inserted since the last save. - let added = Set(adding.map(\.id)) - let dayBlocks = ((try? context.fetch(FetchDescriptor())) ?? []) - .filter { ids.contains($0.sessionId) && !removing.contains($0.id) && !added.contains($0.id) } + adding + let stored = ((try? context.fetch(FetchDescriptor())) ?? []).filter { ids.contains($0.sessionId) } + // Segment with the sync's own bridge, plus every gap the stored nights already hold: a gap a + // quiet-hours window bridged when the night was written stays bridged whatever the schedule + // says today, or this restate would split a night no re-sync can merge back. + let bridge = SleepGapBridge.fromSettings() + .keepingGaps(of: Array(Dictionary(grouping: stored, by: \.sessionId).values)) + + let removed = Set(removing.map(\.id)) + for block in removing { context.delete(block) } + for block in adding { context.insert(block) } + // `adding` is passed on explicitly: a fetch need not see blocks inserted since the last save. + let dayBlocks = stored.filter { !removed.contains($0.id) } + adding SleepService.reconcileWakingDay(dateKey: day, context: context, - daySessions: daySessions, dayBlocks: dayBlocks) + daySessions: daySessions, dayBlocks: dayBlocks, bridge: bridge) try? context.save() let now = Date() diff --git a/PulseLoop/Services/SleepSegmentation.swift b/PulseLoop/Services/SleepSegmentation.swift index ca5ba615..4ca22077 100644 --- a/PulseLoop/Services/SleepSegmentation.swift +++ b/PulseLoop/Services/SleepSegmentation.swift @@ -12,9 +12,13 @@ enum SleepSegmentation { 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]] { + /// block's end to the next block's start is >= `sessionGapMinutes`, unless `bridge` says the + /// wearer's quiet-hours window spans it (Android #105). Input need not be sorted. Returns + /// time-ordered groups; empty in -> empty out. + /// + /// Every writer of sessions — the sync's reconcile and the record editor's restate — must pass + /// the same bridge, or an edit would split a night the next sync merges back. + static func segment(_ blocks: [SleepStageBlock], bridge: SleepGapBridge = .none) -> [[SleepStageBlock]] { let sorted = blocks.sorted { $0.startAt < $1.startAt } guard let firstBlock = sorted.first else { return [] } let gapSeconds = Double(sessionGapMinutes) * 60 @@ -22,7 +26,7 @@ enum SleepSegmentation { var current: [SleepStageBlock] = [firstBlock] var prevEnd = firstBlock.startAt.addingTimeInterval(Double(firstBlock.durationMinutes) * 60) for block in sorted.dropFirst() { - if block.startAt.timeIntervalSince(prevEnd) >= gapSeconds { + if block.startAt.timeIntervalSince(prevEnd) >= gapSeconds, !bridge.bridges(prevEnd, block.startAt) { groups.append(current) current = [block] } else { @@ -37,6 +41,65 @@ enum SleepSegmentation { } } +/// Whether a gap of `SleepSegmentation.sessionGapMinutes` or more between two runs of sleep is still +/// one night (Android #105, `SleepGapBridge`). +/// +/// The hour-gap rule keeps a nap apart from the night, but it also split a night whose wearer lay +/// awake for 72 minutes between two ring records, both inside their quiet hours. The wearer's own +/// window is better evidence than the gap's length, so a gap wholly inside it is bridged: the two +/// parts stay one session and the gap reads as "Awake Xm between" records. +struct SleepGapBridge { + let bridges: (_ gapStart: Date, _ gapEnd: Date) -> Bool + + static let none = SleepGapBridge { _, _ in false } + + /// The bridge for the stored quiet-hours schedule. Reads it once. + static func fromSettings(calendar: Calendar = .current) -> SleepGapBridge { + forSchedule(SleepQuietHours.load(), calendar: calendar) + } + + /// Bridges a gap whose every minute lies inside the clock window — only while the schedule is on. + /// A window whose start equals its end means "trimming off", not "asleep all day": bridging under + /// it would merge an afternoon nap into the night, so it bridges nothing. Nor does a gap of a day + /// or more. + static func forSchedule(_ quiet: SleepQuietHours, calendar: Calendar = .current) -> SleepGapBridge { + let (start, end) = (quiet.startMinutes, quiet.endMinutes) + guard quiet.enabled, start != end, !(start == 0 && end >= 24 * 60) else { return .none } + func covered(_ date: Date) -> Bool { + SleepQuietHours.covers(start: start, end: end, minuteOfDay: SleepQuietHours.minuteOfDay(date, calendar: calendar)) + } + return SleepGapBridge { gapStart, gapEnd in + guard gapEnd > gapStart, gapEnd.timeIntervalSince(gapStart) < 24 * 3600 else { return false } + var t = gapStart + while t < gapEnd { + if !covered(t) { return false } + t = t.addingTimeInterval(60) + } + return covered(gapEnd.addingTimeInterval(-0.001)) + } + } + + /// This bridge, plus every gap a stored session already holds open. A session holds an hour-plus + /// gap only because a bridge allowed it when the night was written, so re-segmenting after a + /// delete or edit must keep it whole even if the schedule changed since (no re-sync can re-merge + /// a night the ring no longer has). `sessions` are the stored sessions' blocks, one group each: + /// a gap is kept when it lies inside one session's span and none of its blocks overlaps it — so + /// a gap a deleted record leaves behind is judged afresh, and the gap to a separate nap isn't kept. + func keepingGaps(of sessions: [[SleepStageBlock]]) -> SleepGapBridge { + // Snapshot the intervals now: the caller may delete some of these blocks before segmenting. + let spans: [(start: Date, end: Date, blocks: [(start: Date, end: Date)])] = sessions.compactMap { blocks in + guard let start = blocks.map(\.startAt).min(), let end = blocks.map(\.endAt).max() else { return nil } + return (start, end, blocks.map { ($0.startAt, $0.endAt) }) + } + return SleepGapBridge { gapStart, gapEnd in + bridges(gapStart, gapEnd) || spans.contains { span in + span.start <= gapStart && gapEnd <= span.end + && !span.blocks.contains { $0.start < gapEnd && $0.end > gapStart } + } + } + } +} + @MainActor extension SleepService { /// Re-derive one waking day's sessions from its raw stage blocks: split by @@ -54,9 +117,13 @@ extension SleepService { /// 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. + /// + /// `bridge` decides which hour-plus gaps still belong to one night; it defaults to the stored + /// quiet-hours schedule (`SleepGapBridge.fromSettings`). static func reconcileWakingDay(dateKey: Date, context: ModelContext, daySessions injectedSessions: [SleepSession]? = nil, - dayBlocks injectedBlocks: [SleepStageBlock]? = nil) { + dayBlocks injectedBlocks: [SleepStageBlock]? = nil, + bridge: SleepGapBridge? = nil) { let calendar = Calendar.current let day = calendar.startOfDay(for: dateKey) let now = Date() @@ -74,7 +141,7 @@ extension SleepService { .filter { daySessionIds.contains($0.sessionId) } } - let rawSegments = SleepSegmentation.segment(dayBlocks) + let rawSegments = SleepSegmentation.segment(dayBlocks, bridge: bridge ?? .fromSettings()) // No blocks left on this day — drop the empty rows entirely. guard !rawSegments.isEmpty else { diff --git a/PulseLoop/Settings/SleepQuietHoursStore.swift b/PulseLoop/Settings/SleepQuietHoursStore.swift new file mode 100644 index 00000000..29340944 --- /dev/null +++ b/PulseLoop/Settings/SleepQuietHoursStore.swift @@ -0,0 +1,121 @@ +import Foundation + +/// The opt-in "sleep quiet hours" schedule (Android #79/#80): ignore sleep the ring records outside +/// a daily clock window. A still wrist reads as sleep, so an evening on the sofa lands as a phantom +/// record and the night runs an hour high; the wearer knows their bedtime, the sensor guesses. +/// +/// **Minute-level, not record-level.** A record is trimmed to the minutes inside the window +/// (`keptMinutes`) rather than accepted or declined by its start: the ring often opens one record on +/// the sofa and runs it straight on into the real night. +/// +/// **Drop-on-import, and not recoverable.** Trimmed minutes are never written, so widening the +/// window later can't bring back what was skipped — the alternative (import everything, hide +/// outside the window) would leave the ring's figure and the displayed one forever disagreeing. +/// Off by default for exactly this reason. Android's Bedtime/DND source is an Android platform +/// feature and isn't ported: on iOS the clock schedule is the only source. +struct SleepQuietHours: Codable, Equatable { + var enabled = false + /// Window start, minutes of the local day (default 22:00). + var startMinutes = 22 * 60 + /// Window end, minutes of the local day (default 07:00). + var endMinutes = 7 * 60 + + static let `default` = SleepQuietHours() + static let storageKey = "pulseloop.sleepquiethours.v1" + + init() {} + + init(enabled: Bool, startMinutes: Int, endMinutes: Int) { + self.enabled = enabled + self.startMinutes = startMinutes + self.endMinutes = endMinutes + } + + /// Tolerant decode: a missing key falls back to its default, so a blob from an older build is + /// never discarded. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let d = SleepQuietHours.default + enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? d.enabled + startMinutes = try c.decodeIfPresent(Int.self, forKey: .startMinutes) ?? d.startMinutes + endMinutes = try c.decodeIfPresent(Int.self, forKey: .endMinutes) ?? d.endMinutes + } + + /// The stored schedule, read at use time so a settings change applies from the next record. + static func load(from defaults: UserDefaults = .standard) -> SleepQuietHours { + guard let data = defaults.data(forKey: storageKey), + let value = try? JSONDecoder().decode(SleepQuietHours.self, from: data) else { return .default } + return value + } + + /// Which minutes of a ring record starting at `start` with `count` stage minutes to keep, as + /// indices into its stages: the whole record when the schedule is off, else the clock window's + /// longest stretch (`keptMinutes`). Nil drops the record. + func keptMinutes(start: Date, count: Int, calendar: Calendar = .current) -> Range? { + guard count > 0 else { return nil } + guard enabled else { return 0.. Bool { + if start == end { return true } + return start < end ? (start..= start || minuteOfDay < end) + } + + /// The longest contiguous stretch of a record's minutes inside the window. Longest-stretch rather + /// than every in-window minute because the window is one daily interval: a record long enough to + /// touch two of them must not bridge the hole between with a record that was never contiguous. + /// Nil when no minute is inside. + static func keptMinutes(start: Date, count: Int, startMinutes: Int, endMinutes: Int, + calendar: Calendar = .current) -> Range? { + guard count > 0 else { return nil } + var best: Range? + var runStart: Int? + for i in 0...count { + let inside = i < count && covers(start: startMinutes, end: endMinutes, + minuteOfDay: minuteOfDay(start.addingTimeInterval(Double(i) * 60), calendar: calendar)) + if inside, runStart == nil { runStart = i } + if !inside, let from = runStart { + if (i - from) > (best?.count ?? 0) { best = from.. Int { + let parts = calendar.dateComponents([.hour, .minute], from: date) + return (parts.hour ?? 0) * 60 + (parts.minute ?? 0) + } +} + +/// Observable, `UserDefaults`-backed store for the quiet-hours schedule (the Settings binding). +/// Follows the `NutritionPrefsStore` pattern; the import reads `SleepQuietHours.load()` directly. +@MainActor +@Observable +final class SleepQuietHoursStore { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + static let shared = SleepQuietHoursStore() + + private let defaults: UserDefaults + + var prefs: SleepQuietHours { + didSet { + guard let data = try? JSONEncoder().encode(prefs) else { return } + defaults.set(data, forKey: SleepQuietHours.storageKey) + } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.prefs = SleepQuietHours.load(from: defaults) + } +} diff --git a/PulseLoop/Views/Settings/WearableSettingsView.swift b/PulseLoop/Views/Settings/WearableSettingsView.swift index 47156040..ef23cecd 100644 --- a/PulseLoop/Views/Settings/WearableSettingsView.swift +++ b/PulseLoop/Views/Settings/WearableSettingsView.swift @@ -61,6 +61,7 @@ struct WearableSettingsView: View { SecondaryButton(title: "Forget ring", systemImage: "trash") { ble.forget() } } } + SleepTrimmingSection() } .padding() } @@ -69,6 +70,50 @@ struct WearableSettingsView: View { } } +/// "Sleep trimming" (Android #79/#80): the opt-in quiet-hours schedule. When on, imported sleep is +/// trimmed to the clock window and a long waking inside it no longer splits the night. The rationale +/// lives on `SleepQuietHours`; this is just the switch and the two times. +private struct SleepTrimmingSection: View { + @State private var store = SleepQuietHoursStore.shared + + private var enabled: Binding { + Binding(get: { store.prefs.enabled }, set: { store.prefs.enabled = $0 }) + } + + /// A clock-time binding over minutes-of-day, on an arbitrary reference day. + private func time(_ keyPath: WritableKeyPath) -> Binding { + let calendar = Calendar.current + let day = calendar.startOfDay(for: Date()) + return Binding( + get: { day.addingTimeInterval(Double(store.prefs[keyPath: keyPath]) * 60) }, + set: { store.prefs[keyPath: keyPath] = SleepQuietHours.minuteOfDay($0, calendar: calendar) } + ) + } + + var body: some View { + SettingsGroup( + header: "Sleep trimming", + footer: "A still wrist reads as sleep, so an evening on the sofa can run the night high. " + + (store.prefs.enabled + ? "Only sleep inside this schedule is kept, and naps outside it are skipped. " + : "") + + "Trimmed minutes are never imported — changing these later won't bring them back." + ) { + FormToggleRow(title: "Sleep quiet hours", isOn: enabled) + if store.prefs.enabled { + FormValueRow(title: "From") { + DatePicker("From", selection: time(\.startMinutes), displayedComponents: .hourAndMinute) + .labelsHidden() + } + FormValueRow(title: "Until") { + DatePicker("Until", selection: time(\.endMinutes), displayedComponents: .hourAndMinute) + .labelsHidden() + } + } + } + } +} + /// Battery-drainage chart for the connected ring. Reads the throttled `BatterySample` history (written /// by `EventPersistenceSubscriber`) for the selected window and draws it with the shared `ZoneLineChart` /// on a fixed 0–100 axis. The fetch is on-demand (only while this screen is visible) and re-runs when the diff --git a/PulseLoopTests/SleepQuietHoursTests.swift b/PulseLoopTests/SleepQuietHoursTests.swift new file mode 100644 index 00000000..b61855fa --- /dev/null +++ b/PulseLoopTests/SleepQuietHoursTests.swift @@ -0,0 +1,243 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// The opt-in sleep quiet-hours schedule (Android #79/#80 and the clock half of #105, +/// `QuietHoursPrefsTest` + `SleepGapsTest`): the window arithmetic the import trims by, the bridge +/// that keeps a long waking inside the window from splitting the night, and both end to end. +@MainActor +final class SleepQuietHoursTests: XCTestCase { + private var utc: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + return c + }() + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: SleepQuietHours.storageKey) + super.tearDown() + } + + private func store(_ quiet: SleepQuietHours) throws { + UserDefaults.standard.set(try JSONEncoder().encode(quiet), forKey: SleepQuietHours.storageKey) + } + + // MARK: - Window arithmetic + + /// The default 22:00 → 07:00 wraps midnight. It *keeps* a 23:08 sofa start, which is why the + /// settings copy asks for the real bedtime. + func testTheDefaultWindowWrapsMidnight() { + XCTAssertTrue(SleepQuietHours.covers(start: 22 * 60, end: 7 * 60, minuteOfDay: 23 * 60 + 8)) + XCTAssertTrue(SleepQuietHours.covers(start: 22 * 60, end: 7 * 60, minuteOfDay: 3 * 60)) + XCTAssertFalse(SleepQuietHours.covers(start: 22 * 60, end: 7 * 60, minuteOfDay: 14 * 60)) + } + + /// Inclusive at the start, exclusive at the end — a 07:00 record belongs to the day. + func testBoundariesAreStartInclusiveAndEndExclusive() { + XCTAssertTrue(SleepQuietHours.covers(start: 22 * 60, end: 7 * 60, minuteOfDay: 22 * 60)) + XCTAssertFalse(SleepQuietHours.covers(start: 22 * 60, end: 7 * 60, minuteOfDay: 7 * 60)) + XCTAssertTrue(SleepQuietHours.covers(start: 9 * 60, end: 18 * 60, minuteOfDay: 9 * 60)) + XCTAssertFalse(SleepQuietHours.covers(start: 9 * 60, end: 18 * 60, minuteOfDay: 18 * 60)) + } + + /// A plain daytime window (no wrap) still works — shift workers sleep in the day. + func testANonWrappingWindowBehaves() { + XCTAssertTrue(SleepQuietHours.covers(start: 9 * 60, end: 18 * 60, minuteOfDay: 12 * 60)) + XCTAssertFalse(SleepQuietHours.covers(start: 9 * 60, end: 18 * 60, minuteOfDay: 21 * 60)) + XCTAssertFalse(SleepQuietHours.covers(start: 9 * 60, end: 18 * 60, minuteOfDay: 6 * 60)) + } + + /// start == end means the whole day: the schedule is on but filters nothing. + func testAnEqualWindowCoversTheWholeDay() { + for minute in [0, 7 * 60, 12 * 60, 23 * 60 + 59] { + XCTAssertTrue(SleepQuietHours.covers(start: 8 * 60, end: 8 * 60, minuteOfDay: minute)) + } + } + + func testMinuteOfDayUsesTheLocalWallClock() { + let date = utc.date(from: DateComponents(year: 2026, month: 9, day: 21, hour: 23, minute: 8))! + XCTAssertEqual(SleepQuietHours.minuteOfDay(date, calendar: utc), 23 * 60 + 8) + } + + // MARK: - Minute-level trim + + private func sep17(_ h: Int, _ m: Int) -> Date { + utc.date(from: DateComponents(year: 2026, month: 9, day: 17, hour: h, minute: m))! + } + private func kept(_ start: Date, _ count: Int, _ from: Int, _ until: Int) -> Range? { + SleepQuietHours.keptMinutes(start: start, count: count, startMinutes: from, endMinutes: until, calendar: utc) + } + + /// The reported night as one unsplit record, 23:08 on the sofa straight through to 07:08: the + /// sofa minutes before a 00:05 start and the minutes after 07:00 go, and the real night stays. + func testASofaStartRunningIntoTheNightIsTrimmedNotDropped() { + XCTAssertEqual(kept(sep17(23, 8), 8 * 60, 5, 7 * 60), 57..<(57 + 6 * 60 + 55)) + } + + func testARecordWhollyOutsideTheWindowIsDropped() { + XCTAssertNil(kept(sep17(14, 0), 45, 22 * 60, 7 * 60)) + } + + func testARecordWhollyInsideTheWindowIsKeptWhole() { + XCTAssertEqual(kept(sep17(23, 30), 300, 22 * 60, 7 * 60), 0..<300) + } + + func testAnEqualWindowKeepsEveryMinute() { + XCTAssertEqual(kept(sep17(15, 0), 90, 8 * 60, 8 * 60), 0..<90) + } + + /// A record touching two windows keeps the longer stretch, never both across the hole. + func testARecordTouchingTwoWindowsKeepsTheLongerStretch() { + XCTAssertEqual(kept(sep17(12, 30), 24 * 60 - 20, 12 * 60, 13 * 60), 0..<30) + } + + func testAnEmptyRecordKeepsNothing() { + XCTAssertNil(kept(sep17(23, 0), 0, 22 * 60, 7 * 60)) + } + + func testTheScheduleOffKeepsTheWholeRecord() { + let off = SleepQuietHours(enabled: false, startMinutes: 22 * 60, endMinutes: 7 * 60) + XCTAssertEqual(off.keptMinutes(start: sep17(14, 0), count: 45, calendar: utc), 0..<45) + } + + // MARK: - The bridge (#105, clock window) + + private func oct11(_ h: Int, _ m: Int) -> Date { + utc.date(from: DateComponents(year: 2026, month: 10, day: 11, hour: h, minute: m))! + } + private func bridge(_ enabled: Bool, _ from: Int, _ until: Int) -> SleepGapBridge { + SleepGapBridge.forSchedule(SleepQuietHours(enabled: enabled, startMinutes: from, endMinutes: until), calendar: utc) + } + /// The reporter's night: records 00:51–05:44 and 06:56–08:07. + private var gap: (Date, Date) { (oct11(5, 44), oct11(6, 56)) } + + func testTheClockWindowDecides() { + XCTAssertTrue(bridge(true, 22 * 60, 7 * 60).bridges(gap.0, gap.1)) + XCTAssertFalse(bridge(true, 22 * 60, 6 * 60).bridges(gap.0, gap.1), "the gap runs past 06:00") + } + + func testNothingIsBridgedWithTheScheduleOff() { + XCTAssertFalse(bridge(false, 22 * 60, 7 * 60).bridges(gap.0, gap.1)) + } + + func testANapAfterTheNightIsNotBridged() { + XCTAssertFalse(bridge(true, 22 * 60, 7 * 60).bridges(oct11(6, 30), oct11(14, 0))) + } + + /// A window whose start equals its end means "trimming off", not "asleep all day". + func testAnAllDayWindowBridgesNothing() { + XCTAssertFalse(bridge(true, 22 * 60, 22 * 60).bridges(oct11(15, 0), oct11(23, 0))) + XCTAssertFalse(bridge(true, 0, 24 * 60).bridges(oct11(15, 0), oct11(23, 0))) + } + + /// The bridge and the import gate agree: a gap the bridge keeps inside the night is one the + /// import would keep whole as a record. + func testTheBridgeAndTheImportGateAgree() { + let minutes = Int(gap.1.timeIntervalSince(gap.0) / 60) + for (enabled, until) in [(false, 7 * 60), (true, 7 * 60), (true, 6 * 60)] { + let quiet = SleepQuietHours(enabled: enabled, startMinutes: 22 * 60, endMinutes: until) + let bridged = SleepGapBridge.forSchedule(quiet, calendar: utc).bridges(gap.0, gap.1) + let keptWhole = enabled && quiet.keptMinutes(start: gap.0, count: minutes, calendar: utc) == 0.. [SleepStageBlock] { + SleepRecords.stageBlocks(start: start, stages: Array(repeating: .light, count: minutes)) + } + + func testSegmentationKeepsABridgedNightWholeAndCountsTheGapAwake() { + let night = blocks(oct11(0, 51), 293) + blocks(oct11(6, 56), 71) + XCTAssertEqual(SleepSegmentation.segment(night).count, 2) + let whole = SleepSegmentation.segment(night, bridge: bridge(true, 22 * 60, 7 * 60)) + XCTAssertEqual(whole.count, 1) + XCTAssertEqual(SleepRecords.awakeMinutes(whole[0]), 72) + } + + /// A delete or edit long after the night was written keeps the gaps it already held, and judges + /// a new gap it opens afresh. + func testARestateKeepsTheGapsTheNightAlreadyHeldAndJudgesNewOnes() { + let before = blocks(oct11(0, 51), 293) + blocks(oct11(6, 56), 71) + blocks(oct11(8, 30), 30) + let kept = SleepGapBridge.none.keepingGaps(of: [before]) + XCTAssertTrue(kept.bridges(oct11(5, 44), oct11(6, 56)), "already open") + XCTAssertFalse(kept.bridges(oct11(5, 44), oct11(8, 30)), "opened by deleting the middle") + XCTAssertFalse(kept.bridges(oct11(9, 0), oct11(13, 0)), "past the night: a nap's gap is never kept") + } + + // MARK: - End to end through the import + + private let local = Calendar.current + private func localAt(_ h: Int, _ m: Int) -> Date { + local.date(from: DateComponents(year: 2026, month: 10, day: 8))!.addingTimeInterval(TimeInterval((h * 60 + m) * 60)) + } + private func light(_ n: Int) -> [SleepStage] { Array(repeating: .light, count: n) } + + func testTheImportTrimsASofaStartToTheWindowAndKeepsTheRecordsIdentity() throws { + try store(SleepQuietHours(enabled: true, startMinutes: 5, endMinutes: 7 * 60)) + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(.sleepTimeline(timestamp: localAt(-1, 8), stages: light(8 * 60))) // 23:08 → 07:08 + + let night = try XCTUnwrap(SleepRepository.sessions(context: context).first) + XCTAssertEqual(night.startAt, localAt(0, 5)) + XCTAssertEqual(night.endAt, localAt(7, 0)) + let runs = SleepRecords.runs(SleepRepository.blocks(sessionId: night.id, context: context)) + XCTAssertEqual(runs.map(\.recordStartAt), [localAt(-1, 8)], "the record is still known by its declared start") + } + + func testTheImportDropsARecordWhollyOutsideTheWindow() throws { + try store(SleepQuietHours(enabled: true, startMinutes: 22 * 60, endMinutes: 7 * 60)) + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(.sleepTimeline(timestamp: localAt(14, 0), stages: light(45))) + XCTAssertTrue(SleepRepository.sessions(context: context).isEmpty) + } + + private func seedThreeRecordNight(_ subscriber: EventPersistenceSubscriber) { + subscriber.persist(.sleepTimeline(timestamp: localAt(0, 51), stages: light(293))) // → 05:44 + subscriber.persist(.sleepTimeline(timestamp: localAt(6, 56), stages: light(71))) // → 08:07 + subscriber.persist(.sleepTimeline(timestamp: localAt(8, 30), stages: light(30))) // → 09:00 + subscriber.flush() + } + + /// The 72-minute waking inside the window stays one night and reads as awake between records. + func testALongWakingInsideTheWindowDoesNotSplitTheNight() throws { + try store(SleepQuietHours(enabled: true, startMinutes: 22 * 60, endMinutes: 10 * 60)) + let context = try TestSupport.makeContext() + seedThreeRecordNight(EventPersistenceSubscriber(context: context)) + + let sessions = SleepRepository.sessions(context: context) + XCTAssertEqual(sessions.count, 1) + let summary = SleepService.summary(for: sessions[0], context: context) + XCTAssertEqual(summary.awakeMinutes, 72 + 23) + let runs = SleepRecords.runs(summary.blocks) + XCTAssertEqual(SleepRecords.betweenRecordAwakeMinutes(runs[0], runs[1]), 72) + } + + /// Turning the schedule off later doesn't split a night it already holds when a record is + /// deleted — but a new hour-plus hole the delete opens is judged by today's settings. + func testADeleteKeepsAGapTheNightAlreadyHeldEvenWithTheScheduleOff() throws { + try store(SleepQuietHours(enabled: true, startMinutes: 22 * 60, endMinutes: 10 * 60)) + let context = try TestSupport.makeContext() + seedThreeRecordNight(EventPersistenceSubscriber(context: context)) + let night = try XCTUnwrap(SleepRepository.sessions(context: context).first) + try store(SleepQuietHours(enabled: false, startMinutes: 22 * 60, endMinutes: 10 * 60)) + + XCTAssertTrue(SleepRecordEditor.delete(sessionId: night.id, recordStartAt: localAt(8, 30), context: context)) + XCTAssertEqual(SleepRepository.sessions(context: context).count, 1, "the 72-minute gap was already held") + + XCTAssertTrue(SleepRecordEditor.delete(sessionId: night.id, recordStartAt: localAt(6, 56), context: context)) + XCTAssertEqual(SleepRepository.sessions(context: context).count, 1, "only the first record is left") + } + + func testADeleteThatOpensANewHourPlusHoleSplitsWithTheScheduleOff() throws { + try store(SleepQuietHours(enabled: true, startMinutes: 22 * 60, endMinutes: 10 * 60)) + let context = try TestSupport.makeContext() + seedThreeRecordNight(EventPersistenceSubscriber(context: context)) + let night = try XCTUnwrap(SleepRepository.sessions(context: context).first) + try store(SleepQuietHours(enabled: false, startMinutes: 22 * 60, endMinutes: 10 * 60)) + + SleepRecordEditor.delete(sessionId: night.id, recordStartAt: localAt(6, 56), context: context) + XCTAssertEqual(SleepRepository.sessions(context: context).count, 2) + } +} From db2423ce8aa1f79c1f81c5afa6c744ea11d2a902 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:12:29 -0500 Subject: [PATCH 16/20] Delete individual vital readings from the detail screen, tombstoned so a re-sync can't restore them --- PulseLoop/Events/PulseEventBus.swift | 6 +- PulseLoop/Health/HealthSyncService.swift | 33 ++++ PulseLoop/Models/PulseModels.swift | 23 +++ PulseLoop/Persistence/DataArchive.swift | 22 +++ .../Persistence/DataArchiveService.swift | 10 +- .../Persistence/ModelContainerFactory.swift | 1 + PulseLoop/Persistence/SeedData.swift | 1 + .../Services/MeasurementDeletionService.swift | 88 ++++++++++ PulseLoop/Services/VitalReadings.swift | 95 ++++++++++ PulseLoop/Views/MetricDetailView.swift | 136 ++++++++++++++- PulseLoop/Views/RootViews.swift | 4 + PulseLoopTests/DataArchiveTests.swift | 3 + PulseLoopTests/MeasurementDeletionTests.swift | 164 ++++++++++++++++++ 13 files changed, 583 insertions(+), 3 deletions(-) create mode 100644 PulseLoop/Services/MeasurementDeletionService.swift create mode 100644 PulseLoop/Services/VitalReadings.swift create mode 100644 PulseLoopTests/MeasurementDeletionTests.swift diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index e70a3873..3bf94ecc 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -216,7 +216,7 @@ final class EventPersistenceSubscriber { $0.kindRaw == kind.rawValue && $0.timestamp == timestamp && $0.sourceRaw == MeasurementSource.history.rawValue }) { row.value = value - } else { + } else if !MeasurementDeletionService.isDeleted(kind: kind, timestamp: timestamp, context: context) { let row = Measurement(kind: kind, value: value, unit: kind.unit, timestamp: timestamp, source: .history) context.insert(row) measurements.append(row) @@ -486,6 +486,10 @@ final class EventPersistenceSubscriber { /// valid identity. Live samples keep append semantics (two readings a second apart are two events). private func persistMeasurement(kind: MeasurementKind, value: Double, timestamp: Date, source: MeasurementSource, kindLabel: String) { if source == .history, isDuplicateHistory(kind: kind, value: value, timestamp: timestamp) { return } + // A reading the user deleted (issue #60). The ring replays its log on every re-sync, so without + // this gate the next sync would restore exactly what was removed. Checked only for a row about + // to be inserted — a duplicate already returned above without touching the tombstones. + if MeasurementDeletionService.isDeleted(kind: kind, timestamp: timestamp, context: context) { return } let row = Measurement(kind: kind, value: value, unit: kind.unit, timestamp: timestamp, source: source) context.insert(row) context.insert(DerivedUpdateRow(kind: kindLabel, entityType: "measurement", entityId: row.id.uuidString)) diff --git a/PulseLoop/Health/HealthSyncService.swift b/PulseLoop/Health/HealthSyncService.swift index 7ad15318..2b83a0c1 100644 --- a/PulseLoop/Health/HealthSyncService.swift +++ b/PulseLoop/Health/HealthSyncService.swift @@ -547,6 +547,39 @@ final class HealthSyncService { Task { await self.deleteWorkout(syncID: syncID) } } + /// Fire-and-forget removal of one exported vital reading the user deleted locally. Every exported + /// reading carries a deterministic sync identifier (kind + instant), so the sample is found without + /// any local bookkeeping; a reading that was never exported simply matches nothing. Blood pressure + /// is a correlation (keyed by instant) whose two halves carry no metadata of their own, so those are + /// matched by type, source and their exact instant. + func deleteExportedVital(kind: MeasurementKind, timestamp: Date) { + guard isAvailable, !isRunningUnitTests else { return } + let sourcePredicate = HKQuery.predicateForObjects(from: HKSource.default()) + func scoped(_ predicate: NSPredicate) -> NSPredicate { + NSCompoundPredicate(andPredicateWithSubpredicates: [sourcePredicate, predicate]) + } + switch kind { + case .bloodPressureSystolic, .bloodPressureDiastolic: + guard let correlation = HKCorrelationType.correlationType(forIdentifier: .bloodPressure), + let systolic = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic), + let diastolic = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic) else { return } + let syncID = HealthKitTypeMappings.bloodPressureSyncID(timestamp: timestamp) + let byID = scoped(HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeySyncIdentifier, allowedValues: [syncID])) + let atInstant = scoped(HKQuery.predicateForSamples(withStart: timestamp, end: timestamp, + options: [.strictStartDate, .strictEndDate])) + Task { + await self.deleteOwnObjects(of: correlation, predicate: byID) + await self.deleteOwnObjects(of: systolic, predicate: atInstant) + await self.deleteOwnObjects(of: diastolic, predicate: atInstant) + } + default: + guard let mapping = HealthKitTypeMappings.quantityMapping(for: kind) else { return } + let syncID = HealthKitTypeMappings.vitalsSyncID(kindRaw: kind.rawValue, timestamp: timestamp) + let byID = scoped(HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeySyncIdentifier, allowedValues: [syncID])) + Task { await self.deleteOwnObjects(of: mapping.type, predicate: byID) } + } + } + private func deleteWorkout(syncID: String) async { let metaPredicate = HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeySyncIdentifier, allowedValues: [syncID]) let sourcePredicate = HKQuery.predicateForObjects(from: HKSource.default()) diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index f6ce7753..ad0d24bd 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -253,6 +253,29 @@ final class Measurement { var kind: MeasurementKind { MeasurementKind(rawValue: kindRaw) ?? .heartRate } } +/// A tombstone for a reading the user deleted, so the next sync of that day can't write it back. +/// +/// Ring history is replayed on every re-sync with deterministic timestamps, and the persistence gate +/// upserts by `(kind, timestamp)` — without this row a deleted reading would simply reappear. Keyed by +/// what a re-sync can reproduce (the kind and the whole-second instant), never by the row's UUID. +/// Activity buckets reuse the table with an `activity:` key (see `MeasurementDeletionService`). +@Model +final class MeasurementDeletion { + /// `":"` for a vital, `"activity:"` for an activity bucket. + @Attribute(.unique) var key: String + /// A `MeasurementKind` raw value, or `MeasurementDeletionService.activityKindRaw`. + var kindRaw: String + var timestamp: Date + var deletedAt: Date + + init(key: String, kindRaw: String, timestamp: Date, deletedAt: Date = Date()) { + self.key = key + self.kindRaw = kindRaw + self.timestamp = timestamp + self.deletedAt = deletedAt + } +} + /// A single historical battery reading, kept so the Wearable screen can chart drainage over time. /// Battery is stored separately from `Measurement` (it isn't a health vital) but follows the same /// timestamped-sample shape. Writes are throttled (on-change or a 30-min floor) so the table stays diff --git a/PulseLoop/Persistence/DataArchive.swift b/PulseLoop/Persistence/DataArchive.swift index 3358e1af..2f90973c 100644 --- a/PulseLoop/Persistence/DataArchive.swift +++ b/PulseLoop/Persistence/DataArchive.swift @@ -56,6 +56,10 @@ nonisolated struct PulseArchive: Codable, Sendable { var coachNotificationRecords: [ArchiveCoachNotificationRecord] var coachSummaries: [ArchiveCoachSummary] var wearableLogs: [ArchiveWearableLog] + /// Deleted-reading tombstones (issue #60). Optional so archives written before tombstones existed + /// still decode; a restore must carry them, or the first sync after it resurrects every reading + /// the user deleted. + var measurementDeletions: [ArchiveMeasurementDeletion]? = nil /// Raw UserDefaults JSON blobs keyed by their storage key (metric prefs, workout prefs, /// calibration, coach settings, Apple Health prefs). Opaque pass-through — never re-encoded. @@ -263,6 +267,24 @@ nonisolated struct ArchiveMeasurement: Codable, Sendable { } } +nonisolated struct ArchiveMeasurementDeletion: Codable, Sendable { + var key: String + var kindRaw: String + var timestamp: Date + var deletedAt: Date + + @MainActor init(_ m: MeasurementDeletion) { + key = m.key + kindRaw = m.kindRaw + timestamp = m.timestamp + deletedAt = m.deletedAt + } + + @MainActor func insert(into context: ModelContext) { + context.insert(MeasurementDeletion(key: key, kindRaw: kindRaw, timestamp: timestamp, deletedAt: deletedAt)) + } +} + nonisolated struct ArchiveBatterySample: Codable, Sendable { var id: UUID var percent: Int diff --git a/PulseLoop/Persistence/DataArchiveService.swift b/PulseLoop/Persistence/DataArchiveService.swift index 99760728..e326714b 100644 --- a/PulseLoop/Persistence/DataArchiveService.swift +++ b/PulseLoop/Persistence/DataArchiveService.swift @@ -120,6 +120,7 @@ enum DataArchiveService { let coachNotificationRecords = try await collect(CoachNotificationRecord.self, context) { ArchiveCoachNotificationRecord($0) } let coachSummaries = try await collect(CoachSummary.self, context) { ArchiveCoachSummary($0) } let wearableLogs = try await collect(WearableLog.self, context) { ArchiveWearableLog($0) } + let measurementDeletions = try await collect(MeasurementDeletion.self, context) { ArchiveMeasurementDeletion($0) } var settings: [String: String] = [:] for key in settingsKeys { @@ -153,7 +154,8 @@ enum DataArchiveService { "coachToolCalls": coachToolCalls.count, "coachNotificationRecords": coachNotificationRecords.count, "coachSummaries": coachSummaries.count, - "wearableLogs": wearableLogs.count + "wearableLogs": wearableLogs.count, + "measurementDeletions": measurementDeletions.count ] let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" @@ -188,6 +190,7 @@ enum DataArchiveService { coachNotificationRecords: coachNotificationRecords, coachSummaries: coachSummaries, wearableLogs: wearableLogs, + measurementDeletions: measurementDeletions, settings: settings, attachments: collectAttachments(from: attachmentsDirectory) ) @@ -307,6 +310,7 @@ enum DataArchiveService { || has(ActivityEvent.self) || has(ActivitySensorPollEvent.self) || has(CoachConversation.self) || has(CoachMessage.self) || has(CoachMemory.self) || has(CoachToolCall.self) || has(CoachNotificationRecord.self) || has(CoachSummary.self) || has(WearableLog.self) + || has(MeasurementDeletion.self) } /// Deletes every row of every model in the schema — all 25 types, unlike `SeedData.clearAll` @@ -338,6 +342,7 @@ enum DataArchiveService { try deleteAll(CoachNotificationRecord.self, context) try deleteAll(CoachSummary.self, context) try deleteAll(WearableLog.self, context) + try deleteAll(MeasurementDeletion.self, context) } private static func deleteAll(_ type: T.Type, _ context: ModelContext) throws { @@ -372,6 +377,7 @@ enum DataArchiveService { insert(archive.coachNotificationRecords, context) insert(archive.coachSummaries, context) insert(archive.wearableLogs, context) + insert(archive.measurementDeletions ?? [], context) } private static func insert(_ rows: [some ArchiveInsertable], _ context: ModelContext) { @@ -406,6 +412,7 @@ enum DataArchiveService { try requireUnique(archive.coachMemories.map(\.id), entity: "coach memory") try requireUnique(archive.coachToolCalls.map(\.id), entity: "tool call") try requireUnique(archive.coachNotificationRecords.map(\.id), entity: "notification record") + try requireUnique((archive.measurementDeletions ?? []).map(\.key), entity: "deleted reading") try requireUnique(archive.coachSummaries.map(\.id), entity: "coach summary") try requireUnique(archive.wearableLogs.map(\.id), entity: "wearable log") @@ -512,3 +519,4 @@ extension ArchiveCoachToolCall: ArchiveInsertable {} extension ArchiveCoachNotificationRecord: ArchiveInsertable {} extension ArchiveCoachSummary: ArchiveInsertable {} extension ArchiveWearableLog: ArchiveInsertable {} +extension ArchiveMeasurementDeletion: ArchiveInsertable {} diff --git a/PulseLoop/Persistence/ModelContainerFactory.swift b/PulseLoop/Persistence/ModelContainerFactory.swift index 2f74e811..10a811c0 100644 --- a/PulseLoop/Persistence/ModelContainerFactory.swift +++ b/PulseLoop/Persistence/ModelContainerFactory.swift @@ -6,6 +6,7 @@ enum ModelContainerFactory { Device.self, ActivityDaily.self, Measurement.self, + MeasurementDeletion.self, BatterySample.self, SleepSession.self, SleepStageBlock.self, diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index 074040f8..9a73c245 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -346,6 +346,7 @@ enum SeedData { deleteAll(ActivitySession.self, context) deleteAll(ActivitySample.self, context) deleteAll(ActivityBucketSample.self, context) + deleteAll(MeasurementDeletion.self, context) deleteAll(ActivityGpsPoint.self, context) deleteAll(ActivityEvent.self, context) deleteAll(CoachConversation.self, context) diff --git a/PulseLoop/Services/MeasurementDeletionService.swift b/PulseLoop/Services/MeasurementDeletionService.swift new file mode 100644 index 00000000..2b158f1e --- /dev/null +++ b/PulseLoop/Services/MeasurementDeletionService.swift @@ -0,0 +1,88 @@ +import Foundation +import SwiftData + +/// Deleting individual readings (Android #62 / issue #60). +/// +/// A reading can be wrong in ways nothing downstream can detect — a ring worn loosely, a measurement +/// started by mistake, a value taken mid-movement — and without a delete it stays in every average +/// computed over it. Deletion only: a recorded health value may be removed, never edited into a +/// different number. +/// +/// Two rules make a delete stick, and both live here so no caller has to remember them: +/// * **Tombstone it.** Ring history is replayed with deterministic timestamps on every re-sync, so a +/// `MeasurementDeletion` row keyed by kind + instant is checked by the persistence gate before a +/// sample is written (`EventPersistenceSubscriber`). +/// * **Delete a blood-pressure reading as a pair.** One reading is two rows (systolic + diastolic) +/// sharing an instant; removing one half would chart a systolic with no diastolic. +@MainActor +enum MeasurementDeletionService { + + /// The tombstone key for a vital reading. Whole seconds: history timestamps are whole seconds, + /// and a sub-second difference can never be a different reading of the same kind. + static func key(kind: MeasurementKind, timestamp: Date) -> String { + "\(kind.rawValue):\(Int(timestamp.timeIntervalSince1970.rounded(.down)))" + } + + /// Whether the user deleted the reading of `kind` at `timestamp`. Indexed point lookup on the + /// unique key — cheap enough for the persistence gate's per-sample check. + static func isDeleted(kind: MeasurementKind, timestamp: Date, context: ModelContext) -> Bool { + isTombstoned(key(kind: kind, timestamp: timestamp), context: context) + } + + static func isTombstoned(_ key: String, context: ModelContext) -> Bool { + var descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + descriptor.fetchLimit = 1 + return ((try? context.fetchCount(descriptor)) ?? 0) > 0 + } + + /// Record a tombstone unless one already exists (re-deleting the same instant is a no-op). + static func recordTombstone(key: String, kindRaw: String, timestamp: Date, context: ModelContext) { + guard !isTombstoned(key, context: context) else { return } + context.insert(MeasurementDeletion(key: key, kindRaw: kindRaw, timestamp: timestamp)) + } + + /// The rows one displayed reading is made of: both halves for blood pressure, the row itself for + /// everything else. + static func rowsForReading(_ row: Measurement, context: ModelContext) -> [Measurement] { + guard row.kind == .bloodPressureSystolic || row.kind == .bloodPressureDiastolic else { return [row] } + let instant = row.timestamp + let sys = MeasurementKind.bloodPressureSystolic.rawValue + let dia = MeasurementKind.bloodPressureDiastolic.rawValue + let pair = (try? context.fetch(FetchDescriptor(predicate: #Predicate { + ($0.kindRaw == sys || $0.kindRaw == dia) && $0.timestamp == instant + }))) ?? [] + return pair.isEmpty ? [row] : pair + } + + /// Delete `rows`, tombstone each, remove them from Apple Health if PulseLoop exported them, and + /// signal observers. Saves immediately: a delete that lost its tombstone would come back on the + /// next sync. Returns the number of rows removed. + @discardableResult + static func delete(_ rows: [Measurement], context: ModelContext) -> Int { + guard !rows.isEmpty else { return 0 } + var exported: [(MeasurementKind, Date)] = [] + for row in rows { + recordTombstone(key: key(kind: row.kind, timestamp: row.timestamp), kindRaw: row.kindRaw, + timestamp: row.timestamp, context: context) + if row.sourceRaw != MeasurementSource.mock.rawValue { exported.append((row.kind, row.timestamp)) } + context.delete(row) + } + try? context.save() + // One removal per reading: a BP pair is one correlation in Health. + var seenBP: Set = [] + for (kind, timestamp) in exported { + if kind == .bloodPressureSystolic || kind == .bloodPressureDiastolic { + guard seenBP.insert(Int(timestamp.timeIntervalSince1970 * 1000)).inserted else { continue } + } + HealthSyncService.shared.deleteExportedVital(kind: kind, timestamp: timestamp) + } + PulseDataChange.shared.notify() + return rows.count + } + + /// Delete one displayed reading — the row plus, for blood pressure, its partner half. + @discardableResult + static func deleteReading(_ row: Measurement, context: ModelContext) -> Int { + delete(rowsForReading(row, context: context), context: context) + } +} diff --git a/PulseLoop/Services/VitalReadings.swift b/PulseLoop/Services/VitalReadings.swift new file mode 100644 index 00000000..a8a094bd --- /dev/null +++ b/PulseLoop/Services/VitalReadings.swift @@ -0,0 +1,95 @@ +import Foundation +import SwiftData + +/// One stored reading as the detail screen's READINGS list shows it (issue #60). Values are in +/// canonical units (°C, mg/dL) with the calibration offset applied — the same values the chart plots. +struct VitalReading: Identifiable, Equatable { + /// The primary row's id (the systolic row for blood pressure) — what a delete resolves. + let id: UUID + let timestamp: Date + let value: Double + /// The diastolic half of a blood-pressure reading; nil for every other metric. + let secondary: Double? + /// Ring-estimated metrics (blood pressure, glucose) and hourly SpO₂ a ring computed from heart rate + /// (`EstimatedSpO2`) are never shown unlabelled. + let isEstimated: Bool +} + +/// Reads and deletes the individual rows behind a vital's detail window. Raw rows, never the +/// display-downsampled chart series — a bucket average isn't something a user can delete. +@MainActor +enum VitalReadings { + + /// The stored kind a detail metric lists (systolic stands for a blood-pressure reading). + static func primaryKind(for metric: MetricKind) -> MeasurementKind { + switch metric { + case .heartRate: return .heartRate + case .spo2: return .spo2 + case .hrv: return .hrv + case .bloodPressure: return .bloodPressureSystolic + case .stress: return .stress + case .fatigue: return .fatigue + case .glucose: return .bloodSugar + case .temperature: return .temperature + } + } + + static func isEstimated(_ metric: MetricKind) -> Bool { + metric == .bloodPressure || metric == .glucose + } + + /// The window's readings, newest first. `[start, end)`. Estimated hourly SpO₂ follows the chart's + /// rule: dropped while "Hide estimated SpO₂" is on (the default), tagged "Estimated" when it's off. + static func load(metric: MetricKind, start: Date, end: Date, limit: Int = 10_000, + calibration: Calibration? = nil, hideEstimatedSpO2: Bool? = nil, + context: ModelContext) -> [VitalReading] { + let calibration = calibration ?? CalibrationStore.shared.settings + let kind = primaryKind(for: metric) + var rows = fetch(kind: kind, start: start, end: end, limit: limit, context: context) + var estimatedSpO2: Set = [] + if metric == .spo2, let newest = rows.first?.timestamp, let oldest = rows.last?.timestamp { + estimatedSpO2 = EstimatedSpO2.estimatedTimestamps(from: oldest, to: newest, context: context) + if !estimatedSpO2.isEmpty, hideEstimatedSpO2 ?? MetricPrefsStore.shared.settings.hideEstimatedSpO2 { + rows.removeAll { estimatedSpO2.contains($0.timestamp) } + estimatedSpO2 = [] + } + } + var diastolic: [Date: Double] = [:] + if metric == .bloodPressure { + for row in fetch(kind: .bloodPressureDiastolic, start: start, end: end, limit: limit, context: context) { + diastolic[row.timestamp] = diastolic[row.timestamp] ?? row.value + } + } + let estimated = isEstimated(metric) + return rows.map { row in + VitalReading( + id: row.id, + timestamp: row.timestamp, + value: calibration.adjusted(row.value, kind: kind), + secondary: diastolic[row.timestamp].map { calibration.adjusted($0, kind: .bloodPressureDiastolic) }, + isEstimated: estimated || estimatedSpO2.contains(row.timestamp) + ) + } + } + + /// Delete one listed reading (both halves for blood pressure), tombstoned against re-sync. + @discardableResult + static func delete(_ reading: VitalReading, context: ModelContext) -> Int { + let id = reading.id + var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + descriptor.fetchLimit = 1 + guard let row = try? context.fetch(descriptor).first else { return 0 } + return MeasurementDeletionService.deleteReading(row, context: context) + } + + private static func fetch(kind: MeasurementKind, start: Date, end: Date, limit: Int, + context: ModelContext) -> [Measurement] { + let raw = kind.rawValue + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.kindRaw == raw && $0.timestamp >= start && $0.timestamp < end }, + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + descriptor.fetchLimit = limit + return (try? context.fetch(descriptor)) ?? [] + } +} diff --git a/PulseLoop/Views/MetricDetailView.swift b/PulseLoop/Views/MetricDetailView.swift index 01341b8f..880f3d0a 100644 --- a/PulseLoop/Views/MetricDetailView.swift +++ b/PulseLoop/Views/MetricDetailView.swift @@ -18,6 +18,11 @@ struct MetricDetailView: View { @State private var secondary: [MetricSample] = [] // diastolic, for BP /// The SpO₂ window holds hourly readings this ring computed from heart rate (`EstimatedSpO2`). @State private var spo2Estimated = false + /// The window's stored readings, newest first — the deletable list under the chart (issue #60). + @State private var readings: [VitalReading] = [] + @State private var readingsExpanded = false + @State private var readingsVisible = Self.readingsPageSize + @State private var pendingDelete: VitalReading? /// Observed so the detail chart/tiles re-fetch when a background sync lands while this is open. @State private var dataChange = PulseDataChange.shared @@ -70,6 +75,7 @@ struct MetricDetailView: View { periodSelector chartSection statTiles + readingsCard legend explainer if isEstimatedMetric { disclaimer } @@ -83,8 +89,17 @@ struct MetricDetailView: View { // Shared glass chrome: centered title + glass back button, no system nav // bar (so the zoom transition doesn't reflow the content). .pageChrome(metric.title) - .task(id: period) { reload() } + .task(id: period) { readingsVisible = Self.readingsPageSize; reload() } .onChange(of: dataChange.token) { _, _ in reload() } + .alert("Delete this reading?", isPresented: Binding( + get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } } + ), presenting: pendingDelete) { reading in + Button("Delete", role: .destructive) { delete(reading) } + Button("Cancel", role: .cancel) {} + } message: { reading in + Text("\(readingText(reading))\(unitLabel.isEmpty ? "" : " \(unitLabel)") at \(readingTime(reading.timestamp)).\n\n" + + "This can't be undone, and the reading stays deleted the next time your ring syncs.") + } } // MARK: - Period selector @@ -205,6 +220,111 @@ struct MetricDetailView: View { Rectangle().fill(PulseColors.borderSubtle).frame(width: 1, height: 34) } + // MARK: - Readings + + /// How many readings the list shows before asking for more — a Month of all-day history is + /// thousands of rows, and someone scanning for one bad measurement reads a page. + private static let readingsPageSize = 50 + + /// The window's individual readings with a delete per row (issue #60). Deletion only — a recorded + /// value can be removed, never edited — and always behind a confirmation. + private var readingsCard: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(PulseMotion.spring) { readingsExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Text("READINGS").font(PulseFont.caption2.weight(.semibold)).tracking(1.0).foregroundStyle(PulseColors.textMuted) + Spacer() + Text("\(readings.count)").font(PulseFont.caption).monospacedDigit().foregroundStyle(PulseColors.textMuted) + Image(systemName: readingsExpanded ? "chevron.up" : "chevron.down") + .font(PulseFont.caption.weight(.semibold)) + .foregroundStyle(PulseColors.textMuted) + } + .padding(16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Readings, \(readings.count)") + .accessibilityHint(readingsExpanded ? "Hides the readings" : "Shows the readings") + + if readingsExpanded { + if readings.isEmpty { + Text("No readings in this period.") + .font(PulseFont.footnote.weight(.regular)).foregroundStyle(PulseColors.textMuted) + .padding(.horizontal, 16).padding(.bottom, 14) + } else { + LazyVStack(spacing: 0) { + ForEach(readings.prefix(readingsVisible)) { reading in + readingRow(reading) + } + } + if readings.count > readingsVisible { + Button("Show \(min(Self.readingsPageSize, readings.count - readingsVisible)) more") { + readingsVisible += Self.readingsPageSize + } + .font(PulseFont.footnote.weight(.semibold)) + .foregroundStyle(PulseColors.accent) + .padding(.horizontal, 16).padding(.vertical, 10) + } + Spacer().frame(height: 6) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + + private func readingRow(_ reading: VitalReading) -> some View { + HStack(spacing: 6) { + Text(readingTime(reading.timestamp)) + .font(PulseFont.footnote.weight(.regular)).foregroundStyle(PulseColors.textSecondary) + .lineLimit(1) + Spacer(minLength: 8) + Text(readingText(reading)) + .font(PulseFont.footnote.weight(.semibold)).monospacedDigit().foregroundStyle(PulseColors.textPrimary) + if !unitLabel.isEmpty { + Text(unitLabel).font(PulseFont.caption2.weight(.regular)).foregroundStyle(PulseColors.textMuted) + } + if reading.isEstimated { + Text("Estimated") + .font(PulseFont.caption2.weight(.semibold)) + .foregroundStyle(PulseColors.warning) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(PulseColors.warning.opacity(0.14), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + } + Button { pendingDelete = reading } label: { + Image(systemName: "trash") + .font(PulseFont.footnote) + .foregroundStyle(PulseColors.textMuted) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Delete reading \(readingText(reading)) at \(readingTime(reading.timestamp))") + } + .padding(.leading, 16).padding(.trailing, 6) + .accessibilityElement(children: .contain) + } + + private func readingText(_ reading: VitalReading) -> String { + let primary = fmt(toDisplay(reading.value)) + guard let secondary = reading.secondary else { return primary } + return "\(primary)/\(fmt(toDisplay(secondary)))" + } + + private func readingTime(_ date: Date) -> String { + period == .today + ? date.formatted(date: .omitted, time: .shortened) + : date.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().hour().minute()) + } + + private func delete(_ reading: VitalReading) { + VitalReadings.delete(reading, context: modelContext) + pendingDelete = nil + reload() + } + // MARK: - Legend private var legend: some View { @@ -269,6 +389,20 @@ struct MetricDetailView: View { primary = MetricsService.metricRange(metric: metric.metricKey, range: period.range, context: modelContext) secondary = [] } + let window = readingsWindow + readings = VitalReadings.load(metric: metric, start: window.start, end: window.end, context: modelContext) + } + + /// The span the readings list covers — the same rolling window the chart fetches. + private var readingsWindow: DateInterval { + let now = Date() + let seconds: TimeInterval + switch period { + case .today: seconds = 86_400 + case .week: seconds = 7 * 86_400 + case .month: seconds = 30 * 86_400 + } + return DateInterval(start: now.addingTimeInterval(-seconds), end: now.addingTimeInterval(1)) } /// HRV detail can compute a real baseline because Week/Month fetch enough history. diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 6001e353..abe0f060 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -112,6 +112,10 @@ struct RootAppView: View { if UserDefaults.standard.bool(forKey: "openRecord") { path.append(AppRoute.recordSelect) } + // Test tooling: `-openMetric hrv` opens that vital's detail screen. + if let raw = UserDefaults.standard.string(forKey: "openMetric"), let metric = MetricKind(rawValue: raw) { + path.append(AppRoute.metricDetail(metric)) + } } // Re-attach to an in-progress workout left running across launches. liveWorkout.recover() diff --git a/PulseLoopTests/DataArchiveTests.swift b/PulseLoopTests/DataArchiveTests.swift index 516881c5..11411d74 100644 --- a/PulseLoopTests/DataArchiveTests.swift +++ b/PulseLoopTests/DataArchiveTests.swift @@ -36,6 +36,7 @@ final class DataArchiveTests: XCTestCase { context.insert(WearableLog(category: .sync, level: .info, message: "sync done", metadataJSON: #"{"n":1}"#)) context.insert(SleepRecordEdit(wakingDay: Date(timeIntervalSince1970: 1_750_000_000), recordStartAt: Date(timeIntervalSince1970: 1_749_990_000), kind: .blockDeleted)) + context.insert(MeasurementDeletion(key: "hr:1750000000", kindRaw: "hr", timestamp: Date(timeIntervalSince1970: 1_750_000_000))) try? context.save() } @@ -52,6 +53,7 @@ final class DataArchiveTests: XCTestCase { check(ActivityEvent.self); check(ActivitySensorPollEvent.self); check(CoachConversation.self) check(CoachMessage.self); check(CoachMemory.self); check(CoachToolCall.self) check(CoachNotificationRecord.self); check(CoachSummary.self); check(WearableLog.self) + check(MeasurementDeletion.self) } private func makeSuiteDefaults(_ name: String) -> UserDefaults { @@ -159,6 +161,7 @@ final class DataArchiveTests: XCTestCase { assertEmpty(ActivityEvent.self); assertEmpty(ActivitySensorPollEvent.self); assertEmpty(CoachConversation.self) assertEmpty(CoachMessage.self); assertEmpty(CoachMemory.self); assertEmpty(CoachToolCall.self) assertEmpty(CoachNotificationRecord.self); assertEmpty(CoachSummary.self); assertEmpty(WearableLog.self) + assertEmpty(MeasurementDeletion.self) } // MARK: - Rejection without data loss diff --git a/PulseLoopTests/MeasurementDeletionTests.swift b/PulseLoopTests/MeasurementDeletionTests.swift new file mode 100644 index 00000000..c2a73a93 --- /dev/null +++ b/PulseLoopTests/MeasurementDeletionTests.swift @@ -0,0 +1,164 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Deleting individual readings (issue #60): the row goes, a tombstone keeps it gone across re-syncs, +/// and a blood-pressure reading is removed as its systolic + diastolic pair. +@MainActor +final class MeasurementDeletionTests: XCTestCase { + private let ts = Date(timeIntervalSince1970: 1_760_000_000) + + private func rows(_ kind: MeasurementKind, _ context: ModelContext) throws -> [PulseLoop.Measurement] { + let raw = kind.rawValue + return try context.fetch(FetchDescriptor(predicate: #Predicate { $0.kindRaw == raw })) + } + + private func window() -> (Date, Date) { (ts.addingTimeInterval(-86_400), ts.addingTimeInterval(86_400)) } + + func testDeletedHistoryReadingIsNotRestoredByResync() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(.historyMeasurement(kind: .heartRate, value: 140, timestamp: ts)) + subscriber.persist(.historyMeasurement(kind: .heartRate, value: 62, timestamp: ts.addingTimeInterval(300))) + subscriber.persist(.syncProgress(stage: "done")) + subscriber.flush() + + let (start, end) = window() + let readings = VitalReadings.load(metric: .heartRate, start: start, end: end, context: context) + XCTAssertEqual(readings.map(\.value), [62, 140], "newest first") + let bad = try XCTUnwrap(readings.last) + XCTAssertEqual(VitalReadings.delete(bad, context: context), 1) + XCTAssertEqual(try rows(.heartRate, context).map(\.value), [62]) + XCTAssertTrue(MeasurementDeletionService.isDeleted(kind: .heartRate, timestamp: ts, context: context)) + + // The ring replays the whole day on the next sync. + subscriber.persist(.historyMeasurement(kind: .heartRate, value: 140, timestamp: ts)) + subscriber.persist(.historyMeasurement(kind: .heartRate, value: 62, timestamp: ts.addingTimeInterval(300))) + subscriber.persist(.historyMeasurement(kind: .heartRate, value: 70, timestamp: ts.addingTimeInterval(600))) + subscriber.flush() + + XCTAssertEqual(try rows(.heartRate, context).map(\.value).sorted(), [62, 70], + "the deleted slot stays deleted; new and existing slots still sync") + } + + func testTombstoneIsPerKind() throws { + let context = try TestSupport.makeContext() + TestSupport.insertMeasurement(kind: .heartRate, value: 80, timestamp: ts, into: context) + let row = try XCTUnwrap(try rows(.heartRate, context).first) + MeasurementDeletionService.deleteReading(row, context: context) + + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(.historyMeasurement(kind: .spo2, value: 97, timestamp: ts)) + subscriber.flush() + XCTAssertEqual(try rows(.spo2, context).count, 1, "an SpO2 reading at the same instant is unaffected") + } + + func testBloodPressureDeletesBothHalvesAndStaysDeleted() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(.bloodPressureSample(systolic: 150, diastolic: 98, timestamp: ts)) + subscriber.persist(.bloodPressureSample(systolic: 118, diastolic: 76, timestamp: ts.addingTimeInterval(3600))) + subscriber.flush() + + let (start, end) = window() + let readings = VitalReadings.load(metric: .bloodPressure, start: start, end: end, context: context) + XCTAssertEqual(readings.count, 2) + let high = try XCTUnwrap(readings.first { $0.value == 150 }) + XCTAssertEqual(high.secondary, 98) + XCTAssertTrue(high.isEstimated, "ring blood pressure is never shown unlabelled") + + XCTAssertEqual(VitalReadings.delete(high, context: context), 2, "a reading is its systolic + diastolic rows") + XCTAssertEqual(try rows(.bloodPressureSystolic, context).map(\.value), [118]) + XCTAssertEqual(try rows(.bloodPressureDiastolic, context).map(\.value), [76]) + + subscriber.persist(.bloodPressureSample(systolic: 150, diastolic: 98, timestamp: ts)) + subscriber.flush() + XCTAssertEqual(try rows(.bloodPressureSystolic, context).count, 1) + XCTAssertEqual(try rows(.bloodPressureDiastolic, context).count, 1) + } + + func testReadingsApplyCalibrationAndWindow() throws { + let context = try TestSupport.makeContext() + TestSupport.insertMeasurement(kind: .bloodSugar, value: 100, timestamp: ts, into: context) + TestSupport.insertMeasurement(kind: .bloodSugar, value: 90, timestamp: ts.addingTimeInterval(-3 * 86_400), into: context) + var calibration = Calibration() + calibration.glucoseOffsetMgdl = 5 + let (start, end) = window() + let readings = VitalReadings.load(metric: .glucose, start: start, end: end, calibration: calibration, context: context) + XCTAssertEqual(readings.map(\.value), [105], "calibrated, and only inside the window") + XCTAssertTrue(readings[0].isEstimated) + } + + /// Hourly SpO₂ a ring computes from heart rate (`EstimatedSpO2`): 30 hourly history rows with + /// respiratory siblings and an HR row each, plus one live spot reading. + private func seedDerivedSpO2Ring(_ context: ModelContext, now: Date) throws -> (hourly: [Date], spot: Date) { + let heartRates = [48, 57, 67, 75, 82, 92, 105, 70] + func spo2(_ hr: Int) -> Double { hr <= 48 ? 88 : hr <= 57 ? 94 : hr <= 92 ? 98 : 96 } + var hourly: [Date] = [] + for i in 0..<30 { + let t = now.addingTimeInterval(-Double(30 - i) * 3600 + 34) + let hr = heartRates[i % heartRates.count] + hourly.append(t) + TestSupport.insertMeasurement(kind: .spo2, value: spo2(hr), timestamp: t, source: .history, into: context) + TestSupport.insertMeasurement(kind: .respiratoryRate, value: Double(hr) / 5, timestamp: t, source: .history, into: context) + TestSupport.insertMeasurement(kind: .heartRate, value: Double(hr), timestamp: t.addingTimeInterval(-5), source: .history, into: context) + } + let spot = now.addingTimeInterval(-600) + TestSupport.insertMeasurement(kind: .spo2, value: 99, timestamp: spot, source: .live, into: context) + return (hourly, spot) + } + + func testEstimatedSpO2ReadingsAreHiddenOrTaggedLikeTheChart() throws { + let context = try TestSupport.makeContext() + let now = Date() + let seeded = try seedDerivedSpO2Ring(context, now: now) + let start = now.addingTimeInterval(-86_400), end = now.addingTimeInterval(1) + + let hidden = VitalReadings.load(metric: .spo2, start: start, end: end, hideEstimatedSpO2: true, context: context) + XCTAssertEqual(hidden.map(\.timestamp), [seeded.spot], "estimates hidden: only the measured spot reading") + XCTAssertFalse(hidden[0].isEstimated) + + let shown = VitalReadings.load(metric: .spo2, start: start, end: end, hideEstimatedSpO2: false, context: context) + XCTAssertEqual(shown.count, seeded.hourly.filter { $0 >= start }.count + 1) + XCTAssertEqual(shown.filter(\.isEstimated).count, shown.count - 1, "every hourly estimate is tagged") + XCTAssertFalse(try XCTUnwrap(shown.first { $0.timestamp == seeded.spot }).isEstimated) + } + + func testDeletingTwiceKeepsOneTombstone() throws { + let context = try TestSupport.makeContext() + TestSupport.insertMeasurement(kind: .hrv, value: 40, timestamp: ts, into: context) + TestSupport.insertMeasurement(kind: .hrv, value: 41, timestamp: ts.addingTimeInterval(0.4), into: context) + for row in try rows(.hrv, context) { MeasurementDeletionService.deleteReading(row, context: context) } + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertTrue(try rows(.hrv, context).isEmpty) + } + + func testArchiveRoundTripsTombstonesAndAcceptsOlderArchives() async throws { + let source = try TestSupport.makeContext() + TestSupport.insertMeasurement(kind: .heartRate, value: 80, timestamp: ts, into: source) + MeasurementDeletionService.deleteReading(try XCTUnwrap(try rows(.heartRate, source).first), context: source) + + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let suite = "MeasurementDeletionTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { UserDefaults().removePersistentDomain(forName: suite) } + + let data = try await DataArchiveService.exportArchive(context: source, defaults: defaults, attachmentsDirectory: dir) + let target = try TestSupport.makeContext() + try await DataArchiveService.importArchive(data, context: target, defaults: defaults, + attachmentsDirectory: dir, refreshStores: false) + XCTAssertTrue(MeasurementDeletionService.isDeleted(kind: .heartRate, timestamp: ts, context: target), + "a restore must carry the tombstones, or the next sync resurrects deleted readings") + + // An archive written before tombstones existed has no `measurementDeletions` key. + var json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + json.removeValue(forKey: "measurementDeletions") + let older = try JSONSerialization.data(withJSONObject: json) + let fresh = try TestSupport.makeContext() + try await DataArchiveService.importArchive(older, context: fresh, defaults: defaults, + attachmentsDirectory: dir, refreshStores: false) + XCTAssertEqual(try fresh.fetchCount(FetchDescriptor()), 0) + } +} From 99c8bd07a27b2a43654600a36960eebc9e90940d Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:16:26 -0500 Subject: [PATCH 17/20] Show vital details by calendar day, week and month with period navigation and a full-window chart axis --- .../DesignSystem/PeriodNavigationHeader.swift | 166 ++++++++++++++++++ PulseLoop/DesignSystem/VitalsCharts.swift | 4 + PulseLoop/Services/PulseServices.swift | 41 ++++- PulseLoop/Services/Repositories.swift | 10 ++ PulseLoop/Services/VitalPeriods.swift | 63 +++++++ PulseLoop/Views/MetricDetailView.swift | 145 +++++++++------ PulseLoopTests/VitalPeriodsTests.swift | 120 +++++++++++++ 7 files changed, 496 insertions(+), 53 deletions(-) create mode 100644 PulseLoop/DesignSystem/PeriodNavigationHeader.swift create mode 100644 PulseLoop/Services/VitalPeriods.swift create mode 100644 PulseLoopTests/VitalPeriodsTests.swift diff --git a/PulseLoop/DesignSystem/PeriodNavigationHeader.swift b/PulseLoop/DesignSystem/PeriodNavigationHeader.swift new file mode 100644 index 00000000..165ca876 --- /dev/null +++ b/PulseLoop/DesignSystem/PeriodNavigationHeader.swift @@ -0,0 +1,166 @@ +import SwiftUI + +/// The ‹ older · label · newer › stepper Sleep's Day view introduced, shared by the Activity screen +/// and the vitals detail screen (issues #76 / #102). Glass chevrons that mute at a bound, a two-line +/// label (relative + absolute) that never changes height, and an optional tap on the label (a date +/// picker on Activity). +struct PeriodNavigationHeader: View { + let labels: PeriodNavLabels.Labels + let canGoOlder: Bool + let canGoNewer: Bool + var olderLabel = "Previous day" + var newerLabel = "Next day" + var onOlder: () -> Void + var onNewer: () -> Void + /// When set, the label is a button (with a ▾ glyph) — e.g. opens a date picker. + var onLabelTap: (() -> Void)? + /// Drives the label's cross-fade when the shown period changes. + var animationValue: AnyHashable = 0 + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + HStack(spacing: 8) { + chevron("chevron.left", enabled: canGoOlder, label: olderLabel, + disabledHint: "Earliest recorded data", action: onOlder) + Spacer(minLength: 4) + Group { + if let onLabelTap { + Button(action: onLabelTap) { label } + .buttonStyle(.plain) + .accessibilityAddTraits(.isButton) + .accessibilityHint("Opens a date picker to choose a day") + } else { + label // Not a disabled Button: that would dim the label. + } + } + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.isHeader) + .accessibilityLabel(labels.spoken) + Spacer(minLength: 4) + chevron("chevron.right", enabled: canGoNewer, label: newerLabel, + disabledHint: "You're viewing the most recent period", action: onNewer) + } + .frame(maxWidth: .infinity) + } + + private var label: some View { + VStack(spacing: 1) { + HStack(spacing: 4) { + Text(labels.primary) + .font(PulseFont.headline) + .foregroundStyle(PulseColors.textPrimary) + .contentTransition(.opacity) + if onLabelTap != nil { + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(PulseColors.textMuted) + } + } + // Always render the subtitle line (a space when empty) so the content below never + // shifts vertically as the label crosses Today/Yesterday/weekday bounds. + Text(labels.secondary.isEmpty ? " " : labels.secondary) + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textSecondary) + .textCase(.uppercase) + .kerning(0.5) + .contentTransition(.opacity) + } + .frame(minHeight: 44) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .animation(reduceMotion ? nil : PulseMotion.spring, value: animationValue) + } + + /// A ≥44pt tappable chevron; glass disc only while enabled, muted glyph when at a bound. + private func chevron(_ system: String, enabled: Bool, label: String, + disabledHint: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: system) + .font(PulseFont.headline.weight(.semibold)) + .foregroundStyle(enabled ? PulseColors.textPrimary : PulseColors.textMuted) + .frame(width: 44, height: 44) + .background { + if enabled { + Circle().fill(Color.clear).frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.pulseTap) + .disabled(!enabled) + .accessibilityLabel(label) + .accessibilityHint(enabled ? "" : disabledHint) + } +} + +/// Header strings for a shown day / week / month. Pure: `now` and the calendar are injected. +enum PeriodNavLabels { + struct Labels: Equatable { + /// Relative where it helps ("Today", "Yesterday", "This week"). + let primary: String + /// The absolute date(s), or empty. + let secondary: String + /// Full, unabbreviated phrase for VoiceOver. + let spoken: String + } + + static func labels(_ period: VitalPeriod, anchor: Date, now: Date = Date(), + calendar: Calendar = .current) -> Labels { + switch period { + case .day: return day(anchor, now: now, calendar: calendar) + case .week: return week(anchor, now: now, calendar: calendar) + case .month: return month(anchor, now: now, calendar: calendar) + } + } + + /// Mirrors Sleep's Day header: Today / Yesterday / weekday (within a week) / date. + static func day(_ shownDay: Date, now: Date = Date(), calendar: Calendar = .current) -> Labels { + let today = calendar.startOfDay(for: now) + let day = calendar.startOfDay(for: shownDay) + let daysAgo = calendar.dateComponents([.day], from: day, to: today).day ?? 0 + let sameYear = calendar.component(.year, from: day) == calendar.component(.year, from: now) + let absolute = format(day, .dateTime.weekday(.abbreviated).month(.abbreviated).day(), calendar) + let full = format(day, .dateTime.weekday(.wide).month(.wide).day(), calendar) + let spokenFull = sameYear ? full : format(day, .dateTime.weekday(.wide).month(.wide).day().year(), calendar) + switch daysAgo { + case 0: return Labels(primary: "Today", secondary: absolute, spoken: "Today, \(full)") + case 1: return Labels(primary: "Yesterday", secondary: absolute, spoken: "Yesterday, \(full)") + case 2..<7: + return Labels(primary: format(day, .dateTime.weekday(.wide), calendar), + secondary: format(day, .dateTime.month(.abbreviated).day(), calendar), + spoken: spokenFull) + default: + let primary = sameYear ? absolute : format(day, .dateTime.month(.abbreviated).day().year(), calendar) + return Labels(primary: primary, secondary: "", spoken: spokenFull) + } + } + + static func week(_ anchor: Date, now: Date = Date(), calendar: Calendar = .current) -> Labels { + let window = VitalPeriods.window(.week, anchor: anchor, calendar: calendar) + let last = calendar.date(byAdding: .day, value: -1, to: window.end) ?? window.start + let short = Date.FormatStyle.dateTime.month(.abbreviated).day() + let span = "\(format(window.start, short, calendar)) – \(format(last, short, calendar))" + let spokenSpan = "\(format(window.start, .dateTime.month(.wide).day(), calendar)) to \(format(last, .dateTime.month(.wide).day(), calendar))" + if window.start == VitalPeriods.latestAnchor(.week, now: now, calendar: calendar) { + return Labels(primary: "Last 7 days", secondary: span, spoken: "Last 7 days, \(spokenSpan)") + } + let sameYear = calendar.component(.year, from: last) == calendar.component(.year, from: now) + return Labels(primary: span, secondary: sameYear ? "" : format(last, .dateTime.year(), calendar), spoken: spokenSpan) + } + + static func month(_ anchor: Date, now: Date = Date(), calendar: Calendar = .current) -> Labels { + let name = format(anchor, .dateTime.month(.wide).year(), calendar) + if anchor == VitalPeriods.latestAnchor(.month, now: now, calendar: calendar) { + return Labels(primary: "This month", secondary: name, spoken: "This month, \(name)") + } + return Labels(primary: name, secondary: "", spoken: name) + } + + private static func format(_ date: Date, _ style: Date.FormatStyle, _ calendar: Calendar) -> String { + var style = style + style.calendar = calendar + style.timeZone = calendar.timeZone + return date.formatted(style) + } +} diff --git a/PulseLoop/DesignSystem/VitalsCharts.swift b/PulseLoop/DesignSystem/VitalsCharts.swift index 4fd99673..a4c37369 100644 --- a/PulseLoop/DesignSystem/VitalsCharts.swift +++ b/PulseLoop/DesignSystem/VitalsCharts.swift @@ -106,8 +106,12 @@ struct ZoneLineChart: View { var verticalGridlines: [Date] = [] /// Injected from the threshold engine: maps a value to the color its segment should take. let colorForValue: (Double) -> Color + /// A fixed x-axis span (a detail screen's whole calendar window, issue #102), so the hours the ring + /// wasn't worn read as gaps instead of the axis shrinking to first→last reading. Nil = fit the data. + var xDomain: ClosedRange? = nil private var timeDomain: ClosedRange? { + if let xDomain { return xDomain } guard let first = samples.first?.timestamp, let last = samples.last?.timestamp, first < last else { return nil } return first...last } diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index bebbfaa5..62e0ed66 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -140,9 +140,24 @@ enum MetricsService { isEstimated: window.isEstimated) } + /// `spo2Series` over a fixed calendar window `[start, end)` (a vital detail period, issue #102) — + /// the same hide-or-flag rule, decided on the raw rows before bucketing. + static func spo2Series(start: Date, end: Date, range: MetricRange, context: ModelContext, + scope: MetricScope = .vitals) -> SpO2Series { + let window = applyEstimatedSpO2Rule(windowSamples(kind: .spo2, start: start, end: end, context: context), + context: context) + return SpO2Series(samples: displaySamples(window.samples, range: range, scope: scope), + isEstimated: window.isEstimated) + } + /// The raw (un-bucketed) SpO₂ rows of a window after the hide-estimated preference. private static func spo2Window(range: MetricRange, context: ModelContext) -> (samples: [MetricSample], isEstimated: Bool) { - let raw = rangeSamples(kind: .spo2, range: range, context: context) + applyEstimatedSpO2Rule(rangeSamples(kind: .spo2, range: range, context: context), context: context) + } + + /// Hide estimated hourly SpO₂ rows (the default) or keep them and flag the series. + private static func applyEstimatedSpO2Rule(_ raw: [MetricSample], + context: ModelContext) -> (samples: [MetricSample], isEstimated: Bool) { let estimated = estimatedSpO2Timestamps(in: raw, context: context) guard !estimated.isEmpty else { return (raw, false) } if MetricPrefsStore.shared.settings.hideEstimatedSpO2 { @@ -172,6 +187,30 @@ enum MetricsService { return EstimatedSpO2.estimatedTimestamps(from: min(first, last), to: max(first, last), context: context) } + /// One kind's readings inside a fixed calendar window `[start, end)` (a vital detail period, + /// issue #102), oldest first, calibrated and display-downsampled exactly like `metricRange`. Unlike + /// `metricRange` it never widens to full history for demo data — the window is the window. SpO₂ + /// goes through `spo2Series(start:end:…)` so estimated hourly rows are hidden or flagged as on + /// every other surface; use that directly when the flag is needed. + static func measurementWindow(kind: MeasurementKind, start: Date, end: Date, range: MetricRange, + context: ModelContext, scope: MetricScope = .vitals) -> [MetricSample] { + if kind == .spo2 { + return spo2Series(start: start, end: end, range: range, context: context, scope: scope).samples + } + let samples = calibrated(windowSamples(kind: kind, start: start, end: end, context: context), kind: kind) + return displaySamples(samples, range: range, scope: scope) + } + + /// Raw rows of one kind in `[start, end)`, oldest first. + private static func windowSamples(kind: MeasurementKind, start: Date, end: Date, context: ModelContext) -> [MetricSample] { + let raw = kind.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.kindRaw == raw && $0.timestamp >= start && $0.timestamp < end }, + sortBy: [SortDescriptor(\.timestamp, order: .forward)] + ) + return ((try? context.fetch(descriptor)) ?? []).map { MetricSample(timestamp: $0.timestamp, value: $0.value) } + } + /// The display transform applied to already-fetched, windowed samples: bucket-average per the /// user's graph-resolution preference (`.full`/targetBuckets 0 is identity). This is the SINGLE /// source of truth for the vitals sparkline/chart shape, so any caller that runs it over the same diff --git a/PulseLoop/Services/Repositories.swift b/PulseLoop/Services/Repositories.swift index d4ea614d..a50afa7f 100644 --- a/PulseLoop/Services/Repositories.swift +++ b/PulseLoop/Services/Repositories.swift @@ -123,6 +123,16 @@ enum MetricsRepository { return try? context.fetch(descriptor).first?.timestamp } + /// Oldest timestamp of one kind — how far back a vital's detail screen may page. + @MainActor + static func oldestMeasurementTimestamp(kind: MeasurementKind, context: ModelContext) -> Date? { + let raw = kind.rawValue + var descriptor = FetchDescriptor(predicate: #Predicate { $0.kindRaw == raw }, + sortBy: [SortDescriptor(\.timestamp, order: .forward)]) + descriptor.fetchLimit = 1 + return try? context.fetch(descriptor).first?.timestamp + } + /// Whether any mock measurement exists (demo-mode detection). `fetchLimit: 1` predicated probe. @MainActor static func hasMockMeasurement(context: ModelContext) -> Bool { diff --git a/PulseLoop/Services/VitalPeriods.swift b/PulseLoop/Services/VitalPeriods.swift new file mode 100644 index 00000000..3461aa0e --- /dev/null +++ b/PulseLoop/Services/VitalPeriods.swift @@ -0,0 +1,63 @@ +import Foundation + +/// The periods a vital's detail screen pages through (issue #102). +enum VitalPeriod: String, CaseIterable, Identifiable { + case day = "Day" + case week = "Week" + case month = "Month" + + var id: String { rawValue } + + /// The range the chart styling keys off (axis stride, gap breaking, downsampling). + var range: MetricRange { + switch self { + case .day: return .twentyFourHours + case .week: return .sevenDays + case .month: return .thirtyDays + } + } +} + +/// The calendar window a vital detail chart shows for each `VitalPeriod` (issue #102). +/// +/// The anchor is always the window's **start**: local midnight for a day, the first of the 7 days +/// for a week (the latest week ends today), the 1st for a month. Day used to be a rolling 24 h ending +/// now, so at 10:04 on Oct 6 it showed Oct 5 10:04 → Oct 6 10:04, and the evening of the 6th only +/// appeared under the 7th. Sleep and Activity already page by calendar day. +/// +/// Steps go through calendar days rather than fixed seconds, so a day across a DST change is still +/// midnight to midnight (23 or 25 h). Pure — the calendar (and its time zone) is injected. +enum VitalPeriods { + + /// The latest window's anchor: today, the week ending today, or this month. + static func latestAnchor(_ period: VitalPeriod, now: Date, calendar: Calendar = .current) -> Date { + let today = calendar.startOfDay(for: now) + switch period { + case .day: + return today + case .week: + return calendar.startOfDay(for: calendar.date(byAdding: .day, value: -6, to: today) ?? today) + case .month: + let parts = calendar.dateComponents([.year, .month], from: today) + return calendar.date(from: parts).map { calendar.startOfDay(for: $0) } ?? today + } + } + + /// `anchor` moved by `steps` whole periods (negative = back). + static func step(_ period: VitalPeriod, anchor: Date, by steps: Int, calendar: Calendar = .current) -> Date { + let moved: Date? + switch period { + case .day: moved = calendar.date(byAdding: .day, value: steps, to: anchor) + case .week: moved = calendar.date(byAdding: .day, value: 7 * steps, to: anchor) + case .month: moved = calendar.date(byAdding: .month, value: steps, to: anchor) + } + return calendar.startOfDay(for: moved ?? anchor) + } + + /// The window `[start, end)` an anchor covers. + static func window(_ period: VitalPeriod, anchor: Date, calendar: Calendar = .current) -> DateInterval { + let start = calendar.startOfDay(for: anchor) + let end = step(period, anchor: start, by: 1, calendar: calendar) + return DateInterval(start: start, end: max(end, start)) + } +} diff --git a/PulseLoop/Views/MetricDetailView.swift b/PulseLoop/Views/MetricDetailView.swift index 880f3d0a..1289b881 100644 --- a/PulseLoop/Views/MetricDetailView.swift +++ b/PulseLoop/Views/MetricDetailView.swift @@ -2,10 +2,10 @@ import SwiftUI import SwiftData import Charts -/// Tap-through detail for one vitals metric: period selector, a full-axis zone chart, stat tiles, -/// a threshold legend, an explainer, and a disclaimer for estimated metrics. Re-fetches through the -/// existing `MetricsService.metricRange` (the same fetch path the dashboard uses) when the period -/// changes — no new data plumbing. +/// Tap-through detail for one vitals metric: period selector, a calendar-period stepper, a zone chart +/// spanning the whole period, stat tiles, the deletable readings list, a threshold legend, an +/// explainer, and a disclaimer for estimated metrics. Each period is a calendar window (`VitalPeriods`): +/// a day is midnight→midnight, a week the 7 days ending on the shown day, a month the calendar month. struct MetricDetailView: View { let metric: MetricKind @Binding var path: NavigationPath @@ -13,7 +13,13 @@ struct MetricDetailView: View { @Environment(\.dismiss) private var dismiss @Query private var profiles: [UserProfile] - @State private var period: DetailPeriod = .today + @State private var period: VitalPeriod = .day + /// Start of the shown window; see `VitalPeriods`. Reset to the latest window on a period change. + @State private var anchor: Date = VitalPeriods.latestAnchor(.day, now: Date()) + @State private var earliestReading: Date? + /// HRV's baseline on a Day: the 24 h ending at the window's end (a calendar day holds too few + /// readings early on to establish one). + @State private var hrvBaselineSamples: [MetricSample] = [] @State private var primary: [MetricSample] = [] @State private var secondary: [MetricSample] = [] // diastolic, for BP /// The SpO₂ window holds hourly readings this ring computed from heart rate (`EstimatedSpO2`). @@ -55,41 +61,54 @@ struct MetricDetailView: View { } } - enum DetailPeriod: String, CaseIterable, Identifiable { - case today = "Today" - case week = "Week" - case month = "Month" - var id: String { rawValue } - var range: MetricRange { - switch self { - case .today: return .twentyFourHours - case .week: return .sevenDays - case .month: return .thirtyDays - } - } + private var window: DateInterval { VitalPeriods.window(period, anchor: anchor) } + private var latestAnchor: Date { VitalPeriods.latestAnchor(period, now: Date()) } + private var canGoNewer: Bool { anchor < latestAnchor } + /// Back while the previous window still reaches the earliest stored reading (one-year floor). + private var canGoOlder: Bool { + guard let earliestReading else { return false } + let floor = max(earliestReading, Calendar.current.date(byAdding: .day, value: -365, to: Date()) ?? earliestReading) + return window.start > floor } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { periodSelector - chartSection - statTiles - readingsCard - legend - explainer - if isEstimatedMetric { disclaimer } + // Outside the cards' glass container: merged into it, the chevrons' glass discs + // draw over their own glyphs. + PeriodNavigationHeader( + labels: PeriodNavLabels.labels(period, anchor: anchor), + canGoOlder: canGoOlder, canGoNewer: canGoNewer, + olderLabel: "Previous \(period.rawValue.lowercased())", + newerLabel: "Next \(period.rawValue.lowercased())", + onOlder: { stepPeriod(-1) }, onNewer: { stepPeriod(1) }, + animationValue: anchor + ) + VStack(alignment: .leading, spacing: 18) { + chartSection + statTiles + readingsCard + legend + explainer + if isEstimatedMetric { disclaimer } + } + // Stacked glass cards share one container so their glass renders/blends consistently. + .pulseGlassContainer(spacing: 18) } .padding(16) .padding(.bottom, 40) - // Stacked glass cards share one container so their glass renders/blends consistently. - .pulseGlassContainer(spacing: 18) } .background(PulseColors.background) // Shared glass chrome: centered title + glass back button, no system nav // bar (so the zoom transition doesn't reflow the content). .pageChrome(metric.title) - .task(id: period) { readingsVisible = Self.readingsPageSize; reload() } + .onChange(of: period) { _, newPeriod in anchor = VitalPeriods.latestAnchor(newPeriod, now: Date()) } + .task(id: "\(period.rawValue)-\(anchor.timeIntervalSince1970)") { + readingsVisible = Self.readingsPageSize + reload() + } + .sensoryFeedback(.selection, trigger: anchor) .onChange(of: dataChange.token) { _, _ in reload() } .alert("Delete this reading?", isPresented: Binding( get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } } @@ -106,7 +125,7 @@ struct MetricDetailView: View { private var periodSelector: some View { Picker("Period", selection: $period) { - ForEach(DetailPeriod.allCases) { Text($0.rawValue).tag($0) } + ForEach(VitalPeriod.allCases) { Text($0.rawValue).tag($0) } } .pickerStyle(.segmented) } @@ -125,7 +144,9 @@ struct MetricDetailView: View { .foregroundStyle(PulseColors.textMuted) .frame(maxWidth: .infinity, alignment: .leading) } - if chart.count < 2 { + // One reading is enough once the axis spans the whole period (issue #102) — a once-a-day + // metric's Day would otherwise never chart. + if chart.isEmpty { Text("Not enough data for this period.") .font(PulseFont.footnote.weight(.regular)).foregroundStyle(PulseColors.textMuted) .frame(maxWidth: .infinity, minHeight: 120, alignment: .center) @@ -146,7 +167,9 @@ struct MetricDetailView: View { colorForValue: { value in // Samples are in display units; color lookup needs canonical units. VitalsThresholdEngine.colorToken(forValue: toRaw(value), metric: metric, profile: profile, baseline: baseline).color - } + }, + // The whole period, so time the ring wasn't worn shows as a gap (issue #102). + xDomain: window.start...window.end ) } } @@ -173,8 +196,20 @@ struct MetricDetailView: View { .foregroundStyle(PulseColors.bloodPressure.opacity(0.5)) .interpolationMethod(.monotone) } + // Points too: a lone reading in a sparse period has no line to draw. + if sys.count < 8 { + ForEach(sys) { s in + PointMark(x: .value("Time", s.timestamp), y: .value("Systolic", s.value)) + .foregroundStyle(PulseColors.bloodPressure).symbolSize(30) + } + ForEach(dia) { s in + PointMark(x: .value("Time", s.timestamp), y: .value("Diastolic", s.value)) + .foregroundStyle(PulseColors.bloodPressure.opacity(0.5)).symbolSize(30) + } + } } .chartYScale(domain: lo...hi) + .chartXScale(domain: window.start...window.end) .frame(height: 200) } @@ -314,7 +349,7 @@ struct MetricDetailView: View { } private func readingTime(_ date: Date) -> String { - period == .today + period == .day ? date.formatted(date: .omitted, time: .shortened) : date.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().hour().minute()) } @@ -377,37 +412,42 @@ struct MetricDetailView: View { // MARK: - Data private func reload() { - if metric == .bloodPressure { - primary = MetricsService.metricRange(metric: .bloodPressureSystolic, range: period.range, context: modelContext) - secondary = MetricsService.metricRange(metric: .bloodPressureDiastolic, range: period.range, context: modelContext) - } else if metric == .spo2 { - let series = MetricsService.spo2Series(range: period.range, context: modelContext) + let window = window + let kind = VitalReadings.primaryKind(for: metric) + func fetch(_ kind: MeasurementKind, _ start: Date, _ end: Date) -> [MetricSample] { + MetricsService.measurementWindow(kind: kind, start: start, end: end, range: period.range, context: modelContext) + } + if metric == .spo2 { + // Estimated hourly SpO₂ is hidden or flagged by the same rule as the card (`EstimatedSpO2`). + let series = MetricsService.spo2Series(start: window.start, end: window.end, range: period.range, + context: modelContext) primary = series.samples - secondary = [] spo2Estimated = series.isEstimated } else { - primary = MetricsService.metricRange(metric: metric.metricKey, range: period.range, context: modelContext) - secondary = [] + primary = fetch(kind, window.start, window.end) + spo2Estimated = false } - let window = readingsWindow + secondary = metric == .bloodPressure ? fetch(.bloodPressureDiastolic, window.start, window.end) : [] + if metric == .hrv, period == .day { + let end = min(Date(), window.end) + hrvBaselineSamples = fetch(.hrv, end.addingTimeInterval(-86_400), end) + } else { + hrvBaselineSamples = primary + } + earliestReading = MetricsRepository.oldestMeasurementTimestamp(kind: kind, context: modelContext) readings = VitalReadings.load(metric: metric, start: window.start, end: window.end, context: modelContext) } - /// The span the readings list covers — the same rolling window the chart fetches. - private var readingsWindow: DateInterval { - let now = Date() - let seconds: TimeInterval - switch period { - case .today: seconds = 86_400 - case .week: seconds = 7 * 86_400 - case .month: seconds = 30 * 86_400 - } - return DateInterval(start: now.addingTimeInterval(-seconds), end: now.addingTimeInterval(1)) + private func stepPeriod(_ steps: Int) { + let target = VitalPeriods.step(period, anchor: anchor, by: steps) + guard steps < 0 ? canGoOlder : canGoNewer else { return } + withAnimation(PulseMotion.spring) { anchor = min(target, latestAnchor) } } - /// HRV detail can compute a real baseline because Week/Month fetch enough history. + /// HRV detail can compute a real baseline because Week/Month fetch enough history (and a Day + /// borrows the 24 h ending at the shown day's end). private var baselineForChart: BaselineStats? { - metric == .hrv ? BaselineStats.compute(primary) : nil + metric == .hrv ? BaselineStats.compute(hrvBaselineSamples) : nil } private func detailBands(baseline: BaselineStats?) -> [ReferenceBand] { @@ -431,7 +471,8 @@ struct MetricDetailView: View { if metric == .spo2 { return 88...100 } if metric == .stress || metric == .fatigue { return 0...100 } let values = samples.map(\.value).filter { $0 > 0 } - guard let lo = values.min(), let hi = values.max(), lo < hi else { return 0...100 } + guard let lo = values.min(), let hi = values.max() else { return 0...100 } + if lo == hi { return (lo - 10)...(hi + 10) } let pad = (hi - lo) * 0.1 + 1 return (lo - pad)...(hi + pad) } diff --git a/PulseLoopTests/VitalPeriodsTests.swift b/PulseLoopTests/VitalPeriodsTests.swift new file mode 100644 index 00000000..7b15c2c4 --- /dev/null +++ b/PulseLoopTests/VitalPeriodsTests.swift @@ -0,0 +1,120 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Calendar windows for the vital detail periods (issue #102): midnight→midnight days that stay +/// correct across DST, weeks ending today, calendar months, and chart fetches bounded to the window. +@MainActor +final class VitalPeriodsTests: XCTestCase { + private var calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "America/New_York")! + return c + }() + + private func date(_ y: Int, _ m: Int, _ d: Int, _ h: Int = 0, _ min: Int = 0) -> Date { + calendar.date(from: DateComponents(year: y, month: m, day: d, hour: h, minute: min))! + } + + /// The reporter's case: at 10:04 on Oct 6, Day is Oct 6 00:00 → Oct 7 00:00, not a rolling 24 h. + func testDayAt1004IsTheCalendarDay() { + let now = date(2026, 10, 6, 10, 4) + let anchor = VitalPeriods.latestAnchor(.day, now: now, calendar: calendar) + let window = VitalPeriods.window(.day, anchor: anchor, calendar: calendar) + XCTAssertEqual(window.start, date(2026, 10, 6)) + XCTAssertEqual(window.end, date(2026, 10, 7)) + XCTAssertTrue(window.contains(date(2026, 10, 6, 21, 30)), "the evening belongs to the 6th") + XCTAssertFalse(window.contains(date(2026, 10, 5, 22, 0))) + } + + func testDSTDaysAreMidnightToMidnight() { + // Spring forward (23 h) and fall back (25 h) in New York. + let spring = VitalPeriods.window(.day, anchor: date(2026, 3, 8), calendar: calendar) + XCTAssertEqual(spring.duration, 23 * 3600) + XCTAssertEqual(spring.end, date(2026, 3, 9)) + + let fall = VitalPeriods.window(.day, anchor: date(2026, 11, 1), calendar: calendar) + XCTAssertEqual(fall.duration, 25 * 3600) + XCTAssertEqual(fall.end, date(2026, 11, 2)) + + // Stepping back across the change still lands on midnight. + let back = VitalPeriods.step(.day, anchor: date(2026, 3, 9), by: -1, calendar: calendar) + XCTAssertEqual(back, date(2026, 3, 8)) + XCTAssertEqual(calendar.component(.hour, from: VitalPeriods.step(.day, anchor: date(2026, 3, 8), by: 1, calendar: calendar)), 0) + } + + func testWeekEndsTodayAndStepsBySevenDays() { + let now = date(2026, 11, 9, 19, 0) + let anchor = VitalPeriods.latestAnchor(.week, now: now, calendar: calendar) + XCTAssertEqual(anchor, date(2026, 11, 3)) + XCTAssertEqual(VitalPeriods.window(.week, anchor: anchor, calendar: calendar).end, date(2026, 11, 10)) + // A week spanning the fall-back change is 7 calendar days (169 h). + let previous = VitalPeriods.step(.week, anchor: anchor, by: -1, calendar: calendar) + XCTAssertEqual(previous, date(2026, 10, 27)) + XCTAssertEqual(VitalPeriods.window(.week, anchor: previous, calendar: calendar).duration, 7 * 86_400 + 3600) + } + + func testMonthIsTheCalendarMonth() { + let now = date(2026, 3, 31, 12) + let anchor = VitalPeriods.latestAnchor(.month, now: now, calendar: calendar) + XCTAssertEqual(anchor, date(2026, 3, 1)) + XCTAssertEqual(VitalPeriods.window(.month, anchor: anchor, calendar: calendar).end, date(2026, 4, 1)) + XCTAssertEqual(VitalPeriods.step(.month, anchor: anchor, by: -1, calendar: calendar), date(2026, 2, 1)) + XCTAssertEqual(VitalPeriods.window(.month, anchor: date(2026, 2, 1), calendar: calendar).end, date(2026, 3, 1)) + } + + func testLabels() { + let now = date(2026, 11, 9, 19, 0) + XCTAssertEqual(PeriodNavLabels.day(date(2026, 11, 9), now: now, calendar: calendar).primary, "Today") + XCTAssertEqual(PeriodNavLabels.day(date(2026, 11, 8), now: now, calendar: calendar).primary, "Yesterday") + XCTAssertEqual(PeriodNavLabels.week(date(2026, 11, 3), now: now, calendar: calendar).primary, "Last 7 days") + XCTAssertEqual(PeriodNavLabels.month(date(2026, 11, 1), now: now, calendar: calendar).primary, "This month") + XCTAssertNotEqual(PeriodNavLabels.month(date(2026, 10, 1), now: now, calendar: calendar).primary, "This month") + } + + /// The chart fetch is bounded to `[start, end)` — no rolling 24 h, no demo full-history widening. + func testMeasurementWindowIsBoundedToThePeriod() throws { + let context = try TestSupport.makeContext() + let day = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: 1_760_000_000)) + let next = Calendar.current.date(byAdding: .day, value: 1, to: day)! + TestSupport.insertMeasurement(kind: .heartRate, value: 60, timestamp: day.addingTimeInterval(-60), source: .mock, into: context) + TestSupport.insertMeasurement(kind: .heartRate, value: 70, timestamp: day.addingTimeInterval(3600), source: .mock, into: context) + TestSupport.insertMeasurement(kind: .heartRate, value: 80, timestamp: next, source: .mock, into: context) + let samples = MetricsService.measurementWindow(kind: .heartRate, start: day, end: next, range: .twentyFourHours, context: context) + XCTAssertEqual(samples.map(\.value), [70]) + } + /// The calendar-window fetch applies the estimated-SpO₂ rule (`EstimatedSpO2`) on the raw rows: + /// hidden by default, kept and flagged when the user shows estimates. + func testSpO2WindowHidesOrFlagsEstimatedHourlyRows() throws { + let context = try TestSupport.makeContext() + let now = Date() + let heartRates = [48, 57, 67, 75, 82, 92, 105, 70] + func spo2(_ hr: Int) -> Double { hr <= 48 ? 88 : hr <= 57 ? 94 : hr <= 92 ? 98 : 96 } + for i in 0..<30 { + let t = now.addingTimeInterval(-Double(30 - i) * 3600 + 34) + let hr = heartRates[i % heartRates.count] + TestSupport.insertMeasurement(kind: .spo2, value: spo2(hr), timestamp: t, source: .history, into: context) + TestSupport.insertMeasurement(kind: .respiratoryRate, value: Double(hr) / 5, timestamp: t, source: .history, into: context) + TestSupport.insertMeasurement(kind: .heartRate, value: Double(hr), timestamp: t.addingTimeInterval(-5), source: .history, into: context) + } + let spot = now.addingTimeInterval(-600) + TestSupport.insertMeasurement(kind: .spo2, value: 99, timestamp: spot, source: .live, into: context) + let start = now.addingTimeInterval(-86_400), end = now.addingTimeInterval(1) + + let saved = MetricPrefsStore.shared.settings + defer { MetricPrefsStore.shared.settings = saved } + var prefs = saved + prefs.resolution = .full + prefs.hideEstimatedSpO2 = true + MetricPrefsStore.shared.settings = prefs + XCTAssertEqual(MetricsService.measurementWindow(kind: .spo2, start: start, end: end, range: .twentyFourHours, + context: context).map(\.timestamp), [spot]) + XCTAssertFalse(MetricsService.spo2Series(start: start, end: end, range: .twentyFourHours, context: context).isEstimated) + + prefs.hideEstimatedSpO2 = false + MetricPrefsStore.shared.settings = prefs + let shown = MetricsService.spo2Series(start: start, end: end, range: .twentyFourHours, context: context) + XCTAssertTrue(shown.isEstimated) + XCTAssertEqual(shown.samples.count, 25, "24 hourly estimates in the window plus the spot reading") + } +} From 74598b68ad8aa1abb948e3cf5acfb21a3ac6e45b Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:21:35 -0500 Subject: [PATCH 18/20] Page the Activity screen by day and list each day's activity records with delete, tombstoned against re-sync --- PulseLoop/Models/PulseModels.swift | 5 + PulseLoop/Persistence/DataArchive.swift | 7 + PulseLoop/Persistence/SeedData.swift | 20 +++ .../RWfitActivityPersistence.swift | 10 +- .../Services/ActivityBucketDeletion.swift | 116 ++++++++++++++ PulseLoop/Services/ActivityDayTotals.swift | 30 ++++ .../Services/MeasurementDeletionService.swift | 10 ++ PulseLoop/Services/PulseServices.swift | 22 ++- PulseLoop/Views/ActivityRecordsCard.swift | 120 +++++++++++++++ PulseLoop/Views/ActivityView.swift | 103 +++++++++++-- .../ActivityBucketDeletionTests.swift | 141 ++++++++++++++++++ PulseLoopTests/DataArchiveTests.swift | 9 +- 12 files changed, 568 insertions(+), 25 deletions(-) create mode 100644 PulseLoop/Services/ActivityBucketDeletion.swift create mode 100644 PulseLoop/Services/ActivityDayTotals.swift create mode 100644 PulseLoop/Views/ActivityRecordsCard.swift create mode 100644 PulseLoopTests/ActivityBucketDeletionTests.swift diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index ad0d24bd..aecfc434 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -189,6 +189,11 @@ final class ActivityDaily { /// total adds the BMR baseline at read. Optional/defaulted for additive lightweight migration — /// same pattern as `UserProfile`'s physiology fields. var estimatedActiveCalories: Double? = nil + /// Steps / metres of activity buckets the user deleted from this day (issue #70). The ring's live + /// cumulative counter still includes them, so every later live reading is reduced by this much + /// before it ratchets the day — see `ActivityBucketDeletion.ratchet`. Defaulted (additive migration). + var deletedSteps: Int = 0 + var deletedDistanceMeters: Double = 0 init( id: UUID = UUID(), diff --git a/PulseLoop/Persistence/DataArchive.swift b/PulseLoop/Persistence/DataArchive.swift index 2f90973c..c1608694 100644 --- a/PulseLoop/Persistence/DataArchive.swift +++ b/PulseLoop/Persistence/DataArchive.swift @@ -199,6 +199,9 @@ nonisolated struct ArchiveActivityDaily: Codable, Sendable { var syncedAt: Date? var createdAt: Date var updatedAt: Date + /// Issue #70 deletion deficit. Optional so older archives decode. + var deletedSteps: Int? + var deletedDistanceMeters: Double? @MainActor init(_ m: ActivityDaily) { id = m.id @@ -211,6 +214,8 @@ nonisolated struct ArchiveActivityDaily: Codable, Sendable { syncedAt = m.syncedAt createdAt = m.createdAt updatedAt = m.updatedAt + deletedSteps = m.deletedSteps + deletedDistanceMeters = m.deletedDistanceMeters } @MainActor func insert(into context: ModelContext) { @@ -225,6 +230,8 @@ nonisolated struct ArchiveActivityDaily: Codable, Sendable { m.syncedAt = syncedAt m.createdAt = createdAt m.updatedAt = updatedAt + m.deletedSteps = deletedSteps ?? 0 + m.deletedDistanceMeters = deletedDistanceMeters ?? 0 context.insert(m) } } diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index 9a73c245..8d4697af 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -77,6 +77,9 @@ enum SeedData { activeMinutes: max(8, steps / 230) ) ) + // The last week also gets the intraday blocks behind each total, so the Activity + // RECORDS card (issue #70) has something to list and delete. + if offset >= -6 { seedDemoBuckets(context, day: date, steps: steps, isToday: offset == 0, calendar: calendar, now: now) } } seedVitals(context, now: now, calendar: calendar) @@ -234,6 +237,23 @@ enum SeedData { /// zone-colored charts show their full color range. HR/SpO₂ are dense over the last 24h (the /// dashboard's window); the slow vitals span ~30 days so the detail screen's baseline/trend fills. @MainActor + /// Hourly demo buckets that sum exactly to the day's `steps` (waking hours; today only up to now). + private static func seedDemoBuckets(_ context: ModelContext, day: Date, steps: Int, isToday: Bool, + calendar: Calendar, now: Date) { + let lastHour = isToday ? Swift.min(21, calendar.component(.hour, from: now)) : 21 + let hours = Array(7...Swift.max(7, lastHour)) + let weights = hours.map { 1.2 + sin(Double($0) * 0.9) + ($0 == 18 ? 2.5 : 0) } + let total = weights.reduce(0, +) + var remaining = steps + for (i, hour) in hours.enumerated() { + guard let start = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: day) else { continue } + let share = i == hours.count - 1 ? remaining : Int(Double(steps) * weights[i] / total) + remaining -= share + context.insert(ActivityBucketSample(timestamp: start, steps: share, + distanceMeters: Double(share) * 0.72, source: "mock")) + } + } + private static func seedVitals(_ context: ModelContext, now: Date, calendar: Calendar) { func add(_ kind: MeasurementKind, _ value: Double, _ ts: Date) { context.insert(Measurement(kind: kind, value: value, unit: kind.unit, timestamp: ts, source: .mock)) diff --git a/PulseLoop/RingProtocol/RWfitActivityPersistence.swift b/PulseLoop/RingProtocol/RWfitActivityPersistence.swift index 91f27115..107408b1 100644 --- a/PulseLoop/RingProtocol/RWfitActivityPersistence.swift +++ b/PulseLoop/RingProtocol/RWfitActivityPersistence.swift @@ -23,14 +23,18 @@ final class RWfitActivityPersistence { switch event { case let .activityUpdate(timestamp, steps, distanceMeters, calories): let row = day(timestamp) - row.steps = max(row.steps, steps) - row.distanceMeters = max(row.distanceMeters, distanceMeters) - row.calories = max(row.calories, calories) + // Net of any bucket the user deleted from this day (issue #70) — see `ActivityBucketDeletion`. + row.steps = ActivityBucketDeletion.ratchet(stored: row.steps, ring: steps, deleted: row.deletedSteps) + row.distanceMeters = ActivityBucketDeletion.ratchet(stored: row.distanceMeters, ring: distanceMeters, + deleted: row.deletedDistanceMeters) + if row.deletedSteps == 0 && row.deletedDistanceMeters == 0 { row.calories = max(row.calories, calories) } row.source = "live" recordChange(row) return true case let .activityBucket(timestamp, steps, distanceMeters): let epoch = Int(timestamp.timeIntervalSince1970) + // A bucket the user deleted stays deleted (issue #70). + if MeasurementDeletionService.isActivityBucketDeleted(startEpoch: epoch, context: context) { return true } if let bucket = buckets.first(where: { $0.startEpoch == epoch }) { bucket.steps = steps bucket.distanceMeters = distanceMeters diff --git a/PulseLoop/Services/ActivityBucketDeletion.swift b/PulseLoop/Services/ActivityBucketDeletion.swift new file mode 100644 index 00000000..a534bdc2 --- /dev/null +++ b/PulseLoop/Services/ActivityBucketDeletion.swift @@ -0,0 +1,116 @@ +import Foundation +import SwiftData + +/// Deleting an individual activity bucket (Android #71 / issue #70). +/// +/// The sibling of `MeasurementDeletionService`, for the same reason: a ring logs activity in +/// intraday blocks, so a day's steps are the sum of a couple of dozen rows rather than one figure, +/// and a block inflated by carrying the ring rather than wearing it (a rough car journey) is +/// otherwise stuck in the day's total for good. +/// +/// Three rules make it stick, all here rather than in a caller: +/// * **Tombstone it.** Buckets upsert by start time, so the next sync of the day writes the bucket +/// straight back. The persistence gates (`ActivityService.applyActivityBucket`, +/// `RWfitActivityPersistence`) check the tombstone first. +/// * **Restate the day without the ratchet.** Today's total is normally ratcheted against the live +/// cumulative counter, which legitimately leads the bucket log. A deletion is the one case where +/// the total must fall — ratcheting would keep the deleted block's steps in the headline. +/// * **Remember what it took.** The ring's live counter still includes the deleted block and keeps +/// re-sending it (seconds later, and on every reconnect). `ActivityDaily.deletedSteps` / +/// `deletedDistanceMeters` carry the deficit so every later live reading is corrected by it. +/// +/// Calories are dropped rather than corrected: a bucket carries no calorie figure to subtract, and +/// the ring's own daily figure demonstrably counts the deleted block. Clearing it makes the read path +/// fall through to the on-device estimate, recomputed here from the buckets that remain. +@MainActor +enum ActivityBucketDeletion { + + /// One intraday bucket as the RECORDS list shows it. + struct Record: Identifiable, Equatable { + var id: Int { startEpoch } + let startEpoch: Int + let timestamp: Date + let steps: Int + let distanceMeters: Double + } + + /// The buckets behind `day`'s total, newest first. + static func records(on day: Date, context: ModelContext) -> [Record] { + let dayStart = Calendar.current.startOfDay(for: day) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.date == dayStart }, + sortBy: [SortDescriptor(\.startEpoch, order: .reverse)] + ) + return ((try? context.fetch(descriptor)) ?? []).map { + Record(startEpoch: $0.startEpoch, timestamp: $0.timestamp, steps: $0.steps, distanceMeters: $0.distanceMeters) + } + } + + /// The day's step total now, and what it becomes once `record` is deleted — what the confirmation + /// tells the user before they commit. + static func stepsAfterDeleting(_ record: Record, context: ModelContext) -> (before: Int, after: Int) { + let day = Calendar.current.startOfDay(for: record.timestamp) + let all = records(on: day, context: context) + let before = MetricsRepository.activity(on: day, context: context)?.steps ?? all.reduce(0) { $0 + $1.steps } + let after = all.filter { $0.startEpoch != record.startEpoch }.reduce(0) { $0 + $1.steps } + return (before, after) + } + + /// Delete the bucket starting at `startEpoch`, tombstone it and restate its day. Saves and signals + /// observers. Returns true when a bucket was removed. + @discardableResult + static func delete(startEpoch: Int, context: ModelContext) -> Bool { + var descriptor = FetchDescriptor(predicate: #Predicate { $0.startEpoch == startEpoch }) + descriptor.fetchLimit = 1 + guard let bucket = try? context.fetch(descriptor).first else { return false } + let day = bucket.date + let removedSteps = bucket.steps + let removedDistance = bucket.distanceMeters + + MeasurementDeletionService.recordTombstone( + key: MeasurementDeletionService.activityKey(startEpoch: startEpoch), + kindRaw: MeasurementDeletionService.activityKindRaw, + timestamp: bucket.timestamp, context: context + ) + context.delete(bucket) + // Persist the delete so the restate fetch below can't still see the bucket. + try? context.save() + + let remaining = (try? context.fetch(FetchDescriptor( + predicate: #Predicate { $0.date == day } + ))) ?? [] + let row: ActivityDaily + if let existing = MetricsRepository.activity(on: day, context: context) { + row = existing + } else { + row = ActivityDaily(date: day, source: ActivityService.ringHistorySource) + context.insert(row) + } + // Deliberately unconditional — see the type note on the ratchet. A day whose every bucket is + // gone keeps its row at zero, so it reads "synced, nothing recorded". + row.steps = remaining.reduce(0) { $0 + $1.steps } + let remainingDistance = remaining.reduce(0.0) { $0 + $1.distanceMeters } + // A bucket stream with no distance channel (jring `0x10`) never carried the day's distance — + // that came from the live counter — so there is nothing to restate it from. + if remainingDistance > 0 || removedDistance > 0 { row.distanceMeters = remainingDistance } + row.deletedSteps += removedSteps + row.deletedDistanceMeters += removedDistance + row.calories = 0 + row.updatedAt = Date() + DailyCalorieEstimator.recompute(day: day, context: context) + try? context.save() + PulseDataChange.shared.notify() + return true + } + + /// What a day's stored total becomes when the ring pushes a live cumulative `ring` reading for it. + /// The stored value still wins where it leads (the counter's normal climb is unchanged on a day + /// with nothing deleted); otherwise the ring's figure is reduced by what the user deleted. + static func ratchet(stored: Int, ring: Int, deleted: Int) -> Int { + max(stored, max(0, ring - deleted)) + } + + static func ratchet(stored: Double, ring: Double, deleted: Double) -> Double { + max(stored, max(0, ring - deleted)) + } +} diff --git a/PulseLoop/Services/ActivityDayTotals.swift b/PulseLoop/Services/ActivityDayTotals.swift new file mode 100644 index 00000000..d778fcaf --- /dev/null +++ b/PulseLoop/Services/ActivityDayTotals.swift @@ -0,0 +1,30 @@ +import Foundation +import SwiftData + +/// One calendar day's activity totals as the Activity summary card shows them (issues #76 / #101). +/// +/// Read from that day's own `ActivityDaily` row — a day with nothing stored is all-nil and renders +/// "—", never falling back to the latest day the way the Today summary does. Calories go through the +/// same read path as Today (`effectiveCalories`: the ring's figure when it reported one, else BMR +/// accrued plus the on-device active estimate), so the two screens agree on today. +struct ActivityDayTotals: Equatable { + var steps: Int? + var distanceMeters: Double? + var calories: Double? + /// The active-energy part of `calories` — what the calorie goal ring measures. + var activeCalories: Double? + + static let empty = ActivityDayTotals() + + @MainActor + static func load(day: Date, context: ModelContext, now: Date = Date()) -> ActivityDayTotals { + guard let row = MetricsRepository.activity(on: day, context: context) else { return .empty } + let profile = MetricsProfileValues(profile: ProfileRepository.profile(context: context)) + return ActivityDayTotals( + steps: row.steps, + distanceMeters: row.distanceMeters, + calories: row.effectiveCalories(profile: profile, now: now), + activeCalories: row.effectiveActiveCalories + ) + } +} diff --git a/PulseLoop/Services/MeasurementDeletionService.swift b/PulseLoop/Services/MeasurementDeletionService.swift index 2b158f1e..ce0be848 100644 --- a/PulseLoop/Services/MeasurementDeletionService.swift +++ b/PulseLoop/Services/MeasurementDeletionService.swift @@ -29,6 +29,16 @@ enum MeasurementDeletionService { isTombstoned(key(kind: kind, timestamp: timestamp), context: context) } + /// The `kindRaw` an activity-bucket tombstone carries (issue #70). + static let activityKindRaw = "activity_bucket" + + /// The tombstone key for the activity bucket starting at `startEpoch`. + static func activityKey(startEpoch: Int) -> String { "activity:\(startEpoch)" } + + static func isActivityBucketDeleted(startEpoch: Int, context: ModelContext) -> Bool { + isTombstoned(activityKey(startEpoch: startEpoch), context: context) + } + static func isTombstoned(_ key: String, context: ModelContext) -> Bool { var descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) descriptor.fetchLimit = 1 diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 62e0ed66..5e87175a 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -828,9 +828,18 @@ enum ActivityService { row = ActivityDaily(date: update.date, source: update.source) context.insert(row) } - if let steps = update.steps { row.steps = max(row.steps, steps) } - if let calories = update.calories { row.calories = max(row.calories, calories) } - if let distanceMeters = update.distanceMeters { row.distanceMeters = max(row.distanceMeters, distanceMeters) } + // The ring's cumulative counter still includes any bucket the user deleted from this day + // (issue #70); it ratchets only after that deficit is taken off. A day with a deletion keeps + // its calories cleared — the ring's figure counts the deleted block and can't be corrected. + let hasDeletion = row.deletedSteps > 0 || row.deletedDistanceMeters > 0 + if let steps = update.steps { + row.steps = ActivityBucketDeletion.ratchet(stored: row.steps, ring: steps, deleted: row.deletedSteps) + } + if let calories = update.calories, !hasDeletion { row.calories = max(row.calories, calories) } + if let distanceMeters = update.distanceMeters { + row.distanceMeters = ActivityBucketDeletion.ratchet(stored: row.distanceMeters, ring: distanceMeters, + deleted: row.deletedDistanceMeters) + } if let activeMinutes = update.activeMinutes { row.activeMinutes = max(row.activeMinutes, activeMinutes) } row.source = update.source row.syncedAt = update.syncedAt @@ -852,8 +861,11 @@ enum ActivityService { let dayStart = Calendar.current.startOfDay(for: timestamp) let epoch = Int(timestamp.timeIntervalSince1970) - // Upsert the bucket sample by its unique start epoch (replace on re-sync). - if let existing = (try? context.fetch(FetchDescriptor( + // Upsert the bucket sample by its unique start epoch (replace on re-sync) — unless the user + // deleted it (issue #70), in which case the day is recomputed without it. + if MeasurementDeletionService.isActivityBucketDeleted(startEpoch: epoch, context: context) { + // Tombstoned: nothing to write. + } else if let existing = (try? context.fetch(FetchDescriptor( predicate: #Predicate { $0.startEpoch == epoch } )))?.first { existing.steps = steps diff --git a/PulseLoop/Views/ActivityRecordsCard.swift b/PulseLoop/Views/ActivityRecordsCard.swift new file mode 100644 index 00000000..d3130d7f --- /dev/null +++ b/PulseLoop/Views/ActivityRecordsCard.swift @@ -0,0 +1,120 @@ +import SwiftUI +import SwiftData + +/// The intraday activity records behind the shown day's total, each deletable (issue #70). +/// +/// The counterpart to a vital's READINGS list: a ring logs activity in blocks, so a block inflated by +/// carrying the ring rather than wearing it is otherwise stuck in the day's total. Deletion only, +/// behind a confirmation that says how far the total drops. Collapsed by default and paged. +struct ActivityRecordsCard: View { + let day: Date + let units: UnitsPreference + @Environment(\.modelContext) private var modelContext + @State private var dataChange = PulseDataChange.shared + @State private var expanded = false + @State private var records: [ActivityBucketDeletion.Record] = [] + @State private var visible = Self.pageSize + @State private var pending: ActivityBucketDeletion.Record? + @State private var pendingTotals: (before: Int, after: Int) = (0, 0) + + private static let pageSize = 50 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(PulseMotion.spring) { expanded.toggle() } + } label: { + HStack(spacing: 6) { + Text("RECORDS").font(PulseFont.caption2).tracking(1.4).foregroundStyle(PulseColors.textMuted) + Spacer() + Text("\(records.count)").font(PulseFont.caption).monospacedDigit().foregroundStyle(PulseColors.textMuted) + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(PulseFont.caption.weight(.semibold)) + .foregroundStyle(PulseColors.textMuted) + } + .padding(16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Activity records, \(records.count)") + .accessibilityHint(expanded ? "Hides the records" : "Shows the records") + + if expanded { + if records.isEmpty { + Text("No activity records synced for this day.") + .font(PulseFont.footnote.weight(.regular)).foregroundStyle(PulseColors.textMuted) + .padding(.horizontal, 16).padding(.bottom, 14) + } else { + LazyVStack(spacing: 0) { + ForEach(records.prefix(visible)) { record in row(record) } + } + if records.count > visible { + Button("Show \(min(Self.pageSize, records.count - visible)) more") { visible += Self.pageSize } + .font(PulseFont.footnote.weight(.semibold)) + .foregroundStyle(PulseColors.accent) + .padding(.horizontal, 16).padding(.vertical, 10) + } + Spacer().frame(height: 6) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .task(id: day) { visible = Self.pageSize; reload() } + .onChange(of: dataChange.token) { _, _ in reload() } + .alert("Delete this activity record?", isPresented: Binding( + get: { pending != nil }, set: { if !$0 { pending = nil } } + ), presenting: pending) { record in + Button("Delete", role: .destructive) { delete(record) } + Button("Cancel", role: .cancel) {} + } message: { record in + Text("\(record.steps.formatted()) steps at \(time(record)).\n\n" + + "The day's total drops by \((pendingTotals.before - pendingTotals.after).formatted()) steps, " + + "from \(pendingTotals.before.formatted()) to \(pendingTotals.after.formatted()). " + + "This can't be undone, and the record stays deleted the next time this day syncs.") + } + } + + private func row(_ record: ActivityBucketDeletion.Record) -> some View { + HStack(spacing: 6) { + Text(time(record)) + .font(PulseFont.footnote.weight(.regular)).foregroundStyle(PulseColors.textSecondary) + Spacer(minLength: 8) + Text("\(record.steps.formatted()) steps") + .font(PulseFont.footnote.weight(.semibold)).monospacedDigit().foregroundStyle(PulseColors.textPrimary) + if record.distanceMeters > 0 { + let d = UnitsFormatter.distance(meters: record.distanceMeters, units: units) + Text("· \(d.value) \(d.unit)") + .font(PulseFont.caption2.weight(.regular)).foregroundStyle(PulseColors.textMuted) + } + Button { + pendingTotals = ActivityBucketDeletion.stepsAfterDeleting(record, context: modelContext) + pending = record + } label: { + Image(systemName: "trash") + .font(PulseFont.footnote) + .foregroundStyle(PulseColors.textMuted) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Delete record, \(record.steps) steps at \(time(record))") + } + .padding(.leading, 16).padding(.trailing, 6) + .accessibilityElement(children: .contain) + } + + private func time(_ record: ActivityBucketDeletion.Record) -> String { + record.timestamp.formatted(date: .omitted, time: .shortened) + } + + private func reload() { + records = ActivityBucketDeletion.records(on: day, context: modelContext) + } + + private func delete(_ record: ActivityBucketDeletion.Record) { + ActivityBucketDeletion.delete(startEpoch: record.startEpoch, context: modelContext) + pending = nil + reload() + } +} diff --git a/PulseLoop/Views/ActivityView.swift b/PulseLoop/Views/ActivityView.swift index 327cf51c..418b1378 100644 --- a/PulseLoop/Views/ActivityView.swift +++ b/PulseLoop/Views/ActivityView.swift @@ -21,11 +21,43 @@ struct ActivityView: View { @State private var summary: TodaySummary? @State private var stale: [ActivitySession] = [] @State private var caloriesAvailable = false + // Day navigation (issue #76, Sleep's pattern): 0 = today, N = N days earlier. The summary and + // RECORDS cards follow the shown day; the weekly widget and today's workouts stay on today. + @State private var dayOffset = 0 + @State private var maxDayOffset = 0 + @State private var showDayPicker = false + @State private var dayTotals = ActivityDayTotals.empty + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var shownDay: Date { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + return calendar.date(byAdding: .day, value: -dayOffset, to: today) ?? today + } private func reload() { summary = MetricsService.buildTodaySummary(context: modelContext) stale = ActivityRecorderService.recoverStaleSession(context: modelContext) caloriesAvailable = MetricsService.isVisible(.calories, context: modelContext) + maxDayOffset = Self.maxDayOffset(context: modelContext) + dayTotals = ActivityDayTotals.load(day: shownDay, context: modelContext) + } + + /// How far back the stepper may page: a week before the earliest stored day, one-year floor, + /// 0 (locked to today) with nothing stored — the same bound Sleep uses. + private static func maxDayOffset(context: ModelContext) -> Int { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + guard let earliest = MetricsRepository.activityRows(context: context).first?.date else { return 0 } + let earliestMinus7 = calendar.date(byAdding: .day, value: -7, to: calendar.startOfDay(for: earliest)) ?? earliest + let yearAgo = calendar.date(byAdding: .day, value: -365, to: today) ?? today + return max(0, calendar.dateComponents([.day], from: max(earliestMinus7, yearAgo), to: today).day ?? 0) + } + + private func setOffset(_ offset: Int) { + let clamped = min(maxDayOffset, max(0, offset)) + guard clamped != dayOffset else { return } + withAnimation(reduceMotion ? nil : PulseMotion.spring) { dayOffset = clamped } } var body: some View { @@ -43,8 +75,17 @@ struct ActivityView: View { StaleSessionRecoveryCard(sessions: stale) } + PeriodNavigationHeader( + labels: PeriodNavLabels.day(shownDay), + canGoOlder: dayOffset < maxDayOffset, canGoNewer: dayOffset > 0, + onOlder: { setOffset(dayOffset + 1) }, onNewer: { setOffset(dayOffset - 1) }, + onLabelTap: { showDayPicker = true }, + animationValue: dayOffset + ) + DailyActivitySummaryCard( - summary: summary, + totals: dayTotals, + goals: summary.goals, units: units, caloriesAvailable: caloriesAvailable ) { @@ -53,7 +94,8 @@ struct ActivityView: View { // Calorie-intake sibling of the daily summary. `summary.nutrition` is only // populated while the nutrition feature is enabled — nothing renders otherwise. - if let nutrition = summary.nutrition { + // Intake is today's only, so it stays off a past day. + if dayOffset == 0, let nutrition = summary.nutrition { NutritionSummaryCard( totals: nutrition, goals: summary.goals, @@ -159,6 +201,10 @@ struct ActivityView: View { .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) } .buttonStyle(.plain) + + // The blocks behind the shown day's total, each removable (issue #70). Below the + // summary: it answers "why is that number wrong", asked only after reading it. + ActivityRecordsCard(day: shownDay, units: units) } // if let summary } .padding(.horizontal, 16) @@ -171,6 +217,9 @@ struct ActivityView: View { .pulseScrollEdges() .task(id: dataChange.token) { reload() } .onChange(of: sessions.count) { _, _ in reload() } + .onChange(of: dayOffset) { _, _ in dayTotals = ActivityDayTotals.load(day: shownDay, context: modelContext) } + .sensoryFeedback(.selection, trigger: dayOffset) + .sheet(isPresented: $showDayPicker) { dayPickerSheet } .sheet(isPresented: $goalsOpen) { GoalEditorSheet() } .sheet(isPresented: $historyOpen) { WorkoutHistorySheet(units: units) { id in @@ -180,6 +229,31 @@ struct ActivityView: View { } } + /// Graphical date picker bounded to [oldest browsable day ... today]. + private var dayPickerSheet: some View { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let floor = calendar.date(byAdding: .day, value: -maxDayOffset, to: today) ?? today + let selection = Binding( + get: { shownDay }, + set: { date in + setOffset(calendar.dateComponents([.day], from: calendar.startOfDay(for: date), to: today).day ?? 0) + showDayPicker = false + } + ) + return NavigationStack { + DatePicker("Day", selection: selection, in: floor...today, displayedComponents: .date) + .datePickerStyle(.graphical) + .padding() + .navigationTitle("Choose a day") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { showDayPicker = false } } + } + } + .presentationDetents([.medium, .large]) + } + // MARK: data private func weeklyDays(summary: TodaySummary, stepGoal: Int, todayIndex: Int) -> [WeeklyDay] { @@ -203,11 +277,12 @@ struct ActivityView: View { // MARK: - Daily activity summary widget /// Full-width Apple-Fitness-style daily summary: colored Steps / Distance / Calories metrics on the -/// left, three concentric progress rings on the right (outer→inner: steps, distance, calories). Reads -/// the same daily totals and goals the page already uses; tapping opens the Activity Trends detail. -/// Missing (nil) metrics render an em dash and an inactive ring — the ring math is nil/zero-safe. +/// left, three concentric progress rings on the right (outer→inner: steps, distance, calories). Shows +/// the day the Activity header is on; tapping opens the Activity Trends detail. Missing (nil) metrics +/// render an em dash and an inactive ring — the ring math is nil/zero-safe. struct DailyActivitySummaryCard: View { - let summary: TodaySummary + let totals: ActivityDayTotals + let goals: GoalsSummary let units: UnitsPreference /// Whether the paired ring supports active-energy tracking. When false, calories are treated as /// unavailable (em dash + inactive ring) rather than showing whatever raw value the ring reports. @@ -216,19 +291,19 @@ struct DailyActivitySummaryCard: View { /// Calories only when the device actually tracks them; otherwise nil so text shows "—" and the /// ring stays a muted track (matches the Today page's `isVisible(.calories)` gating). - private var effectiveCalories: Double? { caloriesAvailable ? summary.calories : nil } + private var effectiveCalories: Double? { caloriesAvailable ? totals.calories : nil } /// The goal ring measures active energy (`UserGoal.calories` is an active-energy goal), even /// when the displayed number is an estimated total that includes the BMR baseline. - private var effectiveActiveCalories: Double? { caloriesAvailable ? summary.activeCalories : nil } + private var effectiveActiveCalories: Double? { caloriesAvailable ? totals.activeCalories : nil } private var distanceUnit: String { UnitsFormatter.distance(meters: 0, units: units).unit } - private var distanceValue: String? { summary.distanceMeters.map { UnitsFormatter.distance(meters: $0, units: units).value } } + private var distanceValue: String? { totals.distanceMeters.map { UnitsFormatter.distance(meters: $0, units: units).value } } /// Goal distance converted to the display unit, matching `distanceValue`, so the ring and text agree. private var distanceGoalDisplay: Double { - Double(UnitsFormatter.distance(meters: summary.goals.distanceMetersDaily, units: units).value) ?? 0 + Double(UnitsFormatter.distance(meters: goals.distanceMetersDaily, units: units).value) ?? 0 } private var distanceDisplay: Double? { - summary.distanceMeters.flatMap { Double(UnitsFormatter.distance(meters: $0, units: units).value) } + totals.distanceMeters.flatMap { Double(UnitsFormatter.distance(meters: $0, units: units).value) } } var body: some View { @@ -237,7 +312,7 @@ struct DailyActivitySummaryCard: View { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 18) { HStack(alignment: .top, spacing: 12) { - metric(label: "Steps", value: summary.steps.map { $0.formatted() } ?? "—", unit: nil, color: PulseColors.steps) + metric(label: "Steps", value: totals.steps.map { $0.formatted() } ?? "—", unit: nil, color: PulseColors.steps) metric(label: "Distance", value: distanceValue ?? "—", unit: distanceValue == nil ? nil : distanceUnit, color: PulseColors.distance) } metric(label: "Calories", value: effectiveCalories.map { Int($0).formatted() } ?? "—", unit: effectiveCalories == nil ? nil : "cal", color: PulseColors.calories) @@ -245,9 +320,9 @@ struct DailyActivitySummaryCard: View { .frame(maxWidth: .infinity, alignment: .leading) ActivityRingsView(rings: [ - ActivityRing(value: summary.steps.map(Double.init), goal: Double(summary.goals.stepsDaily), color: PulseColors.steps), + ActivityRing(value: totals.steps.map(Double.init), goal: Double(goals.stepsDaily), color: PulseColors.steps), ActivityRing(value: distanceDisplay, goal: distanceGoalDisplay, color: PulseColors.distance), - ActivityRing(value: effectiveActiveCalories, goal: Double(summary.goals.caloriesDaily), color: PulseColors.calories) + ActivityRing(value: effectiveActiveCalories, goal: Double(goals.caloriesDaily), color: PulseColors.calories) ], size: 112, stroke: 11, spacing: 5) .frame(width: 112, height: 112) } diff --git a/PulseLoopTests/ActivityBucketDeletionTests.swift b/PulseLoopTests/ActivityBucketDeletionTests.swift new file mode 100644 index 00000000..bc5be70e --- /dev/null +++ b/PulseLoopTests/ActivityBucketDeletionTests.swift @@ -0,0 +1,141 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Deleting an intraday activity bucket (issue #70): the day is restated without the live ratchet, +/// the bucket stays deleted across re-syncs, and the ring's live counter is corrected by the deficit. +/// Plus the Activity screen's per-day totals (issues #76 / #101). +@MainActor +final class ActivityBucketDeletionTests: XCTestCase { + private let calendar = Calendar.current + + private func at(_ day: Date, hour: Int) -> Date { + calendar.date(bySettingHour: hour, minute: 0, second: 0, of: day)! + } + + private func buckets(_ context: ModelContext) throws -> [ActivityBucketSample] { + try context.fetch(FetchDescriptor()) + } + + func testDeletingAPastBucketRestatesTheDayAndSurvivesResync() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + let day = TestSupport.day(-2) + subscriber.persist(.activityBucket(timestamp: at(day, hour: 9), steps: 1_000, distanceMeters: 700)) + subscriber.persist(.activityBucket(timestamp: at(day, hour: 13), steps: 6_000, distanceMeters: 4_200)) + subscriber.persist(.activityBucket(timestamp: at(day, hour: 17), steps: 2_000, distanceMeters: 1_400)) + subscriber.flush() + XCTAssertEqual(MetricsRepository.activity(on: day, context: context)?.steps, 9_000) + + let records = ActivityBucketDeletion.records(on: day, context: context) + XCTAssertEqual(records.map(\.steps), [2_000, 6_000, 1_000], "newest first") + let car = records[1] + let preview = ActivityBucketDeletion.stepsAfterDeleting(car, context: context) + XCTAssertEqual(preview.before, 9_000) + XCTAssertEqual(preview.after, 3_000) + + XCTAssertTrue(ActivityBucketDeletion.delete(startEpoch: car.startEpoch, context: context)) + let row = try XCTUnwrap(MetricsRepository.activity(on: day, context: context)) + XCTAssertEqual(row.steps, 3_000) + XCTAssertEqual(row.distanceMeters, 2_100, accuracy: 0.001) + XCTAssertEqual(row.deletedSteps, 6_000) + + // The next sync replays the day. + subscriber.persist(.activityBucket(timestamp: at(day, hour: 9), steps: 1_000, distanceMeters: 700)) + subscriber.persist(.activityBucket(timestamp: at(day, hour: 13), steps: 6_000, distanceMeters: 4_200)) + subscriber.persist(.activityBucket(timestamp: at(day, hour: 17), steps: 2_000, distanceMeters: 1_400)) + subscriber.flush() + XCTAssertEqual(try buckets(context).count, 2, "the deleted bucket must not come back") + XCTAssertEqual(MetricsRepository.activity(on: day, context: context)?.steps, 3_000) + XCTAssertFalse(ActivityBucketDeletion.delete(startEpoch: car.startEpoch, context: context), "already gone") + } + + /// Today is normally ratcheted against the live counter; a deletion must still let the total fall, + /// and the next live reading is reduced by what was deleted instead of restoring it. + func testTodayDropsBelowTheRatchetAndLiveUpdatesAreCorrected() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + let today = calendar.startOfDay(for: Date()) + let early = today.addingTimeInterval(60) + subscriber.persist(.activityUpdate(timestamp: early, steps: 5_000, distanceMeters: 3_500, calories: 220)) + subscriber.persist(.activityBucket(timestamp: early, steps: 1_500, distanceMeters: 1_000)) + subscriber.persist(.activityBucket(timestamp: early.addingTimeInterval(900), steps: 3_000, distanceMeters: 2_100)) + subscriber.flush() + XCTAssertEqual(MetricsRepository.activity(on: today, context: context)?.steps, 5_000, "live counter leads the buckets") + + let epoch = Int(early.addingTimeInterval(900).timeIntervalSince1970) + XCTAssertTrue(ActivityBucketDeletion.delete(startEpoch: epoch, context: context)) + var row = try XCTUnwrap(MetricsRepository.activity(on: today, context: context)) + XCTAssertEqual(row.steps, 1_500, "restated as the bucket sum, without the ratchet") + XCTAssertNil(row.deviceReportedCalories, "the ring's calorie figure counted the deleted block") + + // The ring re-sends its cumulative count (still including the deleted 3,000) plus 200 new steps. + subscriber.persist(.activityUpdate(timestamp: early, steps: 5_200, distanceMeters: 3_600, calories: 230)) + subscriber.flush() + row = try XCTUnwrap(MetricsRepository.activity(on: today, context: context)) + XCTAssertEqual(row.steps, 2_200) + XCTAssertEqual(row.distanceMeters, 1_500, accuracy: 0.001) + XCTAssertNil(row.deviceReportedCalories, "calories stay on the estimate after a deletion") + } + + func testRatchetIsUnchangedWithoutADeletion() { + XCTAssertEqual(ActivityBucketDeletion.ratchet(stored: 4_000, ring: 5_000, deleted: 0), 5_000) + XCTAssertEqual(ActivityBucketDeletion.ratchet(stored: 6_000, ring: 5_000, deleted: 0), 6_000) + XCTAssertEqual(ActivityBucketDeletion.ratchet(stored: 1_000, ring: 5_000, deleted: 6_000), 1_000) + } + + func testRWfitHistoryRespectsTheTombstone() throws { + let context = try TestSupport.makeContext() + let day = TestSupport.day(-1) + let ts = at(day, hour: 10) + context.insert(ActivityBucketSample(timestamp: ts, steps: 800, distanceMeters: 500)) + try context.save() + XCTAssertTrue(ActivityBucketDeletion.delete(startEpoch: Int(ts.timeIntervalSince1970), context: context)) + + let persistence = try RWfitActivityPersistence(context: context) + XCTAssertTrue(persistence.apply(.activityBucket(timestamp: ts, steps: 800, distanceMeters: 500))) + try context.save() + XCTAssertTrue(try buckets(context).isEmpty) + } + + func testDayTotalsReadTheShownDayOnly() throws { + let context = try TestSupport.makeContext() + let today = calendar.startOfDay(for: Date()) + TestSupport.insertActivity(date: today, steps: 8_000, calories: 400, distanceMeters: 5_000, into: context) + + XCTAssertEqual(ActivityDayTotals.load(day: TestSupport.day(-3), context: context), .empty, + "a day with nothing stored reads empty, never today's numbers") + + let totals = ActivityDayTotals.load(day: today, context: context) + let row = try XCTUnwrap(MetricsRepository.activity(on: today, context: context)) + XCTAssertEqual(totals.steps, 8_000) + XCTAssertEqual(totals.calories, + row.effectiveCalories(profile: MetricsProfileValues(profile: ProfileRepository.profile(context: context))), + "calories come from the same read path the Today screen uses") + XCTAssertEqual(totals.calories, 400) + } + + func testArchiveCarriesTheDeletionDeficit() async throws { + let source = try TestSupport.makeContext() + let day = TestSupport.day(-1) + let ts = at(day, hour: 10) + ActivityService.applyActivityBucket(date: ts, steps: 800, distanceMeters: 500, context: source) + ActivityService.applyActivityBucket(date: ts.addingTimeInterval(900), steps: 300, distanceMeters: 200, context: source) + try source.save() + ActivityBucketDeletion.delete(startEpoch: Int(ts.timeIntervalSince1970), context: source) + + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let suite = "ActivityBucketDeletionTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { UserDefaults().removePersistentDomain(forName: suite) } + + let data = try await DataArchiveService.exportArchive(context: source, defaults: defaults, attachmentsDirectory: dir) + let target = try TestSupport.makeContext() + try await DataArchiveService.importArchive(data, context: target, defaults: defaults, + attachmentsDirectory: dir, refreshStores: false) + XCTAssertEqual(MetricsRepository.activity(on: day, context: target)?.deletedSteps, 800) + XCTAssertTrue(MeasurementDeletionService.isActivityBucketDeleted(startEpoch: Int(ts.timeIntervalSince1970), context: target)) + } +} diff --git a/PulseLoopTests/DataArchiveTests.swift b/PulseLoopTests/DataArchiveTests.swift index 11411d74..18e62907 100644 --- a/PulseLoopTests/DataArchiveTests.swift +++ b/PulseLoopTests/DataArchiveTests.swift @@ -14,7 +14,8 @@ final class DataArchiveTests: XCTestCase { (try? context.fetchCount(FetchDescriptor())) ?? -1 } - /// One row of every model type `SeedData.seedDemo` does NOT create, so seed + these = all 25. + /// One row of every model type `SeedData.seedDemo` does NOT create (plus a fixture activity + /// bucket alongside the seeded ones), so seed + these = all of them. private func insertModelsMissingFromSeed(_ context: ModelContext, deviceId: UUID) { context.insert(BatterySample(percent: 57, timestamp: Date(timeIntervalSince1970: 1_750_000_000))) context.insert(DeviceMeasurementConfig(deviceId: deviceId)) @@ -87,7 +88,9 @@ final class DataArchiveTests: XCTestCase { let refValue = referenceMeasurement.value let refTimestamp = referenceMeasurement.timestamp let refCapabilities = device.capabilitiesRaw - let refBucketEpoch = try XCTUnwrap(try source.fetch(FetchDescriptor()).first).startEpoch + // The fixture bucket (123 steps / 88 m) — seeded demo buckets sit alongside it. + let fixtureBucket = FetchDescriptor(predicate: #Predicate { $0.steps == 123 && $0.distanceMeters == 88 }) + let refBucketEpoch = try XCTUnwrap(try source.fetch(fixtureBucket).first).startEpoch let defaults = makeSuiteDefaults(#function) let attachmentsDir = try makeTempDirectory() @@ -116,7 +119,7 @@ final class DataArchiveTests: XCTestCase { // startEpoch is the bucket's unique upsert key. The fixture deliberately stores a value // that differs from what the init would re-derive from the timestamp, so this fails if // the importer ever drops the explicit restore. - let importedBucket = try XCTUnwrap(try target.fetch(FetchDescriptor()).first) + let importedBucket = try XCTUnwrap(try target.fetch(fixtureBucket).first) XCTAssertEqual(importedBucket.startEpoch, refBucketEpoch) XCTAssertNotEqual(importedBucket.startEpoch, Int(importedBucket.timestamp.timeIntervalSince1970)) From 6c5dd848e15e9007c072a22ab847329029b8a7e8 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Mon, 9 Nov 2026 19:24:15 -0500 Subject: [PATCH 19/20] Estimate stress from HRV for rings that never report it, labelled as estimated on the card and detail screen --- PulseLoop/Services/DerivedStress.swift | 66 ++++++++++++++++ PulseLoop/Services/PulseServices.swift | 23 ++++++ PulseLoop/ViewModels/VitalsCardFactory.swift | 7 +- PulseLoop/ViewModels/VitalsStore.swift | 25 +++++- PulseLoop/Views/MetricDetailView.swift | 22 +++++- PulseLoopTests/DerivedStressTests.swift | 83 ++++++++++++++++++++ 6 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 PulseLoop/Services/DerivedStress.swift create mode 100644 PulseLoopTests/DerivedStressTests.swift diff --git a/PulseLoop/Services/DerivedStress.swift b/PulseLoop/Services/DerivedStress.swift new file mode 100644 index 00000000..277652e7 --- /dev/null +++ b/PulseLoop/Services/DerivedStress.swift @@ -0,0 +1,66 @@ +import Foundation + +/// A stress figure derived from HRV, for rings whose hardware never reports one (Android #71, #67). +/// +/// Some rings advertise stress (the capability list is a static per-family constant) but never answer +/// the stress query — the R100 sent 22 requests and got 0 replies — so the card could never fill. Those +/// rings do return plenty of HRV, and stress is conventionally read from HRV, so the number is derivable. +/// +/// **It is an inference, and it is labelled as one wherever it is shown** (the ESTIMATED chip on the +/// card, a disclaimer on the detail screen). And it never replaces a ring's own stress: callers derive +/// only when the store holds no stress reading at all. +/// +/// The rule is relative to the person, not a population: HRV varies several-fold between people, so an +/// absolute cutoff would label whole people permanently stressed or calm. A reading is scored against +/// that user's own recent HRV — 50 at their median, moving inversely with HRV (lower than usual reads as +/// more stress), scaled by their own spread so naturally steady HRV isn't pinned to the middle. +enum DerivedStress { + + /// Fewer readings than this and the baseline isn't that person's normal, it's a coincidence. + static let minBaselineSamples = 12 + + /// The baseline is the most recent readings before the scored one, capped so a month of 5-minute + /// HRV (thousands of readings) scores in linear-ish time rather than re-sorting all history per + /// point. 500 readings is well over a day of all-day monitoring — still "your recent normal". + static let maxBaselineSamples = 500 + + /// How far from the median, in units of the baseline's own spread, maps to the ends of the scale. + /// Two is the conventional "unusual for you" distance and keeps ordinary days off 0 / 100. + private static let spreadAtFullScale = 2.0 + + /// A 0–100 stress score (higher = more stress) for `hrv` against `baseline` (the user's recent + /// HRV), or nil when the baseline is too thin to mean anything — an unearned number on a health + /// screen reads as a measurement. + static func score(hrv: Double, baseline: [Double]) -> Int? { + guard hrv > 0 else { return nil } + let usable = baseline.filter { $0 > 0 } + guard usable.count >= minBaselineSamples, let median = median(usable), median > 0 else { return nil } + // Median absolute deviation: a spread a few wild readings can't drag around (ring HRV history + // has obvious outliers). + guard let mad = Self.median(usable.map { abs($0 - median) }) else { return nil } + // A perfectly flat baseline has no spread; fall back to a proportion of the median so the score + // still responds rather than snapping between 0 and 100. + let spread = mad > 0 ? mad : median * 0.1 + guard spread > 0 else { return nil } + let deviations = (median - hrv) / spread // positive = HRV below normal = more stress + let scaled = 50 + 50 * (deviations / spreadAtFullScale) + return Int(min(100, max(0, scaled))) + } + + /// Each HRV reading scored against the (up to `maxBaselineSamples`) readings before it — what the + /// app could have said at the time, not hindsight — paired with the index of the HRV reading it came + /// from. The first readings score nil (too thin a baseline), so the output is shorter than the input; + /// the index is what lets a chart place each score at its HRV reading's time. + static func scored(_ hrv: [Double]) -> [(index: Int, score: Int)] { + hrv.indices.compactMap { i in + score(hrv: hrv[i], baseline: Array(hrv[max(0, i - maxBaselineSamples).. Double? { + guard !values.isEmpty else { return nil } + let sorted = values.sorted() + let mid = sorted.count / 2 + return sorted.count % 2 == 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 + } +} diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 5e87175a..7a96aed6 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -211,6 +211,29 @@ enum MetricsService { return ((try? context.fetch(descriptor)) ?? []).map { MetricSample(timestamp: $0.timestamp, value: $0.value) } } + /// Whether stress may be derived from HRV (issue #67): only when the store has never held a stress + /// reading. Asked of the whole history, not a window — an empty day happens on a ring that *does* + /// measure stress (monitoring off, a quiet night), and a derived number must never stand in for, or + /// be blended with, a ring's own stress. + static func mayDeriveStress(context: ModelContext) -> Bool { + !MetricsRepository.hasAnyMeasurement(kind: .stress, context: context) + } + + /// Stress derived from the raw HRV readings in `[start, end)`, each score at the time of the HRV + /// reading it came from (`DerivedStress`). Not stored — there is nothing to delete or export. + static func derivedStress(start: Date, end: Date, context: ModelContext) -> [MetricSample] { + let raw = MeasurementKind.hrv.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.kindRaw == raw && $0.timestamp >= start && $0.timestamp < end }, + sortBy: [SortDescriptor(\.timestamp, order: .forward)] + ) + let hrv = (try? context.fetch(descriptor)) ?? [] + guard !hrv.isEmpty else { return [] } + return DerivedStress.scored(hrv.map(\.value)).map { + MetricSample(timestamp: hrv[$0.index].timestamp, value: Double($0.score)) + } + } + /// The display transform applied to already-fetched, windowed samples: bucket-average per the /// user's graph-resolution preference (`.full`/targetBuckets 0 is identity). This is the SINGLE /// source of truth for the vitals sparkline/chart shape, so any caller that runs it over the same diff --git a/PulseLoop/ViewModels/VitalsCardFactory.swift b/PulseLoop/ViewModels/VitalsCardFactory.swift index 2d9ec784..c3f8a4dc 100644 --- a/PulseLoop/ViewModels/VitalsCardFactory.swift +++ b/PulseLoop/ViewModels/VitalsCardFactory.swift @@ -11,6 +11,8 @@ enum VitalsCardFactory { var spo2: [MetricSample] = [] var hrv: [MetricSample] = [] var stress: [MetricSample] = [] + /// `stress` was derived from HRV because the ring reports none (issue #67). Never unlabelled. + var stressIsDerived = false var fatigue: [MetricSample] = [] var temperature: [MetricSample] = [] var systolic: [MetricSample] = [] @@ -139,7 +141,8 @@ enum VitalsCardFactory { let quality = SourceQualityResolver.quality(for: metric, latest: samples.last?.timestamp, now: inputs.now, calibration: cal) let interp = latest.map { VitalsThresholdEngine.interpret(value: $0, metric: metric, profile: profile) } let chart = ChartSampleBuilder.from(samples, quality: quality) - let subtitle = metric == .stress ? "Lower is calmer" : "Ring model estimate" + let derived = metric == .stress && inputs.stressIsDerived + let subtitle = derived ? "Estimated from HRV" : (metric == .stress ? "Lower is calmer" : "Ring model estimate") return VitalCardViewModel( metric: metric, title: metric.title, valueText: latest.map { "\(Int($0))" } ?? "--", unitText: nil, @@ -148,7 +151,7 @@ enum VitalsCardFactory { samples: chart, zones: VitalsThresholdEngine.zones(for: metric, profile: profile), yDomain: 0...100, referenceBands: [], dashedRules: [], trend: TrendSummary.compute(samples: chart, metric: metric), - sourceQuality: quality, isEstimated: false, confidenceLabel: nil, + sourceQuality: quality, isEstimated: derived, confidenceLabel: derived ? "Estimated from HRV" : nil, lastUpdatedText: lastUpdated(samples.last?.timestamp, now: inputs.now), isEmpty: latest == nil ) diff --git a/PulseLoop/ViewModels/VitalsStore.swift b/PulseLoop/ViewModels/VitalsStore.swift index a9b06f35..2d2216a9 100644 --- a/PulseLoop/ViewModels/VitalsStore.swift +++ b/PulseLoop/ViewModels/VitalsStore.swift @@ -15,6 +15,8 @@ final class VitalsStore { /// The 24h SpO₂ series holds hourly readings computed from heart rate (`EstimatedSpO2`). private(set) var spo2IsEstimated = false private(set) var stressSamples: [MetricSample] + /// `stressSamples` came from HRV because the ring has never reported stress (issue #67). + private(set) var stressIsDerived = false private(set) var hrvSamples: [MetricSample] private(set) var tempSamples: [MetricSample] // jring/56ff metrics (calibration offsets already applied by `metricRange`). @@ -50,7 +52,7 @@ final class VitalsStore { let spo2 = MetricsService.spo2Series(range: .twentyFourHours, context: modelContext) self.spo2Samples = spo2.samples self.spo2IsEstimated = spo2.isEstimated - self.stressSamples = MetricsService.metricRange(metric: .stress, range: .twentyFourHours, context: modelContext) + self.stressSamples = [] self.hrvSamples = MetricsService.metricRange(metric: .hrv, range: .twentyFourHours, context: modelContext) self.tempSamples = MetricsService.metricRange(metric: .temperature, range: .twentyFourHours, context: modelContext) self.systolicSamples = MetricsService.metricRange(metric: .bloodPressureSystolic, range: .twentyFourHours, context: modelContext) @@ -60,6 +62,7 @@ final class VitalsStore { self.capabilities = MetricsService.deviceCapabilities(modelContext) self.visibleMetrics = Self.computeVisible(context: modelContext) self.cards = [:] + loadStress() self.signature = Self.currentSignature(context: modelContext, profile: profile) applyRebuiltCards() } @@ -94,7 +97,7 @@ final class VitalsStore { let spo2 = MetricsService.spo2Series(range: .twentyFourHours, context: modelContext) spo2Samples = spo2.samples spo2IsEstimated = spo2.isEstimated - stressSamples = MetricsService.metricRange(metric: .stress, range: .twentyFourHours, context: modelContext) + loadStress() hrvSamples = MetricsService.metricRange(metric: .hrv, range: .twentyFourHours, context: modelContext) tempSamples = MetricsService.metricRange(metric: .temperature, range: .twentyFourHours, context: modelContext) systolicSamples = MetricsService.metricRange(metric: .bloodPressureSystolic, range: .twentyFourHours, context: modelContext) @@ -107,13 +110,29 @@ final class VitalsStore { signature = sig } + /// The ring's own 24 h stress, or — only for a ring that has never reported stress at all — stress + /// derived from the last 24 h of HRV (issue #67). Never a blend of the two. + private func loadStress() { + let stored = MetricsService.metricRange(metric: .stress, range: .twentyFourHours, context: modelContext) + if stored.isEmpty, MetricsService.mayDeriveStress(context: modelContext) { + let now = Date() + let derived = MetricsService.derivedStress(start: now.addingTimeInterval(-86_400), end: now.addingTimeInterval(1), + context: modelContext) + stressSamples = MetricsService.displaySamples(derived, range: .twentyFourHours) + stressIsDerived = !stressSamples.isEmpty + } else { + stressSamples = stored + stressIsDerived = false + } + } + /// Assemble every card view-model from the current sample arrays + profile. Runs once per rebuild. private func buildCards() -> [MetricKind: VitalCardViewModel] { let physiology = UserPhysiologyProfile(profile) let calibration = CalibrationStore.shared.settings let inputs = VitalsCardFactory.Inputs( hr: hrSamples, spo2: spo2Samples, hrv: hrvSamples, - stress: stressSamples, fatigue: fatigueSamples, temperature: tempSamples, + stress: stressSamples, stressIsDerived: stressIsDerived, fatigue: fatigueSamples, temperature: tempSamples, systolic: systolicSamples, diastolic: diastolicSamples, glucose: bloodSugarSamples, spo2Estimated: spo2IsEstimated, summary: summary, range: .twentyFourHours, units: profile?.units ?? .metric diff --git a/PulseLoop/Views/MetricDetailView.swift b/PulseLoop/Views/MetricDetailView.swift index 1289b881..6b1eaf82 100644 --- a/PulseLoop/Views/MetricDetailView.swift +++ b/PulseLoop/Views/MetricDetailView.swift @@ -20,6 +20,8 @@ struct MetricDetailView: View { /// HRV's baseline on a Day: the 24 h ending at the window's end (a calendar day holds too few /// readings early on to establish one). @State private var hrvBaselineSamples: [MetricSample] = [] + /// The stress chart is derived from HRV: the ring has never reported stress (issue #67). + @State private var stressIsDerived = false @State private var primary: [MetricSample] = [] @State private var secondary: [MetricSample] = [] // diastolic, for BP /// The SpO₂ window holds hourly readings this ring computed from heart rate (`EstimatedSpO2`). @@ -285,7 +287,9 @@ struct MetricDetailView: View { if readingsExpanded { if readings.isEmpty { - Text("No readings in this period.") + Text(stressIsDerived + ? "Estimated stress isn't stored — it's worked out from your HRV readings, so there's nothing here to delete." + : "No readings in this period.") .font(PulseFont.footnote.weight(.regular)).foregroundStyle(PulseColors.textMuted) .padding(.horizontal, 16).padding(.bottom, 14) } else { @@ -427,6 +431,14 @@ struct MetricDetailView: View { primary = fetch(kind, window.start, window.end) spo2Estimated = false } + // Same gate as the Vitals card, so a filled card never opens onto an empty chart. + stressIsDerived = false + let derivesStress = metric == .stress && MetricsService.mayDeriveStress(context: modelContext) + if derivesStress, primary.isEmpty { + let derived = MetricsService.derivedStress(start: window.start, end: window.end, context: modelContext) + primary = MetricsService.displaySamples(derived, range: period.range) + stressIsDerived = !primary.isEmpty + } secondary = metric == .bloodPressure ? fetch(.bloodPressureDiastolic, window.start, window.end) : [] if metric == .hrv, period == .day { let end = min(Date(), window.end) @@ -434,7 +446,8 @@ struct MetricDetailView: View { } else { hrvBaselineSamples = primary } - earliestReading = MetricsRepository.oldestMeasurementTimestamp(kind: kind, context: modelContext) + // Derived stress pages as far back as the HRV it comes from. + earliestReading = MetricsRepository.oldestMeasurementTimestamp(kind: derivesStress ? .hrv : kind, context: modelContext) readings = VitalReadings.load(metric: metric, start: window.start, end: window.end, context: modelContext) } @@ -497,7 +510,7 @@ struct MetricDetailView: View { } private var isEstimatedMetric: Bool { - metric == .glucose || (metric == .spo2 && spo2Estimated) + metric == .glucose || (metric == .spo2 && spo2Estimated) || stressIsDerived } private var explainerText: String { @@ -537,6 +550,9 @@ struct MetricDetailView: View { + "FDA-authorized to measure or estimate glucose on its own." case .spo2: return EstimatedSpO2.disclaimer + case .stress where stressIsDerived: + return "Estimated from HRV — your ring doesn't measure stress. Scored against your own recent HRV " + + "readings, so it says how this reading compares with your normal, not how you compare with anyone else." case .bloodPressure: return "Ring blood pressure is an estimate. Calibrate against a validated cuff in Settings → Calibration, " + "and talk to a clinician about persistent high or low readings." diff --git a/PulseLoopTests/DerivedStressTests.swift b/PulseLoopTests/DerivedStressTests.swift new file mode 100644 index 00000000..d46f66ed --- /dev/null +++ b/PulseLoopTests/DerivedStressTests.swift @@ -0,0 +1,83 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Stress derived from HRV for rings that never report stress (issue #67): person-relative scoring, +/// labelled estimated, and never standing in for a ring's own stress. +@MainActor +final class DerivedStressTests: XCTestCase { + private let baseline: [Double] = [40, 42, 44, 46, 48, 50, 50, 52, 54, 56, 58, 60] // median 50, MAD 5 + + func testThinBaselineScoresNothing() { + XCTAssertNil(DerivedStress.score(hrv: 40, baseline: Array(baseline.prefix(11)))) + XCTAssertNil(DerivedStress.score(hrv: 0, baseline: baseline)) + } + + func testScoreIsRelativeToThePersonsOwnHRV() { + XCTAssertEqual(DerivedStress.score(hrv: 50, baseline: baseline), 50, "at their own median") + XCTAssertEqual(DerivedStress.score(hrv: 45, baseline: baseline), 75, "one spread below normal") + XCTAssertEqual(DerivedStress.score(hrv: 55, baseline: baseline), 25, "one spread above normal") + XCTAssertEqual(DerivedStress.score(hrv: 20, baseline: baseline), 100, "clamped") + XCTAssertEqual(DerivedStress.score(hrv: 90, baseline: baseline), 0, "clamped") + // Same reading, a person whose normal is far higher: reads as much more stressed. + XCTAssertEqual(DerivedStress.score(hrv: 50, baseline: baseline.map { $0 * 2 }), 100) + } + + func testFlatBaselineFallsBackToAProportionOfTheMedian() { + let flat = Array(repeating: 50.0, count: 12) + XCTAssertEqual(DerivedStress.score(hrv: 45, baseline: flat), 75) // spread = 5 (10% of median) + } + + func testScoredSeriesKeepsTheSourceIndex() { + let series = baseline + [45, 55] + let scored = DerivedStress.scored(series) + XCTAssertEqual(scored.map(\.index), [12, 13], "the first 12 readings have too thin a baseline") + XCTAssertEqual(scored.first?.score, 75) + } + + // MARK: - Where it's used + + private func seedHRVOnlyRing(_ context: ModelContext, now: Date = Date()) { + context.insert(Device(advertisedName: "R100", state: .connected, + capabilities: [.heartRate, .spo2, .steps, .battery, .hrv, .stress])) + for (i, value) in (baseline + [35]).enumerated() { + TestSupport.insertMeasurement(kind: .hrv, value: value, + timestamp: now.addingTimeInterval(Double(i - 12) * 1800), into: context) + } + try? context.save() + } + + func testVitalsCardDerivesAndLabelsStressForAnHRVOnlyRing() throws { + let context = try TestSupport.makeContext() + seedHRVOnlyRing(context) + let store = VitalsStore(modelContext: context) + XCTAssertTrue(store.stressIsDerived) + let card = try XCTUnwrap(store.cards[.stress]) + XCTAssertTrue(card.isEstimated, "a derived figure is never shown as a measurement") + XCTAssertFalse(card.isEmpty) + XCTAssertEqual(card.subtitleText, "Estimated from HRV") + } + + func testRingsOwnStressIsNeverOverridden() throws { + let context = try TestSupport.makeContext() + seedHRVOnlyRing(context) + // One stress reading, days old — the ring measures stress, it just didn't today. + TestSupport.insertMeasurement(kind: .stress, value: 30, timestamp: Date().addingTimeInterval(-5 * 86_400), into: context) + let store = VitalsStore(modelContext: context) + XCTAssertFalse(store.stressIsDerived) + XCTAssertFalse(store.cards[.stress]?.isEstimated ?? true) + XCTAssertFalse(MetricsService.mayDeriveStress(context: context)) + } + + func testDetailWindowDerivesFromThatWindowsHRV() throws { + let context = try TestSupport.makeContext() + let now = Date() + seedHRVOnlyRing(context, now: now) + let derived = MetricsService.derivedStress(start: now.addingTimeInterval(-86_400), end: now.addingTimeInterval(1), context: context) + XCTAssertEqual(derived.count, 1) + XCTAssertEqual(derived.first?.value, 100, "HRV of 35 against a median of 50") + XCTAssertEqual(derived.first?.timestamp.timeIntervalSince1970 ?? 0, now.timeIntervalSince1970, accuracy: 0.001, + "placed at the time of the HRV reading it came from") + XCTAssertTrue(MetricsService.derivedStress(start: now.addingTimeInterval(60), end: now.addingTimeInterval(120), context: context).isEmpty) + } +} From 85d7f0546bdf03554675284de0173ebdd1572784 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Tue, 10 Nov 2026 00:01:51 -0500 Subject: [PATCH 20/20] Move ActivityRecorderService out of PulseServices.swift and wrap long seed lines to stay under SwiftLint's error limits --- PulseLoop/Persistence/SeedData.swift | 6 +- .../Services/ActivityRecorderService.swift | 140 ++++++++++++++++++ PulseLoop/Services/PulseServices.swift | 138 +---------------- 3 files changed, 145 insertions(+), 139 deletions(-) create mode 100644 PulseLoop/Services/ActivityRecorderService.swift diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index 8d4697af..f259d1c6 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -106,7 +106,8 @@ enum SeedData { let secondRecordStart = i == 0 && blocks.count > 5 ? blocks[5].startAt : nil for block in blocks { let recordStart = secondRecordStart.map { block.startAt >= $0 ? $0 : startAt } ?? startAt - context.insert(SleepStageBlock(sessionId: session.id, startAt: block.startAt, startMinute: block.startMinute, durationMinutes: block.durationMinutes, stage: block.stage, recordStartAt: recordStart)) + context.insert(SleepStageBlock(sessionId: session.id, startAt: block.startAt, startMinute: block.startMinute, + durationMinutes: block.durationMinutes, stage: block.stage, recordStartAt: recordStart)) } // A few recent days also get daytime nap(s) — separate sessions sharing the same waking @@ -126,7 +127,8 @@ enum SeedData { let napSession = SleepSession(date: dayDate, startAt: napStart, endAt: napEnd, totalMinutes: nap.minutes, score: napScore.score, syncedAt: napEnd) context.insert(napSession) for block in napBlocks { - context.insert(SleepStageBlock(sessionId: napSession.id, startAt: block.startAt, startMinute: block.startMinute, durationMinutes: block.durationMinutes, stage: block.stage, recordStartAt: napStart)) + context.insert(SleepStageBlock(sessionId: napSession.id, startAt: block.startAt, startMinute: block.startMinute, + durationMinutes: block.durationMinutes, stage: block.stage, recordStartAt: napStart)) } } } diff --git a/PulseLoop/Services/ActivityRecorderService.swift b/PulseLoop/Services/ActivityRecorderService.swift new file mode 100644 index 00000000..6ff7b715 --- /dev/null +++ b/PulseLoop/Services/ActivityRecorderService.swift @@ -0,0 +1,140 @@ +import Foundation +import SwiftData + +@MainActor +enum ActivityRecorderService { + static func start(type: String, useGps: Bool, notes: String?, context: ModelContext) -> ActivitySession { + let session = ActivitySession(type: type, status: .recording, notes: notes, useGps: useGps) + context.insert(session) + context.insert(ActivityEvent(sessionId: session.id, kind: "created")) + context.insert(ActivityEvent(sessionId: session.id, kind: "started")) + try? context.save() + return session + } + + static func pause(_ session: ActivitySession, context: ModelContext) { + guard session.status == .recording else { return } + session.status = .paused + context.insert(ActivityEvent(sessionId: session.id, kind: "paused")) + context.insert(ActivityEvent(sessionId: session.id, kind: "gps_stopped")) + try? context.save() + } + + static func resume(_ session: ActivitySession, context: ModelContext) { + guard session.status == .paused else { return } + if let lastPause = ActivityRepository.events(sessionId: session.id, context: context).last(where: { $0.kind == "paused" }) { + session.totalPauseSeconds += max(0, Date().timeIntervalSince(lastPause.timestamp)) + } + session.status = .recording + context.insert(ActivityEvent(sessionId: session.id, kind: "resumed")) + context.insert(ActivityEvent(sessionId: session.id, kind: "gps_started")) + try? context.save() + } + + static func finish(_ session: ActivitySession, context: ModelContext) { + _ = ActivityService.finishSummary(for: session, context: context) + context.insert(ActivityEvent(sessionId: session.id, kind: "gps_stopped")) + context.insert(ActivityEvent(sessionId: session.id, kind: "finished")) + try? context.save() + PulseDataChange.shared.notify() + } + + static func cancel(_ session: ActivitySession, context: ModelContext) { + session.status = .cancelled + session.endedAt = Date() + session.updatedAt = Date() + context.insert(ActivityEvent(sessionId: session.id, kind: "gps_stopped")) + context.insert(ActivityEvent(sessionId: session.id, kind: "cancelled")) + try? context.save() + } + + /// Permanently deletes a workout and all of its child rows (samples, GPS points, events, sensor + /// poll events — all raw-UUID FKs, so no SwiftData cascade) and reverses its contribution to the + /// day's activity rollup so the totals stay honest. + @MainActor + static func delete(_ session: ActivitySession, context: ModelContext) { + ActivityService.reverseDailyRollup(for: session, context: context) + + // Capture before the row is gone; the day's calorie estimate recomputes without it below. + let affectedStart = session.startedAt + let affectedEnd = session.endedAt + + let id = session.id + ActivityRepository.samples(sessionId: id, context: context).forEach(context.delete) + ActivityRepository.gpsPoints(sessionId: id, context: context).forEach(context.delete) + ActivityRepository.events(sessionId: id, context: context).forEach(context.delete) + let polls = ((try? context.fetch(FetchDescriptor())) ?? []).filter { $0.sessionId == id } + polls.forEach(context.delete) + context.delete(session) + try? context.save() + DailyCalorieEstimator.recompute(day: affectedStart, context: context) + if let affectedEnd, !Calendar.current.isDate(affectedEnd, inSameDayAs: affectedStart) { + DailyCalorieEstimator.recompute(day: affectedEnd, context: context) + } + try? context.save() + HealthSyncService.shared.deleteExportedWorkout(sessionId: id) + PulseDataChange.shared.notify() + } + + static func recoverStaleSession(context: ModelContext) -> [ActivitySession] { + let cutoff = Calendar.current.date(byAdding: .hour, value: -6, to: Date()) ?? Date() + return ActivityRepository.sessions(context: context) + .filter { ($0.status == .recording || $0.status == .paused) && $0.startedAt < cutoff } + } + + /// How long after a session finishes that ring-log samples may still attach to it. Covers the + /// post-workout backfill sync (and a reconnect shortly after finishing). + static let backfillLinkWindowSeconds: TimeInterval = 15 * 60 + /// A coarse history sample this close to an existing live sample of the same kind is noise from + /// the ring's 5-min log overlapping the stream — skip it (half the 5-min log interval). + static let historyDedupeSeconds: TimeInterval = 150 + + @discardableResult + static func linkSample( + kind: MeasurementKind, + value: Double, + timestamp: Date, + measurementId: UUID?, + source: MeasurementSource, + confidence: DecodeConfidence, + context: ModelContext + ) -> ActivitySample? { + let sessions = ActivityRepository.sessions(context: context) + let active = sessions.first { session in + (session.status == .recording || session.status == .paused) && timestamp >= session.startedAt && timestamp <= Date() + } + // Ring-log backfill: history samples arrive *after* finish (post-workout sync / reconnect), + // so also match a just-finished session whose window contains the sample. + let target = active ?? sessions.first { session in + guard session.status == .finished, let ended = session.endedAt else { return false } + return Date().timeIntervalSince(ended) < backfillLinkWindowSeconds + && timestamp >= session.startedAt && timestamp <= ended + } + guard let target else { return nil } + let existing = ActivityRepository.samples(sessionId: target.id, context: context) + if let measurementId, existing.contains(where: { $0.measurementId == measurementId }) { + return nil + } + // Gap-fill rule: ring-log samples only fill stretches the live stream missed. + if source == .history, existing.contains(where: { + $0.kind == kind.rawValue + && $0.source != MeasurementSource.history.rawValue + && abs($0.timestamp.timeIntervalSince(timestamp)) <= historyDedupeSeconds + }) { + return nil + } + let sample = ActivitySample( + sessionId: target.id, + measurementId: measurementId, + kind: kind.rawValue, + value: value, + unit: kind == .heartRate ? "bpm" : "%", + timestamp: timestamp, + source: source.rawValue, + confidence: confidence + ) + context.insert(sample) + context.insert(ActivityEvent(sessionId: target.id, kind: "sensor_sample_linked", timestamp: timestamp)) + return sample + } +} diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 7a96aed6..89e67da5 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -1205,140 +1205,4 @@ enum ActivityService { // ManualActivityCreationError / ManualActivityService live in Services/ManualActivityService.swift. -@MainActor -enum ActivityRecorderService { - static func start(type: String, useGps: Bool, notes: String?, context: ModelContext) -> ActivitySession { - let session = ActivitySession(type: type, status: .recording, notes: notes, useGps: useGps) - context.insert(session) - context.insert(ActivityEvent(sessionId: session.id, kind: "created")) - context.insert(ActivityEvent(sessionId: session.id, kind: "started")) - try? context.save() - return session - } - - static func pause(_ session: ActivitySession, context: ModelContext) { - guard session.status == .recording else { return } - session.status = .paused - context.insert(ActivityEvent(sessionId: session.id, kind: "paused")) - context.insert(ActivityEvent(sessionId: session.id, kind: "gps_stopped")) - try? context.save() - } - - static func resume(_ session: ActivitySession, context: ModelContext) { - guard session.status == .paused else { return } - if let lastPause = ActivityRepository.events(sessionId: session.id, context: context).last(where: { $0.kind == "paused" }) { - session.totalPauseSeconds += max(0, Date().timeIntervalSince(lastPause.timestamp)) - } - session.status = .recording - context.insert(ActivityEvent(sessionId: session.id, kind: "resumed")) - context.insert(ActivityEvent(sessionId: session.id, kind: "gps_started")) - try? context.save() - } - - static func finish(_ session: ActivitySession, context: ModelContext) { - _ = ActivityService.finishSummary(for: session, context: context) - context.insert(ActivityEvent(sessionId: session.id, kind: "gps_stopped")) - context.insert(ActivityEvent(sessionId: session.id, kind: "finished")) - try? context.save() - PulseDataChange.shared.notify() - } - - static func cancel(_ session: ActivitySession, context: ModelContext) { - session.status = .cancelled - session.endedAt = Date() - session.updatedAt = Date() - context.insert(ActivityEvent(sessionId: session.id, kind: "gps_stopped")) - context.insert(ActivityEvent(sessionId: session.id, kind: "cancelled")) - try? context.save() - } - - /// Permanently deletes a workout and all of its child rows (samples, GPS points, events, sensor - /// poll events — all raw-UUID FKs, so no SwiftData cascade) and reverses its contribution to the - /// day's activity rollup so the totals stay honest. - @MainActor - static func delete(_ session: ActivitySession, context: ModelContext) { - ActivityService.reverseDailyRollup(for: session, context: context) - - // Capture before the row is gone; the day's calorie estimate recomputes without it below. - let affectedStart = session.startedAt - let affectedEnd = session.endedAt - - let id = session.id - ActivityRepository.samples(sessionId: id, context: context).forEach(context.delete) - ActivityRepository.gpsPoints(sessionId: id, context: context).forEach(context.delete) - ActivityRepository.events(sessionId: id, context: context).forEach(context.delete) - let polls = ((try? context.fetch(FetchDescriptor())) ?? []).filter { $0.sessionId == id } - polls.forEach(context.delete) - context.delete(session) - try? context.save() - DailyCalorieEstimator.recompute(day: affectedStart, context: context) - if let affectedEnd, !Calendar.current.isDate(affectedEnd, inSameDayAs: affectedStart) { - DailyCalorieEstimator.recompute(day: affectedEnd, context: context) - } - try? context.save() - HealthSyncService.shared.deleteExportedWorkout(sessionId: id) - PulseDataChange.shared.notify() - } - - static func recoverStaleSession(context: ModelContext) -> [ActivitySession] { - let cutoff = Calendar.current.date(byAdding: .hour, value: -6, to: Date()) ?? Date() - return ActivityRepository.sessions(context: context) - .filter { ($0.status == .recording || $0.status == .paused) && $0.startedAt < cutoff } - } - - /// How long after a session finishes that ring-log samples may still attach to it. Covers the - /// post-workout backfill sync (and a reconnect shortly after finishing). - static let backfillLinkWindowSeconds: TimeInterval = 15 * 60 - /// A coarse history sample this close to an existing live sample of the same kind is noise from - /// the ring's 5-min log overlapping the stream — skip it (half the 5-min log interval). - static let historyDedupeSeconds: TimeInterval = 150 - - @discardableResult - static func linkSample( - kind: MeasurementKind, - value: Double, - timestamp: Date, - measurementId: UUID?, - source: MeasurementSource, - confidence: DecodeConfidence, - context: ModelContext - ) -> ActivitySample? { - let sessions = ActivityRepository.sessions(context: context) - let active = sessions.first { session in - (session.status == .recording || session.status == .paused) && timestamp >= session.startedAt && timestamp <= Date() - } - // Ring-log backfill: history samples arrive *after* finish (post-workout sync / reconnect), - // so also match a just-finished session whose window contains the sample. - let target = active ?? sessions.first { session in - guard session.status == .finished, let ended = session.endedAt else { return false } - return Date().timeIntervalSince(ended) < backfillLinkWindowSeconds - && timestamp >= session.startedAt && timestamp <= ended - } - guard let target else { return nil } - let existing = ActivityRepository.samples(sessionId: target.id, context: context) - if let measurementId, existing.contains(where: { $0.measurementId == measurementId }) { - return nil - } - // Gap-fill rule: ring-log samples only fill stretches the live stream missed. - if source == .history, existing.contains(where: { - $0.kind == kind.rawValue - && $0.source != MeasurementSource.history.rawValue - && abs($0.timestamp.timeIntervalSince(timestamp)) <= historyDedupeSeconds - }) { - return nil - } - let sample = ActivitySample( - sessionId: target.id, - measurementId: measurementId, - kind: kind.rawValue, - value: value, - unit: kind == .heartRate ? "bpm" : "%", - timestamp: timestamp, - source: source.rawValue, - confidence: confidence - ) - context.insert(sample) - context.insert(ActivityEvent(sessionId: target.id, kind: "sensor_sample_linked", timestamp: timestamp)) - return sample - } -} +// ActivityRecorderService lives in Services/ActivityRecorderService.swift.