Phase 5h slice 2 — adds the three-tab dashboard chrome (Tiles / Tasks /
Events) and lands the Tiles view as the default landing surface per
m's §7 pick.
URL contract:
/dashboard — Tiles (default, elided)
/dashboard?view=tasks — today's 5-card layout
/dashboard?view=events — Events card promoted to a full-tab view
Unknown ?view= falls back to Tiles.
Refactor: aggregator calls (Todos / Events / Issues) hoisted up into
buildDashboard so the rollup can consume the same uncapped rows without
a second DAV/Gitea round-trip. The legacy collect* helpers split into
pure projectTasks / projectEvents / projectIssues / projectDocs that
take pre-fetched rows. collectStale extended to return its per-item
repo-activity map alongside the trimmed stale list — the rollup uses
the map as a LastActivity signal.
Cache: key now composes (filter | view=X) so each tab has its own 60s
TTL slot. Tab switches don't poison the cache for siblings.
Tiles render with: pin star (when pinned), title + path + live badge,
counts row (open / overdue! / issues / quiet), NextSignal one-liner
(task wins over issue), and a tile-foot LastActivity stamp.
CSS:
- .dash-tabs strip with active-state border bridge.
- .dash-tiles grid: 1/2/3 cols at 600/900px breakpoints.
- .dash-events-view scaffolding for the promoted Events surface.
Templates: dashboard_section.tmpl restructured to dispatch by .View.
The cards layout is now {{define "dashboard-cards"}} and the
events-only surface is {{define "dashboard-events-view"}}. New
dashboard_tiles.tmpl defines {{define "dashboard-tiles"}}. Both
templates registered in the dashboard + dashboard_section bundles.
Tests:
- Existing dashboard tests retargeted at ?view=tasks for the legacy
Tasks-tab expectations (5-card layout, inline writeback, stale card).
- New dashboard_view_test.go covers: default view = Tiles, three-tab
strip rendering + active marker, view=tasks fallback, view=events
promotion, unknown view fallback, tile rendering for seeded item,
cache-key separation between views.
- TestLayoutNoTopHeader scoped to the body chrome before <main> so it
no longer trips on legitimate <header> elements inside cards/tiles.
Out of scope (later slices): scope chip + Quiet fold (slice 3), pin
toggle handler (slice 4), Events tab dedicated polish (slice 5),
mobile polish (slice 7), design.md addendum (slice 8).
164 lines
5.2 KiB
Go
164 lines
5.2 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"
|
|
)
|
|
|
|
// 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()
|
|
// The card-events markup lives on the Tasks tab (Phase 5h).
|
|
code, body := get(t, h, "/dashboard?view=tasks")
|
|
if code != 200 {
|
|
t.Fatalf("GET /dashboard?view=tasks → %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?view=tasks")
|
|
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>`
|
|
}
|