fluffychat/lib/pangea/analytics_data/analytics_database.dart
ggurdin 6b33ae6ce8
Merge main into prod (#5603)
* fix: restrict height of dropdowns in user menu popup

* chore: make sso button order consistent

* fix: use latest edit to make representations

* chore: show tooltip on full phonetic transcription widget

* chore: shrink tooltip text size

Also give it maxTimelineWidth in chat to match other widgets placement, and give slightly less padding between icons

* feat: show audio message transcripts in vocab practice

* moved some logic around

* chore: check for button in showMessageShimmer

* fix: show error message when not enough data for practice

* fix: clear selected token in activity vocab display on word card dismissed

* chore: throw expection while loading practice session is user is unsubscribed

* fix: account for blocked and capped constructs in analytics download model

* chore: save voice in TTS events and re-request if requested voice doesn't match saved voice

* Fix grammar error null error

and only reload current question upon encountering error

* fix: filter RoomMemberChangeType.other events from timeline

* chore: store font size settings per-user

* fix: oops, don't return null from representationByLanguage (#5301)

* feat: expose construct level up stream

* 5259 bot settings   language settings (#5305)

* feat: add voice to user model

* update bot settings on language / learning settings update

* use room summary to determine member count

* translations

* chore: Remove sentence-level pronunciation (#5306)

* fix: use sync stream to update analytics requests indicator (#5307)

* fix: disable text scaling in learning progress indicators (#5313)

* fix: don't auto-play bot audio message if another audio message is playing (#5315)

* fix: restrict when analytics practice session loss popup is shown (#5316)

* feat: rise and fade animation for construct levels

* fix: hide info about course editing in join mode (#5317)

* chore: update knock copy (#5318)

* fix: switch back to flutter's built in dropdown for cerf level dropdown menu (#5322)

* fix: fix public room sheet navigation (#5323)

* fix: update some Russion translations (#5324)

* feat: bring back old course pages (#5328)

* fix: add more space between text and underline for highlighted tokens (#5332)

* chore: close emoji picker on send message (#5336)

* chore: add copy asking user to search for users in invite public tab (#5338)

* chore: hide invite all in space button if everyone from space is already in room (#5340)

* fix: enable language mismatch popup for activity langs that match l1 (#5341)

* chore: remove set status button in settings (#5343)

* chore: hide option to seperate chat types (#5345)

* add translations for error questions

and some spacing tweaks to improve layout and overflow issues

* forgot to push file and formatting

* feat: enable emoji search (#5350)

* re-enable choice notifier

* fix syntax

* fix: reset audio player after auto-playing bot voice message (#5353)

* fix: set explicit height for expanded nav rail item section (#5356)

* fix: move onTap call up a level in widget tree (#5359)

* chore: increase hitbox size of mini analytics navigation buttons

* chore: clamp number of points shown in gain points animation

* chore: reverse change to cefr level display in saved activities

* chore: empty analytics usage dots display update

* simplify growth animation

remove stream, calculate manually with the analytics feedback for XP, new vocab and new morphs

* chore: update disabled toolbar button color

* cleanup

* Limit activity role to 2 lines, use ellipses if needed

* fetch translation on activity target generation

* Disable l1 translation for audio messages

* fix: use token offset and length to determine where to highlight in example messages

* Hide view status toggle in style view

* Hide status message when viewing profile

* Add tooltip to course analytics button

* feat: add progress bar to IT bar

* chore: show loading indicator on recording dialog start up

* fix: prevent out-of-date lemma loading futures from overriding new futures

* chore: If IGC change is different by a whitespace, apply automatically

* chore: prevent UI block on save activity

* chore: Darken Screen further on Activity End Popup

* chore: show shimmer on full activity role indicator

* fix: use event stream for construct level animation

* remove async function for analytics in chat

and sort imports

* chore: block notification permission request on app launch

* fix: uncomment shouldShowActivityInstructions

* feat: use image as activity background

- add switch tile in settings to toggle
- if set, remove image from activity summary widget

* feat: add alert to notification settings to enable notifications

* translations

* add back bot settings widgets

* chore: If link, treat as regular message

* feat: highlight chat with support

* fix: reset bypassExitConfirmation on session-level error

* Add default images when activity doesn't have image

* feat: Bring back language setting in bot avatar popup

* chore: better match tooltip style

* chore: update constant in level equation to make 6000 xp ~level 10

* chore: keep input focused after send

* chore: if mobile keyboard open on show toolbar, close it and still show toolbar

* fix: add padding to bottom of main chat list to make all items visible

* chore: Expand role card if needed/available space

* fix: account for smaller screens

* fix: remove public course route between find a course and public course preview

* fix: prevent avatar flickering on expand nav rail

* fix: only allow one line of text in grammar match choices

* chore: Default courses to public but restricted

* chore: Keep cursor as hand when mousing over word-card emojis

* fix: use unique storage key for morph info cache

* fix: give morph definition a fixed height to prevent other element from jumping around

* chore: Search for course filter not saved when open new course page

* fix: Prevent Grammar Practice Blank Fill-Ins (#5464)

* feat: filter out new constructs with category 'other' (#5454)

* fix: always show scroll bars in activity user summary widgets (#5465)

* fix: distinguish constuct level up animations by construct ID instead of count (#5468)

* chore: Keep Tooltip until word enters Catagory (#5469)

* feat: filter 'other' constructs from existing analytics data (#5473)

* fix: don't include error span as choice in grammar error practice if the translation contains the error span (#5474)

* chore: translation button style update

translation appears in message bubble like in chat with a pressable button and sound effect

* 5415 if invalid lemma definition breaks practice (#5466)

* skip error causing lemmas in practice

* update progress on skipping

and play audio/update value after loading question, so a skipped questions isn't displayed

* remove unnecessary line and comment

* fix: don't label room as activity room if activityID is null (#5480)

* chore: onboarding updates (#5485)

* chore: update logic for which bot chats are targeted for bot options update on language update, add retry logic (#5488)

* chore: ensure grammar category has example and multiple choices

* chore: add subtitle to chat with support tile (#5494)

* Use vocab symbol for newly collected words (#5489)

* Show different course plan page if 500 error is detected (#5478)

* Show different course plan page if 500 error is detected

* translations

---------

Co-authored-by: ggurdin <ggurdin@gmail.com>

* chore: In user search, append needed decorators (#5495)

* Move login/signup back buttons closer to center of screen (#5496)

* fix: better message offset defaults (#5497)

* chore: more onboarding tweaks (#5499)

* chore: don't give normalization errors or single choices

* chore: update room summary model (#5502)

* fix: Don't shimmer disabled translation button (#5505)

* chore: skip recently practiced grammar errors

wip: only partially works due to analytics not being given to every question

* feat: initial updates to public course preview page (#5453)

* feat: initial updates to public course preview page

* chore: account for join rules and power levels in RoomSummaryResponse

* load room preview in course preview page

* seperate public course preview page from selected course page

* display course admins

* Add avatar URL and display name to room summary. Get courseID from room summary

* don't leave page on knock

* fix: on IT closed, only replace source text if IT manually dismissed to prevent race condition with accepted continuance stream for single-span translation (#5510)

* fix: reset IT progress on send and on edit (#5511)

* chore: show close button on error snackbar (#5512)

* fix: make analytics practice view scrollable, fix heights of top elements to prevent jumping around (#5513)

* fix: save activities to analytics room for corresponding language (#5514)

* chore: make login and signup views more consistent (#5518)

* fix: return capped uses

allows all grammar error targets to be searched for recent uses and filtered out, even maxed out ones

* fix: prevent activity title from jumping on phonetic transcription load (#5519)

* chore: fix inkwell border radius in activity summary (#5520)

* fix: listen to scroll metrics to update scroll down button (#5522)

* chore: update copy for auto-igc toggle (#5523)

* chore: error on empty audio recording (#5524)

* chore: show correct answer hint button

and don't show answer description on selection of correct answer

* make grammar icons larger and more spaced

* chore: update bot target gender on user settings gender update (#5528)

* fix: use correct stripe management URL in staging environment (#5530)

* fix: update activity analytics stream on reinit analytics (#5532)

* chore: add padding to extended activity description (#5534)

* chore: don't add artificial profile to DM search results (#5535)

* fix: update language chips materialTapTargetSize (#5538)

* fix: add exampleMessage to AnalyticsActivityTarget

and remove it from PracticeTarget

* fix: only call getUses once in fetchErrors

* feat: make deeplinks work for public course preview page (#5540)

* fix: use stream to always update saved activity list on language update (#5541)

* fix: use MorphInfoRepo to filter valid morph categories

* feat: track end date on cancel subscription click and refresh page when end date changes (#5542)

* initial work to add enable notifications to onboarding

* notification page navigation

* chore: add morphExampleInfo to activity model

* fix: missing line

* fix login redirect

* move try-catch into request permission function

* fix typos, dispose value notifier

* fix: update UI on reply / edit event update

* fix: update data type of user genders in bot options model

* fix: move use activity image background setting into pangea user-specific style settings

* fix: one click to close word card in activity vocab

* fix: don't show error on cancel add recovery email

* fix: filter edited events from search results

* feat: add new parts of speech (idiom, phrasal verb, compound) and update localization (#5564)

* fix: include stt for audio messages in level summary request

* fix: don't pop from language selection page when not possible

* fix: add new parts of speech to function for getting grammar copy (#5586)

* chore: bump version to 4.1.17+7

---------

Co-authored-by: Ava Shilling <165050625+avashilling@users.noreply.github.com>
Co-authored-by: Kelrap <kel.raphael3@outlook.com>
Co-authored-by: Kelrap <99418823+Kelrap@users.noreply.github.com>
Co-authored-by: wcjord <32568597+wcjord@users.noreply.github.com>
2026-02-09 15:56:43 -05:00

656 lines
19 KiB
Dart

// ignore_for_file: implementation_imports, depend_on_referenced_packages
import 'dart:async';
import 'dart:math';
import 'package:matrix/matrix.dart';
import 'package:sqflite_common/sqflite.dart';
import 'package:synchronized/synchronized.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:matrix/src/database/database_file_storage_stub.dart'
if (dart.library.io) 'package:matrix/src/database/database_file_storage_io.dart';
import 'package:matrix/src/database/indexeddb_box.dart'
if (dart.library.io) 'package:matrix/src/database/sqflite_box.dart';
class AnalyticsDatabase with DatabaseFileStorage {
final String name;
late BoxCollection _collection;
late Box<String> _lastEventTimestampBox;
late Box<List> _serverConstructsBox;
late Box<List> _localConstructsBox;
late Box<Map> _aggregatedServerVocabConstructsBox;
late Box<Map> _aggregatedLocalVocabConstructsBox;
late Box<Map> _aggregatedServerMorphConstructsBox;
late Box<Map> _aggregatedLocalMorphConstructsBox;
late Box<Map> _derivedStatsBox;
static const String _serverConstructsBoxName = 'box_server_constructs';
static const String _localConstructsBoxName = 'box_local_constructs';
/// Key is Tuple of construct lemma, type, and category
static const String _aggregatedServerVocabConstructsBoxName =
'box_aggregated_server_vocab_constructs';
static const String _aggregatedLocalVocabConstructsBoxName =
'box_aggregated_local_vocab_constructs';
static const String _aggregatedServerMorphConstructsBoxName =
'box_aggregated_server_morph_constructs';
static const String _aggregatedLocalMorphConstructsBoxName =
'box_aggregated_local_morph_constructs';
static const String _derivedStatsBoxName = 'box_derived_stats';
static const String _lastEventTimestampBoxName = 'box_last_event_timestamp';
Database? database;
/// Custom IdbFactory used to create the indexedDB. On IO platforms it would
/// lead to an error to import "dart:indexed_db" so this is dynamically
/// typed.
final dynamic idbFactory;
/// Custom SQFlite Database Factory used for high level operations on IO
/// like delete. Set it if you want to use sqlite FFI.
final DatabaseFactory? sqfliteFactory;
static Future<AnalyticsDatabase> init(
String name, {
Database? database,
dynamic idbFactory,
DatabaseFactory? sqfliteFactory,
Uri? fileStorageLocation,
Duration? deleteFilesAfterDuration,
}) async {
final analyticsDatabase = AnalyticsDatabase._(
name,
database: database,
idbFactory: idbFactory,
sqfliteFactory: sqfliteFactory,
fileStorageLocation: fileStorageLocation,
deleteFilesAfterDuration: deleteFilesAfterDuration,
);
await analyticsDatabase.open();
return analyticsDatabase;
}
AnalyticsDatabase._(
this.name, {
this.database,
this.idbFactory,
this.sqfliteFactory,
Uri? fileStorageLocation,
Duration? deleteFilesAfterDuration,
}) {
this.fileStorageLocation = fileStorageLocation;
this.deleteFilesAfterDuration = deleteFilesAfterDuration;
}
final _lock = Lock();
Future<void> open() async {
_collection = await BoxCollection.open(
name,
{
_lastEventTimestampBoxName,
_serverConstructsBoxName,
_localConstructsBoxName,
_aggregatedServerVocabConstructsBoxName,
_aggregatedLocalVocabConstructsBoxName,
_aggregatedServerMorphConstructsBoxName,
_aggregatedLocalMorphConstructsBoxName,
_derivedStatsBoxName,
},
sqfliteDatabase: database,
sqfliteFactory: sqfliteFactory,
idbFactory: idbFactory,
version: MatrixSdkDatabase.version,
);
_lastEventTimestampBox = _collection.openBox<String>(
_lastEventTimestampBoxName,
);
_serverConstructsBox = _collection.openBox<List>(
_serverConstructsBoxName,
);
_localConstructsBox = _collection.openBox<List>(
_localConstructsBoxName,
);
_aggregatedServerVocabConstructsBox = _collection.openBox<Map>(
_aggregatedServerVocabConstructsBoxName,
);
_aggregatedLocalVocabConstructsBox = _collection.openBox<Map>(
_aggregatedLocalVocabConstructsBoxName,
);
_aggregatedServerMorphConstructsBox = _collection.openBox<Map>(
_aggregatedServerMorphConstructsBoxName,
);
_aggregatedLocalMorphConstructsBox = _collection.openBox<Map>(
_aggregatedLocalMorphConstructsBoxName,
);
_derivedStatsBox = _collection.openBox<Map>(
_derivedStatsBoxName,
);
}
Future<void> delete() async {
await _collection.deleteDatabase(
database?.path ?? name,
sqfliteFactory ?? idbFactory,
);
}
Future<void> clear() async {
_lastEventTimestampBox.clearQuickAccessCache();
_serverConstructsBox.clearQuickAccessCache();
_localConstructsBox.clearQuickAccessCache();
_aggregatedServerVocabConstructsBox.clearQuickAccessCache();
_aggregatedLocalVocabConstructsBox.clearQuickAccessCache();
_aggregatedServerMorphConstructsBox.clearQuickAccessCache();
_aggregatedLocalMorphConstructsBox.clearQuickAccessCache();
_derivedStatsBox.clearQuickAccessCache();
await _collection.clear();
}
Future<T> _transaction<T>(Future<T> Function() action) {
return _lock.synchronized(action);
}
Box<Map> _aggBox(ConstructTypeEnum type, bool local) =>
switch ((type, local)) {
(ConstructTypeEnum.vocab, true) => _aggregatedLocalVocabConstructsBox,
(ConstructTypeEnum.vocab, false) => _aggregatedServerVocabConstructsBox,
(ConstructTypeEnum.morph, true) => _aggregatedLocalMorphConstructsBox,
(ConstructTypeEnum.morph, false) => _aggregatedServerMorphConstructsBox,
};
Future<String?> getUserID() => _lastEventTimestampBox.get('user_id');
Future<DateTime?> getLastUpdated() async {
final entry = await _lastEventTimestampBox.get('last_updated');
if (entry == null) return null;
return DateTime.tryParse(entry);
}
Future<DateTime?> getLastEventTimestamp() async {
final timestampString =
await _lastEventTimestampBox.get('last_event_timestamp');
if (timestampString == null) return null;
return DateTime.parse(timestampString);
}
Future<DerivedAnalyticsDataModel> getDerivedStats() async {
final raw = await _derivedStatsBox.get('derived_stats');
return raw == null
? DerivedAnalyticsDataModel()
: DerivedAnalyticsDataModel.fromJson(
Map<String, dynamic>.from(raw),
);
}
Future<List<OneConstructUse>> getUses({
int? count,
String? roomId,
DateTime? since,
ConstructUseTypeEnum? type,
}) async {
final stopwatch = Stopwatch()..start();
final results = <OneConstructUse>[];
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
}
if (type != null && use.useType != type) {
return true; // skip but continue
}
results.add(use);
return count == null || results.length < count;
}
// ---- 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) {
final serverUses = await getServerUses(key)
..sort((a, b) => b.timeStamp.compareTo(a.timeStamp));
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 results;
}
Future<List<OneConstructUse>> getLocalUses() async {
final List<OneConstructUse> uses = [];
final localKeys = await _localConstructsBox.getAllKeys();
final localValues = await _localConstructsBox.getAll(localKeys);
for (final rawList in localValues) {
if (rawList == null) continue;
for (final raw in rawList) {
final use = OneConstructUse.fromJson(
Map<String, dynamic>.from(raw),
);
uses.add(use);
}
}
return uses;
}
Future<List<OneConstructUse>> getServerUses(String key) async {
final List<OneConstructUse> uses = [];
final serverValues = await _serverConstructsBox.get(key);
if (serverValues == null) return [];
for (final entry in serverValues) {
uses.add(
OneConstructUse.fromJson(
Map<String, dynamic>.from(entry),
),
);
}
return uses;
}
Future<int> getLocalConstructCount() async {
final keys = await _localConstructsBox.getAllKeys();
return keys.length;
}
Future<List<String>> getVocabConstructKeys() async {
final serverKeys = await _aggregatedServerVocabConstructsBox.getAllKeys();
final localKeys = await _aggregatedLocalVocabConstructsBox.getAllKeys();
return [...serverKeys, ...localKeys];
}
Future<List<String>> getMorphConstructKeys() async {
final serverKeys = await _aggregatedServerMorphConstructsBox.getAllKeys();
final localKeys = await _aggregatedLocalMorphConstructsBox.getAllKeys();
return [...serverKeys, ...localKeys];
}
Future<ConstructUses> getConstructUse(
List<ConstructIdentifier> ids,
) async {
assert(ids.isNotEmpty);
final ConstructUses construct = ConstructUses(
uses: [],
constructType: ids.first.type,
lemma: ids.first.lemma,
category: ids.first.category,
);
for (final id in ids) {
final key = id.storageKey;
ConstructUses? server;
ConstructUses? local;
final serverBox = _aggBox(id.type, false);
final localBox = _aggBox(id.type, true);
final serverRaw = await serverBox.get(key);
if (serverRaw != null) {
server = ConstructUses.fromJson(
Map<String, dynamic>.from(serverRaw),
);
}
final localRaw = await localBox.get(key);
if (localRaw != null) {
local = ConstructUses.fromJson(
Map<String, dynamic>.from(localRaw),
);
}
if (server != null) construct.merge(server);
if (local != null) construct.merge(local);
}
return construct;
}
Future<Map<ConstructIdentifier, ConstructUses>> getConstructUses(
Map<ConstructIdentifier, List<ConstructIdentifier>> ids,
) async {
final Map<ConstructIdentifier, ConstructUses> results = {};
for (final entry in ids.entries) {
final construct = await getConstructUse(entry.value);
results[entry.key] = construct;
}
return results;
}
Future<void> clearLocalConstructData() async {
await _transaction(() async {
await _localConstructsBox.clear();
await _aggregatedLocalVocabConstructsBox.clear();
await _aggregatedLocalMorphConstructsBox.clear();
});
}
/// Group uses by aggregate key
Map<String, List<OneConstructUse>> _groupUses(
List<OneConstructUse> uses,
) {
final Map<String, List<OneConstructUse>> grouped = {};
for (final u in uses) {
final key = u.identifier.storageKey;
(grouped[key] ??= []).add(u);
}
return grouped;
}
Map<String, ConstructUses> _aggregateConstructs(
Map<String, List<OneConstructUse>> groups,
Map<String, Map<dynamic, dynamic>?> existingRaw,
) {
final Map<String, ConstructUses> updates = {};
for (final entry in groups.entries) {
final key = entry.key;
final usesForKey = entry.value;
final raw = existingRaw[key];
ConstructUses model;
if (raw != null) {
model = ConstructUses.fromJson(Map<String, dynamic>.from(raw));
} else {
final u = usesForKey.first;
model = ConstructUses(
uses: [],
constructType: u.constructType,
lemma: u.lemma,
category: u.category,
);
}
model.addUses(usesForKey);
updates[key] = model;
}
return updates;
}
Future<Map<String, ConstructUses>> _aggregateFromBox(
Box<Map> box,
Map<String, List<OneConstructUse>> grouped,
) async {
final keys = grouped.keys.toList();
final existing = await box.getAll(keys);
final existingMap = Map.fromIterables(keys, existing);
return _aggregateConstructs(grouped, existingMap);
}
Future<List<ConstructUses>> getAggregatedConstructs(
ConstructTypeEnum type,
) async {
Map<String, ConstructUses> combined = {};
final stopwatch = Stopwatch()..start();
final localKeys = await _aggBox(type, true).getAllKeys();
final serverKeys = await _aggBox(type, false).getAllKeys();
final serverValues = await _aggBox(type, false).getAll(serverKeys);
final serverConstructs = serverValues
.map((e) => ConstructUses.fromJson(Map<String, dynamic>.from(e!)))
.toList();
final serverAgg = Map.fromIterables(
serverKeys,
serverConstructs,
);
if (localKeys.isEmpty) {
combined = serverAgg;
} else {
final localValues = await _aggBox(type, true).getAll(localKeys);
final localConstructs = localValues
.map((e) => ConstructUses.fromJson(Map<String, dynamic>.from(e!)))
.toList();
final localAgg = Map.fromIterables(
localKeys,
localConstructs,
);
combined = Map<String, ConstructUses>.from(serverAgg);
for (final entry in localAgg.entries) {
final key = entry.key;
final localModel = entry.value;
if (combined.containsKey(key)) {
final serverModel = combined[key]!;
serverModel.merge(localModel);
combined[key] = serverModel;
} else {
combined[key] = localModel;
}
}
}
stopwatch.stop();
Logs().i(
"Combining aggregates took ${stopwatch.elapsedMilliseconds} ms",
);
return combined.values.toList();
}
Future<void> updateUserID(String userID) {
return _transaction(() async {
await _lastEventTimestampBox.put(
'user_id',
userID,
);
});
}
Future<void> updateLastUpdated(DateTime timestamp) {
return _transaction(() async {
await _lastEventTimestampBox.put(
'last_updated',
timestamp.toIso8601String(),
);
});
}
Future<void> updateXPOffset(int offset) {
return _transaction(() async {
final stats = await getDerivedStats();
final updatedStats = stats.copyWith(offset: offset);
await _derivedStatsBox.put(
'derived_stats',
updatedStats.toJson(),
);
});
}
Future<void> updateDerivedStats(DerivedAnalyticsDataModel newStats) =>
_derivedStatsBox.put(
'derived_stats',
newStats.toJson(),
);
Future<void> updateServerAnalytics(
List<ConstructAnalyticsEvent> events,
) async {
if (events.isEmpty) return;
final stopwatch = Stopwatch()..start();
await _transaction(() async {
final lastUpdated = await getLastEventTimestamp();
DateTime mostRecent = lastUpdated ?? events.first.event.originServerTs;
final existingKeys = (await _serverConstructsBox.getAllKeys()).toSet();
final List<OneConstructUse> aggregatedVocabUses = [];
final List<OneConstructUse> aggregatedMorphUses = [];
final Map<String, List<OneConstructUse>> pendingWrites = {};
for (final event in events) {
final ts = event.event.originServerTs;
final key = TupleKey(
event.event.eventId,
ts.millisecondsSinceEpoch.toString(),
).toString();
if (lastUpdated != null && ts.isBefore(lastUpdated)) continue;
if (existingKeys.contains(key)) continue;
if (ts.isAfter(mostRecent)) mostRecent = ts;
pendingWrites[key] = event.content.uses;
for (final u in event.content.uses) {
u.constructType == ConstructTypeEnum.vocab
? aggregatedVocabUses.add(u)
: aggregatedMorphUses.add(u);
}
}
if (pendingWrites.isEmpty) return;
// Write events sequentially
for (final e in pendingWrites.entries) {
_serverConstructsBox.put(
e.key,
e.value.map((u) => u.toJson()).toList(),
);
}
// Update aggregates
final aggVocabUpdates = await _aggregateFromBox(
_aggregatedServerVocabConstructsBox,
_groupUses(aggregatedVocabUses),
);
for (final entry in aggVocabUpdates.entries) {
await _aggregatedServerVocabConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
final aggMorphUpdates = await _aggregateFromBox(
_aggregatedServerMorphConstructsBox,
_groupUses(aggregatedMorphUses),
);
for (final entry in aggMorphUpdates.entries) {
await _aggregatedServerMorphConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
// Update timestamp
await _lastEventTimestampBox.put(
'last_event_timestamp',
mostRecent.toIso8601String(),
);
});
await updateLastUpdated(DateTime.now());
stopwatch.stop();
Logs().i(
"Server analytics update took ${stopwatch.elapsedMilliseconds} ms",
);
}
Future<void> updateLocalAnalytics(
List<OneConstructUse> uses,
) async {
if (uses.isEmpty) return;
final stopwatch = Stopwatch()..start();
await _transaction(() async {
// Store local constructs
final key = DateTime.now().millisecondsSinceEpoch;
_localConstructsBox.put(
key.toString(),
uses.map((u) => u.toJson()).toList(),
);
final List<OneConstructUse> vocabUses = [];
final List<OneConstructUse> morphUses = [];
for (final u in uses) {
u.constructType == ConstructTypeEnum.vocab
? vocabUses.add(u)
: morphUses.add(u);
}
// Update aggregates
final aggVocabUpdates = await _aggregateFromBox(
_aggregatedLocalVocabConstructsBox,
_groupUses(vocabUses),
);
for (final entry in aggVocabUpdates.entries) {
await _aggregatedLocalVocabConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
final aggMorphUpdates = await _aggregateFromBox(
_aggregatedLocalMorphConstructsBox,
_groupUses(morphUses),
);
for (final entry in aggMorphUpdates.entries) {
await _aggregatedLocalMorphConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
});
await updateLastUpdated(DateTime.now());
stopwatch.stop();
Logs().i("Local analytics update took ${stopwatch.elapsedMilliseconds} ms");
}
}