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).
185 lines
6.4 KiB
Go
185 lines
6.4 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestDashboardDefaultViewIsTiles asserts the default landing surface on
|
|
// /dashboard (no ?view= param) is the Tiles tab — m's Phase 5h pick.
|
|
func TestDashboardDefaultViewIsTiles(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/dashboard")
|
|
if code != 200 {
|
|
t.Fatalf("GET /dashboard → %d", code)
|
|
}
|
|
if !strings.Contains(body, `class="dash-tiles"`) {
|
|
t.Errorf("default view should be Tiles — body lacks 'class=\"dash-tiles\"'")
|
|
}
|
|
if strings.Contains(body, `class="card card-tasks"`) {
|
|
t.Errorf("default view should NOT render the Tasks 5-card layout")
|
|
}
|
|
}
|
|
|
|
// TestDashboardTabsRenderAllThree confirms the tab strip shows the three
|
|
// expected entries (Tiles / Tasks / Events) and marks the active one.
|
|
func TestDashboardTabsRenderAllThree(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
cases := []struct {
|
|
url string
|
|
activeTab string
|
|
activeLabel string
|
|
}{
|
|
{"/dashboard", "tiles", "Tiles"},
|
|
{"/dashboard?view=tasks", "tasks", "Tasks"},
|
|
{"/dashboard?view=events", "events", "Events"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.activeTab, func(t *testing.T) {
|
|
code, body := get(t, h, c.url)
|
|
if code != 200 {
|
|
t.Fatalf("GET %s → %d", c.url, code)
|
|
}
|
|
if !strings.Contains(body, `class="dash-tabs"`) {
|
|
t.Errorf("expected dash-tabs nav element")
|
|
}
|
|
for _, label := range []string{"Tiles", "Tasks", "Events"} {
|
|
if !strings.Contains(body, label+"</a>") {
|
|
t.Errorf("tab strip missing label %q", label)
|
|
}
|
|
}
|
|
// Each <a class="dash-tab ..."> carries many HTMX attrs between
|
|
// the class and the label; look for the active class + the
|
|
// label somewhere later in the body. Approximate but stable.
|
|
activeIdx := strings.Index(body, `class="dash-tab active"`)
|
|
if activeIdx < 0 {
|
|
t.Fatalf("no active tab marker in body")
|
|
}
|
|
// Active label must appear after the active class marker AND
|
|
// within a reasonable window (one tab worth of HTML, ~300 chars).
|
|
window := body[activeIdx:]
|
|
if cut := strings.Index(window, `class="dash-tab"`); cut > 0 {
|
|
window = window[:cut]
|
|
}
|
|
if !strings.Contains(window, c.activeLabel) {
|
|
t.Errorf("active tab should be %q — active-class window does not contain it", c.activeLabel)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDashboardTasksViewFallback confirms that ?view=tasks renders the
|
|
// today's 5-card layout (cards), not the tile grid.
|
|
func TestDashboardTasksViewFallback(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard?view=tasks")
|
|
if strings.Contains(body, `class="dash-tiles"`) {
|
|
t.Errorf("view=tasks should NOT render the Tiles grid")
|
|
}
|
|
// Cards either render with chrome or collapse to muted notes; either
|
|
// shape proves the cards partial dispatched, not Tiles.
|
|
if !strings.Contains(body, "No open tasks") {
|
|
t.Errorf("view=tasks with no deps should show collapsed 'No open tasks' note")
|
|
}
|
|
}
|
|
|
|
// TestDashboardEventsViewRenders confirms that ?view=events renders the
|
|
// promoted Events surface (dash-events-view) and not the cards or tiles.
|
|
func TestDashboardEventsViewRenders(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard?view=events")
|
|
if !strings.Contains(body, `class="dash-events-view"`) {
|
|
t.Errorf("view=events should render the promoted Events surface")
|
|
}
|
|
if strings.Contains(body, `class="dash-tiles"`) {
|
|
t.Errorf("view=events should NOT render the Tiles grid")
|
|
}
|
|
if strings.Contains(body, `class="card card-tasks"`) {
|
|
t.Errorf("view=events should NOT render the Tasks 5-card layout")
|
|
}
|
|
}
|
|
|
|
// TestDashboardUnknownViewFallsBackToTiles confirms graceful default
|
|
// behaviour: an unknown ?view= value renders Tiles, not a 404 or empty.
|
|
func TestDashboardUnknownViewFallsBackToTiles(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/dashboard?view=gibberish")
|
|
if code != 200 {
|
|
t.Fatalf("GET /dashboard?view=gibberish → %d", code)
|
|
}
|
|
if !strings.Contains(body, `class="dash-tiles"`) {
|
|
t.Errorf("unknown view should fall back to Tiles")
|
|
}
|
|
}
|
|
|
|
// TestDashboardTilesViewShowsRollupForSeededItem seeds an item, asserts
|
|
// the Tiles view renders a tile for it (the rollup runs across every
|
|
// active item, regardless of links).
|
|
func TestDashboardTilesViewShowsRollupForSeededItem(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
slug := "tile-target-" + stamp
|
|
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[], 'tile target', $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)
|
|
|
|
code, body := get(t, h, "/dashboard")
|
|
if code != 200 {
|
|
t.Fatalf("GET /dashboard → %d", code)
|
|
}
|
|
if !strings.Contains(body, `data-item-path="dev.`+slug+`"`) {
|
|
t.Errorf("expected tile for dev.%s on default Tiles view", slug)
|
|
}
|
|
// Title is rendered as text inside <a class="tile-title">…</a> with
|
|
// surrounding whitespace; a substring check is enough.
|
|
if !strings.Contains(body, "tile target") {
|
|
t.Errorf("expected tile title 'tile target' to appear in body")
|
|
}
|
|
}
|
|
|
|
// TestDashboardCacheKeySeparatesViews ensures the cache layer keys by
|
|
// (filter, view): the same filter under different views must hit
|
|
// independent cache entries. We prove this by priming /dashboard, then
|
|
// /dashboard?view=tasks, and asserting both report "fresh" on their
|
|
// first call (i.e. they don't share a cache slot).
|
|
func TestDashboardCacheKeySeparatesViews(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body1 := get(t, h, "/dashboard")
|
|
if !strings.Contains(body1, "fresh") {
|
|
t.Fatalf("first /dashboard load should be fresh")
|
|
}
|
|
_, body2 := get(t, h, "/dashboard?view=tasks")
|
|
if !strings.Contains(body2, "fresh") {
|
|
t.Errorf("first /dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
|
|
}
|
|
}
|