Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions lib/features/study/data/review_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,38 @@ class ReviewRepository {
whereArgs: reviewIds,
);
}

/// Prunes reviews exceeding 10K per user, keeping only the most recent.
Future<int> pruneOldReviews(String userId) async {
const int maxReviews = 10000;
final db = await _dbHelper.database;

return await db.transaction<int>((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],
);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -140,6 +143,11 @@ class _StudySessionScreenState extends ConsumerState<StudySessionScreen>
};
final Set<String> _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 = [];
Expand Down Expand Up @@ -302,6 +310,18 @@ class _StudySessionScreenState extends ConsumerState<StudySessionScreen>
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(
Expand Down Expand Up @@ -340,6 +360,42 @@ class _StudySessionScreenState extends ConsumerState<StudySessionScreen>
}
}

/// Finalizes the session: saves the session summary and prunes old reviews.
/// Called when the user finishes or exits the session.
Future<void> _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(
Expand Down Expand Up @@ -888,6 +944,7 @@ class _StudySessionScreenState extends ConsumerState<StudySessionScreen>
child: ElevatedButton(
onPressed: () async {
await _flushPendingWrite();
await _finalizeSession();
_invalidateDeckProviders();
unawaited(ref.read(dueReminderSchedulerProvider).rescheduleAll());
if (mounted) context.pop();
Expand Down Expand Up @@ -950,6 +1007,7 @@ class _StudySessionScreenState extends ConsumerState<StudySessionScreen>
);
if (shouldExit == true && context.mounted) {
await _flushPendingWrite();
await _finalizeSession();
_invalidateDeckProviders();
unawaited(ref.read(dueReminderSchedulerProvider).rescheduleAll());
if (context.mounted) context.pop();
Expand Down
181 changes: 181 additions & 0 deletions test/features/study/review_repository_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}