Skip to content

Commit 294bed4

Browse files
authored
fix: skip TZID for date-only events (#1301)
## 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.
1 parent 0bab451 commit 294bed4

2 files changed

Lines changed: 96 additions & 2 deletions

File tree

calendar/calendar.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,18 @@ func parseEventTimestamp(vevent *ics.VEvent, prop ics.ComponentProperty) (time.T
147147

148148
value := p.Value
149149
var tzid string
150+
var isDateOnly bool
150151
if params := p.ICalParameters; params != nil {
151152
if tzids := params["TZID"]; len(tzids) > 0 {
152153
tzid = tzids[0]
153154
}
155+
if vals := params["VALUE"]; len(vals) > 0 && strings.EqualFold(vals[0], "DATE") {
156+
isDateOnly = true
157+
}
158+
}
159+
// RFC 5545 DATE form is YYYYMMDD (8 chars, no time component).
160+
if !isDateOnly && len(value) == 8 {
161+
isDateOnly = true
154162
}
155163

156164
// Try parsing with timezone
@@ -176,8 +184,9 @@ func parseEventTimestamp(vevent *ics.VEvent, prop ics.ComponentProperty) (time.T
176184
return time.Time{}, fmt.Errorf("parse timestamp: %w", err)
177185
}
178186

179-
// Apply timezone if specified
180-
if tzid != "" && !strings.HasSuffix(value, "Z") {
187+
// Apply timezone if specified. RFC 5545: VALUE=DATE has no timezone, so
188+
// TZID must be ignored for date-only values even when present.
189+
if tzid != "" && !strings.HasSuffix(value, "Z") && !isDateOnly {
181190
if loc, locErr := time.LoadLocation(tzid); locErr == nil {
182191
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc)
183192
}

calendar/calendar_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,91 @@ func TestGenerateRSVP(t *testing.T) {
129129
}
130130
}
131131

132+
// buildICS wraps DTSTART/DTEND lines into a minimal VCALENDAR for ParseICS.
133+
func buildICS(dtstart, dtend string) []byte {
134+
return []byte("BEGIN:VCALENDAR\r\n" +
135+
"VERSION:2.0\r\n" +
136+
"PRODID:-//Test//Test//EN\r\n" +
137+
"BEGIN:VEVENT\r\n" +
138+
"UID:date-only@example.com\r\n" +
139+
"DTSTAMP:20260415T120000Z\r\n" +
140+
dtstart + "\r\n" +
141+
dtend + "\r\n" +
142+
"SUMMARY:Test\r\n" +
143+
"END:VEVENT\r\n" +
144+
"END:VCALENDAR\r\n")
145+
}
146+
147+
func TestParseICS_DateOnly(t *testing.T) {
148+
wantStart := time.Date(2026, 4, 21, 0, 0, 0, 0, time.UTC)
149+
wantEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, time.UTC)
150+
151+
tests := []struct {
152+
name string
153+
dtstart string
154+
dtend string
155+
}{
156+
{
157+
name: "VALUE=DATE without TZID",
158+
dtstart: "DTSTART;VALUE=DATE:20260421",
159+
dtend: "DTEND;VALUE=DATE:20260422",
160+
},
161+
{
162+
// Regression: TZID present on a date-only value must be ignored
163+
// (RFC 5545 forbids TZID with VALUE=DATE; some producers emit it anyway).
164+
name: "VALUE=DATE with TZID is ignored",
165+
dtstart: "DTSTART;TZID=America/New_York;VALUE=DATE:20260421",
166+
dtend: "DTEND;TZID=America/New_York;VALUE=DATE:20260422",
167+
},
168+
{
169+
// Shape-only detection: no VALUE param, but YYYYMMDD value with TZID.
170+
name: "YYYYMMDD shape with TZID is treated as date-only",
171+
dtstart: "DTSTART;TZID=America/Los_Angeles:20260421",
172+
dtend: "DTEND;TZID=America/Los_Angeles:20260422",
173+
},
174+
}
175+
176+
for _, tt := range tests {
177+
t.Run(tt.name, func(t *testing.T) {
178+
event, err := ParseICS(buildICS(tt.dtstart, tt.dtend))
179+
if err != nil {
180+
t.Fatalf("ParseICS failed: %v", err)
181+
}
182+
if !event.Start.Equal(wantStart) {
183+
t.Errorf("Start = %v, want %v", event.Start.UTC(), wantStart)
184+
}
185+
if !event.End.Equal(wantEnd) {
186+
t.Errorf("End = %v, want %v", event.End.UTC(), wantEnd)
187+
}
188+
})
189+
}
190+
}
191+
192+
func TestParseICS_TimedWithTZID(t *testing.T) {
193+
// Existing behavior: timed values with TZID keep their zone semantics.
194+
event, err := ParseICS(buildICS(
195+
"DTSTART;TZID=America/New_York:20260421T140000",
196+
"DTEND;TZID=America/New_York:20260421T153000",
197+
))
198+
if err != nil {
199+
t.Fatalf("ParseICS failed: %v", err)
200+
}
201+
202+
loc, err := time.LoadLocation("America/New_York")
203+
if err != nil {
204+
t.Skipf("America/New_York unavailable on this system: %v", err)
205+
}
206+
wantStart := time.Date(2026, 4, 21, 14, 0, 0, 0, loc)
207+
wantEnd := time.Date(2026, 4, 21, 15, 30, 0, 0, loc)
208+
209+
if !event.Start.Equal(wantStart) {
210+
t.Errorf("Start = %v, want %v", event.Start, wantStart)
211+
}
212+
if !event.End.Equal(wantEnd) {
213+
t.Errorf("End = %v, want %v", event.End, wantEnd)
214+
}
215+
}
216+
132217
func TestExtractEmail(t *testing.T) {
133218
tests := []struct {
134219
input string

0 commit comments

Comments
 (0)