feat: studysets record when they last changed; the library lists them so
Decided 2026-10-01: after the starred ones, the library lists the most recently changed studysets first. That needs a change date, so studyset gains updatedAt (ADR 0004): when the studyset itself last changed, its content, place, star or archive date, written by the device making the change in the same update as the change; the last writer wins. Studysets from before have none and come last, the newest of them first. The branch's migration is still unreleased (not merged, not deployed), so it is recreated rather than followed by a second one: 20261001064926911-tree-tags-assets replaces 20260930213716558 and differs only by one line on the server, ALTER TABLE "studyset" ADD COLUMN "updatedAt" timestamp without time zone (nullable, so existing rows keep null and no data changes), the same column in the table devices rebuild, and its version. The model's comment on the payload no longer lists schema 1's fields alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8e0f5cf538
commit
85c028fe3c
8 changed files with 349 additions and 74 deletions
|
|
@ -97,8 +97,8 @@ final class const StudysetListStateFailed() extends StudysetListState {
|
|||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The top-level studysets, starred ones first, each group oldest first,
|
||||
/// with their progress.
|
||||
/// The top-level studysets, starred ones first, each group most recently
|
||||
/// changed first, with their progress.
|
||||
final class const StudysetListStateLoaded(
|
||||
/// The studysets at the top level.
|
||||
final List<Studyset> studysets, {
|
||||
|
|
@ -215,12 +215,18 @@ class StudysetListBloc(
|
|||
}
|
||||
}
|
||||
|
||||
/// The top-level studysets, starred ones first; both groups keep their order
|
||||
/// (oldest first).
|
||||
List<Studyset> _inLibraryOrder(List<Studyset> all) {
|
||||
final top = topLevel(all);
|
||||
return [
|
||||
...top.where((studyset) => studyset.isStarred),
|
||||
...top.where((studyset) => !studyset.isStarred),
|
||||
];
|
||||
}
|
||||
/// The top-level studysets, starred ones first, each group most recently
|
||||
/// changed first (decided 2026-10-01). Studysets from before changes were
|
||||
/// recorded come last, the newest of them first: ids are time-ordered.
|
||||
List<Studyset> _inLibraryOrder(List<Studyset> all) =>
|
||||
[...topLevel(all)]..sort((a, b) {
|
||||
if (a.isStarred != b.isStarred) {
|
||||
return a.isStarred ? -1 : 1;
|
||||
}
|
||||
return switch ((a.updatedAt, b.updatedAt)) {
|
||||
(final a?, final b?) when a != b => b.compareTo(a),
|
||||
(_?, null) => -1,
|
||||
(null, _?) => 1,
|
||||
_ => b.id.compareTo(a.id),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,29 +9,39 @@ typedef StudysetRow = ({
|
|||
String position,
|
||||
DateTime? archivedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
});
|
||||
|
||||
/// The boundary to the studysets in this person's local, synced database.
|
||||
/// Tests replace it with a fake.
|
||||
///
|
||||
/// Every update writes only its own column, so a change of another column on
|
||||
/// another device survives the merge (ADR 0001, rule 1).
|
||||
/// Every update writes only its own column and `updatedAt`, which says when
|
||||
/// the change was made, so a change of another column on another device
|
||||
/// survives the merge (ADR 0001, rule 1).
|
||||
abstract interface class StudysetGateway() {
|
||||
/// All studysets on this device, oldest first.
|
||||
Future<List<StudysetRow>> all();
|
||||
|
||||
/// Stores a new studyset at the top level and returns its id, a UUID v7
|
||||
/// made on the device.
|
||||
Future<String> insert(ByteData payload);
|
||||
/// Stores a new studyset, made at [updatedAt], at the top level and returns
|
||||
/// its id, a UUID v7 made on the device.
|
||||
Future<String> insert(ByteData payload, DateTime updatedAt);
|
||||
|
||||
/// Replaces a studyset's payload.
|
||||
Future<void> updatePayload(String id, ByteData payload);
|
||||
/// Replaces a studyset's payload, as of [updatedAt].
|
||||
Future<void> updatePayload(String id, ByteData payload, DateTime updatedAt);
|
||||
|
||||
/// Sets or clears when a studyset was starred.
|
||||
Future<void> updateStarredAt(String id, DateTime? starredAt);
|
||||
/// Sets or clears when a studyset was starred, as of [updatedAt].
|
||||
Future<void> updateStarredAt(
|
||||
String id,
|
||||
DateTime? starredAt,
|
||||
DateTime updatedAt,
|
||||
);
|
||||
|
||||
/// Sets or clears when a studyset was archived.
|
||||
Future<void> updateArchivedAt(String id, DateTime? archivedAt);
|
||||
/// Sets or clears when a studyset was archived, as of [updatedAt].
|
||||
Future<void> updateArchivedAt(
|
||||
String id,
|
||||
DateTime? archivedAt,
|
||||
DateTime updatedAt,
|
||||
);
|
||||
|
||||
/// Deletes a studyset's row.
|
||||
Future<void> delete(String id);
|
||||
|
|
|
|||
|
|
@ -40,45 +40,47 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
|
||||
/// Creates a studyset with [content] at the top level; returns its id.
|
||||
Future<String> create(StudysetContent content) async {
|
||||
final id = await _gateway.insert(encodeStudysetPayload(content));
|
||||
final id = await _gateway.insert(encodeStudysetPayload(content), _now());
|
||||
await _refresh();
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Stores the studyset [id]'s new [content].
|
||||
Future<void> updateContent(String id, StudysetContent content) async {
|
||||
await _gateway.updatePayload(id, encodeStudysetPayload(content));
|
||||
await _gateway.updatePayload(id, encodeStudysetPayload(content), _now());
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
/// Stars the studyset [id], keeping it at the top of the library.
|
||||
Future<void> star(String id) async {
|
||||
await _gateway.updateStarredAt(id, clock.now().toUtc());
|
||||
final now = _now();
|
||||
await _gateway.updateStarredAt(id, now, now);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
/// Takes the studyset [id]'s star away.
|
||||
Future<void> unstar(String id) async {
|
||||
await _gateway.updateStarredAt(id, null);
|
||||
await _gateway.updateStarredAt(id, null, _now());
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
/// Archives the studyset [id], and so everything inside it.
|
||||
Future<void> archive(String id) async {
|
||||
await _gateway.updateArchivedAt(id, clock.now().toUtc());
|
||||
final now = _now();
|
||||
await _gateway.updateArchivedAt(id, now, now);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
/// Takes the studyset [id] out of the archive.
|
||||
Future<void> unarchive(String id) async {
|
||||
await _gateway.updateArchivedAt(id, null);
|
||||
await _gateway.updateArchivedAt(id, null, _now());
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
/// Deletes a studyset. Its content is blanked first: the sync engine keeps
|
||||
/// deleted rows until the whole space is deleted (ADR 0002, rule 2).
|
||||
Future<void> delete(String id) async {
|
||||
await _gateway.updatePayload(id, redactedPayload);
|
||||
await _gateway.updatePayload(id, redactedPayload, _now());
|
||||
await _gateway.delete(id);
|
||||
await _refresh();
|
||||
}
|
||||
|
|
@ -120,10 +122,14 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
position: row.position,
|
||||
archivedAt: row.archivedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: row.updatedAt,
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The time a change happens, in UTC, as it is stored.
|
||||
DateTime _now() => clock.now().toUtc();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,50 +26,63 @@ class SyncStudysetGateway(
|
|||
position: studyset.position,
|
||||
archivedAt: studyset.archivedAt,
|
||||
starredAt: studyset.starredAt,
|
||||
updatedAt: studyset.updatedAt,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> insert(ByteData payload) async {
|
||||
Future<String> insert(ByteData payload, DateTime updatedAt) async {
|
||||
final studyset = await protocol.Studyset.db.insertRow(
|
||||
_db,
|
||||
protocol.Studyset(payload: payload),
|
||||
protocol.Studyset(payload: payload, updatedAt: updatedAt),
|
||||
);
|
||||
_sync.requestSync();
|
||||
return studyset.id!.uuid;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePayload(String id, ByteData payload) async {
|
||||
Future<void> updatePayload(
|
||||
String id,
|
||||
ByteData payload,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (await _find(id) case final studyset?) {
|
||||
await protocol.Studyset.db.updateRow(
|
||||
_db,
|
||||
studyset.copyWith(payload: payload),
|
||||
columns: (t) => [t.payload],
|
||||
studyset.copyWith(payload: payload, updatedAt: updatedAt),
|
||||
columns: (t) => [t.payload, t.updatedAt],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStarredAt(String id, DateTime? starredAt) async {
|
||||
Future<void> updateStarredAt(
|
||||
String id,
|
||||
DateTime? starredAt,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (await _find(id) case final studyset?) {
|
||||
await protocol.Studyset.db.updateRow(
|
||||
_db,
|
||||
studyset.copyWith(starredAt: starredAt),
|
||||
columns: (t) => [t.starredAt],
|
||||
studyset.copyWith(starredAt: starredAt, updatedAt: updatedAt),
|
||||
columns: (t) => [t.starredAt, t.updatedAt],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateArchivedAt(String id, DateTime? archivedAt) async {
|
||||
Future<void> updateArchivedAt(
|
||||
String id,
|
||||
DateTime? archivedAt,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (await _find(id) case final studyset?) {
|
||||
await protocol.Studyset.db.updateRow(
|
||||
_db,
|
||||
studyset.copyWith(archivedAt: archivedAt),
|
||||
columns: (t) => [t.archivedAt],
|
||||
studyset.copyWith(archivedAt: archivedAt, updatedAt: updatedAt),
|
||||
columns: (t) => [t.archivedAt, t.updatedAt],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ final class const Studyset({
|
|||
|
||||
/// When the person starred it, in UTC; null when it isn't starred.
|
||||
final DateTime? starredAt,
|
||||
|
||||
/// When it last changed itself (its content, place, star or archive), in
|
||||
/// UTC; null for a studyset from before that was recorded.
|
||||
final DateTime? updatedAt,
|
||||
}) with Equatable {
|
||||
/// Whether it was archived itself; a studyset inside an archived one rests
|
||||
/// in the archive too.
|
||||
|
|
@ -36,6 +40,7 @@ final class const Studyset({
|
|||
position,
|
||||
archivedAt,
|
||||
starredAt,
|
||||
updatedAt,
|
||||
];
|
||||
|
||||
/// This studyset with [content] instead of its own.
|
||||
|
|
@ -46,5 +51,6 @@ final class const Studyset({
|
|||
position: position,
|
||||
archivedAt: archivedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ void main() {
|
|||
await cards.create(japanese, newCard());
|
||||
|
||||
bloc.add(const StudysetListEventStarted());
|
||||
final state = await listing(['Japanese', 'German']);
|
||||
// German changed last: it was created after Japanese.
|
||||
final state = await listing(['German', 'Japanese']);
|
||||
|
||||
expect(state.progress, {
|
||||
japanese: const StudyProgress(cards: 1, due: 1, reviewsDue: 0, mature: 0),
|
||||
|
|
@ -81,6 +82,38 @@ void main() {
|
|||
await listing(['German', 'Japanese']);
|
||||
});
|
||||
|
||||
test(
|
||||
'lists the most recently changed first, after the starred ones',
|
||||
() async {
|
||||
DateTime at(int hour) => DateTime.utc(2026, 10, 1, hour);
|
||||
gateway
|
||||
..receiveRemoteChange(
|
||||
'old',
|
||||
encodeStudysetPayload(StudysetContent.titled('Old')),
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'earlier',
|
||||
encodeStudysetPayload(StudysetContent.titled('Earlier')),
|
||||
updatedAt: at(8),
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'starred',
|
||||
encodeStudysetPayload(StudysetContent.titled('Starred')),
|
||||
starredAt: at(7),
|
||||
updatedAt: at(7),
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'later',
|
||||
encodeStudysetPayload(StudysetContent.titled('Later')),
|
||||
updatedAt: at(9),
|
||||
);
|
||||
|
||||
bloc.add(const StudysetListEventStarted());
|
||||
|
||||
await listing(['Starred', 'Later', 'Earlier', 'Old']);
|
||||
},
|
||||
);
|
||||
|
||||
test('lists only the top level', () async {
|
||||
await studysets.create(StudysetContent.titled('Japanese'));
|
||||
gateway.receiveRemoteChange(
|
||||
|
|
|
|||
|
|
@ -60,14 +60,19 @@ void main() {
|
|||
colorName: 'cobalt',
|
||||
);
|
||||
|
||||
final id = await repository.create(content);
|
||||
final createdAt = DateTime.utc(2026, 10, 1, 8);
|
||||
final id = await withClock(
|
||||
Clock.fixed(createdAt),
|
||||
() => repository.create(content),
|
||||
);
|
||||
|
||||
expect(await repository.watchAll().first, [
|
||||
Studyset(id: id, content: content),
|
||||
Studyset(id: id, content: content, updatedAt: createdAt),
|
||||
]);
|
||||
});
|
||||
|
||||
test('stores changed content', () async {
|
||||
final changedAt = DateTime.utc(2026, 10, 1, 9);
|
||||
final id = await repository.create(titled('Japanese'));
|
||||
final changed = StudysetContent(
|
||||
title: '日本語',
|
||||
|
|
@ -78,10 +83,13 @@ void main() {
|
|||
licenseYear: 2026,
|
||||
);
|
||||
|
||||
await repository.updateContent(id, changed);
|
||||
await withClock(
|
||||
Clock.fixed(changedAt),
|
||||
() => repository.updateContent(id, changed),
|
||||
);
|
||||
|
||||
expect(await repository.watchAll().first, [
|
||||
Studyset(id: id, content: changed),
|
||||
Studyset(id: id, content: changed, updatedAt: changedAt),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -110,6 +118,30 @@ void main() {
|
|||
expect(gateway.writes, hasLength(1), reason: 'the payload only once');
|
||||
});
|
||||
|
||||
test('every change stamps when it happened', () async {
|
||||
DateTime at(int hour) => DateTime.utc(2026, 10, 1, hour);
|
||||
Future<DateTime?> updatedAt() async =>
|
||||
(await repository.all()).single.updatedAt;
|
||||
|
||||
final id = await withClock(
|
||||
Clock.fixed(at(8)),
|
||||
() => repository.create(titled('Japanese')),
|
||||
);
|
||||
expect(await updatedAt(), at(8));
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(at(9)),
|
||||
() => repository.updateContent(id, titled('日本語')),
|
||||
);
|
||||
expect(await updatedAt(), at(9));
|
||||
|
||||
await withClock(Clock.fixed(at(10)), () => repository.star(id));
|
||||
expect(await updatedAt(), at(10));
|
||||
|
||||
await withClock(Clock.fixed(at(11)), () => repository.archive(id));
|
||||
expect(await updatedAt(), at(11));
|
||||
});
|
||||
|
||||
test('blanks the content before deleting', () async {
|
||||
final id = await repository.create(titled('Secret'));
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class FakeStudysetGateway() implements StudysetGateway {
|
|||
String position = 'a0',
|
||||
DateTime? archivedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
|
|
@ -37,6 +38,7 @@ class FakeStudysetGateway() implements StudysetGateway {
|
|||
position: position,
|
||||
archivedAt: archivedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
|
@ -57,7 +59,7 @@ class FakeStudysetGateway() implements StudysetGateway {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<String> insert(ByteData payload) async {
|
||||
Future<String> insert(ByteData payload, DateTime updatedAt) async {
|
||||
final id = 'studyset-${_nextId++}';
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
|
|
@ -66,13 +68,18 @@ class FakeStudysetGateway() implements StudysetGateway {
|
|||
position: 'a0',
|
||||
archivedAt: null,
|
||||
starredAt: null,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
writes.add((id, payload));
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePayload(String id, ByteData payload) async {
|
||||
Future<void> updatePayload(
|
||||
String id,
|
||||
ByteData payload,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (_rows[id] case final row?) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
|
|
@ -81,13 +88,18 @@ class FakeStudysetGateway() implements StudysetGateway {
|
|||
position: row.position,
|
||||
archivedAt: row.archivedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
writes.add((id, payload));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStarredAt(String id, DateTime? starredAt) async {
|
||||
Future<void> updateStarredAt(
|
||||
String id,
|
||||
DateTime? starredAt,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (_rows[id] case final row?) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
|
|
@ -96,12 +108,17 @@ class FakeStudysetGateway() implements StudysetGateway {
|
|||
position: row.position,
|
||||
archivedAt: row.archivedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateArchivedAt(String id, DateTime? archivedAt) async {
|
||||
Future<void> updateArchivedAt(
|
||||
String id,
|
||||
DateTime? archivedAt,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (_rows[id] case final row?) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
|
|
@ -110,6 +127,7 @@ class FakeStudysetGateway() implements StudysetGateway {
|
|||
position: row.position,
|
||||
archivedAt: archivedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue