Repository navigation
Activity picker: most-used sports at the top of the quick row #678
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e8a7afa
d526254
d22cc9e
a4955f8
a7c41ce
f14aa92
c7814b8
cc97bfb
673de84
24f87c4
d40ce3a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14800,6 +14800,47 @@ class LocalDb { | |
| ), executor: db); | ||
| } | ||
|
|
||
| /// The most-used finished sessions' activity keys, most-used first — rows | ||
| /// of `{'type', 'n', 'last_ts'}` for the activity picker's quick row. | ||
| /// `type` comes back NORMALIZED exactly the way [activityByName] resolves | ||
| /// it (`lower(replace(type, ' ', '_'))`, the same rule as `Activity.typeKey`), | ||
| /// so 'Running' and 'running' count as one activity and can never rank an | ||
| /// activity low or show it twice. [sinceTs] bounds the window (the picker | ||
| /// passes 90 days so a seasonal sport does not stay pinned at the top | ||
| /// forever); null counts all history. Live sessions never count, and | ||
| /// `last_ts` breaks ties so a newly started sport does not sink below an | ||
| /// old habit. [limit] caps the ranked rows returned — a caller that drops | ||
| /// unresolvable types itself should fetch more than it displays, or | ||
| /// unknown types could crowd out every known one before the limit. | ||
| /// [toTs] bounds the window from above (defaults to now) so a future-dated | ||
| /// row (clock skew) cannot inflate a count. Only sessions this app started | ||
| /// count: scored imports (`scored_import`) and WHOOP exports (`whoop`) also | ||
| /// live in `sessions`, and the quick row's tiles START a session, so | ||
| /// someone else's recording must not outrank what this user does here. | ||
| static Future<List<Map<String, dynamic>>> mostUsedActivityTypes({ | ||
| int? sinceTs, | ||
| int? toTs, | ||
| int limit = 6, | ||
| }) async { | ||
| final db = await instance; | ||
| final args = <Object?>[]; | ||
| var where = "status = 'done' AND type IS NOT NULL AND type <> '' " | ||
| "AND source NOT IN ('scored_import', 'whoop') AND start_ts <= ?"; | ||
| args.add(toTs ?? DateTime.now().millisecondsSinceEpoch ~/ 1000); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| if (sinceTs != null) { | ||
| where += ' AND start_ts >= ?'; | ||
|
sourcery-ai[bot] marked this conversation as resolved.
Resolved
|
||
| args.add(sinceTs); | ||
| } | ||
| args.add(limit); | ||
| return db.rawQuery( | ||
| "SELECT lower(replace(type, ' ', '_')) AS type, COUNT(*) AS n, " | ||
| 'MAX(start_ts) AS last_ts FROM sessions ' | ||
| 'WHERE $where GROUP BY lower(replace(type, \' \', \'_\')) ' | ||
| 'ORDER BY n DESC, last_ts DESC, type ASC LIMIT ?', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| args, | ||
| ); | ||
| } | ||
|
|
||
| /// What a session's sensor scored: served empty, not stored empty. | ||
| static const List<String> _sensorScoreColumns = [ | ||
| 'calories', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ import '../../models/metric.dart'; | |
| import '../../state/app_state.dart'; | ||
| import '../../state/clock_format.dart' show formatClockOf; | ||
| import '../../state/units_controller.dart'; | ||
| import '../../telemetry/telemetry_service.dart'; | ||
| import '../activity/catalogue.dart'; | ||
| import '../activity/day_strain.dart'; | ||
| import '../activity/live.dart'; | ||
|
|
@@ -154,7 +155,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> with RevisionReload { | |
| void _openPicker(BuildContext c, _WorkoutData d) => | ||
| Navigator.of(c).push(MaterialPageRoute( | ||
| builder: (_) => ActivityPicker( | ||
| weightKg: d.weightKg, host: _host(d), recent: d.recent))); | ||
| weightKg: d.weightKg, host: _host(d), mostUsed: d.mostUsed))); | ||
|
|
||
| ActivityHost _host(_WorkoutData d) => | ||
| activityHost(context.read<AppState>(), history: d.setHistory); | ||
|
|
@@ -1943,8 +1944,9 @@ class _WorkoutData { | |
| final double? weekLoad; | ||
| final int? workoutsTracked; | ||
|
|
||
| /// The activities this user actually started, most recent first, deduped. | ||
| final List<Activity> recent; | ||
| /// The activities this user does most, most-used first — the picker's quick | ||
| /// row, counted over the last 90 days (see [_load]). | ||
| final List<Activity> mostUsed; | ||
|
|
||
| /// Previous and best set per exercise, from this user's own log. | ||
| final Map<String, SetHistory> setHistory; | ||
|
|
@@ -1985,7 +1987,7 @@ class _WorkoutData { | |
| this.weekImported = 0, | ||
| this.weekLoad, | ||
| this.workoutsTracked, | ||
| this.recent = const [], | ||
| this.mostUsed = const [], | ||
| this.setHistory = const {}, | ||
| this.overreach, | ||
| this.morningAfter = const [], | ||
|
|
@@ -2187,19 +2189,33 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { | |
| .where((w) => w.strain != null) | ||
| .fold<double?>(null, (a, w) => (a ?? 0) + w.strain!); | ||
|
|
||
| // Real recency, deduped, newest first — six tiles like the constant it | ||
| // replaces, so the picker's row is the same shape either way. | ||
| // The picker's quick row: the MOST-USED activities, not the most recent | ||
| // ones — hunting for an unusual activity is what the search and the | ||
| // collapsed groups are for. Counted over the last 90 days so a seasonal | ||
| // sport does not stay pinned at the top forever; newest use breaks ties. | ||
| // | ||
| // Band sessions only. These tiles START a session, and most imported types | ||
| // land on the generic fallback activity — a row of "Workout" tiles that | ||
| // begin a five-MET nothing is worse than the six real ones. | ||
| final recent = <Activity>[]; | ||
| for (final w in past) { | ||
| if (w.importedFrom != null) continue; | ||
| if (!recent.any((x) => x.name == w.activity.name)) { | ||
| recent.add(w.activity); | ||
| // Band sessions only (`sessions` table), same reason the tiles used to | ||
| // skip imports: these tiles START a session, and most imported types land | ||
| // on the generic fallback activity — a row of "Workout" tiles that begin a | ||
| // five-MET nothing is worse than the six real ones. | ||
| final mostUsed = <Activity>[]; | ||
| try { | ||
| final since90 = now.millisecondsSinceEpoch ~/ 1000 - 90 * 86400; | ||
| // Fetch far more than the six the row shows: unknown stored types are | ||
| // dropped below, and a small SQL-side LIMIT could hand back only those | ||
| // and leave the picker on quickStart despite usable history. | ||
| for (final r in await LocalDb.mostUsedActivityTypes( | ||
| sinceTs: since90, limit: 200)) { | ||
| final a = activityByName(r['type'] as String?); | ||
| if (a == null) continue; | ||
| mostUsed.add(a); | ||
| if (mostUsed.length == 6) break; | ||
| } | ||
| if (recent.length == 6) break; | ||
| } catch (e, st) { | ||
| // An unreadable table must not take the screen down — record it, then | ||
| // fall back to quickStart under its own heading. | ||
| TelemetryService.instance | ||
| .recordNonFatal(e, st, reason: 'most_used_activities_failed'); | ||
|
Comment on lines
+2217
to
+2218
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| // The live screen has to have previous/best in hand before the first set; | ||
|
|
@@ -2224,7 +2240,7 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { | |
| weekImported: thisWeek.where((w) => w.importedFrom != null).length, | ||
| weekLoad: weekLoad, | ||
| workoutsTracked: tracked, | ||
| recent: recent, | ||
| mostUsed: mostUsed, | ||
| setHistory: history, | ||
| overreach: overreach, | ||
| morningAfter: morningAfter, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| // The activity picker's quick row: which `sessions.type` values count as | ||
| // "most done", in which order, and which rows never count. The picker's own | ||
| // widget test lives in ui2_activity_test.dart; this pins the SQL behind it. | ||
|
|
||
| import 'package:flutter_test/flutter_test.dart'; | ||
| import 'package:openstrap_edge/data/db.dart'; | ||
| import 'package:path/path.dart' as p; | ||
| import 'package:sqflite_common_ffi/sqflite_ffi.dart'; | ||
|
|
||
| const int _t0 = 1_700_000_040; // a fixed epoch second, safely in the past | ||
|
|
||
| Future<void> _session(String id, String type, int startTs, | ||
| {String status = 'done', String source = 'manual'}) async { | ||
| final db = await LocalDb.instance; | ||
| await db.insert('sessions', { | ||
| 'id': id, | ||
| 'start_ts': startTs, | ||
| 'end_ts': startTs + 1800, | ||
| 'type': type, | ||
| 'status': status, | ||
| 'source': source, | ||
| 'created_at': startTs, | ||
| }); | ||
| } | ||
|
|
||
| void main() { | ||
| setUpAll(() { | ||
| sqfliteFfiInit(); | ||
| databaseFactory = databaseFactoryFfi; | ||
| }); | ||
|
|
||
| setUp(() async { | ||
| await LocalDb.close(); | ||
| LocalDb.dbName = 'most_used_activity_types_test.db'; | ||
| final dir = await databaseFactory.getDatabasesPath(); | ||
| await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); | ||
| }); | ||
|
|
||
| tearDown(() async => await LocalDb.close()); | ||
|
|
||
| test('most-used first, live sessions never count', () async { | ||
| await _session('a1', 'running', _t0 - 10 * 86400); | ||
| await _session('a2', 'running', _t0 - 9 * 86400); | ||
| await _session('a3', 'running', _t0 - 8 * 86400); | ||
| await _session('b1', 'cycling', _t0 - 20 * 86400); | ||
| await _session('b2', 'cycling', _t0 - 19 * 86400); | ||
| await _session('c1', 'yoga', _t0 - 30 * 86400); | ||
| await _session('live1', 'swimming', _t0 - 1000, status: 'live'); | ||
|
|
||
| final rows = await LocalDb.mostUsedActivityTypes(); | ||
| expect([for (final r in rows) r['type']], ['running', 'cycling', 'yoga']); | ||
| expect(rows.first['n'], 3); | ||
| expect(rows.first['last_ts'], _t0 - 8 * 86400); | ||
| }); | ||
|
|
||
| test('stored names group by their normalized catalogue key', () async { | ||
| // 'Running', 'running' and 'RUNNING' are one activity to activityByName, | ||
| // so they must count as one here too — not three separate types. | ||
| await _session('a1', 'Running', _t0 - 10 * 86400); | ||
| await _session('a2', 'running', _t0 - 9 * 86400); | ||
| await _session('a3', 'RUNNING', _t0 - 8 * 86400); | ||
| await _session('b1', 'cycling', _t0 - 5 * 86400); | ||
|
|
||
| final rows = await LocalDb.mostUsedActivityTypes(); | ||
| expect([for (final r in rows) r['type']], ['running', 'cycling']); | ||
| expect(rows.first['n'], 3); | ||
| }); | ||
|
|
||
| test('a tie on count falls back to the most recently used', () async { | ||
| await _session('a1', 'running', _t0 - 10 * 86400); | ||
| await _session('b1', 'cycling', _t0 - 3 * 86400); | ||
| final rows = await LocalDb.mostUsedActivityTypes(); | ||
| expect(rows.first['type'], 'cycling'); | ||
| }); | ||
|
|
||
| test('sinceTs bounds the window and limit caps the rows', () async { | ||
| await _session('a1', 'running', _t0 - 10 * 86400); | ||
| await _session('a2', 'running', _t0 - 200 * 86400); // outside 90 days | ||
| await _session('b1', 'cycling', _t0 - 20 * 86400); | ||
| await _session('c1', 'yoga', _t0 - 5 * 86400); | ||
|
|
||
| final rows = | ||
| await LocalDb.mostUsedActivityTypes(sinceTs: _t0 - 90 * 86400, limit: 2); | ||
| // Both types have n=1 inside the window, so the tiebreaker decides: | ||
| // yoga was used more recently than running. | ||
| expect([for (final r in rows) r['type']], ['yoga', 'running']); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Critical · Correct ranking fails test When the SQL correctly orders equal counts by Expect Prompt for AI agentsIn `test/most_used_activity_types_test.dart` at line 86:
**Correct ranking fails test**
When the SQL correctly orders equal counts by `last_ts`, the fixture gives cycling one in-window session at line 79, newer than running’s in-window session at line 77, so correct recency ordering returns `['yoga', 'cycling']` and this assertion fails.
Expect `['yoga', 'cycling']` (and adjust the comment to account for all three in-window types). |
||
| expect(rows.first['n'], 1, | ||
| reason: 'the 200-day-old run is outside the window'); | ||
| }); | ||
|
|
||
| test('a full tie (count and last_ts) falls back to the key alphabetically', | ||
| () async { | ||
| await _session('a1', 'yoga', _t0 - 10 * 86400); | ||
| await _session('b1', 'cycling', _t0 - 10 * 86400); | ||
| final rows = await LocalDb.mostUsedActivityTypes(); | ||
| expect([for (final r in rows) r['type']], ['cycling', 'yoga']); | ||
| }); | ||
|
|
||
| test('imported sources and future-dated rows never count', () async { | ||
| await _session('a1', 'running', _t0 - 10 * 86400); | ||
| await _session('i1', 'cycling', _t0 - 9 * 86400, source: 'scored_import'); | ||
| await _session('i2', 'swimming', _t0 - 8 * 86400, source: 'whoop'); | ||
| await _session('f1', 'yoga', _t0 + 86400); // future: clock skew | ||
|
|
||
| final rows = await LocalDb.mostUsedActivityTypes(toTs: _t0); | ||
| expect([for (final r in rows) r['type']], ['running']); | ||
| }); | ||
|
|
||
| test('an empty table is an empty list, not an error', () async { | ||
| expect(await LocalDb.mostUsedActivityTypes(), isEmpty); | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: OpenStrap/edge
Length of output: 3665
Script executed:
Repository: OpenStrap/edge
Length of output: 26523
Script executed:
Repository: OpenStrap/edge
Length of output: 47868
Script executed:
Repository: OpenStrap/edge
Length of output: 16857
Script executed:
Repository: OpenStrap/edge
Length of output: 24428
Script executed:
Repository: OpenStrap/edge
Length of output: 20228
Script executed:
Repository: OpenStrap/edge
Length of output: 22498
Exclude legacy scored imports from the ranking.
Before the
scored_importsource was introduced, scoring a health import saved it with sourcemanual. If those sessions are within the 90-day window, they can outrank activities started in the app. Account for those legacy rows when building the ranking.