Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
60a2052
whoop export times were read in the phone's zone, not the export's
abdulsaheel Oct 17, 2026
e13f902
a failing sleep write held every other metric out of health connect
abdulsaheel Oct 17, 2026
f061ade
whoop import filed the day's total burn as active energy
abdulsaheel Oct 17, 2026
a15a5c9
a non-utf-8 csv aborted the whole import instead of reaching the vend…
abdulsaheel Oct 17, 2026
414583e
stop a no-night day's apple sleep delete from wiping the next night
abdulsaheel Oct 17, 2026
c086a07
health import said the store was empty when the read failed
abdulsaheel Oct 17, 2026
1306253
apple health active energy was missing every workout's calories
abdulsaheel Oct 22, 2026
2f5e101
keep already-banked phone steps for an hour that turns uncovered
abdulsaheel Oct 22, 2026
511485e
scoring an imported workout no longer exports a second copy to health
abdulsaheel Oct 22, 2026
02d4fe9
re-importing a whoop export reverted workout edits and brought back d…
abdulsaheel Oct 22, 2026
1418b8c
imported noop rr beats were reshuffled within each second
abdulsaheel Oct 28, 2026
b249750
one bad unix_s row in a noop csv could strand or blank the rest of th…
abdulsaheel Oct 28, 2026
8bf13d6
noop imports skipped the hr/rr plausibility gate
abdulsaheel Oct 28, 2026
22b5da0
workouts deleted in apple health or health connect stayed in the history
abdulsaheel Oct 28, 2026
8fc92ba
whoop-imported workouts reached health as other
abdulsaheel Oct 28, 2026
c0f98c2
apple health route import stored gps tracks for workouts it had dropped
abdulsaheel Oct 28, 2026
fca5e10
close the picked zip when the importer refuses it
abdulsaheel Oct 28, 2026
68ef04c
apple health sleep export deleted the previous night when it woke aft…
abdulsaheel Oct 28, 2026
3fbf527
re-importing a whoop export duplicated workouts recorded in another zone
abdulsaheel Oct 28, 2026
e520978
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 28, 2026
b601db1
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 28, 2026
a3f2e4c
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 28, 2026
4273817
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 28, 2026
cef06e1
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 29, 2026
ba4787d
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 29, 2026
c30a14c
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 29, 2026
a63ce39
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 29, 2026
d9da285
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 29, 2026
748c615
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 29, 2026
898354e
noop plausibility test: match deriveImportedDays' reset generation pa…
abdulsaheel Oct 29, 2026
206f586
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 29, 2026
842d680
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 30, 2026
758a347
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 30, 2026
6de9d54
Merge branch 'fix/sleep-recovery-and-scores' into fix/health-export-a…
abdulsaheel Oct 30, 2026
e4530d6
skip reading yesterday's bundle on android days that have a night
abdulsaheel Oct 30, 2026
a507a0f
name a journal or lab export that isn't utf-8 instead of dropping it
abdulsaheel Oct 30, 2026
e74472e
whoop re-import: clear the old calories row, don't duplicate a moved …
abdulsaheel Oct 30, 2026
fb0a8d2
noop import: refuse timestamps more than two days ahead
abdulsaheel Oct 30, 2026
53cc28f
stop rewinding the day export when a workout is made private
abdulsaheel Oct 30, 2026
b55ec8c
keep a moved whoop workout deleted, and keep the encoding note on a f…
abdulsaheel Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3161,6 +3161,41 @@ class LocalDb {
});
}

/// Drop every imported workout that starts inside [[startSec], [endSec]]
/// but whose uuid is not in [present], AND its route, like
/// [deleteImportedWorkout]. Returns how many went.
///
/// The window is the one the store was just read over: a uuid it no longer
/// returns there was deleted in the health app, and the upsert alone would
/// keep listing it (and counting it toward the week) forever. Rows outside
/// the window are left alone, since the read says nothing about them.
static Future<int> deleteImportedWorkoutsMissing(
int startSec,
int endSec,
Set<String> present,
) async {
final db = await instance;
return db.transaction((txn) async {
final rows = await txn.query(
'imported_workout',
columns: ['uuid'],
where: 'start_ts >= ? AND start_ts <= ?',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
whereArgs: [startSec, endSec],
);
final gone = [
for (final r in rows)
if (!present.contains(r['uuid'])) r['uuid'] as String,
];
for (final u in gone) {
await txn.delete('imported_workout',
where: 'uuid = ?', whereArgs: [u]);
await txn.delete('workout_route',
where: 'session_id = ?', whereArgs: [u]);
}
return gone.length;
});
}

// ── EXTERNAL HEART-RATE SENSOR (0x180D) ─────────────────────────────────────
/// What a standard Bluetooth heart-rate sensor delivered during ONE workout.
///
Expand Down Expand Up @@ -3742,6 +3777,27 @@ class LocalDb {
return (r.first['s'] as num?)?.toInt() ?? 0;
}

/// [day]'s banked phone windows, so a re-sync can keep the ones for an hour
/// the sensor no longer covers (see `PhonePedometer.syncDay`).
static Future<List<({int startTs, int endTs, int steps})>>
phoneCoverageForDay(String day) async {
final db = await instance;
final rows = await db.query(
'live_coverage',
columns: ['start_ts', 'end_ts', 'steps'],
where: 'day = ? AND source = ?',
whereArgs: [day, kStepSourcePhone],
);
return [
for (final r in rows)
(
startTs: (r['start_ts'] as num).toInt(),
endTs: (r['end_ts'] as num).toInt(),
steps: (r['steps'] as num).toInt(),
),
];
}

