feat(todo): a calendar's feed read, and linked to a project in its payload (ADR 0033, D39)

The developer's decisions of 10 Nov 2026 for calendar feeds, recorded as
D39: a project links to a calendar's iCal feed, its events shown beside
tasks, read-only; each device fetches it, the address sealed in the
project's payload, the last copy kept on the device.

This lays the ground: `readIcal` reads a feed's events (title, start,
end or duration, all-day, place, cancelled, the dates a recurrence
leaves out and the occurrences changed on their own), on the device's
clock through `timezone`, and expands their recurrence with pub.dev's
`rrule` (Apache-2.0, the developer's choice); `ProjectContent` gains
`calendarUrl`, its payload staying schema 1. Nothing shows it yet.

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-10 11:49:34 +01:00
commit 892a58f4fd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 591 additions and 0 deletions

View file

@ -0,0 +1,335 @@
import 'package:equatable/equatable.dart';
import 'package:rrule/rrule.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
/// One occurrence of a calendar's event (ADR 0033, D39), on the device's
/// clock: from [start] to [end], the end not part of it; an [allDay] one
/// from midnight to midnight of its days.
final class const IcalEvent({
required final String uid,
required final String title,
required final DateTime start,
required final DateTime end,
final bool allDay = false,
final String? location,
}) with Equatable {
@override
List<Object?> get props => [uid, title, start, end, allDay, location];
}
/// Whether [text] is a calendar file at all, not a page saying it isn't.
bool isIcal(String text) => text.trimLeft().startsWith('BEGIN:VCALENDAR');
/// The occurrences of [text]'s events that run within [from] to [until],
/// in order: a recurring event's expanded by its rule, the dates it leaves
/// out left out and the occurrences changed on their own in their place;
/// cancelled ones left out. A file that is no calendar holds none.
List<IcalEvent> readIcal(
String text, {
required DateTime from,
required DateTime until,
}) {
if (!isIcal(text)) {
return const [];
}
final components = _events(_lines(text));
// Occurrences changed on their own, by their event and the moment they
// replace.
final changed = <(String, DateTime)>{
for (final event in components)
if (event.recurrenceId case final id?) (event.uid, id),
};
final found = <IcalEvent>[];
for (final event in components) {
if (event.cancelled) {
continue;
}
for (final occurrence in event.occurrences(from, until)) {
final replaced =
event.recurrenceId == null &&
changed.contains((event.uid, occurrence.start));
if (!replaced &&
occurrence.end.isAfter(from) &&
occurrence.start.isBefore(until)) {
found.add(occurrence);
}
}
}
return found..sort((a, b) => a.start.compareTo(b.start));
}
/// A line of the file, its folds joined: `NAME;PARAM=VALUE:value`.
final class const _Line(
final String name,
final Map<String, String> params,
final String value,
);
/// The file's lines, those folded onto the next (starting with a space or
/// a tab) joined back.
List<_Line> _lines(String text) {
final unfolded = text
.replaceAll('\r\n', '\n')
.replaceAll(RegExp('\n[ \t]'), '')
.split('\n');
return [for (final raw in unfolded) ?_line(raw)];
}
_Line? _line(String raw) {
// The value starts after the first colon outside quotes.
var quoted = false;
for (var i = 0; i < raw.length; i++) {
final char = raw[i];
if (char == '"') {
quoted = !quoted;
} else if (char == ':' && !quoted) {
final head = raw.substring(0, i).split(';');
return _Line(head.first.toUpperCase(), {
for (final param in head.skip(1))
if (param.indexOf('=') case final at when at > 0)
param.substring(0, at).toUpperCase(): param
.substring(at + 1)
.replaceAll('"', ''),
}, raw.substring(i + 1));
}
}
return null;
}
/// A text value with its escapes read: `\n`, `\,`, `\;`, `\\`.
String _text(String value) => value.replaceAllMapped(
RegExp(r'\\([nN,;\\])'),
(match) => switch (match[1]) {
'n' || 'N' => '\n',
final char => char!,
},
);
/// How a date or time of the file is read: a day alone, a moment in UTC,
/// a wall clock in a time zone, or a wall clock as written (floating, or a
/// zone not known here).
sealed class const _Clock();
final class const _Day() extends _Clock;
final class const _Utc() extends _Clock;
final class const _Floating() extends _Clock;
final class const _Zone(final tz.Location location) extends _Clock;
/// A date or time as written, its fields held in a UTC [DateTime] (as
/// `rrule` takes them), with how it is to be read.
final class const _Stamp(final DateTime wall, final _Clock clock) {
/// On the device's clock.
DateTime get local => switch (clock) {
_Utc() => wall.toLocal(),
_Zone(:final location) => DateTime.fromMillisecondsSinceEpoch(
tz.TZDateTime(
location,
wall.year,
wall.month,
wall.day,
wall.hour,
wall.minute,
wall.second,
).millisecondsSinceEpoch,
),
_Day() || _Floating() => DateTime(
wall.year,
wall.month,
wall.day,
wall.hour,
wall.minute,
wall.second,
),
};
_Stamp at(DateTime wall) => _Stamp(wall, clock);
}
var _zonesLoaded = false;
_Stamp? _stamp(String value, Map<String, String> params) {
final match = RegExp(
r'^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$',
).firstMatch(value.trim());
if (match == null) {
return null;
}
int at(int group) => int.parse(match[group] ?? '0');
final wall = DateTime.utc(at(1), at(2), at(3), at(4), at(5), at(6));
if (match[4] == null) {
return _Stamp(wall, const _Day());
}
if (match[7] != null) {
return _Stamp(wall, const _Utc());
}
if (params['TZID'] case final zone?) {
if (!_zonesLoaded) {
tz_data.initializeTimeZones();
_zonesLoaded = true;
}
try {
return _Stamp(wall, _Zone(tz.getLocation(zone)));
} on tz.LocationNotFoundException {
// A zone named otherwise (Outlook's Windows names): as written.
}
}
return _Stamp(wall, const _Floating());
}
/// RFC 5545's duration: `P1W`, `P1DT2H30M`, `PT45M`, `-PT15M`.
Duration? _duration(String value) {
final match = RegExp(
r'^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$',
).firstMatch(value.trim());
if (match == null) {
return null;
}
int at(int group) => int.parse(match[group] ?? '0');
final duration = Duration(
days: at(2) * 7 + at(3),
hours: at(4),
minutes: at(5),
seconds: at(6),
);
return match[1] == '-' ? -duration : duration;
}
/// An event of the file, as written.
final class _Event() {
String uid = '';
String title = '';
String? location;
_Stamp? start;
_Stamp? end;
Duration? duration;
RecurrenceRule? rule;
final excluded = <DateTime>{};
bool cancelled = false;
/// The moment on the device's clock of the occurrence it changes, where
/// it changes one of a recurring event's.
DateTime? recurrenceId;
bool get allDay => start?.clock is _Day;
/// How long each occurrence lasts, on the wall clock.
Duration get length {
if (duration case final duration?) {
return duration;
}
if ((start, end) case (final start?, final end?)) {
return end.wall.difference(start.wall);
}
return allDay ? const Duration(days: 1) : Duration.zero;
}
/// Its occurrences that may run within [from] to [until]: each start the
/// rule gives near the window, but those left out.
Iterable<IcalEvent> occurrences(DateTime from, DateTime until) sync* {
final start = this.start;
if (start == null) {
return;
}
final length = this.length;
// A day's margin either side for the zones' offsets.
final after = _wall(from).subtract(length + const Duration(days: 1));
final before = _wall(until).add(const Duration(days: 1));
final starts = switch (rule) {
null => [start.wall],
_ when before.isBefore(start.wall) => const <DateTime>[],
final rule => rule.getInstances(
start: start.wall,
after: after.isBefore(start.wall) ? start.wall : after,
includeAfter: true,
before: before,
),
};
for (final wall in starts) {
final local = start.at(wall).local;
if (excluded.contains(local)) {
continue;
}
yield IcalEvent(
uid: uid,
title: title,
start: local,
end: start.at(wall.add(length)).local,
allDay: allDay,
location: location,
);
}
}
static DateTime _wall(DateTime local) => DateTime.utc(
local.year,
local.month,
local.day,
local.hour,
local.minute,
local.second,
);
}
/// The file's events, but those without a start.
List<_Event> _events(List<_Line> lines) {
final events = <_Event>[];
_Event? event;
// Lines inside a component within the event (an alarm) aren't its own.
var nested = 0;
for (final line in lines) {
switch (line.name) {
case 'BEGIN' when line.value.toUpperCase() == 'VEVENT':
event = _Event();
nested = 0;
case 'BEGIN' when event != null:
nested++;
case 'END' when event != null && nested > 0:
nested--;
case 'END' when event != null:
if (event.start != null) {
events.add(event);
}
event = null;
case _ when event != null && nested == 0:
_read(event, line);
}
}
return events;
}
void _read(_Event event, _Line line) {
switch (line.name) {
case 'UID':
event.uid = line.value;
case 'SUMMARY':
event.title = _text(line.value);
case 'LOCATION':
event.location = _text(line.value);
case 'DTSTART':
event.start = _stamp(line.value, line.params);
case 'DTEND':
event.end = _stamp(line.value, line.params);
case 'DURATION':
event.duration = _duration(line.value);
case 'STATUS':
event.cancelled = line.value.toUpperCase() == 'CANCELLED';
case 'RRULE':
try {
event.rule = RecurrenceRule.fromString('RRULE:${line.value}');
} on FormatException {
// A rule it can't read: its first occurrence alone.
}
case 'EXDATE':
for (final value in line.value.split(',')) {
if (_stamp(value, line.params) case final stamp?) {
event.excluded.add(stamp.local);
}
}
case 'RECURRENCE-ID':
event.recurrenceId = _stamp(line.value, line.params)?.local;
}
}

View file

@ -24,6 +24,7 @@ const _known = {
'parentId',
'favorite',
'defaultView',
'calendarUrl',
};
/// [content] as its row's payload, before sealing: UTF-8 JSON, the colour by
@ -42,6 +43,7 @@ ByteData encodeProject(ProjectContent content) =>
if (content.favorite) 'favorite': true,
if (!content.defaultView.isDefault)
'defaultView': encodeView(content.defaultView),
'calendarUrl': ?content.calendarUrl,
});
/// [view] by its values' names, those left as at first left out.
@ -129,6 +131,7 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
parentId: json['parentId'] as String?,
favorite: json['favorite'] == true,
defaultView: decodeView(json['defaultView']),
calendarUrl: json['calendarUrl'] as String?,
unknown: {
for (final MapEntry(:key, :value) in json.entries)
if (!_known.contains(key)) key: value,

View file

@ -32,6 +32,10 @@ final class const ProjectContent({
/// layout as its editor picks it, the rest as at first.
final ProjectView defaultView = const ProjectView(),
/// The address of a calendar's feed whose events it shows beside its
/// tasks, read-only (D39); none: no calendar.
final String? calendarUrl,
/// What its payload holds that this version doesn't know (as a task's,
/// D18): written back as it was, so that a project changed here keeps
/// what a newer version gave it.
@ -55,6 +59,7 @@ final class const ProjectContent({
String? Function()? parentId,
bool? favorite,
ProjectView? defaultView,
String? Function()? calendarUrl,
}) => ProjectContent(
name: name ?? this.name,
color: color == null ? this.color : color(),
@ -64,6 +69,7 @@ final class const ProjectContent({
parentId: parentId == null ? this.parentId : parentId(),
favorite: favorite ?? this.favorite,
defaultView: defaultView ?? this.defaultView,
calendarUrl: calendarUrl == null ? this.calendarUrl : calendarUrl(),
unknown: unknown,
);
@ -77,6 +83,7 @@ final class const ProjectContent({
parentId,
favorite,
defaultView,
calendarUrl,
unknown,
];
}

View file

@ -61,6 +61,7 @@ dependencies:
package_info_plus: ^10.2.2
path: ^1.9.1
path_provider: ^2.1.6
rrule: ^0.2.18
serverpod_auth_idp_flutter: 4.0.4
serverpod_flutter: 4.0.4
serverpod_offline_sync_client: 0.0.10

View file

@ -0,0 +1,198 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/model/ical.dart';
/// A calendar file holding [events], as a feed serves it.
String calendar(String events) =>
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n'
'${events.trim().replaceAll('\n', '\r\n')}\r\nEND:VCALENDAR\r\n';
void main() {
// November 2026, from the 1st to the 30th, as a month's view asks.
final from = DateTime(2026, 11);
final until = DateTime(2026, 12);
List<IcalEvent> read(String events) =>
readIcal(calendar(events), from: from, until: until);
group("a calendar's events (ADR 0033, D39)", () {
test('a timed one in UTC, on the clock here, its texts unescaped and '
'its folded lines joined', () {
final events = read(r'''
BEGIN:VEVENT
UID:a@test
DTSTART:20261110T090000Z
DTEND:20261110T103000Z
SUMMARY:Team meeting\, weekly
LOCATION:Room 1\, floor 2\nmain building
DESCRIPTION:Bring the
notes
END:VEVENT
''');
final event = events.single;
expect(event.uid, 'a@test');
expect(event.title, 'Team meeting, weekly');
expect(event.location, 'Room 1, floor 2\nmain building');
expect(event.allDay, isFalse);
expect(event.start, DateTime.utc(2026, 11, 10, 9).toLocal());
expect(event.end, DateTime.utc(2026, 11, 10, 10, 30).toLocal());
});
test('an all-day one, a day long without an end', () {
final event = read('''
BEGIN:VEVENT
UID:b@test
DTSTART;VALUE=DATE:20261110
SUMMARY:Holiday
END:VEVENT
''').single;
expect(event.allDay, isTrue);
expect(event.start, DateTime(2026, 11, 10));
expect(event.end, DateTime(2026, 11, 11));
});
test('in a time zone of its own, at the same moment here', () {
// New York is five hours behind UTC in November.
final event = read('''
BEGIN:VEVENT
UID:c@test
DTSTART;TZID=America/New_York:20261110T090000
DTEND;TZID=America/New_York:20261110T100000
SUMMARY:Call
END:VEVENT
''').single;
expect(event.start, DateTime.utc(2026, 11, 10, 14).toLocal());
expect(event.end, DateTime.utc(2026, 11, 10, 15).toLocal());
});
test("a zone it doesn't know, on the clock as written", () {
final event = read('''
BEGIN:VEVENT
UID:d@test
DTSTART;TZID=W. Europe Standard Time:20261110T090000
SUMMARY:Outlook
END:VEVENT
''').single;
expect(event.start, DateTime(2026, 11, 10, 9));
});
test('a duration in place of its end', () {
final event = read('''
BEGIN:VEVENT
UID:e@test
DTSTART:20261110T090000
DURATION:PT1H30M
SUMMARY:Floating
END:VEVENT
''').single;
expect(event.start, DateTime(2026, 11, 10, 9));
expect(event.end, DateTime(2026, 11, 10, 10, 30));
});
test('a weekly one, its occurrences in the window, but the date it '
'leaves out', () {
final events = read('''
BEGIN:VEVENT
UID:f@test
DTSTART:20261102T080000
DTEND:20261102T090000
RRULE:FREQ=WEEKLY;COUNT=3
EXDATE:20261109T080000
SUMMARY:Yoga
END:VEVENT
''');
expect(events.map((event) => event.start), [
DateTime(2026, 11, 2, 8),
DateTime(2026, 11, 16, 8),
]);
expect(events.last.end, DateTime(2026, 11, 16, 9));
});
test('an occurrence changed on its own replaces the one it changes', () {
final events = read('''
BEGIN:VEVENT
UID:g@test
DTSTART:20261102T080000
DTEND:20261102T090000
RRULE:FREQ=WEEKLY;COUNT=2
SUMMARY:Yoga
END:VEVENT
BEGIN:VEVENT
UID:g@test
RECURRENCE-ID:20261109T080000
DTSTART:20261109T180000
DTEND:20261109T190000
SUMMARY:Yoga in the evening
END:VEVENT
''');
expect(
events.map((event) => (event.start, event.title)),
[
(DateTime(2026, 11, 2, 8), 'Yoga'),
(DateTime(2026, 11, 9, 18), 'Yoga in the evening'),
],
);
});
test('a daily one without end, within the window alone', () {
final events = readIcal(
calendar('''
BEGIN:VEVENT
UID:h@test
DTSTART;VALUE=DATE:20200101
RRULE:FREQ=DAILY
SUMMARY:Every day
END:VEVENT
'''),
from: DateTime(2026, 11, 10),
until: DateTime(2026, 11, 13),
);
expect(events.map((event) => event.start), [
DateTime(2026, 11, 10),
DateTime(2026, 11, 11),
DateTime(2026, 11, 12),
]);
});
test('cancelled ones and those outside the window are left out; one '
'running into it is kept', () {
final events = read('''
BEGIN:VEVENT
UID:i@test
DTSTART:20261110T090000
STATUS:CANCELLED
SUMMARY:Off
END:VEVENT
BEGIN:VEVENT
UID:j@test
DTSTART:20261210T090000
SUMMARY:Later
END:VEVENT
BEGIN:VEVENT
UID:k@test
DTSTART;VALUE=DATE:20261030
DTEND;VALUE=DATE:20261103
SUMMARY:Trip
END:VEVENT
''');
expect(events.map((event) => event.title), ['Trip']);
});
test('a file that is no calendar holds no events', () {
expect(
readIcal('<html>Not found</html>', from: from, until: until),
isEmpty,
);
expect(isIcal('<html>Not found</html>'), isFalse);
expect(isIcal(calendar('')), isTrue);
});
});
}

View file

@ -217,4 +217,26 @@ void main() {
expect(decodeProject(encodeProject(renamed)), renamed);
});
});
group('since D39', () {
test("its calendar's feed reads back as written, none left out", () {
const school = ProjectContent(
name: 'School',
calendarUrl: 'https://example.org/timetable.ics',
);
expect(decodeProject(encodeProject(school)), school);
expect(
decodeProject(encodeProject(const ProjectContent(name: 'Home')))
?.calendarUrl,
isNull,
);
expect(
const ProjectContent(
name: 'School',
calendarUrl: 'https://a.example',
).copyWith(calendarUrl: () => null).calendarUrl,
isNull,
);
});
});
}