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:
parent
8565db3d89
commit
3c68a22a5f
3 changed files with 155 additions and 2 deletions
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue