feat(todo): quick add reads on before a day, durations, every !30m and !1pm

`on` (German `am`) before a day is taken with it, as `at` before a time;
`for 2h`, `for 1h30m`, `for 90 min`, `für 1,5 Std.` set the date's
duration where there is a time, and stay text without one; every `!30m`
counts, each once, sorted, a task without a date due at the latest; and
`!1pm`, `!13:00`, `!tomorrow 9am`, `!Monday 8:30` are reminders at a time
of their own, a time without a day on the task's day, else the next to
come. Todoist's newsletter's four examples parse whole (ADR 0033, D18).

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-03 13:38:43 +01:00
commit d40eca6e50
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -295,6 +295,229 @@ void main() {
});
});
group('on before a day, durations, reminders (D18)', () {
const de = [QuickAddWords.de, QuickAddWords.en];
test(
'on before a day is taken with it; on before nothing it reads stays',
() {
final added = parse('Pay rent on Friday');
expect(added.title, 'Pay rent');
expect(added.date?.day, const Day(2026, 11, 6));
expect(parse('Trip on 7 Nov').date?.day, const Day(2026, 11, 7));
expect(parse('Trip on 7 Nov').title, 'Trip');
expect(parse('Work on report').title, 'Work on report');
expect(parse('Work on report').date, isNull);
expect(parse('Move on').title, 'Move on');
},
);
test('am before a day and um before a time, in German; am after a '
'number still the morning', () {
final added = parse('Zahnarzt am Montag um 9 Uhr', words: de);
expect(added.title, 'Zahnarzt');
expect(added.date, const TaskDate(Day(2026, 11, 2), time: 9 * 60));
expect(
parse('Feier am 24.12.', words: de).date?.day,
const Day(2026, 12, 24),
);
expect(parse('Feier am 24.12.', words: de).title, 'Feier');
expect(parse('Steuer jedes Jahr am 1. März', words: de).title, 'Steuer');
expect(
parse('Anrufen morgen um 9 Uhr', words: de).date,
TaskDate(today.plus(1), time: 9 * 60),
);
expect(parse('Anrufen morgen um 9 Uhr', words: de).title, 'Anrufen');
expect(parse('Call 9 am', words: de).date?.time, 9 * 60);
expect(
parse('Treffen am Bahnhof', words: de).title,
'Treffen am Bahnhof',
);
});
test('for and a duration, with a time', () {
Duration? lasts(String text, [List<QuickAddWords>? words]) =>
parse(text, words: words ?? const [QuickAddWords.en]).date?.duration;
expect(lasts('Meet at 3pm for 2h'), const Duration(hours: 2));
expect(lasts('Meet at 3pm for 30m'), const Duration(minutes: 30));
expect(lasts('Meet at 3pm for 1h30m'), const Duration(minutes: 90));
expect(lasts('Meet at 3pm for 1h 30m'), const Duration(minutes: 90));
expect(lasts('Meet at 3pm for 90 min'), const Duration(minutes: 90));
expect(lasts('Meet at 3pm for 2 hours'), const Duration(hours: 2));
expect(lasts('Meet at 3pm for 1.5 hours'), const Duration(minutes: 90));
expect(
lasts('Treffen um 15 Uhr für 2 Std', de),
const Duration(hours: 2),
);
expect(
lasts('Treffen um 15 Uhr für 30 Min', de),
const Duration(minutes: 30),
);
expect(
lasts('Treffen um 15 Uhr für 1,5 Std', de),
const Duration(minutes: 90),
);
expect(
lasts('Treffen um 15 Uhr für 1,5 Std.', de),
const Duration(minutes: 90),
);
expect(parse('Meet for 2h at 3pm').title, 'Meet');
expect(
parse('Treffen um 15 Uhr für 1,5 Std.', words: de).title,
'Treffen',
);
});
test('a duration without a time stays text, as one that is none', () {
expect(parse('Read for 2h').title, 'Read for 2h');
expect(parse('Read for 2h').tokens, isEmpty);
final day = parse('Read tomorrow for 2h');
expect(day.title, 'Read for 2h');
expect(day.date, TaskDate(today.plus(1)));
expect(parse('Cook for 4 people at 6pm').date?.duration, isNull);
expect(parse('Cook for 4 people at 6pm').title, 'Cook for 4 people');
expect(
parse('Sleep at 10pm for 30h').date?.duration,
isNull,
reason: 'longer than a day',
);
});
test('every !30m counts, each once, the nearest to the time last', () {
const text = 'Meeting 3pm !30m !1h !30m';
final added = parse(text);
expect(added.title, 'Meeting');
expect(added.reminders, [30, 60]);
expect(
[
for (final token in added.tokens)
if (token.kind == QuickAddKind.reminder)
text.substring(token.start, token.end),
],
['!30m', '!1h', '!30m'],
);
});
test('several !30m without a date: due at the latest, each reminding '
'that long from now', () {
final added = parse('Leave !30m !1h');
expect(added.date, TaskDate(today, time: 11 * 60 + 15));
expect(added.reminders, [0, 30]);
expect(parse('Pay tomorrow !30m !1h').title, 'Pay !30m !1h');
});
test('! before a time: a reminder at it, on the task’s day, else the '
'next to come', () {
final at = parse('Call !1pm');
expect(at.title, 'Call');
expect(at.date, isNull);
expect(at.reminderTimes, [ReminderTime(today, 13 * 60)]);
expect(parse('Call !13:00').reminderTimes, [
ReminderTime(today, 13 * 60),
]);
expect(
parse('Call !9am').reminderTimes,
[ReminderTime(today.plus(1), 9 * 60)],
reason: 'past already today',
);
expect(parse('Call Friday !9am').reminderTimes, [
const ReminderTime(Day(2026, 11, 6), 9 * 60),
]);
final both = parse('Dentist Friday 3pm !1pm !30m');
expect(both.date, const TaskDate(Day(2026, 11, 6), time: 15 * 60));
expect(both.reminders, [30]);
expect(both.reminderTimes, [
const ReminderTime(Day(2026, 11, 6), 13 * 60),
]);
expect(both.title, 'Dentist');
expect(
parse('Standup every weekday 9am !8:30').reminderTimes,
[const ReminderTime(Day(2026, 11, 2), 8 * 60 + 30)],
reason: "on the recurrence's first day",
);
});
test('! before a day and a time: a reminder then', () {
const text = 'Pay !tomorrow 9am';
final added = parse(text);
expect(added.title, 'Pay');
expect(added.date, isNull);
expect(added.reminderTimes, [ReminderTime(today.plus(1), 9 * 60)]);
expect(
[
for (final token in added.tokens)
(token.kind, text.substring(token.start, token.end)),
],
[(QuickAddKind.reminderTime, '!tomorrow 9am')],
);
expect(parse('Pay !Monday 8:30').reminderTimes, [
const ReminderTime(Day(2026, 11, 2), 8 * 60 + 30),
]);
expect(parse('Zahlen !morgen um 9 Uhr', words: de).reminderTimes, [
ReminderTime(today.plus(1), 9 * 60),
]);
});
test('a ! before no time, or tapped off, stays text', () {
expect(parse('Pay !tomorrow').title, 'Pay !tomorrow');
expect(parse('Pay !tomorrow').date, isNull);
expect(parse('Wow !9').title, 'Wow !9');
expect(parse('Wow !soon').reminderTimes, isEmpty);
final kept = parse('Call !1pm !2pm', ignored: {'!1pm'});
expect(kept.title, 'Call !1pm');
expect(kept.reminderTimes, [ReminderTime(today, 14 * 60)]);
});
group("Todoist's newsletter's four", () {
test('Check work emails every weekday at 9am !30m', () {
final added = parse('Check work emails every weekday at 9am !30m');
expect(added.title, 'Check work emails');
expect(
added.date,
const TaskDate(
Day(2026, 11, 2),
time: 9 * 60,
repeat: Repeat(RepeatUnit.week, weekdays: {1, 2, 3, 4, 5}),
),
);
expect(added.reminders, [30]);
});
test('Prepare document on Monday at 10am for 2h', () {
final added = parse('Prepare document on Monday at 10am for 2h');
expect(added.title, 'Prepare document');
expect(
added.date,
const TaskDate(
Day(2026, 11, 2),
time: 10 * 60,
duration: Duration(hours: 2),
),
);
});
test('Stop watching that viral cat video !1pm !2pm !3pm !4pm', () {
final added = parse(
'Stop watching that viral cat video !1pm !2pm !3pm !4pm',
);
expect(added.title, 'Stop watching that viral cat video');
expect(added.date, isNull);
expect(added.reminderTimes, [
for (final hour in [13, 14, 15, 16]) ReminderTime(today, hour * 60),
]);
});
test('File my tax return every year on 1st March', () {
final added = parse('File my tax return every year on 1st March');
expect(added.title, 'File my tax return');
expect(
added.date,
const TaskDate(Day(2027, 3, 1), repeat: Repeat(RepeatUnit.year)),
);
});
});
});
group('tokens', () {
test('each says where it is in the text and what it is', () {
const text = 'Call #Shopping tomorrow p1 @errand';