Merge branch 'mai/knuth/phase-3l-vevents'
This commit is contained in:
@@ -60,6 +60,29 @@ type Todo struct {
|
||||
Raw string // raw VCALENDAR ICS as returned by the server, preserved for in-place edits
|
||||
}
|
||||
|
||||
// Event is one VEVENT returned by ListEvents. Phase 3l: read-only, no
|
||||
// writeback. RRULE is flagged (Recurring=true) but NOT expanded — the first
|
||||
// DTSTART instance is what the UI shows; m clicks through to his calendar
|
||||
// app for the recurring picture.
|
||||
type Event struct {
|
||||
UID string
|
||||
Summary string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
AllDay bool // DTSTART was VALUE=DATE rather than DATE-TIME
|
||||
Location string
|
||||
Description string
|
||||
Recurring bool // RRULE property present
|
||||
URL string // absolute URL of the .ics resource
|
||||
}
|
||||
|
||||
// ListEventsOpts narrows ListEvents. Both bounds are required (server-side
|
||||
// time-range filter). UTC is assumed.
|
||||
type ListEventsOpts struct {
|
||||
TimeMin time.Time
|
||||
TimeMax time.Time
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, urlStr string, headers map[string]string, body []byte) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, urlStr, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -226,6 +249,76 @@ func (c *Client) ListTodos(ctx context.Context, calendarURL string) ([]Todo, err
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListEvents issues a REPORT calendar-query against a single calendar URL,
|
||||
// restricted by a server-side time-range filter, and returns parsed VEVENTs.
|
||||
// RRULE-bearing events surface with Recurring=true but are NOT expanded; only
|
||||
// the literal DTSTART instance is returned. Recurring expansion is a v2 item
|
||||
// — m has a calendar app for the full picture.
|
||||
func (c *Client) ListEvents(ctx context.Context, calendarURL string, opts ListEventsOpts) ([]Event, error) {
|
||||
tmin := formatICalUTC(opts.TimeMin.UTC())
|
||||
tmax := formatICalUTC(opts.TimeMax.UTC())
|
||||
body := fmt.Appendf(nil, `<?xml version="1.0"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<c:calendar-data/>
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="VEVENT">
|
||||
<c:time-range start="%s" end="%s"/>
|
||||
</c:comp-filter>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>`, tmin, tmax)
|
||||
resp, err := c.do(ctx, "REPORT", calendarURL, map[string]string{
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
}, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 207 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("caldav REPORT VEVENT: %d %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var ms multistatus
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
|
||||
return nil, fmt.Errorf("caldav REPORT VEVENT decode: %w", err)
|
||||
}
|
||||
base, err := url.Parse(calendarURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Event
|
||||
for _, r := range ms.Responses {
|
||||
hrefURL, err := url.Parse(r.Href)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
abs := *base
|
||||
if hrefURL.IsAbs() {
|
||||
abs = *hrefURL
|
||||
} else {
|
||||
abs.Path = hrefURL.Path
|
||||
abs.RawQuery = ""
|
||||
abs.Fragment = ""
|
||||
}
|
||||
for _, ps := range r.PropStat {
|
||||
if ps.Prop.CalendarData == "" {
|
||||
continue
|
||||
}
|
||||
events := parseVEvents(ps.Prop.CalendarData)
|
||||
for i := range events {
|
||||
events[i].URL = abs.String()
|
||||
}
|
||||
out = append(out, events...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateCalendar issues MKCALENDAR for the given absolute URL with the given
|
||||
// display name. Returns ErrCalendarExists when the server reports the URL is
|
||||
// already in use.
|
||||
|
||||
175
caldav/events_test.go
Normal file
175
caldav/events_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestListEventsParse exercises every Event field via a fake REPORT response:
|
||||
// a DATE-TIME event, an all-day DATE event, a recurring RRULE event, plus
|
||||
// fields that are dropped by splitLine's param-stripping (DTSTART;VALUE=DATE)
|
||||
// to confirm the all-day detection happens at the raw-line level.
|
||||
func TestListEventsParse(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/dav/calendars/m/Work/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "REPORT" {
|
||||
t.Errorf("method = %s, want REPORT", r.Method)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if !strings.Contains(string(body), `<c:comp-filter name="VEVENT">`) {
|
||||
t.Errorf("REPORT body missing VEVENT comp-filter: %s", body)
|
||||
}
|
||||
if !strings.Contains(string(body), `<c:time-range`) {
|
||||
t.Errorf("REPORT body missing time-range: %s", body)
|
||||
}
|
||||
w.WriteHeader(207)
|
||||
_, _ = io.WriteString(w, eventsReportBody)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL+"/dav/calendars/m/", "u", "p")
|
||||
opts := ListEventsOpts{
|
||||
TimeMin: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
|
||||
TimeMax: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
events, err := c.ListEvents(context.Background(), c.BaseURL+"Work/", opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ListEvents: %v", err)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("expected 3 events, got %d: %+v", len(events), events)
|
||||
}
|
||||
// Event 1: regular DATE-TIME
|
||||
e1 := events[0]
|
||||
if e1.UID != "ev-1@example" {
|
||||
t.Errorf("events[0].UID = %q", e1.UID)
|
||||
}
|
||||
if e1.Summary != "Meeting with Leonard" {
|
||||
t.Errorf("events[0].Summary = %q", e1.Summary)
|
||||
}
|
||||
if e1.AllDay {
|
||||
t.Errorf("events[0].AllDay = true, want false (DATE-TIME)")
|
||||
}
|
||||
wantStart := time.Date(2026, 5, 16, 10, 0, 0, 0, time.UTC)
|
||||
if !e1.Start.Equal(wantStart) {
|
||||
t.Errorf("events[0].Start = %v, want %v", e1.Start, wantStart)
|
||||
}
|
||||
if e1.Location != "Munich, OG3" {
|
||||
t.Errorf("events[0].Location = %q", e1.Location)
|
||||
}
|
||||
if !strings.HasSuffix(e1.URL, "/Work/ev-1.ics") {
|
||||
t.Errorf("events[0].URL = %q", e1.URL)
|
||||
}
|
||||
if e1.Recurring {
|
||||
t.Errorf("events[0].Recurring = true, want false")
|
||||
}
|
||||
|
||||
// Event 2: all-day (VALUE=DATE)
|
||||
e2 := events[1]
|
||||
if e2.UID != "ev-2@example" {
|
||||
t.Errorf("events[1].UID = %q", e2.UID)
|
||||
}
|
||||
if !e2.AllDay {
|
||||
t.Errorf("events[1].AllDay = false, want true (DATE)")
|
||||
}
|
||||
wantAllDay := time.Date(2026, 5, 17, 0, 0, 0, 0, time.UTC)
|
||||
if !e2.Start.Equal(wantAllDay) {
|
||||
t.Errorf("events[1].Start = %v, want %v", e2.Start, wantAllDay)
|
||||
}
|
||||
|
||||
// Event 3: recurring (RRULE present)
|
||||
e3 := events[2]
|
||||
if e3.UID != "ev-3@example" {
|
||||
t.Errorf("events[2].UID = %q", e3.UID)
|
||||
}
|
||||
if !e3.Recurring {
|
||||
t.Errorf("events[2].Recurring = false, want true (RRULE present)")
|
||||
}
|
||||
// Recurring event should surface only the literal DTSTART, not expanded.
|
||||
wantRecStart := time.Date(2026, 5, 18, 14, 30, 0, 0, time.UTC)
|
||||
if !e3.Start.Equal(wantRecStart) {
|
||||
t.Errorf("events[2].Start = %v, want %v (literal DTSTART, no expansion)", e3.Start, wantRecStart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasDateOnlyParam(t *testing.T) {
|
||||
cases := []struct {
|
||||
line string
|
||||
want bool
|
||||
}{
|
||||
{"DTSTART:20260601T120000Z", false},
|
||||
{"DTSTART;VALUE=DATE:20260601", true},
|
||||
{"DTSTART;VALUE=DATE-TIME:20260601T120000Z", false},
|
||||
{"DTSTART;TZID=Europe/Berlin:20260601T120000", false},
|
||||
{"DTSTART;VALUE=DATE;TZID=foo:20260601", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := hasDateOnlyParam(c.line)
|
||||
if got != c.want {
|
||||
t.Errorf("hasDateOnlyParam(%q) = %v, want %v", c.line, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const eventsReportBody = `<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response>
|
||||
<d:href>/dav/calendars/m/Work/ev-1.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"e1"</d:getetag>
|
||||
<cal:calendar-data>BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:ev-1@example
|
||||
SUMMARY:Meeting with Leonard
|
||||
DTSTART:20260516T100000Z
|
||||
DTEND:20260516T110000Z
|
||||
LOCATION:Munich\, OG3
|
||||
END:VEVENT
|
||||
END:VCALENDAR</cal:calendar-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/calendars/m/Work/ev-2.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"e2"</d:getetag>
|
||||
<cal:calendar-data>BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:ev-2@example
|
||||
SUMMARY:Holiday
|
||||
DTSTART;VALUE=DATE:20260517
|
||||
DTEND;VALUE=DATE:20260518
|
||||
END:VEVENT
|
||||
END:VCALENDAR</cal:calendar-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/calendars/m/Work/ev-3.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"e3"</d:getetag>
|
||||
<cal:calendar-data>BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:ev-3@example
|
||||
SUMMARY:Weekly standup
|
||||
DTSTART:20260518T143000Z
|
||||
DTEND:20260518T150000Z
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
END:VEVENT
|
||||
END:VCALENDAR</cal:calendar-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`
|
||||
@@ -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 {
|
||||
|
||||
@@ -252,7 +252,8 @@ m's CalDAV server lives at `dav.msbls.de/dav/calendars/m/` (SabreDAV, Basic auth
|
||||
- **ICS round-trip**: writes that modify an existing task call `ApplyVTodoEdit` against the server's raw ICS so unknown properties (DESCRIPTION, CATEGORIES, X-extensions, …) survive the round-trip. Only the keys projax knows about (SUMMARY, STATUS, COMPLETED, DUE, PRIORITY, LAST-MODIFIED, DTSTAMP) get rewritten. New tasks go through `BuildVTodoICS` which emits a minimal but valid VCALENDAR wrapper with RFC 5545 folding at 75 octets and CRLF terminators.
|
||||
- **Multi-parent items** keep ONE list per item — the URL is derived from the slug, not the path. `paliad` gets `/dav/calendars/m/paliad/` whether it lives at `work.paliad`, `dev.paliad`, or both.
|
||||
- **Authorisation**: writeback handlers reject calendar URLs not currently linked to the item, so a crafted form can't route writes to arbitrary collections.
|
||||
- **Out of scope (still parked)**: RRULE / recurring VTODOs (rendered as single occurrences until m needs more), background sync, multi-calendar drag-and-drop. Phase 2.c may add a TTL'd `cached_tasks` table if live REPORT-querying gets slow at m's scale.
|
||||
- **VEVENT reading (Phase 3l)** — read-only event listing parallel to VTODO support, closing the mgmt-parity gap before teardown. `caldav.ListEvents(calendarURL, ListEventsOpts{TimeMin, TimeMax})` issues a REPORT calendar-query with a server-side `<c:time-range>` filter and parses VEVENT blocks into an `Event{UID, Summary, Start, End, AllDay, Location, Description, Recurring, URL}` struct. DATE-only DTSTART values are detected at the raw-line level (the param strip in `splitLine` would otherwise lose `VALUE=DATE`); `hasDateOnlyParam` flips `AllDay=true`. RRULE-bearing events surface with `Recurring=true` and only the literal DTSTART instance — projax does NOT expand RRULE at v1; m clicks through to his calendar app for the recurring picture.
|
||||
- **Out of scope (still parked)**: RRULE expansion, VEVENT writeback (create/edit/delete events from projax — calendar app handles), iCal export of projax-managed events, recurring VTODOs, background sync, multi-calendar drag-and-drop. Phase 2.c may add a TTL'd `cached_tasks` table if live REPORT-querying gets slow at m's scale.
|
||||
|
||||
Env contract: `DAV_URL` (default `https://dav.msbls.de/dav/calendars/m/`), `DAV_USER`, `DAV_PASSWORD`. All three live in Dokploy secrets; missing → `/admin/caldav` renders a "not configured" notice and the detail page hides the Tasks section.
|
||||
|
||||
@@ -393,6 +394,10 @@ A single landing surface at `/dashboard` that aggregates open work and recent ac
|
||||
6. **Force-refresh button** — `↻ refresh` link that adds `?refresh=1` to the current URL. The handler invalidates the matching cache key and re-runs the full aggregation. HTMX swaps the section in-place.
|
||||
7. **Empty-card collapse** — when no filter is active AND a card has zero rows, render a one-line `No open tasks.` / `No open issues.` / `No recent documents.` note instead of the full empty-state block. With a filter active the card chrome stays so m can distinguish "filter hid the data" from "no data".
|
||||
|
||||
**Phase 3l addition — Events card (closes the mgmt-parity gap):**
|
||||
|
||||
8. **Events** — every VEVENT in the next 7 days across every `caldav-list` item_link, fanned out via the same 4-worker pool. Time-range filter is server-side (RFC 4791 `<c:time-range>`), so the DAV server returns only what the window contains. Grouped by day with German "Today" / "Tomorrow" / weekday labels lifted from the mgmt cockpit's wording. Sort within day: start asc, summary asc as tiebreaker. Cap 50. Each row: start time (or "ganztägig" for all-day DATE events), project path link, summary, location, and a `↻` badge when the source VEVENT has an RRULE (the dashboard never expands recurrences — only the literal DTSTART instance). Empty-collapse: with no filter and zero events, the card renders "No upcoming events." inline.
|
||||
|
||||
## Graph view (Phase 3f)
|
||||
|
||||
A read-only top-down DAG render of every projax item at `/graph`, server-rendered inline SVG — no client-side layout library, no Excalidraw file. Trade-offs: m gets a single page that prints, downloads, and reflows in a regular browser; no drag-to-rearrange (read-only is enough for the daily glance).
|
||||
|
||||
156
web/dashboard.go
156
web/dashboard.go
@@ -95,6 +95,10 @@ type dashboardPayload struct {
|
||||
Stale []dashboardStale
|
||||
StaleTotal int
|
||||
|
||||
Events []dashboardEventGroup // grouped by day, each group already sorted by start asc
|
||||
EventsFlat []dashboardEvent // flat list (template helper for "next event" sentinel)
|
||||
EventsTotal int
|
||||
|
||||
BuiltAt time.Time
|
||||
Cached bool
|
||||
}
|
||||
@@ -132,6 +136,25 @@ type dashboardDoc struct {
|
||||
ItemPath string
|
||||
}
|
||||
|
||||
// dashboardEvent is one VEVENT surfaced on the dashboard Events card. The
|
||||
// Item it belongs to is resolved (it's the projax item the calendar is linked
|
||||
// to), so a click on the row navigates to /i/{path}/.
|
||||
type dashboardEvent struct {
|
||||
Item *store.Item
|
||||
Event caldav.Event
|
||||
CalendarRef string // calendar URL — kept for cache-bust / debug, not shown
|
||||
DayKey string // YYYY-MM-DD for grouping
|
||||
StartLabel string // "10:00" / "ganztägig" / ""
|
||||
DayLabel string // "Today", "Tomorrow", "Wed 21 May", "Fri 23 May"
|
||||
}
|
||||
|
||||
// dashboardEventGroup bundles a day's events for template-friendly rendering.
|
||||
type dashboardEventGroup struct {
|
||||
DayKey string // YYYY-MM-DD
|
||||
DayLabel string // "Today (3)" — count substituted at render time
|
||||
Events []dashboardEvent
|
||||
}
|
||||
|
||||
// dashboardStale is one mai-managed item whose linked repo is quiet, has no
|
||||
// open issues, and whose linked CalDAV lists hold no open VTODOs. The
|
||||
// "consider archiving?" candidate.
|
||||
@@ -233,6 +256,17 @@ func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashbo
|
||||
p.TaskTotal = total
|
||||
}
|
||||
|
||||
// --- Events card (Phase 3l) ---
|
||||
// Same caldav-list link source as Tasks, time-range filtered to the next
|
||||
// 7 days. Re-uses the 4-worker pool pattern; no extra DAV calls when the
|
||||
// dashboard cache hits.
|
||||
if s.CalDAV != nil {
|
||||
events, flat, total := s.collectEvents(ctx, dashItems, now)
|
||||
p.Events = events
|
||||
p.EventsFlat = flat
|
||||
p.EventsTotal = total
|
||||
}
|
||||
|
||||
// --- Issues card ---
|
||||
if s.Gitea != nil {
|
||||
issues, total := s.collectIssues(ctx, dashItems, now)
|
||||
@@ -706,3 +740,125 @@ func (s *Server) handleDashboardTaskDone(w http.ResponseWriter, r *http.Request)
|
||||
s.dashboard = newDashboardCache(s.dashboard.ttl)
|
||||
s.handleDashboard(w, r)
|
||||
}
|
||||
|
||||
// collectEvents fans out across every (item, caldav-list link) pair using a
|
||||
// 4-worker pool — same pattern as collectTasks. Time window is fixed at the
|
||||
// next 7 days (RFC 4791 §9.9 server-side time-range filter). RRULE-bearing
|
||||
// events surface as a single literal-DTSTART row with Recurring=true; no
|
||||
// expansion. Returns grouped-by-day, flat, and total count.
|
||||
func (s *Server) collectEvents(ctx context.Context, items []*store.Item, now time.Time) ([]dashboardEventGroup, []dashboardEvent, int) {
|
||||
type job struct {
|
||||
item *store.Item
|
||||
link *store.ItemLink
|
||||
}
|
||||
jobs := []job{}
|
||||
for _, it := range items {
|
||||
links, err := s.Store.LinksByType(ctx, it.ID, refTypeCalDAV)
|
||||
if err != nil {
|
||||
s.Logger.Warn("dashboard caldav-events links", "item", it.PrimaryPath(), "err", err)
|
||||
continue
|
||||
}
|
||||
for _, l := range links {
|
||||
jobs = append(jobs, job{item: it, link: l})
|
||||
}
|
||||
}
|
||||
type result struct {
|
||||
item *store.Item
|
||||
ref string
|
||||
events []caldav.Event
|
||||
}
|
||||
results := make(chan result, len(jobs))
|
||||
in := make(chan job, len(jobs))
|
||||
const workers = 4
|
||||
var wg sync.WaitGroup
|
||||
|
||||
opts := caldav.ListEventsOpts{
|
||||
TimeMin: startOfDay(now),
|
||||
TimeMax: startOfDay(now).AddDate(0, 0, 7),
|
||||
}
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := range in {
|
||||
events, err := s.CalDAV.Client.ListEvents(ctx, j.link.RefID, opts)
|
||||
if err != nil {
|
||||
s.Logger.Warn("dashboard list events", "calendar", j.link.RefID, "err", err)
|
||||
continue
|
||||
}
|
||||
results <- result{item: j.item, ref: j.link.RefID, events: events}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, j := range jobs {
|
||||
in <- j
|
||||
}
|
||||
close(in)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
flat := []dashboardEvent{}
|
||||
for r := range results {
|
||||
for _, ev := range r.events {
|
||||
flat = append(flat, dashboardEvent{
|
||||
Item: r.item,
|
||||
Event: ev,
|
||||
CalendarRef: r.ref,
|
||||
DayKey: ev.Start.Format("2006-01-02"),
|
||||
StartLabel: eventStartLabel(ev),
|
||||
DayLabel: dayLabelFor(ev.Start, now),
|
||||
})
|
||||
}
|
||||
}
|
||||
// Sort flat: start asc, summary asc as tiebreaker for stable rendering.
|
||||
sort.Slice(flat, func(i, j int) bool {
|
||||
if !flat[i].Event.Start.Equal(flat[j].Event.Start) {
|
||||
return flat[i].Event.Start.Before(flat[j].Event.Start)
|
||||
}
|
||||
return flat[i].Event.Summary < flat[j].Event.Summary
|
||||
})
|
||||
total := len(flat)
|
||||
if len(flat) > 50 {
|
||||
flat = flat[:50]
|
||||
}
|
||||
|
||||
// Group by DayKey while preserving the start-asc ordering.
|
||||
groups := []dashboardEventGroup{}
|
||||
var cur *dashboardEventGroup
|
||||
for _, e := range flat {
|
||||
if cur == nil || cur.DayKey != e.DayKey {
|
||||
groups = append(groups, dashboardEventGroup{DayKey: e.DayKey, DayLabel: e.DayLabel})
|
||||
cur = &groups[len(groups)-1]
|
||||
}
|
||||
cur.Events = append(cur.Events, e)
|
||||
}
|
||||
return groups, flat, total
|
||||
}
|
||||
|
||||
// eventStartLabel renders an event's start time as "10:00", "ganztägig" for
|
||||
// all-day events, or "" if Start is the zero value.
|
||||
func eventStartLabel(ev caldav.Event) string {
|
||||
if ev.AllDay {
|
||||
return "ganztägig"
|
||||
}
|
||||
if ev.Start.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return ev.Start.Local().Format("15:04")
|
||||
}
|
||||
|
||||
// dayLabelFor returns a short human label for the day containing t, relative
|
||||
// to now: "Today", "Tomorrow", weekday + dd MMM. German weekday names for
|
||||
// consistency with the mgmt cockpit it replaces.
|
||||
func dayLabelFor(t, now time.Time) string {
|
||||
today := startOfDay(now)
|
||||
day := startOfDay(t.Local())
|
||||
switch int(day.Sub(today).Hours() / 24) {
|
||||
case 0:
|
||||
return "Today"
|
||||
case 1:
|
||||
return "Tomorrow"
|
||||
}
|
||||
return day.Format("Mon 02 Jan")
|
||||
}
|
||||
|
||||
|
||||
162
web/dashboard_events_test.go
Normal file
162
web/dashboard_events_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/caldav"
|
||||
"github.com/m/projax/web"
|
||||
)
|
||||
|
||||
// TestDashboardEventsCardSurfacesUpcoming spins up a fake CalDAV server that
|
||||
// answers REPORT for /Work-events/, seeds a projax item with a caldav-list
|
||||
// link pointing at that fake calendar, and asserts /dashboard renders the
|
||||
// Events card with the right row.
|
||||
func TestDashboardEventsCardSurfacesUpcoming(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
|
||||
// Build a fixed-time fake calendar with one tomorrow-event and one all-day
|
||||
// event the day after.
|
||||
now := time.Now().UTC()
|
||||
tomorrow := now.AddDate(0, 0, 1)
|
||||
dayAfter := now.AddDate(0, 0, 2)
|
||||
icsBody := buildFakeEventsReportBody(t, tomorrow, dayAfter)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/dav/calendars/m/Work-evts/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "REPORT" {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(207)
|
||||
_, _ = io.WriteString(w, icsBody)
|
||||
})
|
||||
fake := httptest.NewServer(mux)
|
||||
defer fake.Close()
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/dav/calendars/m/", "u", "p")}
|
||||
|
||||
// Seed projax item under dev with a caldav-list link to the fake calendar.
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
slug := "evt-fix-" + stamp
|
||||
calURL := fake.URL + "/dav/calendars/m/Work-evts/"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var dev, id string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], 'evt', $1, ARRAY[$2]::uuid[])
|
||||
returning id`,
|
||||
slug, dev,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed item: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'caldav-list', $2, 'tracks')`,
|
||||
id, calURL,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/dashboard")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard → %d", code)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`card-events`,
|
||||
`Upcoming dinner`,
|
||||
`Munich`,
|
||||
`/i/dev.` + slug,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("dashboard body missing %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "ganztägig") {
|
||||
t.Errorf("dashboard body should label the DATE-only event as 'ganztägig'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardEventsCardCollapsesWhenEmpty: with no items linked to any
|
||||
// CalDAV calendar and no filter active, the Events card collapses to the
|
||||
// one-line muted note.
|
||||
func TestDashboardEventsCardCollapsesWhenEmpty(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
// Wire a CalDAV client that always returns 0 events.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(207)
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"></d:multistatus>`)
|
||||
})
|
||||
fake := httptest.NewServer(mux)
|
||||
defer fake.Close()
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/", "u", "p")}
|
||||
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
if !strings.Contains(body, "No upcoming events") {
|
||||
t.Errorf("expected collapsed Events card with 'No upcoming events' note")
|
||||
}
|
||||
}
|
||||
|
||||
// buildFakeEventsReportBody returns a CalDAV REPORT multistatus body with one
|
||||
// DATE-TIME event tomorrow at 18:00Z and one all-day DATE event the day
|
||||
// after. Inputs are real UTC times so the test reads against the actual
|
||||
// dashboard "next 7d" window from now.
|
||||
func buildFakeEventsReportBody(t *testing.T, tomorrow, dayAfter time.Time) string {
|
||||
t.Helper()
|
||||
startDT := tomorrow.UTC().Format("20060102T150405Z")
|
||||
endDT := tomorrow.Add(time.Hour).UTC().Format("20060102T150405Z")
|
||||
allDayDate := dayAfter.UTC().Format("20060102")
|
||||
dayAfterPlus1 := dayAfter.AddDate(0, 0, 1).UTC().Format("20060102")
|
||||
return `<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response>
|
||||
<d:href>/dav/calendars/m/Work-evts/ev-1.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"e1"</d:getetag>
|
||||
<cal:calendar-data>BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:ev-1@fake
|
||||
SUMMARY:Upcoming dinner
|
||||
DTSTART:` + startDT + `
|
||||
DTEND:` + endDT + `
|
||||
LOCATION:Munich
|
||||
END:VEVENT
|
||||
END:VCALENDAR</cal:calendar-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/calendars/m/Work-evts/ev-2.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"e2"</d:getetag>
|
||||
<cal:calendar-data>BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:ev-2@fake
|
||||
SUMMARY:All-hands offsite
|
||||
DTSTART;VALUE=DATE:` + allDayDate + `
|
||||
DTEND;VALUE=DATE:` + dayAfterPlus1 + `
|
||||
END:VEVENT
|
||||
END:VCALENDAR</cal:calendar-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`
|
||||
}
|
||||
@@ -399,3 +399,28 @@ table.bulk .chip-add input { padding: 1px 4px; font-size: 0.85em; width: 7em; }
|
||||
}
|
||||
.graph-canvas .fit-screen:hover { background: var(--accent); color: #fff; }
|
||||
.graph-canvas.fit .graph-svg { width: 100%; height: auto; }
|
||||
|
||||
/* --- Dashboard Events card (Phase 3l) --- */
|
||||
.dashboard .card-events .event-day { margin-bottom: 12px; }
|
||||
.dashboard .card-events .event-day:last-child { margin-bottom: 0; }
|
||||
.dashboard .card-events .event-day h3 {
|
||||
font-size: 0.85em; margin: 8px 0 4px; color: var(--muted); font-weight: 500;
|
||||
}
|
||||
.dashboard .card-events .event-list { list-style: none; padding: 0; margin: 0; }
|
||||
.dashboard .card-events .event-row {
|
||||
display: flex; gap: 8px; align-items: baseline;
|
||||
padding: 4px 0; border-bottom: 1px dotted var(--border); flex-wrap: wrap;
|
||||
}
|
||||
.dashboard .card-events .event-row:last-child { border-bottom: none; }
|
||||
.dashboard .card-events .event-row .start {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.88em;
|
||||
color: var(--muted); min-width: 4.5em;
|
||||
}
|
||||
.dashboard .card-events .event-row .proj {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; color: var(--muted);
|
||||
}
|
||||
.dashboard .card-events .event-row .summary { flex: 1; min-width: 8em; }
|
||||
.dashboard .card-events .event-row .loc { font-size: 0.85em; }
|
||||
.dashboard .card-events .event-row .recurring {
|
||||
font-size: 0.85em; color: var(--accent); cursor: help;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,36 @@
|
||||
<p class="card-collapsed muted">No open tasks.</p>
|
||||
{{end}}
|
||||
|
||||
{{if or .P.Events (not $collapse)}}
|
||||
<article class="card card-events">
|
||||
<header>
|
||||
<h2>Events <small class="muted">({{.P.EventsTotal}} upcoming, next 7d)</small></h2>
|
||||
</header>
|
||||
{{if .P.Events}}
|
||||
{{range .P.Events}}
|
||||
<div class="event-day">
|
||||
<h3 class="muted">{{.DayLabel}} <small>({{len .Events}})</small></h3>
|
||||
<ul class="event-list">
|
||||
{{range .Events}}
|
||||
<li class="event-row">
|
||||
<span class="start">{{.StartLabel}}</span>
|
||||
<a class="proj" href="/i/{{.Item.PrimaryPath}}">{{.Item.PrimaryPath}}</a>
|
||||
<span class="summary">{{.Event.Summary}}</span>
|
||||
{{if .Event.Location}}<span class="loc muted">· {{.Event.Location}}</span>{{end}}
|
||||
{{if .Event.Recurring}}<span class="recurring" title="recurring — only literal DTSTART shown">↻</span>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty muted">No events in the next 7 days.</p>
|
||||
{{end}}
|
||||
</article>
|
||||
{{else}}
|
||||
<p class="card-collapsed muted">No upcoming events.</p>
|
||||
{{end}}
|
||||
|
||||
{{if or .P.Issues (not $collapse)}}
|
||||
<article class="card card-issues">
|
||||
<header>
|
||||
|
||||
Reference in New Issue
Block a user