/timeline braids every dated thing in projax into a single chronological spine:
CalDAV VTODOs (DUE anchor), VEVENTs (DTSTART), dated item_links (event_date),
and item-creation markers. Default window past-30d to future-90d; ?order=
toggles asc/desc; ?kind= narrows by row type; tree filter (?tag/?mgmt/?has)
applies across kinds. Today / Tomorrow get sticky pills; rows > today+30d
fade. 90s in-memory TTL cache keyed by (filter, window, order, kinds);
busted on any VTODO writeback or dated-link change.
Scope expansion (per head message during 4a): the dashboard Tasks card now
has edit + delete affordances on every row, matching the detail page. New
/dashboard/task/{edit,delete} endpoints share a writeback path with /done.
Timeline VTODO rows reuse the same handlers; HX-Target=timeline-section
selects the re-render surface. Timeline item_link rows reuse the existing
/i/{path}/links/remove handler with the same surface-switch.
VEVENT rows on the timeline remain read-only at v1 (3l decision stands).
Item-creation events render as muted "added X to projax" markers.
Tests cover empty state, dated-doc surfacing, kind-filter narrowing, order
toggle, mixed CalDAV todos + all-day events (with the (2 days) duration
hint), and tag-filter cross-kind. New dashboard test asserts the edit/
delete affordances are wired up.
docs/design.md gains §12 with the full source list, layout rules, time
window, filter integration, cache TTL, and deferred items.
89 lines
2.7 KiB
Go
89 lines
2.7 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/m/projax/caldav"
|
|
"github.com/m/projax/web"
|
|
)
|
|
|
|
// TestDashboardTaskRowHasEditAndDelete wires a fake CalDAV server with one
|
|
// open VTODO, surfaces it on the dashboard Tasks card, and asserts the row
|
|
// gained the edit + delete affordances added in Phase 4a's scope expansion.
|
|
func TestDashboardTaskRowHasEditAndDelete(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
|
|
icsTodo := `BEGIN:VCALENDAR
|
|
BEGIN:VTODO
|
|
UID:dash-edit-1@fake
|
|
SUMMARY:Edit me please
|
|
STATUS:NEEDS-ACTION
|
|
DUE;VALUE=DATE:` + time.Now().UTC().Format("20060102") + `
|
|
END:VTODO
|
|
END:VCALENDAR`
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/dav/calendars/m/Edit/", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(207)
|
|
_, _ = io.WriteString(w, `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
|
<d:response><d:href>/dav/calendars/m/Edit/td-1.ics</d:href><d:propstat><d:prop>
|
|
<d:getetag>"t1"</d:getetag>
|
|
<cal:calendar-data>`+icsTodo+`</cal:calendar-data>
|
|
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
|
</d:multistatus>`)
|
|
})
|
|
fake := httptest.NewServer(mux)
|
|
defer fake.Close()
|
|
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/dav/calendars/m/", "u", "p")}
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
slug := "dash-edit-" + stamp
|
|
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[], 'edit', $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, fake.URL+"/dav/calendars/m/Edit/",
|
|
); 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{
|
|
`Edit me please`,
|
|
`hx-post="/dashboard/task/edit"`,
|
|
`hx-post="/dashboard/task/delete"`,
|
|
`hx-post="/dashboard/task/done"`,
|
|
`data-vtodo-uid="dash-edit-1@fake"`,
|
|
`hx-confirm="Delete this task`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("dashboard task row missing %q", want)
|
|
}
|
|
}
|
|
}
|