feat(app): reviews know how a card was studied; progress can start afresh
Each review records its mode (ADR 0004, rule 3): front to back, back to front with a schedule of its own, or crammed outside the schedule. A card's schedule replays only the reviews of one mode. Resetting progress appends a 'reset' entry per card, no migration (the column is text): schedules replay what follows it, and the history stays for the streak and the heatmap. Reviews are inserted in batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8cf7749ed6
commit
baace1b6e9
10 changed files with 214 additions and 69 deletions
|
|
@ -56,7 +56,9 @@ revision's payload, and the per-side merge of ADR 0003 becomes per field.
|
||||||
back), `reverse` (back to front, with a schedule of its own, as Anki's reverse cards
|
back), `reverse` (back to front, with a schedule of its own, as Anki's reverse cards
|
||||||
have) or `cram` (outside the schedule: it counts for streaks and the activity
|
have) or `cram` (outside the schedule: it counts for streaks and the activity
|
||||||
heatmap, and never moves a schedule). A card's schedule replays only the reviews of
|
heatmap, and never moves a schedule). A card's schedule replays only the reviews of
|
||||||
one mode. Existing reviews are `review`.
|
one mode. Existing reviews are `review`. Resetting a card's progress (added
|
||||||
|
2026-10-01) appends a `reset` entry rather than deleting reviews: schedules replay
|
||||||
|
only what follows it, and the streak and the heatmap keep the history.
|
||||||
4. **A person's settings are one synced row.** `person_settings` holds settings in a
|
4. **A person's settings are one synced row.** `person_settings` holds settings in a
|
||||||
versioned payload: study goals now, other preferences later. Two devices creating it
|
versioned payload: study goals now, other preferences later. Two devices creating it
|
||||||
while offline make two rows; the app keeps the one with the smallest id and folds the
|
while offline make two rows; the app keeps the one with the smallest id and folds the
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,8 @@ abstract class ReviewLog
|
||||||
/// (back to front, with a schedule of its own, as Anki's reverse cards have),
|
/// (back to front, with a schedule of its own, as Anki's reverse cards have),
|
||||||
/// or 'cram' (outside the schedule: counts for streaks and the activity
|
/// or 'cram' (outside the schedule: counts for streaks and the activity
|
||||||
/// heatmap, never moves a schedule). Reviews from before modes are 'review'.
|
/// heatmap, never moves a schedule). Reviews from before modes are 'review'.
|
||||||
|
/// 'reset' is no rating but a fresh start: the card's schedules replay only
|
||||||
|
/// what follows it, its rating means nothing.
|
||||||
String mode;
|
String mode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -313,6 +315,8 @@ class ReviewLogTable extends _isd.Table<_isc.UuidValue?> {
|
||||||
/// (back to front, with a schedule of its own, as Anki's reverse cards have),
|
/// (back to front, with a schedule of its own, as Anki's reverse cards have),
|
||||||
/// or 'cram' (outside the schedule: counts for streaks and the activity
|
/// or 'cram' (outside the schedule: counts for streaks and the activity
|
||||||
/// heatmap, never moves a schedule). Reviews from before modes are 'review'.
|
/// heatmap, never moves a schedule). Reviews from before modes are 'review'.
|
||||||
|
/// 'reset' is no rating but a fresh start: the card's schedules replay only
|
||||||
|
/// what follows it, its rating means nothing.
|
||||||
late final _isd.ColumnString mode;
|
late final _isd.ColumnString mode;
|
||||||
|
|
||||||
_i4926i81.CardTable get card {
|
_i4926i81.CardTable get card {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ typedef ReviewRow = ({
|
||||||
int rating,
|
int rating,
|
||||||
DateTime reviewedAt,
|
DateTime reviewedAt,
|
||||||
int durationMs,
|
int durationMs,
|
||||||
|
String mode,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The boundary to the reviews in this person's local, synced database. Tests
|
/// The boundary to the reviews in this person's local, synced database. Tests
|
||||||
|
|
@ -13,13 +14,8 @@ abstract interface class ReviewGateway() {
|
||||||
/// The reviews of the cards [cardIds], unordered.
|
/// The reviews of the cards [cardIds], unordered.
|
||||||
Future<List<ReviewRow>> reviewsOf(Set<String> cardIds);
|
Future<List<ReviewRow>> reviewsOf(Set<String> cardIds);
|
||||||
|
|
||||||
/// Appends a review; reviews are never changed.
|
/// Appends reviews; reviews are never changed.
|
||||||
Future<void> insert({
|
Future<void> insert(Iterable<ReviewRow> reviews);
|
||||||
required String cardId,
|
|
||||||
required int rating,
|
|
||||||
required DateTime reviewedAt,
|
|
||||||
required int durationMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Fires when a sync brought changes from the person's other devices.
|
/// Fires when a sync brought changes from the person's other devices.
|
||||||
Stream<void> get remoteChanges;
|
Stream<void> get remoteChanges;
|
||||||
|
|
|
||||||
|
|
@ -13,47 +13,97 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
||||||
for (final row in await _gateway.reviewsOf(cardIds)) ?_decode(row),
|
for (final row in await _gateway.reviewsOf(cardIds)) ?_decode(row),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The schedule of each card in [cardIds], in that order, replayed from
|
/// The schedule of each card in [cardIds] for [mode] (front to back, or
|
||||||
/// its reviews on this device.
|
/// back to front), in that order, replayed from its reviews of that mode
|
||||||
Future<Map<String, CardSchedule>> schedulesOf(List<String> cardIds) async {
|
/// since its last fresh start (ADR 0004, rule 3).
|
||||||
|
Future<Map<String, CardSchedule>> schedulesOf(
|
||||||
|
List<String> cardIds, {
|
||||||
|
StudyMode mode = StudyMode.review,
|
||||||
|
}) async {
|
||||||
final reviewsByCard = <String, List<Review>>{};
|
final reviewsByCard = <String, List<Review>>{};
|
||||||
for (final review in await reviewsOf({...cardIds})) {
|
for (final review in await reviewsOf({...cardIds})) {
|
||||||
(reviewsByCard[review.cardId] ??= []).add(review);
|
(reviewsByCard[review.cardId] ??= []).add(review);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
for (final id in cardIds)
|
for (final id in cardIds)
|
||||||
id: scheduleOf(id, reviewsByCard[id] ?? const []),
|
id: scheduleOf(id, _since(reviewsByCard[id] ?? const [], mode)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records that [cardId] was rated [rating] at [reviewedAt], after
|
/// Records that [cardId] was rated [rating] at [reviewedAt], studied as
|
||||||
/// [duration] of answering.
|
/// [mode], after [duration] of answering.
|
||||||
Future<void> record({
|
Future<void> record({
|
||||||
required String cardId,
|
required String cardId,
|
||||||
required Rating rating,
|
required Rating rating,
|
||||||
required DateTime reviewedAt,
|
required DateTime reviewedAt,
|
||||||
required Duration duration,
|
required Duration duration,
|
||||||
}) => _gateway.insert(
|
StudyMode mode = StudyMode.review,
|
||||||
|
}) => _gateway.insert([
|
||||||
|
(
|
||||||
|
id: '',
|
||||||
cardId: cardId,
|
cardId: cardId,
|
||||||
rating: rating.value,
|
rating: rating.value,
|
||||||
reviewedAt: reviewedAt.toUtc(),
|
reviewedAt: reviewedAt.toUtc(),
|
||||||
durationMs: duration.inMilliseconds,
|
durationMs: duration.inMilliseconds,
|
||||||
);
|
mode: mode.value,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
/// The review in [row], or null for a rating this app doesn't know.
|
/// Starts the progress of [cardIds] afresh at [at], in both directions;
|
||||||
|
/// their reviews stay, for the history.
|
||||||
|
Future<void> reset(Iterable<String> cardIds, DateTime at) => _gateway.insert([
|
||||||
|
for (final cardId in cardIds)
|
||||||
|
(
|
||||||
|
id: '',
|
||||||
|
cardId: cardId,
|
||||||
|
// The rating column needs a value; a fresh start's isn't read.
|
||||||
|
rating: Rating.again.value,
|
||||||
|
reviewedAt: at.toUtc(),
|
||||||
|
durationMs: 0,
|
||||||
|
mode: StudyMode.reset.value,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// Of [reviews], those of [mode] after the last fresh start.
|
||||||
|
List<Review> _since(List<Review> reviews, StudyMode mode) {
|
||||||
|
DateTime? start;
|
||||||
|
for (final review in reviews) {
|
||||||
|
if (review.mode == StudyMode.reset &&
|
||||||
|
(start == null || review.reviewedAt.isAfter(start))) {
|
||||||
|
start = review.reviewedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final review in reviews)
|
||||||
|
if (review.mode == mode &&
|
||||||
|
(start == null || !review.reviewedAt.isBefore(start)))
|
||||||
|
review,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The review in [row], or null for a rating or mode this app doesn't
|
||||||
|
/// know, e.g. one a newer app version added.
|
||||||
Review? _decode(ReviewRow row) {
|
Review? _decode(ReviewRow row) {
|
||||||
for (final rating in Rating.values) {
|
final mode = StudyMode.values
|
||||||
if (rating.value == row.rating) {
|
.where((mode) => mode.value == row.mode)
|
||||||
|
.firstOrNull;
|
||||||
|
final rating = Rating.values
|
||||||
|
.where((rating) => rating.value == row.rating)
|
||||||
|
.firstOrNull;
|
||||||
|
if (mode == null || rating == null) {
|
||||||
|
log(
|
||||||
|
'Skipped review ${row.id}: rating ${row.rating}, mode ${row.mode}',
|
||||||
|
name: 'Reviews',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return Review(
|
return Review(
|
||||||
id: row.id,
|
id: row.id,
|
||||||
cardId: row.cardId,
|
cardId: row.cardId,
|
||||||
rating: rating,
|
rating: rating,
|
||||||
reviewedAt: row.reviewedAt.toUtc(),
|
reviewedAt: row.reviewedAt.toUtc(),
|
||||||
duration: Duration(milliseconds: row.durationMs),
|
duration: Duration(milliseconds: row.durationMs),
|
||||||
|
mode: mode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log('Skipped review ${row.id}: rating ${row.rating}', name: 'Reviews');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -27,26 +27,28 @@ class SyncReviewGateway(
|
||||||
rating: review.rating,
|
rating: review.rating,
|
||||||
reviewedAt: review.reviewedAt,
|
reviewedAt: review.reviewedAt,
|
||||||
durationMs: review.durationMs,
|
durationMs: review.durationMs,
|
||||||
|
mode: review.mode,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> insert({
|
Future<void> insert(Iterable<ReviewRow> reviews) async {
|
||||||
required String cardId,
|
final rows = [
|
||||||
required int rating,
|
for (final review in reviews)
|
||||||
required DateTime reviewedAt,
|
|
||||||
required int durationMs,
|
|
||||||
}) async {
|
|
||||||
await protocol.ReviewLog.db.insertRow(
|
|
||||||
_db,
|
|
||||||
protocol.ReviewLog(
|
protocol.ReviewLog(
|
||||||
cardId: protocol.UuidValue.withValidation(cardId),
|
cardId: protocol.UuidValue.withValidation(review.cardId),
|
||||||
rating: rating,
|
rating: review.rating,
|
||||||
reviewedAt: reviewedAt,
|
reviewedAt: review.reviewedAt,
|
||||||
durationMs: durationMs,
|
durationMs: review.durationMs,
|
||||||
|
mode: review.mode,
|
||||||
),
|
),
|
||||||
);
|
];
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Each row gets its UUID v7 on the device; the ids given are ignored.
|
||||||
|
await protocol.ReviewLog.db.insert(_db, rows);
|
||||||
_sync.requestSync();
|
_sync.requestSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,30 @@ final class const Review({
|
||||||
|
|
||||||
/// How long answering took, from showing the card to rating it.
|
/// How long answering took, from showing the card to rating it.
|
||||||
required final Duration duration,
|
required final Duration duration,
|
||||||
|
|
||||||
|
/// How the card was studied, or a fresh start of its progress.
|
||||||
|
final StudyMode mode = StudyMode.review,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, cardId, rating, reviewedAt, duration];
|
List<Object?> get props => [id, cardId, rating, reviewedAt, duration, mode];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a card was studied (ADR 0004, rule 3), or a fresh start of its
|
||||||
|
/// progress; the review log stores it by [value].
|
||||||
|
enum StudyMode(
|
||||||
|
/// The value the review log stores.
|
||||||
|
final String value,
|
||||||
|
) {
|
||||||
|
/// Front to back, on its schedule.
|
||||||
|
review('review'),
|
||||||
|
|
||||||
|
/// Back to front, on a schedule of its own, as Anki's reverse cards have.
|
||||||
|
reverse('reverse'),
|
||||||
|
|
||||||
|
/// Outside the schedule: counts as studying, never moves a schedule.
|
||||||
|
cram('cram'),
|
||||||
|
|
||||||
|
/// Not a rating: the card's progress starts afresh from here, its
|
||||||
|
/// earlier reviews kept for the history (its rating means nothing).
|
||||||
|
reset('reset'),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||||
|
|
||||||
import '../../../support/fake_review_gateway.dart';
|
import '../../../support/fake_review_gateway.dart';
|
||||||
|
|
||||||
|
|
@ -66,14 +67,77 @@ void main() {
|
||||||
expect(schedules['new']!.isNew, isTrue);
|
expect(schedules['new']!.isNew, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('skips a rating this app does not know', () async {
|
test('skips a rating or a mode this app does not know', () async {
|
||||||
await gateway.insert(
|
await gateway.insert([
|
||||||
|
(
|
||||||
|
id: '',
|
||||||
cardId: 'card',
|
cardId: 'card',
|
||||||
rating: 9,
|
rating: 9,
|
||||||
reviewedAt: DateTime.utc(2026, 10, 5),
|
reviewedAt: DateTime.utc(2026, 10, 5),
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
);
|
mode: 'review',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
id: '',
|
||||||
|
cardId: 'card',
|
||||||
|
rating: 3,
|
||||||
|
reviewedAt: DateTime.utc(2026, 10, 5),
|
||||||
|
durationMs: 0,
|
||||||
|
mode: 'telepathy',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
expect(await reviews.reviewsOf({'card'}), isEmpty);
|
expect(await reviews.reviewsOf({'card'}), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('Modes', () {
|
||||||
|
final day = DateTime.utc(2026, 10, 5, 9);
|
||||||
|
|
||||||
|
Future<void> rate(String card, Rating rating, int hours, StudyMode mode) =>
|
||||||
|
reviews.record(
|
||||||
|
cardId: card,
|
||||||
|
rating: rating,
|
||||||
|
reviewedAt: day.add(Duration(hours: hours)),
|
||||||
|
duration: Duration.zero,
|
||||||
|
mode: mode,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('studying back to front has a schedule of its own; cramming moves '
|
||||||
|
'none', () async {
|
||||||
|
await rate('card', Rating.good, 0, StudyMode.review);
|
||||||
|
await rate('card', Rating.again, 1, StudyMode.reverse);
|
||||||
|
await rate('card', Rating.easy, 2, StudyMode.cram);
|
||||||
|
|
||||||
|
final forward = (await reviews.schedulesOf(['card']))['card']!;
|
||||||
|
final backward = (await reviews.schedulesOf([
|
||||||
|
'card',
|
||||||
|
], mode: StudyMode.reverse))['card']!;
|
||||||
|
|
||||||
|
expect(forward.reviewCount, 1);
|
||||||
|
expect(backward.reviewCount, 1);
|
||||||
|
expect(backward.isLearning, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fresh start makes cards new again in both directions, and '
|
||||||
|
'keeps their reviews', () async {
|
||||||
|
await rate('card', Rating.good, 0, StudyMode.review);
|
||||||
|
await rate('card', Rating.good, 1, StudyMode.reverse);
|
||||||
|
await reviews.reset(['card'], day.add(const Duration(hours: 2)));
|
||||||
|
|
||||||
|
expect((await reviews.schedulesOf(['card']))['card']!.isNew, isTrue);
|
||||||
|
expect(
|
||||||
|
(await reviews.schedulesOf([
|
||||||
|
'card',
|
||||||
|
], mode: StudyMode.reverse))['card']!.isNew,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(await reviews.reviewsOf({'card'}), hasLength(3));
|
||||||
|
|
||||||
|
await rate('card', Rating.good, 3, StudyMode.review);
|
||||||
|
expect(
|
||||||
|
(await reviews.schedulesOf(['card']))['card']!.reviewCount,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,18 @@ class FakeReviewGateway() implements ReviewGateway {
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> insert({
|
Future<void> insert(Iterable<ReviewRow> reviews) async {
|
||||||
required String cardId,
|
for (final review in reviews) {
|
||||||
required int rating,
|
|
||||||
required DateTime reviewedAt,
|
|
||||||
required int durationMs,
|
|
||||||
}) async {
|
|
||||||
rows.add((
|
rows.add((
|
||||||
id: 'review-${(_nextId++).toString().padLeft(4, '0')}',
|
id: 'review-${(_nextId++).toString().padLeft(4, '0')}',
|
||||||
cardId: cardId,
|
cardId: review.cardId,
|
||||||
rating: rating,
|
rating: review.rating,
|
||||||
reviewedAt: reviewedAt,
|
reviewedAt: review.reviewedAt,
|
||||||
durationMs: durationMs,
|
durationMs: review.durationMs,
|
||||||
|
mode: review.mode,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<void> get remoteChanges => const Stream.empty();
|
Stream<void> get remoteChanges => const Stream.empty();
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ abstract class ReviewLog
|
||||||
/// (back to front, with a schedule of its own, as Anki's reverse cards have),
|
/// (back to front, with a schedule of its own, as Anki's reverse cards have),
|
||||||
/// or 'cram' (outside the schedule: counts for streaks and the activity
|
/// or 'cram' (outside the schedule: counts for streaks and the activity
|
||||||
/// heatmap, never moves a schedule). Reviews from before modes are 'review'.
|
/// heatmap, never moves a schedule). Reviews from before modes are 'review'.
|
||||||
|
/// 'reset' is no rating but a fresh start: the card's schedules replay only
|
||||||
|
/// what follows it, its rating means nothing.
|
||||||
String mode;
|
String mode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -311,6 +313,8 @@ class ReviewLogTable extends _is.Table<_is.UuidValue?> {
|
||||||
/// (back to front, with a schedule of its own, as Anki's reverse cards have),
|
/// (back to front, with a schedule of its own, as Anki's reverse cards have),
|
||||||
/// or 'cram' (outside the schedule: counts for streaks and the activity
|
/// or 'cram' (outside the schedule: counts for streaks and the activity
|
||||||
/// heatmap, never moves a schedule). Reviews from before modes are 'review'.
|
/// heatmap, never moves a schedule). Reviews from before modes are 'review'.
|
||||||
|
/// 'reset' is no rating but a fresh start: the card's schedules replay only
|
||||||
|
/// what follows it, its rating means nothing.
|
||||||
late final _is.ColumnString mode;
|
late final _is.ColumnString mode;
|
||||||
|
|
||||||
_i4926i81.CardTable get card {
|
_i4926i81.CardTable get card {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ fields:
|
||||||
### (back to front, with a schedule of its own, as Anki's reverse cards have),
|
### (back to front, with a schedule of its own, as Anki's reverse cards have),
|
||||||
### or 'cram' (outside the schedule: counts for streaks and the activity
|
### or 'cram' (outside the schedule: counts for streaks and the activity
|
||||||
### heatmap, never moves a schedule). Reviews from before modes are 'review'.
|
### heatmap, never moves a schedule). Reviews from before modes are 'review'.
|
||||||
|
### 'reset' is no rating but a fresh start: the card's schedules replay only
|
||||||
|
### what follows it, its rating means nothing.
|
||||||
mode: String, default='review'
|
mode: String, default='review'
|
||||||
indexes:
|
indexes:
|
||||||
# Deleting a space finds its rows through this index (see studyset.spy.yaml).
|
# Deleting a space finds its rows through this index (see studyset.spy.yaml).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue