Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
<key>NSBluetoothAlwaysUsageDescription</key>
<string>OpenStrap connects to your WHOOP band over Bluetooth to sync your health data.</string>
<key>NSHealthShareUsageDescription</key>
<string>OpenStrap reads recent samples to avoid writing duplicates into Apple Health.</string>
<string>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.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>OpenStrap writes your sleep, resting heart rate, HRV, respiratory rate, energy and workouts into Apple Health.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
Expand Down
507 changes: 390 additions & 117 deletions lib/compute/derivation_engine.dart

Large diffs are not rendered by default.

86 changes: 86 additions & 0 deletions lib/compute/movement_floor_policy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/// PURE policy for the frozen personal movement floor.
///
/// The floor is a SINGLE persisted personal scalar, not a per-day value: once
/// committed it is applied to every day, past and future. That is the whole
/// point of freezing it — a floor derived from the signal it thresholds cancels
/// the trend it exists to report if it keeps tracking the user (measured on real
/// substrate: 37 active minutes at 1x, 1.5x, 2x AND 3x activity when
/// recomputed, versus 23 -> 254 frozen).
///
/// Because it is one shared scalar, resolving it is a READ-MODIFY-WRITE against
/// state every day of a sweep touches. `DerivationEngine.run()` dispatches days
/// NEWEST-FIRST through a concurrent worker pool, so the decisions below have to
/// be order-independent or the frozen floor is decided by a race. These helpers
/// are pure so that property is unit-testable without a database.
library;

import '../data/day_label.dart';

/// The `YYYY-MM-DD` label [back] calendar days before [dayId].
///
/// CALENDAR arithmetic, never `Duration`. `DateTime.subtract(Duration(days: n))`
/// is ABSOLUTE: from local midnight on 2026-03-10 (US), subtracting 24 h lands
/// at 23:00 on 2026-03-07 because 2026-03-08 was only 23 h long — so the walk
/// SKIPS 2026-03-08 entirely and the caller mis-counts the gap. Feeding an
/// out-of-range day field to the `DateTime` constructor normalises correctly.
String? dayLabelBefore(String dayId, int back) {
final d = DateTime.tryParse(dayId);
if (d == null) return null;
return dayLabelOf(DateTime(d.year, d.month, d.day - back));
}

/// Consecutive days immediately before [dayId] with no entry in [have].
///
/// A missing `dyn_p90` daily summary means the band produced no usable motion
/// that day, i.e. it was not worn. Used only as a re-freeze trigger: a long gap
/// suggests the body/device relationship may have changed enough that the frozen
/// floor should be re-estimated.
///
/// Returns 0 when [have] is empty — an empty history is "no information", not "a
/// 60-day gap", and must not be allowed to trigger a re-freeze.
int wearGapDays({
required Set<String> have,
required String dayId,
int maxScan = 60,
}) {
if (have.isEmpty) return 0;
var gap = 0;
for (var back = 1; back <= maxScan; back++) {
final label = dayLabelBefore(dayId, back);
if (label == null) return gap;
if (have.contains(label)) break;
gap++;
}
return gap;
}

