From 04854387065942eaae79a59485566978669feb3f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 21:34:12 +0000 Subject: [PATCH 1/9] Add duplicate sleep data cleanup feature Detects same-source, same-night HealthKit sleep samples that overlap in time -- the signature of a source (e.g. Oura) re-syncing a night it already wrote -- and surfaces a small warning button next to the affected source's row in the sleep history list. Tapping it opens a cleanup sheet with: - a timeline of the overlapping entries, split live into "keep" and "delete" lanes - a histogram of when the entries were added to HealthKit ("Date Added"), with a draggable divider defaulting to the largest gap between sync batches - a live preview of how many entries/hours would be kept vs. deleted - a submit button that deletes only the older, superseded batch "Date Added" isn't exposed by any public HKSample API, so it's read via HealthKit's internal creationTimestamp using Key-Value Coding, guarded by a small Objective-C safe-KVC trampoline (KVCSafeAccessor) so an undefined key degrades to nil instead of crashing. HealthKitManager gains deleteDuplicateSamples(_:), which requests write access to sleep analysis (previously only needed by DEBUG fake data helpers) and deletes by sample UUID. Co-authored-by: Greg --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 6 +- Bedtime/Bedtime/Bedtime-Bridging-Header.h | 6 + Bedtime/Bedtime/ContentView.swift | 6 +- .../Models/DuplicateSleepDetector.swift | 172 +++++++++++++++ Bedtime/Bedtime/Models/HealthKitManager.swift | 28 ++- Bedtime/Bedtime/Models/SleepData.swift | 6 + .../Utils/HealthKitCreationDateReader.swift | 29 +++ Bedtime/Bedtime/Utils/KVCSafeAccessor.h | 24 +++ Bedtime/Bedtime/Utils/KVCSafeAccessor.m | 18 ++ .../CreationTimeHistogramView.swift | 128 +++++++++++ .../Components/DuplicateCleanupSheet.swift | 201 ++++++++++++++++++ .../DuplicateOverlapTimelineView.swift | 77 +++++++ .../Views/Components/SleepDayGroup.swift | 12 +- .../SleepSourceComparisonView.swift | 24 ++- .../Views/RecentSleepSessionsCard.swift | 10 +- README.md | 13 +- 16 files changed, 749 insertions(+), 11 deletions(-) create mode 100644 Bedtime/Bedtime/Bedtime-Bridging-Header.h create mode 100644 Bedtime/Bedtime/Models/DuplicateSleepDetector.swift create mode 100644 Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift create mode 100644 Bedtime/Bedtime/Utils/KVCSafeAccessor.h create mode 100644 Bedtime/Bedtime/Utils/KVCSafeAccessor.m create mode 100644 Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift create mode 100644 Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift create mode 100644 Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index d873cad..32ddaf7 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -139,7 +139,7 @@ INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.burnsides.bedtime.background-task"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSHealthShareUsageDescription = "Use your sleep data to calculate your sleep bank and provide personalized bedtime recommendations."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Sync sleep data from Bedger to Apple Health"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Bedger needs permission to remove duplicate sleep entries you choose to delete (e.g. from a source re-syncing the same night twice)."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIBackgroundModes = processing; @@ -157,6 +157,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Bedtime/Bedtime-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -176,7 +177,7 @@ INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.burnsides.bedtime.background-task"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSHealthShareUsageDescription = "Use your sleep data to calculate your sleep bank and provide personalized bedtime recommendations."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Sync sleep data from Bedger to Apple Health"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Bedger needs permission to remove duplicate sleep entries you choose to delete (e.g. from a source re-syncing the same night twice)."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIBackgroundModes = processing; @@ -194,6 +195,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Bedtime/Bedtime-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Bedtime/Bedtime/Bedtime-Bridging-Header.h b/Bedtime/Bedtime/Bedtime-Bridging-Header.h new file mode 100644 index 0000000..f85d05c --- /dev/null +++ b/Bedtime/Bedtime/Bedtime-Bridging-Header.h @@ -0,0 +1,6 @@ +// +// Bedtime-Bridging-Header.h +// Bedtime +// + +#import "Utils/KVCSafeAccessor.h" diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index efaaf9c..9a584f0 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -179,7 +179,11 @@ struct ContentView: View { allSessions: healthKitManager.allSleepSessions, excludedSourceIDs: sourcePreferences.excludedBundleIdentifiers, sleepGoal: userPreferences.sleepGoalHours, - sleepBankDays: sleepBankDaysBinding + sleepBankDays: sleepBankDaysBinding, + duplicateSleepGroups: healthKitManager.duplicateSleepGroups, + onDeleteDuplicates: { samples in + try await healthKitManager.deleteDuplicateSamples(samples) + } ) } } diff --git a/Bedtime/Bedtime/Models/DuplicateSleepDetector.swift b/Bedtime/Bedtime/Models/DuplicateSleepDetector.swift new file mode 100644 index 0000000..d175fe4 --- /dev/null +++ b/Bedtime/Bedtime/Models/DuplicateSleepDetector.swift @@ -0,0 +1,172 @@ +// +// DuplicateSleepDetector.swift +// Bedtime +// +// Detects the "double sync" bug some sources (notably Oura) exhibit: syncing a night, then +// syncing again later re-writes the same stretch of samples a second time. The result is two +// overlapping sets of same-source samples for one night that differ only in when HealthKit +// received them ("Date Added" — see `HealthKitCreationDateReader`). +// + +import Foundation +import HealthKit + +/// A single HealthKit sleep sample retained with its stable identity and best-effort "date +/// added" timestamp, purely for duplicate detection & cleanup. `SleepSession` intentionally +/// drops both — nothing else in the app needs them. +struct DuplicateCandidateSample: Identifiable, Equatable { + let id: UUID + let startDate: Date + let endDate: Date + let sleepType: HKCategoryValueSleepAnalysis + /// `nil` when HealthKit's internal timestamp couldn't be read (see + /// `HealthKitCreationDateReader`). Samples with an unknown creation date are never + /// auto-selected for deletion. + let creationDate: Date? + + var duration: TimeInterval { endDate.timeIntervalSince(startDate) } +} + +extension DuplicateCandidateSample { + init?(sample: HKCategorySample) { + guard let sleepType = HKCategoryValueSleepAnalysis(rawValue: sample.value), + HKCategoryValueSleepAnalysis.allAsleepValues.contains(sleepType) else { return nil } + self.id = sample.uuid + self.startDate = sample.startDate + self.endDate = sample.endDate + self.sleepType = sleepType + self.creationDate = HealthKitCreationDateReader.creationDate(for: sample) + } +} + +/// One night's worth of same-source samples that overlap in time — the signature of a +/// duplicate re-sync — along with enough "date added" data to suggest where to split them. +struct DuplicateSleepGroup: Identifiable, Equatable { + let night: Date + let sourceBundleID: String + let sourceName: String + /// Sorted by `startDate`. + let samples: [DuplicateCandidateSample] + + var id: String { "\(night.timeIntervalSinceReferenceDate)-\(sourceBundleID)" } + + /// Distinct known creation timestamps, sorted ascending. + var distinctCreationDates: [Date] { + Array(Set(samples.compactMap(\.creationDate))).sorted() + } + + /// Whether "date added" data is rich enough to place a meaningful divider: at least two + /// distinct sync times, covering at least half of the samples in this group. + var hasUsableCreationData: Bool { + let knownCount = samples.filter { $0.creationDate != nil }.count + return distinctCreationDates.count >= 2 && knownCount * 2 >= samples.count + } + + /// True when at least two samples from this source overlap in time — back-to-back sleep + /// stages from a single sync never do, so this is specific to duplicate re-syncs. + var hasOverlap: Bool { + let sorted = samples.sorted { $0.startDate < $1.startDate } + guard sorted.count > 1 else { return false } + var runningEnd = sorted[0].endDate + for sample in sorted.dropFirst() { + if sample.startDate < runningEnd { return true } + runningEnd = max(runningEnd, sample.endDate) + } + return false + } + + /// Whether this group is both overlapping and has enough "date added" signal to offer a + /// cleanup UI for. + var isCleanable: Bool { hasOverlap && hasUsableCreationData } + + /// The midpoint of the largest gap between consecutive creation timestamps — i.e. the + /// boundary between two sync batches — used as the default divider position. + var suggestedCutoff: Date? { + guard hasUsableCreationData else { return nil } + let sorted = distinctCreationDates + guard sorted.count > 1 else { return nil } + + var bestGapIndex = 0 + var bestGap: TimeInterval = -1 + for index in 1.. bestGap { + bestGap = gap + bestGapIndex = index - 1 + } + } + return sorted[bestGapIndex].addingTimeInterval(bestGap / 2) + } + + /// The full span of known creation timestamps, for laying out the histogram/divider. + var creationDateRange: ClosedRange? { + let known = samples.compactMap(\.creationDate) + guard let lower = known.min(), let upper = known.max(), lower < upper else { return nil } + return lower...upper + } + + var timeRange: (start: Date, end: Date)? { + guard let start = samples.map(\.startDate).min(), + let end = samples.map(\.endDate).max() else { return nil } + return (start, end) + } +} + +/// How a `DuplicateSleepGroup` resolves at a given divider position. +struct DuplicateResolution { + let toKeep: [DuplicateCandidateSample] + let toDelete: [DuplicateCandidateSample] + + var deletedDuration: TimeInterval { toDelete.reduce(0) { $0 + $1.duration } } + var keptDuration: TimeInterval { toKeep.reduce(0) { $0 + $1.duration } } +} + +enum DuplicateSleepDetector { + /// Scans every fetched sample and returns one group per (night, source) pair that shows + /// signs of a duplicate re-sync and has enough "date added" data to clean up. + static func detectCleanableGroups(in samples: [HKCategorySample]) -> [DuplicateSleepGroup] { + struct Key: Hashable { + let night: Date + let bundleID: String + } + + var samplesByKey: [Key: [DuplicateCandidateSample]] = [:] + var sourceNamesByBundleID: [String: String] = [:] + + for sample in samples { + guard let candidate = DuplicateCandidateSample(sample: sample) else { continue } + let bundleID = sample.sourceRevision.source.bundleIdentifier + sourceNamesByBundleID[bundleID] = sample.sourceRevision.source.name + let night = SleepSession.dateForGrouping(startDate: candidate.startDate, duration: candidate.duration) + samplesByKey[Key(night: night, bundleID: bundleID), default: []].append(candidate) + } + + return samplesByKey.compactMap { key, candidates in + let group = DuplicateSleepGroup( + night: key.night, + sourceBundleID: key.bundleID, + sourceName: sourceNamesByBundleID[key.bundleID] ?? key.bundleID, + samples: candidates.sorted { $0.startDate < $1.startDate } + ) + return group.isCleanable ? group : nil + } + } + + /// Splits a group's samples by a divider time: samples added before `cutoff` are proposed + /// for deletion (the older, superseded sync), samples added at or after it are kept. + /// Samples with no known creation date are always kept — we never guess on those. + static func resolution(for group: DuplicateSleepGroup, cutoff: Date) -> DuplicateResolution { + var toKeep: [DuplicateCandidateSample] = [] + var toDelete: [DuplicateCandidateSample] = [] + + for sample in group.samples { + guard let creationDate = sample.creationDate, creationDate < cutoff else { + toKeep.append(sample) + continue + } + toDelete.append(sample) + } + + return DuplicateResolution(toKeep: toKeep, toDelete: toDelete) + } +} diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 1af4dc3..e025240 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -17,8 +17,9 @@ import Combine /// HealthKit intentionally does **not** report whether read access was granted — /// `requestAuthorization` succeeding only means the user chose /// whether or not to provide permission. We use this flag to avoid re-prompting, -/// not as proof of access. Write/share permission (for debug) is handled separately by -/// `requireWriteAuthorization(for:)`, which can re-prompt when needed. +/// not as proof of access. Write/share permission (needed for debug data generation and for +/// deleting duplicate entries) is handled separately by `requireWriteAuthorization(for:)`, +/// which can re-prompt when needed. enum PermissionsRequestState: Equatable { case loading case shouldRequest @@ -42,6 +43,9 @@ class HealthKitManager: ObservableObject { @Published var sleepSessions: [Date: [SleepSession]] = [:] /// All sessions regardless of source preferences — used for per-source comparison UI. @Published private(set) var allSleepSessions: [Date: [SleepSession]] = [:] + /// Same-source, same-night samples that overlap in time — the signature of a source + /// re-syncing data it already wrote (see `DuplicateSleepDetector`) — grouped for cleanup UI. + @Published private(set) var duplicateSleepGroups: [DuplicateSleepGroup] = [] @Published var errorMessage: String? @Published var availableSources: [HKSource]? @@ -261,9 +265,10 @@ class HealthKitManager: ObservableObject { sourcePreferences.isSourceSelected($0.source.source.bundleIdentifier) } self.sleepSessions = Dictionary(grouping: includedSessions) { $0.dateForGrouping } + + self.duplicateSleepGroups = DuplicateSleepDetector.detectCleanableGroups(in: samples) } - - #if DEBUG + /// Prompts for write access to `type` (plus read access to sleep analysis), /// then verifies share authorization succeeded. Unlike read access, HealthKit /// does report write/share status via `authorizationStatus(for:)`. @@ -307,6 +312,21 @@ class HealthKitManager: ObservableObject { } } + /// Deletes duplicate sleep samples surfaced by `duplicateSleepGroups` (see + /// `DuplicateSleepDetector`), then refreshes so the UI reflects the change. + /// + /// Requires write access to sleep analysis, which is only needed for this cleanup + /// feature — everywhere else the app is read-only — so authorization is requested here + /// rather than upfront. + func deleteDuplicateSamples(_ samples: [DuplicateCandidateSample]) async throws { + guard !samples.isEmpty else { return } + try await requireWriteAuthorization(for: HKCategoryType.sleepAnalysis) + let predicate = HKQuery.predicateForObjects(with: Set(samples.map(\.id))) + try await healthStore.deleteObjects(of: HKCategoryType.sleepAnalysis, predicate: predicate) + try await fetchSleepData() + } + + #if DEBUG /// Writes a batch of fake sleep nights into HealthKit and refreshes the /// in-memory cache so the UI updates immediately. Debug builds only. func generateFakeSleepData(nights: Int = 14, targetSleepHours: Double = 7.5) async throws { diff --git a/Bedtime/Bedtime/Models/SleepData.swift b/Bedtime/Bedtime/Models/SleepData.swift index e1688ee..4c45131 100644 --- a/Bedtime/Bedtime/Models/SleepData.swift +++ b/Bedtime/Bedtime/Models/SleepData.swift @@ -29,6 +29,12 @@ struct SleepSession { } var dateForGrouping: Date { + Self.dateForGrouping(startDate: startDate, duration: duration) + } + + /// Shared with duplicate-detection code (`DuplicateSleepDetector`) so both bucket samples + /// into "nights" using the exact same rule. + static func dateForGrouping(startDate: Date, duration: TimeInterval) -> Date { let midpoint = startDate.addingTimeInterval(duration / 2) let shiftedMidpoint = midpoint.addingTimeInterval(TimeInterval(6 * 60 * 60)) return Calendar.current.startOfDay(for: shiftedMidpoint) diff --git a/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift new file mode 100644 index 0000000..c7dbe26 --- /dev/null +++ b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift @@ -0,0 +1,29 @@ +// +// HealthKitCreationDateReader.swift +// Bedtime +// +// Reads a HealthKit sample's "Date Added" timestamp — the value shown in the iOS Health app's +// sample detail screen, but never exposed by any public `HKSample` API (`startDate`/`endDate` +// describe when the *event* happened, not when HealthKit received it). +// + +import Foundation +import HealthKit + +/// HealthKit stores "Date Added" internally as `creationTimestamp`, readable only via +/// undocumented Key-Value Coding. This is exactly what duplicate-sync detection needs: when a +/// source (e.g. Oura) re-syncs a night it already wrote, the resulting samples have identical +/// start/end times but a distinct, later creation timestamp — the only signal that tells the two +/// syncs apart. +/// +/// This is unsupported API: Apple could rename or remove the underlying property in a future OS +/// release. `KVCSafeAccessor` guards the read with an Objective-C `@try`/`@catch` so an +/// unexpected undefined-key exception degrades to `nil` (the cleanup feature simply becomes +/// unavailable for that sample) instead of crashing the app. +enum HealthKitCreationDateReader { + private static let key = "creationTimestamp" + + static func creationDate(for sample: HKSample) -> Date? { + KVCSafeAccessor.safeValue(key, forObject: sample) as? Date + } +} diff --git a/Bedtime/Bedtime/Utils/KVCSafeAccessor.h b/Bedtime/Bedtime/Utils/KVCSafeAccessor.h new file mode 100644 index 0000000..87aa72b --- /dev/null +++ b/Bedtime/Bedtime/Utils/KVCSafeAccessor.h @@ -0,0 +1,24 @@ +// +// KVCSafeAccessor.h +// Bedtime +// +// Guards reads of undocumented Key-Value Coding properties (e.g. HKSample's private +// "creationTimestamp") against crashing if the key is ever removed or renamed. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Swift's `NSObject.value(forKey:)` does not catch Objective-C exceptions, so an undefined +/// KVC key raises `NSUndefinedKeyException` and crashes the app. This trampoline exists purely +/// to make undocumented/private KVC reads safe to attempt speculatively. +@interface KVCSafeAccessor : NSObject + +/// Returns `[object valueForKey:key]`, or `nil` if that raises any exception (e.g. the key is +/// undefined) instead of crashing. ++ (nullable id)safeValue:(NSString *)key forObject:(id)object; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Bedtime/Bedtime/Utils/KVCSafeAccessor.m b/Bedtime/Bedtime/Utils/KVCSafeAccessor.m new file mode 100644 index 0000000..f213dad --- /dev/null +++ b/Bedtime/Bedtime/Utils/KVCSafeAccessor.m @@ -0,0 +1,18 @@ +// +// KVCSafeAccessor.m +// Bedtime +// + +#import "KVCSafeAccessor.h" + +@implementation KVCSafeAccessor + ++ (nullable id)safeValue:(NSString *)key forObject:(id)object { + @try { + return [object valueForKey:key]; + } @catch (NSException *exception) { + return nil; + } +} + +@end diff --git a/Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift b/Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift new file mode 100644 index 0000000..ca08626 --- /dev/null +++ b/Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift @@ -0,0 +1,128 @@ +// +// CreationTimeHistogramView.swift +// Bedtime +// +// A histogram of when a duplicate group's samples were added to HealthKit, with a +// draggable divider that chooses the cutoff between the older (deleted) and newer +// (kept) sync batch. +// + +import SwiftUI +import HealthKit + +struct CreationTimeHistogramView: View { + let samples: [DuplicateCandidateSample] + let range: ClosedRange + @Binding var cutoff: Date + + private let bucketCount = 28 + private let handleAreaHeight: CGFloat = 28 + + private var rangeDuration: TimeInterval { + max(range.upperBound.timeIntervalSince(range.lowerBound), 1) + } + + private var buckets: [Int] { + var counts = [Int](repeating: 0, count: bucketCount) + for sample in samples { + guard let creationDate = sample.creationDate else { continue } + let fraction = creationDate.timeIntervalSince(range.lowerBound) / rangeDuration + let index = min(bucketCount - 1, max(0, Int(fraction * Double(bucketCount)))) + counts[index] += 1 + } + return counts + } + + private var maxCount: Int { max(buckets.max() ?? 1, 1) } + + private func fraction(for date: Date) -> CGFloat { + CGFloat(min(max(date.timeIntervalSince(range.lowerBound) / rangeDuration, 0), 1)) + } + + var body: some View { + GeometryReader { proxy in + let width = proxy.size.width + let totalHeight = proxy.size.height + let barAreaHeight = max(totalHeight - handleAreaHeight, 1) + let x = fraction(for: cutoff) * width + + ZStack(alignment: .topLeading) { + HStack(alignment: .bottom, spacing: 2) { + ForEach(Array(buckets.enumerated()), id: \.offset) { _, count in + RoundedRectangle(cornerRadius: 2) + .fill(Color.accentColor.opacity(0.45)) + .frame(maxWidth: .infinity) + .frame(height: count > 0 ? max(barAreaHeight * CGFloat(count) / CGFloat(maxCount), 3) : 0) + } + } + .frame(width: width, height: barAreaHeight, alignment: .bottom) + .offset(y: handleAreaHeight) + + Rectangle() + .fill(Color.orange) + .frame(width: 2, height: totalHeight) + .position(x: x, y: totalHeight / 2) + .allowsHitTesting(false) + + dividerHandle + .position(x: x, y: handleAreaHeight / 2) + .allowsHitTesting(false) + + Color.clear + .frame(width: width, height: totalHeight) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let newFraction = min(max(value.location.x / width, 0), 1) + cutoff = range.lowerBound.addingTimeInterval(rangeDuration * Double(newFraction)) + } + ) + } + } + } + + private var dividerHandle: some View { + ZStack { + Circle() + .fill(Color.orange) + Image(systemName: "arrow.left.and.right") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + } + .frame(width: 22, height: 22) + .shadow(color: .black.opacity(0.15), radius: 2, y: 1) + } +} + +#Preview(traits: .sizeThatFitsLayout) { + struct PreviewHost: View { + let range: ClosedRange + let samples: [DuplicateCandidateSample] + @State private var cutoff: Date + + init() { + let now = Date() + let olderBatch = now.addingTimeInterval(-3600) + let newerBatch = now + range = olderBatch.addingTimeInterval(-600)...newerBatch.addingTimeInterval(600) + samples = (0..<8).map { index in + DuplicateCandidateSample( + id: UUID(), + startDate: now, + endDate: now.addingTimeInterval(1800), + sleepType: .asleepCore, + creationDate: index < 4 ? olderBatch.addingTimeInterval(Double(index) * 30) : newerBatch.addingTimeInterval(Double(index) * 30) + ) + } + _cutoff = State(initialValue: olderBatch.addingTimeInterval(1800)) + } + + var body: some View { + CreationTimeHistogramView(samples: samples, range: range, cutoff: $cutoff) + .frame(height: 120) + .padding() + } + } + return PreviewHost() +} diff --git a/Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift b/Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift new file mode 100644 index 0000000..7a8eb6e --- /dev/null +++ b/Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift @@ -0,0 +1,201 @@ +// +// DuplicateCleanupSheet.swift +// Bedtime +// +// Lets the user resolve a detected duplicate sync: shows the overlapping entries on a +// timeline, a histogram of when they were added to HealthKit with a draggable divider +// suggesting where the old sync ends and the new one begins, and deletes the older batch +// on confirmation. +// + +import SwiftUI +import HealthKit + +struct DuplicateCleanupSheet: View { + let group: DuplicateSleepGroup + /// Deletes the given samples from HealthKit. Thrown errors are shown inline; the sheet + /// dismisses itself on success. + let onDelete: ([DuplicateCandidateSample]) async throws -> Void + + @Environment(\.dismiss) private var dismiss + @Environment(\.durationDisplayStyle) private var durationStyle + @State private var cutoff: Date + @State private var isDeleting = false + @State private var errorMessage: String? + + init(group: DuplicateSleepGroup, onDelete: @escaping ([DuplicateCandidateSample]) async throws -> Void) { + self.group = group + self.onDelete = onDelete + _cutoff = State(initialValue: group.suggestedCutoff ?? group.distinctCreationDates.last ?? Date()) + } + + private var resolution: DuplicateResolution { + DuplicateSleepDetector.resolution(for: group, cutoff: cutoff) + } + + private var creationRange: ClosedRange { + guard let range = group.creationDateRange else { + let now = Date() + return now...now.addingTimeInterval(1) + } + // Pad a little so the divider isn't glued to the histogram's edges. + let padding = max(range.upperBound.timeIntervalSince(range.lowerBound) * 0.08, 60) + return range.lowerBound.addingTimeInterval(-padding)...range.upperBound.addingTimeInterval(padding) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEE, MMM d" + return formatter + }() + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "h:mm a" + return formatter + }() + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Bedger found \(group.samples.count) overlapping \(group.sourceName) entries for the night of \(Self.dateFormatter.string(from: group.night)) — likely from a duplicate sync. Choose which sync to keep.") + .font(.subheadline) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 8) { + Text("Overlapping entries") + .font(.headline) + + DuplicateOverlapTimelineView(group: group, cutoff: cutoff) + } + + VStack(alignment: .leading, spacing: 8) { + Text("When entries were added") + .font(.headline) + Text("Drag the divider to choose the cutoff. Entries added before it are deleted; the rest are kept.") + .font(.caption) + .foregroundStyle(.secondary) + + CreationTimeHistogramView(samples: group.samples, range: creationRange, cutoff: $cutoff) + .frame(height: 120) + + Text("Cutoff: \(Self.dateFormatter.string(from: cutoff)) at \(Self.timeFormatter.string(from: cutoff))") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + } + + summary + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + submitButton + } + .padding() + } + .navigationTitle("Clean Up Duplicates") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isDeleting) + } + } + } + .interactiveDismissDisabled(isDeleting) + } + + private var summary: some View { + VStack(alignment: .leading, spacing: 10) { + summaryRow( + color: .red, + text: "\(resolution.toDelete.count) entries will be deleted", + duration: resolution.deletedDuration + ) + summaryRow( + color: .green, + text: "\(resolution.toKeep.count) entries will be kept", + duration: resolution.keptDuration + ) + } + .padding() + .background(Color.cardBackground, in: RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.primary.opacity(0.08), lineWidth: 0.5) + ) + } + + private func summaryRow(color: Color, text: String, duration: TimeInterval) -> some View { + HStack { + Circle().fill(color).frame(width: 8, height: 8) + Text(text) + .font(.subheadline) + Spacer() + Text(TimeFormatter.formatDuration(duration, style: durationStyle)) + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + + private var submitButton: some View { + Button(role: .destructive) { + delete() + } label: { + Group { + if isDeleting { + ProgressView() + } else { + Text("Delete \(resolution.toDelete.count) Duplicate \(resolution.toDelete.count == 1 ? "Entry" : "Entries")") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(resolution.toDelete.isEmpty || isDeleting) + } + + private func delete() { + isDeleting = true + errorMessage = nil + let samplesToDelete = resolution.toDelete + Task { + do { + try await onDelete(samplesToDelete) + isDeleting = false + dismiss() + } catch { + isDeleting = false + errorMessage = "Couldn't delete duplicates: \(error.localizedDescription)" + } + } + } +} + +#Preview { + let now = Date() + let olderBatch = now.addingTimeInterval(-3600) + let newerBatch = now.addingTimeInterval(-120) + let group = DuplicateSleepGroup( + night: Calendar.current.startOfDay(for: now), + sourceBundleID: "com.ouraring.oura", + sourceName: "Oura", + samples: [ + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: olderBatch), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: olderBatch.addingTimeInterval(30)), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-21600), endDate: now.addingTimeInterval(-18000), sleepType: .asleepREM, creationDate: olderBatch.addingTimeInterval(60)), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: newerBatch), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: newerBatch.addingTimeInterval(30)), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-21600), endDate: now.addingTimeInterval(-18000), sleepType: .asleepREM, creationDate: newerBatch.addingTimeInterval(60)), + ] + ) + DuplicateCleanupSheet(group: group, onDelete: { _ in }) +} diff --git a/Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift b/Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift new file mode 100644 index 0000000..d5354d8 --- /dev/null +++ b/Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift @@ -0,0 +1,77 @@ +// +// DuplicateOverlapTimelineView.swift +// Bedtime +// +// Two aligned timeline lanes showing exactly which of a duplicate group's samples would be +// kept vs. deleted at the current divider position, so dragging the divider gives an +// immediate preview of the result. +// + +import SwiftUI +import HealthKit + +struct DuplicateOverlapTimelineView: View { + let group: DuplicateSleepGroup + let cutoff: Date + + private var resolution: DuplicateResolution { + DuplicateSleepDetector.resolution(for: group, cutoff: cutoff) + } + + private var timeRange: (start: Date, end: Date) { + group.timeRange ?? (Date(), Date().addingTimeInterval(1)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + lane(title: "Keep", samples: resolution.toKeep, color: .green) + lane(title: "Delete", samples: resolution.toDelete, color: .red) + } + } + + private func lane(title: String, samples: [DuplicateCandidateSample], color: Color) -> some View { + HStack(spacing: 8) { + Text(title) + .font(.caption2.weight(.medium)) + .foregroundStyle(color) + .frame(width: 44, alignment: .leading) + + Capsule() + .fill(.foreground.quaternary) + .overlay(alignment: .leading) { + GeometryReader { proxy in + let rangeDuration = max(timeRange.end.timeIntervalSince(timeRange.start), 1) + ForEach(samples) { sample in + let offset = sample.startDate.timeIntervalSince(timeRange.start) / rangeDuration + let width = sample.duration / rangeDuration + Rectangle() + .fill(color) + .frame(width: max(proxy.size.width * CGFloat(width), 1)) + .offset(x: proxy.size.width * CGFloat(offset)) + } + } + } + .clipShape(Capsule()) + .frame(height: 18) + } + } +} + +#Preview(traits: .sizeThatFitsLayout) { + let now = Date() + let olderBatch = now.addingTimeInterval(-3600) + let newerBatch = now.addingTimeInterval(-3550) + let group = DuplicateSleepGroup( + night: Calendar.current.startOfDay(for: now), + sourceBundleID: "com.ouraring.oura", + sourceName: "Oura", + samples: [ + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: olderBatch), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: olderBatch), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: newerBatch), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: newerBatch), + ] + ) + DuplicateOverlapTimelineView(group: group, cutoff: newerBatch.addingTimeInterval(-30)) + .padding() +} diff --git a/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift b/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift index ab99370..c008800 100644 --- a/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift +++ b/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift @@ -16,8 +16,13 @@ struct SleepDayGroup: View { let sleepGoal: Double /// Whether this night counts toward the sleep-bank lookback window. var isIncludedInSleepBank: Bool = true + /// Cleanable duplicate-sync groups detected for this night. + var duplicateGroups: [DuplicateSleepGroup] = [] + /// Deletes the given samples from HealthKit (see `HealthKitManager.deleteDuplicateSamples`). + var onDeleteDuplicates: ([DuplicateCandidateSample]) async throws -> Void = { _ in } let onToggle: () -> Void @Environment(\.durationDisplayStyle) private var durationStyle + @State private var selectedDuplicateGroup: DuplicateSleepGroup? private var dateFormatter: DateFormatter { let formatter = DateFormatter() @@ -111,7 +116,9 @@ struct SleepDayGroup: View { SleepSourceComparisonView( sessions: allSessions, - excludedSourceIDs: excludedSourceIDs + excludedSourceIDs: excludedSourceIDs, + duplicateGroups: duplicateGroups, + onReviewDuplicates: { selectedDuplicateGroup = $0 } ) .padding(.leading, 4) @@ -133,5 +140,8 @@ struct SleepDayGroup: View { } .opacity(isIncludedInSleepBank ? 1 : 0.45) .accessibilityHint(isIncludedInSleepBank ? "Included in sleep balance" : "Not included in sleep balance") + .sheet(item: $selectedDuplicateGroup) { group in + DuplicateCleanupSheet(group: group, onDelete: onDeleteDuplicates) + } } } diff --git a/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift b/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift index 1e2c379..8291e86 100644 --- a/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift +++ b/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift @@ -10,6 +10,10 @@ import HealthKit struct SleepSourceComparisonView: View { let sessions: [SleepSession] let excludedSourceIDs: Set + /// Cleanable duplicate-sync groups for this night, keyed by source in `sourceTracks`. + var duplicateGroups: [DuplicateSleepGroup] = [] + /// Called when the little "review duplicates" button next to a source's row is tapped. + var onReviewDuplicates: (DuplicateSleepGroup) -> Void = { _ in } @Environment(\.durationDisplayStyle) private var durationStyle private struct SourceTrack: Identifiable { @@ -47,9 +51,14 @@ struct SleepSourceComparisonView: View { private var shouldShow: Bool { !sourceTracks.isEmpty && ( sourceTracks.count > 1 || - sourceTracks.contains { !$0.isEnabled } + sourceTracks.contains { !$0.isEnabled } || + !duplicateGroups.isEmpty ) } + + private func duplicateGroup(for bundleID: String) -> DuplicateSleepGroup? { + duplicateGroups.first { $0.sourceBundleID == bundleID } + } var body: some View { if let timeRange, shouldShow { @@ -61,6 +70,19 @@ struct SleepSourceComparisonView: View { .foregroundStyle(track.isEnabled ? .secondary : .tertiary) .lineLimit(1) .frame(width: 72, alignment: .leading) + + if let duplicateGroup = duplicateGroup(for: track.id) { + Button { + onReviewDuplicates(duplicateGroup) + } label: { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + .buttonStyle(.plain) + .accessibilityLabel("Possible duplicate \(track.name) data") + .accessibilityHint("Review and remove duplicate entries") + } SleepStageTimelineBar( sessions: track.sessions, diff --git a/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift b/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift index b1d29cd..93e73fa 100644 --- a/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift +++ b/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift @@ -15,11 +15,15 @@ struct RecentSleepSessionsCard: View { excludedSourceIDs: Set, sleepGoal: Double, sleepBankDays: Binding, - dayCount: Int = Constants.sleepHistoryDays + dayCount: Int = Constants.sleepHistoryDays, + duplicateSleepGroups: [DuplicateSleepGroup] = [], + onDeleteDuplicates: @escaping ([DuplicateCandidateSample]) async throws -> Void = { _ in } ) { self.sleepGoal = sleepGoal self.excludedSourceIDs = excludedSourceIDs self._sleepBankDays = sleepBankDays + self.duplicateSleepGroups = duplicateSleepGroups + self.onDeleteDuplicates = onDeleteDuplicates let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) @@ -33,6 +37,8 @@ struct RecentSleepSessionsCard: View { let sortedSessions: [(Date, [SleepSession], [SleepSession])] let sleepGoal: Double let excludedSourceIDs: Set + let duplicateSleepGroups: [DuplicateSleepGroup] + let onDeleteDuplicates: ([DuplicateCandidateSample]) async throws -> Void @Binding var sleepBankDays: Int @State private var expandedNights: Set = [] @@ -77,6 +83,8 @@ struct RecentSleepSessionsCard: View { isExpanded: expandedNights.contains(night), sleepGoal: sleepGoal, isIncludedInSleepBank: isIncluded, + duplicateGroups: duplicateSleepGroups.filter { $0.night == night }, + onDeleteDuplicates: onDeleteDuplicates, onToggle: { withAnimation(.easeInOut(duration: 0.2)) { if expandedNights.contains(night) { diff --git a/README.md b/README.md index 4e54444..4ef14a4 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,16 @@ A native iOS app that helps optimize your sleep by tracking your "sleep bank" an - Track sleep duration over time - Visual representation of your sleep patterns +### 🧹 Duplicate Data Cleanup +- Detects when a source (e.g. Oura) has re-synced a night it already wrote, leaving two + overlapping sets of samples for the same stretch of sleep +- A small warning button appears next to the affected source's row; tapping it opens a + cleanup sheet +- The sheet shows the overlapping entries on a timeline and a histogram of when they were + added to HealthKit, with a divider you can drag to preview exactly which entries would be + kept vs. deleted +- Confirming deletes only the older, superseded sync from HealthKit + ### ⚙️ Customizable Settings - Set your personal sleep goal (6-12 hours, in 15 minute steps) - Configure your preferred wake time @@ -63,7 +73,8 @@ Sleep sessions are assigned to a calendar day using a "midpoint + 6 hours" rule - All data stays on your device - No data is sent to external servers -- HealthKit data is only read, never written to +- HealthKit data is read-only, with one exception: the duplicate cleanup feature deletes + samples you explicitly choose to remove. Bedger never writes new sleep data ## Architecture From 8d668bc96a80bb7cd8ccda4c12f388b899827472 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 21:41:37 +0000 Subject: [PATCH 2/9] Fix Swift KVCSafeAccessor call site + pin Swift name explicitly Clang's importer maps '+safeValue:forObject:' to 'safeValue(_:for:)' in Swift (dropping the redundant 'Object' since the parameter type is generic id), which broke the call site. Update it to match, and add an explicit NS_SWIFT_NAME so the imported signature can't drift again. Co-authored-by: Greg --- Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift | 2 +- Bedtime/Bedtime/Utils/KVCSafeAccessor.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift index c7dbe26..04996fc 100644 --- a/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift +++ b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift @@ -24,6 +24,6 @@ enum HealthKitCreationDateReader { private static let key = "creationTimestamp" static func creationDate(for sample: HKSample) -> Date? { - KVCSafeAccessor.safeValue(key, forObject: sample) as? Date + KVCSafeAccessor.safeValue(key, for: sample) as? Date } } diff --git a/Bedtime/Bedtime/Utils/KVCSafeAccessor.h b/Bedtime/Bedtime/Utils/KVCSafeAccessor.h index 87aa72b..2afc9e7 100644 --- a/Bedtime/Bedtime/Utils/KVCSafeAccessor.h +++ b/Bedtime/Bedtime/Utils/KVCSafeAccessor.h @@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN /// Returns `[object valueForKey:key]`, or `nil` if that raises any exception (e.g. the key is /// undefined) instead of crashing. -+ (nullable id)safeValue:(NSString *)key forObject:(id)object; ++ (nullable id)safeValue:(NSString *)key forObject:(id)object NS_SWIFT_NAME(safeValue(_:for:)); @end From 8aa1c4e5c7cf75a11d0519578c9aae0d00631b21 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 21:52:35 +0000 Subject: [PATCH 3/9] Decouple duplicate-cleanup warning from SleepSourceComparisonView SleepSourceComparisonView.shouldShow only renders when there's more than one source (or an excluded one) to compare -- a different concern from duplicate detection. A night with just a single (duplicated) source could fail to render the comparison view at all, hiding the cleanup button with it. Move the warning into its own DuplicateCleanupRow, rendered unconditionally in SleepDayGroup whenever duplicate groups exist for that night, independent of how many sources are present. Co-authored-by: Greg --- .../Components/DuplicateCleanupRow.swift | 68 +++++++++++++++++++ .../Views/Components/SleepDayGroup.swift | 10 ++- .../SleepSourceComparisonView.swift | 24 +------ 3 files changed, 76 insertions(+), 26 deletions(-) create mode 100644 Bedtime/Bedtime/Views/Components/DuplicateCleanupRow.swift diff --git a/Bedtime/Bedtime/Views/Components/DuplicateCleanupRow.swift b/Bedtime/Bedtime/Views/Components/DuplicateCleanupRow.swift new file mode 100644 index 0000000..8b920f8 --- /dev/null +++ b/Bedtime/Bedtime/Views/Components/DuplicateCleanupRow.swift @@ -0,0 +1,68 @@ +// +// DuplicateCleanupRow.swift +// Bedtime +// +// A small, always-visible warning row for each detected duplicate-sync group on a night. +// Deliberately independent of `SleepSourceComparisonView` (which only renders when there's +// more than one source to compare) so it still shows up on nights with just a single source +// — exactly the case a duplicate re-sync usually produces. +// + +import SwiftUI + +struct DuplicateCleanupRow: View { + let groups: [DuplicateSleepGroup] + let onReview: (DuplicateSleepGroup) -> Void + + var body: some View { + if !groups.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(groups) { group in + Button { + onReview(group) + } label: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.orange) + + Text("Possible duplicate \(group.sourceName) data") + .font(.caption2) + .foregroundStyle(.orange) + .lineLimit(1) + + Spacer(minLength: 4) + + Text("Review") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.orange) + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .accessibilityLabel("Possible duplicate \(group.sourceName) data") + .accessibilityHint("Review and remove duplicate entries") + } + } + .padding(.bottom, 6) + } + } +} + +#Preview(traits: .sizeThatFitsLayout) { + let now = Date() + DuplicateCleanupRow( + groups: [ + DuplicateSleepGroup( + night: Calendar.current.startOfDay(for: now), + sourceBundleID: "com.ouraring.oura", + sourceName: "Oura", + samples: [] + ) + ], + onReview: { _ in } + ) + .padding() +} diff --git a/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift b/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift index c008800..0f883ca 100644 --- a/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift +++ b/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift @@ -114,11 +114,15 @@ struct SleepDayGroup: View { .buttonStyle(PlainButtonStyle()) .disabled(!hasSessions) + DuplicateCleanupRow( + groups: duplicateGroups, + onReview: { selectedDuplicateGroup = $0 } + ) + .padding(.leading, 4) + SleepSourceComparisonView( sessions: allSessions, - excludedSourceIDs: excludedSourceIDs, - duplicateGroups: duplicateGroups, - onReviewDuplicates: { selectedDuplicateGroup = $0 } + excludedSourceIDs: excludedSourceIDs ) .padding(.leading, 4) diff --git a/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift b/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift index 8291e86..1e2c379 100644 --- a/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift +++ b/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift @@ -10,10 +10,6 @@ import HealthKit struct SleepSourceComparisonView: View { let sessions: [SleepSession] let excludedSourceIDs: Set - /// Cleanable duplicate-sync groups for this night, keyed by source in `sourceTracks`. - var duplicateGroups: [DuplicateSleepGroup] = [] - /// Called when the little "review duplicates" button next to a source's row is tapped. - var onReviewDuplicates: (DuplicateSleepGroup) -> Void = { _ in } @Environment(\.durationDisplayStyle) private var durationStyle private struct SourceTrack: Identifiable { @@ -51,14 +47,9 @@ struct SleepSourceComparisonView: View { private var shouldShow: Bool { !sourceTracks.isEmpty && ( sourceTracks.count > 1 || - sourceTracks.contains { !$0.isEnabled } || - !duplicateGroups.isEmpty + sourceTracks.contains { !$0.isEnabled } ) } - - private func duplicateGroup(for bundleID: String) -> DuplicateSleepGroup? { - duplicateGroups.first { $0.sourceBundleID == bundleID } - } var body: some View { if let timeRange, shouldShow { @@ -70,19 +61,6 @@ struct SleepSourceComparisonView: View { .foregroundStyle(track.isEnabled ? .secondary : .tertiary) .lineLimit(1) .frame(width: 72, alignment: .leading) - - if let duplicateGroup = duplicateGroup(for: track.id) { - Button { - onReviewDuplicates(duplicateGroup) - } label: { - Image(systemName: "exclamationmark.triangle.fill") - .font(.caption2) - .foregroundStyle(.orange) - } - .buttonStyle(.plain) - .accessibilityLabel("Possible duplicate \(track.name) data") - .accessibilityHint("Review and remove duplicate entries") - } SleepStageTimelineBar( sessions: track.sessions, From bcc8617baefd6335cf8382e3859683a82e51fdd6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 00:48:33 +0000 Subject: [PATCH 4/9] Fix creationTimestamp cast: it's a boxed NSNumber, not NSDate A debugger dump of HKObject's ivars confirms _creationTimestamp is a raw double stored right alongside _startTimestamp/_endTimestamp, not an NSDate -- so 'as? Date' silently failed for every sample, making hasUsableCreationData always false and hiding every duplicate group regardless of overlap or source count (explains the reported case where an obviously-duplicated Oura night showed no warning at all: 12h15m of stage data crammed into a 10h12m bed-to-wake window). Cast to NSNumber instead, and convert the raw interval using Foundation's reference-date epoch (confirmed by the dump: the creation timestamp's magnitude matches _startTimestamp's), with a plausibility check against 'now' and a Unix-epoch fallback in case a future OS version changes the convention. Co-authored-by: Greg --- .../Utils/HealthKitCreationDateReader.swift | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift index 04996fc..8d083a3 100644 --- a/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift +++ b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift @@ -24,6 +24,31 @@ enum HealthKitCreationDateReader { private static let key = "creationTimestamp" static func creationDate(for sample: HKSample) -> Date? { - KVCSafeAccessor.safeValue(key, for: sample) as? Date + guard let raw = KVCSafeAccessor.safeValue(key, for: sample) else { return nil } + + // On every OS version observed so far this KVC read returns a boxed `NSNumber`, not + // an `NSDate` — internally it's a raw `_creationTimestamp` double, sitting right + // alongside `_startTimestamp`/`_endTimestamp` on `HKObject`. Still, handle a future + // `NSDate`-boxed representation too, since nothing here is documented. + if let date = raw as? Date { + return date + } + guard let interval = (raw as? NSNumber)?.doubleValue else { return nil } + + // That raw double sits in the same units as `_startTimestamp`/`_endTimestamp` — i.e. + // seconds since Foundation's reference date (2001-01-01), not the Unix epoch. Confirm + // via a plausibility check (should land near "now") rather than assuming, in case a + // future OS version switches conventions. + let now = Date() + let plausibleWindow = now.addingTimeInterval(-86400 * 400)...now.addingTimeInterval(86400) + let referenceDateCandidate = Date(timeIntervalSinceReferenceDate: interval) + if plausibleWindow.contains(referenceDateCandidate) { + return referenceDateCandidate + } + let unixEpochCandidate = Date(timeIntervalSince1970: interval) + if plausibleWindow.contains(unixEpochCandidate) { + return unixEpochCandidate + } + return nil } } From fb4c673ff300a5c82eff012fb0d93f72f2320491 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 01:32:17 +0000 Subject: [PATCH 5/9] Request write access up front, not from inside the nested cleanup sheet requestAuthorization() (called once, at app launch) now requests both read and share/write access to sleep analysis in a single prompt, instead of only read. HKHealthStore.requestAuthorization can silently fail to present its system sheet when triggered from a context nested inside an already-presented modal -- exactly the situation the delete button in DuplicateCleanupSheet was in (a sheet presented from SleepDayGroup inside the main scroll view), since requireWriteAuthorization(for:) was calling it lazily at delete time. That's the most likely reason tapping Delete appeared to do nothing: the completion for the write prompt may simply never have fired. Asking for write access up front means the authorization decision already exists (granted or denied) by the time the user reaches the nested sheet, so requireWriteAuthorization(for:)'s later call resolves instantly with no UI to present -- avoiding the nested-modal presentation entirely for the common case. Co-authored-by: Greg --- Bedtime/Bedtime/Models/HealthKitManager.swift | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index e025240..f914ade 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -17,9 +17,9 @@ import Combine /// HealthKit intentionally does **not** report whether read access was granted — /// `requestAuthorization` succeeding only means the user chose /// whether or not to provide permission. We use this flag to avoid re-prompting, -/// not as proof of access. Write/share permission (needed for debug data generation and for -/// deleting duplicate entries) is handled separately by `requireWriteAuthorization(for:)`, -/// which can re-prompt when needed. +/// not as proof of access. `requestAuthorization()` also requests write/share access up +/// front (see its doc comment for why); `requireWriteAuthorization(for:)` re-checks that +/// decision — and can re-prompt if it somehow never happened — right before a write. enum PermissionsRequestState: Equatable { case loading case shouldRequest @@ -80,9 +80,18 @@ class HealthKitManager: ObservableObject { } } - /// Presents the HealthKit authorization sheet for read access if we haven't - /// already. No-op on subsequent calls — see `PermissionsRequestState` for - /// why we can't verify if read access was actually granted. + /// Presents the HealthKit authorization sheet for sleep analysis if we haven't already, + /// requesting both read access (used everywhere) and write/share access (used only by the + /// duplicate-cleanup delete flow) in one prompt. No-op on subsequent calls — see + /// `PermissionsRequestState` for why we can't verify if read access was actually granted. + /// + /// Write access is bundled in here — rather than requested lazily by + /// `requireWriteAuthorization(for:)` at delete time — deliberately: `requestAuthorization` + /// can silently fail to present its system sheet when called from a context that's already + /// nested inside another presented sheet (as the delete button is, inside + /// `DuplicateCleanupSheet`). Asking here, from the top level, means by the time the user + /// reaches that nested sheet the authorization decision already exists, so + /// `requireWriteAuthorization(for:)` resolves instantly with no UI to present. func requestAuthorization() async throws { guard permissionsRequestState != .hasRequested else { return } @@ -90,7 +99,7 @@ class HealthKitManager: ObservableObject { do { try await healthStore.requestAuthorization( - toShare: [], + toShare: [HKCategoryType.sleepAnalysis], read: [HKCategoryType.sleepAnalysis] ) permissionsRequestState = .hasRequested From 957db65738fc535e3b3adcccb4ceb78f94a97d05 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 02:12:44 +0000 Subject: [PATCH 6/9] Handle HealthKit's real delete restriction: apps can't remove another source's samples The delete button appeared to work (no error, sheet dismissed) but never actually removed anything for real duplicates. The cause isn't authorization -- it's a hard, unbypassable HealthKit platform rule: HKHealthStore.deleteObjects(of:predicate:) only deletes objects the calling app itself wrote. Since duplicate samples always come from the other source (e.g. Oura, never Bedger), the delete call matched zero of Bedger's own objects and silently 'succeeded' with nothing deleted -- exactly the reported symptom. - DuplicateCandidateSample now carries sourceBundleID/sourceName so ownership can be checked per-sample. - HealthKitManager.deleteDuplicateSamples checks ownership up front and throws a new ForeignSourceDeletionError with a clear, actionable message instead of attempting a delete that would quietly match nothing. - DuplicateCleanupSheet now detects this case (group.sourceBundleID != Bundle.main.bundleIdentifier, true for every real-world duplicate) and swaps the Delete button for a manual-deletion notice plus an 'Open Health App' shortcut, while still using the same timeline/histogram to show exactly which entries to remove. Direct deletion only remains possible for the rare case where Bedger itself wrote the duplicate samples (e.g. debug data). - Updated README to describe this limitation accurately. Co-authored-by: Greg --- .../Models/DuplicateSleepDetector.swift | 34 +++++++++- Bedtime/Bedtime/Models/HealthKitManager.swift | 15 ++++- .../CreationTimeHistogramView.swift | 4 +- .../Components/DuplicateCleanupSheet.swift | 66 ++++++++++++++++--- .../DuplicateOverlapTimelineView.swift | 8 +-- README.md | 12 +++- 6 files changed, 116 insertions(+), 23 deletions(-) diff --git a/Bedtime/Bedtime/Models/DuplicateSleepDetector.swift b/Bedtime/Bedtime/Models/DuplicateSleepDetector.swift index d175fe4..7f599a0 100644 --- a/Bedtime/Bedtime/Models/DuplicateSleepDetector.swift +++ b/Bedtime/Bedtime/Models/DuplicateSleepDetector.swift @@ -23,6 +23,14 @@ struct DuplicateCandidateSample: Identifiable, Equatable { /// `HealthKitCreationDateReader`). Samples with an unknown creation date are never /// auto-selected for deletion. let creationDate: Date? + /// The app/device that wrote this sample. HealthKit only allows an app to delete objects + /// *it* saved (`HKHealthStore.deleteObjects` is explicitly scoped to "objects saved by this + /// application" — no amount of write authorization lets an app delete another source's + /// data; only the Health app itself, or the original writer, can). Carried per-sample + /// (denormalized from the group) so `HealthKitManager.deleteDuplicateSamples` can check + /// this before attempting a delete that would otherwise silently match nothing. + let sourceBundleID: String + let sourceName: String var duration: TimeInterval { endDate.timeIntervalSince(startDate) } } @@ -36,6 +44,8 @@ extension DuplicateCandidateSample { self.endDate = sample.endDate self.sleepType = sleepType self.creationDate = HealthKitCreationDateReader.creationDate(for: sample) + self.sourceBundleID = sample.sourceRevision.source.bundleIdentifier + self.sourceName = sample.sourceRevision.source.name } } @@ -121,6 +131,25 @@ struct DuplicateResolution { var keptDuration: TimeInterval { toKeep.reduce(0) { $0 + $1.duration } } } +/// Thrown by `HealthKitManager.deleteDuplicateSamples` when asked to delete samples that +/// weren't written by this app — HealthKit's delete APIs are unconditionally scoped to "objects +/// saved by this application" (see `HKHealthStore.deleteObjects(of:predicate:)`), so no amount +/// of write authorization lets Bedger remove another source's (e.g. Oura's) samples. Attempting +/// it anyway wouldn't throw — it would just silently match nothing, look like it worked, and +/// leave the duplicates in place. Only the Health app itself (or the original writer) can +/// delete that data, so this case needs a distinct, actionable error rather than a generic one. +struct ForeignSourceDeletionError: LocalizedError { + let sourceName: String + + var errorDescription: String? { + "Bedger can't delete \(sourceName)'s entries directly — Apple only allows an app to remove data it wrote itself." + } + + var recoverySuggestion: String? { + "Open the Health app, go to Browse → Sleep, select this night, tap \"Show All Data,\" and delete the older \(sourceName) entries there." + } +} + enum DuplicateSleepDetector { /// Scans every fetched sample and returns one group per (night, source) pair that shows /// signs of a duplicate re-sync and has enough "date added" data to clean up. @@ -135,10 +164,9 @@ enum DuplicateSleepDetector { for sample in samples { guard let candidate = DuplicateCandidateSample(sample: sample) else { continue } - let bundleID = sample.sourceRevision.source.bundleIdentifier - sourceNamesByBundleID[bundleID] = sample.sourceRevision.source.name + sourceNamesByBundleID[candidate.sourceBundleID] = candidate.sourceName let night = SleepSession.dateForGrouping(startDate: candidate.startDate, duration: candidate.duration) - samplesByKey[Key(night: night, bundleID: bundleID), default: []].append(candidate) + samplesByKey[Key(night: night, bundleID: candidate.sourceBundleID), default: []].append(candidate) } return samplesByKey.compactMap { key, candidates in diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index f914ade..0cf69b3 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -324,11 +324,20 @@ class HealthKitManager: ObservableObject { /// Deletes duplicate sleep samples surfaced by `duplicateSleepGroups` (see /// `DuplicateSleepDetector`), then refreshes so the UI reflects the change. /// - /// Requires write access to sleep analysis, which is only needed for this cleanup - /// feature — everywhere else the app is read-only — so authorization is requested here - /// rather than upfront. + /// Only ever actually deletes anything when every sample was written by this app — see + /// `ForeignSourceDeletionError`. In practice that means this will almost always throw for + /// real duplicate groups (which come from Oura, not Bedger); it exists mainly so a real + /// self-written duplicate (e.g. from the debug data generator) can still be cleaned up, and + /// so the failure mode for everything else is a clear, honest error instead of a delete call + /// that silently matches zero objects and looks like it worked. func deleteDuplicateSamples(_ samples: [DuplicateCandidateSample]) async throws { guard !samples.isEmpty else { return } + + let ownBundleID = Bundle.main.bundleIdentifier + if let foreign = samples.first(where: { $0.sourceBundleID != ownBundleID }) { + throw ForeignSourceDeletionError(sourceName: foreign.sourceName) + } + try await requireWriteAuthorization(for: HKCategoryType.sleepAnalysis) let predicate = HKQuery.predicateForObjects(with: Set(samples.map(\.id))) try await healthStore.deleteObjects(of: HKCategoryType.sleepAnalysis, predicate: predicate) diff --git a/Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift b/Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift index ca08626..6768a32 100644 --- a/Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift +++ b/Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift @@ -112,7 +112,9 @@ struct CreationTimeHistogramView: View { startDate: now, endDate: now.addingTimeInterval(1800), sleepType: .asleepCore, - creationDate: index < 4 ? olderBatch.addingTimeInterval(Double(index) * 30) : newerBatch.addingTimeInterval(Double(index) * 30) + creationDate: index < 4 ? olderBatch.addingTimeInterval(Double(index) * 30) : newerBatch.addingTimeInterval(Double(index) * 30), + sourceBundleID: "com.ouraring.oura", + sourceName: "Oura" ) } _cutoff = State(initialValue: olderBatch.addingTimeInterval(1800)) diff --git a/Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift b/Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift index 7a8eb6e..ce89221 100644 --- a/Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift +++ b/Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift @@ -4,12 +4,15 @@ // // Lets the user resolve a detected duplicate sync: shows the overlapping entries on a // timeline, a histogram of when they were added to HealthKit with a draggable divider -// suggesting where the old sync ends and the new one begins, and deletes the older batch -// on confirmation. +// suggesting where the old sync ends and the new one begins, and either deletes the older +// batch directly (only possible when Bedger itself wrote the samples) or, for real +// duplicates from another source, tells the user exactly what to remove and hands off to +// the Health app to finish it — see `canDeleteDirectly`. // import SwiftUI import HealthKit +import UIKit struct DuplicateCleanupSheet: View { let group: DuplicateSleepGroup @@ -29,6 +32,15 @@ struct DuplicateCleanupSheet: View { _cutoff = State(initialValue: group.suggestedCutoff ?? group.distinctCreationDates.last ?? Date()) } + /// HealthKit only lets an app delete objects it wrote itself (see + /// `ForeignSourceDeletionError`), so a real Delete button only makes sense when Bedger + /// itself is the source of these samples — which, for actual duplicate-sync bugs like + /// Oura's, it never is. This drives whether we show the delete button or manual + /// instructions for the Health app instead. + private var canDeleteDirectly: Bool { + group.sourceBundleID == Bundle.main.bundleIdentifier + } + private var resolution: DuplicateResolution { DuplicateSleepDetector.resolution(for: group, cutoff: cutoff) } @@ -64,6 +76,10 @@ struct DuplicateCleanupSheet: View { .font(.subheadline) .foregroundStyle(.secondary) + if !canDeleteDirectly { + manualDeletionNotice + } + VStack(alignment: .leading, spacing: 8) { Text("Overlapping entries") .font(.headline) @@ -95,7 +111,11 @@ struct DuplicateCleanupSheet: View { .foregroundStyle(.red) } - submitButton + if canDeleteDirectly { + submitButton + } else { + openHealthAppButton + } } .padding() } @@ -145,6 +165,34 @@ struct DuplicateCleanupSheet: View { } } + private var manualDeletionNotice: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "info.circle.fill") + .foregroundStyle(.blue) + Text("Bedger can't delete this directly") + .font(.subheadline.weight(.semibold)) + } + Text("Apple only lets an app delete HealthKit entries it wrote itself, so Bedger can't remove \(group.sourceName)'s entries — only the Health app can. Use the divider below to see exactly which entries to remove, then delete them from Health → Browse → Sleep → this night → \"Show All Data.\"") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background(Color.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) + } + + private var openHealthAppButton: some View { + Button { + if let url = URL(string: "x-apple-health://"), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } label: { + Text("Open Health App") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + private var submitButton: some View { Button(role: .destructive) { delete() @@ -189,12 +237,12 @@ struct DuplicateCleanupSheet: View { sourceBundleID: "com.ouraring.oura", sourceName: "Oura", samples: [ - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: olderBatch), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: olderBatch.addingTimeInterval(30)), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-21600), endDate: now.addingTimeInterval(-18000), sleepType: .asleepREM, creationDate: olderBatch.addingTimeInterval(60)), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: newerBatch), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: newerBatch.addingTimeInterval(30)), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-21600), endDate: now.addingTimeInterval(-18000), sleepType: .asleepREM, creationDate: newerBatch.addingTimeInterval(60)), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: olderBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: olderBatch.addingTimeInterval(30), sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-21600), endDate: now.addingTimeInterval(-18000), sleepType: .asleepREM, creationDate: olderBatch.addingTimeInterval(60), sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: newerBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: newerBatch.addingTimeInterval(30), sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-21600), endDate: now.addingTimeInterval(-18000), sleepType: .asleepREM, creationDate: newerBatch.addingTimeInterval(60), sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), ] ) DuplicateCleanupSheet(group: group, onDelete: { _ in }) diff --git a/Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift b/Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift index d5354d8..c068cd3 100644 --- a/Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift +++ b/Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift @@ -66,10 +66,10 @@ struct DuplicateOverlapTimelineView: View { sourceBundleID: "com.ouraring.oura", sourceName: "Oura", samples: [ - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: olderBatch), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: olderBatch), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: newerBatch), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: newerBatch), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: olderBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: olderBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: newerBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), + DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: newerBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), ] ) DuplicateOverlapTimelineView(group: group, cutoff: newerBatch.addingTimeInterval(-30)) diff --git a/README.md b/README.md index 4ef14a4..3ac9b50 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,11 @@ A native iOS app that helps optimize your sleep by tracking your "sleep bank" an - The sheet shows the overlapping entries on a timeline and a histogram of when they were added to HealthKit, with a divider you can drag to preview exactly which entries would be kept vs. deleted -- Confirming deletes only the older, superseded sync from HealthKit +- Apple only lets an app delete HealthKit samples it wrote itself, so for real duplicates + (which always come from the other source, e.g. Oura) Bedger can't delete them directly — + the sheet instead points you at exactly what to remove and offers a shortcut into the + Health app to finish it there. Bedger only deletes directly in the rare case where it's + the source of the duplicate samples itself ### ⚙️ Customizable Settings - Set your personal sleep goal (6-12 hours, in 15 minute steps) @@ -73,8 +77,10 @@ Sleep sessions are assigned to a calendar day using a "midpoint + 6 hours" rule - All data stays on your device - No data is sent to external servers -- HealthKit data is read-only, with one exception: the duplicate cleanup feature deletes - samples you explicitly choose to remove. Bedger never writes new sleep data +- HealthKit data is effectively read-only: the duplicate cleanup feature can request write + access, but HealthKit only lets an app delete samples it wrote itself, so it can't + actually delete another source's data (e.g. Oura's) — only guide you to remove it via the + Health app. Bedger never writes new sleep data outside of debug builds ## Architecture From 8c298f58cf5f1f5491ffada6075db503b7f0abf6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 02:58:16 +0000 Subject: [PATCH 7/9] Revert duplicate-cleanup delete feature HealthKit fundamentally can't let a third-party app delete samples another app wrote (see the previous commit's writeup), so the whole 'detect + review + delete' UI can never actually work for the real case (Oura-written duplicates) -- only for the near-impossible case where Bedger wrote the duplicate samples itself. Rather than ship a feature that can only offer 'go delete it yourself in the Health app,' revert it entirely: DuplicateSleepDetector, the cleanup sheet/ histogram/timeline/row views, the KVC-based creationTimestamp reader and its Obj-C bridging support, and the write-authorization/delete plumbing in HealthKitManager. Kept SleepSession.dateForGrouping's static extraction -- a harmless, reusable refactor that upcoming duplicate-filtering work (deduplicating overlapping samples in the app's own calculations, rather than trying to delete them from HealthKit) will also want. Next: improve in-app filtering so overlapping same-source duplicate samples don't skew sleep duration/stats, instead of trying to delete them. Co-authored-by: Greg --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 6 +- Bedtime/Bedtime/Bedtime-Bridging-Header.h | 6 - Bedtime/Bedtime/ContentView.swift | 6 +- .../Models/DuplicateSleepDetector.swift | 200 -------------- Bedtime/Bedtime/Models/HealthKitManager.swift | 54 +--- Bedtime/Bedtime/Models/SleepData.swift | 4 +- .../Utils/HealthKitCreationDateReader.swift | 54 ---- Bedtime/Bedtime/Utils/KVCSafeAccessor.h | 24 -- Bedtime/Bedtime/Utils/KVCSafeAccessor.m | 18 -- .../CreationTimeHistogramView.swift | 130 --------- .../Components/DuplicateCleanupRow.swift | 68 ----- .../Components/DuplicateCleanupSheet.swift | 249 ------------------ .../DuplicateOverlapTimelineView.swift | 77 ------ .../Views/Components/SleepDayGroup.swift | 14 - .../Views/RecentSleepSessionsCard.swift | 10 +- README.md | 19 +- 16 files changed, 15 insertions(+), 924 deletions(-) delete mode 100644 Bedtime/Bedtime/Bedtime-Bridging-Header.h delete mode 100644 Bedtime/Bedtime/Models/DuplicateSleepDetector.swift delete mode 100644 Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift delete mode 100644 Bedtime/Bedtime/Utils/KVCSafeAccessor.h delete mode 100644 Bedtime/Bedtime/Utils/KVCSafeAccessor.m delete mode 100644 Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift delete mode 100644 Bedtime/Bedtime/Views/Components/DuplicateCleanupRow.swift delete mode 100644 Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift delete mode 100644 Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 32ddaf7..d873cad 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -139,7 +139,7 @@ INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.burnsides.bedtime.background-task"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSHealthShareUsageDescription = "Use your sleep data to calculate your sleep bank and provide personalized bedtime recommendations."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Bedger needs permission to remove duplicate sleep entries you choose to delete (e.g. from a source re-syncing the same night twice)."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Sync sleep data from Bedger to Apple Health"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIBackgroundModes = processing; @@ -157,7 +157,6 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Bedtime/Bedtime-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -177,7 +176,7 @@ INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.burnsides.bedtime.background-task"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSHealthShareUsageDescription = "Use your sleep data to calculate your sleep bank and provide personalized bedtime recommendations."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Bedger needs permission to remove duplicate sleep entries you choose to delete (e.g. from a source re-syncing the same night twice)."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Sync sleep data from Bedger to Apple Health"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIBackgroundModes = processing; @@ -195,7 +194,6 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Bedtime/Bedtime-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Bedtime/Bedtime/Bedtime-Bridging-Header.h b/Bedtime/Bedtime/Bedtime-Bridging-Header.h deleted file mode 100644 index f85d05c..0000000 --- a/Bedtime/Bedtime/Bedtime-Bridging-Header.h +++ /dev/null @@ -1,6 +0,0 @@ -// -// Bedtime-Bridging-Header.h -// Bedtime -// - -#import "Utils/KVCSafeAccessor.h" diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 9a584f0..efaaf9c 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -179,11 +179,7 @@ struct ContentView: View { allSessions: healthKitManager.allSleepSessions, excludedSourceIDs: sourcePreferences.excludedBundleIdentifiers, sleepGoal: userPreferences.sleepGoalHours, - sleepBankDays: sleepBankDaysBinding, - duplicateSleepGroups: healthKitManager.duplicateSleepGroups, - onDeleteDuplicates: { samples in - try await healthKitManager.deleteDuplicateSamples(samples) - } + sleepBankDays: sleepBankDaysBinding ) } } diff --git a/Bedtime/Bedtime/Models/DuplicateSleepDetector.swift b/Bedtime/Bedtime/Models/DuplicateSleepDetector.swift deleted file mode 100644 index 7f599a0..0000000 --- a/Bedtime/Bedtime/Models/DuplicateSleepDetector.swift +++ /dev/null @@ -1,200 +0,0 @@ -// -// DuplicateSleepDetector.swift -// Bedtime -// -// Detects the "double sync" bug some sources (notably Oura) exhibit: syncing a night, then -// syncing again later re-writes the same stretch of samples a second time. The result is two -// overlapping sets of same-source samples for one night that differ only in when HealthKit -// received them ("Date Added" — see `HealthKitCreationDateReader`). -// - -import Foundation -import HealthKit - -/// A single HealthKit sleep sample retained with its stable identity and best-effort "date -/// added" timestamp, purely for duplicate detection & cleanup. `SleepSession` intentionally -/// drops both — nothing else in the app needs them. -struct DuplicateCandidateSample: Identifiable, Equatable { - let id: UUID - let startDate: Date - let endDate: Date - let sleepType: HKCategoryValueSleepAnalysis - /// `nil` when HealthKit's internal timestamp couldn't be read (see - /// `HealthKitCreationDateReader`). Samples with an unknown creation date are never - /// auto-selected for deletion. - let creationDate: Date? - /// The app/device that wrote this sample. HealthKit only allows an app to delete objects - /// *it* saved (`HKHealthStore.deleteObjects` is explicitly scoped to "objects saved by this - /// application" — no amount of write authorization lets an app delete another source's - /// data; only the Health app itself, or the original writer, can). Carried per-sample - /// (denormalized from the group) so `HealthKitManager.deleteDuplicateSamples` can check - /// this before attempting a delete that would otherwise silently match nothing. - let sourceBundleID: String - let sourceName: String - - var duration: TimeInterval { endDate.timeIntervalSince(startDate) } -} - -extension DuplicateCandidateSample { - init?(sample: HKCategorySample) { - guard let sleepType = HKCategoryValueSleepAnalysis(rawValue: sample.value), - HKCategoryValueSleepAnalysis.allAsleepValues.contains(sleepType) else { return nil } - self.id = sample.uuid - self.startDate = sample.startDate - self.endDate = sample.endDate - self.sleepType = sleepType - self.creationDate = HealthKitCreationDateReader.creationDate(for: sample) - self.sourceBundleID = sample.sourceRevision.source.bundleIdentifier - self.sourceName = sample.sourceRevision.source.name - } -} - -/// One night's worth of same-source samples that overlap in time — the signature of a -/// duplicate re-sync — along with enough "date added" data to suggest where to split them. -struct DuplicateSleepGroup: Identifiable, Equatable { - let night: Date - let sourceBundleID: String - let sourceName: String - /// Sorted by `startDate`. - let samples: [DuplicateCandidateSample] - - var id: String { "\(night.timeIntervalSinceReferenceDate)-\(sourceBundleID)" } - - /// Distinct known creation timestamps, sorted ascending. - var distinctCreationDates: [Date] { - Array(Set(samples.compactMap(\.creationDate))).sorted() - } - - /// Whether "date added" data is rich enough to place a meaningful divider: at least two - /// distinct sync times, covering at least half of the samples in this group. - var hasUsableCreationData: Bool { - let knownCount = samples.filter { $0.creationDate != nil }.count - return distinctCreationDates.count >= 2 && knownCount * 2 >= samples.count - } - - /// True when at least two samples from this source overlap in time — back-to-back sleep - /// stages from a single sync never do, so this is specific to duplicate re-syncs. - var hasOverlap: Bool { - let sorted = samples.sorted { $0.startDate < $1.startDate } - guard sorted.count > 1 else { return false } - var runningEnd = sorted[0].endDate - for sample in sorted.dropFirst() { - if sample.startDate < runningEnd { return true } - runningEnd = max(runningEnd, sample.endDate) - } - return false - } - - /// Whether this group is both overlapping and has enough "date added" signal to offer a - /// cleanup UI for. - var isCleanable: Bool { hasOverlap && hasUsableCreationData } - - /// The midpoint of the largest gap between consecutive creation timestamps — i.e. the - /// boundary between two sync batches — used as the default divider position. - var suggestedCutoff: Date? { - guard hasUsableCreationData else { return nil } - let sorted = distinctCreationDates - guard sorted.count > 1 else { return nil } - - var bestGapIndex = 0 - var bestGap: TimeInterval = -1 - for index in 1.. bestGap { - bestGap = gap - bestGapIndex = index - 1 - } - } - return sorted[bestGapIndex].addingTimeInterval(bestGap / 2) - } - - /// The full span of known creation timestamps, for laying out the histogram/divider. - var creationDateRange: ClosedRange? { - let known = samples.compactMap(\.creationDate) - guard let lower = known.min(), let upper = known.max(), lower < upper else { return nil } - return lower...upper - } - - var timeRange: (start: Date, end: Date)? { - guard let start = samples.map(\.startDate).min(), - let end = samples.map(\.endDate).max() else { return nil } - return (start, end) - } -} - -/// How a `DuplicateSleepGroup` resolves at a given divider position. -struct DuplicateResolution { - let toKeep: [DuplicateCandidateSample] - let toDelete: [DuplicateCandidateSample] - - var deletedDuration: TimeInterval { toDelete.reduce(0) { $0 + $1.duration } } - var keptDuration: TimeInterval { toKeep.reduce(0) { $0 + $1.duration } } -} - -/// Thrown by `HealthKitManager.deleteDuplicateSamples` when asked to delete samples that -/// weren't written by this app — HealthKit's delete APIs are unconditionally scoped to "objects -/// saved by this application" (see `HKHealthStore.deleteObjects(of:predicate:)`), so no amount -/// of write authorization lets Bedger remove another source's (e.g. Oura's) samples. Attempting -/// it anyway wouldn't throw — it would just silently match nothing, look like it worked, and -/// leave the duplicates in place. Only the Health app itself (or the original writer) can -/// delete that data, so this case needs a distinct, actionable error rather than a generic one. -struct ForeignSourceDeletionError: LocalizedError { - let sourceName: String - - var errorDescription: String? { - "Bedger can't delete \(sourceName)'s entries directly — Apple only allows an app to remove data it wrote itself." - } - - var recoverySuggestion: String? { - "Open the Health app, go to Browse → Sleep, select this night, tap \"Show All Data,\" and delete the older \(sourceName) entries there." - } -} - -enum DuplicateSleepDetector { - /// Scans every fetched sample and returns one group per (night, source) pair that shows - /// signs of a duplicate re-sync and has enough "date added" data to clean up. - static func detectCleanableGroups(in samples: [HKCategorySample]) -> [DuplicateSleepGroup] { - struct Key: Hashable { - let night: Date - let bundleID: String - } - - var samplesByKey: [Key: [DuplicateCandidateSample]] = [:] - var sourceNamesByBundleID: [String: String] = [:] - - for sample in samples { - guard let candidate = DuplicateCandidateSample(sample: sample) else { continue } - sourceNamesByBundleID[candidate.sourceBundleID] = candidate.sourceName - let night = SleepSession.dateForGrouping(startDate: candidate.startDate, duration: candidate.duration) - samplesByKey[Key(night: night, bundleID: candidate.sourceBundleID), default: []].append(candidate) - } - - return samplesByKey.compactMap { key, candidates in - let group = DuplicateSleepGroup( - night: key.night, - sourceBundleID: key.bundleID, - sourceName: sourceNamesByBundleID[key.bundleID] ?? key.bundleID, - samples: candidates.sorted { $0.startDate < $1.startDate } - ) - return group.isCleanable ? group : nil - } - } - - /// Splits a group's samples by a divider time: samples added before `cutoff` are proposed - /// for deletion (the older, superseded sync), samples added at or after it are kept. - /// Samples with no known creation date are always kept — we never guess on those. - static func resolution(for group: DuplicateSleepGroup, cutoff: Date) -> DuplicateResolution { - var toKeep: [DuplicateCandidateSample] = [] - var toDelete: [DuplicateCandidateSample] = [] - - for sample in group.samples { - guard let creationDate = sample.creationDate, creationDate < cutoff else { - toKeep.append(sample) - continue - } - toDelete.append(sample) - } - - return DuplicateResolution(toKeep: toKeep, toDelete: toDelete) - } -} diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 0cf69b3..1af4dc3 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -17,9 +17,8 @@ import Combine /// HealthKit intentionally does **not** report whether read access was granted — /// `requestAuthorization` succeeding only means the user chose /// whether or not to provide permission. We use this flag to avoid re-prompting, -/// not as proof of access. `requestAuthorization()` also requests write/share access up -/// front (see its doc comment for why); `requireWriteAuthorization(for:)` re-checks that -/// decision — and can re-prompt if it somehow never happened — right before a write. +/// not as proof of access. Write/share permission (for debug) is handled separately by +/// `requireWriteAuthorization(for:)`, which can re-prompt when needed. enum PermissionsRequestState: Equatable { case loading case shouldRequest @@ -43,9 +42,6 @@ class HealthKitManager: ObservableObject { @Published var sleepSessions: [Date: [SleepSession]] = [:] /// All sessions regardless of source preferences — used for per-source comparison UI. @Published private(set) var allSleepSessions: [Date: [SleepSession]] = [:] - /// Same-source, same-night samples that overlap in time — the signature of a source - /// re-syncing data it already wrote (see `DuplicateSleepDetector`) — grouped for cleanup UI. - @Published private(set) var duplicateSleepGroups: [DuplicateSleepGroup] = [] @Published var errorMessage: String? @Published var availableSources: [HKSource]? @@ -80,18 +76,9 @@ class HealthKitManager: ObservableObject { } } - /// Presents the HealthKit authorization sheet for sleep analysis if we haven't already, - /// requesting both read access (used everywhere) and write/share access (used only by the - /// duplicate-cleanup delete flow) in one prompt. No-op on subsequent calls — see - /// `PermissionsRequestState` for why we can't verify if read access was actually granted. - /// - /// Write access is bundled in here — rather than requested lazily by - /// `requireWriteAuthorization(for:)` at delete time — deliberately: `requestAuthorization` - /// can silently fail to present its system sheet when called from a context that's already - /// nested inside another presented sheet (as the delete button is, inside - /// `DuplicateCleanupSheet`). Asking here, from the top level, means by the time the user - /// reaches that nested sheet the authorization decision already exists, so - /// `requireWriteAuthorization(for:)` resolves instantly with no UI to present. + /// Presents the HealthKit authorization sheet for read access if we haven't + /// already. No-op on subsequent calls — see `PermissionsRequestState` for + /// why we can't verify if read access was actually granted. func requestAuthorization() async throws { guard permissionsRequestState != .hasRequested else { return } @@ -99,7 +86,7 @@ class HealthKitManager: ObservableObject { do { try await healthStore.requestAuthorization( - toShare: [HKCategoryType.sleepAnalysis], + toShare: [], read: [HKCategoryType.sleepAnalysis] ) permissionsRequestState = .hasRequested @@ -274,10 +261,9 @@ class HealthKitManager: ObservableObject { sourcePreferences.isSourceSelected($0.source.source.bundleIdentifier) } self.sleepSessions = Dictionary(grouping: includedSessions) { $0.dateForGrouping } - - self.duplicateSleepGroups = DuplicateSleepDetector.detectCleanableGroups(in: samples) } - + + #if DEBUG /// Prompts for write access to `type` (plus read access to sleep analysis), /// then verifies share authorization succeeded. Unlike read access, HealthKit /// does report write/share status via `authorizationStatus(for:)`. @@ -321,30 +307,6 @@ class HealthKitManager: ObservableObject { } } - /// Deletes duplicate sleep samples surfaced by `duplicateSleepGroups` (see - /// `DuplicateSleepDetector`), then refreshes so the UI reflects the change. - /// - /// Only ever actually deletes anything when every sample was written by this app — see - /// `ForeignSourceDeletionError`. In practice that means this will almost always throw for - /// real duplicate groups (which come from Oura, not Bedger); it exists mainly so a real - /// self-written duplicate (e.g. from the debug data generator) can still be cleaned up, and - /// so the failure mode for everything else is a clear, honest error instead of a delete call - /// that silently matches zero objects and looks like it worked. - func deleteDuplicateSamples(_ samples: [DuplicateCandidateSample]) async throws { - guard !samples.isEmpty else { return } - - let ownBundleID = Bundle.main.bundleIdentifier - if let foreign = samples.first(where: { $0.sourceBundleID != ownBundleID }) { - throw ForeignSourceDeletionError(sourceName: foreign.sourceName) - } - - try await requireWriteAuthorization(for: HKCategoryType.sleepAnalysis) - let predicate = HKQuery.predicateForObjects(with: Set(samples.map(\.id))) - try await healthStore.deleteObjects(of: HKCategoryType.sleepAnalysis, predicate: predicate) - try await fetchSleepData() - } - - #if DEBUG /// Writes a batch of fake sleep nights into HealthKit and refreshes the /// in-memory cache so the UI updates immediately. Debug builds only. func generateFakeSleepData(nights: Int = 14, targetSleepHours: Double = 7.5) async throws { diff --git a/Bedtime/Bedtime/Models/SleepData.swift b/Bedtime/Bedtime/Models/SleepData.swift index 4c45131..0269f59 100644 --- a/Bedtime/Bedtime/Models/SleepData.swift +++ b/Bedtime/Bedtime/Models/SleepData.swift @@ -32,8 +32,8 @@ struct SleepSession { Self.dateForGrouping(startDate: startDate, duration: duration) } - /// Shared with duplicate-detection code (`DuplicateSleepDetector`) so both bucket samples - /// into "nights" using the exact same rule. + /// Exposed statically so other code that buckets raw samples into "nights" (e.g. duplicate + /// filtering, before `SleepSession`s exist yet) can use the exact same rule. static func dateForGrouping(startDate: Date, duration: TimeInterval) -> Date { let midpoint = startDate.addingTimeInterval(duration / 2) let shiftedMidpoint = midpoint.addingTimeInterval(TimeInterval(6 * 60 * 60)) diff --git a/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift deleted file mode 100644 index 8d083a3..0000000 --- a/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// HealthKitCreationDateReader.swift -// Bedtime -// -// Reads a HealthKit sample's "Date Added" timestamp — the value shown in the iOS Health app's -// sample detail screen, but never exposed by any public `HKSample` API (`startDate`/`endDate` -// describe when the *event* happened, not when HealthKit received it). -// - -import Foundation -import HealthKit - -/// HealthKit stores "Date Added" internally as `creationTimestamp`, readable only via -/// undocumented Key-Value Coding. This is exactly what duplicate-sync detection needs: when a -/// source (e.g. Oura) re-syncs a night it already wrote, the resulting samples have identical -/// start/end times but a distinct, later creation timestamp — the only signal that tells the two -/// syncs apart. -/// -/// This is unsupported API: Apple could rename or remove the underlying property in a future OS -/// release. `KVCSafeAccessor` guards the read with an Objective-C `@try`/`@catch` so an -/// unexpected undefined-key exception degrades to `nil` (the cleanup feature simply becomes -/// unavailable for that sample) instead of crashing the app. -enum HealthKitCreationDateReader { - private static let key = "creationTimestamp" - - static func creationDate(for sample: HKSample) -> Date? { - guard let raw = KVCSafeAccessor.safeValue(key, for: sample) else { return nil } - - // On every OS version observed so far this KVC read returns a boxed `NSNumber`, not - // an `NSDate` — internally it's a raw `_creationTimestamp` double, sitting right - // alongside `_startTimestamp`/`_endTimestamp` on `HKObject`. Still, handle a future - // `NSDate`-boxed representation too, since nothing here is documented. - if let date = raw as? Date { - return date - } - guard let interval = (raw as? NSNumber)?.doubleValue else { return nil } - - // That raw double sits in the same units as `_startTimestamp`/`_endTimestamp` — i.e. - // seconds since Foundation's reference date (2001-01-01), not the Unix epoch. Confirm - // via a plausibility check (should land near "now") rather than assuming, in case a - // future OS version switches conventions. - let now = Date() - let plausibleWindow = now.addingTimeInterval(-86400 * 400)...now.addingTimeInterval(86400) - let referenceDateCandidate = Date(timeIntervalSinceReferenceDate: interval) - if plausibleWindow.contains(referenceDateCandidate) { - return referenceDateCandidate - } - let unixEpochCandidate = Date(timeIntervalSince1970: interval) - if plausibleWindow.contains(unixEpochCandidate) { - return unixEpochCandidate - } - return nil - } -} diff --git a/Bedtime/Bedtime/Utils/KVCSafeAccessor.h b/Bedtime/Bedtime/Utils/KVCSafeAccessor.h deleted file mode 100644 index 2afc9e7..0000000 --- a/Bedtime/Bedtime/Utils/KVCSafeAccessor.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// KVCSafeAccessor.h -// Bedtime -// -// Guards reads of undocumented Key-Value Coding properties (e.g. HKSample's private -// "creationTimestamp") against crashing if the key is ever removed or renamed. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/// Swift's `NSObject.value(forKey:)` does not catch Objective-C exceptions, so an undefined -/// KVC key raises `NSUndefinedKeyException` and crashes the app. This trampoline exists purely -/// to make undocumented/private KVC reads safe to attempt speculatively. -@interface KVCSafeAccessor : NSObject - -/// Returns `[object valueForKey:key]`, or `nil` if that raises any exception (e.g. the key is -/// undefined) instead of crashing. -+ (nullable id)safeValue:(NSString *)key forObject:(id)object NS_SWIFT_NAME(safeValue(_:for:)); - -@end - -NS_ASSUME_NONNULL_END diff --git a/Bedtime/Bedtime/Utils/KVCSafeAccessor.m b/Bedtime/Bedtime/Utils/KVCSafeAccessor.m deleted file mode 100644 index f213dad..0000000 --- a/Bedtime/Bedtime/Utils/KVCSafeAccessor.m +++ /dev/null @@ -1,18 +0,0 @@ -// -// KVCSafeAccessor.m -// Bedtime -// - -#import "KVCSafeAccessor.h" - -@implementation KVCSafeAccessor - -+ (nullable id)safeValue:(NSString *)key forObject:(id)object { - @try { - return [object valueForKey:key]; - } @catch (NSException *exception) { - return nil; - } -} - -@end diff --git a/Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift b/Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift deleted file mode 100644 index 6768a32..0000000 --- a/Bedtime/Bedtime/Views/Components/CreationTimeHistogramView.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// CreationTimeHistogramView.swift -// Bedtime -// -// A histogram of when a duplicate group's samples were added to HealthKit, with a -// draggable divider that chooses the cutoff between the older (deleted) and newer -// (kept) sync batch. -// - -import SwiftUI -import HealthKit - -struct CreationTimeHistogramView: View { - let samples: [DuplicateCandidateSample] - let range: ClosedRange - @Binding var cutoff: Date - - private let bucketCount = 28 - private let handleAreaHeight: CGFloat = 28 - - private var rangeDuration: TimeInterval { - max(range.upperBound.timeIntervalSince(range.lowerBound), 1) - } - - private var buckets: [Int] { - var counts = [Int](repeating: 0, count: bucketCount) - for sample in samples { - guard let creationDate = sample.creationDate else { continue } - let fraction = creationDate.timeIntervalSince(range.lowerBound) / rangeDuration - let index = min(bucketCount - 1, max(0, Int(fraction * Double(bucketCount)))) - counts[index] += 1 - } - return counts - } - - private var maxCount: Int { max(buckets.max() ?? 1, 1) } - - private func fraction(for date: Date) -> CGFloat { - CGFloat(min(max(date.timeIntervalSince(range.lowerBound) / rangeDuration, 0), 1)) - } - - var body: some View { - GeometryReader { proxy in - let width = proxy.size.width - let totalHeight = proxy.size.height - let barAreaHeight = max(totalHeight - handleAreaHeight, 1) - let x = fraction(for: cutoff) * width - - ZStack(alignment: .topLeading) { - HStack(alignment: .bottom, spacing: 2) { - ForEach(Array(buckets.enumerated()), id: \.offset) { _, count in - RoundedRectangle(cornerRadius: 2) - .fill(Color.accentColor.opacity(0.45)) - .frame(maxWidth: .infinity) - .frame(height: count > 0 ? max(barAreaHeight * CGFloat(count) / CGFloat(maxCount), 3) : 0) - } - } - .frame(width: width, height: barAreaHeight, alignment: .bottom) - .offset(y: handleAreaHeight) - - Rectangle() - .fill(Color.orange) - .frame(width: 2, height: totalHeight) - .position(x: x, y: totalHeight / 2) - .allowsHitTesting(false) - - dividerHandle - .position(x: x, y: handleAreaHeight / 2) - .allowsHitTesting(false) - - Color.clear - .frame(width: width, height: totalHeight) - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let newFraction = min(max(value.location.x / width, 0), 1) - cutoff = range.lowerBound.addingTimeInterval(rangeDuration * Double(newFraction)) - } - ) - } - } - } - - private var dividerHandle: some View { - ZStack { - Circle() - .fill(Color.orange) - Image(systemName: "arrow.left.and.right") - .font(.caption2.weight(.bold)) - .foregroundStyle(.white) - } - .frame(width: 22, height: 22) - .shadow(color: .black.opacity(0.15), radius: 2, y: 1) - } -} - -#Preview(traits: .sizeThatFitsLayout) { - struct PreviewHost: View { - let range: ClosedRange - let samples: [DuplicateCandidateSample] - @State private var cutoff: Date - - init() { - let now = Date() - let olderBatch = now.addingTimeInterval(-3600) - let newerBatch = now - range = olderBatch.addingTimeInterval(-600)...newerBatch.addingTimeInterval(600) - samples = (0..<8).map { index in - DuplicateCandidateSample( - id: UUID(), - startDate: now, - endDate: now.addingTimeInterval(1800), - sleepType: .asleepCore, - creationDate: index < 4 ? olderBatch.addingTimeInterval(Double(index) * 30) : newerBatch.addingTimeInterval(Double(index) * 30), - sourceBundleID: "com.ouraring.oura", - sourceName: "Oura" - ) - } - _cutoff = State(initialValue: olderBatch.addingTimeInterval(1800)) - } - - var body: some View { - CreationTimeHistogramView(samples: samples, range: range, cutoff: $cutoff) - .frame(height: 120) - .padding() - } - } - return PreviewHost() -} diff --git a/Bedtime/Bedtime/Views/Components/DuplicateCleanupRow.swift b/Bedtime/Bedtime/Views/Components/DuplicateCleanupRow.swift deleted file mode 100644 index 8b920f8..0000000 --- a/Bedtime/Bedtime/Views/Components/DuplicateCleanupRow.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// DuplicateCleanupRow.swift -// Bedtime -// -// A small, always-visible warning row for each detected duplicate-sync group on a night. -// Deliberately independent of `SleepSourceComparisonView` (which only renders when there's -// more than one source to compare) so it still shows up on nights with just a single source -// — exactly the case a duplicate re-sync usually produces. -// - -import SwiftUI - -struct DuplicateCleanupRow: View { - let groups: [DuplicateSleepGroup] - let onReview: (DuplicateSleepGroup) -> Void - - var body: some View { - if !groups.isEmpty { - VStack(alignment: .leading, spacing: 4) { - ForEach(groups) { group in - Button { - onReview(group) - } label: { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.caption2) - .foregroundStyle(.orange) - - Text("Possible duplicate \(group.sourceName) data") - .font(.caption2) - .foregroundStyle(.orange) - .lineLimit(1) - - Spacer(minLength: 4) - - Text("Review") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.orange) - } - .padding(.vertical, 4) - .padding(.horizontal, 8) - .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) - } - .buttonStyle(.plain) - .accessibilityLabel("Possible duplicate \(group.sourceName) data") - .accessibilityHint("Review and remove duplicate entries") - } - } - .padding(.bottom, 6) - } - } -} - -#Preview(traits: .sizeThatFitsLayout) { - let now = Date() - DuplicateCleanupRow( - groups: [ - DuplicateSleepGroup( - night: Calendar.current.startOfDay(for: now), - sourceBundleID: "com.ouraring.oura", - sourceName: "Oura", - samples: [] - ) - ], - onReview: { _ in } - ) - .padding() -} diff --git a/Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift b/Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift deleted file mode 100644 index ce89221..0000000 --- a/Bedtime/Bedtime/Views/Components/DuplicateCleanupSheet.swift +++ /dev/null @@ -1,249 +0,0 @@ -// -// DuplicateCleanupSheet.swift -// Bedtime -// -// Lets the user resolve a detected duplicate sync: shows the overlapping entries on a -// timeline, a histogram of when they were added to HealthKit with a draggable divider -// suggesting where the old sync ends and the new one begins, and either deletes the older -// batch directly (only possible when Bedger itself wrote the samples) or, for real -// duplicates from another source, tells the user exactly what to remove and hands off to -// the Health app to finish it — see `canDeleteDirectly`. -// - -import SwiftUI -import HealthKit -import UIKit - -struct DuplicateCleanupSheet: View { - let group: DuplicateSleepGroup - /// Deletes the given samples from HealthKit. Thrown errors are shown inline; the sheet - /// dismisses itself on success. - let onDelete: ([DuplicateCandidateSample]) async throws -> Void - - @Environment(\.dismiss) private var dismiss - @Environment(\.durationDisplayStyle) private var durationStyle - @State private var cutoff: Date - @State private var isDeleting = false - @State private var errorMessage: String? - - init(group: DuplicateSleepGroup, onDelete: @escaping ([DuplicateCandidateSample]) async throws -> Void) { - self.group = group - self.onDelete = onDelete - _cutoff = State(initialValue: group.suggestedCutoff ?? group.distinctCreationDates.last ?? Date()) - } - - /// HealthKit only lets an app delete objects it wrote itself (see - /// `ForeignSourceDeletionError`), so a real Delete button only makes sense when Bedger - /// itself is the source of these samples — which, for actual duplicate-sync bugs like - /// Oura's, it never is. This drives whether we show the delete button or manual - /// instructions for the Health app instead. - private var canDeleteDirectly: Bool { - group.sourceBundleID == Bundle.main.bundleIdentifier - } - - private var resolution: DuplicateResolution { - DuplicateSleepDetector.resolution(for: group, cutoff: cutoff) - } - - private var creationRange: ClosedRange { - guard let range = group.creationDateRange else { - let now = Date() - return now...now.addingTimeInterval(1) - } - // Pad a little so the divider isn't glued to the histogram's edges. - let padding = max(range.upperBound.timeIntervalSince(range.lowerBound) * 0.08, 60) - return range.lowerBound.addingTimeInterval(-padding)...range.upperBound.addingTimeInterval(padding) - } - - private static let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "EEE, MMM d" - return formatter - }() - - private static let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "h:mm a" - return formatter - }() - - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - Text("Bedger found \(group.samples.count) overlapping \(group.sourceName) entries for the night of \(Self.dateFormatter.string(from: group.night)) — likely from a duplicate sync. Choose which sync to keep.") - .font(.subheadline) - .foregroundStyle(.secondary) - - if !canDeleteDirectly { - manualDeletionNotice - } - - VStack(alignment: .leading, spacing: 8) { - Text("Overlapping entries") - .font(.headline) - - DuplicateOverlapTimelineView(group: group, cutoff: cutoff) - } - - VStack(alignment: .leading, spacing: 8) { - Text("When entries were added") - .font(.headline) - Text("Drag the divider to choose the cutoff. Entries added before it are deleted; the rest are kept.") - .font(.caption) - .foregroundStyle(.secondary) - - CreationTimeHistogramView(samples: group.samples, range: creationRange, cutoff: $cutoff) - .frame(height: 120) - - Text("Cutoff: \(Self.dateFormatter.string(from: cutoff)) at \(Self.timeFormatter.string(from: cutoff))") - .font(.caption) - .monospacedDigit() - .foregroundStyle(.secondary) - } - - summary - - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundStyle(.red) - } - - if canDeleteDirectly { - submitButton - } else { - openHealthAppButton - } - } - .padding() - } - .navigationTitle("Clean Up Duplicates") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - .disabled(isDeleting) - } - } - } - .interactiveDismissDisabled(isDeleting) - } - - private var summary: some View { - VStack(alignment: .leading, spacing: 10) { - summaryRow( - color: .red, - text: "\(resolution.toDelete.count) entries will be deleted", - duration: resolution.deletedDuration - ) - summaryRow( - color: .green, - text: "\(resolution.toKeep.count) entries will be kept", - duration: resolution.keptDuration - ) - } - .padding() - .background(Color.cardBackground, in: RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.primary.opacity(0.08), lineWidth: 0.5) - ) - } - - private func summaryRow(color: Color, text: String, duration: TimeInterval) -> some View { - HStack { - Circle().fill(color).frame(width: 8, height: 8) - Text(text) - .font(.subheadline) - Spacer() - Text(TimeFormatter.formatDuration(duration, style: durationStyle)) - .font(.subheadline) - .foregroundStyle(.secondary) - .monospacedDigit() - } - } - - private var manualDeletionNotice: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: "info.circle.fill") - .foregroundStyle(.blue) - Text("Bedger can't delete this directly") - .font(.subheadline.weight(.semibold)) - } - Text("Apple only lets an app delete HealthKit entries it wrote itself, so Bedger can't remove \(group.sourceName)'s entries — only the Health app can. Use the divider below to see exactly which entries to remove, then delete them from Health → Browse → Sleep → this night → \"Show All Data.\"") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - .background(Color.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) - } - - private var openHealthAppButton: some View { - Button { - if let url = URL(string: "x-apple-health://"), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } label: { - Text("Open Health App") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - } - - private var submitButton: some View { - Button(role: .destructive) { - delete() - } label: { - Group { - if isDeleting { - ProgressView() - } else { - Text("Delete \(resolution.toDelete.count) Duplicate \(resolution.toDelete.count == 1 ? "Entry" : "Entries")") - } - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(.red) - .disabled(resolution.toDelete.isEmpty || isDeleting) - } - - private func delete() { - isDeleting = true - errorMessage = nil - let samplesToDelete = resolution.toDelete - Task { - do { - try await onDelete(samplesToDelete) - isDeleting = false - dismiss() - } catch { - isDeleting = false - errorMessage = "Couldn't delete duplicates: \(error.localizedDescription)" - } - } - } -} - -#Preview { - let now = Date() - let olderBatch = now.addingTimeInterval(-3600) - let newerBatch = now.addingTimeInterval(-120) - let group = DuplicateSleepGroup( - night: Calendar.current.startOfDay(for: now), - sourceBundleID: "com.ouraring.oura", - sourceName: "Oura", - samples: [ - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: olderBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: olderBatch.addingTimeInterval(30), sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-21600), endDate: now.addingTimeInterval(-18000), sleepType: .asleepREM, creationDate: olderBatch.addingTimeInterval(60), sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: newerBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: newerBatch.addingTimeInterval(30), sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-21600), endDate: now.addingTimeInterval(-18000), sleepType: .asleepREM, creationDate: newerBatch.addingTimeInterval(60), sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), - ] - ) - DuplicateCleanupSheet(group: group, onDelete: { _ in }) -} diff --git a/Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift b/Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift deleted file mode 100644 index c068cd3..0000000 --- a/Bedtime/Bedtime/Views/Components/DuplicateOverlapTimelineView.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// DuplicateOverlapTimelineView.swift -// Bedtime -// -// Two aligned timeline lanes showing exactly which of a duplicate group's samples would be -// kept vs. deleted at the current divider position, so dragging the divider gives an -// immediate preview of the result. -// - -import SwiftUI -import HealthKit - -struct DuplicateOverlapTimelineView: View { - let group: DuplicateSleepGroup - let cutoff: Date - - private var resolution: DuplicateResolution { - DuplicateSleepDetector.resolution(for: group, cutoff: cutoff) - } - - private var timeRange: (start: Date, end: Date) { - group.timeRange ?? (Date(), Date().addingTimeInterval(1)) - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - lane(title: "Keep", samples: resolution.toKeep, color: .green) - lane(title: "Delete", samples: resolution.toDelete, color: .red) - } - } - - private func lane(title: String, samples: [DuplicateCandidateSample], color: Color) -> some View { - HStack(spacing: 8) { - Text(title) - .font(.caption2.weight(.medium)) - .foregroundStyle(color) - .frame(width: 44, alignment: .leading) - - Capsule() - .fill(.foreground.quaternary) - .overlay(alignment: .leading) { - GeometryReader { proxy in - let rangeDuration = max(timeRange.end.timeIntervalSince(timeRange.start), 1) - ForEach(samples) { sample in - let offset = sample.startDate.timeIntervalSince(timeRange.start) / rangeDuration - let width = sample.duration / rangeDuration - Rectangle() - .fill(color) - .frame(width: max(proxy.size.width * CGFloat(width), 1)) - .offset(x: proxy.size.width * CGFloat(offset)) - } - } - } - .clipShape(Capsule()) - .frame(height: 18) - } - } -} - -#Preview(traits: .sizeThatFitsLayout) { - let now = Date() - let olderBatch = now.addingTimeInterval(-3600) - let newerBatch = now.addingTimeInterval(-3550) - let group = DuplicateSleepGroup( - night: Calendar.current.startOfDay(for: now), - sourceBundleID: "com.ouraring.oura", - sourceName: "Oura", - samples: [ - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: olderBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: olderBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-28800), endDate: now.addingTimeInterval(-25200), sleepType: .asleepCore, creationDate: newerBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), - DuplicateCandidateSample(id: UUID(), startDate: now.addingTimeInterval(-25200), endDate: now.addingTimeInterval(-21600), sleepType: .asleepDeep, creationDate: newerBatch, sourceBundleID: "com.ouraring.oura", sourceName: "Oura"), - ] - ) - DuplicateOverlapTimelineView(group: group, cutoff: newerBatch.addingTimeInterval(-30)) - .padding() -} diff --git a/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift b/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift index 0f883ca..ab99370 100644 --- a/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift +++ b/Bedtime/Bedtime/Views/Components/SleepDayGroup.swift @@ -16,13 +16,8 @@ struct SleepDayGroup: View { let sleepGoal: Double /// Whether this night counts toward the sleep-bank lookback window. var isIncludedInSleepBank: Bool = true - /// Cleanable duplicate-sync groups detected for this night. - var duplicateGroups: [DuplicateSleepGroup] = [] - /// Deletes the given samples from HealthKit (see `HealthKitManager.deleteDuplicateSamples`). - var onDeleteDuplicates: ([DuplicateCandidateSample]) async throws -> Void = { _ in } let onToggle: () -> Void @Environment(\.durationDisplayStyle) private var durationStyle - @State private var selectedDuplicateGroup: DuplicateSleepGroup? private var dateFormatter: DateFormatter { let formatter = DateFormatter() @@ -114,12 +109,6 @@ struct SleepDayGroup: View { .buttonStyle(PlainButtonStyle()) .disabled(!hasSessions) - DuplicateCleanupRow( - groups: duplicateGroups, - onReview: { selectedDuplicateGroup = $0 } - ) - .padding(.leading, 4) - SleepSourceComparisonView( sessions: allSessions, excludedSourceIDs: excludedSourceIDs @@ -144,8 +133,5 @@ struct SleepDayGroup: View { } .opacity(isIncludedInSleepBank ? 1 : 0.45) .accessibilityHint(isIncludedInSleepBank ? "Included in sleep balance" : "Not included in sleep balance") - .sheet(item: $selectedDuplicateGroup) { group in - DuplicateCleanupSheet(group: group, onDelete: onDeleteDuplicates) - } } } diff --git a/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift b/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift index 93e73fa..b1d29cd 100644 --- a/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift +++ b/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift @@ -15,15 +15,11 @@ struct RecentSleepSessionsCard: View { excludedSourceIDs: Set, sleepGoal: Double, sleepBankDays: Binding, - dayCount: Int = Constants.sleepHistoryDays, - duplicateSleepGroups: [DuplicateSleepGroup] = [], - onDeleteDuplicates: @escaping ([DuplicateCandidateSample]) async throws -> Void = { _ in } + dayCount: Int = Constants.sleepHistoryDays ) { self.sleepGoal = sleepGoal self.excludedSourceIDs = excludedSourceIDs self._sleepBankDays = sleepBankDays - self.duplicateSleepGroups = duplicateSleepGroups - self.onDeleteDuplicates = onDeleteDuplicates let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) @@ -37,8 +33,6 @@ struct RecentSleepSessionsCard: View { let sortedSessions: [(Date, [SleepSession], [SleepSession])] let sleepGoal: Double let excludedSourceIDs: Set - let duplicateSleepGroups: [DuplicateSleepGroup] - let onDeleteDuplicates: ([DuplicateCandidateSample]) async throws -> Void @Binding var sleepBankDays: Int @State private var expandedNights: Set = [] @@ -83,8 +77,6 @@ struct RecentSleepSessionsCard: View { isExpanded: expandedNights.contains(night), sleepGoal: sleepGoal, isIncludedInSleepBank: isIncluded, - duplicateGroups: duplicateSleepGroups.filter { $0.night == night }, - onDeleteDuplicates: onDeleteDuplicates, onToggle: { withAnimation(.easeInOut(duration: 0.2)) { if expandedNights.contains(night) { diff --git a/README.md b/README.md index 3ac9b50..4e54444 100644 --- a/README.md +++ b/README.md @@ -27,20 +27,6 @@ A native iOS app that helps optimize your sleep by tracking your "sleep bank" an - Track sleep duration over time - Visual representation of your sleep patterns -### 🧹 Duplicate Data Cleanup -- Detects when a source (e.g. Oura) has re-synced a night it already wrote, leaving two - overlapping sets of samples for the same stretch of sleep -- A small warning button appears next to the affected source's row; tapping it opens a - cleanup sheet -- The sheet shows the overlapping entries on a timeline and a histogram of when they were - added to HealthKit, with a divider you can drag to preview exactly which entries would be - kept vs. deleted -- Apple only lets an app delete HealthKit samples it wrote itself, so for real duplicates - (which always come from the other source, e.g. Oura) Bedger can't delete them directly — - the sheet instead points you at exactly what to remove and offers a shortcut into the - Health app to finish it there. Bedger only deletes directly in the rare case where it's - the source of the duplicate samples itself - ### ⚙️ Customizable Settings - Set your personal sleep goal (6-12 hours, in 15 minute steps) - Configure your preferred wake time @@ -77,10 +63,7 @@ Sleep sessions are assigned to a calendar day using a "midpoint + 6 hours" rule - All data stays on your device - No data is sent to external servers -- HealthKit data is effectively read-only: the duplicate cleanup feature can request write - access, but HealthKit only lets an app delete samples it wrote itself, so it can't - actually delete another source's data (e.g. Oura's) — only guide you to remove it via the - Health app. Bedger never writes new sleep data outside of debug builds +- HealthKit data is only read, never written to ## Architecture From ff7a8547a2f8d690bf1c0ce4afcda3df912f1465 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 03:04:07 +0000 Subject: [PATCH 8/9] Automatically filter out duplicate-sync data instead of trying to delete it Adds SleepSampleDeduplicator: for each (night, source) group of raw sleep samples, detects same-source time overlaps -- which never happen within a single sync, since stage segments are sequential -- and, for any overlapping span, keeps only the most complete generation (the one covering the most total sleep), dropping the rest. A re-sync's non-overlapping new tail, and any night with no overlap at all, passes through untouched. This replaces the reverted delete-based cleanup feature: since HealthKit can only let an app delete samples it wrote itself, and real duplicates always come from another source (e.g. Oura), deletion could never actually work for the real case. Filtering the app's own calculations sidesteps that restriction entirely -- Bedger never needs write access, and a double-synced night no longer inflates Sleep Bank totals, per-night durations, or per-source comparisons. Wired into HealthKitManager.processSleepSamples, ahead of both allSleepSessions and the source-filtered sleepSessions, so every downstream consumer benefits without needing its own dedup logic. Preserves the original reverse-chronological ordering sessions are fetched in, which SleepDayGroup/LastNightCard rely on for wake/bed times. Co-authored-by: Greg --- Bedtime/Bedtime/Models/HealthKitManager.swift | 3 +- .../Models/SleepSampleDeduplicator.swift | 134 ++++++++++++++++++ README.md | 6 + 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 1af4dc3..2216336 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -254,7 +254,8 @@ class HealthKitManager: ObservableObject { } private func processSleepSamples(_ samples: [HKCategorySample]) { - let allSessions = samples.compactMap { SleepSession(sample: $0) } + let rawSessions = samples.compactMap { SleepSession(sample: $0) } + let allSessions = SleepSampleDeduplicator.deduplicate(rawSessions) self.allSleepSessions = Dictionary(grouping: allSessions) { $0.dateForGrouping } let includedSessions = allSessions.filter { diff --git a/Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift b/Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift new file mode 100644 index 0000000..0c00dea --- /dev/null +++ b/Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift @@ -0,0 +1,134 @@ +// +// SleepSampleDeduplicator.swift +// Bedtime +// +// Some sources (notably Oura) have a "double sync" bug: syncing a night, then falling back +// asleep and syncing again, re-writes the same stretch of stage samples a second time instead +// of only writing the newly-recorded tail. Left alone, that inflates every duration total this +// app computes (Sleep Bank balance, per-night totals, source comparisons) by roughly 2x for +// the affected night. There's no public, reliable way to tell *when* HealthKit received a +// sample (only when the sleep it describes happened), so this can't distinguish "old sync" from +// "new sync" directly — instead it relies on a structural fact: a single sync's stage samples +// never overlap each other (core/deep/REM segments are sequential), so any same-source, +// same-night overlap is necessarily two syncs' worth of data for the same stretch of time. +// + +import Foundation +import HealthKit + +enum SleepSampleDeduplicator { + /// A lightweight stand-in for identity: `SleepSession` has no sample UUID to key off of, so + /// this keys on its visible fields instead. Collisions (two sessions + /// with identical start, end, type, and source) are harmless here — they'd be genuine + /// duplicates anyway, and treating them as the same identity just means they're dropped or + /// kept together, which is the correct outcome either way. + private struct Identity: Hashable { + let startDate: Date + let endDate: Date + let sleepTypeRawValue: Int + let sourceBundleID: String + + init(_ session: SleepSession) { + startDate = session.startDate + endDate = session.endDate + sleepTypeRawValue = session.sleepType.rawValue + sourceBundleID = session.source.source.bundleIdentifier + } + } + + /// Removes the "old", superseded half of any same-source, same-night duplicate re-sync from + /// `sessions`, so summed durations aren't inflated by data a source wrote twice. Sessions + /// that never overlap anything (the common case, and also a re-sync's non-overlapping new + /// tail) pass through untouched. Preserves `sessions`' original relative order — callers + /// (e.g. `SleepDayGroup`, which reads `.first`/`.last` as wake/bed times) rely on it staying + /// reverse-chronological. + static func deduplicate(_ sessions: [SleepSession]) -> [SleepSession] { + struct Key: Hashable { + let night: Date + let bundleID: String + } + + let grouped = Dictionary(grouping: sessions) { + Key(night: $0.dateForGrouping, bundleID: $0.source.source.bundleIdentifier) + } + + let toDrop = Set(grouped.values.flatMap(sessionsToDrop).map(Identity.init)) + guard !toDrop.isEmpty else { return sessions } + return sessions.filter { !toDrop.contains(Identity($0)) } + } + + /// Resolves one (night, source) group and returns just the sessions that should be + /// discarded — the superseded generation(s) of any overlapping span. + private static func sessionsToDrop(_ sessions: [SleepSession]) -> [SleepSession] { + guard sessions.count > 1 else { return [] } + let sortedByStart = sessions.sorted { $0.startDate < $1.startDate } + return overlapComponents(sortedByStart).flatMap(droppedFromComponent) + } + + /// Merges sessions into components under the "reachable via a chain of pairwise time + /// overlaps" relation — the standard sweep used to merge overlapping intervals. A duplicate + /// re-sync's shared span becomes one component; anything that never overlaps anything else + /// — including a re-sync's non-overlapping "new" tail, which should always survive — ends up + /// in its own singleton component. + private static func overlapComponents(_ sortedByStart: [SleepSession]) -> [[SleepSession]] { + guard let first = sortedByStart.first else { return [] } + + var components: [[SleepSession]] = [] + var current: [SleepSession] = [first] + var currentEnd = first.endDate + + for session in sortedByStart.dropFirst() { + if session.startDate < currentEnd { + current.append(session) + currentEnd = max(currentEnd, session.endDate) + } else { + components.append(current) + current = [session] + currentEnd = session.endDate + } + } + components.append(current) + return components + } + + /// If a component is internally overlapping, it's the contested span written by two (or + /// more) syncs. Splits it into the minimum number of mutually non-overlapping "generations" + /// and drops every generation except the one that covers the most total sleep — in + /// practice always the latest sync, since falling back asleep and re-syncing can only add + /// data for that span, never remove it. A non-overlapping component is real, unduplicated + /// data, so nothing is dropped from it. + private static func droppedFromComponent(_ component: [SleepSession]) -> [SleepSession] { + guard component.count > 1 else { return [] } + let generations = nonOverlappingGenerations(component) + guard generations.count > 1 else { return [] } + guard let bestIndex = generations.indices.max(by: { totalDuration(generations[$0]) < totalDuration(generations[$1]) }) else { + return [] + } + return generations.enumerated().filter { $0.offset != bestIndex }.flatMap(\.element) + } + + /// Greedy interval partitioning: walks sessions in start-time order, placing each into the + /// still-open generation whose most recent session ends soonest (while still being at or + /// before this session's start) — the standard minimum-generations strategy — opening a new + /// generation only when no existing one qualifies. Because `component` came from + /// `overlapComponents`, every session here participates in the same contested span, so this + /// only ever separates that span's own duplicate generations from each other. + private static func nonOverlappingGenerations(_ component: [SleepSession]) -> [[SleepSession]] { + let sortedByStart = component.sorted { $0.startDate < $1.startDate } + var generations: [[SleepSession]] = [] + + for session in sortedByStart { + let eligibleIndices = generations.indices.filter { generations[$0].last!.endDate <= session.startDate } + if let bestIndex = eligibleIndices.max(by: { generations[$0].last!.endDate < generations[$1].last!.endDate }) { + generations[bestIndex].append(session) + } else { + generations.append([session]) + } + } + return generations + } + + private static func totalDuration(_ sessions: [SleepSession]) -> TimeInterval { + sessions.reduce(0) { $0 + $1.duration } + } +} diff --git a/README.md b/README.md index 4e54444..f01840d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ A native iOS app that helps optimize your sleep by tracking your "sleep bank" an - View your recent sleep sessions from HealthKit - Track sleep duration over time - Visual representation of your sleep patterns +- Automatically filters out duplicate data from sources that occasionally re-sync a night + they already wrote (e.g. Oura, after falling back asleep and syncing again), so a + double-synced night doesn't inflate sleep totals. Since HealthKit only lets an app delete + data it wrote itself, this is done by excluding the older, superseded sync from Bedger's + own calculations rather than deleting anything from HealthKit — see + `SleepSampleDeduplicator` ### ⚙️ Customizable Settings - Set your personal sleep goal (6-12 hours, in 15 minute steps) From c5cfa976e5cae7886c2f1f56201ded8de7beb7de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 03:10:30 +0000 Subject: [PATCH 9/9] Use creation-time ('Date Added'), not overlap span size, to pick which sync to drop The previous version picked whichever overlapping 'generation' covered more total sleep -- but that biases toward whichever sync happens to produce longer stage segments, which isn't necessarily the newer one. Switch to the same signal a person would use looking in the Health app: 'Date Added.' Restores the KVC-based HealthKitCreationDateReader (with its safe Objective-C accessor and bridging header) from the reverted delete-feature branch -- this time used purely for internal filtering, never surfaced in any UI or write path, so the risk profile of relying on an undocumented property is much lower: if it ever stops working, filtering just silently no-ops for that sample instead of anything user-visible breaking. SleepSampleDeduplicator now operates on raw HKCategorySamples (before they become SleepSessions, since 'creation date' isn't otherwise needed downstream) and, for a same-source night that shows overlap, uses the largest gap between distinct creation timestamps across the whole group -- not just the contested overlap span -- as the cutoff, dropping every sample created before it. Samples with no readable creation date, or groups without enough distinct timestamps to place a meaningful cutoff, are always kept. No new HealthKit authorization needed -- this only reads a property on samples already covered by existing read access. Co-authored-by: Greg --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 2 + Bedtime/Bedtime/Bedtime-Bridging-Header.h | 6 + Bedtime/Bedtime/Models/HealthKitManager.swift | 4 +- .../Models/SleepSampleDeduplicator.swift | 167 ++++++++---------- .../Utils/HealthKitCreationDateReader.swift | 54 ++++++ Bedtime/Bedtime/Utils/KVCSafeAccessor.h | 24 +++ Bedtime/Bedtime/Utils/KVCSafeAccessor.m | 18 ++ README.md | 8 +- 8 files changed, 180 insertions(+), 103 deletions(-) create mode 100644 Bedtime/Bedtime/Bedtime-Bridging-Header.h create mode 100644 Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift create mode 100644 Bedtime/Bedtime/Utils/KVCSafeAccessor.h create mode 100644 Bedtime/Bedtime/Utils/KVCSafeAccessor.m diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index d873cad..5d17895 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -157,6 +157,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Bedtime/Bedtime-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -194,6 +195,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Bedtime/Bedtime-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Bedtime/Bedtime/Bedtime-Bridging-Header.h b/Bedtime/Bedtime/Bedtime-Bridging-Header.h new file mode 100644 index 0000000..f85d05c --- /dev/null +++ b/Bedtime/Bedtime/Bedtime-Bridging-Header.h @@ -0,0 +1,6 @@ +// +// Bedtime-Bridging-Header.h +// Bedtime +// + +#import "Utils/KVCSafeAccessor.h" diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 2216336..9f677f4 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -254,8 +254,8 @@ class HealthKitManager: ObservableObject { } private func processSleepSamples(_ samples: [HKCategorySample]) { - let rawSessions = samples.compactMap { SleepSession(sample: $0) } - let allSessions = SleepSampleDeduplicator.deduplicate(rawSessions) + let dedupedSamples = SleepSampleDeduplicator.deduplicate(samples) + let allSessions = dedupedSamples.compactMap { SleepSession(sample: $0) } self.allSleepSessions = Dictionary(grouping: allSessions) { $0.dateForGrouping } let includedSessions = allSessions.filter { diff --git a/Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift b/Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift index 0c00dea..0bd0fe4 100644 --- a/Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift +++ b/Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift @@ -6,129 +6,102 @@ // asleep and syncing again, re-writes the same stretch of stage samples a second time instead // of only writing the newly-recorded tail. Left alone, that inflates every duration total this // app computes (Sleep Bank balance, per-night totals, source comparisons) by roughly 2x for -// the affected night. There's no public, reliable way to tell *when* HealthKit received a -// sample (only when the sleep it describes happened), so this can't distinguish "old sync" from -// "new sync" directly — instead it relies on a structural fact: a single sync's stage samples -// never overlap each other (core/deep/REM segments are sequential), so any same-source, -// same-night overlap is necessarily two syncs' worth of data for the same stretch of time. +// the affected night. +// +// HealthKit can only ever delete samples an app wrote itself, and these duplicates always come +// from the other source (Oura), never Bedger -- so there's no way to actually remove them from +// HealthKit. Instead, this filters the older, superseded sync out of Bedger's own calculations, +// using the same signal a person would use if they went looking in the Health app: "Date +// Added." See `HealthKitCreationDateReader`. // import Foundation import HealthKit enum SleepSampleDeduplicator { - /// A lightweight stand-in for identity: `SleepSession` has no sample UUID to key off of, so - /// this keys on its visible fields instead. Collisions (two sessions - /// with identical start, end, type, and source) are harmless here — they'd be genuine - /// duplicates anyway, and treating them as the same identity just means they're dropped or - /// kept together, which is the correct outcome either way. - private struct Identity: Hashable { - let startDate: Date - let endDate: Date - let sleepTypeRawValue: Int - let sourceBundleID: String - - init(_ session: SleepSession) { - startDate = session.startDate - endDate = session.endDate - sleepTypeRawValue = session.sleepType.rawValue - sourceBundleID = session.source.source.bundleIdentifier - } - } - - /// Removes the "old", superseded half of any same-source, same-night duplicate re-sync from - /// `sessions`, so summed durations aren't inflated by data a source wrote twice. Sessions - /// that never overlap anything (the common case, and also a re-sync's non-overlapping new - /// tail) pass through untouched. Preserves `sessions`' original relative order — callers - /// (e.g. `SleepDayGroup`, which reads `.first`/`.last` as wake/bed times) rely on it staying - /// reverse-chronological. - static func deduplicate(_ sessions: [SleepSession]) -> [SleepSession] { + /// Removes the "old", superseded sync from any same-source, same-night duplicate re-sync in + /// `samples`, so summed durations aren't inflated by data a source wrote twice. Preserves + /// `samples`' original relative order — callers fetch (and downstream code like + /// `SleepDayGroup`/`LastNightCard` reads `.first`/`.last` off) samples sorted newest-first, + /// so this only ever removes elements, never reorders them. + static func deduplicate(_ samples: [HKCategorySample]) -> [HKCategorySample] { struct Key: Hashable { let night: Date let bundleID: String } - let grouped = Dictionary(grouping: sessions) { - Key(night: $0.dateForGrouping, bundleID: $0.source.source.bundleIdentifier) + let grouped = Dictionary(grouping: samples) { sample in + Key( + night: SleepSession.dateForGrouping(startDate: sample.startDate, duration: sample.endDate.timeIntervalSince(sample.startDate)), + bundleID: sample.sourceRevision.source.bundleIdentifier + ) } - let toDrop = Set(grouped.values.flatMap(sessionsToDrop).map(Identity.init)) - guard !toDrop.isEmpty else { return sessions } - return sessions.filter { !toDrop.contains(Identity($0)) } + let toDropIDs = Set(grouped.values.flatMap(samplesToDrop).map(\.uuid)) + guard !toDropIDs.isEmpty else { return samples } + return samples.filter { !toDropIDs.contains($0.uuid) } } - /// Resolves one (night, source) group and returns just the sessions that should be - /// discarded — the superseded generation(s) of any overlapping span. - private static func sessionsToDrop(_ sessions: [SleepSession]) -> [SleepSession] { - guard sessions.count > 1 else { return [] } - let sortedByStart = sessions.sorted { $0.startDate < $1.startDate } - return overlapComponents(sortedByStart).flatMap(droppedFromComponent) - } + /// Resolves one (night, source) group and returns just the samples that should be + /// discarded. Only touches groups that show the structural signature of a duplicate + /// re-sync -- same-source samples overlapping in time, which never happens within a single + /// healthy sync since stage segments are sequential -- and only when "Date Added" data is + /// rich enough to place a meaningful split. Otherwise drops nothing: we never guess at + /// removing data without a real signal to justify it. + private static func samplesToDrop(_ samples: [HKCategorySample]) -> [HKCategorySample] { + guard samples.count > 1, hasOverlap(samples) else { return [] } - /// Merges sessions into components under the "reachable via a chain of pairwise time - /// overlaps" relation — the standard sweep used to merge overlapping intervals. A duplicate - /// re-sync's shared span becomes one component; anything that never overlaps anything else - /// — including a re-sync's non-overlapping "new" tail, which should always survive — ends up - /// in its own singleton component. - private static func overlapComponents(_ sortedByStart: [SleepSession]) -> [[SleepSession]] { - guard let first = sortedByStart.first else { return [] } + let creationDatesByID = Dictionary(uniqueKeysWithValues: samples.map { + ($0.uuid, HealthKitCreationDateReader.creationDate(for: $0)) + }) + let knownCreationDates = creationDatesByID.values.compactMap { $0 } + let distinctCreationDates = Array(Set(knownCreationDates)).sorted() - var components: [[SleepSession]] = [] - var current: [SleepSession] = [first] - var currentEnd = first.endDate + // Need at least two distinct sync times, known for at least half the group, to place a + // meaningful divider between "old sync" and "new sync." + guard distinctCreationDates.count >= 2, knownCreationDates.count * 2 >= samples.count else { return [] } - for session in sortedByStart.dropFirst() { - if session.startDate < currentEnd { - current.append(session) - currentEnd = max(currentEnd, session.endDate) - } else { - components.append(current) - current = [session] - currentEnd = session.endDate - } + guard let cutoff = suggestedCutoff(distinctCreationDates) else { return [] } + + // Samples with no readable creation date are always kept -- only ever drop what we can + // positively identify as belonging to the older sync. + return samples.filter { sample in + // Dictionary lookup returns `Date??`: the outer optional is always populated here + // (every sample was inserted above), the inner one is `nil` when the creation date + // itself couldn't be read. + guard case .some(.some(let creationDate)) = creationDatesByID[sample.uuid] else { return false } + return creationDate < cutoff } - components.append(current) - return components } - /// If a component is internally overlapping, it's the contested span written by two (or - /// more) syncs. Splits it into the minimum number of mutually non-overlapping "generations" - /// and drops every generation except the one that covers the most total sleep — in - /// practice always the latest sync, since falling back asleep and re-syncing can only add - /// data for that span, never remove it. A non-overlapping component is real, unduplicated - /// data, so nothing is dropped from it. - private static func droppedFromComponent(_ component: [SleepSession]) -> [SleepSession] { - guard component.count > 1 else { return [] } - let generations = nonOverlappingGenerations(component) - guard generations.count > 1 else { return [] } - guard let bestIndex = generations.indices.max(by: { totalDuration(generations[$0]) < totalDuration(generations[$1]) }) else { - return [] + /// True when at least two samples from this source overlap in time -- back-to-back sleep + /// stages from a single sync never do, so this is specific to duplicate re-syncs. + private static func hasOverlap(_ samples: [HKCategorySample]) -> Bool { + let sorted = samples.sorted { $0.startDate < $1.startDate } + guard sorted.count > 1 else { return false } + var runningEnd = sorted[0].endDate + for sample in sorted.dropFirst() { + if sample.startDate < runningEnd { return true } + runningEnd = max(runningEnd, sample.endDate) } - return generations.enumerated().filter { $0.offset != bestIndex }.flatMap(\.element) + return false } - /// Greedy interval partitioning: walks sessions in start-time order, placing each into the - /// still-open generation whose most recent session ends soonest (while still being at or - /// before this session's start) — the standard minimum-generations strategy — opening a new - /// generation only when no existing one qualifies. Because `component` came from - /// `overlapComponents`, every session here participates in the same contested span, so this - /// only ever separates that span's own duplicate generations from each other. - private static func nonOverlappingGenerations(_ component: [SleepSession]) -> [[SleepSession]] { - let sortedByStart = component.sorted { $0.startDate < $1.startDate } - var generations: [[SleepSession]] = [] + /// The midpoint of the largest gap between consecutive distinct creation timestamps -- i.e. + /// the boundary between two sync batches. `distinctCreationDates` must already be sorted + /// ascending and have at least two entries. + private static func suggestedCutoff(_ distinctCreationDates: [Date]) -> Date? { + guard distinctCreationDates.count > 1 else { return nil } - for session in sortedByStart { - let eligibleIndices = generations.indices.filter { generations[$0].last!.endDate <= session.startDate } - if let bestIndex = eligibleIndices.max(by: { generations[$0].last!.endDate < generations[$1].last!.endDate }) { - generations[bestIndex].append(session) - } else { - generations.append([session]) + var bestGapIndex = 0 + var bestGap: TimeInterval = -1 + for index in 1.. bestGap { + bestGap = gap + bestGapIndex = index - 1 } } - return generations - } - - private static func totalDuration(_ sessions: [SleepSession]) -> TimeInterval { - sessions.reduce(0) { $0 + $1.duration } + return distinctCreationDates[bestGapIndex].addingTimeInterval(bestGap / 2) } } diff --git a/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift new file mode 100644 index 0000000..70b50cc --- /dev/null +++ b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift @@ -0,0 +1,54 @@ +// +// HealthKitCreationDateReader.swift +// Bedtime +// +// Reads a HealthKit sample's "Date Added" timestamp — the value shown in the iOS Health app's +// sample detail screen, but never exposed by any public `HKSample` API (`startDate`/`endDate` +// describe when the *event* happened, not when HealthKit received it). +// + +import Foundation +import HealthKit + +/// HealthKit stores "Date Added" internally as `creationTimestamp`, readable only via +/// undocumented Key-Value Coding. This is exactly what `SleepSampleDeduplicator` needs: when a +/// source (e.g. Oura) re-syncs a night it already wrote, the resulting samples have overlapping +/// start/end times but a distinct, later creation timestamp — the only signal that tells the two +/// syncs apart. +/// +/// This is unsupported API: Apple could rename or remove the underlying property in a future OS +/// release. `KVCSafeAccessor` guards the read with an Objective-C `@try`/`@catch` so an +/// unexpected undefined-key exception degrades to `nil` (duplicate filtering simply becomes +/// unavailable for that sample, rather than guessing) instead of crashing the app. +enum HealthKitCreationDateReader { + private static let key = "creationTimestamp" + + static func creationDate(for sample: HKSample) -> Date? { + guard let raw = KVCSafeAccessor.safeValue(key, for: sample) else { return nil } + + // On every OS version observed so far this KVC read returns a boxed `NSNumber`, not + // an `NSDate` — internally it's a raw `_creationTimestamp` double, sitting right + // alongside `_startTimestamp`/`_endTimestamp` on `HKObject`. Still, handle a future + // `NSDate`-boxed representation too, since nothing here is documented. + if let date = raw as? Date { + return date + } + guard let interval = (raw as? NSNumber)?.doubleValue else { return nil } + + // That raw double sits in the same units as `_startTimestamp`/`_endTimestamp` — i.e. + // seconds since Foundation's reference date (2001-01-01), not the Unix epoch. Confirm + // via a plausibility check (should land near "now") rather than assuming, in case a + // future OS version switches conventions. + let now = Date() + let plausibleWindow = now.addingTimeInterval(-86400 * 400)...now.addingTimeInterval(86400) + let referenceDateCandidate = Date(timeIntervalSinceReferenceDate: interval) + if plausibleWindow.contains(referenceDateCandidate) { + return referenceDateCandidate + } + let unixEpochCandidate = Date(timeIntervalSince1970: interval) + if plausibleWindow.contains(unixEpochCandidate) { + return unixEpochCandidate + } + return nil + } +} diff --git a/Bedtime/Bedtime/Utils/KVCSafeAccessor.h b/Bedtime/Bedtime/Utils/KVCSafeAccessor.h new file mode 100644 index 0000000..2afc9e7 --- /dev/null +++ b/Bedtime/Bedtime/Utils/KVCSafeAccessor.h @@ -0,0 +1,24 @@ +// +// KVCSafeAccessor.h +// Bedtime +// +// Guards reads of undocumented Key-Value Coding properties (e.g. HKSample's private +// "creationTimestamp") against crashing if the key is ever removed or renamed. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Swift's `NSObject.value(forKey:)` does not catch Objective-C exceptions, so an undefined +/// KVC key raises `NSUndefinedKeyException` and crashes the app. This trampoline exists purely +/// to make undocumented/private KVC reads safe to attempt speculatively. +@interface KVCSafeAccessor : NSObject + +/// Returns `[object valueForKey:key]`, or `nil` if that raises any exception (e.g. the key is +/// undefined) instead of crashing. ++ (nullable id)safeValue:(NSString *)key forObject:(id)object NS_SWIFT_NAME(safeValue(_:for:)); + +@end + +NS_ASSUME_NONNULL_END diff --git a/Bedtime/Bedtime/Utils/KVCSafeAccessor.m b/Bedtime/Bedtime/Utils/KVCSafeAccessor.m new file mode 100644 index 0000000..f213dad --- /dev/null +++ b/Bedtime/Bedtime/Utils/KVCSafeAccessor.m @@ -0,0 +1,18 @@ +// +// KVCSafeAccessor.m +// Bedtime +// + +#import "KVCSafeAccessor.h" + +@implementation KVCSafeAccessor + ++ (nullable id)safeValue:(NSString *)key forObject:(id)object { + @try { + return [object valueForKey:key]; + } @catch (NSException *exception) { + return nil; + } +} + +@end diff --git a/README.md b/README.md index f01840d..101eb9e 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,10 @@ A native iOS app that helps optimize your sleep by tracking your "sleep bank" an - Visual representation of your sleep patterns - Automatically filters out duplicate data from sources that occasionally re-sync a night they already wrote (e.g. Oura, after falling back asleep and syncing again), so a - double-synced night doesn't inflate sleep totals. Since HealthKit only lets an app delete - data it wrote itself, this is done by excluding the older, superseded sync from Bedger's - own calculations rather than deleting anything from HealthKit — see - `SleepSampleDeduplicator` + double-synced night doesn't inflate sleep totals. Detects the older, superseded sync using + the same "Date Added" signal the Health app shows, and excludes it from Bedger's own + calculations. Since HealthKit only lets an app delete data it wrote itself, this can't + delete anything from HealthKit — see `SleepSampleDeduplicator` ### ⚙️ Customizable Settings - Set your personal sleep goal (6-12 hours, in 15 minute steps)