From 1d5bd79dfdc809e47662f2bb9fdd4b2719cc82f6 Mon Sep 17 00:00:00 2001 From: djanderson26 Date: Fri, 17 Apr 2026 17:08:38 -0400 Subject: [PATCH 1/3] feat(study): cap reviews at 10K per user via pruneOldReviews() (#133) - Add pruneOldReviews(userId) to ReviewRepository - Deletes oldest reviews beyond 10K threshold per user - Runs in database transaction for consistency - Returns count of deleted rows --- .../study/data/review_repository.dart | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/lib/features/study/data/review_repository.dart b/lib/features/study/data/review_repository.dart index 822d5e7..88f5d6d 100644 --- a/lib/features/study/data/review_repository.dart +++ b/lib/features/study/data/review_repository.dart @@ -62,4 +62,38 @@ class ReviewRepository { whereArgs: reviewIds, ); } + + /// Prunes reviews exceeding 10K per user, keeping only the most recent. + Future pruneOldReviews(String userId) async { + const int maxReviews = 10000; + final db = await _dbHelper.database; + + return await db.transaction((txn) async { + // Count reviews for this user + final countResult = await txn.rawQuery( + 'SELECT COUNT(*) as count FROM ${DatabaseConstants.tableReviews} ' + 'WHERE ${DatabaseConstants.colUserId} = ?', + [userId], + ); + final count = (countResult.first['count'] as int?) ?? 0; + + if (count <= maxReviews) { + return 0; // No pruning needed + } + + // Delete oldest reviews beyond the 10K limit + final deleteCount = count - maxReviews; + return await txn.rawDelete( + '''DELETE FROM ${DatabaseConstants.tableReviews} + WHERE ${DatabaseConstants.colReviewId} IN ( + SELECT ${DatabaseConstants.colReviewId} + FROM ${DatabaseConstants.tableReviews} + WHERE ${DatabaseConstants.colUserId} = ? + ORDER BY ${DatabaseConstants.colReviewedAt} ASC + LIMIT ? + )''', + [userId, deleteCount], + ); + }); + } } From 7c39d138222d506f4571f7850dae01fca8967123 Mon Sep 17 00:00:00 2001 From: djanderson26 Date: Fri, 17 Apr 2026 17:09:12 -0400 Subject: [PATCH 2/3] feat(study): record session summaries and prune reviews on session end (#133) - Add card state tracking (_newCount, _learningCount, _reviewCount) - Implement _finalizeSession() to create and persist ReviewSessionSummary - Call pruneOldReviews() after each session to enforce 10K cap - Trigger review pruning on both normal exit and early termination - Schedule sync push after session finalization --- .../screens/study_session_screen.dart | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/lib/features/study/presentation/screens/study_session_screen.dart b/lib/features/study/presentation/screens/study_session_screen.dart index 3cd6105..fa9621c 100644 --- a/lib/features/study/presentation/screens/study_session_screen.dart +++ b/lib/features/study/presentation/screens/study_session_screen.dart @@ -16,8 +16,11 @@ import 'package:lapse/features/cards/data/card_repository_provider.dart'; import 'package:lapse/features/cards/domain/flashcard.dart'; import 'package:lapse/features/study/domain/rating.dart'; import 'package:lapse/features/study/data/review_repository_provider.dart'; +import 'package:lapse/features/study/data/review_session_summary_repository.dart'; import 'package:lapse/features/study/application/study_session_service.dart'; +import 'package:lapse/features/study/domain/review_session_summary.dart'; import 'package:lapse/core/sync/sync_service.dart'; +import 'package:lapse/features/auth/application/auth_service.dart'; import 'package:lapse/features/notifications/presentation/providers/notification_providers.dart'; import 'package:lapse/features/study/domain/study_session.dart'; import 'package:lapse/features/study/presentation/widgets/card_stack.dart'; @@ -140,6 +143,11 @@ class _StudySessionScreenState extends ConsumerState }; final Set _graduatedCardIds = {}; + // Card state counts for session summary + int _newCount = 0; + int _learningCount = 0; + int _reviewCount = 0; + // Debug panel state bool _showDebugPanel = false; final List<_ReviewLogEntry> _reviewLog = []; @@ -302,6 +310,18 @@ class _StudySessionScreenState extends ConsumerState final graduated = result.updatedCard.cardState == CardState.review; final previousSession = _session; + // Track card states for session summary + switch (_currentCard.cardState) { + case CardState.newCard: + _newCount++; + case CardState.learning: + _learningCount++; + case CardState.review: + _reviewCount++; + case CardState.relearning: + _learningCount++; + } + setState(() { // Buffer this rating — written to DB on the next rating or session end. _pendingRating = _PendingRating( @@ -340,6 +360,42 @@ class _StudySessionScreenState extends ConsumerState } } + /// Finalizes the session: saves the session summary and prunes old reviews. + /// Called when the user finishes or exits the session. + Future _finalizeSession() async { + try { + final authService = AuthService(); + final userId = await authService.getCurrentUserId(); + + // Create and save session summary + final summary = ReviewSessionSummary.fromSession( + startedAt: _session.startedAt, + endedAt: DateTime.now(), + againCount: _ratingCounts[Rating.again]!, + hardCount: _ratingCounts[Rating.hard]!, + goodCount: _ratingCounts[Rating.good]!, + easyCount: _ratingCounts[Rating.easy]!, + newCount: _newCount, + learningCount: _learningCount, + reviewCount: _reviewCount, + userId: userId, + ); + + final summaryRepo = ReviewSessionSummaryRepository(); + await summaryRepo.add(summary); + + // Prune reviews exceeding 10K per user + final reviewRepo = ref.read(reviewRepositoryProvider); + await reviewRepo.pruneOldReviews(userId); + + // Schedule push to sync the session summary + ref.read(syncServiceProvider.notifier).schedulePush(); + } catch (e) { + debugPrint('[StudySession] Error finalizing session: $e'); + // Non-fatal — don't block session exit + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -888,6 +944,7 @@ class _StudySessionScreenState extends ConsumerState child: ElevatedButton( onPressed: () async { await _flushPendingWrite(); + await _finalizeSession(); _invalidateDeckProviders(); unawaited(ref.read(dueReminderSchedulerProvider).rescheduleAll()); if (mounted) context.pop(); @@ -950,6 +1007,7 @@ class _StudySessionScreenState extends ConsumerState ); if (shouldExit == true && context.mounted) { await _flushPendingWrite(); + await _finalizeSession(); _invalidateDeckProviders(); unawaited(ref.read(dueReminderSchedulerProvider).rescheduleAll()); if (context.mounted) context.pop(); From 8117cf5b2581b5971a134cd6d2cbfd471fa3be46 Mon Sep 17 00:00:00 2001 From: djanderson26 Date: Fri, 17 Apr 2026 17:09:47 -0400 Subject: [PATCH 3/3] test(study): add 10K review cap pruning tests (#133) - Test: no pruning under 10K reviews - Test: correct capping at 10K threshold - Test: oldest reviews deleted while newest kept - Test: multi-user isolation - Test: edge cases (empty userId, non-existent user) --- .../study/review_repository_test.dart | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/test/features/study/review_repository_test.dart b/test/features/study/review_repository_test.dart index 3f31e79..d05bdf9 100644 --- a/test/features/study/review_repository_test.dart +++ b/test/features/study/review_repository_test.dart @@ -280,4 +280,185 @@ void main() { await repository.markSynced([]); }); }); + + group('Review pruning (10K cap per user)', () { + test('pruneOldReviews removes nothing when under 10K', () async { + final userId = 'test_user_1'; + + // Add 100 reviews + for (int i = 0; i < 100; i++) { + final review = Review( + reviewId: 'r_$i', + cardId: 'card_$i', + reviewedAt: DateTime.now().subtract(Duration(hours: 100 - i)), + rating: Rating.good, + scheduledDays: 1, + elapsedDays: 0, + state: CardState.review, + userId: userId, + ); + await repository.addReview(review); + } + + final deleted = await repository.pruneOldReviews(userId); + + expect(deleted, 0); + final db = await dbHelper.database; + final countResult = await db.rawQuery( + 'SELECT COUNT(*) as count FROM ${DatabaseConstants.tableReviews} WHERE ${DatabaseConstants.colUserId} = ?', + [userId], + ); + final count = (countResult.first['count'] as int?) ?? 0; + expect(count, 100); + }); + + test('pruneOldReviews caps at 10K when exceeding', () async { + const maxReviews = 10000; + final userId = 'test_user_2'; + + // Add 10,100 reviews using batch inserts for speed (100 beyond the cap) + final db = await dbHelper.database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (int i = 0; i < 10100; i++) { + final review = Review( + reviewId: 'r_$i', + cardId: 'card_${i % 1000}', + reviewedAt: DateTime.now().subtract(Duration(seconds: 10100 - i)), + rating: Rating.good, + scheduledDays: 1, + elapsedDays: 0, + state: CardState.review, + userId: userId, + ); + batch.insert(DatabaseConstants.tableReviews, review.toMap()); + } + await batch.commit(); + }); + + final deleted = await repository.pruneOldReviews(userId); + + expect(deleted, 100); + final countResult = await db.rawQuery( + 'SELECT COUNT(*) as count FROM ${DatabaseConstants.tableReviews} WHERE ${DatabaseConstants.colUserId} = ?', + [userId], + ); + final count = (countResult.first['count'] as int?) ?? 0; + expect(count, maxReviews); + }); + + test('pruneOldReviews keeps newest reviews and deletes oldest', () async { + final userId = 'test_user_3'; + const maxReviews = 10000; + + // Add 10,005 reviews using batch inserts for speed + final db = await dbHelper.database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (int i = 0; i < 10005; i++) { + final review = Review( + reviewId: 'r_$i', + cardId: 'card_${i % 500}', + reviewedAt: DateTime(2026, 1, 1).add(Duration(seconds: i)), + rating: Rating.good, + scheduledDays: 1, + elapsedDays: 0, + state: CardState.review, + userId: userId, + ); + batch.insert(DatabaseConstants.tableReviews, review.toMap()); + } + await batch.commit(); + }); + + final deleted = await repository.pruneOldReviews(userId); + + expect(deleted, 5); + + // Get all remaining reviews ordered by reviewedAt + final remaining = await db.query( + DatabaseConstants.tableReviews, + where: '${DatabaseConstants.colUserId} = ?', + whereArgs: [userId], + orderBy: '${DatabaseConstants.colReviewedAt} ASC', + ); + + expect(remaining.length, maxReviews); + // First review should be the 5th one (indices 0-4 deleted) + expect(remaining.first[DatabaseConstants.colReviewId], 'r_5'); + // Last review should be the most recent one + expect(remaining.last[DatabaseConstants.colReviewId], 'r_10004'); + }); + + test('pruneOldReviews only affects specified user', () async { + const maxReviews = 10000; + final user1 = 'user_1'; + final user2 = 'user_2'; + + // Add 10,050 reviews for user_1 using batch inserts + final db = await dbHelper.database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (int i = 0; i < 10050; i++) { + final review = Review( + reviewId: 'u1_r_$i', + cardId: 'card_${i % 500}', + reviewedAt: DateTime(2026, 1, 1).add(Duration(seconds: i)), + rating: Rating.good, + scheduledDays: 1, + elapsedDays: 0, + state: CardState.review, + userId: user1, + ); + batch.insert(DatabaseConstants.tableReviews, review.toMap()); + } + + // Add 500 reviews for user_2 + for (int i = 0; i < 500; i++) { + final review = Review( + reviewId: 'u2_r_$i', + cardId: 'card_${i % 100}', + reviewedAt: DateTime(2026, 1, 1).add(Duration(seconds: i)), + rating: Rating.hard, + scheduledDays: 1, + elapsedDays: 0, + state: CardState.review, + userId: user2, + ); + batch.insert(DatabaseConstants.tableReviews, review.toMap()); + } + await batch.commit(); + }); + + // Prune user_1 + final deleted = await repository.pruneOldReviews(user1); + + expect(deleted, 50); + + final user1CountResult = await db.rawQuery( + 'SELECT COUNT(*) as count FROM ${DatabaseConstants.tableReviews} WHERE ${DatabaseConstants.colUserId} = ?', + [user1], + ); + final user1Count = (user1CountResult.first['count'] as int?) ?? 0; + + final user2CountResult = await db.rawQuery( + 'SELECT COUNT(*) as count FROM ${DatabaseConstants.tableReviews} WHERE ${DatabaseConstants.colUserId} = ?', + [user2], + ); + final user2Count = (user2CountResult.first['count'] as int?) ?? 0; + + expect(user1Count, maxReviews); + expect(user2Count, 500); // User 2 unaffected + }); + + test('pruneOldReviews on user with no reviews returns 0', () async { + final deleted = await repository.pruneOldReviews('nonexistent_user'); + expect(deleted, 0); + }); + + test('pruneOldReviews with empty userId is safe', () async { + final deleted = await repository.pruneOldReviews(''); + expect(deleted, 0); + }); + }); }