/// Age of the frozen floor as seen from [dayId], NEVER negative.
///
/// A day BEFORE the freeze date is not a stale floor — it is a backfill. The
/// previous `.abs()` made every historical re-derive look maximally stale, which
/// matters because a `kAlgoVersion` bump re-derives days newest-first: walking
/// backwards past `maxAgeDays` tripped the staleness rule and re-froze the
/// shared floor onto an OLDER `frozenOn`, which could then trip again on the
/// next real derive. Clamping to 0 makes a backfill day simply consume the
/// stored floor, which is what "frozen" means.
int daysSinceFrozen({required String frozenOn, required String dayId}) {
final from = DateTime.tryParse(frozenOn);
final to = DateTime.tryParse(dayId);
if (from == null || to == null) return 0;
final diff = to.difference(from).inDays;
return diff > 0 ? diff : 0;
Comment on lines +66 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 'daysSinceFrozen|difference\(from\)\.inDays' \
  lib/compute/movement_floor_policy.dart

rg -n -C 5 'spring-forward|daysSinceFrozen|Duration\(days' \
  test/movement_floor_policy_test.dart

fd -HI -t f -e yml -e yaml -e sh . |
while IFS= read -r file; do
  rg -n 'TZ=|flutter test|movement_floor_policy_test' "$file" || true
done

Repository: OpenStrap/edge

Length of output: 2927


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## movement_floor_policy.dart outline"
ast-grep outline lib/compute/movement_floor_policy.dart || true

echo "## movement_floor_policy.dart relevant sections"
sed -n '1,140p' lib/compute/movement_floor_policy.dart | cat -n

echo "## movement_floor_policy_test.dart relevant sections"
sed -n '1,115p' test/movement_floor_policy_test.dart | cat -n

echo "## timezone config search"
rg -n --hidden --glob '!**/.git/**' 'TZ=|dart test|flutter test|timezone|America/|America/New_York|UTC' . || true

echo "## dart availability"
command -v dart || true
dart --version || true

Repository: OpenStrap/edge

Length of output: 20576


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## search for daysSinceFrozen callers"
rg -n --hidden --glob '!**/.git/**' 'daysSinceFrozen\(' . || true

echo "## test config around movement_floor_policy_test"
sed -n '1,110p' .github/workflows/test.yml | cat -n
sed -n '80,108p' test/day_window_dst_test.dart | cat -n

echo "## inspect day_label implementation"
sed -n '1,120p' lib/data/day_label.dart | cat -n

echo "## deterministic Dart DateTime duration probe from Dart source behavior"
# Probe only if Dart is available so this does not execute repository code.
if command -v dart >/dev/null 2>&1; then
  TZ=America/New_York dart - <<'DART' || true
void main() {
  final from = DateTime.tryParse('2026-03-01');
  final to = DateTime.tryParse('2026-03-08');
  final toDsr = DateTime.tryParse('2026-03-09');
  print('from=$from zone=${from.timeZoneOffset}');
  print('to=$to');
  print('difference(${to.difference(from)}) inDays=${to.difference(from).inDays}');
  print('toDst=$toDsr');
  print('difference=${toDsr.difference(to).inDays}');
  print('utc diff=${DateTime.utc(toDsr.year,toDsr.month,toDsr.day).difference(DateTime.utc(to.year,to.month,to.day)).inDays}');
}
DART
else
  echo "dart not available; reading Dart docs in next step"
fi

Repository: OpenStrap/edge

Length of output: 9822


🌐 Web query:

Dart documentation DateTime inDays Duration days property rounds toward zero example local DST

💡 Result:

In Dart, the Duration.inDays property returns the number of entire days spanned by a Duration object [1][2]. The value is calculated using integer division (~/) of the duration's total microseconds by the number of microseconds in a day [1][3]. This effectively truncates the result toward zero, meaning it returns the number of whole days and discards any remaining time (hours, minutes, etc.) [4][5][6]. For example: - A duration of 4 days and 3 hours will return 4 for inDays [1][2]. - A duration of 88 hours (3 days and 16 hours) will return 3 for inDays [4][6]. Regarding Daylight Saving Time (DST) and DateTime calculations: The Duration class represents a fixed span of time independent of calendar or time zone context [5]. When using DateTime.difference to calculate a duration between two points in local time, the result is the absolute difference in microseconds [7][8]. Because DST changes can cause a day to have 23 or 25 hours rather than 24, calculating the difference between two calendar dates in local time may not yield a whole multiple of 24 hours [9][7]. If a DST transition causes a period to be shorter than a full 24-hour day (e.g., 23 hours), Duration.inDays will return 0 for that period because the time span is less than one complete day [9]. If you need to count calendar days regardless of DST shifts, it is recommended to perform calculations using UTC time instead of local time [9].

Citations:


🌐 Web query:

Dart DateTime inDays Duration property rounds toward zero example local DST

💡 Result:

In Dart, the Duration.inDays property retrieves the number of entire days in a duration by performing integer division of the total microseconds by the number of microseconds in a day [1][2]. Consequently, it effectively truncates toward zero—it always rounds down to the nearest whole number (e.g., a duration of 4 days and 23 hours returns 4) [1][2]. Because Duration is a context-independent measurement of elapsed time—where 1 day is strictly defined as 24 hours—it does not account for calendar-based changes like Daylight Saving Time (DST) [2][3][4]. When you use DateTime.difference on local times that cross a DST transition, the resulting Duration may not contain a multiple of 24 hours, leading to behavior that can appear "incorrect" if you are expecting calendar days [5][6][4]. Example of DST impact on inDays: If you calculate the difference between two midnights in a local time zone that transitions to DST, the elapsed time might be 23 hours instead of 24. Even though it is "one calendar day" apart, Duration.inDays will return 0 because it has not yet reached the 24-hour threshold [5][4][7]. Example of rounding: const duration = Duration(hours: 47); print(duration.inDays); // Output: 1 (47 hours / 24 = 1.958, rounded down to 1) [1][2] To perform calendar-aware arithmetic that respects DST or standard calendar days, it is recommended to use UTC time to avoid local zone shifts or to manually handle day increments using the DateTime constructor [5][6][3][8].

Citations:


Use UTC calendar fields for daysSinceFrozen.

daysSinceFrozen uses to.difference(from).inDays on local-midnight instants. Near spring-forward, one calendar day can be 23 hours, so inDays reports 0 and delays the re-freeze age check. Subtract the constructed UTC calendar days before clamping at zero. The caller from DerivationEngine.run() reads this age during newest-first sweeps.

Proposed fix
-  final diff = to.difference(from).inDays;
+  final fromDay = DateTime.utc(from.year, from.month, from.day);
+  final toDay = DateTime.utc(to.year, to.month, to.day);
+  final diff = toDay.difference(fromDay).inDays;
   return diff > 0 ? diff : 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
int daysSinceFrozen({required String frozenOn, required String dayId}) {
final from = DateTime.tryParse(frozenOn);
final to = DateTime.tryParse(dayId);
if (from == null || to == null) return 0;
final diff = to.difference(from).inDays;
return diff > 0 ? diff : 0;
int daysSinceFrozen({required String frozenOn, required String dayId}) {
final from = DateTime.tryParse(frozenOn);
final to = DateTime.tryParse(dayId);
if (from == null || to == null) return 0;
final fromDay = DateTime.utc(from.year, from.month, from.day);
final toDay = DateTime.utc(to.year, to.month, to.day);
final diff = toDay.difference(fromDay).inDays;
return diff > 0 ? diff : 0;
📍 Affects 2 files
  • lib/compute/movement_floor_policy.dart#L66-L71 (this comment)
  • test/movement_floor_policy_test.dart#L11-L18
  • test/movement_floor_policy_test.dart#L70-L86
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/movement_floor_policy.dart` around lines 66 - 71, Update
daysSinceFrozen in lib/compute/movement_floor_policy.dart: construct the parsed
dates from their UTC calendar fields, subtract those UTC calendar dates, then
clamp negative results to zero. Update the related coverage in
test/movement_floor_policy_test.dart at lines 11-18 and 70-86 to verify
calendar-day behavior across daylight-saving boundaries and preserve the
non-negative result contract.

Source: Coding guidelines

}

/// May [dayId] commit (or re-commit) the shared floor?
///
/// A day may only move the floor FORWARD in time. Without this, a backfill day
/// in a newest-first sweep could overwrite a freeze that a newer day had just
/// established, making the persisted floor — and therefore every day's
/// `active_min` — depend on which worker in the pool finished last.
///
/// This is the same principle `_BaselineHistoryCache.valuesBefore` already
/// states for baselines: a sweep must not make the result depend on sweep order.
bool mayCommitFloorOn({required String? frozenOn, required String dayId}) {
if (frozenOn == null) return true;
return dayId.compareTo(frozenOn) >= 0;
}
186 changes: 163 additions & 23 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import 'dart:convert';
import 'dart:io';

import 'package:openstrap_analytics/onehz.dart' as ana;
import 'package:openstrap_protocol/openstrap_protocol.dart' as proto;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
Expand Down Expand Up @@ -91,7 +90,7 @@ class LocalDb {
/// pass it: sqflite throws `ArgumentError('onCreate must be null if no
/// version is specified')` BEFORE opening anything when `onCreate` is given
/// without `version` (sqflite_common database_mixin.dart).
static const int schemaVersion = 26;
static const int schemaVersion = 27;

/// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` —
/// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)`
Expand Down Expand Up @@ -391,11 +390,23 @@ class LocalDb {
// use by FiredKeyStore, so nothing is lost on upgrade.
await _createNotifFired(db);
}
if (oldV < 27) {
// `live_coverage` gains a `source` column so a phone-pedometer count
// can be told apart from the band's 100 Hz wrist count. Existing rows
// default to 'band', which is what they are.
//
// This matters because the two sources must NEVER be summed: they
// both count the same walk from different places on the body. The
// reader prefers phone rows for a day when any exist (a
// pocket-carried pedometer sees gait; a wrist one confuses arm work
// for steps), and falls back to band rows otherwise.
await _ensureLiveCoverageSource(db);
}
},
onOpen: (db) async {
await _repairOpenSchema(db);
},
version: 26,
version: schemaVersion,
);
}

Expand Down Expand Up @@ -435,6 +446,7 @@ class LocalDb {
await db.execute('DROP INDEX IF EXISTS $ix');
}
await _createLiveCoverage(db);
await _ensureLiveCoverageSource(db);
await _createCycleSymptom(db);
await _ensureSessionSchema(db);
await _ensureSyncStateSchema(db);
Expand Down Expand Up @@ -755,14 +767,34 @@ class LocalDb {
start_ts INTEGER NOT NULL,
end_ts INTEGER NOT NULL,
steps INTEGER NOT NULL,
day TEXT NOT NULL
day TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '$kStepSourceBand'
)
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_live_coverage_day ON live_coverage(day)',
);
}

