feat(phase 3l vevents): VEVENT support on dashboard — closes mgmt-parity gap

caldav package:
- Event struct: UID, Summary, Start, End, AllDay, Location, Description,
  Recurring, URL — read-only, no writeback
- ListEvents(ctx, calendarURL, ListEventsOpts{TimeMin, TimeMax}) issues
  REPORT calendar-query with server-side <c:time-range> filter
- parseVEvents handles DATE vs DATE-TIME (via hasDateOnlyParam since
  splitLine strips ;VALUE=DATE), RRULE-present → Recurring=true with NO
  expansion (literal DTSTART only)
- 2 unit tests: full parse (DATE-TIME, all-day, recurring), hasDateOnlyParam

web dashboard:
- dashboardEvent / dashboardEventGroup types
- collectEvents fans out 4-worker pool across every caldav-list link,
  fixed 7-day window from now, sort start-asc, cap 50, group by day
- dayLabelFor: Today / Tomorrow / weekday-day-month
- Events card on /dashboard between Tasks and Issues, with empty-collapse
- 2 integration tests with stubbed CalDAV: surfaces upcoming + DATE/RRULE
  rendering; empty-collapse with no links

design.md §5 (CalDAV) + §Dashboard updated; mgmt-teardown plan's one
blocking gap is now closed.
This commit is contained in:
mAi
2026-05-16 00:57:52 +02:00
parent 67f2e992e3
commit d49ad219a4
8 changed files with 727 additions and 1 deletions

View File

@@ -59,6 +59,86 @@ func parseVTodos(ics string) []Todo {
return out
}
// parseVEvents extracts every VEVENT block from a calendar-data string.
// Mirrors parseVTodos but for read-only event listing (no writeback). DTSTART
// with VALUE=DATE marks the event all-day; the parser inspects the raw line
// before splitLine drops params. RRULE presence flips Recurring=true; the
// rule itself is intentionally NOT parsed — projax surfaces the literal
// DTSTART occurrence and a recurring badge.
func parseVEvents(ics string) []Event {
ics = unfold(ics)
lines := strings.Split(ics, "\n")
var out []Event
var inEvent bool
var cur Event
for _, ln := range lines {
ln = strings.TrimRight(ln, "\r")
if ln == "BEGIN:VEVENT" {
inEvent = true
cur = Event{}
continue
}
if ln == "END:VEVENT" {
if cur.UID != "" {
out = append(out, cur)
}
inEvent = false
continue
}
if !inEvent {
continue
}
key, val := splitLine(ln)
switch key {
case "UID":
cur.UID = val
case "SUMMARY":
cur.Summary = unescapeText(val)
case "DESCRIPTION":
cur.Description = unescapeText(val)
case "LOCATION":
cur.Location = unescapeText(val)
case "DTSTART":
if t, ok := parseICalTime(val); ok {
cur.Start = t
}
if hasDateOnlyParam(ln) {
cur.AllDay = true
}
case "DTEND":
if t, ok := parseICalTime(val); ok {
cur.End = t
}
case "RRULE":
cur.Recurring = true
}
}
return out
}
// hasDateOnlyParam reports whether the property line carried VALUE=DATE
// (rather than DATE-TIME) before the value separator. This matters because
// splitLine throws params away, so the caller has to inspect the raw line
// to know if the date is all-day or has a clock component.
func hasDateOnlyParam(ln string) bool {
colon := strings.Index(ln, ":")
if colon < 0 {
return false
}
head := ln[:colon]
semi := strings.Index(head, ";")
if semi < 0 {
return false
}
params := strings.ToUpper(head[semi+1:])
for _, p := range strings.Split(params, ";") {
if p == "VALUE=DATE" {
return true
}
}
return false
}
// unfold collapses RFC 5545 line continuations (a CRLF followed by a single
// SP or HT continues the previous line).
func unfold(s string) string {