/// Drop every phone-sourced coverage row (the user turned phone steps off).
/// Band rows are untouched, so days fall back to the band count.
static Future<int> clearPhoneCoverage() async {
Expand Down Expand Up @@ -12446,6 +12502,35 @@ class LocalDb {
});
}

/// Move a session's window. Not `putSession`: the row's type correction,
/// RPE and private flag must survive. What was measured over the OLD window
/// (the frozen HR trace, average HR, zone minutes) belongs to other seconds,
/// so it goes; a re-score fills it again if the new window is still on
/// record. Returns the number of rows changed.
static Future<int> setSessionWindow(String id, int startTs, int? endTs) async {
final db = await instance;
return db.transaction((tx) async {
final n = await tx.update(
'sessions',
{
'start_ts': startTs,
'end_ts': endTs,
'trace_json': null,
'trace_samples': null,
'avg_hr': null,
'zone_min_json': null,
},
where: 'id = ?',
whereArgs: [id],
);
if (n > 0) {
await ActivityStore.supersede(tx, ActivityKind.workout, startTs,
endTs ?? DateTime.now().millisecondsSinceEpoch ~/ 1000);
}
return n;
});
}

/// Update ONLY a session's derived score columns.
///
/// Deliberately not `putSession`: that is INSERT-OR-REPLACE over the whole
Expand Down Expand Up @@ -12487,6 +12572,17 @@ class LocalDb {
);
}

/// Whether a [source] session already starts at [startTs], under any id.
static Future<bool> hasSessionStartingAt(String source, int startTs) async {
final db = await instance;
final rows = await db.query('sessions',
columns: ['id'],
where: 'source = ? AND start_ts = ?',
whereArgs: [source, startTs],
limit: 1);
return rows.isNotEmpty;
}

static Future<Map<String, dynamic>?> session(String id) async {
final db = await instance;
final rows = await db.query(
Expand Down
2 changes: 2 additions & 0 deletions lib/data/local_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,12 @@ abstract class LocalRepository {
/// detected, or detected too narrowly. Scored from the 1 Hz substrate over
/// [startTs, endTs] (epoch SECONDS); a window with no substrate is still
/// saved, just unscored. Returns `{workout_id, unscored, hr_samples}`.
/// [source] is stamped on a NEW row only; an update keeps the row's own.
Future<Map<String, dynamic>> logManualWorkout({
required int startTs,
required int endTs,
required String type,
String source = 'manual',
}) => throw UnimplementedError('re-layer: logManualWorkout');

/// Retime an existing session and re-score it over the new window. Used to
Expand Down
21 changes: 12 additions & 9 deletions lib/data/local_repository_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import 'db.dart';
import 'activity_store.dart';
import '../models/activity_suggestion.dart';
import '../health/health_export.dart';
import '../import/whoop_import.dart' show WhoopImporter;
import 'journal_fields.dart';
import '../ble/adapters/_registry.dart' show kBandRegistry;
import 'local_repository.dart';
Expand Down Expand Up @@ -2833,22 +2834,22 @@ class LocalRepositoryImpl extends LocalRepository {
int? _profileAge() => (getProfileMap()?['age'] as num?)?.round();

@override
Future<void> deleteWorkout(String id) async => LocalDb.deleteSession(id);
Future<void> deleteWorkout(String id) async {
// Tombstone first: a failed delete then only leaves a row the importer
// already skips, while the other order's failure brings it back.
if (id.startsWith(WhoopImporter.workoutIdPrefix)) {
await WhoopImporter.rememberDeletedWorkout(id,
startTs: ((await LocalDb.session(id))?['start_ts'] as num?)?.toInt());
}
await LocalDb.deleteSession(id);
}

@override
Future<void> setWorkoutPrivate(String id, bool private) async {
await LocalDb.setSessionPrivate(id, private);
// The workout was already exported when it stopped, before this flag
// landed. Re-export: a private row clears its window and writes nothing.
unawaited(HealthExporter.exportWorkoutId(id));
// The day's active energy has exported workouts' calories taken off, so
// it changes too, and a day behind the export cursor is never rewritten.
final start = ((await LocalDb.session(id))?['start_ts'] as num?)?.toInt();
if (start != null) {
unawaited(HealthExporter.reexportFrom(
dayLabelOf(DateTime.fromMillisecondsSinceEpoch(start * 1000)),
));
}
}

@override
Expand Down Expand Up @@ -2916,6 +2917,7 @@ class LocalRepositoryImpl extends LocalRepository {
required int startTs,
required int endTs,
required String type,
String source = 'manual',
}) async {
// A manual row's id is derived from its start second, so re-logging the
// IDENTICAL window is an update of that row, not a collision with it. A
Expand Down Expand Up @@ -2951,6 +2953,7 @@ class LocalRepositoryImpl extends LocalRepository {
existing: same ? prior : null,
sessionId:
moved ? '$id:${DateTime.now().millisecondsSinceEpoch}' : null,
source: source,
validateAgainstId: same
? prior['id'] as String
: (prior == null && !moved ? id : null),
Expand Down
3 changes: 3 additions & 0 deletions lib/health/auto_workout_import.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ class AutoWorkoutImport {
}
return AutoImportOutcome.ran;
} catch (_) {
// A read that threw is not a pass that ran. The stamp above still holds
// it off for the interval, so a store refusing on quota is not hammered
// on every foreground.
return AutoImportOutcome.skipped;
}
}
Expand Down
Loading
Loading