updates to how analytics events are processed, stored, and displayed. Added automatic updating of student analytics events.

This commit is contained in:
ggurdin 2024-06-07 13:58:37 -04:00
parent 0e47b84552
commit 20cdc3796a
35 changed files with 989 additions and 1568 deletions

View file

@ -845,6 +845,7 @@ class ChatListController extends State<ChatList>
if (mounted) {
GoogleAnalytics.analyticsUserUpdate(client.userID);
await pangeaController.subscriptionController.initialize();
await pangeaController.myAnalytics.addEventsListener();
pangeaController.afterSyncAndFirstLoginInitialization(context);
await pangeaController.inviteBotToExistingSpaces();
await pangeaController.setPangeaPushRules();

View file

@ -11,4 +11,5 @@ class PLocalKey {
static const String dismissedPaywall = 'dismissedPaywall';
static const String paywallBackoff = 'paywallBackoff';
static const String autoPlayMessages = 'autoPlayMessages';
static const String messagesSinceUpdate = 'messagesSinceLastUpdate';
}

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer';
import 'package:collection/collection.dart';
@ -5,20 +6,24 @@ import 'package:fluffychat/pangea/constants/match_rule_ids.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/time_span.dart';
import 'package:fluffychat/pangea/models/constructs_event.dart';
import 'package:fluffychat/pangea/models/summary_analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_event.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../constants/class_default_values.dart';
import '../extensions/client_extension/client_extension.dart';
import '../extensions/pangea_room_extension/pangea_room_extension.dart';
import '../models/chart_analytics_model.dart';
import '../models/analytics/chart_analytics_model.dart';
import 'base_controller.dart';
import 'pangea_controller.dart';
// controls the fetching of analytics data
class AnalyticsController extends BaseController {
late PangeaController _pangeaController;
@ -29,6 +34,7 @@ class AnalyticsController extends BaseController {
_pangeaController = pangeaController;
}
///////// TIME SPANS //////////
String get _analyticsTimeSpanKey => "ANALYTICS_TIME_SPAN_KEY";
TimeSpan get currentAnalyticsTimeSpan {
@ -57,50 +63,137 @@ class AnalyticsController extends BaseController {
);
}
Future<List<SummaryAnalyticsEvent>> allMySummaryAnalytics() async {
Future<DateTime?> myAnalyticsLastUpdated(String type) async {
// given an analytics event type, get the last updated times
// for each of the user's analytics rooms and return the most recent
// Most Recent instead of the oldest because, for instance:
// My last Spanish event was sent 3 days ago.
// My last English event was sent 1 day ago.
// When I go to check if the cached data is out of date, the cached item was set 2 days ago.
// I know theres new data available because the English update data (the most recent) is after the caches creation time.
// So, I should update the cache.
final List<Room> analyticsRooms = _pangeaController
.matrixState.client.allMyAnalyticsRooms
.where((room) => room.isAnalyticsRoom)
.toList();
final List<DateTime> lastUpdates = [];
for (final Room analyticsRoom in analyticsRooms) {
final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated(
type,
_pangeaController.matrixState.client.userID!,
);
if (lastUpdated != null) {
lastUpdates.add(lastUpdated);
}
}
if (lastUpdates.isEmpty) return null;
return lastUpdates.reduce(
(check, mostRecent) => check.isAfter(mostRecent) ? check : mostRecent,
);
}
Future<DateTime?> spaceAnalyticsLastUpdated(
String type,
Room space,
String langCode,
) async {
// check if any students have recently updated their analytics
// if any have, then the cache needs to be updated
// TODO - figure out how to do this on a per-student basis
await space.requestParticipants();
final List<Future<DateTime?>> lastUpdatedFutures = [];
for (final student in space.students) {
final Room? analyticsRoom = _pangeaController.matrixState.client
.analyticsRoomLocal(langCode, student.id);
if (analyticsRoom == null) continue;
lastUpdatedFutures.add(
analyticsRoom.analyticsLastUpdated(
type,
student.id,
),
);
}
final List<DateTime?> lastUpdatedWithNulls =
await Future.wait(lastUpdatedFutures);
final List<DateTime> lastUpdates =
lastUpdatedWithNulls.where((e) => e != null).cast<DateTime>().toList();
if (lastUpdates.isNotEmpty) {
return lastUpdates.reduce(
(check, mostRecent) => check.isAfter(mostRecent) ? check : mostRecent,
);
}
return null;
}
//////////////////////////// MESSAGE SUMMARY ANALYTICS ////////////////////////////
Future<List<SummaryAnalyticsEvent>> mySummaryAnalytics() async {
// gets all the summary analytics events for the user
// since the current timespace's cut off date
final analyticsRooms =
_pangeaController.matrixState.client.allMyAnalyticsRooms;
final List<SummaryAnalyticsEvent> allEvents = [];
// TODO switch to using list of futures
for (final Room analyticsRoom in analyticsRooms) {
final List<SummaryAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
final List<AnalyticsEvent>? roomEvents =
await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.summaryAnalytics,
since: currentAnalyticsTimeSpan.cutOffDate,
))
?.cast<SummaryAnalyticsEvent>();
allEvents.addAll(roomEvents ?? []);
userId: _pangeaController.matrixState.client.userID!,
);
allEvents.addAll(
roomEvents?.cast<SummaryAnalyticsEvent>() ?? [],
);
}
return allEvents;
}
Future<List<SummaryAnalyticsEvent>> allSpaceMemberAnalytics(
Future<List<SummaryAnalyticsEvent>> spaceMemberAnalytics(
Room space,
) async {
// gets all the summary analytics events for the students
// in a space since the current timespace's cut off date
final langCode = _pangeaController.languageController.activeL2Code(
roomID: space.id,
);
final List<SummaryAnalyticsEvent> analyticsEvents = [];
// ensure that all the space's events are loaded (mainly the for langCode)
// and that the participants are loaded
await space.postLoad();
await space.requestParticipants();
// TODO switch to using list of futures
final List<SummaryAnalyticsEvent> analyticsEvents = [];
for (final student in space.students) {
final Room? analyticsRoom = _pangeaController.matrixState.client
.analyticsRoomLocal(langCode, student.id);
if (analyticsRoom != null) {
final List<SummaryAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
final List<AnalyticsEvent>? roomEvents =
await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.summaryAnalytics,
since: currentAnalyticsTimeSpan.cutOffDate,
))
?.cast<SummaryAnalyticsEvent>();
analyticsEvents.addAll(roomEvents ?? []);
userId: student.id,
);
analyticsEvents.addAll(
roomEvents?.cast<SummaryAnalyticsEvent>() ?? [],
);
}
}
// get a list of all the space's children, including sub-space children
final resp = await space.client.getSpaceHierarchy(space.id);
final List<String> spaceChildrenIds =
resp.rooms.map((room) => room.roomId).toList();
// filter out the analyics events that don't belong to the space's children
final List<SummaryAnalyticsEvent> allAnalyticsEvents = [];
for (final analyticsEvent in analyticsEvents) {
analyticsEvent.content.messages.removeWhere(
@ -115,10 +208,10 @@ class AnalyticsController extends BaseController {
ChartAnalyticsModel? getAnalyticsLocal({
TimeSpan? timeSpan,
required AnalyticsSelected defaultSelected,
required DateTime? analyticsLastUpdated,
AnalyticsSelected? selected,
bool forceUpdate = false,
bool updateExpired = false,
DateTime? lastUpdated,
}) {
timeSpan ??= currentAnalyticsTimeSpan;
final int index = _cachedAnalyticsModels.indexWhere(
@ -131,11 +224,9 @@ class AnalyticsController extends BaseController {
);
if (index != -1) {
final DateTime? cachedLastUpdate =
_cachedAnalyticsModels[index].summaryLastUpdated;
if ((updateExpired && _cachedAnalyticsModels[index].isExpired) ||
forceUpdate ||
cachedLastUpdate != analyticsLastUpdated) {
_cachedAnalyticsModels[index].needsUpdate(lastUpdated)) {
_cachedAnalyticsModels.removeAt(index);
} else {
return _cachedAnalyticsModels[index].chartAnalyticsModel;
@ -148,7 +239,6 @@ class AnalyticsController extends BaseController {
void cacheAnalytics({
required ChartAnalyticsModel chartAnalyticsModel,
required AnalyticsSelected defaultSelected,
required DateTime? summaryLastUpdated,
AnalyticsSelected? selected,
TimeSpan? timeSpan,
}) {
@ -158,7 +248,6 @@ class AnalyticsController extends BaseController {
chartAnalyticsModel: chartAnalyticsModel,
defaultSelected: defaultSelected,
selected: selected,
summaryLastUpdated: summaryLastUpdated,
),
);
}
@ -256,6 +345,14 @@ class AnalyticsController extends BaseController {
Room? space,
AnalyticsSelected? selected,
}) async {
for (int i = 0; i < unfilteredAnalytics.length; i++) {
unfilteredAnalytics[i].content.messages.removeWhere(
(record) => record.time.isBefore(
currentAnalyticsTimeSpan.cutOffDate,
),
);
}
switch (selected?.type) {
case null:
return unfilteredAnalytics;
@ -286,20 +383,11 @@ class AnalyticsController extends BaseController {
bool forceUpdate = false,
}) async {
try {
final DateTime? analyticsLastUpdated = await _pangeaController.myAnalytics
.analyticsLastUpdated(PangeaEventTypes.summaryAnalytics);
final local = getAnalyticsLocal(
defaultSelected: defaultSelected,
selected: selected,
forceUpdate: forceUpdate,
analyticsLastUpdated: analyticsLastUpdated,
);
if (local != null && !forceUpdate) {
return local;
}
await _pangeaController.matrixState.client.roomsLoading;
// if the user is looking at space analytics, then fetch the space
Room? space;
String? langCode;
if (defaultSelected.type == AnalyticsEntryType.space) {
space = _pangeaController.matrixState.client.getRoomById(
defaultSelected.id,
@ -317,13 +405,60 @@ class AnalyticsController extends BaseController {
timeSpan: currentAnalyticsTimeSpan,
);
}
await space.postLoad();
langCode = _pangeaController.languageController.activeL2Code(
roomID: space.id,
);
if (langCode == null) {
ErrorHandler.logError(
m: "langCode missing in getAnalytics",
data: {
"space": space,
},
);
return ChartAnalyticsModel(
msgs: [],
timeSpan: currentAnalyticsTimeSpan,
);
}
}
DateTime? lastUpdated;
if (defaultSelected.type != AnalyticsEntryType.space) {
// if default selected view is my analytics, check for the last
// time the logged in user updated their analytics events
// this gets passed to getAnalyticsLocal to determine if the cached
// entry is out-of-date
lastUpdated = await myAnalyticsLastUpdated(
PangeaEventTypes.summaryAnalytics,
);
} else {
// else, get the last time a student in the space updated their analytics
lastUpdated = await spaceAnalyticsLastUpdated(
PangeaEventTypes.summaryAnalytics,
space!,
langCode!,
);
}
final ChartAnalyticsModel? local = getAnalyticsLocal(
defaultSelected: defaultSelected,
selected: selected,
forceUpdate: forceUpdate,
lastUpdated: lastUpdated,
);
if (local != null && !forceUpdate) {
return local;
}
// get all the relevant summary analytics events for the current timespan
final List<SummaryAnalyticsEvent> summaryEvents =
defaultSelected.type == AnalyticsEntryType.space
? await allSpaceMemberAnalytics(space!)
: await allMySummaryAnalytics();
? await spaceMemberAnalytics(space!)
: await mySummaryAnalytics();
// filter out the analytics events based on filters the user has chosen
final List<SummaryAnalyticsEvent> filteredAnalytics =
await filterAnalytics(
unfilteredAnalytics: summaryEvents,
@ -332,6 +467,7 @@ class AnalyticsController extends BaseController {
selected: selected,
);
// then create and return the model to be displayed
final ChartAnalyticsModel newModel = ChartAnalyticsModel(
timeSpan: currentAnalyticsTimeSpan,
msgs: filteredAnalytics
@ -340,15 +476,12 @@ class AnalyticsController extends BaseController {
.toList(),
);
if (local == null) {
cacheAnalytics(
chartAnalyticsModel: newModel,
defaultSelected: defaultSelected,
selected: selected,
timeSpan: currentAnalyticsTimeSpan,
summaryLastUpdated: analyticsLastUpdated,
);
}
cacheAnalytics(
chartAnalyticsModel: newModel,
defaultSelected: defaultSelected,
selected: selected,
timeSpan: currentAnalyticsTimeSpan,
);
return newModel;
} catch (err, s) {
@ -379,6 +512,8 @@ class AnalyticsController extends BaseController {
final List<ConstructAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.construct,
since: currentAnalyticsTimeSpan.cutOffDate,
userId: _pangeaController.matrixState.client.userID!,
))
?.cast<ConstructAnalyticsEvent>();
allConstructs.addAll(roomEvents ?? []);
@ -430,6 +565,8 @@ class AnalyticsController extends BaseController {
final List<ConstructAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.construct,
since: currentAnalyticsTimeSpan.cutOffDate,
userId: student.id,
))
?.cast<ConstructAnalyticsEvent>();
constructEvents.addAll(roomEvents ?? []);
@ -525,8 +662,8 @@ class AnalyticsController extends BaseController {
required TimeSpan timeSpan,
required ConstructType constructType,
required AnalyticsSelected defaultSelected,
required DateTime? constructsLastUpdated,
AnalyticsSelected? selected,
DateTime? lastUpdated,
}) {
final index = _cachedConstructs.indexWhere(
(e) =>
@ -539,8 +676,7 @@ class AnalyticsController extends BaseController {
);
if (index > -1) {
if (_cachedConstructs[index].constructsLastUpdated !=
constructsLastUpdated) {
if (_cachedConstructs[index].needsUpdate(lastUpdated)) {
_cachedConstructs.removeAt(index);
return null;
}
@ -554,19 +690,16 @@ class AnalyticsController extends BaseController {
required ConstructType constructType,
required List<ConstructAnalyticsEvent> events,
required AnalyticsSelected defaultSelected,
required DateTime? constructsLastUpdated,
AnalyticsSelected? selected,
}) {
_cachedConstructs.add(
ConstructCacheEntry(
timeSpan: currentAnalyticsTimeSpan,
type: constructType,
events: events,
defaultSelected: defaultSelected,
selected: selected,
constructsLastUpdated: constructsLastUpdated,
),
final entry = ConstructCacheEntry(
timeSpan: currentAnalyticsTimeSpan,
type: constructType,
events: List.from(events),
defaultSelected: defaultSelected,
selected: selected,
);
_cachedConstructs.add(entry);
}
Future<List<ConstructAnalyticsEvent>> getMyConstructs({
@ -661,31 +794,75 @@ class AnalyticsController extends BaseController {
required ConstructType constructType,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
bool removeIT = false,
bool removeIT = true,
bool forceUpdate = false,
}) async {
final DateTime? constructsLastUpdated = await _pangeaController.myAnalytics
.analyticsLastUpdated(PangeaEventTypes.construct);
if (settingConstructs) return _constructs;
settingConstructs = true;
await _pangeaController.matrixState.client.roomsLoading;
Room? space;
String? langCode;
if (defaultSelected.type == AnalyticsEntryType.space) {
space = _pangeaController.matrixState.client.getRoomById(
defaultSelected.id,
);
if (space == null) {
ErrorHandler.logError(
m: "space not found in setConstructs",
data: {
"defaultSelected": defaultSelected,
"selected": selected,
},
);
settingConstructs = false;
return _constructs;
}
await space.postLoad();
langCode = _pangeaController.languageController.activeL2Code(
roomID: space.id,
);
if (langCode == null) {
ErrorHandler.logError(
m: "langCode missing in setConstructs",
data: {
"space": space,
},
);
settingConstructs = false;
return _constructs;
}
}
DateTime? lastUpdated;
if (defaultSelected.type != AnalyticsEntryType.space) {
// if default selected view is my analytics, check for the last
// time the logged in user updated their analytics events
// this gets passed to getAnalyticsLocal to determine if the cached
// entry is out-of-date
lastUpdated = await myAnalyticsLastUpdated(
PangeaEventTypes.construct,
);
} else {
// else, get the last time a student in the space updated their analytics
lastUpdated = await spaceAnalyticsLastUpdated(
PangeaEventTypes.construct,
space!,
langCode!,
);
}
final List<ConstructAnalyticsEvent>? local = getConstructsLocal(
timeSpan: currentAnalyticsTimeSpan,
constructType: constructType,
defaultSelected: defaultSelected,
selected: selected,
constructsLastUpdated: constructsLastUpdated,
lastUpdated: lastUpdated,
);
if (local != null && !forceUpdate) {
_constructs = local;
return _constructs;
}
if (settingConstructs) return _constructs;
settingConstructs = true;
await _pangeaController.matrixState.client.roomsLoading;
Room? space;
if (defaultSelected.type == AnalyticsEntryType.space) {
space = _pangeaController.matrixState.client.getRoomById(
defaultSelected.id,
);
settingConstructs = false;
return local;
}
final filteredConstructs = space == null
@ -720,46 +897,48 @@ class AnalyticsController extends BaseController {
events: _constructs!,
defaultSelected: defaultSelected,
selected: selected,
constructsLastUpdated: constructsLastUpdated,
);
}
settingConstructs = false;
return _constructs;
}
// used to aggregate ConstructEvents from
// multiple senders (students) with the same lemma
List<AggregateConstructUses> aggregateConstructData(
List<ConstructAnalyticsEvent> constructs,
) {
final Map<String, List<LemmaConstructsModel>> lemmasToConstructs = {};
for (final construct in constructs) {
for (final lemmaUses in construct.content.uses) {
lemmasToConstructs[lemmaUses.lemma] ??= [];
lemmasToConstructs[lemmaUses.lemma]!.add(lemmaUses);
}
}
final List<AggregateConstructUses> aggregatedConstructs = [];
for (final lemmaToConstructs in lemmasToConstructs.entries) {
final List<LemmaConstructsModel> lemmaConstructs =
lemmaToConstructs.value;
final AggregateConstructUses aggregatedData = AggregateConstructUses(
lemmaUses: lemmaConstructs,
);
aggregatedConstructs.add(aggregatedData);
}
return aggregatedConstructs;
}
}
class ConstructCacheEntry {
abstract class CacheEntry {
final TimeSpan timeSpan;
final ConstructType type;
final List<ConstructAnalyticsEvent> events;
final AnalyticsSelected defaultSelected;
AnalyticsSelected? selected;
final DateTime? constructsLastUpdated;
late final DateTime _createdAt;
ConstructCacheEntry({
CacheEntry({
required this.timeSpan,
required this.type,
required this.events,
required this.defaultSelected,
required this.constructsLastUpdated,
this.selected,
});
}
class AnalyticsCacheModel {
TimeSpan timeSpan;
ChartAnalyticsModel chartAnalyticsModel;
final AnalyticsSelected defaultSelected;
AnalyticsSelected? selected;
late DateTime _createdAt;
final DateTime? summaryLastUpdated;
AnalyticsCacheModel({
required this.timeSpan,
required this.chartAnalyticsModel,
required this.defaultSelected,
required this.summaryLastUpdated,
this.selected,
}) {
_createdAt = DateTime.now();
@ -768,4 +947,47 @@ class AnalyticsCacheModel {
bool get isExpired =>
DateTime.now().difference(_createdAt).inMinutes >
ClassDefaultValues.minutesDelayToMakeNewChartAnalytics;
bool needsUpdate(DateTime? lastEventUpdated) {
// cache entry is invalid if it's older than the last event update
// if lastEventUpdated is null, that would indicate that no events
// of this type have been sent to the room. In this case, there
// shouldn't be any cached data.
if (lastEventUpdated == null) {
Sentry.addBreadcrumb(
Breadcrumb(message: "lastEventUpdated is null in needsUpdate"),
);
return false;
}
return _createdAt.isBefore(lastEventUpdated);
}
}
class ConstructCacheEntry extends CacheEntry {
final ConstructType type;
final List<ConstructAnalyticsEvent> events;
ConstructCacheEntry({
required this.type,
required this.events,
required super.timeSpan,
required super.defaultSelected,
super.selected,
});
}
class AnalyticsCacheModel extends CacheEntry {
ChartAnalyticsModel chartAnalyticsModel;
AnalyticsCacheModel({
required this.chartAnalyticsModel,
required super.timeSpan,
required super.defaultSelected,
super.selected,
});
@override
bool get isExpired =>
DateTime.now().difference(_createdAt).inMinutes >
ClassDefaultValues.minutesDelayToMakeNewChartAnalytics;
}

View file

@ -1,40 +1,188 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/base_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics_event.dart';
import 'package:fluffychat/pangea/models/constructs_event.dart';
import 'package:fluffychat/pangea/models/constructs_model.dart';
import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart';
import 'package:fluffychat/pangea/models/summary_analytics_event.dart';
import 'package:fluffychat/pangea/models/summary_analytics_model.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import '../extensions/client_extension/client_extension.dart';
import '../extensions/pangea_room_extension/pangea_room_extension.dart';
import '../models/constructs_analytics_model.dart';
// controls the sending of analytics events
class MyAnalyticsController extends BaseController {
late PangeaController _pangeaController;
Timer? _updateTimer;
final int _maxMessagesCached = 10;
final int _minutesBeforeUpdate = 5;
MyAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
}
final List<String> analyticsEventTypes = [
PangeaEventTypes.summaryAnalytics,
PangeaEventTypes.construct,
];
// adds the listener that handles when to run automatic updates
// to analytics - either after a certain number of messages sent/
// received or after a certain amount of time without an update
Future<void> addEventsListener() async {
final Client client = _pangeaController.matrixState.client;
Future<void> sendAllAnalyticsEvents(
// if analytics haven't been updated in the last day, update them
DateTime? lastUpdated = await _pangeaController.analytics
.myAnalyticsLastUpdated(PangeaEventTypes.summaryAnalytics);
final DateTime yesterday = DateTime.now().subtract(const Duration(days: 1));
if (lastUpdated?.isBefore(yesterday) ?? false) {
debugPrint("analytics out-of-date, updating");
await updateAnalytics();
lastUpdated = await _pangeaController.analytics
.myAnalyticsLastUpdated(PangeaEventTypes.summaryAnalytics);
}
client.onSync.stream
.where((SyncUpdate update) => update.rooms?.join != null)
.listen((update) {
updateAnalyticsTimer(update, lastUpdated);
});
}
// given an update from sync stream, check if the update contains
// messages for which analytics will be saved. If so, reset the timer
// and add the event ID to the cache of un-added event IDs
void updateAnalyticsTimer(SyncUpdate update, DateTime? lastUpdated) {
for (final entry in update.rooms!.join!.entries) {
final Room room =
_pangeaController.matrixState.client.getRoomById(entry.key)!;
// get the new events in this sync that are messages
final List<Event>? events = entry.value.timeline?.events
?.map((event) => Event.fromMatrixEvent(event, room))
.where((event) => eventHasAnalytics(event, lastUpdated))
.toList();
// add their event IDs to the cache of un-added event IDs
if (events == null || events.isEmpty) continue;
for (final event in events) {
addMessageSinceUpdate(event.eventId);
}
// cancel the last timer that was set on message event and
// reset it to fire after _minutesBeforeUpdate minutes
_updateTimer?.cancel();
_updateTimer = Timer(Duration(minutes: _minutesBeforeUpdate), () {
debugPrint("timer fired, updating analytics");
updateAnalytics();
});
}
}
// checks if event from sync update is a message that should have analytics
bool eventHasAnalytics(Event event, DateTime? lastUpdated) {
return event.originServerTs.isAfter(lastUpdated ?? DateTime.now()) &&
event.type == EventTypes.Message &&
event.messageType == MessageTypes.Text &&
!(event.eventId.contains("web") &&
!(event.eventId.contains("android")) &&
!(event.eventId.contains("iOS")));
}
// adds an event ID to the cache of un-added event IDs
// if the event IDs isn't already added
void addMessageSinceUpdate(String eventId) {
final List<String> currentCache = messagesSinceUpdate;
if (!currentCache.contains(eventId)) {
currentCache.add(eventId);
_pangeaController.pStoreService.save(
PLocalKey.messagesSinceUpdate,
currentCache,
local: true,
);
}
// if the cached has reached if max-length, update analytics
if (messagesSinceUpdate.length > _maxMessagesCached) {
debugPrint("reached max messages, updating");
updateAnalytics();
}
}
// called before updating analytics
void clearMessagesSinceUpdate() {
_pangeaController.pStoreService.save(
PLocalKey.messagesSinceUpdate,
[],
local: true,
);
}
// a local cache of eventIds for messages sent since the last update
// it's possible for this cache to be invalid or deleted
// It's a proxy measure for messages sent since last update
List<String> get messagesSinceUpdate {
final dynamic locallySaved = _pangeaController.pStoreService.read(
PLocalKey.messagesSinceUpdate,
local: true,
);
if (locallySaved == null) {
_pangeaController.pStoreService.save(
PLocalKey.messagesSinceUpdate,
[],
local: true,
);
return [];
}
try {
return locallySaved as List<String>;
} catch (err) {
_pangeaController.pStoreService.save(
PLocalKey.messagesSinceUpdate,
[],
local: true,
);
return [];
}
}
Future<void> updateAnalytics() async {
// top level analytics sending function. Send analytics
// for each type of analytics event
// to each of the applicable analytics rooms
clearMessagesSinceUpdate();
// fetch a list of all the chats that the user is studying
// and a list of all the spaces in which the user is studying
await setStudentChats();
await setStudentSpaces();
// get all the analytics rooms that the user has
// and create any missing analytics rooms (if the user is studying
// in a class but doesn't have an analytics room for that class's L2)
final List<Room> analyticsRooms =
_pangeaController.matrixState.client.allMyAnalyticsRooms;
analyticsRooms.addAll(await createMissingAnalyticsRooms());
// finally, send an analytics event for each analytics room and
// each type of analytics event
for (final Room analyticsRoom in analyticsRooms) {
for (final String type in AnalyticsEvent.analyticsEventTypes) {
await sendAnalyticsEvent(analyticsRoom, type);
}
}
}
Future<void> sendAnalyticsEvent(
Room analyticsRoom,
String type,
) async {
// given an analytics room for a language and a type of analytics event
// gathers all the relevant data and sends it to the analytics room
// get the language code for the analytics room
final String? langCode = analyticsRoom.madeForLang;
if (langCode == null) {
ErrorHandler.logError(
@ -44,175 +192,78 @@ class MyAnalyticsController extends BaseController {
return;
}
final Map<String, AnalyticsEvent?> prevEvents = {};
for (final type in analyticsEventTypes) {
final prevEvent = await analyticsRoom.getLastAnalyticsEvent(type);
prevEvents[type] = prevEvent;
}
// get the last time an analytics event of this type was sent to this room
final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated(
type,
_pangeaController.matrixState.client.userID!,
);
final List<DateTime?> lastUpdates = prevEvents.values
.map((e) => e?.content.lastUpdated)
.cast<DateTime?>()
.toList();
DateTime? earliestLastUpdated;
if (!lastUpdates.any((updated) => updated == null)) {
earliestLastUpdated = lastUpdates.reduce(
(min, e) => e!.isBefore(min!) ? e : min,
);
}
final List<RecentMessageRecord> analyticsContent = [];
final List<OneConstructUse> constructsContent = [];
// each type of analytics event has a format for storing per-message data
// for SummaryAnalytics events, this is RecentMessageRecord
// for Construct events, this is OneConstructUse
// analyticsContent is a list of these formatted data
final List<dynamic> analyticsContent = [];
for (final Room chat in _studentChats) {
// for each chat the student studies in, check if the langCode
// matches the langCode of the analytics room
final String? chatLangCode =
_pangeaController.languageController.activeL2Code(roomID: chat.id);
if (chatLangCode != langCode) continue;
final List<PangeaMessageEvent> recentMsgs =
await chat.myMessageEventsInChat(
since: earliestLastUpdated,
);
analyticsContent.addAll(
formatAnalyticsContent(
recentMsgs,
prevEvents[PangeaEventTypes.summaryAnalytics]
as SummaryAnalyticsEvent?,
),
);
constructsContent.addAll(
formatConstructsContent(
recentMsgs,
prevEvents[PangeaEventTypes.construct] as ConstructAnalyticsEvent?,
),
);
}
if (analyticsContent.isNotEmpty) {
final SummaryAnalyticsModel analyticsModel = SummaryAnalyticsModel(
messages: analyticsContent,
lastUpdated: DateTime.now(),
prevEventId:
prevEvents[PangeaEventTypes.summaryAnalytics]?.event.eventId,
prevLastUpdated:
prevEvents[PangeaEventTypes.summaryAnalytics]?.content.lastUpdated,
);
await analyticsRoom.sendEvent(
analyticsModel.toJson(),
type: PangeaEventTypes.summaryAnalytics,
);
}
if (constructsContent.isNotEmpty) {
final Map<String, List<OneConstructUse>> lemmasUses = {};
for (final use in constructsContent) {
if (use.lemma == null) {
debugPrint("use has no lemma!");
continue;
}
lemmasUses[use.lemma!] ??= [];
lemmasUses[use.lemma]!.add(use);
// get messages the logged in user has sent in all chats
// since the last analytics event was sent
List<PangeaMessageEvent>? recentMsgs;
try {
recentMsgs = await chat.myMessageEventsInChat(
since: lastUpdated,
);
} catch (err) {
debugPrint("failed to fetch messages for chat ${chat.id}");
continue;
}
final ConstructAnalyticsModel constructsModel = ConstructAnalyticsModel(
type: ConstructType.grammar,
uses: lemmasUses.entries
.map(
(entry) => LemmaConstructsModel(
lemma: entry.key,
uses: entry.value,
),
)
.toList(),
lastUpdated: DateTime.now(),
prevEventId: prevEvents[PangeaEventTypes.construct]?.event.eventId,
prevLastUpdated:
prevEvents[PangeaEventTypes.construct]?.content.lastUpdated,
);
if (lastUpdated != null) {
recentMsgs.removeWhere(
(msg) => msg.event.originServerTs.isBefore(lastUpdated),
);
}
await analyticsRoom.sendEvent(
constructsModel.toJson(),
type: PangeaEventTypes.construct,
// then format that data into analytics data and add the formatted
// data to the list of analyticsContent
analyticsContent.addAll(
AnalyticsModel.formatAnalyticsContent(recentMsgs, type),
);
}
// send the analytics data to the analytics room
if (analyticsContent.isEmpty) return;
await AnalyticsEvent.sendEvent(
analyticsRoom,
type,
analyticsContent,
);
}
List<RecentMessageRecord> formatAnalyticsContent(
List<PangeaMessageEvent> recentMsgs,
SummaryAnalyticsEvent? prevEvent,
) {
List<PangeaMessageEvent> filtered = List.from(recentMsgs);
if (prevEvent?.content.lastUpdated != null) {
filtered = recentMsgs
.where(
(msg) => msg.event.originServerTs.isAfter(
prevEvent!.content.lastUpdated!,
),
)
.toList();
}
final List<String> addedMsgIds =
prevEvent?.content.messages.map((msg) => msg.eventId).toList() ?? [];
filtered.removeWhere((msg) => addedMsgIds.contains(msg.eventId));
final List<RecentMessageRecord> records = filtered
// on the off chance that the user is in a class but doesn't have an analytics
// room for the target language of that class, create the analytics room(s)
Future<List<Room>> createMissingAnalyticsRooms() async {
List<String> targetLangs = [];
final String? userL2 = _pangeaController.languageController.activeL2Code();
if (userL2 != null) targetLangs.add(userL2);
final List<String?> spaceL2s = studentSpaces
.map(
(msg) => RecentMessageRecord(
eventId: msg.eventId,
chatId: msg.room.id,
useType: msg.useType,
time: msg.originServerTs,
(space) => _pangeaController.languageController.activeL2Code(
roomID: space.id,
),
)
.toList();
return records;
}
List<OneConstructUse> formatConstructsContent(
List<PangeaMessageEvent> recentMsgs,
ConstructAnalyticsEvent? prevEvent,
) {
List<PangeaMessageEvent> filtered = List.from(recentMsgs);
if (prevEvent?.content.lastUpdated != null) {
filtered = recentMsgs
.where(
(msg) => msg.event.originServerTs.isAfter(
prevEvent!.content.lastUpdated!,
),
)
.toList();
targetLangs.addAll(spaceL2s.where((l2) => l2 != null).cast<String>());
targetLangs = targetLangs.toSet().toList();
for (final String langCode in targetLangs) {
await _pangeaController.matrixState.client.getMyAnalyticsRoom(langCode);
}
final List<String> addedMsgIds = prevEvent?.content.uses
.map((lemmause) => lemmause.uses.map((use) => use.msgId))
.expand((element) => element)
.where((element) => element != null)
.cast<String>()
.toList() ??
[];
filtered.removeWhere((msg) => addedMsgIds.contains(msg.eventId));
final List<OneConstructUse> uses = filtered
.map(
(msg) => msg.originalSent?.choreo?.toGrammarConstructUse(
msg.eventId,
msg.room.id,
msg.originServerTs,
),
)
.where((element) => element != null)
.cast<List<OneConstructUse>>()
.expand((element) => element)
.toList();
return uses;
return _pangeaController.matrixState.client.allMyAnalyticsRooms;
}
List<Room> _studentChats = [];
@ -259,103 +310,4 @@ class MyAnalyticsController extends BaseController {
return [];
}
}
// on the off chance that the user is in a class but doesn't have an analytics
// room for the target language of that class, create the analytics room(s)
Future<List<Room>> createMissingAnalyticsRoom() async {
List<String> targetLangs = [];
final String? userL2 = _pangeaController.languageController.activeL2Code();
if (userL2 != null) targetLangs.add(userL2);
final List<String?> spaceL2s = studentSpaces
.map(
(space) => _pangeaController.languageController.activeL2Code(
roomID: space.id,
),
)
.toList();
targetLangs.addAll(spaceL2s.where((l2) => l2 != null).cast<String>());
targetLangs = targetLangs.toSet().toList();
for (final String langCode in targetLangs) {
await _pangeaController.matrixState.client.getMyAnalyticsRoom(langCode);
}
return _pangeaController.matrixState.client.allMyAnalyticsRooms;
}
Future<void> updateAnalytics() async {
await setStudentChats();
await setStudentSpaces();
final List<Room> analyticsRooms =
_pangeaController.matrixState.client.allMyAnalyticsRooms;
analyticsRooms.addAll(await createMissingAnalyticsRoom());
for (final Room analyticsRoom in analyticsRooms) {
await sendAllAnalyticsEvents(analyticsRoom);
}
}
// used to aggregate ConstructEvents, from multiple senders (students) with the same lemma
List<AggregateConstructUses> aggregateConstructData(
List<ConstructAnalyticsEvent> constructs,
) {
final Map<String, List<LemmaConstructsModel>> lemmasToConstructs = {};
for (final construct in constructs) {
for (final lemmaUses in construct.content.uses) {
lemmasToConstructs[lemmaUses.lemma] ??= [];
lemmasToConstructs[lemmaUses.lemma]!.add(lemmaUses);
}
}
final List<AggregateConstructUses> aggregatedConstructs = [];
for (final lemmaToConstructs in lemmasToConstructs.entries) {
final List<LemmaConstructsModel> lemmaConstructs =
lemmaToConstructs.value;
final AggregateConstructUses aggregatedData = AggregateConstructUses(
lemmaUses: lemmaConstructs,
);
aggregatedConstructs.add(aggregatedData);
}
return aggregatedConstructs;
}
Future<DateTime?> analyticsLastUpdated(String type) async {
final List<Room> analyticsRooms =
_pangeaController.matrixState.client.allMyAnalyticsRooms;
if (analyticsRooms.isEmpty) return null;
final List<DateTime> lastUpdates = [];
for (final analyticsRoom in analyticsRooms) {
final AnalyticsEvent? lastEvent =
await analyticsRoom.getLastAnalyticsEvent(
type,
);
if (lastEvent?.content.lastUpdated != null) {
lastUpdates.add(lastEvent!.content.lastUpdated!);
}
}
if (lastUpdates.isEmpty) return null;
return lastUpdates.reduce(
(value, element) => value.isAfter(element) ? value : element,
);
}
}
class AggregateConstructUses {
final List<LemmaConstructsModel> _lemmaUses;
AggregateConstructUses({required List<LemmaConstructsModel> lemmaUses})
: _lemmaUses = lemmaUses;
String get lemma {
assert(
_lemmaUses.isNotEmpty &&
_lemmaUses.every(
(construct) => construct.lemma == _lemmaUses.first.lemma,
),
);
return _lemmaUses.first.lemma;
}
List<OneConstructUse> get uses => _lemmaUses
.map((lemmaUse) => lemmaUse.uses)
.expand((element) => element)
.toList();
}

View file

@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../models/chart_analytics_model.dart';
import '../models/analytics/chart_analytics_model.dart';
enum TimeSpan { day, week, month, sixmonths, year }

View file

@ -7,7 +7,6 @@ extension EventsRoomExtension on Room {
required String type,
}) async {
try {
debugPrint("creating $type child for $parentEventId");
Sentry.addBreadcrumb(Breadcrumb.fromJson(content));
if (parentEventId.contains("web")) {
debugger(when: kDebugMode);
@ -268,58 +267,82 @@ extension EventsRoomExtension on Room {
Future<List<PangeaMessageEvent>> myMessageEventsInChat({
DateTime? since,
}) async {
final List<Event> msgEvents = await getEventsBySender(
type: EventTypes.Message,
sender: client.userID!,
since: since,
);
final Timeline timeline = await getTimeline();
return msgEvents
.where((event) => (event.content['msgtype'] == MessageTypes.Text))
.map((event) {
return PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: true,
);
}).toList();
}
// fetch event of a certain type by a certain sender
// since a certain time or up to a certain amount
Future<List<Event>> getEventsBySender({
required String type,
required String sender,
DateTime? since,
int? count,
}) async {
try {
int numberOfSearches = 0;
if (isSpace) {
throw Exception(
"In messageListForChat with room that is not a chat",
);
}
final Timeline timeline = await getTimeline();
while (timeline.canRequestHistory && numberOfSearches < 50) {
try {
await timeline.requestHistory();
} catch (err) {
break;
List<Event> relevantEvents() => timeline.events
.where((event) => event.senderId == sender && event.type == type)
.toList();
bool reachedEnd() {
if (since != null) {
return relevantEvents().any(
(event) => event.originServerTs.isBefore(since),
);
}
if (count != null) {
return relevantEvents().length >= count;
}
return false;
}
while (timeline.canRequestHistory &&
!reachedEnd() &&
numberOfSearches < 10) {
await timeline.requestHistory(historyCount: 100);
numberOfSearches += 1;
if (timeline.events.any(
(event) => event.originServerTs.isAfter(since ?? DateTime.now()),
)) {
if (reachedEnd()) {
break;
}
}
final List<PangeaMessageEvent> msgs = [];
for (Event event in timeline.events) {
final bool hasAnalytics = (event.senderId == client.userID) &&
(event.type == EventTypes.Message) &&
(event.content['msgtype'] == MessageTypes.Text &&
!(event.relationshipType == RelationshipTypes.edit));
if (hasAnalytics &&
(since == null || event.originServerTs.isAfter(since))) {
if (event.hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
event = event
.aggregatedEvents(
timeline,
RelationshipTypes.edit,
)
.sorted(
(a, b) => b.originServerTs.compareTo(a.originServerTs),
)
.firstOrNull ??
event;
}
final PangeaMessageEvent pMsgEvent = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: true,
);
msgs.add(pMsgEvent);
}
final List<Event> fetchedEvents = timeline.events
.where((event) => event.senderId == sender && event.type == type)
.toList();
if (since != null) {
fetchedEvents.removeWhere(
(event) => event.originServerTs.isBefore(since),
);
}
return msgs;
final List<Event> events = [];
for (Event event in fetchedEvents) {
if (event.relationshipType == RelationshipTypes.edit) continue;
if (event.hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
event = event.getDisplayEvent(timeline);
}
events.add(event);
}
return events;
} catch (err, s) {
if (kDebugMode) rethrow;
debugger(when: kDebugMode);

View file

@ -6,11 +6,12 @@ import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_event.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart';
import 'package:fluffychat/pangea/models/bot_options_model.dart';
import 'package:fluffychat/pangea/models/class_model.dart';
import 'package:fluffychat/pangea/models/constructs_event.dart';
import 'package:fluffychat/pangea/models/summary_analytics_event.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/utils/bot_name.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
@ -28,7 +29,6 @@ import '../../constants/pangea_event_types.dart';
import '../../enum/use_type.dart';
import '../../models/choreo_record.dart';
import '../../models/representation_content_model.dart';
import '../../models/student_analytics_summary_model.dart';
import '../client_extension/client_extension.dart';
part "children_and_parents_extension.dart";
@ -69,19 +69,20 @@ extension PangeaRoom on Room {
Future<AnalyticsEvent?> getLastAnalyticsEvent(
String type,
String userId,
) async =>
await _getLastAnalyticsEvent(type);
await _getLastAnalyticsEvent(type, userId);
Future<AnalyticsEvent?> getPrevAnalyticsEvent(
AnalyticsEvent analyticsEvent,
) async =>
await _getPrevAnalyticsEvent(analyticsEvent);
Future<DateTime?> analyticsLastUpdated(String type, String userId) async {
return await _analyticsLastUpdated(type, userId);
}
Future<List<AnalyticsEvent>?> getAnalyticsEvents({
required String type,
required String userId,
DateTime? since,
}) async =>
await _getAnalyticsEvents(type: type, since: since);
await _getAnalyticsEvents(type: type, since: since, userId: userId);
String? get madeForLang => _madeForLang;

View file

@ -197,77 +197,53 @@ extension AnalyticsRoomExtension on Room {
Future<AnalyticsEvent?> _getLastAnalyticsEvent(
String type,
String userId,
) async {
final Timeline timeline = await getTimeline();
int requests = 0;
Event? lastEvent = timeline.events.firstWhereOrNull(
(event) => event.type == type,
final List<Event> events = await getEventsBySender(
type: type,
sender: userId,
count: 1,
);
while (requests < 10 && timeline.canRequestHistory && lastEvent == null) {
await timeline.requestHistory();
lastEvent = timeline.events.firstWhereOrNull(
(event) => event.type == type,
);
requests++;
}
if (lastEvent == null) return null;
if (events.isEmpty) return null;
final Event event = events.first;
AnalyticsEvent? analyticsEvent;
switch (type) {
case PangeaEventTypes.summaryAnalytics:
return SummaryAnalyticsEvent(event: lastEvent);
analyticsEvent = SummaryAnalyticsEvent(event: event);
case PangeaEventTypes.construct:
return ConstructAnalyticsEvent(event: lastEvent);
analyticsEvent = ConstructAnalyticsEvent(event: event);
}
return null;
return analyticsEvent;
}
Future<AnalyticsEvent?> _getPrevAnalyticsEvent(
AnalyticsEvent analyticsEvent,
) async {
if (analyticsEvent.content.prevEventId == null) {
return null;
}
final Event? prevEvent = await getEventById(
analyticsEvent.content.prevEventId!,
);
if (prevEvent == null) return null;
switch (analyticsEvent.event.type) {
case PangeaEventTypes.summaryAnalytics:
return SummaryAnalyticsEvent(event: prevEvent);
case PangeaEventTypes.construct:
return ConstructAnalyticsEvent(event: prevEvent);
}
return null;
Future<DateTime?> _analyticsLastUpdated(String type, String userId) async {
final lastEvent = await _getLastAnalyticsEvent(type, userId);
return lastEvent?.event.originServerTs;
}
Future<List<AnalyticsEvent>?> _getAnalyticsEvents({
required String type,
required String userId,
DateTime? since,
}) async {
final AnalyticsEvent? mostRecentEvent = await getLastAnalyticsEvent(type);
if (mostRecentEvent == null) return null;
final List<AnalyticsEvent> events = [mostRecentEvent];
bool getAllEvents() =>
since == null && events.last.content.prevEventId == null;
bool reachedUpdated() =>
since != null &&
(events.last.content.lastUpdated?.isBefore(since) ?? true);
while (getAllEvents() || !reachedUpdated()) {
final AnalyticsEvent? prevEvent = await getPrevAnalyticsEvent(
events.last,
);
if (prevEvent == null) break;
events.add(prevEvent);
final List<Event> events = await getEventsBySender(
type: type,
sender: userId,
since: since,
);
final List<AnalyticsEvent> analyticsEvents = [];
for (final Event event in events) {
switch (type) {
case PangeaEventTypes.summaryAnalytics:
analyticsEvents.add(SummaryAnalyticsEvent(event: event));
break;
case PangeaEventTypes.construct:
analyticsEvents.add(ConstructAnalyticsEvent(event: event));
break;
}
}
return events;
return analyticsEvents;
}
String? get _madeForLang {

View file

@ -0,0 +1,59 @@
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart';
import 'package:matrix/matrix.dart';
// superclass for all analytics events
abstract class AnalyticsEvent {
late Event _event;
AnalyticsModel? contentCache;
AnalyticsEvent({required Event event}) {
_event = event;
}
Event get event => _event;
AnalyticsModel get content {
switch (_event.type) {
case PangeaEventTypes.summaryAnalytics:
contentCache ??= SummaryAnalyticsModel.fromJson(event.content);
break;
case PangeaEventTypes.construct:
contentCache ??= ConstructAnalyticsModel.fromJson(event.content);
break;
}
return contentCache!;
}
static List<String> analyticsEventTypes = [
PangeaEventTypes.summaryAnalytics,
PangeaEventTypes.construct,
];
static Future<String?> sendEvent(
Room analyticsRoom,
String type,
List<dynamic> analyticsContent,
) async {
String? eventId;
switch (type) {
case PangeaEventTypes.summaryAnalytics:
eventId = await SummaryAnalyticsEvent.sendSummaryAnalyticsEvent(
analyticsRoom,
analyticsContent.cast<RecentMessageRecord>(),
);
break;
case PangeaEventTypes.construct:
eventId = await ConstructAnalyticsEvent.sendConstructsEvent(
analyticsRoom,
analyticsContent.cast<OneConstructUse>(),
);
break;
}
return eventId;
}
}

View file

@ -0,0 +1,19 @@
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart';
abstract class AnalyticsModel {
static List<dynamic> formatAnalyticsContent(
List<PangeaMessageEvent> recentMsgs,
String type,
) {
switch (type) {
case PangeaEventTypes.summaryAnalytics:
return SummaryAnalyticsModel.formatSummaryContent(recentMsgs);
case PangeaEventTypes.construct:
return ConstructAnalyticsModel.formatConstructsContent(recentMsgs);
}
return [];
}
}

View file

@ -1,10 +1,10 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/time_span.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart';
import 'package:flutter/foundation.dart';
import 'package:fluffychat/pangea/enum/time_span.dart';
import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart';
import '../enum/use_type.dart';
import '../../enum/use_type.dart';
class TimeSeriesTotals {
int ta;

View file

@ -0,0 +1,65 @@
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:matrix/matrix.dart';
import '../../constants/pangea_event_types.dart';
class ConstructAnalyticsEvent extends AnalyticsEvent {
ConstructAnalyticsEvent({required Event event}) : super(event: event) {
if (event.type != PangeaEventTypes.construct) {
throw Exception(
"${event.type} should not be used to make a ConstructAnalyticsEvent",
);
}
}
@override
ConstructAnalyticsModel get content {
contentCache ??= ConstructAnalyticsModel.fromJson(event.content);
return contentCache as ConstructAnalyticsModel;
}
static Future<String?> sendConstructsEvent(
Room analyticsRoom,
List<OneConstructUse> uses,
) async {
// create a map of lemmas to their uses
final Map<String, List<OneConstructUse>> lemmasToUses = {};
for (final use in uses) {
if (use.lemma == null) {
ErrorHandler.logError(
e: "use has no lemma in sendConstructsEvent",
s: StackTrace.current,
);
continue;
}
lemmasToUses[use.lemma!] ??= [];
lemmasToUses[use.lemma]!.add(use);
}
// convert the map of lemmas to uses into a list of LemmaConstructsModel
// each entry in this list contains one lemma to many uses
final List<LemmaConstructsModel> lemmaUses = lemmasToUses.entries
.map(
(entry) => LemmaConstructsModel(
lemma: entry.key,
uses: entry.value,
),
)
.toList();
// finally, send the construct analytics event to the analytics room
final ConstructAnalyticsModel constructsModel = ConstructAnalyticsModel(
type: ConstructType.grammar,
uses: lemmaUses,
);
final String? eventId = await analyticsRoom.sendEvent(
constructsModel.toJson(),
type: PangeaEventTypes.construct,
);
return eventId;
}
}

View file

@ -1,70 +1,94 @@
import 'dart:developer';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:flutter/foundation.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_model.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import '../enum/construct_type_enum.dart';
import '../../enum/construct_type_enum.dart';
class ConstructUses {
String lemma;
class ConstructAnalyticsModel extends AnalyticsModel {
ConstructType type;
List<LemmaConstructsModel> uses;
List<OneConstructUse> uses;
//PTODO - how to incorporate semantic similarity score into this?
//PTODO - add variables for saving requests for
// 1) definitions
// 2) translations
// 3) examples??? (gpt suggested)
ConstructUses({
required this.lemma,
ConstructAnalyticsModel({
required this.type,
this.uses = const [],
});
factory ConstructUses.fromJson(Map<String, dynamic> json) {
// try {
debugger(
when:
kDebugMode && (json['uses'] == null || json[ModelKey.lemma] == null),
static const _usesKey = "uses";
factory ConstructAnalyticsModel.fromJson(Map<String, dynamic> json) {
return ConstructAnalyticsModel(
type: ConstructTypeUtil.fromString(json['type']),
uses: json[_usesKey]
.values
.map((lemmaUses) => LemmaConstructsModel.fromJson(lemmaUses))
.cast<LemmaConstructsModel>()
.toList(),
);
return ConstructUses(
}
toJson() {
final Map<String, dynamic> usesMap = {};
for (final use in uses) {
usesMap[use.lemma] = use.toJson();
}
return {
'type': type.string,
_usesKey: usesMap,
};
}
static List<OneConstructUse> formatConstructsContent(
List<PangeaMessageEvent> recentMsgs,
) {
final List<PangeaMessageEvent> filtered = List.from(recentMsgs);
final List<OneConstructUse> uses = filtered
.map(
(msg) => msg.originalSent?.choreo?.toGrammarConstructUse(
msg.eventId,
msg.room.id,
msg.originServerTs,
),
)
.where((element) => element != null)
.cast<List<OneConstructUse>>()
.expand((element) => element)
.toList();
return uses;
}
}
class LemmaConstructsModel {
String lemma;
List<OneConstructUse> uses;
LemmaConstructsModel({
required this.lemma,
this.uses = const [],
});
factory LemmaConstructsModel.fromJson(Map<String, dynamic> json) {
return LemmaConstructsModel(
lemma: json[ModelKey.lemma],
uses: (json['uses'] as Iterable)
uses: (json['uses'] ?? [] as Iterable)
.map<OneConstructUse?>(
(use) => use != null ? OneConstructUse.fromJson(use) : null,
)
.where((element) => element != null)
.cast<OneConstructUse>()
.toList(),
type: ConstructTypeUtil.fromString(json['type']),
);
// } catch (err) {
// debugger(when: kDebugMode);
// rethrow;
// }
}
toJson() {
Map<String, dynamic> toJson() {
return {
ModelKey.lemma: lemma,
'uses': uses.map((use) => use.toJson()).toList(),
'type': type.string,
};
}
void addUsesByUseType(List<OneConstructUse> uses) {
for (final use in uses) {
if (use.lemma != lemma) {
throw Exception('lemma mismatch');
}
uses.add(use);
}
}
}
enum ConstructUseType {
@ -209,3 +233,25 @@ class OneConstructUse {
return room.getEventById(msgId!);
}
}
class AggregateConstructUses {
final List<LemmaConstructsModel> _lemmaUses;
AggregateConstructUses({required List<LemmaConstructsModel> lemmaUses})
: _lemmaUses = lemmaUses;
String get lemma {
assert(
_lemmaUses.isNotEmpty &&
_lemmaUses.every(
(construct) => construct.lemma == _lemmaUses.first.lemma,
),
);
return _lemmaUses.first.lemma;
}
List<OneConstructUse> get uses => _lemmaUses
.map((lemmaUse) => lemmaUse.uses)
.expand((element) => element)
.toList();
}

View file

@ -0,0 +1,35 @@
import 'package:fluffychat/pangea/models/analytics/analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart';
import 'package:matrix/matrix.dart';
import '../../constants/pangea_event_types.dart';
class SummaryAnalyticsEvent extends AnalyticsEvent {
SummaryAnalyticsEvent({required Event event}) : super(event: event) {
if (event.type != PangeaEventTypes.summaryAnalytics) {
throw Exception(
"${event.type} should not be used to make a SummaryAnalyticsEvent",
);
}
}
@override
SummaryAnalyticsModel get content {
contentCache ??= SummaryAnalyticsModel.fromJson(event.content);
return contentCache as SummaryAnalyticsModel;
}
static Future<String?> sendSummaryAnalyticsEvent(
Room analyticsRoom,
List<RecentMessageRecord> records,
) async {
final SummaryAnalyticsModel analyticsModel = SummaryAnalyticsModel(
messages: records,
);
final String? eventId = await analyticsRoom.sendEvent(
analyticsModel.toJson(),
type: PangeaEventTypes.summaryAnalytics,
);
return eventId;
}
}

View file

@ -1,10 +1,64 @@
import 'dart:convert';
import 'package:fluffychat/pangea/enum/use_type.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import '../enum/use_type.dart';
class SummaryAnalyticsModel extends AnalyticsModel {
late List<RecentMessageRecord> _messages;
SummaryAnalyticsModel({
required List<RecentMessageRecord> messages,
}) {
_messages = messages;
}
List<RecentMessageRecord> get messages => _messages;
static const _messagesKey = "msgs";
Map<String, dynamic> toJson() => {
_messagesKey: jsonEncode(_messages.map((e) => e.toJson()).toList()),
};
factory SummaryAnalyticsModel.fromJson(json) {
List<RecentMessageRecord> savedMessages = [];
try {
savedMessages = json[_messagesKey] != null
? (jsonDecode(json[_messagesKey] ?? "[]") as Iterable)
.map((e) => RecentMessageRecord.fromJson(e))
.toList()
.cast<RecentMessageRecord>()
: [];
} catch (err, stack) {
if (kDebugMode) rethrow;
ErrorHandler.logError(e: err, s: stack);
}
return SummaryAnalyticsModel(
messages: savedMessages,
);
}
static List<RecentMessageRecord> formatSummaryContent(
List<PangeaMessageEvent> recentMsgs,
) {
final List<PangeaMessageEvent> filtered = List.from(recentMsgs);
final List<RecentMessageRecord> records = filtered
.map(
(msg) => RecentMessageRecord(
eventId: msg.eventId,
chatId: msg.room.id,
useType: msg.useType,
time: msg.originServerTs,
),
)
.toList();
return records;
}
}
class RecentMessageRecord {
String eventId;
@ -55,60 +109,3 @@ class RecentMessageRecord {
static const _typeOfUseKey = "typ";
static const _timeKey = "t";
}
class StudentAnalyticsSummary {
late List<RecentMessageRecord> _messages;
DateTime? lastUpdated;
StudentAnalyticsSummary({
required List<RecentMessageRecord> messages,
required this.lastUpdated,
}) {
_messages = messages;
}
void addAll(List<RecentMessageRecord> msgs) {
for (final msg in msgs) {
if (!(_messages.any((element) => element.eventId == msg.eventId))) {
_messages.add(msg);
}
}
}
void removeEdittedMessages(Client client, List<String> removeEventIds) {
_messages.removeWhere(
(element) => removeEventIds.contains(element.eventId),
);
}
List<RecentMessageRecord> get messages => _messages;
static const _messagesKey = "msgs";
static const _lastUpdatedKey = "lupt";
Map<String, dynamic> toJson() => {
_messagesKey: jsonEncode(_messages.map((e) => e.toJson()).toList()),
_lastUpdatedKey: lastUpdated?.toIso8601String(),
};
factory StudentAnalyticsSummary.fromJson(json) {
List<RecentMessageRecord> savedMessages = [];
try {
savedMessages = json[_messagesKey] != null
? (jsonDecode(json[_messagesKey] ?? "[]") as Iterable)
.map((e) => RecentMessageRecord.fromJson(e))
.toList()
.cast<RecentMessageRecord>()
: [];
} catch (err, stack) {
if (kDebugMode) rethrow;
ErrorHandler.logError(e: err, s: stack);
}
return StudentAnalyticsSummary(
messages: savedMessages,
lastUpdated: json[_lastUpdatedKey] != null
? DateTime.parse(json[_lastUpdatedKey])
: null,
);
}
}

View file

@ -1,28 +0,0 @@
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/models/analytics_model.dart';
import 'package:fluffychat/pangea/models/constructs_model.dart';
import 'package:fluffychat/pangea/models/summary_analytics_model.dart';
import 'package:matrix/matrix.dart';
abstract class AnalyticsEvent {
late Event _event;
AnalyticsModel? contentCache;
AnalyticsEvent({required Event event}) {
_event = event;
}
Event get event => _event;
AnalyticsModel get content {
switch (_event.type) {
case PangeaEventTypes.summaryAnalytics:
contentCache ??= SummaryAnalyticsModel.fromJson(event.content);
break;
case PangeaEventTypes.construct:
contentCache ??= ConstructAnalyticsModel.fromJson(event.content);
break;
}
return contentCache!;
}
}

View file

@ -1,11 +0,0 @@
abstract class AnalyticsModel {
DateTime? lastUpdated;
String? prevEventId;
DateTime? prevLastUpdated;
AnalyticsModel({
this.lastUpdated,
this.prevEventId,
this.prevLastUpdated,
});
}

View file

@ -1,100 +0,0 @@
// import 'dart:convert';
// class UserTimeSeriesInterval {
// String? userId;
// int? taTotal;
// int? gaTotal;
// int? waTotal;
// UserTimeSeriesInterval({
// required this.userId,
// required this.taTotal,
// required this.gaTotal,
// required this.waTotal,
// });
// Map<String, dynamic> toJson() =>
// {"usr": userId, "ta": taTotal, "ga": gaTotal, "wa": waTotal};
// factory UserTimeSeriesInterval.fromJson(json) => UserTimeSeriesInterval(
// userId: json["usr"],
// taTotal: json["ta"],
// gaTotal: json["ga"],
// waTotal: json["wa"],
// );
// }
// class TimeSeriesInterval {
// DateTime start;
// DateTime end;
// List<UserTimeSeriesInterval> users;
// TimeSeriesInterval({
// required this.start,
// required this.end,
// required this.users,
// });
// Map<String, dynamic> toJson() => {
// "strt": start,
// "end": end,
// "usrs": jsonEncode(users.map((e) => e.toJson()).toList())
// };
// factory TimeSeriesInterval.fromJson(json) => TimeSeriesInterval(
// start: json["strt"],
// end: json["end"],
// users: ((jsonDecode(json["usrs"]) as Iterable)
// .map((e) => UserTimeSeriesInterval.fromJson(e))
// .toList()
// .cast<UserTimeSeriesInterval>()),
// );
// }
// class RoomAnalyticsSummary {
// List<TimeSeriesInterval> monthlyTotalsForAllTime;
// List<TimeSeriesInterval> dailyTotalsForLast30Days;
// List<TimeSeriesInterval> hourlyTotalsForLast24Hours;
// DateTime? updatedAt;
// RoomAnalyticsSummary({
// required this.monthlyTotalsForAllTime,
// required this.dailyTotalsForLast30Days,
// required this.hourlyTotalsForLast24Hours,
// });
// Map<String, dynamic> toJson() => {
// "mnths":
// jsonEncode(monthlyTotalsForAllTime.map((e) => e.toJson()).toList()),
// "dys": jsonEncode(
// dailyTotalsForLast30Days.map((e) => e.toJson()).toList()),
// "hrs": jsonEncode(
// hourlyTotalsForLast24Hours.map((e) => e.toJson()).toList()),
// };
// factory RoomAnalyticsSummary.fromJson(json) => RoomAnalyticsSummary(
// monthlyTotalsForAllTime: (jsonDecode(json["mnths"]) as Iterable)
// .map((e) => TimeSeriesInterval.fromJson(e))
// .toList()
// .cast<TimeSeriesInterval>(),
// dailyTotalsForLast30Days: (jsonDecode(json["dys"]) as Iterable)
// .map((e) => TimeSeriesInterval.fromJson(e))
// .toList()
// .cast<TimeSeriesInterval>(),
// hourlyTotalsForLast24Hours: (jsonDecode(json["hrs"]) as Iterable)
// .map((e) => TimeSeriesInterval.fromJson(e))
// .toList()
// .cast<TimeSeriesInterval>(),
// );
// }
// class UserDirectChatAnalyticsSummary {
// // directChatRoomIds and analytics for those rooms
// // updated by user;
// Map<String, RoomAnalyticsSummary>? directChatSummaries;
// Map<String, dynamic> toJson() => {};
// }
// // maybe search how to do date ranges in dart

View file

@ -1,124 +0,0 @@
// import 'dart:convert';
// class ChatTimeSeriesInterval {
// String? chatId;
// int? taTotal;
// int? gaTotal;
// int? waTotal;
// ChatTimeSeriesInterval({
// required this.chatId,
// required this.taTotal,
// required this.gaTotal,
// required this.waTotal,
// });
// Map<String, dynamic> toJson() =>
// {"id": chatId, "ta": taTotal, "ga": gaTotal, "wa": waTotal};
// factory ChatTimeSeriesInterval.fromJson(json) => ChatTimeSeriesInterval(
// chatId: json["id"],
// taTotal: json["ta"],
// gaTotal: json["ga"],
// waTotal: json["wa"],
// );
// }
// class TimeSeriesInterval {
// DateTime start;
// DateTime end;
// List<ChatTimeSeriesInterval> chats;
// TimeSeriesInterval({
// required this.start,
// required this.end,
// required this.chats,
// });
// Map<String, dynamic> toJson() => {
// "strt": start,
// "end": end,
// "usrs": jsonEncode(chats.map((e) => e.toJson()).toList())
// };
// factory TimeSeriesInterval.fromJson(json) => TimeSeriesInterval(
// start: DateTime(json["strt"]),
// end: DateTime(json["end"]),
// chats: ((jsonDecode(json["usrs"]) as Iterable)
// .map((e) => ChatTimeSeriesInterval.fromJson(e))
// .toList()
// .cast<ChatTimeSeriesInterval>()),
// );
// }
// // class RecentMessageRecord {
// // String eventId;
// // String typeOfUse;
// // String time;
// // }
// class StudentAnalyticsSummary {
// /// event statekey = studentId
// // String studentId;
// List<TimeSeriesInterval> monthlyTotalsForAllTime;
// List<TimeSeriesInterval> dailyTotalsForLast30Days;
// List<TimeSeriesInterval> hourlyTotalsForLast24Hours;
// // List<RecentMessageRecord> messages;
// DateTime lastLogin;
// DateTime lastMessage;
// DateTime lastUpdated;
// StudentAnalyticsSummary({
// // required this.studentId,
// required this.monthlyTotalsForAllTime,
// required this.dailyTotalsForLast30Days,
// required this.hourlyTotalsForLast24Hours,
// required this.lastLogin,
// required this.lastMessage,
// required this.lastUpdated,
// });
// // static const _studentIdKey = 'usr';
// static const _monthKey = "mnths";
// static const _dayKey = "dys";
// static const _hoursKey = "hrs";
// static const _lastLoginKey = "lgn";
// static const _lastMessageKey = "msg";
// static const _lastUpdated = "lupt";
// Map<String, dynamic> toJson() => {
// _monthKey:
// jsonEncode(monthlyTotalsForAllTime.map((e) => e.toJson()).toList()),
// _dayKey: jsonEncode(
// dailyTotalsForLast30Days.map((e) => e.toJson()).toList()),
// _hoursKey: jsonEncode(
// hourlyTotalsForLast24Hours.map((e) => e.toJson()).toList()),
// // _studentIdKey: studentId,
// _lastLoginKey: lastLogin.toIso8601String(),
// _lastMessageKey: lastMessage.toIso8601String(),
// _lastUpdated: lastUpdated.toIso8601String()
// };
// factory StudentAnalyticsSummary.fromJson(json) => StudentAnalyticsSummary(
// // studentId: json[_studentIdKey],
// monthlyTotalsForAllTime: (jsonDecode(json[_monthKey]) as Iterable)
// .map((e) => TimeSeriesInterval.fromJson(e))
// .toList()
// .cast<TimeSeriesInterval>(),
// dailyTotalsForLast30Days: (jsonDecode(json[_dayKey]) as Iterable)
// .map((e) => TimeSeriesInterval.fromJson(e))
// .toList()
// .cast<TimeSeriesInterval>(),
// hourlyTotalsForLast24Hours: (jsonDecode(json[_hoursKey]) as Iterable)
// .map((e) => TimeSeriesInterval.fromJson(e))
// .toList()
// .cast<TimeSeriesInterval>(),
// lastLogin: DateTime(json[_lastLoginKey]),
// lastUpdated: DateTime(json[_lastLoginKey]),
// lastMessage: DateTime(json[_lastMessageKey]),
// );
// }

View file

@ -1,77 +0,0 @@
// import 'dart:convert';
// class BaseDataModel {
// late int spanTotal;
// late int spanIT;
// late int spanIGC;
// late int spanDirect;
// BaseDataModel(Map<String, dynamic> json) {
// fromJson(json);
// }
// fromJson(Map<String, dynamic> json) {
// spanTotal = json["total"];
// spanIT = json["it"];
// spanIGC = json["igc"];
// spanDirect = json["direct"];
// }
// }
// class TimeSeriesInterval extends BaseDataModel {
// //note: always in UTC
// late DateTime start;
// late DateTime end;
// TimeSeriesInterval(Map<String, dynamic> json) : super(json) {
// fromJsonTimeSeriesInterval(json);
// }
// fromJsonTimeSeriesInterval(Map<String, dynamic> json) {
// start = DateTime.parse(json["start"]);
// end = DateTime.parse(json["end"]);
// }
// }
// class chartAnalytics extends BaseDataModel {
// late String id;
// late int allTotal;
// late int allIT;
// late int allIGC;
// late int allDirect;
// late String timeSpan;
// late DateTime fetchedAt;
// late List<String>? chatIds;
// late List<String>? userIds;
// late List<String>? classIds;
// late List<TimeSeriesInterval> timeSeries;
// chartAnalytics(Map<String, dynamic> json) : super(json) {
// fromJsonchartAnalytics(json);
// fetchedAt = DateTime.now();
// }
// fromJsonchartAnalytics(Map<String, dynamic> json) {
// id = json["id"];
// timeSpan = json["timespan"];
// allTotal = json["alltime"]["total"];
// allIT = json["alltime"]["it"];
// allIGC = json["alltime"]["igc"];
// allDirect = json["alltime"]["direct"];
// timeSeries = (json["timeseries"] as Iterable<dynamic>)
// .map(
// (timeSeriesJsonEntry) => TimeSeriesInterval(timeSeriesJsonEntry),
// )
// .toList()
// .cast<TimeSeriesInterval>();
// chatIds = json["chats"] != null && json["chats"] != []
// ? (json["chats"] as List<dynamic>).cast<String>()
// : null;
// userIds = json["users"] != null && json["userIds"] != []
// ? (json["users"] as List<dynamic>).cast<String>()
// : null;
// classIds = json["classes"] != null && json["classes"] != []
// ? (json["classes"] as List<dynamic>).cast<String>()
// : null;
// }
// }

View file

@ -1,11 +1,11 @@
import 'dart:convert';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import '../constants/choreo_constants.dart';
import '../enum/construct_type_enum.dart';
import 'constructs_analytics_model.dart';
import 'it_step.dart';
import 'lemma.dart';

View file

@ -1,100 +0,0 @@
import 'package:intl/intl.dart';
class ClassAnalyticsModel {
ClassAnalyticsModel();
late final Null classId;
late final List<String> userIds;
late final List<Analytics> analytics;
get tableView {}
ClassAnalyticsModel.fromJson(Map<String, dynamic> json) {
classId = null;
userIds = List.castFrom<dynamic, String>(json['user_ids']);
analytics =
List.from(json['analytics']).map((e) => Analytics.fromJson(e)).toList();
}
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['class_id'] = classId;
data['user_ids'] = userIds;
data['analytics'] = analytics.map((e) => e.toJson()).toList();
return data;
}
}
class Analytics {
Analytics({
required this.title,
required this.section,
});
late final String title;
late final List<Section> section;
Analytics.fromJson(Map<String, dynamic> json) {
title = json['title'];
section =
List.from(json['section']).map((e) => Section.fromJson(e)).toList();
}
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['title'] = title;
data['section'] = section.map((e) => e.toJson()).toList();
return data;
}
}
class Section {
Section({
required this.title,
required this.classTotal,
required this.data,
});
late final String title;
late final String classTotal;
late final List<Data> data;
Section.fromJson(Map<String, dynamic> json) {
title = json['title'];
classTotal = json['class_total'];
data = List.from(json['data']).map((e) => Data.fromJson(e)).toList();
}
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['title'] = title;
data['class_total'] = classTotal;
(data['data'] as List).map((item) => Data.fromJson(item)).toList();
return data;
}
}
class Data {
Data();
set value(String val) => _value = val;
String get value {
if (value_type == 'date') {
return DateFormat('yyyy/M/dd hh:mm a')
.format(DateTime.parse(_value).toLocal())
.toString();
}
return _value;
}
late final String userId;
late final String _value;
late final String value_type;
Data.fromJson(Map<String, dynamic> json) {
userId = json['user_id'];
_value = json['value'];
value_type = json['value_type'];
}
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['user_id'] = userId;
data['value'] = _value;
data['value_type'] = value_type;
return data;
}
}

View file

@ -1,52 +0,0 @@
import 'package:fluffychat/pangea/models/analytics_event.dart';
import 'package:fluffychat/pangea/models/constructs_model.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
class ConstructAnalyticsEvent extends AnalyticsEvent {
ConstructAnalyticsEvent({required Event event}) : super(event: event) {
if (event.type != PangeaEventTypes.construct) {
throw Exception(
"${event.type} should not be used to make a ConstructAnalyticsEvent",
);
}
}
@override
ConstructAnalyticsModel get content {
contentCache ??= ConstructAnalyticsModel.fromJson(event.content);
return contentCache as ConstructAnalyticsModel;
}
// void addAll(List<OneConstructUse> uses) {
// for (final use in uses) {
// if (content.uses.any((element) => element.id == use.id)) {
// continue;
// }
// debugPrint("${use.toJson()}");
// content.uses.add(use);
// }
// event.content = content.toJson();
// }
// Future<void> removeEdittedUses(
// List<String> removeIds,
// Client client,
// ) async {
// _contentCache ??= ConstructUses.fromJson(event.content);
// if (_contentCache == null || _event.stateKey == null) return;
// final previousLength = _contentCache!.uses.length;
// _contentCache!.uses.removeWhere(
// (element) => removeIds.contains(element.msgId),
// );
// if (previousLength > _contentCache!.uses.length) {
// await client.setRoomStateWithKey(
// _event.room.id,
// _event.type,
// _event.stateKey!,
// _contentCache!.toJson(),
// );
// }
// }
}

View file

@ -1,191 +0,0 @@
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/models/analytics_model.dart';
import 'package:fluffychat/pangea/models/constructs_analytics_model.dart';
import 'package:flutter/material.dart';
import '../enum/construct_type_enum.dart';
class ConstructAnalyticsModel extends AnalyticsModel {
ConstructType type;
List<LemmaConstructsModel> uses;
ConstructAnalyticsModel({
required this.type,
this.uses = const [],
super.lastUpdated,
super.prevEventId,
super.prevLastUpdated,
});
static const _lastUpdatedKey = "lupt";
static const _usesKey = "uses";
factory ConstructAnalyticsModel.fromJson(Map<String, dynamic> json) {
// try {
// debugger(
// when:
// kDebugMode && (json['uses'] == null || json[ModelKey.lemma] == null),
// );
return ConstructAnalyticsModel(
// lemma: json[ModelKey.lemma],
// uses: (json['uses'] as Iterable)
// .map<OneConstructUse?>(
// (use) => use != null ? OneConstructUse.fromJson(use) : null,
// )
// .where((element) => element != null)
// .cast<OneConstructUse>()
// .toList(),
type: ConstructTypeUtil.fromString(json['type']),
lastUpdated: json[_lastUpdatedKey] != null
? DateTime.parse(json[_lastUpdatedKey])
: null,
prevEventId: json[ModelKey.prevEventId],
prevLastUpdated: json[ModelKey.prevLastUpdated] != null
? DateTime.parse(json[ModelKey.prevLastUpdated])
: null,
uses: json[_usesKey]
.values
.map((lemmaUses) => LemmaConstructsModel.fromJson(lemmaUses))
.cast<LemmaConstructsModel>()
.toList(),
);
// } catch (err) {
// debugger(when: kDebugMode);
// rethrow;
// }
}
toJson() {
final Map<String, dynamic> usesMap = {};
for (final use in uses) {
usesMap[use.lemma] = use.toJson();
}
return {
// ModelKey.lemma: lemma,
// 'uses': uses.map((use) => use.toJson()).toList(),
'type': type.string,
_lastUpdatedKey: lastUpdated?.toIso8601String(),
_usesKey: usesMap,
ModelKey.prevEventId: prevEventId,
ModelKey.prevLastUpdated: prevLastUpdated?.toIso8601String(),
};
}
// void addUsesByUseType(List<OneConstructUse> uses) {
// for (final use in uses) {
// if (use.lemma != lemma) {
// throw Exception('lemma mismatch');
// }
// uses.add(use);
// }
// }
}
class LemmaConstructsModel {
String lemma;
List<OneConstructUse> uses;
LemmaConstructsModel({
required this.lemma,
this.uses = const [],
});
factory LemmaConstructsModel.fromJson(Map<String, dynamic> json) {
return LemmaConstructsModel(
lemma: json[ModelKey.lemma],
uses: (json['uses'] ?? [] as Iterable)
.map<OneConstructUse?>(
(use) => use != null ? OneConstructUse.fromJson(use) : null,
)
.where((element) => element != null)
.cast<OneConstructUse>()
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
ModelKey.lemma: lemma,
'uses': uses.map((use) => use.toJson()).toList(),
};
}
}
enum ConstructUseType {
/// produced in chat by user, igc was run, and we've judged it to be a correct use
wa,
/// produced in chat by user, igc was run, and we've judged it to be a incorrect use
/// Note: if the IGC match is ignored, this is not counted as an incorrect use
ga,
/// produced in chat by user and igc was not run
unk,
/// selected correctly in IT flow
corIt,
/// encountered as IT distractor and correctly ignored it
ignIt,
/// encountered as it distractor and selected it
incIt,
/// encountered in igc match and ignored match
ignIGC,
/// selected correctly in IGC flow
corIGC,
/// encountered as distractor in IGC flow and selected it
incIGC,
}
extension on ConstructUseType {
String get string {
switch (this) {
case ConstructUseType.ga:
return 'ga';
case ConstructUseType.wa:
return 'wa';
case ConstructUseType.corIt:
return 'corIt';
case ConstructUseType.incIt:
return 'incIt';
case ConstructUseType.ignIt:
return 'ignIt';
case ConstructUseType.ignIGC:
return 'ignIGC';
case ConstructUseType.corIGC:
return 'corIGC';
case ConstructUseType.incIGC:
return 'incIGC';
case ConstructUseType.unk:
return 'unk';
}
}
IconData get icon {
switch (this) {
case ConstructUseType.ga:
return Icons.check;
case ConstructUseType.wa:
return Icons.thumb_up_sharp;
case ConstructUseType.corIt:
return Icons.check;
case ConstructUseType.incIt:
return Icons.close;
case ConstructUseType.ignIt:
return Icons.close;
case ConstructUseType.ignIGC:
return Icons.close;
case ConstructUseType.corIGC:
return Icons.check;
case ConstructUseType.incIGC:
return Icons.close;
case ConstructUseType.unk:
return Icons.help;
}
}
}

View file

@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:developer';
import 'package:fluffychat/pangea/models/constructs_analytics_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

View file

@ -1,165 +0,0 @@
// import 'dart:developer';
// import 'package:fluffychat/pangea/extensions/client_extension.dart';
// import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart';
// import 'package:fluffychat/pangea/utils/error_handler.dart';
// import 'package:flutter/foundation.dart';
// import 'package:matrix/matrix.dart';
// import '../constants/pangea_event_types.dart';
// import 'chart_analytics_model.dart';
// class StudentAnalyticsEvent {
// late Event _event;
// StudentAnalyticsSummary? _contentCache;
// List<RecentMessageRecord> _messagesToSave = [];
// StudentAnalyticsEvent({required Event event}) {
// if (event.type != PangeaEventTypes.studentAnalyticsSummary) {
// throw Exception(
// "${event.type} should not be used to make a StudentAnalyticsEvent",
// );
// }
// _event = event;
// _messagesToSave = [];
// }
// Event get event => _event;
// StudentAnalyticsSummary get content {
// _contentCache ??= StudentAnalyticsSummary.fromJson(event.content);
// return _contentCache!;
// }
// Future<void> removeEdittedMessages(
// RecentMessageRecord message,
// ) async {
// final List<String> removeIds = await event.room.client.getEditHistory(
// message.chatId,
// message.eventId,
// );
// if (removeIds.isEmpty) return;
// _messagesToSave.removeWhere(
// (msg) => removeIds.any((e) => e == msg.eventId),
// );
// content.removeEdittedMessages(
// event.room.client,
// removeIds,
// );
// }
// // Future<void> handleNewMessage(
// // RecentMessageRecord message, {
// // isEdit = false,
// // }) async {
// // if (isEdit) {
// // await removeEdittedMessages(message);
// // }
// // // _addMessage(message);
// // _messagesToSave.add(message);
// // debugPrint("messages to save is now: ${_messagesToSave.length}");
// // if (DateTime.now().difference(content.lastUpdated).inMinutes >
// // ClassDefaultValues.minutesDelayToUpdateMyAnalytics) {
// // _updateStudentAnalytics();
// // }
// // }
// // Future<void> bulkUpdate(List<RecentMessageRecord> messages) async {
// // // if (event.room.client.userID != _event.stateKey) {
// // // debugger(when: kDebugMode);
// // // ErrorHandler.logError(
// // // m: "should not be in bulkUpdate ${event.room.client.userID} != ${_event.stateKey}",
// // // );
// // // return;
// // // }
// // for (final message in messages) {
// // await removeEdittedMessages(message);
// // }
// // _messagesToSave.addAll(messages);
// // await _updateStudentAnalytics();
// // }
// // Future<void> _updateStudentAnalytics() async {
// // content.lastUpdated = DateTime.now();
// // content.addAll(_messagesToSave);
// // _clearMessages();
// // await event.room.client.setRoomStateWithKey(
// // event.room.id,
// // _event.type,
// // '',
// // content.toJson(),
// // );
// // }
// Future<void> updateStudentAnalytics() async {
// content.lastUpdated = DateTime.now();
// // content.addAll(_messagesToSave);
// // _clearMessages();
// await event.room.client.setRoomStateWithKey(
// event.room.id,
// _event.type,
// '',
// content.toJson(),
// );
// await event.room.postLoad();
// }
// _addMessage(RecentMessageRecord message) {
// if (_messagesToSave.every((e) => e.eventId != message.eventId)) {
// _messagesToSave.add(message);
// } else {
// debugger(when: kDebugMode);
// ErrorHandler.logError(
// m: "adding message twice in StudentAnalyticsEvent._addMessage",
// );
// }
// //PTODO - save to local storagge
// }
// _clearMessages() {
// _messagesToSave.clear();
// //PTODO - clear local storagge
// }
// Future<TimeSeriesTotals> getTotals(String? chatId) async {
// final TimeSeriesTotals totals = TimeSeriesTotals.empty;
// final msgs = chatId == null
// ? content.messages
// : content.messages.where((msg) => msg.chatId == chatId);
// for (final msg in msgs) {
// totals.increment(msg);
// }
// return totals;
// }
// Future<TimeSeriesInterval> getTimeServiesInterval(
// DateTime start,
// DateTime end,
// String? chatId,
// ) async {
// final TimeSeriesInterval interval = TimeSeriesInterval(
// start: start,
// end: end,
// totals: TimeSeriesTotals.empty,
// );
// for (final msg in content.messages) {
// if (msg.time.isAfter(start) &&
// msg.time.isBefore(end) &&
// (chatId == null || chatId == msg.chatId)) {
// interval.totals.increment(msg);
// }
// }
// return interval;
// }
// bool isAlreadyAdded(RecentMessageRecord message) {
// return content.messages.any(
// (element) => element.eventId == message.eventId,
// );
// }
// }

View file

@ -1,51 +0,0 @@
// import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
// import 'package:fluffychat/pangea/models/analytics_model_older.dart';
// import 'package:matrix/matrix.dart';
// import '../constants/pangea_event_types.dart';
// class StudentAnalyticsEvent {
// late Event _event;
// StudentAnalyticsSummary? _contentCache;
// StudentAnalyticsEvent({required Event event}) {
// if (event.type != PangeaEventTypes.studentAnalyticsSummary) {
// throw Exception(
// "${event.type} should not be used to make a StudentAnalyticsEvent",
// );
// }
// _event = event;
// }
// Event get event => _event;
// StudentAnalyticsSummary get _content {
// _contentCache ??= event.getPangeaContent<StudentAnalyticsSummary>();
// return _contentCache!;
// }
// List<TimeSeriesInterval> get monthly => _content.monthlyTotalsForAllTime;
// List<TimeSeriesInterval> get daily => _content.dailyTotalsForLast30Days;
// List<TimeSeriesInterval> get hourly => _content.hourlyTotalsForLast24Hours;
// // updateLocal
// // updateServer
// handleNewMessage() {}
// /// if monthly.isNotEmpty && last.end.month < now.month
// /// push empty intervals until last.end.month >= now.month
// /// if daily.isEmpty
// /// push empty intervals until last.end.day >= now.day
// /// else if daily.where(e => e.month < now.month)
// /// sum and add to monthly
// ///
// /// if hourly.isEmpty || last.end.hour < now.hour
// /// push empty intervals until last.end.hour >= now.hour
// /// increment hourly
// updateLocal() {}
// // if server copy is older than x, push local version
// // get new server copy, local version = server copy
// updateServer() {}
// }

View file

@ -1,21 +0,0 @@
import 'package:fluffychat/pangea/models/analytics_event.dart';
import 'package:fluffychat/pangea/models/summary_analytics_model.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
class SummaryAnalyticsEvent extends AnalyticsEvent {
SummaryAnalyticsEvent({required Event event}) : super(event: event) {
if (event.type != PangeaEventTypes.summaryAnalytics) {
throw Exception(
"${event.type} should not be used to make a SummaryAnalyticsEvent",
);
}
}
@override
SummaryAnalyticsModel get content {
contentCache ??= SummaryAnalyticsModel.fromJson(event.content);
return contentCache as SummaryAnalyticsModel;
}
}

View file

@ -1,57 +0,0 @@
import 'dart:convert';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/models/analytics_model.dart';
import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
class SummaryAnalyticsModel extends AnalyticsModel {
late List<RecentMessageRecord> _messages;
SummaryAnalyticsModel({
required List<RecentMessageRecord> messages,
super.lastUpdated,
super.prevEventId,
super.prevLastUpdated,
}) {
_messages = messages;
}
List<RecentMessageRecord> get messages => _messages;
static const _messagesKey = "msgs";
static const _lastUpdatedKey = "lupt";
Map<String, dynamic> toJson() => {
_messagesKey: jsonEncode(_messages.map((e) => e.toJson()).toList()),
_lastUpdatedKey: lastUpdated?.toIso8601String(),
ModelKey.prevEventId: prevEventId,
ModelKey.prevLastUpdated: prevLastUpdated?.toIso8601String(),
};
factory SummaryAnalyticsModel.fromJson(json) {
List<RecentMessageRecord> savedMessages = [];
try {
savedMessages = json[_messagesKey] != null
? (jsonDecode(json[_messagesKey] ?? "[]") as Iterable)
.map((e) => RecentMessageRecord.fromJson(e))
.toList()
.cast<RecentMessageRecord>()
: [];
} catch (err, stack) {
if (kDebugMode) rethrow;
ErrorHandler.logError(e: err, s: stack);
}
return SummaryAnalyticsModel(
messages: savedMessages,
lastUpdated: json[_lastUpdatedKey] != null
? DateTime.parse(json[_lastUpdatedKey])
: null,
prevEventId: json[ModelKey.prevEventId],
prevLastUpdated: json[ModelKey.prevLastUpdated] != null
? DateTime.parse(json[ModelKey.prevLastUpdated])
: null,
);
}
}

View file

@ -11,7 +11,7 @@ import 'package:matrix/matrix.dart';
import '../../../../utils/date_time_extension.dart';
import '../../../widgets/avatar.dart';
import '../../../widgets/matrix.dart';
import '../../models/chart_analytics_model.dart';
import '../../models/analytics/chart_analytics_model.dart';
import 'base_analytics.dart';
import 'list_summary_analytics.dart';

View file

@ -4,7 +4,7 @@ import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_event.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics_view.dart';
import 'package:fluffychat/pangea/pages/analytics/student_analytics/student_analytics.dart';
import 'package:flutter/material.dart';
@ -14,7 +14,7 @@ import '../../../widgets/matrix.dart';
import '../../controllers/pangea_controller.dart';
import '../../enum/bar_chart_view_enum.dart';
import '../../enum/time_span.dart';
import '../../models/chart_analytics_model.dart';
import '../../models/analytics/chart_analytics_model.dart';
class BaseAnalyticsPage extends StatefulWidget {
final String pageTitle;
@ -73,10 +73,14 @@ class BaseAnalyticsController extends State<BaseAnalyticsPage> {
final List<AnalyticsEvent> analyticsEvent = [];
for (final analyticsRoom in analyticsRooms) {
final lastSummaryEvent = await analyticsRoom
.getLastAnalyticsEvent(PangeaEventTypes.summaryAnalytics);
final lastConstructEvent =
await analyticsRoom.getLastAnalyticsEvent(PangeaEventTypes.construct);
final lastSummaryEvent = await analyticsRoom.getLastAnalyticsEvent(
PangeaEventTypes.summaryAnalytics,
Matrix.of(context).client.userID!,
);
final lastConstructEvent = await analyticsRoom.getLastAnalyticsEvent(
PangeaEventTypes.construct,
Matrix.of(context).client.userID!,
);
if (lastSummaryEvent != null) {
analyticsEvent.add(lastSummaryEvent);
}

View file

@ -8,7 +8,7 @@ import 'package:matrix/matrix.dart';
import '../../../../widgets/matrix.dart';
import '../../../controllers/pangea_controller.dart';
import '../../../models/chart_analytics_model.dart';
import '../../../models/analytics/chart_analytics_model.dart';
import '../../../utils/sync_status_util_v2.dart';
import '../../../widgets/common/list_placeholder.dart';

View file

@ -2,12 +2,11 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart';
import 'package:fluffychat/pangea/models/constructs_analytics_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
@ -223,7 +222,7 @@ class ConstructListViewState extends State<ConstructListView> {
if (widget.pangeaController.analytics.constructs == null) {
return null;
}
return widget.pangeaController.myAnalytics
return widget.pangeaController.analytics
.aggregateConstructData(widget.pangeaController.analytics.constructs!)
.where((lemmaUses) => lemmaUses.uses.isNotEmpty)
.sorted((a, b) {

View file

@ -1,10 +1,9 @@
import 'dart:math';
import 'package:fluffychat/pangea/models/analytics/chart_analytics_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/models/chart_analytics_model.dart';
import '../../enum/use_type.dart';
class ListSummaryAnalytics extends StatelessWidget {

View file

@ -10,7 +10,7 @@ import 'package:intl/intl.dart';
import '../../enum/time_span.dart';
import '../../enum/use_type.dart';
import '../../models/chart_analytics_model.dart';
import '../../models/analytics/chart_analytics_model.dart';
import 'bar_chart_card.dart';
import 'messages_legend_widget.dart';
@ -58,10 +58,10 @@ class MessagesBarChartState extends State<MessagesBarChart> {
getTitlesWidget: leftTitles,
),
),
topTitles: AxisTitles(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: AxisTitles(
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
);