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/repeat.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.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;
|
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
|
/// [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,
|
/// `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)
|
/// 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
|
/// 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, {
|
ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
||||||
|
...content.unknown,
|
||||||
'title': content.title,
|
'title': content.title,
|
||||||
'projectId': content.projectId,
|
'projectId': content.projectId,
|
||||||
if (content.description.isNotEmpty) 'description': content.description,
|
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.priority != Priority.none) 'priority': content.priority.number,
|
||||||
if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds,
|
if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds,
|
||||||
if (content.reminders.isNotEmpty) 'reminders': content.reminders,
|
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?)
|
if (content.completedAt case final at?)
|
||||||
'completedAt': at.toUtc().toIso8601String(),
|
'completedAt': at.toUtc().toIso8601String(),
|
||||||
if (content.completions.isNotEmpty)
|
if (content.completions.isNotEmpty)
|
||||||
|
|
@ -90,14 +122,34 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
|
||||||
_ => throw const FormatException('reminders'),
|
_ => throw const FormatException('reminders'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
reminderTimes: [
|
||||||
|
for (final at in json['reminderTimes'] as List<Object?>? ?? const [])
|
||||||
|
_reminderTime(at),
|
||||||
|
],
|
||||||
completions: [
|
completions: [
|
||||||
for (final at in json['completions'] as List<Object?>? ?? const [])
|
for (final at in json['completions'] as List<Object?>? ?? const [])
|
||||||
DateTime.parse(at! as String).toLocal(),
|
DateTime.parse(at! as String).toLocal(),
|
||||||
],
|
],
|
||||||
hideDoneSubTasks: json['hideDoneSubTasks'] as bool? ?? false,
|
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) {
|
TaskDate _date(Map<String, Object?> json) {
|
||||||
final day = Day.tryParse(json['day']! as String);
|
final day = Day.tryParse(json['day']! as String);
|
||||||
final time = json['time'] as int?;
|
final time = json['time'] as int?;
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,26 @@ final class const TaskDate(
|
||||||
List<Object?> get props => [day, time, duration, repeat];
|
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.
|
/// How urgent a task is (D6): [p1] the most, [none] not marked.
|
||||||
enum Priority() {
|
enum Priority() {
|
||||||
p1,
|
p1,
|
||||||
|
|
@ -143,6 +163,10 @@ final class const TaskContent({
|
||||||
/// (D5); none for a date without a time.
|
/// (D5); none for a date without a time.
|
||||||
final List<int> reminders = const [],
|
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.
|
/// When it was done; none: not yet.
|
||||||
final DateTime? completedAt,
|
final DateTime? completedAt,
|
||||||
|
|
||||||
|
|
@ -154,6 +178,11 @@ final class const TaskContent({
|
||||||
/// developer's, 1 Nov: its menu's Hide completed sub-tasks, as Todoist
|
/// developer's, 1 Nov: its menu's Hide completed sub-tasks, as Todoist
|
||||||
/// keeps it per task, on every device).
|
/// keeps it per task, on every device).
|
||||||
final bool hideDoneSubTasks = false,
|
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 {
|
}) with Equatable {
|
||||||
/// Whether it is done.
|
/// Whether it is done.
|
||||||
bool get completed => completedAt != null;
|
bool get completed => completedAt != null;
|
||||||
|
|
@ -170,6 +199,7 @@ final class const TaskContent({
|
||||||
Priority? priority,
|
Priority? priority,
|
||||||
List<String>? labelIds,
|
List<String>? labelIds,
|
||||||
List<int>? reminders,
|
List<int>? reminders,
|
||||||
|
List<ReminderTime>? reminderTimes,
|
||||||
DateTime? Function()? completedAt,
|
DateTime? Function()? completedAt,
|
||||||
List<DateTime>? completions,
|
List<DateTime>? completions,
|
||||||
bool? hideDoneSubTasks,
|
bool? hideDoneSubTasks,
|
||||||
|
|
@ -185,9 +215,11 @@ final class const TaskContent({
|
||||||
priority: priority ?? this.priority,
|
priority: priority ?? this.priority,
|
||||||
labelIds: labelIds ?? this.labelIds,
|
labelIds: labelIds ?? this.labelIds,
|
||||||
reminders: reminders ?? this.reminders,
|
reminders: reminders ?? this.reminders,
|
||||||
|
reminderTimes: reminderTimes ?? this.reminderTimes,
|
||||||
completedAt: completedAt == null ? this.completedAt : completedAt(),
|
completedAt: completedAt == null ? this.completedAt : completedAt(),
|
||||||
completions: completions ?? this.completions,
|
completions: completions ?? this.completions,
|
||||||
hideDoneSubTasks: hideDoneSubTasks ?? this.hideDoneSubTasks,
|
hideDoneSubTasks: hideDoneSubTasks ?? this.hideDoneSubTasks,
|
||||||
|
unknown: unknown,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -203,9 +235,11 @@ final class const TaskContent({
|
||||||
priority,
|
priority,
|
||||||
labelIds,
|
labelIds,
|
||||||
reminders,
|
reminders,
|
||||||
|
reminderTimes,
|
||||||
completedAt,
|
completedAt,
|
||||||
completions,
|
completions,
|
||||||
hideDoneSubTasks,
|
hideDoneSubTasks,
|
||||||
|
unknown,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ void main() {
|
||||||
priority: Priority.p2,
|
priority: Priority.p2,
|
||||||
labelIds: const ['l1', 'l2'],
|
labelIds: const ['l1', 'l2'],
|
||||||
reminders: const [30, 0],
|
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),
|
completedAt: DateTime(2026, 11, 2, 7),
|
||||||
completions: [DateTime(2026, 10, 19, 7, 5), DateTime(2026, 10, 22, 6)],
|
completions: [DateTime(2026, 10, 19, 7, 5), DateTime(2026, 10, 22, 6)],
|
||||||
hideDoneSubTasks: true,
|
hideDoneSubTasks: true,
|
||||||
|
|
@ -80,6 +84,10 @@ void main() {
|
||||||
'priority': 2,
|
'priority': 2,
|
||||||
'labelIds': ['l1', 'l2'],
|
'labelIds': ['l1', 'l2'],
|
||||||
'reminders': [30, 0],
|
'reminders': [30, 0],
|
||||||
|
'reminderTimes': [
|
||||||
|
{'day': '2026-11-01', 'time': 1200},
|
||||||
|
{'day': '2026-11-02', 'time': 360},
|
||||||
|
],
|
||||||
'completedAt': DateTime(2026, 11, 2, 7).toUtc().toIso8601String(),
|
'completedAt': DateTime(2026, 11, 2, 7).toUtc().toIso8601String(),
|
||||||
'completions': [
|
'completions': [
|
||||||
DateTime(2026, 10, 19, 7, 5).toUtc().toIso8601String(),
|
DateTime(2026, 10, 19, 7, 5).toUtc().toIso8601String(),
|
||||||
|
|
@ -136,6 +144,16 @@ void main() {
|
||||||
'date': {'day': '2026-11-02', 'repeat': repeat},
|
'date': {'day': '2026-11-02', 'repeat': repeat},
|
||||||
},
|
},
|
||||||
{...base, 'date': 'today'},
|
{...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');
|
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', () {
|
test('a priority it doesn’t know is none', () {
|
||||||
expect(
|
expect(
|
||||||
decodeTask(
|
decodeTask(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue