Skip to content
Merged
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
63 changes: 61 additions & 2 deletions lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import 'dart:io' show Platform;
import 'dart:isolate';
import 'dart:math' as math;

import 'strain_backfill.dart' show backfillStrainScale;

import 'package:flutter/foundation.dart';
import 'nap_edits.dart';
import 'package:openstrap_analytics/onehz.dart' as ana;
Expand Down Expand Up @@ -736,7 +738,39 @@ import 'substrate.dart';
// before citing a sibling-package change here — a bump whose stated cause is
// not in the pinned code is how a fix was believed shipped for three releases
// while the pin never carried it.
const int kAlgoVersion = 64;
//
// v65: THE 0–21 HEADLINE STRAIN SCALE IS RECALIBRATED.
//
// `strainScore` was `min(21, ln(TRIMP+1)/ln(1.5))` over whole-waking-day
// Banister TRIMP. Two things were wrong with that, and they compounded:
//
// * Whole-day TRIMP counts every waking minute above resting, so ~16 h of
// ordinary living accrues ~180 TRIMP before any exercise. Log base 1.5 is
// steepest near zero, so that overhead alone bought ~13 of the 21 points:
// on a real bundle an INACTIVE full-wear day scored 12.8.
// * Each further point cost 1.5x the load, so 21 sat at TRIMP ~4987 —
// roughly 35 h at 80 % HRR. The top third of the scale was unreachable;
// a marathon read ~15.8. The whole usable range was about 8 to 16.
//
// Strain is now the load earned ABOVE a quiet-waking baseline (20 % of HRR,
// scaled by the wake window actually observed, so partial wear is not charged
// a full day's overhead), mapped by 21·ln(1+u·14)/ln(15) with u = net/400.
// Anchored on real days: inactive ~0, rest + a walk 2-4, a 45-min moderate
// run 8-11, a 90-min hard session 14-17, 5 h at 160 bpm 21.
//
// SAME BUMP: `strainTarget`'s recovery bands are rebased onto that
// distribution (they asked for "recover 4-8" on a scale whose floor was 13),
// and its fatigue/freshness tests are now ratios against CTL — they compared
// raw TRIMP in the hundreds against thresholds of 10 and 5, sized for the
// 0-21 scale, so they fired on ordinary week-to-week noise. The intraday
// `strain_curve` also picks up Banister's 0.64/0.86 scale coefficient, which
// it had been dropping entirely (it accumulated a TRIMP 1.5625x the day's).
//
// Days inside the raw-retention window re-derive from substrate on this bump.
// Older days have no raw to re-derive from, so `strain_backfill.dart` rebuilds
// their headline from the stored TRIMP + wake window instead — see that file
// for why that is exact and what it deliberately drops.
const int kAlgoVersion = 65;

// Fold idempotency, the minimum-nights warm-up, and legacy-payload handling
// all live in SleepProfilePolicy (pure, unit-tested) — see
Expand Down Expand Up @@ -1127,6 +1161,23 @@ class DerivationEngine {
}) async {
if (_running) return 0;
_running = true;
// ONE-SHOT: rescale stored strain onto the v63 scale. Days inside the raw
// window re-derive below from substrate; everything older has none, so its
// headline is rebuilt from the stored TRIMP + wake window instead. Runs
// before the sweep so the two never disagree mid-pass, and no-ops after the
// first successful pass (`compute_freshness`). Never fatal — a failed
// rescale must not take the derive cycle down with it.
try {
final rescaled = await backfillStrainScale(
female: workoutSex(profile.sex) == 'female',
);
if (rescaled.didWork) {
_log('[derive] strain rescale: ${rescaled.bundleDays} day(s) rebuilt, '
'${rescaled.skipped} skipped (no TRIMP or no wake window)');
}
} catch (e) {
_log('[derive] strain rescale failed (kept old values): $e');
}
Comment on lines +1164 to +1180

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

ast-grep outline lib/compute/derivation_engine.dart --items all --type method
rg -n -C 4 'runDays\s*\(|\.runDays\s*\(|backfillStrainScale\s*\(' lib test

Repository: OpenStrap/edge

Length of output: 11127


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- DerivationEngine.run and shared helpers ---'
sed -n '1000,1170p' lib/compute/derivation_engine.dart
printf '%s\n' '--- DerivationEngine.runDays and adjacent entry paths ---'
sed -n '1300,1535p' lib/compute/derivation_engine.dart
printf '%s\n' '--- strain backfill implementation ---'
sed -n '1,180p' lib/compute/strain_backfill.dart
printf '%s\n' '--- related tests and freshness references ---'
rg -n -C 8 'kStrainRescaleKey|strain rescale|runDays|rescanRecent|backfillStrainScale' lib/compute lib/state test

Repository: OpenStrap/edge

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- rescanRecent body and completion path ---'
sed -n '2100,2295p' lib/compute/derivation_engine.dart
printf '%s\n' '--- runDays backfill-related calls in DerivationEngine ---'
python3 - <<'PY'
from pathlib import Path
p = Path('lib/compute/derivation_engine.dart')
s = p.read_text()
for name in ('run(', 'runDays(', 'rescanRecent('):
    start = s.find('Future<int> ' + name)
    if start < 0:
        print(name, 'NOT FOUND')
        continue
    next_methods = [s.find('\n  Future<int> ', start + 1), s.find('\n  Future<void> ', start + 1)]
    end = min(x for x in next_methods if x >= 0)
    body = s[start:end]
    print(f'{name}: backfillStrainScale={body.count("backfillStrainScale(")}, '
          f'runDays={body.count(".runDays(")}, '
          f'returns={body.count("return ")}')
PY
printf '%s\n' '--- existing selected-day strain/backfill regression coverage ---'
rg -n -i -C 5 'selected.*day|historical|raw.?prun|strain.*runDays|runDays.*strain|strain.*rescal|rescal.*runDays' test lib

Repository: OpenStrap/edge

Length of output: 50371


Run the strain backfill from runDays().

runDays() never calls backfillStrainScale(). A selected re-analysis of a raw-pruned legacy day therefore leaves its old strain values unchanged. Extract the one-shot gate into a shared method and add a runDays() regression test.

🤖 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/derivation_engine.dart` around lines 1125 - 1141, The one-shot
strain backfill currently runs only in the full derive flow, so selected
re-analysis through runDays() skips it. Extract the existing guarded backfill
and logging logic into a shared method, invoke that method from both the current
derive path and runDays(), and preserve its one-shot, non-fatal behavior. Add a
regression test covering selected re-analysis of a raw-pruned legacy day.

Source: Coding guidelines

final startedAt = DateTime.now().millisecondsSinceEpoch;
_diag
..['running'] = true
Expand Down Expand Up @@ -4041,7 +4092,15 @@ class DerivationEngine {
sex: _workoutSex(sex) == 'female' ? ana.Sex.female : ana.Sex.male,
);
if (trimp.present && trimp.value != null) {
final score = ana.strainScoreMetric(trimp.value);
// `perMin` IS the wake window the TRIMP was accumulated over, so it
// sets the quiet-waking baseline that gets subtracted. Passing the
// observed length (not an assumed 24 h) is what stops a partial-wear
// day from being charged a full day's overhead.
final score = ana.strainScoreMetric(
trimp.value,
wakeMinutes: perMin.length.toDouble(),
female: _workoutSex(sex) == 'female',
);
if (score.present) strain = score.value;
}
}
Expand Down
9 changes: 8 additions & 1 deletion lib/compute/manual_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,14 @@ double? strainFromPerMinuteHr(
sex: workoutSex(sex) == 'female' ? ana.Sex.female : ana.Sex.male,
);
if (!trimp.present || trimp.value == null) return null;
final score = ana.strainScoreMetric(trimp.value);
// The window's own length is the baseline window: strain is the load earned
// ABOVE quiet waking, and the same sex constant has to price the baseline as
// priced the TRIMP or the subtraction is off by the male/female coefficient.
final score = ana.strainScoreMetric(
trimp.value,
wakeMinutes: perMinuteHr.length.toDouble(),
female: workoutSex(sex) == 'female',
);
return score.present ? score.value : null;
}

Expand Down
36 changes: 28 additions & 8 deletions lib/compute/onehz_pipeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -539,9 +539,15 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> inputJson) {
}
}

// HEADLINE STRAIN = 0–21 log-squash of raw TRIMP; raw TRIMP kept as a detail.
// HEADLINE STRAIN = 0–21 map of the TRIMP earned ABOVE the quiet-waking
// baseline; raw TRIMP kept as a detail. `perMin` is the wake window the TRIMP
// was accumulated over, so it sets the baseline that gets subtracted.
final rawTrimp = trimp.present ? trimp.value : null;
final strainMetric = strainScoreMetric(rawTrimp);
final strainMetric = strainScoreMetric(
rawTrimp,
wakeMinutes: perMin.isEmpty ? null : perMin.length.toDouble(),
female: workoutSex(sex) == 'female',
);

// ── curve series for the UI ────────────────────────────────────────────────
final hrCurve = _downsampleHr(d.dayTsSec, d.dayHr);
Expand Down Expand Up @@ -1094,19 +1100,33 @@ List<Map<String, num>> _strainCurve(
sex == null) {
return const [];
}
// Banister's sex constant, via the shared normalisation — a profile stored
// as 'female' by the profile screen used to fall through to the male value
// here while scoring female everywhere else.
final b = workoutSex(sex) == 'female' ? 1.67 : 1.92;
// Banister's sex constants, via the ONE shared weighting factor. This used to
// inline `exp(b·hrr)` and drop the 0.64/0.86 scale coefficient entirely, so
// the curve accumulated a TRIMP 1.5625× the day's own — the curve and the
// headline were never on the same scale. It matters more now: the headline
// subtracts a baseline priced with `banisterY`, so a curve accumulating
// without it would be netted against an allowance from a different formula.
final female = workoutSex(sex) == 'female';
final reserve = maxHr - restingHr;
var trimp = 0.0;
var wakeMin = 0.0;
final out = <Map<String, num>>[];
for (final p in wakeHr) {
var hrr = (p.hr - restingHr) / reserve;
if (hrr < 0) hrr = 0;
if (hrr > 1) hrr = 1;
trimp += hrr * math.exp(b * hrr);
out.add({'t': p.tsSec, 'v': _round(strainScore(trimp), 2)});
trimp += hrr * StrainScorer.banisterY(hrr, female: female);
// The baseline grows with the wake window ALREADY elapsed, so the curve
// stays flat through quiet waking and climbs only on real effort — rather
// than charging a whole day's allowance against the first minute.
wakeMin += 1;
out.add({
't': p.tsSec,
'v': _round(
strainScore(trimp, wakeMinutes: wakeMin, female: female),
2,
),
});
}
return out;
}
Expand Down
215 changes: 215 additions & 0 deletions lib/compute/strain_backfill.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// ONE-SHOT BACKFILL — stored strain onto the recalibrated 0–21 scale.
//
// The headline strain map changed: it used to be `min(21, ln(TRIMP+1)/ln(1.5))`
// over whole-waking-day TRIMP, which charged ~180 TRIMP of simply being awake
// as training load and put an INACTIVE full-wear day at ~13/21. It is now the
// load earned ABOVE a quiet-waking baseline that scales with the wake window.
// Every day derived before that change carries a number on the old scale, so
// trends, v_daily/coach SQL and the day-detail screen would show a step change
// at the fix date rather than a real one in the user's training.
//
// WHY NOT JUST RE-DERIVE: raw 1 Hz substrate is pruned `rawRetentionDays` (3)
// behind the DATA EDGE. For anything older there is no substrate — the engine
// logs "no substrate (raw pruned) — kept" and keeps the old row — so a
// kAlgoVersion bump alone can only ever fix the last few days.
//
// It does not need raw. Strain is a pure function of (TRIMP, wake minutes,
// sex), and `metric_series` already stores `trimp`, `worn_min` and `tst_min`
// for every derived day, so the headline can be rebuilt exactly from what is
// on disk. (`series.strain_curve` carries one point per wake minute, and on a
// real bundle its length equals `worn_min − tst_min` — the reconstruction of
// the wake window used here is the same one the pipeline fed the scorer.)

import 'dart:convert';

import 'package:openstrap_analytics/onehz.dart' as ana;

import '../data/db.dart';
import 'derivation_engine.dart' show kAlgoVersion, rawRetentionDays;

/// `compute_freshness` key marking the rescale as already applied. Bumped with
/// the algo version so a future rescale is a new one-shot rather than a no-op.
const String kStrainRescaleKey = 'strain_rescale_v63';

class StrainBackfillResult {
/// Days whose `metric_series` strain was rewritten (trends / v_daily).
final int seriesDays;

/// Days that got a fresh `day_result` row at the current algo version.
final int bundleDays;

/// Days left exactly as they were because they could not be rescaled.
final int skipped;

const StrainBackfillResult({
required this.seriesDays,
required this.bundleDays,
required this.skipped,
});

bool get didWork => seriesDays > 0 || bundleDays > 0;
}

/// Rebuild one day's headline strain from its stored scalars.
///
/// Returns null when the day cannot be rescaled — no TRIMP to rescale from, or
/// no wake window to price the baseline over. A day that cannot be rescaled is
/// LEFT ALONE: an un-rescalable day must not silently become 0, which is a
/// number, not an absence.
double? rescaledStrain({
required double? trimp,
required double? wornMin,
required double? tstMin,
required bool female,
}) {
if (trimp == null || wornMin == null) return null;
final wake = wornMin - (tstMin ?? 0);
if (wake <= 0) return null;
return ana.strainScore(trimp, wakeMinutes: wake, female: female);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Rescale every stored day that can no longer be re-derived from raw.
///
/// [female] selects the Banister constant for the quiet-waking baseline; it has
/// to match the constant the stored TRIMP was scored with or the subtraction is
/// off by the male/female coefficient. Runs once — set [force] to re-run.
Future<StrainBackfillResult> backfillStrainScale({
required bool female,
bool force = false,
}) async {
const none = StrainBackfillResult(seriesDays: 0, bundleDays: 0, skipped: 0);
if (!force && await LocalDb.computeFreshness(kStrainRescaleKey) != null) {
return none;
}

final strainRows = await LocalDb.metricSeries('strain');
if (strainRows.isEmpty) {
await _markDone();
return none;
}

final trimpBy = await _byDate('trimp');
final wornBy = await _byDate('worn_min');
final tstBy = await _byDate('tst_min');

// The DATA EDGE is the newest day on disk, matching how the pruner measures
// retention (never the wall clock — a multi-day flash backfill received in
// one sync must not be treated as old). Days at or after the cutoff still
// have raw and are LEFT for a real re-derive: writing a patched row at
// kAlgoVersion here would satisfy the derive gate, which matches
// algo_version EXACTLY, and a partial patch would stand in for a full
// re-derivation of the day.
final days = <String>[
for (final r in strainRows) ?(r['date'] as String?),
]..sort();
final cutoff = _shiftDays(days.last, -rawRetentionDays);
Comment on lines +95 to +105

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 | 🟠 Major | ⚡ Quick win

Derive retention from the decoded-data edge.

days.last is the latest non-null strain series row, not the latest decoded record. metricSeries('strain') excludes null values. If recent raw days have no strain, this cutoff moves backward and the backfill skips older raw-pruned days permanently when _markDone() runs.

Use LocalDb.lastDecodedRecTs() as the retention edge. If no decoded data exists, process all eligible stored days. Add a test where decoded data is newer than the newest strain row.

🤖 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/strain_backfill.dart` around lines 95 - 105, Update the retention
cutoff calculation in the strain backfill flow around _markDone() to use
LocalDb.lastDecodedRecTs() as the decoded-data edge instead of days.last from
strainRows. When no decoded record exists, leave the cutoff unset so all
eligible stored days are processed; preserve existing retention-day shifting
when a decoded timestamp is available, and add coverage for decoded data newer
than the newest strain row.


var seriesDays = 0;
var bundleDays = 0;
var skipped = 0;

for (final day in days) {
if (day.compareTo(cutoff) >= 0) continue;

final row = await LocalDb.dayResult(day);
// Already carries a row at the current version — rescaled on a prior pass.
if (row != null &&
((row['algo_version'] as num?)?.toInt() ?? 0) >= kAlgoVersion) {
continue;
}

final next = rescaledStrain(
trimp: trimpBy[day],
wornMin: wornBy[day],
tstMin: tstBy[day],
female: female,
);
if (next == null) {
skipped++;
continue;
}

if (row == null) {
// A series row with no bundle behind it: still worth fixing the trend.
await LocalDb.putMetricSeriesValue(day, 'strain', next);
seriesDays++;
continue;
}

final payload = _decode(row['payload_json']);
if (payload == null) {
skipped++;
continue;
}
final scalars = payload['scalars'];
if (scalars is! Map) {
skipped++;
continue;
}
scalars['strain'] = next;

// The intraday curve is cumulative strain, one point per wake minute, built
// from per-sample HR that no longer exists — it cannot be rescaled, and its
// last point IS the old headline. A curve ending at 12.79 under a headline
// of 9.03 contradicts itself, so it is DROPPED rather than left to disagree.
final series = payload['series'];
if (series is Map) series.remove('strain_curve');

final partial = (row['partial'] as num?)?.toInt() == 1;
await LocalDb.putDayResult(
dayId: day,
algoVersion: kAlgoVersion,
payloadJson: jsonEncode(payload),
windowJson: (row['window_json'] as String?) ?? '{}',
finalized: (row['finalized'] as num?)?.toInt() == 1,
skipped: (row['skipped'] as num?)?.toInt() == 1,
partial: partial,
rhr: (row['rhr'] as num?)?.toDouble(),
rmssd: (row['rmssd'] as num?)?.toDouble(),
readiness: (row['readiness'] as num?)?.toDouble(),
// `putDayResult` skips the series write for a partial row, so only count
// the trend as rewritten when it actually was.
series: {'strain': next},
);
bundleDays++;
if (!partial) seriesDays++;
Comment on lines +111 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the historical rewrite conditional and atomic.

Another derivation isolate can write a complete v63 result after Line 114 reads the v62 row and before Line 159 calls putDayResult(). ConflictAlgorithm.replace then replaces that complete v63 payload with this stale v62 payload, removes its newly derived strain_curve, and can discard other current-version detail.

Add one database transaction that verifies no current-version row exists and writes the bundle and series only when that condition still holds. Coordinate the compute_freshness claim in the same transaction. Add an interleaving regression test.

Based on learnings, “Recomputation must be idempotent: repeated derivation with additional data must not duplicate baseline entries, drift persisted scalars, or append where replacement is required.”

🤖 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/strain_backfill.dart` around lines 111 - 175, Make the historical
rewrite in the backfill flow atomic by replacing the separate read/write
sequence around LocalDb.dayResult and LocalDb.putDayResult with one database
transaction that rechecks absence of a current-version row before writing the
bundle and series; skip the stale rewrite when another derivation has already
produced version kAlgoVersion. Update the compute_freshness claim within that
same transaction, preserving idempotent replacement semantics and preventing
current payload details or strain_curve data from being overwritten. Add an
interleaving regression test covering a concurrent current-version write.

Source: Learnings

}

await _markDone();
return StrainBackfillResult(
seriesDays: seriesDays,
bundleDays: bundleDays,
skipped: skipped,
);
}

Future<void> _markDone() =>
LocalDb.putComputeFreshness(kStrainRescaleKey, jsonEncode({'done': true}));

Future<Map<String, double>> _byDate(String key) async {
final out = <String, double>{};
for (final r in await LocalDb.metricSeries(key)) {
final d = r['date'] as String?;
final v = (r['value'] as num?)?.toDouble();
if (d != null && v != null) out[d] = v;
}
return out;
}

Map<String, dynamic>? _decode(Object? json) {
if (json is! String) return null;
try {
final v = jsonDecode(json);
return v is Map ? v.cast<String, dynamic>() : null;
} catch (_) {
return null;
}
}

/// Shift a 'YYYY-MM-DD' label by [days] calendar days.
String _shiftDays(String day, int days) {
final t = DateTime.parse(day).add(Duration(days: days));
final mm = t.month.toString().padLeft(2, '0');
final dd = t.day.toString().padLeft(2, '0');
return '${t.year}-$mm-$dd';
}
Comment on lines +209 to +215

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

fd -a '^day_label\.dart$' lib
rg -n -C 3 'dayLabelOf|todayLabel|localDayStartSec|localDayEndSec' lib/data
rg -n -C 3 '_shiftDays|backfillStrainScale' lib test

Repository: OpenStrap/edge

Length of output: 15772


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- strain_backfill.dart ---'
sed -n '1,235p' lib/compute/strain_backfill.dart

printf '%s\n' '--- day_label.dart ---'
cat -n lib/data/day_label.dart

printf '%s\n' '--- relevant tests and package API references ---'
rg -n -C 5 'shiftDays|dayLabelOf|localDay(Start|End)Sec|DST|spring|fall|strainScore|wakeMinutes|female' test lib pubspec.yaml

Repository: OpenStrap/edge

Length of output: 50373


🌐 Web query:

Dart DateTime.parse date-only string local UTC semantics and DateTime.add Duration days daylight saving time documentation

💡 Result:

In Dart, the DateTime.parse() method and DateTime.add() behavior are governed by specific rules regarding time zones and the definition of a "day" [1][2][3]. DateTime.parse Semantics When using DateTime.parse() on a string that does not explicitly include a time zone offset (e.g., "2026-08-12"), Dart interprets the result in local time [4][5][6]. If the string includes an explicit UTC offset (e.g., "2026-08-12T14:00:00Z" or "2026-08-12T14:00:00-0500"), Dart parses it as a specific point in time and, if an offset other than UTC is provided, converts it to the equivalent UTC time [1][7]. To force a string to be parsed as UTC when it lacks an explicit offset, a common convention is to append 'Z' to the string (e.g., DateTime.parse(dateString + 'Z')), which informs the parser to treat it as UTC [4][5]. DateTime.add and Daylight Saving Time (DST) The DateTime.add() method adds a Duration to a DateTime instance [2]. A Duration of "days" is strictly defined as 24 hours (86,400 seconds) [8][9][3]. It does not represent a calendar day [8]. Because DateTime.add() operates on absolute time, it is affected by daylight saving time transitions when using local time [2][9]. If a transition (e.g., clocks moving forward or backward) occurs during the interval added, the resulting local time of day may shift, or the calendar date may not match expectations (e.g., adding 1 day might result in the same calendar day or skip one) [2][8][9]. Best Practices To perform calendar-based calculations without DST interference, it is recommended to work exclusively with UTC DateTime objects [8][9][10]. Because UTC does not observe daylight saving time, adding 86,400 seconds consistently results in a shift of exactly one calendar day [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- strain_backfill.dart ---'
cat -n lib/compute/strain_backfill.dart | sed -n '1,225p'

printf '%s\n' '--- strain backfill tests ---'
cat -n test/strain_rescale_backfill_test.dart | sed -n '120,225p'

printf '%s\n' '--- strainScore references ---'
rg -n -C 8 'ana\.strainScore|strainScore|wakeMinutes|female' lib/compute lib test -g '*.dart'

Repository: OpenStrap/edge

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

if ! command -v dart >/dev/null 2>&1; then
  echo "dart runtime unavailable"
  exit 0
fi

TZ=America/New_York dart - <<'DART'
String legacy(String day, int days) {
  final t = DateTime.parse(day).add(Duration(days: days));
  final mm = t.month.toString().padLeft(2, '0');
  final dd = t.day.toString().padLeft(2, '0');
  return '${t.year}-$mm-$dd';
}

String calendar(String day, int days) {
  final p = day.split('-').map(int.parse).toList();
  final t = DateTime(p[0], p[1], p[2] + days);
  final mm = t.month.toString().padLeft(2, '0');
  final dd = t.day.toString().padLeft(2, '0');
  return '${t.year}-${mm}-${dd}';
}

for (final day in ['2026-03-09', '2026-11-02']) {
  print('$day - 3 days: legacy=${legacy(day, -3)}, '
      'calendar=${calendar(day, -3)}');
}
DART

Repository: OpenStrap/edge

Length of output: 177


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")
day = date(2026, 3, 9)

local_midnight = datetime.combine(day, datetime.min.time(), tzinfo=tz)
elapsed_result = (local_midnight.astimezone(timezone.utc) - timedelta(days=3)).astimezone(tz)
calendar_result = datetime.combine(day + timedelta(days=-3), datetime.min.time(), tzinfo=tz)

print("fixed-duration result:", elapsed_result.isoformat(), elapsed_result.date())
print("calendar-date result:", calendar_result.isoformat(), calendar_result.date())
PY

Repository: OpenStrap/edge

Length of output: 2023


Use local calendar arithmetic in _shiftDays.

Duration(days: days) adds fixed 24-hour periods. Across a DST transition, this can produce the wrong local date label and cutoff. Construct the target with DateTime(year, month, day + days) and format it with dayLabelOf(). Add a spring-forward test.

🤖 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/strain_backfill.dart` around lines 209 - 215, Update _shiftDays
to use local calendar arithmetic by constructing the target with DateTime(year,
month, day + days) instead of adding Duration(days: days), then return the
formatted result through dayLabelOf(). Add a test covering a spring-forward DST
transition and verifying the expected date label.

Source: Coding guidelines

Loading
Loading