feat(phase3): reminders, ICS feeds, templates and nightly backups

Reminders are absolute or relative ("10 minutes before"). A relative one
is resolved against the task in SQL, so moving a task moves its reminders
too. They are marked sent before delivery, not after: a reminder that
goes out unrecorded repeats every minute forever, which is worse than
one recorded that fails to send.

The calendar feed is a per-user token in the path, because Apple Calendar
and Google fetch with no credentials and no way to prompt for any. Tasks
go out as VTODO — the same component CalDAV will serve — and timed ones
also as VEVENT, since Google ignores VTODO entirely.

Templates store relative day offsets rather than dates, which is what
makes them reusable: an onboarding checklist is "day 1, day 3, day 14",
not three dates in March. Building a project from one is a single
transaction, so a failure cannot leave half a checklist.

Backups use SQLite's VACUUM INTO, not a file copy — a copy of a live
WAL database can miss recent writes or catch a page mid-update, and the
result looks like a database right up until it is needed. Rotation keeps
fourteen by count, not by age: a container that was off for a month must
not come back and delete every backup it has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 07:05:24 +02:00
co-authored by Claude Opus 5
parent 4d142b2fa7
commit 7e4c730049
13 changed files with 2315 additions and 1 deletions
+223
View File
@@ -0,0 +1,223 @@
// Package ics writes iCalendar documents.
//
// Tasks go out as VTODO, not VEVENT. A to-do has a due date and a completion state;
// an event has a start and an end and neither of those things. Apple Reminders and
// Thunderbird read VTODO natively, and it is the same component the CalDAV server
// will serve later — so a task exported today is the task synchronised tomorrow,
// with no second representation to keep in step.
//
// Google Calendar is the awkward exception: it subscribes to ICS but ignores VTODO
// entirely. Tasks that carry a clock time are therefore also emitted as VEVENT,
// which is what makes a feed useful in the calendar most people actually keep.
package ics
import (
"fmt"
"strings"
"time"
)
// Task is the subset of a task a calendar needs. Defined here rather than importing
// the store's type, so this package stays a pure formatter with no database in it.
type Task struct {
ID string
Content string
Description string
ProjectName string
DueDate string // "YYYY-MM-DD"
DueDatetime *time.Time
DurationMin *int
Priority int
Recurrence string
Completed bool
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type Calendar struct {
Name string
// Domain appears in every UID. It has to be stable for the lifetime of the
// feed: a client that sees a new UID for the same task treats it as a new item
// and leaves the old one behind as a duplicate.
Domain string
Tasks []Task
}
// Render writes the whole document.
func Render(cal Calendar) string {
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\n")
b.WriteString("VERSION:2.0\r\n")
b.WriteString("PRODID:-//verdande//verdande//DA\r\n")
b.WriteString("CALSCALE:GREGORIAN\r\n")
b.WriteString("METHOD:PUBLISH\r\n")
writeLine(&b, "X-WR-CALNAME:"+escape(cal.Name))
// Tells a subscribing client how often to poll. Without it Google refetches on
// its own schedule, which can be many hours.
b.WriteString("X-PUBLISHED-TTL:PT1H\r\n")
b.WriteString("REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\n")
for _, t := range cal.Tasks {
writeTodo(&b, t, cal.Domain)
// A dated task also goes out as an event, for clients that ignore VTODO.
if t.DueDatetime != nil && !t.Completed {
writeEvent(&b, t, cal.Domain)
}
}
b.WriteString("END:VCALENDAR\r\n")
return b.String()
}
func writeTodo(b *strings.Builder, t Task, domain string) {
b.WriteString("BEGIN:VTODO\r\n")
writeLine(b, "UID:"+t.ID+"@"+domain)
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
writeLine(b, "CREATED:"+utc(t.CreatedAt))
writeLine(b, "SUMMARY:"+escape(t.Content))
if t.Description != "" {
writeLine(b, "DESCRIPTION:"+escape(t.Description))
}
if t.ProjectName != "" {
writeLine(b, "CATEGORIES:"+escape(t.ProjectName))
}
switch {
case t.DueDatetime != nil:
writeLine(b, "DUE:"+utc(*t.DueDatetime))
case t.DueDate != "":
// A whole-day due date is a DATE, not a DATE-TIME. Emitting midnight in
// some timezone instead would move the task a day for anybody east or
// west of the server.
writeLine(b, "DUE;VALUE=DATE:"+strings.ReplaceAll(t.DueDate, "-", ""))
}
if t.Recurrence != "" {
writeLine(b, "RRULE:"+strings.TrimPrefix(t.Recurrence, "RRULE:"))
}
// RFC 5545 priority runs 1 (highest) to 9, with 0 meaning undefined. verdande's
// P4 is "no priority stated" rather than "lowest", so it maps to 0 — otherwise
// every unprioritised task arrives in a client marked explicitly low.
switch t.Priority {
case 1:
writeLine(b, "PRIORITY:1")
case 2:
writeLine(b, "PRIORITY:5")
case 3:
writeLine(b, "PRIORITY:7")
default:
writeLine(b, "PRIORITY:0")
}
if t.Completed {
writeLine(b, "STATUS:COMPLETED")
writeLine(b, "PERCENT-COMPLETE:100")
if t.CompletedAt != nil {
writeLine(b, "COMPLETED:"+utc(*t.CompletedAt))
}
} else {
writeLine(b, "STATUS:NEEDS-ACTION")
}
b.WriteString("END:VTODO\r\n")
}
func writeEvent(b *strings.Builder, t Task, domain string) {
start := *t.DueDatetime
// Without a stated duration a task is shown as half an hour. A zero-length
// event collapses to an invisible sliver in most calendar grids.
minutes := 30
if t.DurationMin != nil && *t.DurationMin > 0 {
minutes = *t.DurationMin
}
end := start.Add(time.Duration(minutes) * time.Minute)
b.WriteString("BEGIN:VEVENT\r\n")
// A distinct UID suffix: the event and the to-do describe the same task, and
// sharing a UID would make a client treat them as two versions of one item.
writeLine(b, "UID:"+t.ID+"-event@"+domain)
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
writeLine(b, "DTSTART:"+utc(start))
writeLine(b, "DTEND:"+utc(end))
writeLine(b, "SUMMARY:"+escape(t.Content))
if t.Description != "" {
writeLine(b, "DESCRIPTION:"+escape(t.Description))
}
if t.Recurrence != "" {
writeLine(b, "RRULE:"+strings.TrimPrefix(t.Recurrence, "RRULE:"))
}
writeLine(b, "TRANSP:TRANSPARENT")
b.WriteString("END:VEVENT\r\n")
}
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is
// a value separator in iCalendar, so an unescaped one silently truncates the
// summary at that character in every client.
func escape(s string) string {
r := strings.NewReplacer(
"\\", "\\\\",
";", "\\;",
",", "\\,",
"\n", "\\n",
"\r", "",
)
return r.Replace(s)
}
// writeLine folds at 75 octets, as the spec requires, with a space beginning each
// continuation. Folding is by byte with a check for UTF-8 continuation bytes: a
// break in the middle of "ø" produces a line no parser can read.
func writeLine(b *strings.Builder, line string) {
const limit = 73 // 75 minus CRLF
if len(line) <= limit {
b.WriteString(line)
b.WriteString("\r\n")
return
}
for i := 0; i < len(line); {
end := i + limit
if i > 0 {
end = i + limit - 1 // the leading space on a continuation counts
}
if end > len(line) {
end = len(line)
}
// Never split a multi-byte character.
for end > i && end < len(line) && line[end]&0xC0 == 0x80 {
end--
}
if i > 0 {
b.WriteString(" ")
}
b.WriteString(line[i:end])
b.WriteString("\r\n")
i = end
}
}
// Filename is what a browser saves the feed as.
func Filename(name string) string {
safe := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
return r
case r >= 'A' && r <= 'Z':
return r + 32
case r == ' ', r == '_':
return '-'
}
return -1
}, name)
if safe == "" {
safe = "verdande"
}
return fmt.Sprintf("%s.ics", safe)
}
+222
View File
@@ -0,0 +1,222 @@
package ics
import (
"strings"
"testing"
"time"
)
func sample() Calendar {
due := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC)
done := time.Date(2026, 3, 9, 14, 30, 0, 0, time.UTC)
duration := 45
return Calendar{
Name: "verdande",
Domain: "todo.example.dk",
Tasks: []Task{
{
ID: "t1", Content: "Betal moms", Description: "husk bilag",
ProjectName: "Firma", DueDate: "2026-03-10", DueDatetime: &due,
DurationMin: &duration, Priority: 1,
CreatedAt: done, UpdatedAt: done,
},
{
ID: "t2", Content: "Ryd op", DueDate: "2026-03-12", Priority: 4,
CreatedAt: done, UpdatedAt: done,
},
{
ID: "t3", Content: "Vand planterne", Recurrence: "FREQ=WEEKLY;BYDAY=MO",
DueDate: "2026-03-16", Priority: 3, CreatedAt: done, UpdatedAt: done,
},
{
ID: "t4", Content: "Færdig ting", Completed: true, CompletedAt: &done,
CreatedAt: done, UpdatedAt: done,
},
},
}
}
func TestRenderStructure(t *testing.T) {
out := Render(sample())
if !strings.HasPrefix(out, "BEGIN:VCALENDAR\r\n") {
t.Error("the document does not begin with BEGIN:VCALENDAR")
}
if !strings.HasSuffix(out, "END:VCALENDAR\r\n") {
t.Error("the document does not end with END:VCALENDAR")
}
if strings.Count(out, "BEGIN:VTODO") != 4 {
t.Errorf("got %d VTODOs, want one per task", strings.Count(out, "BEGIN:VTODO"))
}
if a, b := strings.Count(out, "BEGIN:VTODO"), strings.Count(out, "END:VTODO"); a != b {
t.Errorf("%d BEGIN:VTODO but %d END:VTODO", a, b)
}
// Every line ends CRLF — a bare LF is not iCalendar and some clients refuse it.
for _, line := range strings.Split(strings.TrimSuffix(out, "\r\n"), "\r\n") {
if strings.Contains(line, "\n") {
t.Fatalf("a line contains a bare newline: %q", line)
}
}
}
// Tasks are VTODO. That is what makes them to-dos in Apple Reminders rather than
// events, and it is the same component CalDAV will serve.
func TestTasksAreTodos(t *testing.T) {
out := Render(sample())
if !strings.Contains(out, "SUMMARY:Betal moms") {
t.Error("the summary is missing")
}
if !strings.Contains(out, "UID:t1@todo.example.dk") {
t.Error("the UID is missing or not domain-qualified")
}
if !strings.Contains(out, "STATUS:NEEDS-ACTION") {
t.Error("an open task is not marked NEEDS-ACTION")
}
if !strings.Contains(out, "STATUS:COMPLETED") {
t.Error("a completed task is not marked COMPLETED")
}
if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") {
t.Error("the recurrence rule did not survive into the feed")
}
if !strings.Contains(out, "CATEGORIES:Firma") {
t.Error("the project is not carried as a category")
}
}
// A whole-day due date must be a DATE, not a DATE-TIME. Emitting midnight in some
// timezone would move the task by a day for anybody east or west of the server.
func TestWholeDayDueDatesAreDates(t *testing.T) {
out := Render(sample())
if !strings.Contains(out, "DUE;VALUE=DATE:20260312") {
t.Error("a dateless task's due date is not a DATE value")
}
// The one with a clock time keeps it.
if !strings.Contains(out, "DUE:20260310T090000Z") {
t.Error("a timed task lost its clock time")
}
}
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
// or a feed is useless in the calendar most people actually keep.
func TestTimedTasksAlsoBecomeEvents(t *testing.T) {
out := Render(sample())
if strings.Count(out, "BEGIN:VEVENT") != 1 {
t.Fatalf("got %d VEVENTs, want one for the single timed task",
strings.Count(out, "BEGIN:VEVENT"))
}
if !strings.Contains(out, "DTSTART:20260310T090000Z") {
t.Error("the event has no start")
}
// 45 minutes of stated duration.
if !strings.Contains(out, "DTEND:20260310T094500Z") {
t.Error("the event's end does not reflect the task's duration")
}
// The event must not share the to-do's UID, or clients treat them as one item.
if !strings.Contains(out, "UID:t1-event@todo.example.dk") {
t.Error("the event does not have a UID of its own")
}
}
// A comma or semicolon is a value separator in iCalendar. Unescaped, it silently
// truncates the summary at that character in every client.
func TestSpecialCharactersAreEscaped(t *testing.T) {
now := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC)
out := Render(Calendar{
Name: "verdande", Domain: "example.dk",
Tasks: []Task{{
ID: "t1", Content: `Køb: mælk, brød; smør \ og mere`,
Description: "linje 1\nlinje 2",
CreatedAt: now, UpdatedAt: now,
}},
})
if !strings.Contains(out, `SUMMARY:Køb: mælk\, brød\; smør \\ og mere`) {
t.Errorf("the summary is not escaped correctly:\n%s", out)
}
if !strings.Contains(out, `DESCRIPTION:linje 1\nlinje 2`) {
t.Error("a newline in the description was not escaped")
}
// And the raw separators must not survive unescaped anywhere in the summary.
for _, line := range strings.Split(out, "\r\n") {
if !strings.HasPrefix(line, "SUMMARY:") {
continue
}
body := strings.TrimPrefix(line, "SUMMARY:")
for i := 0; i < len(body); i++ {
if (body[i] == ',' || body[i] == ';') && (i == 0 || body[i-1] != '\\') {
t.Errorf("unescaped %q in %q", body[i], line)
}
}
}
}
// Lines fold at 75 octets, and a fold in the middle of a multi-byte character
// produces a line no parser can read.
func TestLongLinesFoldWithoutBreakingCharacters(t *testing.T) {
now := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC)
long := strings.Repeat("ø", 200) // two bytes each, so folds land mid-character
out := Render(Calendar{
Name: "verdande", Domain: "example.dk",
Tasks: []Task{{ID: "t1", Content: long, CreatedAt: now, UpdatedAt: now}},
})
for _, line := range strings.Split(out, "\r\n") {
if len(line) > 75 {
t.Errorf("a line is %d octets, over the 75 limit: %q", len(line), line)
}
}
// Unfolding must reproduce the original text exactly.
unfolded := strings.ReplaceAll(out, "\r\n ", "")
if !strings.Contains(unfolded, "SUMMARY:"+long) {
t.Error("the summary did not survive folding and unfolding")
}
}
// P4 means "no priority stated", not "lowest". Mapping it to 9 would arrive in a
// client as an explicit low priority on every ordinary task.
func TestPriorityMapping(t *testing.T) {
now := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC)
want := map[int]string{1: "PRIORITY:1", 2: "PRIORITY:5", 3: "PRIORITY:7", 4: "PRIORITY:0"}
for priority, expected := range want {
out := Render(Calendar{
Name: "v", Domain: "example.dk",
Tasks: []Task{{ID: "t", Content: "x", Priority: priority, CreatedAt: now, UpdatedAt: now}},
})
if !strings.Contains(out, expected) {
t.Errorf("priority %d did not produce %q", priority, expected)
}
}
}
func TestEmptyCalendarIsStillValid(t *testing.T) {
out := Render(Calendar{Name: "verdande", Domain: "example.dk"})
if !strings.Contains(out, "BEGIN:VCALENDAR") || !strings.Contains(out, "END:VCALENDAR") {
t.Error("an empty calendar is not a well-formed document")
}
if strings.Contains(out, "BEGIN:VTODO") {
t.Error("an empty calendar contains a task")
}
}
func TestFilename(t *testing.T) {
cases := map[string]string{
"verdande": "verdande.ics",
"Mine opgaver": "mine-opgaver.ics",
"Q3/2026 møder": "q32026-mder.ics",
"": "verdande.ics",
"???": "verdande.ics",
}
for in, want := range cases {
if got := Filename(in); got != want {
t.Errorf("Filename(%q) = %q, want %q", in, got, want)
}
}
}