From 60d66b728edc9e4ff351d1d5145f9543d837a6d5 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Tue, 25 Aug 2026 08:42:25 +0200 Subject: [PATCH] fix(ics): a date-only task now shows in a subscribed calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar feed emitted every task as a VTODO and, on top of that, an event only for tasks that carried a clock time. But a subscribed calendar in Apple Calendar or Google ignores VTODO entirely and draws only events — so a task with just a due date, which is most of them, appeared in the verdande app's own calendar and nowhere in the calendar it had been subscribed into. "Verdande does not update in my calendar, only in the verdande app." Every dated, unfinished task is an event now: a clock time makes a timed event as before, a bare date an all-day one (a DATE start and an exclusive next-day end, so it stays on its own day). Finished tasks stay off the calendar, as they were. Test updated: the sample's timed task and its two date-only tasks all become events now, the all-day ones in the DATE form, and the finished one still makes none. Co-Authored-By: Claude Opus 5 --- internal/ics/ics.go | 51 ++++++++++++++++++++++++++++++---------- internal/ics/ics_test.go | 27 ++++++++++++++------- 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/internal/ics/ics.go b/internal/ics/ics.go index efeb77d..7064fef 100644 --- a/internal/ics/ics.go +++ b/internal/ics/ics.go @@ -62,8 +62,13 @@ func Render(cal Calendar) string { 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 { + // A dated task also goes out as an event, for the calendar apps that ignore + // VTODO — which is all of them: Apple Calendar and Google both drop a + // subscribed to-do on the floor and show only events. A task with a clock + // time becomes a timed event; one with only a date, an all-day event. Once + // this was limited to timed tasks, so a date-only task — most of them — + // simply never appeared in the calendar it was subscribed into. + if !t.Completed && (t.DueDatetime != nil || t.DueDate != "") { writeEvent(&b, t, cal.Domain) } } @@ -128,22 +133,31 @@ func writeTodo(b *strings.Builder, t Task, domain string) { } 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)) + + if t.DueDatetime != nil { + 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 + } + writeLine(b, "DTSTART:"+utc(start)) + writeLine(b, "DTEND:"+utc(start.Add(time.Duration(minutes)*time.Minute))) + } else { + // A date with no time is an all-day event. DTEND is exclusive — a one-day + // event ends on the following date — which is what keeps it a single day in + // the grid rather than bleeding into the next. + day := strings.ReplaceAll(t.DueDate, "-", "") + writeLine(b, "DTSTART;VALUE=DATE:"+day) + writeLine(b, "DTEND;VALUE=DATE:"+nextDay(t.DueDate)) + } + writeLine(b, "SUMMARY:"+escape(t.Content)) if t.Description != "" { writeLine(b, "DESCRIPTION:"+escape(t.Description)) @@ -157,6 +171,17 @@ func writeEvent(b *strings.Builder, t Task, domain string) { func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") } +// nextDay is the YYYYMMDD after a YYYY-MM-DD date, for an all-day event's exclusive +// DTEND. A malformed date is returned as its own compacted form rather than +// dropped, so a task never loses its event over a bad string. +func nextDay(date string) string { + d, err := time.Parse("2006-01-02", date) + if err != nil { + return strings.ReplaceAll(date, "-", "") + } + return d.AddDate(0, 0, 1).Format("20060102") +} + // 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. diff --git a/internal/ics/ics_test.go b/internal/ics/ics_test.go index 8cd3f3a..b6c9b9f 100644 --- a/internal/ics/ics_test.go +++ b/internal/ics/ics_test.go @@ -102,19 +102,28 @@ func TestWholeDayDueDatesAreDates(t *testing.T) { // 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) { +func TestDatedTasksBecomeEvents(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")) + // Every dated, unfinished task becomes an event, so it shows in a calendar app + // that ignores VTODO — timed t1, and date-only t2 and t3. The finished t4 has no + // date and no event. + if got := strings.Count(out, "BEGIN:VEVENT"); got != 3 { + t.Fatalf("got %d VEVENTs, want 3 (one timed, two all-day)", got) } - if !strings.Contains(out, "DTSTART:20260310T090000Z") { - t.Error("the event has no start") + + // The timed one keeps its clock and its 45-minute duration. + if !strings.Contains(out, "DTSTART:20260310T090000Z") || !strings.Contains(out, "DTEND:20260310T094500Z") { + t.Error("the timed event's start or duration is wrong") } - // 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 date-only ones are all-day: a DATE start and an exclusive next-day end, so + // they sit on their own day rather than bleeding into the next. + if !strings.Contains(out, "DTSTART;VALUE=DATE:20260312") || !strings.Contains(out, "DTEND;VALUE=DATE:20260313") { + t.Error("the date-only event is not a single all-day event") + } + // A recurring date-only task carries its rule onto the event. + if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") { + t.Error("the recurring task's event has no RRULE") } // 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") {