fix: skip TZID for date-only events (#1301)

nanookclaw created

## What?

`parseEventTimestamp` now detects date-only iCalendar values
(`VALUE=DATE` or bare `YYYYMMDD`) and skips TZID re-anchoring for them.
Timed DTSTART/DTEND values with TZID keep the existing timezone
behavior.

## Why?

Closes #1058.

RFC 5545 DATE values have no timezone. Ignoring TZID for all-day events
prevents a stray TZID parameter from shifting the displayed date.

Change summary

calendar/calendar.go      | 13 +++++
calendar/calendar_test.go | 85 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 96 insertions(+), 2 deletions(-)

Detailed changes

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)
 		}

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