feat(todo): recurring tasks, set through a picker (ADR 0033, D7)

A task's date may recur: every n days, weeks (on which weekdays),
months (on its day, or its nth or last weekday), years, from its date
or from when it is done, until a day or for n times. Done, it moves on
to the next day after both its date and today, its deadline as far, its
time kept, its done sub-tasks open again and when it was done kept in
its payload; undo writes all of it back. The date sheet offers the rules
that fit the task's day and a rule of the person's own; rows show a
recurring date with its own icon, the task sheet the rule in words.

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 12:15:51 +01:00
commit aa013f5a00
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 1655 additions and 25 deletions

View file

@ -8,8 +8,10 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_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_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../support/memory_content_key_gateway.dart';
@ -382,6 +384,54 @@ void main() {
expect(title.style?.decoration, TextDecoration.lineThrough);
});
testWidgets('a task set to repeat every day, done, comes again '
'tomorrow, when it was done kept', (tester) async {
final today = Day.of(DateTime.now());
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Stretch',
projectId: inbox(),
date: TaskDate(today),
),
);
await goTab(tester, 'Inbox');
await tester.tap(find.text('Stretch'));
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithIcon(ListTile, LucideIcons.calendar).last,
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Repeat'));
await tester.pumpAndSettle();
await tester.tap(find.text('Every day'));
await tester.pumpAndSettle();
expect(find.text('Every day'), findsOneWidget, reason: 'in the sheet');
await tester.tap(find.byTooltip('Close').last);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.widgetWithText(ListTile, 'Stretch'),
matching: find.byIcon(LucideIcons.repeat),
),
findsOneWidget,
);
await tester.tap(find.byTooltip('Mark as done'));
await tester.pumpAndSettle();
expect(find.text('Stretch'), findsOneWidget, reason: 'still to do');
expect(find.text('Tomorrow'), findsOneWidget);
final stored = (await tester.runAsync(() => tasks().all()))!.single;
expect(
stored.content?.date,
TaskDate(today.plus(1), repeat: const Repeat(RepeatUnit.day)),
);
expect(stored.content?.completions, hasLength(1));
expect(stored.content?.completed, isFalse);
});
testWidgets('Today puts what is overdue first; Reschedule moves it to '
'the day picked', (tester) async {
final today = Day.of(DateTime.now());

View file

@ -0,0 +1,163 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// A recurrence (ADR 0033, D7): the day a task done moves on to, counted
/// from its date or from the day it was done, and when it stops.
void main() {
// Monday 2 Nov 2026.
const monday = Day(2026, 11, 2);
group('counted from its date', () {
test('every n days, weeks, months and years', () {
expect(
const Repeat(RepeatUnit.day).next(monday, today: monday),
const Day(2026, 11, 3),
);
expect(
const Repeat(RepeatUnit.day, every: 3).next(monday, today: monday),
const Day(2026, 11, 5),
);
expect(
const Repeat(RepeatUnit.week, every: 2).next(monday, today: monday),
const Day(2026, 11, 16),
);
expect(
const Repeat(RepeatUnit.month).next(monday, today: monday),
const Day(2026, 12, 2),
);
expect(
const Repeat(RepeatUnit.year).next(monday, today: monday),
const Day(2027, 11, 2),
);
});
test('on given weekdays, every week or every other one', () {
const weekdays = Repeat(
RepeatUnit.week,
weekdays: {DateTime.monday, DateTime.wednesday, DateTime.friday},
);
expect(weekdays.next(monday, today: monday), const Day(2026, 11, 4));
expect(
weekdays.next(const Day(2026, 11, 6), today: monday),
const Day(2026, 11, 9),
);
const otherWeek = Repeat(
RepeatUnit.week,
every: 2,
weekdays: {DateTime.monday, DateTime.thursday},
);
expect(otherWeek.next(monday, today: monday), const Day(2026, 11, 5));
// The week after is skipped: Monday 16th, not the 9th.
expect(
otherWeek.next(const Day(2026, 11, 5), today: monday),
const Day(2026, 11, 16),
);
});
test("on a month's day, kept through shorter months", () {
const lastDay = Repeat(RepeatUnit.month, monthDay: 31);
final february = lastDay.next(const Day(2027, 1, 31), today: monday);
expect(february, const Day(2027, 2, 28));
expect(lastDay.next(february!, today: monday), const Day(2027, 3, 31));
const leap = Repeat(RepeatUnit.year, monthDay: 29);
expect(
leap.next(const Day(2028, 2, 29), today: monday),
const Day(2029, 2, 28),
);
});
test("on a month's nth or last weekday", () {
// The first Monday of November 2026 is the 2nd.
const first = Repeat(RepeatUnit.month, monthWeek: 1);
expect(first.next(monday, today: monday), const Day(2026, 12, 7));
const last = Repeat(RepeatUnit.month, monthWeek: -1);
expect(
last.next(const Day(2026, 11, 30), today: monday),
const Day(2026, 12, 28),
);
});
test('past today where it was overdue, never on a day already done', () {
const daily = Repeat(RepeatUnit.day);
expect(
daily.next(const Day(2026, 10, 28), today: monday),
const Day(2026, 11, 3),
);
const weekly = Repeat(RepeatUnit.week);
expect(
weekly.next(const Day(2026, 10, 19), today: monday),
const Day(2026, 11, 9),
);
// Done early, it moves on from its own date.
expect(
daily.next(const Day(2026, 11, 5), today: monday),
const Day(2026, 11, 6),
);
});
});
test('counted from the day it was done, its date aside', () {
const fromDone = Repeat(RepeatUnit.day, every: 3, fromCompletion: true);
expect(
fromDone.next(const Day(2026, 10, 20), today: monday),
const Day(2026, 11, 5),
);
expect(
const Repeat(
RepeatUnit.month,
fromCompletion: true,
).next(const Day(2026, 12, 25), today: monday),
const Day(2026, 12, 2),
);
});
test('stops after its last day or its last time', () {
const until = Repeat(RepeatUnit.week, until: Day(2026, 11, 12));
expect(until.next(monday, today: monday), const Day(2026, 11, 9));
expect(until.next(const Day(2026, 11, 9), today: monday), isNull);
const twice = Repeat(RepeatUnit.day, left: 1);
expect(twice.next(monday, today: monday), const Day(2026, 11, 3));
expect(twice.counted(), const Repeat(RepeatUnit.day, left: 0));
expect(twice.counted().next(monday, today: monday), isNull);
expect(
const Repeat(RepeatUnit.day).counted(),
const Repeat(RepeatUnit.day),
);
});
test('starts on the first day it falls on', () {
const weekdays = Repeat(
RepeatUnit.week,
weekdays: {
DateTime.monday,
DateTime.tuesday,
DateTime.wednesday,
DateTime.thursday,
DateTime.friday,
},
);
// Saturday 7th: Monday 9th.
expect(weekdays.firstFrom(const Day(2026, 11, 7)), const Day(2026, 11, 9));
expect(weekdays.firstFrom(monday), monday);
expect(
const Repeat(RepeatUnit.month, monthWeek: -1).firstFrom(monday),
const Day(2026, 11, 30),
);
expect(
const Repeat(RepeatUnit.month, monthDay: 15).firstFrom(monday),
const Day(2026, 11, 15),
);
expect(
const Repeat(RepeatUnit.month, monthDay: 1).firstFrom(monday),
const Day(2026, 12, 1),
);
});
test("the nth and last weekday of a day's month", () {
expect(monday.weekOfMonth, 1);
expect(const Day(2026, 11, 30).weekOfMonth, 5);
expect(const Day(2026, 11, 30).lastWeekOfMonth, isTrue);
expect(const Day(2026, 11, 23).lastWeekOfMonth, isFalse);
});
}

View file

@ -7,6 +7,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.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_ui/halkyon_ui.dart';
@ -31,11 +32,18 @@ void main() {
Day(2026, 11, 2),
time: 6 * 60 + 30,
duration: Duration(minutes: 30),
repeat: Repeat(
RepeatUnit.week,
every: 2,
weekdays: {DateTime.monday, DateTime.thursday},
until: Day(2027, 3, 1),
),
),
deadline: const Day(2026, 11, 8),
priority: Priority.p2,
labelIds: const ['l1', 'l2'],
completedAt: DateTime(2026, 11, 2, 7),
completions: [DateTime(2026, 10, 19, 7, 5), DateTime(2026, 10, 22, 6)],
);
group('a task', () {
@ -55,11 +63,25 @@ void main() {
'sectionId': 's',
'parentId': 't',
'position': 3.0,
'date': {'day': '2026-11-02', 'time': 390, 'minutes': 30},
'date': {
'day': '2026-11-02',
'time': 390,
'minutes': 30,
'repeat': {
'unit': 'week',
'every': 2,
'weekdays': [1, 4],
'until': '2027-03-01',
},
},
'deadline': '2026-11-08',
'priority': 2,
'labelIds': ['l1', 'l2'],
'completedAt': DateTime(2026, 11, 2, 7).toUtc().toIso8601String(),
'completions': [
DateTime(2026, 10, 19, 7, 5).toUtc().toIso8601String(),
DateTime(2026, 10, 22, 6).toUtc().toIso8601String(),
],
});
expect(
written(encodeTask(const TaskContent(title: 'Milk', projectId: 'p'))),
@ -89,6 +111,26 @@ void main() {
'date': {'day': '2026-11-02', 'time': 1440},
},
{...base, 'deadline': 'soon'},
{
...base,
'completions': ['yesterday'],
},
for (final repeat in [
{'unit': 'fortnight'},
{'unit': 'day', 'every': 0},
{
'unit': 'week',
'weekdays': [8],
},
{'unit': 'month', 'monthDay': 32},
{'unit': 'month', 'monthWeek': 5},
{'unit': 'day', 'left': -1},
{'unit': 'day', 'until': 'never'},
])
{
...base,
'date': {'day': '2026-11-02', 'repeat': repeat},
},
{...base, 'date': 'today'},
]) {
expect(decodeTask(json(broken)), isNull, reason: '$broken');
@ -96,6 +138,23 @@ void main() {
expect(decodeTask(ByteData(0)), isNull);
});
test('recurring in every way reads back as it was written', () {
for (final repeat in const [
Repeat(RepeatUnit.day),
Repeat(RepeatUnit.day, every: 3, fromCompletion: true, left: 4),
Repeat(RepeatUnit.month, monthDay: 31),
Repeat(RepeatUnit.month, monthWeek: -1),
Repeat(RepeatUnit.year, every: 2),
]) {
final task = TaskContent(
title: 'A',
projectId: 'p',
date: const TaskDate(Day(2026, 11, 2)).repeating(repeat),
);
expect(decodeTask(encodeTask(task)), task, reason: '$repeat');
}
});
test('a priority it doesn’t know is none', () {
expect(
decodeTask(

View file

@ -10,6 +10,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/model/label.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/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 '../../support/memory_content_key_gateway.dart';
@ -80,6 +81,79 @@ void main() {
expect(await phone.all(), isEmpty);
});
test('a recurring task done moves on with its deadline, its time kept and '
'its sub-tasks open again; undone, all of it is as it was', () async {
final phone = await device(alice);
final weekly = milk.copyWith(
date: () => milk.date!.repeating(
const Repeat(RepeatUnit.week, left: 2),
),
deadline: () => const Day(2026, 11, 3),
);
final id = await phone.add(weekly);
final doneAt = DateTime(2026, 10, 30);
final sub = TaskContent(
title: 'Oat',
projectId: 'inbox',
parentId: id,
completedAt: doneAt,
);
final subId = await phone.add(sub);
final other = await phone.add(milk.copyWith(completedAt: () => doneAt));
final at = DateTime(2026, 11, 2, 9, 30);
final before = await withClock(
Clock.fixed(at),
() => phone.complete(StoredTask(id, weekly)),
);
final after = {for (final t in await phone.all()) t.id: t.content};
expect(
after[id],
weekly.copyWith(
date: () => const TaskDate(
Day(2026, 11, 9),
time: 9 * 60,
repeat: Repeat(RepeatUnit.week, left: 1),
),
deadline: () => const Day(2026, 11, 10),
completions: [at],
),
);
expect(after[subId]?.completed, isFalse);
expect(after[other]?.completed, isTrue);
await phone.restore(before);
expect(
{for (final t in await phone.all()) t.id: t.content},
{
id: weekly,
subId: sub,
other: milk.copyWith(completedAt: () => doneAt),
},
);
});
test('a recurring task done its last time is done, its sub-tasks as they '
'were', () async {
final phone = await device(alice);
final last = milk.copyWith(
date: () => milk.date!.repeating(
const Repeat(RepeatUnit.day, until: Day(2026, 11, 2)),
),
);
final id = await phone.add(last);
final at = DateTime(2026, 11, 2, 9, 30);
await withClock(
Clock.fixed(at),
() => phone.complete(StoredTask(id, last)),
);
expect(
(await phone.all()).single.content,
last.copyWith(completedAt: () => at),
);
});
test(
'deleting a task deletes its sub-tasks and theirs, and no other',
() async {