Skip to content
Draft
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
41 changes: 41 additions & 0 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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<String> _sensorScoreColumns = [
'calories',
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_hi.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,7 @@
"activityPickerNoMatchBody": "कैटलॉग में प्रकाशित ऊर्जा खर्च वाली लगभग सत्तर गतिविधियाँ शामिल हैं। सबसे नज़दीकी गतिविधि चुनें।",
"activityPickerQuickStart": "क्विक स्टार्ट",
"activityPickerRecent": "हाल की",
"activityPickerMostDone": "सबसे अधिक उपयोग की गईं",
"activityPickerCalorieEstimatesTitle": "कैलोरी आंकड़े अनुमानित हैं",
"activityPickerMetValue": "{met} MET",
"activityPickerKcalPer30": "{kcal} kcal / 30 मिनट",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_ru.arb
Original file line number Diff line number Diff line change
Expand Up @@ -4469,6 +4469,7 @@
"activityPickerNoMatchBody": "В каталоге около семидесяти активностей с опубликованными энергозатратами. Выберите ближайшую.",
"activityPickerQuickStart": "БЫСТРЫЙ СТАРТ",
"activityPickerRecent": "НЕДАВНИЕ",
"activityPickerMostDone": "ЧАЩЕ ВСЕГО",
"activityPickerCalorieEstimatesTitle": "Калории — оценочные значения",
"activityPickerMetValue": "{met}× от покоя",
"@activityPickerMetValue": {
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_th.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,7 @@
"activityPickerNoMatchBody": "活动库收录了约七十项已公布能耗数据的活动,请选择最接近的一项。",
"activityPickerQuickStart": "快速开始",
"activityPickerRecent": "最近使用",
"activityPickerMostDone": "最常用",
"activityPickerCalorieEstimatesTitle": "卡路里数值为估算值",
"activityPickerMetValue": "{met} MET",
"activityPickerKcalPer30": "{kcal} 千卡 / 30 分钟",
Expand Down
24 changes: 12 additions & 12 deletions lib/ui2/activity/picker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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<Activity> 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<Activity> mostUsed;

/// Where a chosen activity goes. Defaults to [ActivitySetup].
final void Function(BuildContext c, Activity a)? onPick;
Expand All @@ -36,7 +36,7 @@ class ActivityPicker extends StatefulWidget {
super.key,
this.weightKg,
this.host = ActivityHost.none,
this.recent = const [],
this.mostUsed = const [],
this.onPick,
});

Expand Down Expand Up @@ -152,18 +152,18 @@ class _ActivityPickerState extends State<ActivityPicker> {
]),
)
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(
Expand Down
50 changes: 34 additions & 16 deletions lib/ui2/screens/workout_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 [],
Expand Down Expand Up @@ -2187,19 +2189,35 @@ 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
final mostUsed = <Activity>[];
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;
Expand All @@ -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,
Expand Down
112 changes: 112 additions & 0 deletions test/most_used_activity_types_test.dart
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_800_000_040; // a fixed epoch second

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']);
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);
});
}
Loading
Loading