perf: keep a card's burial on its row rather than in the review log

Finding today's burials in the review log meant reading every review the
cards ever had, once more in the library's progress, the card list and
each round. card.buriedAt holds it instead, read with the cards those
screens read anyway. The sync engine merges a row's fields one by one, so
a burial and a move of the same card on two devices both stand (a sync
test checks it). The review log's bury mode is gone.

The migration adds one nullable column, on the server and on devices:
ALTER TABLE "card" ADD COLUMN "buriedAt" (timestamp without time zone on
the server, INTEGER in SQLite); existing rows read as not buried. Apps
built before it stop syncing until they are updated.

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-08 10:21:18 +02:00
commit beb3a32c30
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
35 changed files with 21507 additions and 161 deletions

View file

@ -138,7 +138,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: `StudysetDeletions`, opened with the person's data, keeps every studyset deletion that can still be undone; lists leave out what it reports as pending, and once the time to undo is over it deletes the whole subtree, what is inside first, each studyset's cards blanked before it. Widgets delete and undo through it directly (`deleteStudysetWithUndo`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. It closes first with the person's data and drops what still waits. Tags differ, being values (a tag is its name, ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`) and a burial (`bury`: the card set aside until the next local day, out of every queue and count; Unbury deletes it), neither ever counting as studying; older app versions skip a mode they don't know. A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), in the order the studied studyset's options ask for (reviews first, mixed, new first). The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), in the order the studied studyset's options ask for (reviews first, mixed, new first). The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.

View file

@ -0,0 +1,33 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "card",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "buriedAt",
"fieldName": "buriedAt",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -11,6 +11,7 @@ part '20261001064926911-tree-tags-assets/migration.dart';
part '20261002191122122-notes/migration.dart';
part '20261002211346701-learning-paths/migration.dart';
part '20261003110855698-pdf-sources/migration.dart';
part '20261007205440868-card-buried-at/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -24,6 +25,7 @@ class MigrationRegistry {
_Migration20261002191122122_notes(),
_Migration20261002211346701_learning_paths(),
_Migration20261003110855698_pdf_sources(),
_Migration20261007205440868_card_buried_at(),
];
/// List of all client-side database migration versions.

View file

