perf(learn): a rated card gives way to the next one at once

Rating a card while studying waited for its review to be written, then
for the card's schedule to be replayed, before the next card showed.
StudySessionBloc now starts keeping the review and shows what comes
next at once: the card's new place in the round is worked out from its
reviews with the new one counted as if kept already
(ReviewRepository.schedulesAsStudied's pending, each review counted
once should it be kept by then).

Should keeping it fail, the step leaves the session's Undo, the rating
leaves its accuracy, and the card comes back where it stood, saying so
(withRatingFailed), as a failed rating did before. An Undo of a rating
still being kept waits for it and takes it back once kept; one that
couldn't be kept has nothing to take back, and the Undo has brought
the card back already.

Whats-New: While studying, the next flashcard shows the moment you rate one.
Whats-New-De: Beim Lernen kommt die nächste Karteikarte, sobald du eine bewertet hast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-07 23:26:25 +01:00
commit 4940230ef1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 235 additions and 57 deletions

View file

@ -534,10 +534,14 @@ class StudySessionBloc(
/// The schedules of [ids], each in the direction its card is studied:
/// written by hand, back first or front first.
Future<Map<String, CardSchedule>> _schedules(List<String> ids) =>
_reviews.schedulesAsStudied([
for (final id in ids) _cardsById[id]!,
], _options);
Future<Map<String, CardSchedule>> _schedules(
List<String> ids, {
Iterable<Review> pending = const [],
}) => _reviews.schedulesAsStudied(
[for (final id in ids) _cardsById[id]!],
_options,
pending: pending,
);
/// Shows the round's next card: the first of those to come, in the
/// round's order, whether or not its time has come (decided 2026-10-16);
@ -712,57 +716,118 @@ class StudySessionBloc(
if (state case final StudySessionStateShowing showing) {
final card = showing.card;
final now = clock.now().toUtc();
try {
await _reviews.record(
cardId: card.id,
rating: event.rating,
reviewedAt: now,
duration: now.difference(showing.shownAt),
mode: _modeOf(card),
);
} on Exception catch (error) {
// The card stays, to be rated again.
log('Keeping a rating failed', name: 'Study', error: error);
emit(showing.withRatingFailed());
return;
}
_steps.add(_Rated(_standOf(card), at: now, mode: _modeOf(card)));
final review = Review(
id: '',
cardId: card.id,
rating: event.rating,
reviewedAt: now,
duration: now.difference(showing.shownAt),
mode: _modeOf(card),
);
// The next card shows at once, while the rating is being kept
// (optimistic state); should keeping it fail, the card comes back.
final step = _Rated(
_standOf(card),
at: now,
mode: review.mode,
kept: _keep(review),
);
_steps.add(step);
_ratings[card.id] = event.rating;
_given.add(event.rating);
if (_options.mode == LearnMode.cram) {
_done.add(card.id);
// The next card not rated yet, after this one and then from the
// start; the round is done once every card has a rating.
final next = [
for (var i = 1; i <= _round.length; i++)
(_cramIndex + i) % _round.length,
].where((i) => !_ratings.containsKey(_round[i])).firstOrNull;
if (next == null) {
emit(await _roundDone());
return;
}
_cramIndex = next;
emit(_showing(_round[next], now, schedule: null));
await _showAfterRating(emit, card, review);
if (!await step.kept) {
await _ratingNotKept(emit, step, event.rating);
}
}
}
/// Keeps [review], saying whether that went through.
Future<bool> _keep(Review review) async {
try {
await _reviews.record(
cardId: review.cardId,
rating: review.rating,
reviewedAt: review.reviewedAt,
duration: review.duration,
mode: review.mode,
);
return true;
} on Exception catch (error) {
log('Keeping a rating failed', name: 'Study', error: error);
return false;
}
}
/// Shows what comes after [card] was rated as [review] says, which may
/// still be being kept.
Future<void> _showAfterRating(
Emitter<StudySessionState> emit,
Card card,
Review review,
) async {
final now = review.reviewedAt;
if (_options.mode == LearnMode.cram) {
_done.add(card.id);
// The next card not rated yet, after this one and then from the
// start; the round is done once every card has a rating.
final next = [
for (var i = 1; i <= _round.length; i++)
(_cramIndex + i) % _round.length,
].where((i) => !_ratings.containsKey(_round[i])).firstOrNull;
if (next == null) {
emit(await _roundDone());
return;
}
final schedule = (await _schedules([card.id]))[card.id]!;
// The round goes in an order (decided 2026-10-16): the cards done,
// then the one shown, then those to come. A day or more away, the
// card is done for this round, the last of those done; else it goes
// back among those to come, where the rating puts it.
_round.remove(card.id);
final firstToCome = _round.where(_graduated.contains).length;
if (!schedule.due!.isBefore(now.add(_roundsDay))) {
_graduated.add(card.id);
_done.add(card.id);
_round.insert(firstToCome, card.id);
} else {
_round.insert(
firstToCome + cardsBefore(event.rating, _round.length - firstToCome),
card.id,
);
}
await _showNext(emit, now);
_cramIndex = next;
emit(_showing(_round[next], now, schedule: null));
return;
}
final schedule = (await _schedules(
[card.id],
pending: [review],
))[card.id]!;
// The round goes in an order (decided 2026-10-16): the cards done,
// then the one shown, then those to come. A day or more away, the
// card is done for this round, the last of those done; else it goes
// back among those to come, where the rating puts it.
_round.remove(card.id);
final firstToCome = _round.where(_graduated.contains).length;
if (!schedule.due!.isBefore(now.add(_roundsDay))) {
_graduated.add(card.id);
_done.add(card.id);
_round.insert(firstToCome, card.id);
} else {
_round.insert(
firstToCome + cardsBefore(review.rating, _round.length - firstToCome),
card.id,
);
}
await _showNext(emit, now);
}
/// Puts the session back as it was before [step], whose rating [rating]
/// couldn't be kept: the card shows again, to be rated again, saying so.
/// Nothing to do once the step was taken back (Undo brought the card
/// back already).
Future<void> _ratingNotKept(
Emitter<StudySessionState> emit,
_Rated step,
Rating rating,
) async {
if (isClosed || !_steps.remove(step)) {
return;
}
if (step.before.session == _sessionNumber) {
_given.remove(rating);
}
// While another round is being read, it reads the card as unrated.
if (!_offersUndo) {
return;
}
await _bringBack(emit, step.before, show: true);
if (state case final StudySessionStateShowing showing) {
emit(showing.withRatingFailed());
}
}
@ -894,12 +959,16 @@ class StudySessionBloc(
Future<void> _takeBack(_Step step) async {
final id = step.before.card.id;
switch (step) {
case _Rated(:final at, :final mode):
await _reviews.undo(cardId: id, reviewedAt: at, mode: mode);
case _Rated(:final at, :final mode, :final kept):
// Ratings are taken back last first: it was the last given.
if (step.before.session == _sessionNumber && _given.isNotEmpty) {
_given.removeLast();
}
// One still being kept is taken back once it is; one that couldn't
// be has nothing to take back.
if (await kept) {
await _reviews.undo(cardId: id, reviewedAt: at, mode: mode);
}
case _Buried():
await _cards.unbury(id);
_setAside.remove(id);
@ -1437,11 +1506,13 @@ typedef _Stand = ({
/// [before].
sealed class const _Step(final _Stand before);
/// The card was rated [at], studied as [mode].
/// The card was rated [at], studied as [mode]; [kept] says whether the
/// review was kept, once it is known.
final class const _Rated(
super.before, {
required final DateTime at,
required final StudyMode mode,
required final Future<bool> kept,
}) extends _Step;
/// The card was buried: set aside for the day.

View file

@ -150,11 +150,14 @@ class ReviewRepository(final ReviewGateway _gateway) {
/// The schedules of [cards], each on the one it is studied on under
/// [options] ([scheduleModeOf]: written by hand, back first or front
/// first), as a session would study them.
/// first), as a session would study them; with [pending], reviews being
/// recorded still, as if they were kept already (a session shows the
/// next card before its rating is kept), each counted once.
Future<Map<String, CardSchedule>> schedulesAsStudied(
List<Card> cards,
StudyOptions options,
) async {
StudyOptions options, {
Iterable<Review> pending = const [],
}) async {
final byMode = <StudyMode, List<String>>{};
for (final card in cards) {
(byMode[scheduleModeOf(card.content, options)] ??= []).add(card.id);
@ -163,6 +166,16 @@ class ReviewRepository(final ReviewGateway _gateway) {
return const {};
}
final reviews = await reviewsOf({for (final card in cards) card.id});
for (final review in pending) {
if (!reviews.any(
(kept) =>
kept.cardId == review.cardId &&
kept.mode == review.mode &&
kept.reviewedAt == review.reviewedAt,
)) {
reviews.add(review);
}
}
return {
for (final MapEntry(key: mode, value: ids) in byMode.entries)
...schedulesFrom(reviews, ids, mode: mode),