From 4f012e350c07d6dce5e3314d592d4aa0f4b83c5b Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:00:05 +0400 Subject: [PATCH] calendar: resolve DTSTART against its own TZID parseDT stamped a zoned or floating DTSTART as UTC while every window around it is built in local time, so the two sides of every comparison were in different frames. On a +03 box a 22:00 local event parsed as 22:00Z, past the end of the local day, and the whole evening dropped out of the busy gate and the day plan. A 13:00 Moscow meeting read on a +04 box was recited at 17:00 next to its own printed 13:00. DTSTART now resolves three ways: a Z suffix is UTC, a TZID is loaded from the zone database, and a floating value is read in the caller's location. tzdata is embedded because the deploy image carries none, and a silent fallback to the box offset is the bug being fixed. FactKey and FactValue stamp the owner's clock, so the key date the store range-scans is the same day the plan asks for. FactSummary drops the time tail for callers that print the hour themselves. Found in review of #56 and #58. --- cmd/mavweb/ambient_test.go | 5 +- internal/calendar/calendar.go | 53 ++++++++++++++++--- internal/calendar/calendar_test.go | 85 ++++++++++++++++++++++++++---- internal/calendar/ical.go | 81 +++++++++++++++++++++------- models/stt | 1 + models/tts | 1 + 6 files changed, 189 insertions(+), 37 deletions(-) create mode 120000 models/stt create mode 120000 models/tts diff --git a/cmd/mavweb/ambient_test.go b/cmd/mavweb/ambient_test.go index f6870ac..b77226f 100644 --- a/cmd/mavweb/ambient_test.go +++ b/cmd/mavweb/ambient_test.go @@ -55,7 +55,10 @@ func meetingNotification() calendar.Notification { Package: "com.google.android.gm", Title: "Планёрка", Text: "10:00-10:30", - Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC), + // Local, like a phone relaying from the box's own timezone: the fact + // key and value are stamped on the owner's clock, so a UTC reading + // here would only be testing the offset of the test machine. + Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local), } } diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 01a683d..829a9e0 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -72,15 +72,56 @@ type Event struct { // across polls so re-reading an unchanged calendar rewrites nothing. // // The date prefix is load-bearing — store.CalendarEvents selects a day range -// by key prefix, not by a timestamp column. -func FactKey(e Event) string { - return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary)) +// by key prefix, not by a timestamp column — and it is the OWNER's day, taken +// on the box clock. An event carries the zone its server stated it in, so +// keying off the event's own location would file a 21:00 Moscow meeting under +// a different date than the day plan asks for. +func FactKey(e Event) string { return FactKeyIn(e, time.Local) } + +// FactKeyIn is FactKey against an explicit location. +func FactKeyIn(e Event, loc *time.Location) string { + return fmt.Sprintf("calendar_event_%s_%s", e.Start.In(loc).Format("20060102"), safeKey(e.Summary)) } // FactValue is the human-readable rendering stored as the fact value, and the -// string the day plan and the query path read back. -func FactValue(e Event) string { - return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04")) +// string the day plan and the query path read back. Times are the owner's wall +// clock, for the same reason the key date is. +func FactValue(e Event) string { return FactValueIn(e, time.Local) } + +// FactValueIn is FactValue against an explicit location. +func FactValueIn(e Event, loc *time.Location) string { + return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.In(loc).Format("15:04"), e.End.In(loc).Format("15:04")) +} + +// FactSummary strips the "@ HH:MM-HH:MM" tail FactValue appends, for a caller +// that prints the time itself. The day plan does: without this it renders +// "14:00 — Standup @ 14:00-14:30" and says the hour twice. +func FactSummary(value string) string { + i := strings.LastIndex(value, " @ ") + if i < 0 { + return value + } + tail := value[i+len(" @ "):] + if len(tail) != len("15:04-15:04") { + return value + } + for j, r := range tail { + switch j { + case 2, 8: + if r != ':' { + return value + } + case 5: + if r != '-' { + return value + } + default: + if r < '0' || r > '9' { + return value + } + } + } + return value[:i] } // KeyPrefixForDay is the fact-key prefix covering one calendar day. The store diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index 31d3047..a7ffdbb 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -78,11 +78,13 @@ func TestParseICalDayUsesOwnersDay(t *testing.T) { func TestParseVEVENT(t *testing.T) { block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting" - e, ok := parseVEVENT(block) + e, ok := parseVEVENT(block, time.UTC) if !ok { t.Fatal("expected a parsed event") } - if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) { + // 13:00 Moscow is 10:00Z. Reading it as 13:00Z is the bug that put the + // event three hours late in the day plan. + if !e.Start.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) { t.Errorf("start = %v", e.Start) } if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) { @@ -93,26 +95,34 @@ func TestParseVEVENT(t *testing.T) { } allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day" - if _, ok := parseVEVENT(allDay); ok { + if _, ok := parseVEVENT(allDay, time.UTC); ok { t.Error("all-day event should be rejected") } } func TestParseDT(t *testing.T) { + plus4 := time.FixedZone("+04", 4*60*60) tests := []struct { name string line string + loc *time.Location want time.Time wantOK bool }{ - {"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, - {"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true}, - {"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false}, - {"garbage", "DTSTART:garbage", time.Time{}, false}, + {"UTC", "DTEND:20260703T100000Z", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, + {"tzid", "DTSTART;TZID=Europe/Moscow:20260703T130000", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, + {"tzid quoted", `DTSTART;TZID="Europe/Moscow":20260703T130000`, plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, + {"tzid with other params", "DTSTART;VALUE=DATE-TIME;TZID=Asia/Tokyo:20260703T130000", plus4, time.Date(2026, 7, 3, 4, 0, 0, 0, time.UTC), true}, + // An unloadable zone falls back to the reader's own clock, not to UTC. + {"unknown tzid", "DTSTART;TZID=Mars/Olympus:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true}, + // Floating: no Z, no TZID. Local to whoever reads it. + {"floating", "DTSTART:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true}, + {"all-day", "DTSTART;VALUE=DATE:20260703", plus4, time.Time{}, false}, + {"garbage", "DTSTART:garbage", plus4, time.Time{}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := parseDT(tt.line) + got, ok := parseDT(tt.line, tt.loc) if ok != tt.wantOK { t.Errorf("ok = %v, want %v", ok, tt.wantOK) } @@ -143,20 +153,73 @@ func TestFactKeyAndValue(t *testing.T) { Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC), End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC), } - if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want { + if got, want := FactKeyIn(e, time.UTC), "calendar_event_20260703_Team-sync"; got != want { t.Errorf("FactKey = %q, want %q", got, want) } - if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want { + if got, want := FactValueIn(e, time.UTC), "Team sync @ 14:00-15:00"; got != want { t.Errorf("FactValue = %q, want %q", got, want) } if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want { t.Errorf("KeyPrefixForDay = %q, want %q", got, want) } - if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) { + if !strings.HasPrefix(FactKeyIn(e, time.UTC), KeyPrefixForDay(e.Start)) { t.Error("FactKey must start with the day prefix the store range-scans on") } } +// The key date and the printed time are the owner's, not the calendar +// server's. A 23:00 Moscow event read on a +04 box belongs to the next local +// day, and filing it under the Moscow day would hide it from the day plan the +// store range-scans for. +func TestFactKeyAndValueUseTheOwnersClock(t *testing.T) { + msk := time.FixedZone("MSK", 3*60*60) + plus4 := time.FixedZone("+04", 4*60*60) + e := Event{ + Summary: "Late sync", + Start: time.Date(2026, 7, 3, 23, 30, 0, 0, msk), + End: time.Date(2026, 7, 4, 0, 30, 0, 0, msk), + } + if got, want := FactKeyIn(e, plus4), "calendar_event_20260704_Late-sync"; got != want { + t.Errorf("FactKeyIn = %q, want %q", got, want) + } + if got, want := FactValueIn(e, plus4), "Late sync @ 00:30-01:30"; got != want { + t.Errorf("FactValueIn = %q, want %q", got, want) + } +} + +func TestFactSummaryDropsTheTimeTail(t *testing.T) { + if got, want := FactSummary("Standup @ 14:00-14:30"), "Standup"; got != want { + t.Errorf("FactSummary = %q, want %q", got, want) + } + // Nothing that is not the exact tail FactValue writes is touched. + for _, in := range []string{"Coffee @ home", "Standup", "Standup @ 14:00-14:3", "Standup @ 1a:00-14:30"} { + if got := FactSummary(in); got != in { + t.Errorf("FactSummary(%q) = %q, want it unchanged", in, got) + } + } +} + +// Regression for the mirror image of the window bug: the window is local, so +// the event must be a real instant too. A 22:00 event stated in the poller's +// own zone used to parse as 22:00Z, which on a +03 box is past the end of the +// local day, and the whole evening dropped out of both the busy gate and the +// day plan. +func TestParseICalDayKeepsTheEveningInAZonedCalendar(t *testing.T) { + plus3 := time.FixedZone("+03", 3*60*60) + now := time.Date(2026, 8, 1, 12, 0, 0, 0, plus3) + body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DTSTART;TZID=Europe/Moscow:20260801T220000\nDTEND;TZID=Europe/Moscow:20260801T230000\n" + + "SUMMARY:Evening call\nEND:VEVENT\nEND:VCALENDAR") + + events := ParseICalDay(body, now) + if len(events) != 1 { + t.Fatalf("got %d events, want the evening one", len(events)) + } + if got := events[0].Start.In(plus3).Format("15:04"); got != "22:00" { + t.Errorf("start reads %s locally, want 22:00", got) + } +} + func TestBusyAndOverlapping(t *testing.T) { base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC) events := []Event{ diff --git a/internal/calendar/ical.go b/internal/calendar/ical.go index c7617f7..1aa40de 100644 --- a/internal/calendar/ical.go +++ b/internal/calendar/ical.go @@ -4,12 +4,22 @@ import ( "fmt" "strings" "time" + + // The TZID of a DTSTART names an IANA zone, and resolving it needs the zone + // database. The deploy image has no system tzdata, so embed it: without it + // every zoned event would silently fall back to the box's own offset, which + // is the bug this package had before. + _ "time/tzdata" ) // ParseICal scans iCal text for VEVENT components and returns the events // overlapping [from, to). All-day events are skipped: parseDT reports no time // for a VALUE=DATE value, and an event with no clock reading answers neither // the busy gate nor the day plan. +// +// from's location is the fallback zone for a floating DTSTART — one with +// neither a Z suffix nor a TZID. RFC 5545 says a floating time is local to +// wherever it is read, and here that is the box the poller runs on. func ParseICal(body []byte, from, to time.Time) []Event { var events []Event text := string(body) @@ -26,7 +36,7 @@ func ParseICal(body []byte, from, to time.Time) []Event { block := text[:j] text = text[j+len("END:VEVENT"):] - e, ok := parseVEVENT(block) + e, ok := parseVEVENT(block, from.Location()) if !ok { continue } @@ -40,11 +50,12 @@ func ParseICal(body []byte, from, to time.Time) []Event { // ParseICalDay is ParseICal over the calendar day containing now, in now's own // location — the window cmd/mavcaldav polls. // -// The location matters. The old inline version took the day number off a local -// clock reading but built the boundaries in UTC, so east of Greenwich the -// window was shifted by the offset and part of the evening fell outside -// "today": on a +04 box after 20:00 UTC the poller saw an empty calendar. The -// owner's day is the day the day plan and the busy gate mean. +// The location matters, on both sides of the comparison. An older version took +// the day number off a local clock reading but built the boundaries in UTC, so +// east of Greenwich the window was shifted by the offset and part of the +// evening fell outside "today". Building the window locally is only half the +// fix: parseDT used to stamp a zoned DTSTART as UTC, which lost the mirror +// image of the same evening. Both sides are real instants now. func ParseICalDay(body []byte, now time.Time) []Event { y, m, d := now.Date() start := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) @@ -53,17 +64,17 @@ func ParseICalDay(body []byte, now time.Time) []Event { // parseVEVENT extracts UID, start, end and summary from a VEVENT block. // Reports false for all-day events and parse failures. -func parseVEVENT(block string) (Event, bool) { +func parseVEVENT(block string, loc *time.Location) (Event, bool) { var e Event for _, line := range strings.Split(block, "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "DTSTART"): - if t, ok := parseDT(line); ok { + if t, ok := parseDT(line, loc); ok { e.Start = t } case strings.HasPrefix(line, "DTEND"): - if t, ok := parseDT(line); ok { + if t, ok := parseDT(line, loc); ok { e.End = t } case strings.HasPrefix(line, "SUMMARY"): @@ -85,16 +96,21 @@ func afterColon(line string) string { return "" } -// parseDT parses a DTSTART/DTEND value: +// parseDT parses a DTSTART/DTEND value into a real instant: // // - UTC: DTEND:20260703T100000Z -// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000 -// - All-day: DTSTART;VALUE=DATE:20260703 (rejected) +// - Zoned: DTSTART;TZID=Europe/Moscow:20260703T130000 +// - Floating: DTSTART:20260703T130000 (read in loc) +// - All-day: DTSTART;VALUE=DATE:20260703 (rejected) // -// A local time is read as UTC, the behaviour cmd/mavcaldav has always had: the -// CalDAV server and the poller run in the same timezone, and the busy gate only -// needs busy/not-busy to be right. -func parseDT(line string) (time.Time, bool) { +// A zoned value is resolved against its own TZID, not stamped as UTC. The old +// behaviour was "the server and the poller share a timezone, and the busy gate +// only needs busy/not-busy to be right", and that stopped being enough when the +// day plan started reciting the wall clock: a 13:00 Moscow meeting read as +// 13:00Z was recited at 17:00 on a +04 box, and a 21:00 one fell out of the day +// altogether. An unknown or unloadable TZID falls back to loc, which is the +// closest thing to the reader's own wall clock we have. +func parseDT(line string, loc *time.Location) (time.Time, bool) { if strings.Contains(line, "VALUE=DATE:") { return time.Time{}, false } @@ -102,12 +118,39 @@ func parseDT(line string) (time.Time, bool) { if i < 0 { return time.Time{}, false } - val := strings.TrimSuffix(strings.TrimSpace(line[i+1:]), "Z") - t, err := time.Parse("20060102T150405", val) + if loc == nil { + loc = time.UTC + } + raw := strings.TrimSpace(line[i+1:]) + if strings.HasSuffix(raw, "Z") { + t, err := time.ParseInLocation("20060102T150405", strings.TrimSuffix(raw, "Z"), time.UTC) + if err != nil { + return time.Time{}, false + } + return t, true + } + if tz := tzidOf(line[:i]); tz != "" { + if l, err := time.LoadLocation(tz); err == nil { + loc = l + } + } + t, err := time.ParseInLocation("20060102T150405", raw, loc) if err != nil { return time.Time{}, false } - return t.UTC(), true + return t, true +} + +// tzidOf pulls the TZID out of a property's parameter list ("DTSTART;TZID=..." +// up to the value colon). The value may be quoted, per RFC 5545 param syntax. +func tzidOf(params string) string { + for _, p := range strings.Split(params, ";")[1:] { + if !strings.HasPrefix(strings.ToUpper(p), "TZID=") { + continue + } + return strings.Trim(strings.TrimSpace(p[len("TZID="):]), `"`) + } + return "" } // RenderICal wraps events in a VCALENDAR body suitable for PUTting to a CalDAV diff --git a/models/stt b/models/stt new file mode 120000 index 0000000..b983fa3 --- /dev/null +++ b/models/stt @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/stt \ No newline at end of file diff --git a/models/tts b/models/tts new file mode 120000 index 0000000..66782fb --- /dev/null +++ b/models/tts @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/tts \ No newline at end of file