feat(app): bury a card until tomorrow while studying

"Bury until tomorrow" in a card's menu sets it aside for the rest of the
local day: it leaves the round, and no session or count takes it until
the next day begins; its schedule stays as it was. Undo, offered for a
few seconds, brings it back into the round.

A burial is an entry of its own in the review log (mode "bury"), as a
fresh start is: no synced table changes, older app versions skip it as
a mode they don't know, and it never counts as studying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-07 21:15:51 +02:00
commit bd00fc0d9a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 257 additions and 12 deletions

View file

@ -443,6 +443,8 @@
"cardTileConflict": "Auf zwei Geräten geändert",
"studyClose": "Sitzung schließen",
"studyBuryCard": "Bis morgen zurückstellen",
"studyCardBuried": "Karte bis morgen zurückgestellt",
"studyUndoLastRating": "Letzte Bewertung rückgängig machen",
"studyLockScreen": "Bildschirm sperren",
"studyUnlock": "Entsperren",

View file

@ -490,6 +490,8 @@
"cardTileConflict": "Changed on two devices",
"studyClose": "Close session",
"studyBuryCard": "Bury until tomorrow",
"studyCardBuried": "Card buried until tomorrow",
"studyUndoLastRating": "Undo last rating",
"studyLockScreen": "Lock screen",
"studyUnlock": "Unlock",

View file

