fix(studyset): an exam's pace keeps its rate, and plans hold what they show

The pace kept a week for reviews only while more than two weeks were
left: followed from further off, it halved its rate at two weeks and
brought new cards until the exam. It now keeps a week, or half the days
left when the exam is closer, and the sheet says so.

An exam whose plan is gone said "Today", as the days left came from the
pace, which needs the plan; they are now counted from the date alone.
Once the exam's day was over, the study plan still asked for everything
new at once and Study for the exam queued every new card; now neither
shows, and a session for it studies as usual.

Cards tagged HSK 3 alone offered HSK 1 and 2, plans covering nothing;
a level offers a plan only when it covers some tag present. The plan
editor kept topics deleted or moved out since and tags spelled in
another case, chosen but not shown, and ticking such a tag added it
twice; once it has read what a plan may cover, only that stays chosen,
each tag as the flashcards spell it.

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-14 17:58:52 +02:00
commit 9c39155b94
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 264 additions and 78 deletions

View file

@ -1806,7 +1806,7 @@
"examPacePages": "{count, plural, =1{1 Seite am Tag} other{{count} Seiten am Tag}}",
"examPaceDone": "Alles Neue ist gelernt: bis zur Prüfung wiederholen.",
"examStudy": "Für die Prüfung lernen",
"examSheetHelp": "Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vorher gelernt ist und die letzte Woche zum Wiederholen bleibt. Das Datum siehst nur du.",
"examSheetHelp": "Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vor der Prüfung gelernt ist, oder auf halbem Weg dorthin, wenn sie näher ist, und der Rest zum Wiederholen bleibt. Das Datum siehst nur du.",
"examDate": "Prüfungsdatum",
"examSheetPlans": "Prüfungspläne dieses Lernsets",
"examSheetFromTags": "Aus den Stufen-Tags",

View file

@ -2137,7 +2137,7 @@
"@examPacePages": {"placeholders": {"count": {"type": "int"}}},
"examPaceDone": "Everything new is learnt: review until the exam.",
"examStudy": "Study for the exam",
"examSheetHelp": "Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.",
"examSheetHelp": "Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before the exam, or halfway there when it is closer, leaving the rest for reviews. Only you see the date.",
"examDate": "Exam date",
"examSheetPlans": "This studyset’s exam plans",
"examSheetFromTags": "From the level tags",

View file

