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:
parent
3c68a22a5f
commit
d40eca6e50
4 changed files with 422 additions and 24 deletions
|
|
@ -9,9 +9,17 @@ enum QuickAddKind() {
|
|||
label,
|
||||
deadline,
|
||||
priority,
|
||||
|
||||
/// `!30m`: so long before the task's time.
|
||||
reminder,
|
||||
|
||||
/// `!1pm`, `!tomorrow 9am`: at a time of its own (D18).
|
||||
reminderTime,
|
||||
date,
|
||||
time,
|
||||
|
||||
/// `for 2h` (D18).
|
||||
duration,
|
||||
repeat,
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +49,9 @@ final class const ParsedTask({
|
|||
|
||||
/// Reminders, minutes before the task's time, as [TaskContent.reminders].
|
||||
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.
|
||||
|
|
@ -50,9 +61,12 @@ typedef NamedId = ({String id, String name});
|
|||
/// `@label` by their names (the longest matching, the case aside; an
|
||||
/// 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
|
||||
/// long before its time), and dates, times and recurrences in [words]. Only
|
||||
/// the first of each kind counts. A token whose text, in lower case, is
|
||||
/// among [ignored] (tapped off by the person) stays text.
|
||||
/// long before its time), `!1pm` and `!tomorrow 9am` (reminded then, D18),
|
||||
/// and dates (`on` before one taken with it), times, durations (`for 2h`,
|
||||
/// 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(
|
||||
String text, {
|
||||
required DateTime now,
|
||||
|
|
@ -82,21 +96,25 @@ class _Parser(
|
|||
Day? _day;
|
||||
int? _time;
|
||||
Repeat? _repeat;
|
||||
int? _reminder;
|
||||
QuickAddToken? _reminderToken;
|
||||
Duration? _duration;
|
||||
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 _isIgnored(int start, int end) =>
|
||||
_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);
|
||||
_tokens.add(token);
|
||||
if (kind == QuickAddKind.reminder) {
|
||||
_reminderToken = token;
|
||||
}
|
||||
_rest = _rest.replaceRange(start, end, ' ' * (end - start));
|
||||
return token;
|
||||
}
|
||||
|
||||
static bool _boundary(String text, int at) =>
|
||||
|
|
@ -104,6 +122,8 @@ class _Parser(
|
|||
|
||||
ParsedTask parse(List<NamedId> projects, List<NamedId> labels) {
|
||||
_symbols(projects, labels);
|
||||
_reminderTimesIn();
|
||||
_durationIn();
|
||||
_phrases();
|
||||
return _result();
|
||||
}
|
||||
|
|
@ -183,10 +203,97 @@ class _Parser(
|
|||
for (final match in RegExp(
|
||||
r'(?<=^|\s)!(\d{1,4})(m|min|h)(?=\s|$)',
|
||||
).allMatches(_text)) {
|
||||
if (_reminder == null && !_isIgnored(match.start, match.end)) {
|
||||
if (!_isIgnored(match.start, match.end)) {
|
||||
final amount = int.parse(match.group(1)!);
|
||||
_reminder = match.group(2) == 'h' ? amount * 60 : amount;
|
||||
_take(QuickAddKind.reminder, match.start, match.end);
|
||||
_reminders.add((
|
||||
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 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) {
|
||||
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;
|
||||
if (_in((words) => words.today, word)) {
|
||||
return (_today, 1);
|
||||
|
|
@ -546,26 +659,42 @@ class _Parser(
|
|||
var day = _day;
|
||||
var time = _time;
|
||||
var reminders = const <int>[];
|
||||
if (_reminder case final reminder?) {
|
||||
final before = {for (final (minutes, _) in _reminders) minutes};
|
||||
if (before.isNotEmpty) {
|
||||
if (time != null) {
|
||||
reminders = [reminder];
|
||||
reminders = before.toList()..sort();
|
||||
} 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);
|
||||
time = due.hour * 60 + due.minute;
|
||||
reminders = [0];
|
||||
reminders = [for (final minutes in before) latest - minutes]..sort();
|
||||
} else {
|
||||
// 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) {
|
||||
final nowMinutes = _now.hour * 60 + _now.minute;
|
||||
day = time > nowMinutes ? _today : _today.plus(1);
|
||||
day = next(time);
|
||||
}
|
||||
if (_repeat case final repeat?) {
|
||||
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));
|
||||
final title = StringBuffer();
|
||||
var from = 0;
|
||||
|
|
@ -582,8 +711,16 @@ class _Parser(
|
|||
newLabels: List.unmodifiable(_newLabels),
|
||||
priority: _priority,
|
||||
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,
|
||||
reminderTimes: reminderTimes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 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
|
||||
/// 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.
|
||||
final class const QuickAddWords({
|
||||
/// Today, and the words for it.
|
||||
|
|
@ -49,6 +50,15 @@ final class const QuickAddWords({
|
|||
/// What may stand before a time: `at 7pm`.
|
||||
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`.
|
||||
required final Set<String> and,
|
||||
|
||||
|
|
@ -154,6 +164,20 @@ final class const QuickAddWords({
|
|||
next: {'next'},
|
||||
inWords: {'in'},
|
||||
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', '&'},
|
||||
meridiem: {'am': 0, 'pm': 12},
|
||||
daySuffixes: {'st', 'nd', 'rd', 'th'},
|
||||
|
|
@ -235,6 +259,17 @@ final class const QuickAddWords({
|
|||
next: {'nächsten', 'nächste', 'nächster', 'kommenden', 'kommende'},
|
||||
inWords: {'in'},
|
||||
at: {'um'},
|
||||
on: {'am'},
|
||||
forWords: {'für'},
|
||||
durationUnits: {
|
||||
'h': 60,
|
||||
'std': 60,
|
||||
'stunde': 60,
|
||||
'stunden': 60,
|
||||
'min': 1,
|
||||
'minute': 1,
|
||||
'minuten': 1,
|
||||
},
|
||||
and: {'und'},
|
||||
oClock: {'uhr'},
|
||||
daySuffixes: {'.'},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 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`,
|
||||
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m`, dates, times and
|
||||
/// recurrences in the app's language and English, each highlighted and
|
||||
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m` (as many as typed), `!1pm`,
|
||||
/// 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
|
||||
/// 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
|
||||
|
|
@ -320,6 +321,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
|||
priority: parsed.priority ?? _priority,
|
||||
labelIds: {..._labelIds, ...parsed.labelIds, ...made}.toList(),
|
||||
reminders: parsed.reminders,
|
||||
reminderTimes: parsed.reminderTimes,
|
||||
),
|
||||
);
|
||||
} on KeysMissing {
|
||||
|
|
@ -473,6 +475,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
|||
QuickAddKind.date,
|
||||
QuickAddKind.time,
|
||||
QuickAddKind.repeat,
|
||||
QuickAddKind.duration,
|
||||
QuickAddKind.reminder,
|
||||
});
|
||||
setState(() => _date = date);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue