diff --git a/lib/data/db.dart b/lib/data/db.dart index c3ab9f1f6..223583aa8 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -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>> 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 <> '' " + "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); + } + 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 ?', + 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/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/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..23b11a8bb 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'; @@ -154,7 +155,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 +1944,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 +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,35 @@ 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 = + now.subtract(Motion.tick * 86400 * 90).millisecondsSinceEpoch ~/ + 1000; + // 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'); } // The live screen has to have previous/best in hand before the first set; @@ -2224,7 +2242,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..8b77bb44d --- /dev/null +++ b/test/most_used_activity_types_test.dart @@ -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_800_000_040; // a fixed epoch second + +Future _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']); + 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); + }); +} diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index c5e4ce5e1..4eef5cc90 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -1749,6 +1749,52 @@ 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); + // 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); + }); + testWidgets('a location denial is named on the live screen', (tester) async { tester.view.physicalSize = const Size(390 * 3, 2200 * 3);