Merge branch 'mai/knuth/phase-3l-vevents'

This commit is contained in:
mAi
2026-05-16 00:57:57 +02:00
8 changed files with 727 additions and 1 deletions

View File

@@ -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
View 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>`

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 {

View File

@@ -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).

View File

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

View 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>`
}

View File

@@ -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;
}

View File

@@ -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>