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).
90 lines
2.8 KiB
Go
90 lines
2.8 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()
|
|
// Inline VTODO writeback rows live 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{
|
|
`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)
|
|
}
|
|
}
|
|
}
|