@ -27,6 +27,7 @@ abstract class Card
required this.studysetId,
this.studyset,
required this.position,
this.buriedAt,
});
factory Card({
@ -35,6 +36,7 @@ abstract class Card
required _isc.UuidValue studysetId,
_ie5k1jc2.Studyset? studyset,
required String position,
DateTime? buriedAt,
}) = _CardImpl;
factory Card.fromJson(Map<String, dynamic> jsonSerialization) {
@ -52,6 +54,9 @@ abstract class Card
jsonSerialization['studyset'],
),
position: jsonSerialization['position'] as String,
buriedAt: jsonSerialization['buriedAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['buriedAt']),
);
}
@ -75,6 +80,13 @@ abstract class Card
/// (decided 2026-09-30).
String position;
/// When the card was buried (set aside), in UTC; null while it isn't. It
/// isn't studied until the next local day begins on the device looking, so
/// it comes back at midnight wherever that is. A column of its own, not a
/// revision: it says where the card stands, not what it holds, and the
/// sync engine merges it apart from `position` (decided 2026-10-07).
DateTime? buriedAt;
@override
_isd.Table<_isc.UuidValue?> get table => t;
@ -87,6 +99,7 @@ abstract class Card
_isc.UuidValue? studysetId,
_ie5k1jc2.Studyset? studyset,
String? position,
DateTime? buriedAt,
});
@override
Map<String, dynamic> toJson() {
@ -97,6 +110,7 @@ abstract class Card
'studysetId': studysetId.toJson(),
if (studyset != null) 'studyset': studyset?.toJson(),
'position': position,
if (buriedAt != null) 'buriedAt': buriedAt?.toJson(),
};
}
@ -109,6 +123,7 @@ abstract class Card
'studysetId': studysetId.toJson(),
if (studyset != null) 'studyset': studyset?.toJsonForProtocol(),
'position': position,
if (buriedAt != null) 'buriedAt': buriedAt?.toJson(),
};
}
@ -149,12 +164,14 @@ class _CardImpl extends Card {
required _isc.UuidValue studysetId,
_ie5k1jc2.Studyset? studyset,
required String position,
DateTime? buriedAt,
}) : super._(
id: id,
spaceId: spaceId,
studysetId: studysetId,
studyset: studyset,
position: position,
buriedAt: buriedAt,
);
/// Returns a shallow copy of this [Card]
@ -167,6 +184,7 @@ class _CardImpl extends Card {
_isc.UuidValue? studysetId,
Object? studyset = _Undefined,
String? position,
Object? buriedAt = _Undefined,
}) {
return Card(
id: id is _isc.UuidValue? ? id : this.id,
@ -176,6 +194,7 @@ class _CardImpl extends Card {
? studyset
: this.studyset?.copyWith(),
position: position ?? this.position,
buriedAt: buriedAt is DateTime? ? buriedAt : this.buriedAt,
);
}
}
@ -199,6 +218,12 @@ class CardUpdateTable extends _isd.UpdateTable<CardTable> {
table.position,
value,
);
_isd.ColumnValue<DateTime, DateTime> buriedAt(DateTime? value) =>
_isd.ColumnValue(
table.buriedAt,
value,
);
}
class CardTable extends _isd.Table<_isc.UuidValue?> {
@ -216,6 +241,10 @@ class CardTable extends _isd.Table<_isc.UuidValue?> {
'position',
this,
);
buriedAt = _isd.ColumnDateTime(
'buriedAt',
this,
);
}
late final CardUpdateTable updateTable;
@ -233,6 +262,13 @@ class CardTable extends _isd.Table<_isc.UuidValue?> {
/// (decided 2026-09-30).
late final _isd.ColumnString position;
/// When the card was buried (set aside), in UTC; null while it isn't. It
/// isn't studied until the next local day begins on the device looking, so
/// it comes back at midnight wherever that is. A column of its own, not a
/// revision: it says where the card stands, not what it holds, and the
/// sync engine merges it apart from `position` (decided 2026-10-07).
late final _isd.ColumnDateTime buriedAt;
_ie5k1jc2.StudysetTable get studyset {
if (_studyset != null) return _studyset!;
_studyset = _isd.createRelationTable(
@ -252,6 +288,7 @@ class CardTable extends _isd.Table<_isc.UuidValue?> {
spaceId,
studysetId,
position,
buriedAt,
];
@override

View file

@ -217,6 +217,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
isNullable: false,
dartType: 'String',
),
_isd.ColumnDefinition(
name: 'buriedAt',
columnType: _isd.ColumnType.timestampWithoutTimeZone,
isNullable: true,
dartType: 'DateTime?',
),
],
foreignKeys: [
_isd.ForeignKeyDefinition(

View file

@ -5,7 +5,6 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart'
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
@ -17,7 +16,6 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
class CardListBloc(
final CardRepository _cards,
final TagRepository _tags,
final ReviewRepository _reviews,
final CardDeletions _deletions,
final String _studysetId,
) extends Bloc<CardListEvent, CardListState> {
@ -33,18 +31,14 @@ class CardListBloc(
);
on<CardListEventBuryToggled>((event, emit) async {
final now = clock.now();
if (_buried.contains(event.card.id)) {
await _reviews.unburyToday(event.card.id, startOfLocalDay(now));
// As the list holds the card now: the event's may be from before.
final card =
_cardsNow?.where((each) => each.id == event.card.id).firstOrNull ??
event.card;
if (card.isBuried(startOfLocalDay(now))) {
await _cards.unbury(event.card.id);
} else {
await _reviews.bury(event.card.id, now);
}
});
on<CardListEventBuriedRead>((event, emit) async {
_buried = await _reviews.buriedSince({
for (final card in _cardsNow ?? const <Card>[]) card.id,
}, startOfLocalDay(clock.now()));
if (_loaded() case final loaded?) {
emit(loaded);
await _cards.bury(event.card.id, now);
}
});
on<CardListEventTagFilterChanged>((event, emit) {
@ -57,7 +51,6 @@ class CardListBloc(
List<Card>? _cardsNow;
var _tagging = Tagging.empty;
var _buried = <String>{};
/// The cards whose deletion can still be undone: they don't show.
var _deleted = <String>{};
@ -72,15 +65,10 @@ class CardListBloc(
_cards.watch(_studysetId),
onData: (cards) {
_cardsNow = cards;
add(const CardListEventBuriedRead());
return _loaded()!;
},
onError: (_, _) => const CardListStateFailed(),
),
emit.onEach(
_reviews.changes,
onData: (_) => add(const CardListEventBuriedRead()),
),
emit.onEach(
_deletions.watchPending(),
onData: (pending) {
@ -108,18 +96,25 @@ class CardListBloc(
) => _cards.move(_studysetId, event.card, toIndex: event.toIndex);
/// The list as it stands, or null while the cards are being read.
CardListStateLoaded? _loaded() => switch (_cardsNow) {
final cards? => CardListStateLoaded(
CardListStateLoaded? _loaded() {
final cards = _cardsNow;
if (cards == null) {
return null;
}
final startOfToday = startOfLocalDay(clock.now());
return CardListStateLoaded(
[
for (final card in cards)
if (!_deleted.contains(card.id)) card,
],
tagging: _tagging,
tagFilter: _tagFilter,
buried: _buried,
),
null => null,
};
buried: {
for (final card in cards)
if (card.isBuried(startOfToday)) card.id,
},
);
}
}
/// What happens to the list of cards.
@ -169,9 +164,6 @@ final class const CardListEventBuryToggled(final Card card)
List<Object?> get props => [card];
}
/// Read which cards are buried, after a change; the bloc sends it itself.
final class const CardListEventBuriedRead() extends CardListEvent;
/// A studyset's cards, as the list shows them.
sealed class const CardListState() with Equatable {
@override

View file

@ -1,7 +1,7 @@
import 'dart:typed_data';
/// A card's row as the device's database stores it.
typedef CardRow = ({String id, String position});
typedef CardRow = ({String id, String position, DateTime? buriedAt});
/// A card wherever it is: its id, its studyset, and when it was made (from
/// its UUID v7 id; null for an id that doesn't say).
@ -55,6 +55,10 @@ abstract interface class CardGateway() {
/// Moves the card [cardId] to [position]; the only column that changes.
Future<void> updatePosition(String cardId, String position);
/// Buries the card [cardId] at [buriedAt], or unburies it with null; the
/// only column that changes.
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt);
/// Blanks the payload of every revision of the card [cardId].
Future<void> blankRevisions(String cardId);

View file

@ -88,6 +88,7 @@ class CardRepository(final CardGateway _gateway) {
Card(
id: row.id,
position: row.position,
buriedAt: row.buriedAt,
content: merged.content,
heads: merged.heads,
conflicts: merged.conflicts,
@ -243,6 +244,19 @@ class CardRepository(final CardGateway _gateway) {
_changes.add(null);
}
/// Buries the card [cardId] at [at]: it isn't studied until the next local
/// day begins ([Card.isBuried]).
Future<void> bury(String cardId, DateTime at) async {
await _gateway.updateBuriedAt(cardId, at.toUtc());
_changes.add(null);
}
/// Takes the burial of the card [cardId] back.
Future<void> unbury(String cardId) async {
await _gateway.updateBuriedAt(cardId, null);
_changes.add(null);
}
/// Deletes [card]. Its revisions are blanked first: the sync engine keeps
/// deleted rows until the whole space is deleted (ADR 0002, rule 2).
Future<void> delete(Card card) => deleteById(card.id);

View file

@ -22,7 +22,11 @@ class SyncCardGateway(
where: (t) =>
t.studysetId.equals(protocol.UuidValue.withValidation(studysetId)),
))
(id: card.id!.uuid, position: card.position),
(
id: card.id!.uuid,
position: card.position,
buriedAt: card.buriedAt?.toUtc(),
),
];
@override
@ -156,6 +160,24 @@ class SyncCardGateway(
_sync.requestSync();
}
@override
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt) async {
final card = await protocol.Card.db.findById(
_db,
protocol.UuidValue.withValidation(cardId),
);
if (card == null) {
return;
}
// Only the named column changes (ADR 0001, rule 1).
await protocol.Card.db.updateRow(
_db,
card.copyWith(buriedAt: buriedAt?.toUtc()),
columns: (t) => [t.buriedAt],
);
_sync.requestSync();
}
@override
Future<void> blankRevisions(String cardId) async {
final revisions = await protocol.CardRevision.db.find(

View file

@ -16,6 +16,9 @@ final class const Card({
/// The revisions the next save replaces: all current heads.
required final Set<String> heads,
/// When the card was buried, in UTC; null while it isn't ([isBuried]).
final DateTime? buriedAt,
/// The fields changed differently on different devices, with every
/// version, oldest first.
final Map<CardField, List<Object?>> conflicts = const {},
@ -24,6 +27,20 @@ final class const Card({
/// choose.
bool get hasConflict => conflicts.isNotEmpty;
/// Whether the card is set aside for the local day that began at
/// [startOfToday]: buried since then.
bool isBuried(DateTime startOfToday) => switch (buriedAt) {
final at? => !at.isBefore(startOfToday),
null => false,
};
@override
List<Object?> get props => [id, position, content, heads, conflicts];
List<Object?> get props => [
id,
position,
content,
heads,
buriedAt,
conflicts,
];
}

View file

@ -13,7 +13,6 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_info.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
@ -32,9 +31,6 @@ class const CardList({
/// The person's tags.
required final TagRepository tags,
/// The person's reviews, which say what is buried.
required final ReviewRepository reviews,
/// The studyset whose cards these are.
required final String studysetId,
super.key,
@ -44,7 +40,6 @@ class const CardList({
create: (context) => CardListBloc(
cards,
tags,
reviews,
PersonScope.of(context).cardDeletions,
studysetId,
)..add(const CardListEventStarted()),

View file

@ -204,9 +204,7 @@ class StudySessionBloc(
_tagging = await _tags.read();
final now = clock.now().toUtc();
_pools = QuizPools.of([for (final card in _cardsById.values) card.content]);
_setAside = await _reviews.buriedSince({
..._cardsById.keys,
}, startOfLocalDay(now));
_setAside = _buriedOf(_cardsById.values, now);
if (_options.mode == LearnMode.cram) {
_clearingBacklog = false;
final all = [
@ -290,24 +288,38 @@ class StudySessionBloc(
for (final id in studied)
[
for (final card in await _cards.cardsOf(id))
if (!_deletions.pending.contains(card.id)) card.id,
if (!_deletions.pending.contains(card.id)) card,
],
];
final ids = [for (final group in groups) ...group];
final ids = [
for (final group in groups)
for (final card in group) card.id,
];
final schedules = await _reviews.schedulesOf(ids);
return [
for (final id in studyQueue(
groups: groups,
groups: [
for (final group in groups) [for (final card in group) card.id],
],
options: _groupOptions,
schedules: schedules,
now: now,
startOfToday: startOfLocalDay(now),
setAside: await _reviews.buriedSince({...ids}, startOfLocalDay(now)),
setAside: _buriedOf([for (final group in groups) ...group], now),
))
if (!(schedules[id]?.isNew ?? true)) id,
];
}
/// Of [cards], those buried for the local day [now] is in.
static Set<String> _buriedOf(Iterable<Card> cards, DateTime now) {
final startOfToday = startOfLocalDay(now);
return {
for (final card in cards)
if (card.isBuried(startOfToday)) card.id,
};
}
/// The study queue of the round's studysets with their [schedules] at
/// [now].
List<String> _queue(Map<String, CardSchedule> schedules, DateTime now) =>
@ -677,9 +689,9 @@ class StudySessionBloc(
final card = showing.card;
final before = _standOf(card);
final now = clock.now().toUtc();
await _reviews.bury(card.id, now);
await _cards.bury(card.id, now);
_setAside.add(card.id);
_steps.add(_Buried(before, at: now));
_steps.add(_Buried(before));
await _leave(emit, card.id);
}
}
@ -743,8 +755,8 @@ class StudySessionBloc(
if (step.before.session == _sessionNumber && _given.isNotEmpty) {
_given.removeLast();
}
case _Buried(:final at):
await _reviews.unbury(id, at);
case _Buried():
await _cards.unbury(id);
_setAside.remove(id);
case _Deleted():
_deletions.undo(id);
@ -1260,9 +1272,8 @@ final class const _Rated(
required final StudyMode mode,
}) extends _Step;
/// The card was set aside [at].
final class const _Buried(super.before, {required final DateTime at})
extends _Step;
/// The card was buried: set aside for the day.
final class const _Buried(super.before) extends _Step;
/// The card was deleted; the deletion is held until the session ends.
final class const _Deleted(super.before) extends _Step;

View file

@ -122,56 +122,6 @@ 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);
}
/// Takes back every burial of [cardId] in the local day that began at
/// [startOfToday].
Future<void> unburyToday(String cardId, DateTime startOfToday) async {
for (final review in await reviewsOf({cardId})) {
if (review.mode == StudyMode.bury &&
!review.reviewedAt.isBefore(startOfToday)) {
await _gateway.deleteReviews(
{cardId},
review.reviewedAt,
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

@ -43,12 +43,7 @@ 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');
reset('reset');
/// Whether this is a rating of the card, as studying counts it.
bool get rates => this == review || this == reverse || this == cram;

View file

@ -323,10 +323,17 @@ class StudysetListBloc(
final studied = studyset.isArchived
? subtreeOf(studyset.id, _studysetsRead)
: studiedIn(studyset.id, _studysetsRead);
final groups = [
for (final id in studied)
[for (final card in await _cards.cardsOf(id)) card.id],
];
final startOfToday = startOfLocalDay(now);
final groups = <List<String>>[];
final buried = <String>{};
for (final id in studied) {
final cards = await _cards.cardsOf(id);
groups.add([for (final card in cards) card.id]);
buried.addAll([
for (final card in cards)
if (card.isBuried(startOfToday)) card.id,
]);
}
final ids = [for (final group in groups) ...group];
progress[studyset.id] =
studyProgress(
@ -334,11 +341,8 @@ class StudysetListBloc(
options: [for (final id in studied) optionsOf(id)],
schedules: await _reviews.schedulesOf(ids),
now: now,
startOfToday: startOfLocalDay(now),
setAside: await _reviews.buriedSince(
{...ids},
startOfLocalDay(now),
),
startOfToday: startOfToday,
setAside: buried,
).withContent(
notes: studied.fold(0, (sum, id) => sum + (notes[id] ?? 0)),
paths: studied.fold(0, (sum, id) => sum + (paths[id] ?? 0)),

View file

@ -495,7 +495,6 @@ class _ContentState()
CardList(
cards: PersonScope.of(context).cards,
tags: PersonScope.of(context).tags,
reviews: PersonScope.of(context).reviews,
studysetId: widget.studysetId,
),
],

View file

@ -4,11 +4,9 @@ import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart'
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
RichContent text(String text) => RichContent({
@ -22,19 +20,16 @@ void main() {
late FakeCardGateway gateway;
late CardRepository cards;
late TagRepository tags;
late ReviewRepository reviews;
late CardDeletions deletions;
setUp(() {
gateway = FakeCardGateway();
cards = CardRepository(gateway);
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
reviews = ReviewRepository(FakeReviewGateway());
deletions = CardDeletions(cards, undoWindow: Duration.zero);
});
tearDown(() async {
await deletions.dispose();
await reviews.dispose();
await tags.dispose();
await cards.dispose();
});
@ -44,7 +39,7 @@ void main() {
'a card reported or buried shows so, and either is taken back',
() async {
final id = await cards.create('japanese', card('猫', 'cat'));
final bloc = CardListBloc(cards, tags, reviews, deletions, 'japanese')
final bloc = CardListBloc(cards, tags, deletions, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
Future<CardListStateLoaded> next(
@ -74,7 +69,7 @@ void main() {
test('lists the cards and follows every change', () async {
await cards.create('japanese', card('猫', 'cat'));
final bloc = CardListBloc(cards, tags, reviews, deletions, 'japanese')
final bloc = CardListBloc(cards, tags, deletions, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
@ -104,7 +99,7 @@ void main() {
test('moves a card', () async {
await cards.create('japanese', card('a', ''));
await cards.create('japanese', card('b', ''));
final bloc = CardListBloc(cards, tags, reviews, deletions, 'japanese')
final bloc = CardListBloc(cards, tags, deletions, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
final loaded = await bloc.stream.firstWhere(
@ -141,7 +136,7 @@ void main() {
await tags.setTagsOf(ids[0], {noun});
await tags.setTagsOf(ids[1], {noun});
await tags.setTagsOf(ids[2], {verb});
bloc = CardListBloc(cards, tags, reviews, deletions, 'japanese')
bloc = CardListBloc(cards, tags, deletions, 'japanese')
..add(const CardListEventStarted());
});
tearDown(() => bloc.close());

Some files were not shown because too many files have changed in this diff Show more