@ -2674,6 +2674,18 @@ abstract class AppLocalizations {
/// **'Close session'**
String get studyClose;
/// No description provided for @studyBuryCard.
///
/// In en, this message translates to:
/// **'Bury until tomorrow'**
String get studyBuryCard;
/// No description provided for @studyCardBuried.
///
/// In en, this message translates to:
/// **'Card buried until tomorrow'**
String get studyCardBuried;
/// No description provided for @studyUndoLastRating.
///
/// In en, this message translates to:

View file

@ -1550,6 +1550,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studyClose => 'Sitzung schließen';
@override
String get studyBuryCard => 'Bis morgen zurückstellen';
@override
String get studyCardBuried => 'Karte bis morgen zurückgestellt';
@override
String get studyUndoLastRating => 'Letzte Bewertung rückgängig machen';

View file

@ -1539,6 +1539,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studyClose => 'Close session';
@override
String get studyBuryCard => 'Bury until tomorrow';
@override
String get studyCardBuried => 'Card buried until tomorrow';
@override
String get studyUndoLastRating => 'Undo last rating';

View file

@ -12,7 +12,6 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progre
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
@ -101,7 +100,7 @@ class HomeBloc(
final steps = await _pathProgress.doneTimes();
final ratings = [
for (final review in reviews)
if (review.mode != StudyMode.reset) review.reviewedAt,
if (review.mode.rates) review.reviewedAt,
];
final activity = StudyActivity.of(
ratings: ratings,

View file

@ -62,6 +62,8 @@ class StudySessionBloc(
on<StudySessionEventTagsChanged>(_onTagsChanged);
on<StudySessionEventLockToggled>(_onLockToggled);
on<StudySessionEventUndone>(_onUndone);
on<StudySessionEventBuried>(_onBuried);
on<StudySessionEventUnburied>(_onUnburied);
on<StudySessionEventCardEdited>(_onCardEdited);
on<StudySessionEventCardDeleted>(_onCardDeleted);
on<StudySessionEventBackFirstToggled>((event, emit) async {
@ -94,6 +96,12 @@ class StudySessionBloc(
/// The tags of the session's cards.
var _tagging = Tagging.empty;
/// The cards set aside for the day: they aren't studied.
var _setAside = <String>{};
/// The card set aside last in this round, and when, to undo it.
({Card card, DateTime at, int index})? _lastBuried;
final _ratings = <String, Rating>{};
final _graduated = <String>{};
var _cramIndex = 0;
@ -149,6 +157,7 @@ class StudySessionBloc(
_ratings.clear();
_graduated.clear();
_undoable.clear();
_lastBuried = null;
_cramIndex = 0;
final groups = await _groups();
_cardsById = {
@ -157,11 +166,15 @@ class StudySessionBloc(
};
_tagging = await _tags.read();
final now = clock.now().toUtc();
_setAside = await _reviews.buriedSince({
..._cardsById.keys,
}, startOfLocalDay(now));
if (_options.mode == LearnMode.cram) {
final all = [
for (final group in groups)
for (final card in group)
if (!_crammed.contains(card.id)) card.id,
if (!_crammed.contains(card.id) && !_setAside.contains(card.id))
card.id,
];
_round = _ordered(all).take(_options.cardsPerRound).toList();
if (_round.isEmpty) {
@ -206,6 +219,7 @@ class StudySessionBloc(
schedules: schedules,
now: now,
startOfToday: startOfLocalDay(now),
setAside: _setAside,
);
/// The cards of every studyset studied, each studyset's in order, with
@ -529,6 +543,40 @@ class StudySessionBloc(
}
}
Future<void> _onBuried(
StudySessionEventBuried event,
Emitter<StudySessionState> emit,
) async {
if (state case final StudySessionStateShowing showing) {
final card = showing.card;
final now = clock.now().toUtc();
await _reviews.bury(card.id, now);
_setAside.add(card.id);
_lastBuried = (card: card, at: now, index: _round.indexOf(card.id));
await _leave(emit, card.id);
}
}
Future<void> _onUnburied(
StudySessionEventUnburied event,
Emitter<StudySessionState> emit,
) async {
final buried = _lastBuried;
_lastBuried = null;
if (buried == null) {
return;
}
final (:card, :at, :index) = buried;
await _reviews.unbury(card.id, at);
_setAside.remove(card.id);
// Back where it was in the round under way; the card on screen stays.
if (state case final StudySessionStateShowing showing) {
_cardsById[card.id] = card;
_round.insert(index.clamp(0, _round.length), card.id);
_reshow(emit, showing);
}
}
Future<void> _onUndone(
StudySessionEventUndone event,
Emitter<StudySessionState> emit,
@ -646,6 +694,12 @@ final class const StudySessionEventContinued() extends StudySessionEvent;
/// whether it was right.
final class const StudySessionEventAnswerShown() extends StudySessionEvent;
/// Set the card on screen aside until the next local day, and go on.
final class const StudySessionEventBuried() extends StudySessionEvent;
/// Take back setting the last card aside: it is studied again.
final class const StudySessionEventUnburied() extends StudySessionEvent;
/// Take back the last rating of the round.
final class const StudySessionEventUndone() extends StudySessionEvent;

View file

@ -122,6 +122,40 @@ class ReviewRepository(final ReviewGateway _gateway) {
_changes.add(null);
}
/// Sets [cardId] aside at [at] until the next local day begins.
Future<void> bury(String cardId, DateTime at) async {
await _gateway.insert([
(
id: '',
cardId: cardId,
// The rating column needs a value; a burial's isn't read.
rating: Rating.again.value,
reviewedAt: at.toUtc(),
durationMs: 0,
mode: StudyMode.bury.value,
),
]);
_changes.add(null);
}
/// Takes back the burial of [cardId] at [at].
Future<void> unbury(String cardId, DateTime at) async {
await _gateway.deleteReviews({cardId}, at.toUtc(), StudyMode.bury.value);
_changes.add(null);
}
/// Of [cardIds], those set aside in the local day that began at
/// [startOfToday].
Future<Set<String>> buriedSince(
Set<String> cardIds,
DateTime startOfToday,
) async => {
for (final review in await reviewsOf(cardIds))
if (review.mode == StudyMode.bury &&
!review.reviewedAt.isBefore(startOfToday))
review.cardId,
};
/// Takes back the rating of [cardId] at [reviewedAt], studied as [mode].
Future<void> undo({
required String cardId,

View file

@ -44,4 +44,12 @@ enum StudyMode(
/// Not a rating: the card's progress starts afresh from here, its
/// earlier reviews kept for the history (its rating means nothing).
reset('reset'),
/// Not a rating: the card is set aside until the next local day begins,
/// its schedule untouched (its rating means nothing). Older app versions
/// skip it, as any mode they don't know.
bury('bury');
/// Whether this is a rating of the card, as studying counts it.
bool get rates => this == review || this == reverse || this == cram;
}

View file

@ -48,14 +48,15 @@ final class const StudyProgress({
/// The progress of the cards in [groups] (each studyset's, in tree order, as
/// [studyQueue] takes them, with their [options]) with their [schedules] at
/// [now], in the local day
/// that began at [startOfToday]: what a session started now would study, and
/// how much is mature.
/// that began at [startOfToday]: what a session started now would study
/// (leaving out the cards [setAside] for the day), and how much is mature.
StudyProgress studyProgress({
required List<List<String>> groups,
required Map<String, CardSchedule> schedules,
required DateTime now,
required DateTime startOfToday,
List<StudysetStudyOptions> options = const [],
Set<String> setAside = const {},
}) {
final cards = [for (final group in groups) ...group];
final queue = studyQueue(
@ -64,6 +65,7 @@ StudyProgress studyProgress({
schedules: schedules,
now: now,
startOfToday: startOfToday,
setAside: setAside,
);
return StudyProgress(
cards: cards.length,

View file

@ -19,7 +19,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_opt
/// first, or the new ones spread among the due; with [shuffle], the due
/// cards and the new ones are each shuffled first, keeping that order between
/// them. When nothing else is due, a learning card due within [learnAhead]
/// comes early.
/// comes early. Cards [setAside] for the day (buried) come not at all.
List<String> studyQueue({
required List<List<String>> groups,
required Map<String, CardSchedule> schedules,
@ -30,6 +30,7 @@ List<String> studyQueue({
NewCardOrder order = NewCardOrder.reviewsFirst,
math.Random? shuffle,
Duration learnAhead = const Duration(minutes: 20),
Set<String> setAside = const {},
}) {
DateTime? dueOf(String id) => schedules[id]?.due;
int byDue(String a, String b) => dueOf(a)!.compareTo(dueOf(b)!);
@ -51,7 +52,9 @@ List<String> studyQueue({
: StudysetStudyOptions(newPerDay: newPerDay);
final groupDue = [
for (final id in group)
if (dueOf(id) case final due? when !due.isAfter(now)) id,
if (dueOf(id) case final due?
when !due.isAfter(now) && !setAside.contains(id))
id,
]..sort(byDue);
final learning = groupDue.where((id) => schedules[id]!.isLearning);
due
@ -66,7 +69,7 @@ List<String> studyQueue({
fresh.addAll(
[
for (final id in group)
if (schedules[id]?.isNew ?? true) id,
if ((schedules[id]?.isNew ?? true) && !setAside.contains(id)) id,
].take(left(limits.newPerDay, group.where(startedToday).length)),
);
}
@ -87,7 +90,8 @@ List<String> studyQueue({
for (final id in cards)
if (schedules[id] case final schedule?
when schedule.isLearning &&
!schedule.due!.isAfter(now.add(learnAhead)))
!schedule.due!.isAfter(now.add(learnAhead)) &&
!setAside.contains(id))
id,
]..sort(byDue);
return soon.take(1).toList();

View file

@ -72,6 +72,7 @@ class const StudyCardSurface({
enum _CardAction() {
tags,
edit,
bury,
delete,
}
@ -90,6 +91,9 @@ class const StudyCardHeader({
/// Opens the card to edit.
required final VoidCallback onEdit,
/// Sets the card aside until tomorrow.
required final VoidCallback onBury,
/// Deletes the card, asking first.
required final VoidCallback onDelete,
super.key,
@ -136,11 +140,13 @@ class const StudyCardHeader({
onSelected: (action) => switch (action) {
_CardAction.tags => onTags(),
_CardAction.edit => onEdit(),
_CardAction.bury => onBury(),
_CardAction.delete => onDelete(),
},
itemBuilder: (context) => [
item(_CardAction.tags, LucideIcons.tag, l10n.cardTagsAdd),
item(_CardAction.edit, LucideIcons.pencil, l10n.cardTileEdit),
item(_CardAction.bury, LucideIcons.moon, l10n.studyBuryCard),
item(
_CardAction.delete,
LucideIcons.trash2,

View file

@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_chip.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
@ -255,6 +256,19 @@ class const _Showing(
bloc.add(const StudySessionEventCardEdited());
}
},
onBury: () {
final bloc = context.read<StudySessionBloc>()
..add(const StudySessionEventBuried());
showUndoSnackBar(
context,
message: l10n.studyCardBuried,
onUndo: () {
if (!bloc.isClosed) {
bloc.add(const StudySessionEventUnburied());
}
},
);
},
onDelete: () async {
final bloc = context.read<StudySessionBloc>();
if (await confirmDestructiveAction(

View file

@ -327,15 +327,18 @@ class StudysetListBloc(
for (final id in studied)
[for (final card in await _cards.cardsOf(id)) card.id],
];
final ids = [for (final group in groups) ...group];
progress[studyset.id] =
studyProgress(
groups: groups,
options: [for (final id in studied) optionsOf(id)],
schedules: await _reviews.schedulesOf([
for (final group in groups) ...group,
]),
schedules: await _reviews.schedulesOf(ids),
now: now,
startOfToday: startOfLocalDay(now),
setAside: await _reviews.buriedSince(
{...ids},
startOfLocalDay(now),
),
).withContent(
notes: studied.fold(0, (sum, id) => sum + (notes[id] ?? 0)),
paths: studied.fold(0, (sum, id) => sum + (paths[id] ?? 0)),

View file

@ -195,6 +195,45 @@ void main() {
);
});
test(
'buried, leaves the round for the day, and comes back with Undo',
() async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
bloc.add(const StudySessionEventBuried());
expect((await showing(bloc, '犬')).pills, hasLength(1));
bloc.add(const StudySessionEventUnburied());
await until(
bloc,
(s) => s is StudySessionStateShowing && s.pills.length == 2,
);
bloc.add(const StudySessionEventBuried());
await until(
bloc,
(s) => s is StudySessionStateShowing && s.pills.length == 1,
);
final later = session();
addTearDown(later.close);
// Another session the same day leaves it out.
expect(
(await until(
later,
(s) => s is StudySessionStateShowing,
) as StudySessionStateShowing).pills,
hasLength(1),
);
});
},
);
test('edited elsewhere, shows as it was saved', () async {
final id = await cards.create('japanese', card('猫'));

View file

@ -140,4 +140,36 @@ void main() {
);
});
});
group('Burying', () {
final now = DateTime.utc(2026, 10, 5, 12);
final startOfToday = DateTime.utc(2026, 10, 5);
test('sets a card aside for the day, without touching its schedule, '
'and is undone', () async {
await reviews.record(
cardId: 'neko',
rating: Rating.good,
reviewedAt: now.subtract(const Duration(days: 3)),
duration: Duration.zero,
);
final before = (await reviews.schedulesOf(['neko']))['neko'];
await reviews.bury('neko', now);
expect(await reviews.buriedSince({'neko', 'inu'}, startOfToday), {
'neko',
});
expect((await reviews.schedulesOf(['neko']))['neko'], before);
await reviews.unbury('neko', now);
expect(await reviews.buriedSince({'neko'}, startOfToday), isEmpty);
});
test('holds until the next day begins', () async {
await reviews.bury('neko', now.subtract(const Duration(days: 1)));
expect(await reviews.buriedSince({'neko'}, startOfToday), isEmpty);
});
});
}

View file

@ -37,6 +37,28 @@ void main() {
expect(queue, ['early', 'late', 'n1', 'n2']);
});
test('leaves out cards set aside for the day, due or new', () {
final queue = studyQueue(
groups: [
['n1', 'due', 'n2', 'learning'],
],
schedules: {
'n1': fresh,
'due': dueAt(now.subtract(const Duration(hours: 1))),
'n2': fresh,
'learning': dueAt(
now.add(const Duration(minutes: 5)),
learning: true,
),
},
now: now,
startOfToday: startOfToday,
setAside: {'due', 'n1', 'learning'},
);
expect(queue, ['n2']);
});
test('leaves out cards due later', () {
final queue = studyQueue(
groups: [