feat(todo): a project's Calendar, Upcoming's Display and its 3-day grid (ADR 0033, D17)

A project's Display gains the Calendar layout: Upcoming's week strip
and agenda, now shared as DayAgenda, over the project's tasks it keeps,
with No date beside the month and Future occurrences, each repeating
task also on the days it will come back (calendarOf on Repeat.next),
muted, neither ticked off nor dragged.

Upcoming gets a Display of its own, the same sheet over another target
(DisplayTarget): Agenda or 3-day, done tasks, future occurrences,
sorting, the filters by deadline, priority and label, and in the grid
Colouring by priority, label or project. It is kept in the person's
settings, one sealed row under an ID keyed by the account in the new
synced table todo_settings (migration settings: the table, its index
on spaceId and its foreign key to the space, cascading on delete).

The 3-day grid shows today and the two days after, stepped or swiped
three at a time: tasks without a time all day, timed ones as blocks as
long as they last, overlapping ones side by side (layOutDay). A long
press on a half hour adds a task there for half an hour; Overdue opens
the overdue tasks with Reschedule.

The board's separator builder names its BuildContext, as the root's
analyzer plugin asks (never_discard_build_context).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-01 20:01:00 +01:00
commit 2939437c74
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 21845 additions and 314 deletions

View file

@ -13,14 +13,17 @@ import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_board.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_calendar_view.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_todo_flutter/src/tabs/three_day_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -42,6 +45,7 @@ void main() {
late MemorySealedRowGateway sectionRows;
late MemorySealedRowGateway taskRows;
late MemorySealedRowGateway labelRows;
late MemorySealedRowGateway settingsRows;
late MemoryReminderGateway reminders;
tearDown(() async {
@ -65,6 +69,7 @@ void main() {
sectionRows = MemorySealedRowGateway();
taskRows = MemorySealedRowGateway();
labelRows = MemorySealedRowGateway();
settingsRows = MemorySealedRowGateway();
reminders = MemoryReminderGateway();
store = MemoryInstanceStore();
gateway = FakeAuthGateway();
@ -148,6 +153,7 @@ void main() {
sections: SectionRepository(sectionRows, keys),
tasks: TaskRepository(taskRows, keys),
labels: LabelRepository(labelRows, keys),
settings: SettingsRepository(settingsRows, keys),
reminders: reminders,
);
},
@ -612,6 +618,219 @@ void main() {
expect(find.text('Errands 2'), findsOneWidget);
});
});
group('Calendar', () {
Future<void> calendarOf(WidgetTester tester) async {
await inboxOf(tester);
await display(tester);
await tester.tap(find.text('Calendar'));
await tester.pumpAndSettle();
await done(tester);
}
testWidgets('the week strip and the days with the dated tasks; those '
'without a date under No date; kept in the project', (
tester,
) async {
await calendarOf(tester);
await seed(
tester,
TaskContent(
title: 'Dentist',
projectId: inbox(),
date: TaskDate(Day.of(DateTime.now()).plus(1)),
),
);
expect(find.byType(WeekStrip), findsOneWidget);
expect(row('Dentist'), findsOneWidget);
expect(row('cherry'), findsNothing, reason: 'no date');
final projects = await tester.runAsync(
() => ProjectRepository(projectRows, keys).all(),
);
expect(
projects!
.singleWhere((project) => project.id == inbox())
.content
?.view
.layout,
ProjectLayout.calendar,
);
await tester.tap(find.byKey(ProjectCalendarView.noDateKey));
await tester.pumpAndSettle();
for (final title in ['cherry', 'Apple', 'banana']) {
expect(row(title), findsOneWidget);
}
expect(row('Dentist'), findsNWidgets(1), reason: 'behind the sheet');
});
testWidgets('Future occurrences shows a repeating task on its later '
'days too, not ticked off there; grouping is left out', (
tester,
) async {
await calendarOf(tester);
await seed(
tester,
TaskContent(
title: 'Water plants',
projectId: inbox(),
date: TaskDate(
Day.of(DateTime.now()),
repeat: const Repeat(RepeatUnit.day),
),
),
);
expect(row('Water plants'), findsOneWidget);
await display(tester);
expect(find.widgetWithText(ListTile, 'Grouping'), findsNothing);
await tester.tap(
find.widgetWithText(SwitchListTile, 'Future occurrences'),
);
await tester.pumpAndSettle();
await done(tester);
expect(row('Water plants').evaluate().length, greaterThan(7));
expect(
find.byTooltip('A later time of a repeating task'),
findsNWidgets(row('Water plants').evaluate().length - 1),
);
expect(find.byTooltip('Mark as done'), findsOneWidget);
});
});
});
group("Upcoming's Display (D17)", () {
Future<void> display(WidgetTester tester) async {
await tester.tap(find.byTooltip('Display'));
await tester.pumpAndSettle();
}
Future<void> done(WidgetTester tester) async {
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
await tester.pumpAndSettle();
}
Future<void> threeDay(WidgetTester tester) async {
await goTab(tester, 'Upcoming');
await display(tester);
await tester.tap(find.text('3-day'));
await tester.pumpAndSettle();
await done(tester);
}
testWidgets('3-day shows today and the next two days, a timed task as '
'a block and one without a time all day; kept in the settings, '
'sealed', (tester) async {
await start(tester, usedBefore: true);
final today = Day.of(DateTime.now());
await seed(
tester,
TaskContent(
title: 'Dentist',
projectId: inbox(),
date: TaskDate(today.plus(1), time: 9 * 60),
),
);
await seed(
tester,
TaskContent(
title: 'Shop',
projectId: inbox(),
date: TaskDate(today.plus(2)),
),
);
await seed(
tester,
TaskContent(
title: 'Far',
projectId: inbox(),
date: TaskDate(today.plus(3)),
),
);
await threeDay(tester);
expect(find.byType(ThreeDayGrid), findsOneWidget);
for (final day in [today, today.plus(1), today.plus(2)]) {
expect(find.byKey(ThreeDayGrid.columnKey(day)), findsOneWidget);
}
final stored = await tester.runAsync(() => tasks().all());
final dentist = find.byKey(
ThreeDayGrid.blockKey(
stored!.singleWhere((t) => t.content?.title == 'Dentist').id,
today.plus(1),
),
);
expect(dentist, findsOneWidget);
expect(tester.getSize(dentist).height, closeTo(28, 1));
expect(find.text('Shop'), findsOneWidget);
expect(find.text('Far'), findsNothing);
expect(settingsRows.byId.values.single.keyId, isNotNull);
await tester.tap(find.byTooltip('Later days'));
await tester.pumpAndSettle();
expect(find.text('Far'), findsOneWidget);
});
testWidgets('a long press on a half hour adds a task there for half an '
'hour', (tester) async {
await start(tester, usedBefore: true);
await threeDay(tester);
final today = Day.of(DateTime.now());
final column = find.byKey(ThreeDayGrid.columnKey(today));
// 10:00 is 600 logical pixels down the day.
await tester.longPressAt(
tester.getTopLeft(column) + const Offset(20, 10 * 60 + 10),
);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(quickAddTitleKey), 'Call');
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
await tester.pumpAndSettle();
final stored = await tester.runAsync(() => tasks().all());
final date = stored!.single.content!.date!;
expect(date.day, today);
expect(date.time, 10 * 60);
expect(date.duration, const Duration(minutes: 30));
});
testWidgets('its filters keep the agenda to what they ask for; Reset '
'all puts it back', (tester) async {
await start(tester, usedBefore: true);
final today = Day.of(DateTime.now());
for (final (title, priority) in [
('Urgent', Priority.p1),
('Someday', Priority.none),
]) {
await seed(
tester,
TaskContent(
title: title,
projectId: inbox(),
priority: priority,
date: TaskDate(today),
),
);
}
await goTab(tester, 'Upcoming');
expect(find.text('Someday'), findsOneWidget);
await display(tester);
await tester.tap(find.widgetWithText(ListTile, 'Priority'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(CheckboxListTile, 'Priority 1'));
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
await tester.pumpAndSettle();
await done(tester);
expect(find.text('Urgent'), findsOneWidget);
expect(find.text('Someday'), findsNothing);
await display(tester);
await tester.tap(find.text('Reset all'));
await tester.pumpAndSettle();
await done(tester);
expect(find.text('Someday'), findsOneWidget);
});
});
group('quick add reads what is typed (D15)', () {

View file

@ -0,0 +1,174 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// A project's Calendar (ADR 0033, D17): its overdue tasks, then each day
/// with its tasks and, where asked, the later times of repeating ones.
void main() {
// Sunday, 1 Nov 2026.
const today = Day(2026, 11, 1);
OpenTask task(
String id, {
Day? day,
int? time,
Repeat? repeat,
bool completed = false,
}) => (
id: id,
content: TaskContent(
title: id,
projectId: 'p',
completedAt: completed ? DateTime(2026, 11) : null,
date: day == null ? null : TaskDate(day, time: time, repeat: repeat),
),
);
/// Each day's entries as `id` or, for a later time, `id*`.
Map<Day, String> shown(ProjectCalendar calendar) => {
for (final (day, entries) in calendar.days)
if (entries.isNotEmpty)
day: [
for (final entry in entries)
'${entry.task.id}${entry.occurrence ? '*' : ''}',
].join(','),
};
test('overdue first, then each day from today on with its tasks, those '
'without a date and done overdue ones left out', () {
final calendar = calendarOf(
[
task('none'),
task('late', day: today.plus(-2)),
task('done late', day: today.plus(-1), completed: true),
task('later', day: today.plus(3)),
task('now', day: today, time: 9 * 60),
task('now early', day: today, time: 7 * 60),
task('too far', day: today.plus(10)),
],
today,
days: 7,
);
expect([for (final t in calendar.overdue) t.id], ['late']);
expect(calendar.days, hasLength(7));
expect(calendar.days.first.$1, today);
expect(shown(calendar), {
today: 'now early,now',
today.plus(3): 'later',
});
});
test('a repeating task is shown on its later days only where asked', () {
final daily = task(
'daily',
day: today,
repeat: const Repeat(RepeatUnit.day),
);
expect(shown(calendarOf([daily], today, days: 3)), {today: 'daily'});
expect(
shown(calendarOf([daily], today, days: 3, occurrences: true)),
{today: 'daily', today.plus(1): 'daily*', today.plus(2): 'daily*'},
);
});
test('an overdue repeating task comes next after today, as done today it '
'would; a done one, one counted out or one ended no further', () {
final weekly = task(
'weekly',
day: today.plus(-3),
repeat: const Repeat(RepeatUnit.week),
);
expect(
shown(calendarOf([weekly], today, days: 14, occurrences: true)),
{today.plus(4): 'weekly*', today.plus(11): 'weekly*'},
);
final twice = task(
'twice',
day: today,
repeat: const Repeat(RepeatUnit.day, left: 2),
);
final until = task(
'until',
day: today,
repeat: Repeat(RepeatUnit.day, until: today.plus(1)),
);
final done = task(
'done',
day: today,
completed: true,
repeat: const Repeat(RepeatUnit.day),
);
expect(
shown(
calendarOf(
[twice, until, done],
today,
days: 7,
occurrences: true,
),
),
{
today: 'done,twice,until',
today.plus(1): 'twice*,until*',
today.plus(2): 'twice*',
},
);
});
test('a later time is the task on that day, its ID kept', () {
final calendar = calendarOf(
[
task(
'daily',
day: today,
time: 6 * 60,
repeat: const Repeat(RepeatUnit.day),
),
],
today,
days: 2,
occurrences: true,
);
final later = calendar.days[1].$2.single;
expect(later.occurrence, isTrue);
expect(later.task.id, 'daily');
expect(later.task.content.date?.day, today.plus(1));
expect(later.task.content.date?.time, 6 * 60);
});
test('the days with open tasks, beyond those listed too, and with later '
'times; not those with done ones only', () {
final calendar = calendarOf(
[
task('a', day: today.plus(2)),
task('b', day: today, completed: true),
task('c', day: today.plus(5), repeat: const Repeat(RepeatUnit.day)),
task('d', day: today.plus(40)),
],
today,
days: 7,
occurrences: true,
);
expect(calendar.busy, {
today.plus(2),
today.plus(5),
today.plus(6),
today.plus(40),
});
});
test("a day's tasks in another order where asked", () {
final calendar = calendarOf(
[
task('b', day: today, time: 9 * 60),
task('a', day: today, time: 10 * 60),
],
today,
days: 1,
order: (x, y) => x.id.compareTo(y.id),
);
expect(shown(calendar), {today: 'a,b'});
});
}

View file

@ -31,7 +31,9 @@ void main() {
test("a project's Display reads back as it was set, by names; left "
'as at first, nothing of it is written (D17)', () {
const view = ProjectView(
layout: ProjectLayout.calendar,
completed: true,
occurrences: true,
grouping: TaskGrouping.label,
sorting: TaskSorting.priority,
date: DayRange.next7Days,
@ -46,7 +48,9 @@ void main() {
utf8.decode(Uint8List.sublistView(encodeProject(home))),
) as Map<String, Object?>;
expect(written['view'], {
'layout': 'calendar',
'completed': true,
'occurrences': true,
'grouping': 'label',
'sorting': 'priority',
'date': 'next7Days',

View file

@ -46,6 +46,7 @@ void main() {
expect(view.isDefault, isTrue);
expect(view.layout, ProjectLayout.list);
expect(view.completed, isFalse);
expect(view.occurrences, isFalse, reason: "a Calendar's later times");
expect(view.grouping, TaskGrouping.none);
expect(view.sorting, TaskSorting.manual);
expect(view.canReorder, isTrue);

View file

@ -0,0 +1,71 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// The person's settings as their row's payload (ADR 0033, D17): what
/// differs from how Todo starts, by names.
void main() {
Map<String, Object?> written(TodoSettings settings) =>
jsonDecode(utf8.decode(Uint8List.sublistView(encodeSettings(settings))))
as Map<String, Object?>;
ByteData payload(Object? value) =>
ByteData.sublistView(Uint8List.fromList(utf8.encode(jsonEncode(value))));
test('as at first, only the schema is written', () {
expect(written(const TodoSettings()), {'schemaVersion': 1});
expect(
decodeSettings(encodeSettings(const TodoSettings())),
const TodoSettings(),
);
});
test("Upcoming's Display reads back as it was set", () {
const settings = TodoSettings(
upcoming: UpcomingView(
layout: UpcomingLayout.threeDay,
coloring: TaskColoring.project,
filters: ProjectView(
completed: true,
occurrences: true,
sorting: TaskSorting.priority,
deadline: DayRange.next7Days,
priorities: {Priority.p1},
labelIds: {'l1'},
),
),
);
expect(decodeSettings(encodeSettings(settings)), settings);
expect(written(settings)['upcoming'], {
'layout': 'threeDay',
'coloring': 'project',
'completed': true,
'occurrences': true,
'sorting': 'priority',
'deadline': 'next7Days',
'priorities': ['p1'],
'labelIds': ['l1'],
});
});
test('a value this version does not know reads as at first; a newer '
'schema not at all', () {
expect(
decodeSettings(
payload({
'schemaVersion': 1,
'upcoming': {'layout': 'week', 'coloring': 'mood', 'completed': true},
}),
),
const TodoSettings(
upcoming: UpcomingView(filters: ProjectView(completed: true)),
),
);
expect(decodeSettings(payload({'schemaVersion': 2})), isNull);
});
}

View file

@ -0,0 +1,101 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_sealed_row_gateway.dart';
/// The person's settings (ADR 0033, D17): one row of theirs, sealed, under
/// an ID keyed by the account, so every device writes the same one.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late MemoryContentKeyGateway keyRows;
late MemorySealedRowGateway rows;
final opened = <TodoKeys>[];
const threeDay = TodoSettings(
upcoming: UpcomingView(layout: UpcomingLayout.threeDay),
);
Future<SettingsRepository> device(
UnlockedAccountKeys? account, {
MemoryContentKeyGateway? keysOf,
}) async {
final keys = TodoKeys(
keysOf ?? keyRows,
SodiumContentCipher(),
() async => account,
);
opened.add(keys);
await keys.refresh();
return SettingsRepository(rows, keys);
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
bob = (await crypto.newAccount('bob password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemorySealedRowGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('none kept reads as at first, and writes nothing', () async {
final phone = await device(alice);
expect(await phone.read(), const TodoSettings());
expect(rows.byId, isEmpty);
});
test('written on one device, read on another, in one row sealed for its '
'table', () async {
final phone = await device(alice);
await phone.write(threeDay);
await phone.write(threeDay);
final laptop = await device(alice);
expect(await laptop.read(), threeDay);
expect(rows.byId, hasLength(1));
expect(rows.byId.values.single.keyId, isNotNull);
});
test('two devices writing offline write one row, not two', () async {
final phone = await device(alice);
final laptop = await device(alice);
await Future.wait([phone.write(threeDay), laptop.write(threeDay)]);
expect(rows.byId, hasLength(1));
});
test(
"another person's keys don't open them: they read as at first",
() async {
final phone = await device(alice);
await phone.write(threeDay);
final id = rows.byId.keys.single;
final mallory = await device(bob, keysOf: MemoryContentKeyGateway());
// Moved under the ID Bob's account keys the row with.
rows.byId[mallory.id] = rows.byId[id]!;
expect(await mallory.read(), const TodoSettings());
},
);
test('a device without the keys reads them as at first and writes '
'nothing', () async {
final locked = await device(null);
expect(await locked.read(), const TodoSettings());
await expectLater(locked.write(threeDay), throwsA(isA<KeysMissing>()));
expect(rows.byId, isEmpty);
});
}

View file

@ -0,0 +1,78 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/day_grid.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// A day of the 3-day grid (ADR 0033, D17): its timed tasks as blocks, side
/// by side where they overlap.
void main() {
const day = Day(2026, 11, 2);
CalendarEntry entry(String id, {int? time, Duration? duration}) => (
task: (
id: id,
content: TaskContent(
title: id,
projectId: 'p',
date: TaskDate(day, time: time, duration: duration),
),
),
occurrence: false,
);
String laid(List<GridBlock> blocks) => [
for (final b in blocks)
'${b.entry.task.id} ${b.start}-${b.end} ${b.lane}/${b.lanes}',
].join('; ');
test('a task without a time is all day, not a block', () {
final (:allDay, :blocks) = layOutDay([
entry('Shop'),
entry('Call', time: 9 * 60),
]);
expect([for (final e in allDay) e.task.id], ['Shop']);
expect(laid(blocks), 'Call 540-570 0/1');
});
test('a block lasts its duration, half an hour at least', () {
final (:allDay, :blocks) = layOutDay([
entry('Run', time: 7 * 60, duration: const Duration(minutes: 90)),
entry('Pills', time: 12 * 60, duration: const Duration(minutes: 5)),
]);
expect(laid(blocks), 'Run 420-510 0/1; Pills 720-750 0/1');
});
test('overlapping blocks share their width; one after them has it all', () {
final (:allDay, :blocks) = layOutDay([
entry('B', time: 9 * 60 + 30),
entry('A', time: 9 * 60, duration: const Duration(hours: 1)),
entry('C', time: 9 * 60 + 30),
entry('D', time: 10 * 60 + 15),
entry('E', time: 11 * 60),
]);
expect(
laid(blocks),
'A 540-600 0/3; B 570-600 1/3; C 570-600 2/3; '
'D 615-645 0/1; E 660-690 0/1',
);
});
test('a lane freed is taken again within the same cluster', () {
final (:allDay, :blocks) = layOutDay([
entry('Long', time: 9 * 60, duration: const Duration(hours: 2)),
entry('First', time: 9 * 60),
entry('Second', time: 10 * 60),
]);
expect(
laid(blocks),
'Long 540-660 0/2; First 540-570 1/2; Second 600-630 1/2',
);
});
test('a block running past midnight ends at it', () {
final (:allDay, :blocks) = layOutDay([
entry('Late', time: 23 * 60 + 30, duration: const Duration(hours: 2)),
]);
expect(laid(blocks), 'Late 1410-1440 0/1');
});
}

View file

@ -219,7 +219,11 @@ void main() {
);
final weekdays = parse('Stand-up every weekday 9am').date;
expect(weekdays?.repeat?.weekdays, {1, 2, 3, 4, 5});
expect(weekdays?.day, const Day(2026, 11, 2), reason: 'Monday, the first');
expect(
weekdays?.day,
const Day(2026, 11, 2),
reason: 'Monday, the first',
);
});
test('every! counts from when it was done', () {
@ -237,7 +241,10 @@ void main() {
expect(parse('Zahlen heute', words: de).date?.day, today);
expect(parse('Zahlen morgen', words: de).date?.day, today.plus(1));
expect(parse('Zahlen übermorgen', words: de).date?.day, today.plus(2));
expect(parse('Sport Montag', words: de).date?.day, const Day(2026, 11, 2));
expect(
parse('Sport Montag', words: de).date?.day,
const Day(2026, 11, 2),
);
expect(parse('Anrufen in 3 Tagen', words: de).date?.day, today.plus(3));
expect(parse('Zahlen morgen', words: de).title, 'Zahlen');
});