feat(todo): a task holds reminders at a time of their own, and keeps the fields it doesn't know

ReminderTime is a day and minutes after midnight, the wall clock without
a zone as TaskDate's; a task's reminderTimes are written as
[{"day", "time"}] and left out where there are none, so older payloads
read with none. The payload's schema stays 1 (as D17's view): what a
newer version wrote at its top that this one doesn't read is kept in
TaskContent.unknown and written back beneath the fields it knows, so a
task changed here keeps it (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:37 +01:00
commit 3c68a22a5f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 155 additions and 2 deletions

View file

@ -4,15 +4,42 @@ import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// The schema of a task's payload this app writes and reads.
/// The schema of a task's payload this app writes and reads. It stays 1
/// as fields are added (ADR 0033, D17, D18): an older version reads a task
/// with them, and keeps them as it writes the task (its
/// [TaskContent.unknown]).
const taskSchemaVersion = 1;
/// The fields of a task's payload this version reads; any other is kept as
/// it was (D18).
const _known = {
'schemaVersion',
'title',
'projectId',
'description',
'sectionId',
'parentId',
'position',
'date',
'deadline',
'priority',
'labelIds',
'reminders',
'reminderTimes',
'completedAt',
'completions',
'hideDoneSubTasks',
};
/// [content] as its row's payload, before sealing: UTF-8 JSON, days as
/// `YYYY-MM-DD`, a time as minutes after midnight, a duration in minutes,
/// the priority as its number, when it was done in UTC; a recurrence (D7)
/// within its date, by its unit's name and only what differs from its
/// defaults.
/// defaults; reminders at a time of their own as a day and minutes (D18);
/// what a newer version wrote and this one doesn't know as it was, beneath
/// what this one writes.
ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
...content.unknown,
'title': content.title,
'projectId': content.projectId,
if (content.description.isNotEmpty) 'description': content.description,
@ -30,6 +57,11 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
if (content.priority != Priority.none) 'priority': content.priority.number,
if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds,
if (content.reminders.isNotEmpty) 'reminders': content.reminders,
if (content.reminderTimes.isNotEmpty)
'reminderTimes': [
for (final at in content.reminderTimes)
{'day': '${at.day}', 'time': at.time},
],
if (content.completedAt case final at?)
'completedAt': at.toUtc().toIso8601String(),
if (content.completions.isNotEmpty)
@ -90,14 +122,34 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
_ => throw const FormatException('reminders'),
},
],
reminderTimes: [
for (final at in json['reminderTimes'] as List<Object?>? ?? const [])
_reminderTime(at),
],
completions: [
for (final at in json['completions'] as List<Object?>? ?? const [])
DateTime.parse(at! as String).toLocal(),
],
hideDoneSubTasks: json['hideDoneSubTasks'] as bool? ?? false,
unknown: {
for (final MapEntry(:key, :value) in json.entries)
if (!_known.contains(key)) key: value,
},
),
);
/// A reminder at a time of its own as [encodeTask] wrote it; throws for
/// one that isn't a day and a time within it.
ReminderTime _reminderTime(Object? json) {
if (json case {'day': final String day, 'time': final int time}
when time >= 0 && time < 24 * 60) {
if (Day.tryParse(day) case final day?) {
return ReminderTime(day, time);
}
}
throw const FormatException('reminderTimes');
}
TaskDate _date(Map<String, Object?> json) {
final day = Day.tryParse(json['day']! as String);
final time = json['time'] as int?;

View file

@ -92,6 +92,26 @@ final class const TaskDate(
List<Object?> get props => [day, time, duration, repeat];
}
/// A reminder at a moment of its own (ADR 0033, D18): on [day] at [time]
/// (minutes after midnight), the person's wall clock as [TaskDate]'s, so
/// it comes at that hour wherever the device is; apart from the task's
/// date, which it neither needs nor follows.
final class const ReminderTime(final Day day, final int time)
with Equatable
implements Comparable<ReminderTime> {
/// The moment it comes, in the device's local time.
DateTime get at => DateTime(day.year, day.month, day.day, 0, time);
@override
int compareTo(ReminderTime other) => switch (day.compareTo(other.day)) {
0 => time.compareTo(other.time),
final order => order,
};
@override
List<Object?> get props => [day, time];
}
/// How urgent a task is (D6): [p1] the most, [none] not marked.
enum Priority() {
p1,
@ -143,6 +163,10 @@ final class const TaskContent({
/// (D5); none for a date without a time.
final List<int> reminders = const [],
/// Its reminders at moments of their own (D18), the earliest first,
/// whatever its date.
final List<ReminderTime> reminderTimes = const [],
/// When it was done; none: not yet.
final DateTime? completedAt,
@ -154,6 +178,11 @@ final class const TaskContent({
/// developer's, 1 Nov: its menu's Hide completed sub-tasks, as Todoist
/// keeps it per task, on every device).
final bool hideDoneSubTasks = false,
/// What its payload holds that this version doesn't know (D18): written
/// back as it was, so that a task changed here keeps what a newer version
/// gave it.
final Map<String, Object?> unknown = const {},
}) with Equatable {
/// Whether it is done.
bool get completed => completedAt != null;
@ -170,6 +199,7 @@ final class const TaskContent({
Priority? priority,
List<String>? labelIds,
List<int>? reminders,
List<ReminderTime>? reminderTimes,
DateTime? Function()? completedAt,
List<DateTime>? completions,
bool? hideDoneSubTasks,
@ -185,9 +215,11 @@ final class const TaskContent({
priority: priority ?? this.priority,
labelIds: labelIds ?? this.labelIds,
reminders: reminders ?? this.reminders,
reminderTimes: reminderTimes ?? this.reminderTimes,
completedAt: completedAt == null ? this.completedAt : completedAt(),
completions: completions ?? this.completions,
hideDoneSubTasks: hideDoneSubTasks ?? this.hideDoneSubTasks,
unknown: unknown,
);
@override
@ -203,9 +235,11 @@ final class const TaskContent({
priority,
labelIds,
reminders,
reminderTimes,
completedAt,
completions,
hideDoneSubTasks,
unknown,
];
}

View file

@ -43,6 +43,10 @@ void main() {
priority: Priority.p2,
labelIds: const ['l1', 'l2'],
reminders: const [30, 0],
reminderTimes: const [
ReminderTime(Day(2026, 11, 1), 20 * 60),
ReminderTime(Day(2026, 11, 2), 6 * 60),
],
completedAt: DateTime(2026, 11, 2, 7),
completions: [DateTime(2026, 10, 19, 7, 5), DateTime(2026, 10, 22, 6)],
hideDoneSubTasks: true,
@ -80,6 +84,10 @@ void main() {
'priority': 2,
'labelIds': ['l1', 'l2'],
'reminders': [30, 0],
'reminderTimes': [
{'day': '2026-11-01', 'time': 1200},
{'day': '2026-11-02', 'time': 360},
],
'completedAt': DateTime(2026, 11, 2, 7).toUtc().toIso8601String(),
'completions': [
DateTime(2026, 10, 19, 7, 5).toUtc().toIso8601String(),
@ -136,6 +144,16 @@ void main() {
'date': {'day': '2026-11-02', 'repeat': repeat},
},
{...base, 'date': 'today'},
for (final at in [
{'day': '2026-11-02'},
{'day': 'soon', 'time': 60},
{'day': '2026-11-02', 'time': 1440},
'2026-11-02 13:00',
])
{
...base,
'reminderTimes': [at],
},
]) {
expect(decodeTask(json(broken)), isNull, reason: '$broken');
}
@ -159,6 +177,55 @@ void main() {
}
});
test('of the schema before reminders at a time of their own reads with '
'none (D18)', () {
final older = decodeTask(
json({
'schemaVersion': 1,
'title': 'A',
'projectId': 'p',
'date': {'day': '2026-11-02', 'time': 540},
'reminders': [30],
}),
);
expect(older?.reminders, [30]);
expect(older?.reminderTimes, isEmpty);
expect(older?.unknown, isEmpty);
});
test('what a newer version wrote that this one doesn’t know is kept, '
'and written back as it was, however the task changes here (D18)', () {
final newer = {
'schemaVersion': 1,
'title': 'A',
'projectId': 'p',
'location': {'lat': 52.5, 'lon': 13.4},
'note': 'kept',
};
final read = decodeTask(json(newer))!;
expect(read.unknown, {
'location': {'lat': 52.5, 'lon': 13.4},
'note': 'kept',
});
final renamed = read.copyWith(title: 'B');
expect(written(encodeTask(renamed)), {
...newer,
'title': 'B',
'position': 0.0,
});
expect(decodeTask(encodeTask(renamed)), renamed);
});
test('a field it knows wins over one kept as unknown of the same name', () {
const task = TaskContent(
title: 'A',
projectId: 'p',
unknown: {'title': 'stale', 'extra': 1},
);
expect(written(encodeTask(task))['title'], 'A');
expect(written(encodeTask(task))['extra'], 1);
});
test('a priority it doesn’t know is none', () {
expect(
decodeTask(