/// Ensure `live_coverage.source` exists (v27).
///
/// Uses the shared guarded helper — an unguarded ALTER TABLE on an
/// already-migrated db bricks the upgrade (that has bitten this file twice).
static Future<void> _ensureLiveCoverageSource(Database db) async {
await _addColumnIfMissing(
db,
'live_coverage',
'source',
"TEXT NOT NULL DEFAULT '$kStepSourceBand'",
);
}

/// Step-count provenance for a `live_coverage` row.
///
/// These are never summed together — see [liveStepsForDay].
static const String kStepSourceBand = 'band'; // band 100 Hz AN-2554 (wrist)
static const String kStepSourcePhone = 'phone'; // phone pedometer (pocket)

/// Record a real 100 Hz step window (device-time seconds) + its step count.
///
/// The window is normalised by [sanitizeCoverageWindow] first: a zero-width
Expand All @@ -777,8 +809,9 @@ class LocalDb {
int startTs,
int endTs,
int steps,
String day,
) async {
String day, {
String source = kStepSourceBand,
}) async {
final w = sanitizeCoverageWindow(startTs, endTs, steps);
if (w == null) return;
final db = await instance;
Expand All @@ -787,6 +820,37 @@ class LocalDb {
'end_ts': w.endTs,
'steps': steps,
'day': day,
'source': source,
});
}