@ -10680,7 +10680,7 @@ abstract class AppLocalizations {
/// No description provided for @examSheetHelp.
///
/// In en, this message translates to:
/// **'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.'**
/// **'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before the exam, or halfway there when it is closer, leaving the rest for reviews. Only you see the date.'**
String get examSheetHelp;
/// No description provided for @examDate.

View file

@ -6790,7 +6790,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get examSheetHelp =>
'Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vorher gelernt ist und die letzte Woche zum Wiederholen bleibt. Das Datum siehst nur du.';
'Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vor der Prüfung gelernt ist, oder auf halbem Weg dorthin, wenn sie näher ist, und der Rest zum Wiederholen bleibt. Das Datum siehst nur du.';
@override
String get examDate => 'Prüfungsdatum';

View file

@ -6729,7 +6729,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get examSheetHelp =>
'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.';
'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before the exam, or halfway there when it is closer, leaving the rest for reviews. Only you see the date.';
@override
String get examDate => 'Exam date';

View file

@ -418,7 +418,8 @@ class StudySessionBloc(
/// For a session studying for the exam the person prepares for in the
/// studyset: what the exam's plan covers, as one group whose new cards
/// a day are the pace that gets them through by the exam (`examPace`);
/// null without an exam, or with one its studyset no longer plans for.
/// null without an exam, with one whose day is over, or with one its
/// studyset no longer plans for: the session then studies as usual.
Future<List<List<Card>>?> _examGroups() async {
final all = await _studysets.all();
final choice = all
@ -426,7 +427,7 @@ class StudySessionBloc(
.firstOrNull
?.content
.exam;
if (choice == null) {
if (choice == null || choice.isPastAt(clock.now())) {
return null;
}
final coverage = ExamCoverage(

View file

@ -75,6 +75,15 @@ final class const ExamChoice({
_ => null,
};
/// The days from [now]'s day to the exam's, as the calendar counts them;
/// none on the day and after it.
int daysLeftAt(DateTime now) => _daysBetween(now, date);
/// Whether the exam's day is over at [now]: from the day after it on, a
/// pace to it or studying for it means nothing any more.
bool isPastAt(DateTime now) =>
date.isBefore(DateTime(now.year, now.month, now.day));
@override
List<Object?> get props => [plan, date];
}
@ -118,21 +127,22 @@ const _examLevels = [
/// The plans offered from the level tags a studyset's cards carry
/// ([tagNames], whatever their case): one for each level an exam tests,
/// taking the levels below it too, as an exam does (JLPT N4 takes N5's
/// words), up to the highest level present.
/// words), up to the highest level present. A level with none of its tags
/// or those below present is left out, as it would cover nothing: cards
/// tagged HSK 3 alone offer no HSK 1 or 2.
List<ExamPlan> automaticExamPlans(Iterable<String> tagNames) {
final present = {for (final name in tagNames) name.toLowerCase()};
bool has((String, String) level) => present.contains(level.$2.toLowerCase());
return [
for (final (:prefix, :levels, :exam) in _examLevels)
for (final (index, (level, _)) in levels.indexed)
if (levels
.skip(index)
.any((each) => present.contains(each.$2.toLowerCase())))
if (levels.skip(index).any(has) && levels.take(index + 1).any(has))
ExamPlan(
id: '$_automatic$prefix:${index + 1}',
name: '$exam $level',
tags: [
for (final (_, tag) in levels.take(index + 1))
if (present.contains(tag.toLowerCase())) tag,
for (final each in levels.take(index + 1))
if (has(each)) each.$2,
],
),
];
@ -141,8 +151,8 @@ List<ExamPlan> automaticExamPlans(Iterable<String> tagNames) {
/// How far a person has come with what an exam covers, and the pace that
/// gets them through it by the exam: the days left, the new cards to learn
/// a day, the steps of learning paths to work through a week and the pages
/// to read a day, so that what is new is learnt a week before the exam (or
/// by it, closer than that), leaving the last week for reviews.
/// to read a day, so that what is new is learnt a week before the exam, or
/// halfway there when it is closer, leaving the time after for reviews.
typedef ExamPace = ({
int daysLeft,
int newPerDay,
@ -152,7 +162,10 @@ typedef ExamPace = ({
/// The pace from [today] to the exam on [examDate] for [newCards] cards
/// never studied, [stepsLeft] steps not done and [pagesLeft] pages not
/// read (see [ExamPace]).
/// read (see [ExamPace]). The days kept for reviews are a week, or half of
/// those left when fewer than two weeks are: a pace followed from further
/// off keeps its rate as two weeks are left, rather than halving it and
/// bringing new cards up to the exam.
ExamPace examPace({
required DateTime today,
required DateTime examDate,
@ -160,14 +173,8 @@ ExamPace examPace({
required int stepsLeft,
required int pagesLeft,
}) {
final start = DateTime(today.year, today.month, today.day);
final end = DateTime(examDate.year, examDate.month, examDate.day);
// Whole days between, as the calendar counts them, summer time or not.
final daysLeft = math.max(
0,
(end.difference(start).inHours / 24).round(),
);
final learning = math.max(1, daysLeft > 14 ? daysLeft - 7 : daysLeft);
final daysLeft = _daysBetween(today, examDate);
final learning = math.max(1, daysLeft - math.min(7, daysLeft ~/ 2));
int perDay(int left) => left <= 0 ? 0 : (left / learning).ceil();
return (
daysLeft: daysLeft,
@ -179,6 +186,14 @@ ExamPace examPace({
);
}
/// The whole days from [from]'s day to [to]'s, as the calendar counts them,
/// summer time or not; none when [to] is that day or before it.
int _daysBetween(DateTime from, DateTime to) {
final start = DateTime(from.year, from.month, from.day);
final end = DateTime(to.year, to.month, to.day);
return math.max(0, (end.difference(start).inHours / 24).round());
}
List<String> _strings(Object? json) => [
if (json is List)
for (final item in json)

View file

@ -17,9 +17,9 @@ import 'package:material_ui/material_ui.dart';
/// top of its study plan (decided 2026-10-14). Without one, a row to
/// prepare for one; with one, its name and how many days are left, how far
/// the person has come with what it covers (flashcards started, steps
/// done, pages read), the pace that gets them through by the exam, and a
/// button to study for it: what it covers alone, its new cards at that
/// pace.
/// done, pages read), and until the exam's day is over the pace that gets
/// them through by the exam and a button to study for it: what it covers
/// alone, its new cards at that pace.
class const ExamSection({required final String studysetId, super.key})
extends StatelessWidget {
@override
@ -56,7 +56,8 @@ class const ExamSection({required final String studysetId, super.key})
final date = MaterialLocalizations.of(context).formatMediumDate(
choice.date,
);
final pace = progress == null
final past = choice.isPastAt(today);
final pace = progress == null || past
? null
: examPace(
today: today,
@ -65,9 +66,6 @@ class const ExamSection({required final String studysetId, super.key})
stepsLeft: progress.steps - progress.stepsDone,
pagesLeft: progress.pages - progress.pagesRead,
);
final past = choice.date.isBefore(
DateTime(today.year, today.month, today.day),
);
return DecoratedBox(
decoration: BoxDecoration(
color: colors.surfaceContainer,
@ -94,7 +92,7 @@ class const ExamSection({required final String studysetId, super.key})
Text(
past
? l10n.examPast(date)
: l10n.examDaysLeft(pace?.daysLeft ?? 0, date),
: l10n.examDaysLeft(choice.daysLeftAt(today), date),
style: muted,
),
],
@ -107,7 +105,7 @@ class const ExamSection({required final String studysetId, super.key})
),
],
),
if (plan != null && progress != null && pace != null)
if (plan != null && progress != null)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
@ -140,35 +138,40 @@ class const ExamSection({required final String studysetId, super.key})
progress.pages,
),
),
const SizedBox(height: 8),
Text(l10n.examPaceTitle, style: theme.textTheme.labelLarge),
const SizedBox(height: 2),
Text(
switch ([
if (pace.newPerDay > 0)
l10n.examPaceNew(pace.newPerDay),
if (pace.stepsPerWeek > 0)
l10n.examPaceSteps(pace.stepsPerWeek),
if (pace.pagesPerDay > 0)
l10n.examPacePages(pace.pagesPerDay),
]) {
final parts when parts.isEmpty => l10n.examPaceDone,
final parts => parts.join(' · '),
},
style: muted,
),
if (progress.cards > 0) ...[
const SizedBox(height: 12),
FilledButton.tonalIcon(
icon: const Icon(LucideIcons.layers),
label: Text(l10n.examStudy),
onPressed: () => context.push(
RoutePaths.study(
studysetId,
query: const StudyFocus(exam: true).toQuery(),
if (pace != null) ...[
const SizedBox(height: 8),
Text(
l10n.examPaceTitle,
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 2),
Text(
switch ([
if (pace.newPerDay > 0)
l10n.examPaceNew(pace.newPerDay),
if (pace.stepsPerWeek > 0)
l10n.examPaceSteps(pace.stepsPerWeek),
if (pace.pagesPerDay > 0)
l10n.examPacePages(pace.pagesPerDay),
]) {
final parts when parts.isEmpty => l10n.examPaceDone,
final parts => parts.join(' · '),
},
style: muted,
),
if (progress.cards > 0) ...[
const SizedBox(height: 12),
FilledButton.tonalIcon(
icon: const Icon(LucideIcons.layers),
label: Text(l10n.examStudy),
onPressed: () => context.push(
RoutePaths.study(
studysetId,
query: const StudyFocus(exam: true).toQuery(),
),
),
),
),
],
],
],
),
@ -405,10 +408,28 @@ class _PlanEditorState() extends State<_PlanEditor> {
late final _name = TextEditingController(text: widget.plan?.name);
late final _topics = {...?widget.plan?.topics};
late final _tags = {...?widget.plan?.tags};
/// What the plan may cover. Once it is read, only what shows stays
/// chosen: no topic deleted or moved out since, and each tag as the cards
/// spell it, so that its chip shows it chosen and ticking it never adds
/// it twice.
late final Future<
({List<({Studyset studyset, int depth})> topics, List<String> tags})
>
_coverable = context.read<ExamCubit>().coverable();
_coverable = context.read<ExamCubit>().coverable().then((coverable) {
final topics = {for (final topic in coverable.topics) topic.studyset.id};
final spelled = {for (final tag in coverable.tags) tag.toLowerCase(): tag};
if (mounted) {
setState(() {
_topics.retainAll(topics);
final tags = {for (final tag in _tags) ?spelled[tag.toLowerCase()]};
_tags
..clear()
..addAll(tags);
});
}
return coverable;
});
@override
void dispose() {

View file

@ -1193,7 +1193,7 @@ void main() {
.withExamPlans(const [
ExamPlan(id: 'verbs', name: 'Verb test', tags: ['verb']),
])
.withExam(ExamChoice(plan: 'verbs', date: DateTime(2026, 10, 7))),
.withExam(ExamChoice(plan: 'verbs', date: DateTime(2026, 10, 9))),
);
await withClock(Clock(() => now), () async {
@ -1201,7 +1201,8 @@ void main() {
addTearDown(bloc.close);
final first = await showing(bloc, '食べる');
// Two new verbs over the two days left: one a day.
// Two new verbs over the first two of the four days left, the
// last two kept for reviews: one a day.
expect(first.pills, hasLength(1));
});
await studysets.updateContent(
@ -1216,6 +1217,25 @@ void main() {
});
});
test('for an exam whose day is over, studies as usual rather than all '
'its new cards at once', () async {
await studysets.updateContent(
'japanese',
StudysetContent.titled('Japanese')
.withExamPlans(const [
ExamPlan(id: 'verbs', name: 'Verb test', tags: ['verb']),
])
.withExam(ExamChoice(plan: 'verbs', date: DateTime(2026, 9, 30))),
);
await withClock(Clock(() => now), () async {
final bloc = focused(const StudyFocus(exam: true));
addTearDown(bloc.close);
expect((await showing(bloc, '食べる')).pills, hasLength(3));
});
});
test('goes to the route and back as its query', () {
const focus = StudyFocus(
tag: (id: 'verb-id', name: 'verb'),

View file

@ -25,20 +25,19 @@ void main() {
[
for (final plan in automaticExamPlans(['HSK-7-9', '중급'])) plan.name,
],
[
'HSK 1',
'HSK 2',
'HSK 3',
'HSK 4',
'HSK 5',
'HSK 6',
'HSK 7–9',
'Korean 초급',
'Korean 중급',
],
['HSK 7–9', 'Korean 중급'],
);
expect(automaticExamPlans(['noun', 'verb']), isEmpty);
});
test('leave out the levels below the lowest present, which would cover '
'nothing', () {
expect(automaticExamPlans(['HSK-3', 'HSK-5']), const [
ExamPlan(id: 'auto:hsk:3', name: 'HSK 3', tags: ['HSK-3']),
ExamPlan(id: 'auto:hsk:4', name: 'HSK 4', tags: ['HSK-3']),
ExamPlan(id: 'auto:hsk:5', name: 'HSK 5', tags: ['HSK-3', 'HSK-5']),
]);
});
});
group('An exam plan', () {
@ -76,6 +75,17 @@ void main() {
expect(ExamChoice.fromJson({'plan': 'final', 'date': 'soon'}), isNull);
expect(ExamChoice.fromJson({'plan': 'final'}), isNull);
});
test('counts the days left by the calendar, and is past from the day '
'after it on', () {
final choice = ExamChoice(plan: 'final', date: DateTime(2026, 12, 6));
expect(choice.daysLeftAt(DateTime(2026, 10, 7, 23)), 60);
expect(choice.daysLeftAt(DateTime(2026, 12, 6, 8)), 0);
expect(choice.isPastAt(DateTime(2026, 12, 6, 23, 59)), isFalse);
expect(choice.isPastAt(DateTime(2026, 12, 7)), isTrue);
expect(choice.daysLeftAt(DateTime(2026, 12, 8)), 0);
});
});
group('The pace to an exam', () {
@ -98,7 +108,8 @@ void main() {
));
});
test('uses every day left when two weeks or fewer are', () {
test('leaves half the days left for reviews when the exam is closer '
'than two weeks', () {
final pace = examPace(
today: DateTime(2026, 10, 14),
examDate: DateTime(2026, 10, 24),
@ -107,14 +118,30 @@ void main() {
pagesLeft: 101,
);
// 10 days left, 5 of them to learn in.
expect(pace, (
daysLeft: 10,
newPerDay: 3,
newPerDay: 5,
stepsPerWeek: 3,
pagesPerDay: 11,
pagesPerDay: 21,
));
});
test('keeps its rate when followed until two weeks are left', () {
int newPerDay(DateTime today, int newCards) => examPace(
today: today,
examDate: DateTime(2026, 12, 6),
newCards: newCards,
stepsLeft: 0,
pagesLeft: 0,
).newPerDay;
// 30 days out, 230 cards over the 23 before the last week.
expect(newPerDay(DateTime(2026, 11, 6), 230), 10);
// 16 days on, 70 are left, with 14 days to go: not half the rate.
expect(newPerDay(DateTime(2026, 11, 22), 70), 10);
});
test('counts calendar days across a change of summer time', () {
expect(
examPace(

View file

@ -30,6 +30,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_opt
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/exam_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
@ -119,6 +120,8 @@ void main() {
String? frontLanguage,
String? backLanguage,
String? colorName,
List<ExamPlan> examPlans = const [],
ExamChoice? exam,
}) => gateway.receiveRemoteChange(
id,
encodeStudysetPayload(
@ -132,6 +135,8 @@ void main() {
bibliography: bibliography,
frontLanguage: frontLanguage,
backLanguage: backLanguage,
examPlans: examPlans,
exam: exam,
),
),
parentId: parent,
@ -2105,6 +2110,103 @@ void main() {
expect(find.text('Prepare for an exam'), findsOneWidget);
});
testWidgets('an exam whose plan is gone still says how far off it '
'is', (tester) async {
final today = clock.now();
stored(
'japanese',
'Japanese',
exam: ExamChoice(
plan: 'gone',
date: DateTime(today.year, today.month, today.day + 60),
),
);
await showApp(tester, location: RoutePaths.studyset('japanese'));
await openPart(tester, 'Study plan');
expect(find.text('This exam plan is gone'), findsOneWidget);
expect(find.textContaining('In 60 days'), findsOneWidget);
});
testWidgets("once the exam's day is over, it shows how far the person "
'came, without a pace or studying for it', (tester) async {
final today = clock.now();
stored(
'japanese',
'Japanese',
examPlans: const [
ExamPlan(id: 'kana-quiz', name: 'Kana quiz', topics: ['kana']),
],
exam: ExamChoice(
plan: 'kana-quiz',
date: DateTime(today.year, today.month, today.day - 3),
),
);
stored('kana', 'Kana', parent: 'japanese');
await card('kana', 'あ');
await showApp(tester, location: RoutePaths.studyset('japanese'));
await openPart(tester, 'Study plan');
expect(find.textContaining('It was on'), findsOneWidget);
expect(find.text('0 of 1 flashcard started'), findsOneWidget);
expect(find.text('Your pace'), findsNothing);
expect(find.text('Study for the exam'), findsNothing);
});
testWidgets('editing a plan keeps only the topics and tags it shows, '
'each tag as the flashcards spell it', (tester) async {
stored(
'japanese',
'Japanese',
examPlans: const [
ExamPlan(
id: 'final',
name: 'Final',
topics: ['kana', 'gone'],
tags: ['VERB'],
),
],
);
stored('kana', 'Kana', parent: 'japanese');
final taberu = await card('kana', '食べる');
await showApp(tester, location: RoutePaths.studyset('japanese'));
final person = PersonScope.of(
tester.element(find.byType(StudysetScreen)),
);
await person.tags.setTagsOf(taberu, {await person.tags.create('Verb')});
await openPart(tester, 'Study plan');
await tester.tap(find.text('Prepare for an exam'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Edit exam plan'));
await tester.pumpAndSettle();
expect(
tester
.widget<CheckboxListTile>(
find.widgetWithText(CheckboxListTile, 'Kana'),
)
.value,
isTrue,
);
expect(
tester
.widget<FilterChip>(find.widgetWithText(FilterChip, 'Verb'))
.selected,
isTrue,
);
await tester.tap(find.text('Save').last);
await tester.pumpAndSettle();
final japanese = (await person.studysets.all()).singleWhere(
(studyset) => studyset.id == 'japanese',
);
expect(japanese.content.examPlans, const [
ExamPlan(id: 'final', name: 'Final', topics: ['kana'], tags: ['Verb']),
]);
});
});
testWidgets('choosing another part ticks, as the navigation bar does; '