diff --git a/calendar/calendar.go b/calendar/calendar.go index c3d27c0fc527657f704c8adf2f8b032754f6d442..1a02deaa4ce92ebccc786de5ea463c2919b742a4 100644 --- a/calendar/calendar.go +++ b/calendar/calendar.go @@ -147,10 +147,18 @@ func parseEventTimestamp(vevent *ics.VEvent, prop ics.ComponentProperty) (time.T value := p.Value var tzid string + var isDateOnly bool if params := p.ICalParameters; params != nil { if tzids := params["TZID"]; len(tzids) > 0 { tzid = tzids[0] } + if vals := params["VALUE"]; len(vals) > 0 && strings.EqualFold(vals[0], "DATE") { + isDateOnly = true + } + } + // RFC 5545 DATE form is YYYYMMDD (8 chars, no time component). + if !isDateOnly && len(value) == 8 { + isDateOnly = true } // Try parsing with timezone @@ -176,8 +184,9 @@ func parseEventTimestamp(vevent *ics.VEvent, prop ics.ComponentProperty) (time.T return time.Time{}, fmt.Errorf("parse timestamp: %w", err) } - // Apply timezone if specified - if tzid != "" && !strings.HasSuffix(value, "Z") { + // Apply timezone if specified. RFC 5545: VALUE=DATE has no timezone, so + // TZID must be ignored for date-only values even when present. + if tzid != "" && !strings.HasSuffix(value, "Z") && !isDateOnly { if loc, locErr := time.LoadLocation(tzid); locErr == nil { t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc) } diff --git a/calendar/calendar_test.go b/calendar/calendar_test.go index 4d7da3efce072f68c9e1bf9f3f6e0209b28fc8aa..71554dfacda240f5066e4f568e97f0ca9676f281 100644 --- a/calendar/calendar_test.go +++ b/calendar/calendar_test.go @@ -129,6 +129,91 @@ func TestGenerateRSVP(t *testing.T) { } } +// buildICS wraps DTSTART/DTEND lines into a minimal VCALENDAR for ParseICS. +func buildICS(dtstart, dtend string) []byte { + return []byte("BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "PRODID:-//Test//Test//EN\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:date-only@example.com\r\n" + + "DTSTAMP:20260415T120000Z\r\n" + + dtstart + "\r\n" + + dtend + "\r\n" + + "SUMMARY:Test\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n") +} + +func TestParseICS_DateOnly(t *testing.T) { + wantStart := time.Date(2026, 4, 21, 0, 0, 0, 0, time.UTC) + wantEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + dtstart string + dtend string + }{ + { + name: "VALUE=DATE without TZID", + dtstart: "DTSTART;VALUE=DATE:20260421", + dtend: "DTEND;VALUE=DATE:20260422", + }, + { + // Regression: TZID present on a date-only value must be ignored + // (RFC 5545 forbids TZID with VALUE=DATE; some producers emit it anyway). + name: "VALUE=DATE with TZID is ignored", + dtstart: "DTSTART;TZID=America/New_York;VALUE=DATE:20260421", + dtend: "DTEND;TZID=America/New_York;VALUE=DATE:20260422", + }, + { + // Shape-only detection: no VALUE param, but YYYYMMDD value with TZID. + name: "YYYYMMDD shape with TZID is treated as date-only", + dtstart: "DTSTART;TZID=America/Los_Angeles:20260421", + dtend: "DTEND;TZID=America/Los_Angeles:20260422", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event, err := ParseICS(buildICS(tt.dtstart, tt.dtend)) + if err != nil { + t.Fatalf("ParseICS failed: %v", err) + } + if !event.Start.Equal(wantStart) { + t.Errorf("Start = %v, want %v", event.Start.UTC(), wantStart) + } + if !event.End.Equal(wantEnd) { + t.Errorf("End = %v, want %v", event.End.UTC(), wantEnd) + } + }) + } +} + +func TestParseICS_TimedWithTZID(t *testing.T) { + // Existing behavior: timed values with TZID keep their zone semantics. + event, err := ParseICS(buildICS( + "DTSTART;TZID=America/New_York:20260421T140000", + "DTEND;TZID=America/New_York:20260421T153000", + )) + if err != nil { + t.Fatalf("ParseICS failed: %v", err) + } + + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Skipf("America/New_York unavailable on this system: %v", err) + } + wantStart := time.Date(2026, 4, 21, 14, 0, 0, 0, loc) + wantEnd := time.Date(2026, 4, 21, 15, 30, 0, 0, loc) + + if !event.Start.Equal(wantStart) { + t.Errorf("Start = %v, want %v", event.Start, wantStart) + } + if !event.End.Equal(wantEnd) { + t.Errorf("End = %v, want %v", event.End, wantEnd) + } +} + func TestExtractEmail(t *testing.T) { tests := []struct { input string