/// Replace ALL phone-pedometer rows for [day] with [windows], atomically.
///
/// Phone step data is a re-readable snapshot, not an append-only stream: the
/// same day can be synced repeatedly as it fills in. So the phone sync is
/// delete-then-insert scoped to `source = 'phone'`, which is idempotent by
/// construction and needs no window-clipping. Band rows are untouched.
static Future<void> replacePhoneCoverageForDay(
String day,
List<({int startTs, int endTs, int steps})> windows,
) async {
final db = await instance;
await db.transaction((txn) async {
await txn.delete(
'live_coverage',
where: 'day = ? AND source = ?',
whereArgs: [day, kStepSourcePhone],
);
for (final w in windows) {
if (w.steps <= 0 || w.endTs <= w.startTs) continue;
await txn.insert('live_coverage', {
'start_ts': w.startTs,
'end_ts': w.endTs,
'steps': w.steps,
'day': day,
'source': kStepSourcePhone,
});
}
});
}

Expand All @@ -806,27 +870,86 @@ class LocalDb {
return r.isNotEmpty;
}

/// Real (100 Hz) steps attributed to [day].
/// Phone-sourced steps already banked for [day].
///
/// Used by the pedometer sync to tell "this day really had no steps" from
/// "this read came back empty" before it replaces a day wholesale — see
/// [replacePhoneCoverageForDay], which is delete-then-insert.
static Future<int> phoneStepsForDay(String day) async {
final db = await instance;
final r = await db.rawQuery(
'SELECT COALESCE(SUM(steps),0) s FROM live_coverage '
'WHERE day = ? AND source = ?',
[day, kStepSourcePhone],
);
return (r.first['s'] as num?)?.toInt() ?? 0;
}

/// 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 {
final db = await instance;
return db.delete(
'live_coverage',
where: 'source = ?',
whereArgs: [kStepSourcePhone],
);
}

