From 2c176c052d72da1e7b06115c71a5e2ecd08c0de0 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:39:49 -0500 Subject: [PATCH] 5053 can get points from lemma with max score (#5078) * make uses a private field for ConstructUses * expose capped list of uses in ConstructUses * filter capped construct uses in getUses --- .../analytics_data_service.dart | 18 ++- .../analytics_data/analytics_database.dart | 119 +++++++-------- .../analytics_data/construct_merge_table.dart | 5 +- .../lemma_usage_dots.dart | 2 +- .../lemma_use_example_messages.dart | 2 +- .../vocab_analytics_details_view.dart | 8 +- .../analytics_dowload_dialog.dart | 8 +- .../space_analytics_summary_model.dart | 2 +- .../analytics_misc/construct_use_model.dart | 135 ++++++++++-------- .../practice_selection_repo.dart | 11 +- 10 files changed, 164 insertions(+), 146 deletions(-) diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 6ac9f1fbb..18a6d4603 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -241,7 +241,23 @@ class AnalyticsDataService { ); final blocked = blockedConstructs; - return uses.where((use) => !blocked.contains(use.identifier)).toList(); + final List filtered = []; + + final Map cappedLastUseCache = {}; + for (final use in uses) { + if (blocked.contains(use.identifier)) continue; + if (!cappedLastUseCache.containsKey(use.identifier)) { + final constructs = await getConstructUse(use.identifier); + cappedLastUseCache[use.identifier] = constructs.cappedLastUse; + } + final cappedLastUse = cappedLastUseCache[use.identifier]; + if (cappedLastUse != null && use.timeStamp.isAfter(cappedLastUse)) { + continue; + } + filtered.add(use); + } + + return filtered; } Future> getLocalUses() async { diff --git a/lib/pangea/analytics_data/analytics_database.dart b/lib/pangea/analytics_data/analytics_database.dart index 836d4e0e5..23dd4be8c 100644 --- a/lib/pangea/analytics_data/analytics_database.dart +++ b/lib/pangea/analytics_data/analytics_database.dart @@ -199,82 +199,55 @@ class AnalyticsDatabase with DatabaseFileStorage { DateTime? since, }) async { final stopwatch = Stopwatch()..start(); - final List uses = []; + final results = []; - // first, get all of the local (most recent) keys - final localKeys = await _localConstructsBox.getAllKeys(); - final localValues = await _localConstructsBox.getAll(localKeys); - final local = Map.fromIterables( - localKeys, - localValues, - ).entries.toList(); - - local.sort( - (a, b) => int.parse(b.key).compareTo(int.parse(a.key)), - ); - - for (final entry in local) { - // filter by date - if (since != null && - int.parse(entry.key) < since.millisecondsSinceEpoch) { - continue; + bool addUseIfValid(OneConstructUse use) { + if (since != null && use.timeStamp.isBefore(since)) { + return false; // stop iteration entirely + } + if (roomId != null && use.metadata.roomId != roomId) { + return true; // skip but continue } - final rawUses = entry.value; - if (rawUses == null) continue; - for (final raw in rawUses) { - // filter by count - if (count != null && uses.length >= count) break; - - final use = OneConstructUse.fromJson( - Map.from(raw), - ); - - // filter by roomID - if (roomId != null && use.metadata.roomId != roomId) { - continue; - } - - uses.add(use); - } - if (count != null && uses.length >= count) break; + results.add(use); + return count == null || results.length < count; } - if (count != null && uses.length >= count) return uses; - // then get server uses - final serverKeys = await _serverConstructsBox.getAllKeys(); - serverKeys.sort( - (a, b) => - int.parse(b.split('|')[1]).compareTo(int.parse(a.split('|')[1])), - ); + // ---- Local uses ---- + final localUses = await getLocalUses() + ..sort((a, b) => b.timeStamp.compareTo(a.timeStamp)); + + for (final use in localUses) { + if (!addUseIfValid(use)) break; + } + + if (count != null && results.length >= count) { + stopwatch.stop(); + Logs().i("Get uses took ${stopwatch.elapsedMilliseconds} ms"); + return results; + } + + // ---- Server uses ---- + final serverKeys = await _serverConstructsBox.getAllKeys() + ..sort( + (a, b) => + int.parse(b.split('|')[1]).compareTo(int.parse(a.split('|')[1])), + ); + for (final key in serverKeys) { - // filter by count - if (count != null && uses.length >= count) break; - final rawUses = await _serverConstructsBox.get(key); - if (rawUses == null) continue; - for (final raw in rawUses) { - if (count != null && uses.length >= count) break; - final use = OneConstructUse.fromJson( - Map.from(raw), - ); + final serverUses = await getServerUses(key) + ..sort((a, b) => b.timeStamp.compareTo(a.timeStamp)); - // filter by roomID - if (roomId != null && use.metadata.roomId != roomId) { - continue; - } - - // filter by date - if (since != null && use.timeStamp.isBefore(since)) { - continue; - } - uses.add(use); + for (final use in serverUses) { + if (!addUseIfValid(use)) break; } + + if (count != null && results.length >= count) break; } stopwatch.stop(); Logs().i("Get uses took ${stopwatch.elapsedMilliseconds} ms"); - - return uses.take(count ?? uses.length).toList(); + return results; } Future> getLocalUses() async { @@ -293,6 +266,21 @@ class AnalyticsDatabase with DatabaseFileStorage { return uses; } + Future> getServerUses(String key) async { + final List uses = []; + final serverValues = await _serverConstructsBox.get(key); + if (serverValues == null) return []; + + for (final entry in serverValues) { + uses.add( + OneConstructUse.fromJson( + Map.from(entry), + ), + ); + } + return uses; + } + Future getLocalConstructCount() async { final keys = await _localConstructsBox.getAllKeys(); return keys.length; @@ -408,8 +396,7 @@ class AnalyticsDatabase with DatabaseFileStorage { } for (final u in usesForKey) { - model.uses.add(u); - model.setLastUsed(u.timeStamp); + model.addUse(u); } updates[key] = model; diff --git a/lib/pangea/analytics_data/construct_merge_table.dart b/lib/pangea/analytics_data/construct_merge_table.dart index 3e3c18239..97d18f67c 100644 --- a/lib/pangea/analytics_data/construct_merge_table.dart +++ b/lib/pangea/analytics_data/construct_merge_table.dart @@ -13,7 +13,10 @@ class ConstructMergeTable { List constructs, Set exclude, ) { - addConstructsByUses(constructs.expand((c) => c.uses).toList(), exclude); + addConstructsByUses( + constructs.expand((c) => c.cappedUses).toList(), + exclude, + ); } void addConstructsByUses( diff --git a/lib/pangea/analytics_details_popup/lemma_usage_dots.dart b/lib/pangea/analytics_details_popup/lemma_usage_dots.dart index f900fa269..aa4e78e83 100644 --- a/lib/pangea/analytics_details_popup/lemma_usage_dots.dart +++ b/lib/pangea/analytics_details_popup/lemma_usage_dots.dart @@ -26,7 +26,7 @@ class LemmaUsageDots extends StatelessWidget { /// Find lemma uses for the given exercise type, to create dot list List sortedUses(LearningSkillsEnum category) { final List useList = []; - for (final OneConstructUse use in construct.uses) { + for (final OneConstructUse use in construct.cappedUses) { if (use.xp == 0) { continue; } diff --git a/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart b/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart index 5c3c9b4dc..86dbc3364 100644 --- a/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart +++ b/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart @@ -25,7 +25,7 @@ class LemmaUseExampleMessages extends StatelessWidget { Future> _getExampleMessages() async { final List examples = []; - for (final OneConstructUse use in construct.uses) { + for (final OneConstructUse use in construct.cappedUses) { if (use.useType.skillsEnumType != LearningSkillsEnum.writing || use.metadata.eventId == null || use.form == null || diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart index 4417973ec..6de39e654 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart @@ -65,13 +65,7 @@ class VocabDetailsView extends StatelessWidget { ? level.color(context) : level.darkColor(context)); - final forms = construct?.uses - .map((e) => e.form) - .whereType() - .toSet() - .toList() ?? - []; - + final forms = construct?.forms ?? []; final tokenText = PangeaTokenText.fromString(constructId.lemma); final token = PangeaToken( text: tokenText, diff --git a/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart b/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart index cf5c2e618..1101945ec 100644 --- a/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart +++ b/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart @@ -165,7 +165,8 @@ class AnalyticsDownloadDialogState extends State { final xp = uses.map((e) => e.points).reduce((a, total) => a + total); final exampleMessages = await _getExampleMessages(uses); - final allUses = uses.map((u) => u.uses).expand((element) => element); + final allUses = + uses.map((u) => u.cappedUses).expand((element) => element); int independantUseOccurrences = 0; int assistedUseOccurrences = 0; @@ -218,7 +219,7 @@ class AnalyticsDownloadDialogState extends State { final xp = uses.points; final exampleMessages = await _getExampleMessages([uses]); - final allUses = uses.uses; + final allUses = uses.cappedUses; int independantUseOccurrences = 0; int assistedUseOccurrences = 0; @@ -261,7 +262,8 @@ class AnalyticsDownloadDialogState extends State { Future> _getExampleMessages( List constructUses, ) async { - final allUses = constructUses.map((e) => e.uses).expand((e) => e).toList(); + final allUses = + constructUses.map((e) => e.cappedUses).expand((e) => e).toList(); final List examples = []; for (final OneConstructUse use in allUses) { if (use.metadata.roomId == null) continue; diff --git a/lib/pangea/analytics_downloads/space_analytics_summary_model.dart b/lib/pangea/analytics_downloads/space_analytics_summary_model.dart index 5c742bdee..ddccf10e9 100644 --- a/lib/pangea/analytics_downloads/space_analytics_summary_model.dart +++ b/lib/pangea/analytics_downloads/space_analytics_summary_model.dart @@ -252,7 +252,7 @@ class SpaceAnalyticsSummaryModel { final systemUsesCorrect = []; final systemUsesIncorrect = []; - for (final use in entry.uses) { + for (final use in entry.cappedUses) { if (originalUseTypes.contains(use.useType)) { use.xp > 0 ? originalUsesCorrect.add(use) diff --git a/lib/pangea/analytics_misc/construct_use_model.dart b/lib/pangea/analytics_misc/construct_use_model.dart index 64744b46f..dd40200c3 100644 --- a/lib/pangea/analytics_misc/construct_use_model.dart +++ b/lib/pangea/analytics_misc/construct_use_model.dart @@ -1,30 +1,35 @@ import 'dart:math'; +import 'package:collection/collection.dart'; + import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; /// One lemma and a list of construct uses for that lemma class ConstructUses { - final List uses; + final List _uses; final ConstructTypeEnum constructType; final String lemma; String? _category; - DateTime? _lastUsed; ConstructUses({ - required this.uses, + required List uses, required this.constructType, required this.lemma, required category, - }) : _category = category; + }) : _category = category, + _uses = List.from(uses) { + _sortUses(); + } // Total points for all uses of this lemma int get points { return min( - uses.fold( + _uses.fold( 0, (total, use) => total + use.xp, ), @@ -32,28 +37,16 @@ class ConstructUses { ); } - DateTime? get lastUsed { - if (_lastUsed != null) return _lastUsed; - final lastUse = uses.fold(null, (DateTime? last, use) { - if (last == null) return use.timeStamp; - return use.timeStamp.isAfter(last) ? use.timeStamp : last; - }); - return _lastUsed = lastUse; - } - - void setLastUsed(DateTime time) { - if (_lastUsed == null || time.isAfter(_lastUsed!)) { - _lastUsed = time; - } - } + DateTime? get lastUsed => _uses.lastOrNull?.timeStamp; + DateTime? get cappedLastUse => cappedUses.lastOrNull?.timeStamp; String get category { if (_category == null || _category!.isEmpty) return "other"; return _category!.toLowerCase(); } - bool get hasCorrectUse => uses.any((use) => use.xp > 0); - bool get hasIncorrectUse => uses.any((use) => use.xp < 0); + bool get hasCorrectUse => _uses.any((use) => use.xp > 0); + bool get hasIncorrectUse => _uses.any((use) => use.xp < 0); ConstructIdentifier get id => ConstructIdentifier( lemma: lemma, @@ -61,38 +54,6 @@ class ConstructUses { category: category, ); - Map toJson() { - final json = { - 'construct_id': id.toJson(), - 'xp': points, - 'last_used': lastUsed?.toIso8601String(), - 'uses': uses.map((e) => e.toJson()).toList(), - }; - return json; - } - - factory ConstructUses.fromJson(Map json) { - final constructId = ConstructIdentifier.fromJson( - Map.from(json['construct_id']), - ); - - List usesJson = []; - if (json['uses'] is List) { - usesJson = List.from(json['uses']); - } - - final uses = usesJson - .map((e) => OneConstructUse.fromJson(Map.from(e))) - .toList(); - - return ConstructUses( - uses: uses, - constructType: constructId.type, - lemma: constructId.lemma, - category: constructId.category, - ); - } - /// Get the lemma category, based on points ConstructLevelEnum get lemmaCategory { if (points < AnalyticsConstants.xpForGreens) { @@ -122,6 +83,66 @@ class ConstructUses { _ => ConstructLevelEnum.flowers, }; + List get forms => + _uses.map((e) => e.form).whereType().toSet().toList(); + + List get cappedUses { + final result = []; + var totalXp = 0; + + for (final use in _uses) { + if (totalXp >= AnalyticsConstants.xpForFlower) break; + totalXp += use.xp; + result.add(use); + } + + return result; + } + + DateTime? lastUseByTypes(List types) => + _uses.lastWhereOrNull((u) => types.contains(u.useType))?.timeStamp; + + Map toJson() { + final json = { + 'construct_id': id.toJson(), + 'xp': points, + 'last_used': lastUsed?.toIso8601String(), + 'uses': _uses.map((e) => e.toJson()).toList(), + }; + return json; + } + + factory ConstructUses.fromJson(Map json) { + final constructId = ConstructIdentifier.fromJson( + Map.from(json['construct_id']), + ); + + List usesJson = []; + if (json['uses'] is List) { + usesJson = List.from(json['uses']); + } + + final uses = usesJson + .map((e) => OneConstructUse.fromJson(Map.from(e))) + .toList(); + + return ConstructUses( + uses: uses, + constructType: constructId.type, + lemma: constructId.lemma, + category: constructId.category, + ); + } + + void _sortUses() { + _uses.sort((a, b) => a.timeStamp.compareTo(b.timeStamp)); + } + + void addUse(OneConstructUse use) { + _uses.add(use); + _sortUses(); + } + void merge(ConstructUses other) { if (other.lemma.toLowerCase() != lemma.toLowerCase() || other.constructType != constructType) { @@ -130,10 +151,8 @@ class ConstructUses { ); } - uses.addAll(other.uses); - if (other.lastUsed != null) { - setLastUsed(other.lastUsed!); - } + _uses.addAll(other._uses); + _sortUses(); if (category == 'other' && other.category != 'other') { _category = other.category; @@ -147,7 +166,7 @@ class ConstructUses { String? category, }) { return ConstructUses( - uses: uses ?? this.uses, + uses: uses ?? _uses, constructType: constructType ?? this.constructType, lemma: lemma ?? this.lemma, category: category ?? _category, diff --git a/lib/pangea/practice_activities/practice_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index babba161a..31afe6a76 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -1,4 +1,3 @@ -import 'package:collection/collection.dart'; import 'package:get_storage/get_storage.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -242,13 +241,11 @@ class PracticeSelectionRepo { for (final token in tokens) { final construct = constructs[idMap[token]]; - final lastUsed = construct?.uses.firstWhereOrNull( - (u) => activityType.associatedUseTypes.contains(u.useType), - ); + final lastUsed = + construct?.lastUseByTypes(activityType.associatedUseTypes); - final daysSinceLastUsed = lastUsed == null - ? 20 - : DateTime.now().difference(lastUsed.timeStamp).inDays; + final daysSinceLastUsed = + lastUsed == null ? 20 : DateTime.now().difference(lastUsed).inDays; scores[token] = daysSinceLastUsed * (token.vocabConstructID.isContentWord ? 10 : 9);