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
4 changed files with 422 additions and 24 deletions

View file

@ -9,9 +9,17 @@ enum QuickAddKind() {
label, label,
deadline, deadline,
priority, priority,
/// `!30m`: so long before the task's time.
reminder, reminder,
/// `!1pm`, `!tomorrow 9am`: at a time of its own (D18).
reminderTime,
date, date,
time, time,
/// `for 2h` (D18).
duration,
repeat, repeat,
} }
@ -41,6 +49,9 @@ final class const ParsedTask({
/// Reminders, minutes before the task's time, as [TaskContent.reminders]. /// Reminders, minutes before the task's time, as [TaskContent.reminders].
final List<int> reminders = const [], final List<int> reminders = const [],
/// Reminders at times of their own, as [TaskContent.reminderTimes].
final List<ReminderTime> reminderTimes = const [],
}); });
/// A project's or a label's ID and the name it is typed by. /// A project's or a label's ID and the name it is typed by.
@ -50,9 +61,12 @@ typedef NamedId = ({String id, String name});
/// `@label` by their names (the longest matching, the case aside; an /// `@label` by their names (the longest matching, the case aside; an
/// unknown label to be made, an unknown project left as text), `{deadline}`, /// unknown label to be made, an unknown project left as text), `{deadline}`,
/// `!!1` and `p1` to `p4`, `!30m` (due that long from now, or reminded that /// `!!1` and `p1` to `p4`, `!30m` (due that long from now, or reminded that
/// long before its time), and dates, times and recurrences in [words]. Only /// long before its time), `!1pm` and `!tomorrow 9am` (reminded then, D18),
/// the first of each kind counts. A token whose text, in lower case, is /// and dates (`on` before one taken with it), times, durations (`for 2h`,
/// among [ignored] (tapped off by the person) stays text. /// with a time alone) and recurrences in [words]. Only the first of each
/// kind counts, but for reminders and labels, each of which counts once. A
/// token whose text, in lower case, is among [ignored] (tapped off by the
/// person) stays text.
ParsedTask parseQuickAdd( ParsedTask parseQuickAdd(
String text, { String text, {
required DateTime now, required DateTime now,
@ -82,21 +96,25 @@ class _Parser(
Day? _day; Day? _day;
int? _time; int? _time;
Repeat? _repeat; Repeat? _repeat;
int? _reminder; Duration? _duration;
QuickAddToken? _reminderToken; QuickAddToken? _durationToken;
/// `!30m`'s minutes, each with its token.
final _reminders = <(int, QuickAddToken)>[];
/// `!1pm`'s day where it names one, and time.
final _reminderTimes = <(Day?, int)>[];
bool _has(QuickAddKind kind) => _tokens.any((token) => token.kind == kind); bool _has(QuickAddKind kind) => _tokens.any((token) => token.kind == kind);
bool _isIgnored(int start, int end) => bool _isIgnored(int start, int end) =>
_ignored.contains(_text.substring(start, end).toLowerCase()); _ignored.contains(_text.substring(start, end).toLowerCase());
void _take(QuickAddKind kind, int start, int end) { QuickAddToken _take(QuickAddKind kind, int start, int end) {
final token = QuickAddToken(kind, start, end); final token = QuickAddToken(kind, start, end);
_tokens.add(token); _tokens.add(token);
if (kind == QuickAddKind.reminder) {
_reminderToken = token;
}
_rest = _rest.replaceRange(start, end, ' ' * (end - start)); _rest = _rest.replaceRange(start, end, ' ' * (end - start));
return token;
} }
static bool _boundary(String text, int at) => static bool _boundary(String text, int at) =>
@ -104,6 +122,8 @@ class _Parser(
ParsedTask parse(List<NamedId> projects, List<NamedId> labels) { ParsedTask parse(List<NamedId> projects, List<NamedId> labels) {
_symbols(projects, labels); _symbols(projects, labels);
_reminderTimesIn();
_durationIn();
_phrases(); _phrases();
return _result(); return _result();
} }
@ -183,10 +203,97 @@ class _Parser(
for (final match in RegExp( for (final match in RegExp(
r'(?<=^|\s)!(\d{1,4})(m|min|h)(?=\s|$)', r'(?<=^|\s)!(\d{1,4})(m|min|h)(?=\s|$)',
).allMatches(_text)) { ).allMatches(_text)) {
if (_reminder == null && !_isIgnored(match.start, match.end)) { if (!_isIgnored(match.start, match.end)) {
final amount = int.parse(match.group(1)!); final amount = int.parse(match.group(1)!);
_reminder = match.group(2) == 'h' ? amount * 60 : amount; _reminders.add((
_take(QuickAddKind.reminder, match.start, match.end); match.group(2) == 'h' ? amount * 60 : amount,
_take(QuickAddKind.reminder, match.start, match.end),
));
}
}
}
/// Every `!` before a time, or before a day and a time (D18): `!1pm`,
/// `!13:00`, `!tomorrow 9am`, `!Monday at 8:30`.
void _reminderTimesIn() {
final words = _wordsIn(_rest);
for (var i = 0; i < words.length; i++) {
final word = words[i];
if (!word.text.startsWith('!') || word.text.length < 2) {
continue;
}
final stripped = [
...words.take(i),
_Word(word.text.substring(1), word.start + 1, word.end),
...words.skip(i + 1),
];
(Day?, int, int)? found;
if (_timeAt(stripped, i) case (final time, final length)) {
found = (null, time, length);
} else if (_dateAt(stripped, i) case (final day, final length)
when i + length < words.length) {
if (_timeAt(stripped, i + length) case (final time, final more)) {
found = (day, time, length + more);
}
}
if (found case (final day, final time, final length)) {
final end = words[i + length - 1].end;
if (!_isIgnored(word.start, end)) {
_reminderTimes.add((day, time));
_take(QuickAddKind.reminderTime, word.start, end);
i += length - 1;
}
}
}
}
/// The first duration (D18): `for 2h`, `for 1h30m`, `for 90 min`, `für
/// 1,5 Std.`, a day at most; a decimal comma or point taken, which the
/// words alone would split.
void _durationIn() {
final leads = {for (final words in _words) ...words.forWords};
final units = <String, int>{
for (final words in _words.reversed) ...words.durationUnits,
};
if (leads.isEmpty || units.isEmpty) {
return;
}
String either(Iterable<String> options) =>
(options.toList()..sort((a, b) => b.length.compareTo(a.length)))
.map(RegExp.escape)
.join('|');
final unit =
'(${either(units.keys)})'
r'\.?';
final pattern = RegExp(
r'(?<=^|\s)(?:'
'${either(leads)}'
r')\s+(\d+(?:[.,]\d+)?)\s*'
'$unit'
r'(?:\s*(\d+)\s*'
'$unit'
r')?(?=$|[\s,;!?])',
caseSensitive: false,
unicode: true,
);
for (final match in pattern.allMatches(_rest)) {
if (_isIgnored(match.start, match.end)) {
continue;
}
double minutesOf(String amount, String name) =>
double.parse(amount.replaceAll(',', '.')) *
units[name.toLowerCase()]!;
final minutes =
(minutesOf(match.group(1)!, match.group(2)!) +
switch ((match.group(3), match.group(4))) {
(final amount?, final name?) => minutesOf(amount, name),
_ => 0,
})
.round();
if (minutes > 0 && minutes <= 24 * 60) {
_duration = Duration(minutes: minutes);
_durationToken = _take(QuickAddKind.duration, match.start, match.end);
return;
} }
} }
} }
@ -387,9 +494,15 @@ class _Parser(
/// A day at [i]: today, tomorrow, a weekday, `next` one, the weekend, `in` /// A day at [i]: today, tomorrow, a weekday, `next` one, the weekend, `in`
/// a span, a month's day (either way round, with or without its year), /// a span, a month's day (either way round, with or without its year),
/// `2026-12-24` or `24.12.` (`24.12.2026`). /// `2026-12-24` or `24.12.` (`24.12.2026`); maybe after `on` (D18).
(Day, int)? _dateAt(List<_Word> words, int i) { (Day, int)? _dateAt(List<_Word> words, int i) {
final word = words[i].text; final word = words[i].text;
if (_in((words) => words.on, word) && i + 1 < words.length) {
return switch (_dateAt(words, i + 1)) {
(final day, final length) => (day, length + 1),
null => null,
};
}
String? after(int n) => i + n < words.length ? words[i + n].text : null; String? after(int n) => i + n < words.length ? words[i + n].text : null;
if (_in((words) => words.today, word)) { if (_in((words) => words.today, word)) {
return (_today, 1); return (_today, 1);
@ -546,26 +659,42 @@ class _Parser(
var day = _day; var day = _day;
var time = _time; var time = _time;
var reminders = const <int>[]; var reminders = const <int>[];
if (_reminder case final reminder?) { final before = {for (final (minutes, _) in _reminders) minutes};
if (before.isNotEmpty) {
if (time != null) { if (time != null) {
reminders = [reminder]; reminders = before.toList()..sort();
} else if (day == null) { } else if (day == null) {
final due = _now.add(Duration(minutes: reminder)); // Due at the latest, each reminding that long from now.
final latest = before.reduce((a, b) => a > b ? a : b);
final due = _now.add(Duration(minutes: latest));
day = Day.of(due); day = Day.of(due);
time = due.hour * 60 + due.minute; time = due.hour * 60 + due.minute;
reminders = [0]; reminders = [for (final minutes in before) latest - minutes]..sort();
} else { } else {
// A day without a time has nothing to be reminded before. // A day without a time has nothing to be reminded before.
_tokens.remove(_reminderToken); for (final (_, token) in _reminders) {
_tokens.remove(token);
}
} }
} }
if (time == null && _durationToken != null) {
// A duration lasts from a time; without one it is text (D18).
_tokens.remove(_durationToken);
}
final nowMinutes = _now.hour * 60 + _now.minute;
Day next(int time) => time > nowMinutes ? _today : _today.plus(1);
if (day == null && time != null) { if (day == null && time != null) {
final nowMinutes = _now.hour * 60 + _now.minute; day = next(time);
day = time > nowMinutes ? _today : _today.plus(1);
} }
if (_repeat case final repeat?) { if (_repeat case final repeat?) {
day = repeat.firstFrom(day ?? _today); day = repeat.firstFrom(day ?? _today);
} }
// A reminder's time without its day: the task's day, else the next to
// come (D18).
final reminderTimes = {
for (final (named, at) in _reminderTimes)
ReminderTime(named ?? day ?? next(at), at),
}.toList()..sort();
_tokens.sort((a, b) => a.start.compareTo(b.start)); _tokens.sort((a, b) => a.start.compareTo(b.start));
final title = StringBuffer(); final title = StringBuffer();
var from = 0; var from = 0;
@ -582,8 +711,16 @@ class _Parser(
newLabels: List.unmodifiable(_newLabels), newLabels: List.unmodifiable(_newLabels),
priority: _priority, priority: _priority,
deadline: _deadline, deadline: _deadline,
date: day == null ? null : TaskDate(day, time: time, repeat: _repeat), date: day == null
? null
: TaskDate(
day,
time: time,
duration: time == null ? null : _duration,
repeat: _repeat,
),
reminders: reminders, reminders: reminders,
reminderTimes: reminderTimes,
); );
} }
} }

View file

@ -3,7 +3,8 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
/// The words quick add reads dates, times and recurrences in, one table a /// The words quick add reads dates, times and recurrences in, one table a
/// language (ADR 0033, D15): a new app language adds a table here, not a /// language (ADR 0033, D15): a new app language adds a table here, not a
/// parser. Quick add reads the app's language and English together; the /// parser. Quick add reads the app's language and English together; the
/// symbols (`#`, `@`, `{}`, `!!`, `p1`, `!30m`) hold in every one. Every /// symbols (`#`, `@`, `{}`, `!!`, `p1`, `!30m`, `!` before a time) hold
/// in every one. Every
/// word is lower case, as the text is compared folded to it. /// word is lower case, as the text is compared folded to it.
final class const QuickAddWords({ final class const QuickAddWords({
/// Today, and the words for it. /// Today, and the words for it.
@ -49,6 +50,15 @@ final class const QuickAddWords({
/// What may stand before a time: `at 7pm`. /// What may stand before a time: `at 7pm`.
required final Set<String> at, required final Set<String> at,
/// What may stand before a day: `on Monday`, `on 1st March`.
final Set<String> on = const {},
/// What starts a duration: `for 2h`.
final Set<String> forWords = const {},
/// A duration's units, each to its minutes: `h` to 60, `min` to 1.
final Map<String, int> durationUnits = const {},
/// What joins weekdays: `mon and fri`. /// What joins weekdays: `mon and fri`.
required final Set<String> and, required final Set<String> and,
@ -154,6 +164,20 @@ final class const QuickAddWords({
next: {'next'}, next: {'next'},
inWords: {'in'}, inWords: {'in'},
at: {'at'}, at: {'at'},
on: {'on'},
forWords: {'for'},
durationUnits: {
'h': 60,
'hr': 60,
'hrs': 60,
'hour': 60,
'hours': 60,
'm': 1,
'min': 1,
'mins': 1,
'minute': 1,
'minutes': 1,
},
and: {'and', '&'}, and: {'and', '&'},
meridiem: {'am': 0, 'pm': 12}, meridiem: {'am': 0, 'pm': 12},
daySuffixes: {'st', 'nd', 'rd', 'th'}, daySuffixes: {'st', 'nd', 'rd', 'th'},
@ -235,6 +259,17 @@ final class const QuickAddWords({
next: {'nächsten', 'nächste', 'nächster', 'kommenden', 'kommende'}, next: {'nächsten', 'nächste', 'nächster', 'kommenden', 'kommende'},
inWords: {'in'}, inWords: {'in'},
at: {'um'}, at: {'um'},
on: {'am'},
forWords: {'für'},
durationUnits: {
'h': 60,
'std': 60,
'stunde': 60,
'stunden': 60,
'min': 1,
'minute': 1,
'minuten': 1,
},
and: {'und'}, and: {'und'},
oClock: {'uhr'}, oClock: {'uhr'},
daySuffixes: {'.'}, daySuffixes: {'.'},

View file

@ -32,8 +32,9 @@ import 'package:material_ui/material_ui.dart';
/// It shows the title alone at first, its chips once typing starts, and its /// It shows the title alone at first, its chips once typing starts, and its
/// description from its + or the down arrow (the developer's, 1 Nov, D15). /// description from its + or the down arrow (the developer's, 1 Nov, D15).
/// What the title says is read as it is typed (`parseQuickAdd`): `#project`, /// What the title says is read as it is typed (`parseQuickAdd`): `#project`,
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m`, dates, times and /// `@label`, `{deadline}`, `!!1`, `p1`, `!30m` (as many as typed), `!1pm`,
/// recurrences in the app's language and English, each highlighted and /// dates, times, durations and recurrences in the app's language and
/// English, each highlighted and
/// shown on its chip; a tap on one keeps it as text, and a chip picked /// shown on its chip; a tap on one keeps it as text, and a chip picked
/// takes its token out of the title. As `#` or `@` is typed, the projects /// takes its token out of the title. As `#` or `@` is typed, the projects
/// or labels it may mean are offered below the title, one tapped /// or labels it may mean are offered below the title, one tapped
@ -320,6 +321,7 @@ class _QuickAddState() extends State<_QuickAdd> {
priority: parsed.priority ?? _priority, priority: parsed.priority ?? _priority,
labelIds: {..._labelIds, ...parsed.labelIds, ...made}.toList(), labelIds: {..._labelIds, ...parsed.labelIds, ...made}.toList(),
reminders: parsed.reminders, reminders: parsed.reminders,
reminderTimes: parsed.reminderTimes,
), ),
); );
} on KeysMissing { } on KeysMissing {
@ -473,6 +475,7 @@ class _QuickAddState() extends State<_QuickAdd> {
QuickAddKind.date, QuickAddKind.date,
QuickAddKind.time, QuickAddKind.time,
QuickAddKind.repeat, QuickAddKind.repeat,
QuickAddKind.duration,
QuickAddKind.reminder, QuickAddKind.reminder,
}); });
setState(() => _date = date); setState(() => _date = date);

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', () { group('tokens', () {
test('each says where it is in the text and what it is', () { test('each says where it is in the text and what it is', () {
const text = 'Call #Shopping tomorrow p1 @errand'; const text = 'Call #Shopping tomorrow p1 @errand';