/// Real pedometer steps attributed to [day], from ONE source.
///
/// Phone and band counts are never added together: both count the same walk,
/// one from the pocket and one from the wrist, so summing them roughly
/// doubles a day. When the phone has any data for the day it wins outright —
/// a pocket/waist pedometer observes trunk motion (real gait), whereas a
/// wrist one is documented emitting 22-27 false steps/min during dishes,
/// reaching and driving while missing slow walking (O'Connell 2017,
/// doi:10.1371/journal.pone.0169616). Band rows are the fallback.
static Future<int> liveStepsForDay(String day) async {
final db = await instance;
final r = await db.rawQuery(
'SELECT COALESCE(SUM(steps),0) s FROM live_coverage WHERE day = ?',
'SELECT source, COALESCE(SUM(steps),0) s FROM live_coverage '
'WHERE day = ? GROUP BY source',
[day],
);
return (r.first['s'] as num?)?.toInt() ?? 0;
var band = 0;
var phone = 0;
for (final row in r) {
final n = (row['s'] as num?)?.toInt() ?? 0;
if (row['source'] == kStepSourcePhone) {
phone += n;
} else {
band += n;
}
}
return phone > 0 ? phone : band;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Coverage windows ([startSec, endSec]) overlapping [loSec, hiSec) — used to
/// exclude already-counted minutes from the 1 Hz estimate.
/// Coverage windows ([startSec, endSec]) overlapping [loSec, hiSec), for ONE
/// [source] (band by default).
///
/// The 1 Hz-estimate exclusion this originally served is gone along with the
/// estimator. Its only remaining caller is the NOOP importer, which reads back
/// the spans it has already banked so `stepRuns` can clip them out and a
/// re-import over an overlapping span cannot double-count.
///
/// THE SOURCE FILTER IS LOAD-BEARING for that caller. Phone-pedometer rows now
/// share this table and cover the same wall-clock hours, so an unfiltered read
/// let a user with phone steps enabled import a NOOP backup whose BAND step
/// runs were clipped against the PHONE's windows and silently dropped — the
/// import reporting success while banking nothing for those days. Band clips
/// against band. Phone coverage needs no clipping at all: it is replaced
/// wholesale per day (see [replacePhoneCoverageForDay]).
static Future<List<List<int>>> coverageWindowsOverlapping(
int loSec,
int hiSec,
) async {
int hiSec, {
String source = kStepSourceBand,
}) async {
final db = await instance;
final rows = await db.query(
'live_coverage',
where: 'end_ts >= ? AND start_ts < ?',
whereArgs: [loSec, hiSec],
where: 'end_ts >= ? AND start_ts < ? AND source = ?',
whereArgs: [loSec, hiSec, source],
);
return [
for (final r in rows)
Expand Down Expand Up @@ -3823,22 +3946,39 @@ class LocalDb {
return (rows.first['value'] as num?)?.toDouble();
}

static Future<ana.StepCalibration?> getStepCalibration() async {
final row = await baseline('step_calibration');
/// The FROZEN personal movement floor (g, dynAmp units) + when it was frozen.
///
/// Persisted rather than recomputed because a floor that keeps tracking the
/// user cancels the trend it exists to report — see the derivation-engine
/// comment for the measured before/after. Returns null until enrollment
/// completes, which is the estimator's signal to abstain.
static Future<({double floorG, String frozenOn, int days})?>
getMovementFloor() async {
final row = await baseline('movement_floor');
final raw = row?['payload_json'];
if (raw is! String || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
return decoded is Map
? ana.StepCalibration.fromJson(decoded.cast<String, dynamic>())
: null;
final d = jsonDecode(raw);
if (d is! Map) return null;
final f = (d['floor_g'] as num?)?.toDouble();
final on = d['frozen_on'] as String?;
if (f == null || !f.isFinite || f <= 0 || on == null) return null;
return (floorG: f, frozenOn: on, days: (d['days'] as num?)?.toInt() ?? 0);
} catch (_) {
return null;
}
}

static Future<void> putStepCalibration(ana.StepCalibration calibration) =>
putBaseline('step_calibration', jsonEncode(calibration.toJson()));
static Future<void> putMovementFloor({
required double floorG,
required String frozenOn,
required int days,
}) =>
putBaseline(
'movement_floor',
jsonEncode({'floor_g': floorG, 'frozen_on': frozenOn, 'days': days}),
);


/// A long-format metric series (oldest first) for trends/sparklines.
static Future<List<Map<String, dynamic>>> metricSeries(
Expand Down
Loading
Loading