From d5262545a1c2fc869fd1686d3cacca33a9d481c3 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 18:13:42 +0000 Subject: [PATCH 1/6] activity picker: show most-used activities in the quick row The picker's top row was 'most recent first'; now it is the user's most-used activities (COUNT over the last 90 days, newest use as tiebreaker), so the sports someone actually does sit at the top without searching. Heading RECENT -> MOST DONE (en/de). Co-authored-by: BucciMobile --- lib/data/db.dart | 26 ++++++++ lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 ++ lib/ui2/activity/picker.dart | 24 ++++---- lib/ui2/screens/workout_screen.dart | 42 ++++++++----- test/most_used_activity_types_test.dart | 79 +++++++++++++++++++++++++ test/ui2_activity_test.dart | 42 +++++++++++++ 7 files changed, 190 insertions(+), 28 deletions(-) create mode 100644 test/most_used_activity_types_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index c3ab9f1f6..3f39eaae2 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -14800,6 +14800,32 @@ class LocalDb { ), executor: db); } + /// The `type` values of the most-used finished sessions, most-used first — + /// rows of `{'type', 'n', 'last_ts'}` for the activity picker's quick row. + /// [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. Unknown types are the caller's to + /// drop: the catalogue resolves what it knows via `activityByName`. + static Future>> mostUsedActivityTypes({ + int? sinceTs, + int limit = 6, + }) async { + final db = await instance; + final args = []; + var where = "status = 'done' AND type IS NOT NULL AND type <> ''"; + if (sinceTs != null) { + where += ' AND start_ts >= ?'; + args.add(sinceTs); + } + args.add(limit); + return db.rawQuery( + 'SELECT type, COUNT(*) AS n, MAX(start_ts) AS last_ts FROM sessions ' + 'WHERE $where GROUP BY type ORDER BY n DESC, last_ts DESC LIMIT ?', + args, + ); + } + /// What a session's sensor scored: served empty, not stored empty. static const List _sensorScoreColumns = [ 'calories', diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fe1fa7f57..ef3c9ebc7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1940,6 +1940,7 @@ "activityPickerNoMatchBody": "Der Katalog umfasst etwa siebzig Aktivitäten mit veröffentlichtem Energieverbrauch. Wähle die, die am besten passt.", "activityPickerQuickStart": "SCHNELLSTART", "activityPickerRecent": "ZULETZT", + "activityPickerMostDone": "HÄUFIG GENUTZT", "activityPickerCalorieEstimatesTitle": "Kalorienwerte sind Schätzungen", "activityPickerMetValue": "{met} MET", "activityPickerKcalPer30": "{kcal} kcal pro 30 Min.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ea800171..2e0983819 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -9635,6 +9635,10 @@ "@activityPickerRecent": { "description": "Section heading over the recent-activities row" }, + "activityPickerMostDone": "MOST DONE", + "@activityPickerMostDone": { + "description": "Section heading over the most-used-activities row" + }, "activityPickerCalorieEstimatesTitle": "Calorie figures are estimates", "@activityPickerCalorieEstimatesTitle": { "description": "Status card title explaining that calorie figures are estimates" diff --git a/lib/ui2/activity/picker.dart b/lib/ui2/activity/picker.dart index 8d702a72e..9ba64addf 100644 --- a/lib/ui2/activity/picker.dart +++ b/lib/ui2/activity/picker.dart @@ -2,7 +2,7 @@ import '../../l10n/display_text.dart'; // Choose an activity. // // Two ways in, because there are two kinds of user: the one who does the same -// six things (the recent row, one tap) and the one who did something unusual +// six things (the most-used row, one tap) and the one who did something unusual // today (search, then eight collapsed groups). Both land on the same setup // screen, so there is one path to a live session, not two. @@ -24,10 +24,10 @@ class ActivityPicker extends StatefulWidget { /// The app behind the session this picker starts. final ActivityHost host; - /// The activities this user actually did, most recent first. Empty means - /// there is no history to show, and the row falls back to [quickStart] - /// under its own honest heading. - final List recent; + /// The activities this user does most, most-used first. Empty means there + /// is no history to show, and the row falls back to [quickStart] under its + /// own honest heading. + final List mostUsed; /// Where a chosen activity goes. Defaults to [ActivitySetup]. final void Function(BuildContext c, Activity a)? onPick; @@ -36,7 +36,7 @@ class ActivityPicker extends StatefulWidget { super.key, this.weightKg, this.host = ActivityHost.none, - this.recent = const [], + this.mostUsed = const [], this.onPick, }); @@ -152,18 +152,18 @@ class _ActivityPickerState extends State { ]), ) else ...[ - // "RECENT" over a fixed six-item constant was a lie the - // first time anyone read it. The heading follows the data. + // "MOST DONE" over a fixed six-item constant would be the + // same lie "RECENT" was. The heading follows the data. Text( - widget.recent.isEmpty + widget.mostUsed.isEmpty ? (l?.activityPickerQuickStart ?? 'QUICK START') - : (l?.activityPickerRecent ?? 'RECENT'), + : (l?.activityPickerMostDone ?? 'MOST DONE'), style: F.over.copyWith(color: p.ink3)), const SizedBox(height: S.x3), Builder(builder: (_) { - final row = widget.recent.isEmpty + final row = widget.mostUsed.isEmpty ? quickStart - : widget.recent; + : widget.mostUsed; return SizedBox( height: 96, child: ListView.separated( diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index ec0c8308e..6ed7f72c4 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -154,7 +154,7 @@ class _WorkoutScreenState extends State 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(), history: d.setHistory); @@ -1943,8 +1943,9 @@ class _WorkoutData { final double? weekLoad; final int? workoutsTracked; - /// The activities this user actually started, most recent first, deduped. - final List 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 mostUsed; /// Previous and best set per exercise, from this user's own log. final Map setHistory; @@ -1985,7 +1986,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 +2188,28 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { .where((w) => w.strain != null) .fold(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 = []; - 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 = []; + try { + final since90 = + end.subtract(Motion.tick * 86400 * 90).millisecondsSinceEpoch ~/ + 1000; + for (final r + in await LocalDb.mostUsedActivityTypes(sinceTs: since90, limit: 6)) { + final a = activityByName(r['type'] as String?); + if (a != null) mostUsed.add(a); } - if (recent.length == 6) break; + } catch (_) { + // An unreadable table must not take the screen down — the picker falls + // back to quickStart under its own heading. } // The live screen has to have previous/best in hand before the first set; @@ -2224,7 +2234,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, diff --git a/test/most_used_activity_types_test.dart b/test/most_used_activity_types_test.dart new file mode 100644 index 000000000..50c557e90 --- /dev/null +++ b/test/most_used_activity_types_test.dart @@ -0,0 +1,79 @@ +// 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_800_000_040; // a fixed epoch second + +Future _session(String id, String type, int startTs, + {String status = 'done'}) async { + final db = await LocalDb.instance; + await db.insert('sessions', { + 'id': id, + 'start_ts': startTs, + 'end_ts': startTs + 1800, + 'type': type, + 'status': status, + 'source': 'manual', + '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('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); + expect([for (final r in rows) r['type']], ['running', 'yoga']); + expect(rows.first['n'], 1, + reason: 'the 200-day-old run is outside the window'); + }); + + test('an empty table is an empty list, not an error', () async { + expect(await LocalDb.mostUsedActivityTypes(), isEmpty); + }); +} diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index c5e4ce5e1..5fa590838 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -1749,6 +1749,48 @@ void main() { reason: 'the open group, and only the open group'); }); + testWidgets('the quick row shows the most-used activities, most-used first', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 1400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + // `_frame` carries no localizations, so the English fallbacks are what + // these headings assert. + final mostUsed = [ + activityByName('running')!, + activityByName('weight_training')!, + ]; + await tester.pumpWidget(_frame( + ActivityPicker(weightKg: 72.4, mostUsed: mostUsed), + Brightness.light, + 1.0)); + await tester.pumpAndSettle(); + + expect(find.text('MOST DONE'), findsOneWidget); + expect(find.text('QUICK START'), findsNothing); + final running = tester.getTopLeft(find.text('Running').first); + final weights = tester.getTopLeft(find.text('Weight training').first); + expect(running.dx < weights.dx, isTrue, + reason: 'the row is left to right, most-used first'); + expect(tester.takeException(), isNull); + }); + + testWidgets('with no history the row falls back to QUICK START', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 1400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + _frame(const ActivityPicker(weightKg: 72.4), Brightness.light, 1.0)); + await tester.pumpAndSettle(); + + expect(find.text('QUICK START'), findsOneWidget); + expect(find.text('MOST DONE'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('a location denial is named on the live screen', (tester) async { tester.view.physicalSize = const Size(390 * 3, 2200 * 3); From d22cc9e9f035139050cfc1063ee8798e12cbb44f Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 18:23:05 +0000 Subject: [PATCH 2/6] address CodeRabbit review on #21 - mostUsedActivityTypes now groups by the normalized catalogue key (lower/replace, same rule as activityByName), so 'Running' and 'running' count as one activity - workout_screen fetches 24 ranked types and stops at 6 known ones, so unknown types can no longer crowd the quick row out via LIMIT - query failures are recorded via TelemetryService.recordNonFatal before falling back to quickStart Co-authored-by: BucciMobile --- lib/data/db.dart | 25 ++++++++++++++++--------- lib/ui2/screens/workout_screen.dart | 20 ++++++++++++++------ test/most_used_activity_types_test.dart | 13 +++++++++++++ 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 3f39eaae2..ea96012f4 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -14800,13 +14800,18 @@ class LocalDb { ), executor: db); } - /// The `type` values of the most-used finished sessions, most-used first — - /// rows of `{'type', 'n', 'last_ts'}` for the activity picker's quick row. - /// [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. Unknown types are the caller's to - /// drop: the catalogue resolves what it knows via `activityByName`. + /// 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 six + /// unknown types could crowd out every known one before the limit. static Future>> mostUsedActivityTypes({ int? sinceTs, int limit = 6, @@ -14820,8 +14825,10 @@ class LocalDb { } args.add(limit); return db.rawQuery( - 'SELECT type, COUNT(*) AS n, MAX(start_ts) AS last_ts FROM sessions ' - 'WHERE $where GROUP BY type ORDER BY n DESC, last_ts DESC LIMIT ?', + "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 LIMIT ?', args, ); } diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index 6ed7f72c4..002639876 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -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'; @@ -2202,14 +2203,21 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { final since90 = end.subtract(Motion.tick * 86400 * 90).millisecondsSinceEpoch ~/ 1000; - for (final r - in await LocalDb.mostUsedActivityTypes(sinceTs: since90, limit: 6)) { + // Fetch more than the six the row shows: unknown stored types are + // dropped below, and a SQL-side LIMIT 6 could hand back six of those + // and leave the picker on quickStart despite usable history. + for (final r in await LocalDb.mostUsedActivityTypes( + sinceTs: since90, limit: 24)) { final a = activityByName(r['type'] as String?); - if (a != null) mostUsed.add(a); + if (a == null) continue; + mostUsed.add(a); + if (mostUsed.length == 6) break; } - } catch (_) { - // An unreadable table must not take the screen down — the picker falls - // back to quickStart under its own heading. + } 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'); } // The live screen has to have previous/best in hand before the first set; diff --git a/test/most_used_activity_types_test.dart b/test/most_used_activity_types_test.dart index 50c557e90..5d6ebcd5b 100644 --- a/test/most_used_activity_types_test.dart +++ b/test/most_used_activity_types_test.dart @@ -53,6 +53,19 @@ void main() { 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); From a4955f84d6c56b070f50a4e3ec9003520284c34a Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 18:48:15 +0000 Subject: [PATCH 3/6] most-used window: 90 days back from now, not from midnight CodeRabbit review on #21: end is today midnight, so the window ended up to 24h short of a true 90 days. Co-authored-by: BucciMobile --- lib/ui2/screens/workout_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index 002639876..f77f34b53 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -2201,7 +2201,7 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { final mostUsed = []; try { final since90 = - end.subtract(Motion.tick * 86400 * 90).millisecondsSinceEpoch ~/ + now.subtract(Motion.tick * 86400 * 90).millisecondsSinceEpoch ~/ 1000; // Fetch more than the six the row shows: unknown stored types are // dropped below, and a SQL-side LIMIT 6 could hand back six of those From a7c41cef1df1b6b887b70e469936024f63939a32 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 20:39:05 +0000 Subject: [PATCH 4/6] fix failing tests on #21 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - most_used_activity_types_test: inside the 90-day window both types have n=1, so the recency tiebreaker (yoga newer than running) decides the order — assert that instead of the wrong expectation - activityPickerMostDone added to all eight ARB locales so the l10n coverage tests pass (en/de/es/fr/hi/ru/th/zh) Co-authored-by: BucciMobile --- lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_th.arb | 1 + lib/l10n/app_zh.arb | 1 + test/most_used_activity_types_test.dart | 4 +++- 7 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 415c2de32..f23dcc62f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1879,6 +1879,7 @@ "activityPickerNoMatchBody": "El catálogo cubre unas setenta actividades con un coste energético publicado. Elige la más parecida.", "activityPickerQuickStart": "INICIO RÁPIDO", "activityPickerRecent": "RECIENTES", + "activityPickerMostDone": "MÁS USADAS", "activityPickerCalorieEstimatesTitle": "Las calorías son estimaciones", "activityPickerMetValue": "{met} MET", "activityPickerKcalPer30": "{kcal} kcal / 30 min", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7c26d7e89..9272b5ec7 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1867,6 +1867,7 @@ "activityPickerNoMatchBody": "Le catalogue couvre environ soixante-dix activités avec un coût énergétique publié. Choisissez la plus proche.", "activityPickerQuickStart": "DÉMARRAGE RAPIDE", "activityPickerRecent": "RÉCENTES", + "activityPickerMostDone": "PLUS UTILISÉES", "activityPickerCalorieEstimatesTitle": "Les calories sont des estimations", "activityPickerMetValue": "{met} MET", "activityPickerKcalPer30": "{kcal} kcal / 30 min", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 990d67975..632790ef7 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1867,6 +1867,7 @@ "activityPickerNoMatchBody": "कैटलॉग में प्रकाशित ऊर्जा खर्च वाली लगभग सत्तर गतिविधियाँ शामिल हैं। सबसे नज़दीकी गतिविधि चुनें।", "activityPickerQuickStart": "क्विक स्टार्ट", "activityPickerRecent": "हाल की", + "activityPickerMostDone": "सबसे अधिक उपयोग की गईं", "activityPickerCalorieEstimatesTitle": "कैलोरी आंकड़े अनुमानित हैं", "activityPickerMetValue": "{met} MET", "activityPickerKcalPer30": "{kcal} kcal / 30 मिनट", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c86544a36..5c227d3b7 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -4469,6 +4469,7 @@ "activityPickerNoMatchBody": "В каталоге около семидесяти активностей с опубликованными энергозатратами. Выберите ближайшую.", "activityPickerQuickStart": "БЫСТРЫЙ СТАРТ", "activityPickerRecent": "НЕДАВНИЕ", + "activityPickerMostDone": "ЧАЩЕ ВСЕГО", "activityPickerCalorieEstimatesTitle": "Калории — оценочные значения", "activityPickerMetValue": "{met}× от покоя", "@activityPickerMetValue": { diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 924d3dbf9..c10d75cc6 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -9635,6 +9635,7 @@ "@activityPickerRecent": { "description": "Section heading over the recent-activities row" }, + "activityPickerMostDone": "ใช้บ่อยที่สุด", "activityPickerCalorieEstimatesTitle": "ตัวเลขแคลอรีเป็นค่าประมาณ", "@activityPickerCalorieEstimatesTitle": { "description": "Status card title explaining that calorie figures are estimates" diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1cf2c9848..baee4e664 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1867,6 +1867,7 @@ "activityPickerNoMatchBody": "活动库收录了约七十项已公布能耗数据的活动,请选择最接近的一项。", "activityPickerQuickStart": "快速开始", "activityPickerRecent": "最近使用", + "activityPickerMostDone": "最常用", "activityPickerCalorieEstimatesTitle": "卡路里数值为估算值", "activityPickerMetValue": "{met} MET", "activityPickerKcalPer30": "{kcal} 千卡 / 30 分钟", diff --git a/test/most_used_activity_types_test.dart b/test/most_used_activity_types_test.dart index 5d6ebcd5b..9691aad79 100644 --- a/test/most_used_activity_types_test.dart +++ b/test/most_used_activity_types_test.dart @@ -81,7 +81,9 @@ void main() { final rows = await LocalDb.mostUsedActivityTypes(sinceTs: _t0 - 90 * 86400, limit: 2); - expect([for (final r in rows) r['type']], ['running', 'yoga']); + // 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']); expect(rows.first['n'], 1, reason: 'the 200-day-old run is outside the window'); }); From f14aa924110ec1eaaff007880d1f6ea526d9549f Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 22:26:59 +0000 Subject: [PATCH 5/6] address sourcery review on edge#678 - mostUsedActivityTypes: exclude import sources (scored_import, whoop) and future-dated rows (start_ts <= toTs, default now) so the quick row ranks only sessions this app started - workout_screen: fetch 200 ranked types before stopping at 6 known ones - tests: import/future exclusion covered; QUICK START fallback test now also asserts a quick-start tile is actually visible Co-authored-by: BucciMobile --- lib/data/db.dart | 12 ++++++++++-- lib/ui2/screens/workout_screen.dart | 6 +++--- test/most_used_activity_types_test.dart | 14 ++++++++++++-- test/ui2_activity_test.dart | 4 ++++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index ea96012f4..3bb45b69f 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -14810,15 +14810,23 @@ class LocalDb { /// 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 six + /// 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>> mostUsedActivityTypes({ int? sinceTs, + int? toTs, int limit = 6, }) async { final db = await instance; final args = []; - var where = "status = 'done' AND type IS NOT NULL AND type <> ''"; + 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); if (sinceTs != null) { where += ' AND start_ts >= ?'; args.add(sinceTs); diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index f77f34b53..23b11a8bb 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -2203,11 +2203,11 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { final since90 = now.subtract(Motion.tick * 86400 * 90).millisecondsSinceEpoch ~/ 1000; - // Fetch more than the six the row shows: unknown stored types are - // dropped below, and a SQL-side LIMIT 6 could hand back six of those + // 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: 24)) { + sinceTs: since90, limit: 200)) { final a = activityByName(r['type'] as String?); if (a == null) continue; mostUsed.add(a); diff --git a/test/most_used_activity_types_test.dart b/test/most_used_activity_types_test.dart index 9691aad79..a97c5e808 100644 --- a/test/most_used_activity_types_test.dart +++ b/test/most_used_activity_types_test.dart @@ -10,7 +10,7 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const int _t0 = 1_800_000_040; // a fixed epoch second Future _session(String id, String type, int startTs, - {String status = 'done'}) async { + {String status = 'done', String source = 'manual'}) async { final db = await LocalDb.instance; await db.insert('sessions', { 'id': id, @@ -18,7 +18,7 @@ Future _session(String id, String type, int startTs, 'end_ts': startTs + 1800, 'type': type, 'status': status, - 'source': 'manual', + 'source': source, 'created_at': startTs, }); } @@ -88,6 +88,16 @@ void main() { reason: 'the 200-day-old run is outside the window'); }); + 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); }); diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index 5fa590838..4eef5cc90 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -1788,6 +1788,10 @@ void main() { expect(find.text('QUICK START'), findsOneWidget); expect(find.text('MOST DONE'), findsNothing); + // The heading alone proves nothing — the fallback row must actually + // show the quick-start tiles, not render empty under the heading. + expect(find.text('Running'), findsOneWidget, + reason: 'quickStart[0] is Running; its tile must be on screen'); expect(tester.takeException(), isNull); }); From c7814b897aab38ae07db76356565f60c5500bc51 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 9 Nov 2026 04:37:01 +0000 Subject: [PATCH 6/6] mostUsedActivityTypes: deterministic final tie-breaker (type ASC) coderabbit on edge#678. The legacy-manual-import exclusion is NOT applied on purpose: pre-stamp scored health imports carry source manual, indistinguishable from real manual workouts in SQL, so excluding manual would rank nothing at all. Co-authored-by: BucciMobile --- lib/data/db.dart | 2 +- test/most_used_activity_types_test.dart | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 3bb45b69f..223583aa8 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -14836,7 +14836,7 @@ class LocalDb { "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 LIMIT ?', + 'ORDER BY n DESC, last_ts DESC, type ASC LIMIT ?', args, ); } diff --git a/test/most_used_activity_types_test.dart b/test/most_used_activity_types_test.dart index a97c5e808..8b77bb44d 100644 --- a/test/most_used_activity_types_test.dart +++ b/test/most_used_activity_types_test.dart @@ -88,6 +88,14 @@ void main() { 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');