diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 22b93dbd..6b3b6863 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -64,6 +64,13 @@
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index c59e3b85..75b3d356 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -61,6 +61,15 @@
NSBluetoothAlwaysUsageDescription
OpenStrap connects to your WHOOP band over Bluetooth to sync your health data.
+
+ UIFileSharingEnabled
+
+ LSSupportsOpeningDocumentsInPlace
+
NSHealthShareUsageDescription
OpenStrap reads your step count from Apple Health to show your daily steps, and reads back its own recent samples so it never writes duplicates.
NSHealthUpdateUsageDescription
diff --git a/lib/app.dart b/lib/app.dart
index 241d9247..98110341 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -88,6 +88,11 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver
// already-running process (openAppWhenRun doesn't guarantee a fresh
// launch) — the constructor-time check alone would miss that case.
unawaited(app.checkPendingSiriRoute());
+ // Backups run on foreground, when due — there is no background scheduler
+ // that works on both platforms, and a schedule that claims "daily" while
+ // delivering whenever the OS feels like it is worse than one that is
+ // honest about when it fires.
+ unawaited(app.runBackupIfDue());
if (app.isPaired) app.openSession();
} else if (state == AppLifecycleState.paused) {
// Backgrounded: hand the band to the iOS restore path so it can wake-and-drain
diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index 5e0fcc8a..b568d680 100644
--- a/lib/compute/derivation_engine.dart
+++ b/lib/compute/derivation_engine.dart
@@ -26,6 +26,7 @@ import 'dart:isolate';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
+import 'nap_edits.dart';
import 'package:openstrap_analytics/onehz.dart' as ana;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_performance/firebase_performance.dart';
@@ -608,7 +609,21 @@ import 'substrate.dart';
// O(window) sum that ran before its cadence gate was checked. Both curves keep
// their sampling intent; points that were previously emitted a beat or two
// after a failed attempt now land on the next cadence tick instead.
-const int kAlgoVersion = 60;
+// v61 - NAP EDITS. The nap detector's answer is now a PROPOSAL: a nap the user
+// logged is added, and one they rejected is suppressed, replayed over the
+// detector's output on every derivation rather than written into it (so a
+// better detector later still respects "there was no nap here"). Rejection
+// matches by OVERLAP, not by exact bounds, because the detector's boundaries
+// shift between runs and an edit that stopped applying when a boundary moved
+// by a minute would be worse than useless.
+//
+// This moves numbers, which is why it is a version bump rather than a read
+// path: `nap_min` is summed over the merged list, so a logged nap credits
+// against sleep need and sleep debt exactly as a detected one does — that
+// was the explicit product decision, not an accident of where the code sat.
+// Days carrying an edit are force-derived alongside sleep-override days, so
+// an edit to an already-finalized day actually takes effect.
+const int kAlgoVersion = 61;
// Fold idempotency, the minimum-nights warm-up, and legacy-payload handling
// all live in SleepProfilePolicy (pure, unit-tested) — see
@@ -1046,7 +1061,11 @@ class DerivationEngine {
// FINALIZED (locked) day — it's the user's word. Force those back into the
// todo set. (No-raw days are guarded in the per-day loop so we never
// clobber a good manual result with an empty re-derive once raw is pruned.)
- final overrideDays = await LocalDb.sleepOverrideDays();
+ final overrideDays = {
+ ...await LocalDb.sleepOverrideDays(),
+ // A nap edit on a finalized day has to take effect too — same reason.
+ ...await LocalDb.napEditDays(),
+ };
final todoDays = [
for (final day in scope.targetDays)
if (!finalized.contains(day) || overrideDays.contains(day)) day,
@@ -2416,9 +2435,22 @@ class DerivationEngine {
// Built on THIS isolate so the Isolate.run closure captures only this plain
// sendable object (never `this`, `day`, or `bundle`).
+ // Read HERE, on the main isolate — the worker has no database.
+ final napEdits = [
+ for (final row in await LocalDb.napEdits(day.date))
+ NapEdit(
+ kind: row['source'] == 'rejected'
+ ? NapEditKind.rejected
+ : NapEditKind.added,
+ startSec: (row['start_ts'] as num).toInt(),
+ endSec: (row['end_ts'] as num).toInt(),
+ ),
+ ];
+
final blocksInput = _DayBlocksInput(
daySub: daySub,
napSub: day.napSub,
+ napEdits: napEdits,
sleepSub: sleepSub,
profile: profile,
onsetSec: day.sleepOnsetSec,
@@ -4333,6 +4365,57 @@ class DerivationEngine {
/// distinguishable only by HOW the abstention happened. A reader that checks
/// `bundle['naps']?['value'] == null` and one that checks
/// `bundle.containsKey('naps')` would disagree.
+ /// Abstention path that still honours what the USER logged.
+ ///
+ /// The detector abstains on exactly the days this feature exists for — strap
+ /// off for part of the afternoon, a short record, a failure. Returning early
+ /// there dropped every logged nap on the floor: no card to see, no minutes
+ /// credited, and no way to delete the row the user had just created, while
+ /// the edit kept force-re-deriving that day forever.
+ ///
+ /// A logged nap needs nothing from the detector — it carries its own absolute
+ /// bounds — so it is published on its own. The day is still reported as
+ /// unjudged when the user logged nothing, because that is what it is.
+ static List