Files
projax/web/dashboard_test.go
mAi 316b4e408a feat(dashboard): tab strip + Tiles view + view-switcher URL routing
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).
2026-05-26 12:22:32 +02:00

358 lines
13 KiB
Go

package web_test
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/m/projax/gitea"
"github.com/m/projax/web"
)
// TestDashboardRendersWithoutDeps asserts that GET /dashboard?view=tasks
// renders cleanly when CalDAV + Gitea are both disabled (no integrations
// wired). The handler should still render the three card scaffolds and
// "Nothing" copy. Phase 5h: this asserts the Tasks tab; the new default
// /dashboard (Tiles) is covered by TestDashboardTilesViewRenders.
func TestDashboardRendersWithoutDeps(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d body=%s", code, body)
}
// Empty-card collapse (phase 3g) replaces full card chrome with a
// one-line "No open tasks." style note when there is no filter active
// AND zero rows. So the body should contain the collapsed strings.
for _, want := range []string{
`id="dashboard-section"`,
`No open tasks`,
`No open issues`,
`No recent documents`,
} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
}
// TestDashboardRecentDocsSurfacesDatedLinks seeds an item + a dated item_link
// (event_date today), then asserts the dashboard's Recent Documents card
// surfaces the row.
func TestDashboardRecentDocsSurfacesDatedLinks(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 := "dash-doc-" + 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[], 'Dash doc', $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, note, event_date)
values ($1, 'document', $2, 'contains', $3, current_date)`,
id, "https://example.com/dash-doc-"+stamp, fmt.Sprintf("dash test %s", stamp),
); err != nil {
t.Fatalf("seed link: %v", err)
}
// The Recent Documents card 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)
}
wantPER := "dev." + slug + "." + time.Now().UTC().Format("060102")
if !strings.Contains(body, wantPER) {
t.Errorf("dashboard body missing PER %q (event_date today should surface)", wantPER)
}
}
// TestDashboardFilterByTagNarrowsCard seeds two items in different areas, each
// with a dated link, then asserts /dashboard?tag=dev only shows the dev one.
func TestDashboardFilterByTagNarrowsCard(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"), ".", "")
var dev, home 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, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil {
t.Fatalf("home: %v", err)
}
mkItem := func(parent, slug, tag string) string {
var id string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, tags)
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], ARRAY[$4]::text[])
returning id`,
"X "+slug, slug, parent, tag,
).Scan(&id); err != nil {
t.Fatalf("seed %s: %v", slug, err)
}
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel, event_date)
values ($1, 'document', $2, 'contains', current_date)`,
id, "https://example.com/"+slug,
); err != nil {
t.Fatalf("link %s: %v", slug, err)
}
return id
}
devSlug := "filter-dev-" + stamp
homeSlug := "filter-home-" + stamp
devID := mkItem(dev, devSlug, "dev")
homeID := mkItem(home, homeSlug, "home")
defer func() {
for _, id := range []string{devID, homeID} {
_, _ = pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
}
}()
// Doc rows surface on the Tasks tab; the filter narrows both views.
code, body := get(t, h, "/dashboard?tag=dev&view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?tag=dev&view=tasks → %d", code)
}
if !strings.Contains(body, "dev."+devSlug) {
t.Errorf("expected dev row in filtered dashboard")
}
if strings.Contains(body, "home."+homeSlug) {
t.Errorf("home row should be filtered out when ?tag=dev")
}
}
// TestDashboardRefreshBustsCache asserts that ?refresh=1 invalidates the
// cache entry for the matching (filter, view) key: the response no longer
// says "cached" even when called within the 60s TTL of a preceding fetch.
func TestDashboardRefreshBustsCache(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// Prime the cache.
_, _ = get(t, h, "/dashboard")
// Second hit shows cached label.
_, cachedBody := get(t, h, "/dashboard")
if !strings.Contains(cachedBody, "cached") {
n := len(cachedBody)
if n > 600 {
n = 600
}
t.Fatalf("setup: second load should be cached, got body:\n%s", cachedBody[:n])
}
// Third hit with ?refresh=1 should be fresh again.
code, body := get(t, h, "/dashboard?refresh=1")
if code != 200 {
t.Fatalf("GET /dashboard?refresh=1 → %d", code)
}
if strings.Contains(body, "cached") {
t.Errorf("refresh=1 should bust cache — body still contains 'cached'")
}
if !strings.Contains(body, "fresh") {
t.Errorf("refresh=1 response should be 'fresh'")
}
}
// TestDashboardCollapsesEmptyCardsWhenNoFilter checks the 3g empty-collapse
// behaviour on the Tasks tab: when there are zero rows AND no filter active,
// cards render as one-line "No open tasks" muted notes instead of the full
// card chrome.
func TestDashboardCollapsesEmptyCardsWhenNoFilter(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
if !strings.Contains(body, "card-collapsed") {
t.Errorf("expected at least one card-collapsed inline note (no rows + no filter)")
}
// Card chrome should NOT appear for the collapsed sections.
if strings.Contains(body, `class="card card-tasks"`) {
t.Errorf("card-tasks should be collapsed when no tasks and no filter")
}
}
// TestDashboardFilterKeepsFullCardChrome inverse of the above: with a filter
// active the cards stay rendered even when empty, so m can tell whether the
// filter is hiding data or there genuinely isn't any.
func TestDashboardFilterKeepsFullCardChrome(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?tag=nothing-matches-zzz&view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?tag=… → %d", code)
}
if !strings.Contains(body, `class="card card-tasks"`) {
t.Errorf("filter active should keep card-tasks chrome rendered")
}
}
// TestDashboardStaleCardSurfacesDormantMaiProject seeds a mai-managed item
// linked to a fake Gitea repo whose updated_at is 90 days ago. With no open
// tasks or issues, the stale card must list this item.
func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "stale-fix-" + stamp
repoRef := "fake-org/" + slug
// Fake Gitea server returning 90-days-old updated_at for the repo above
// and an empty issue list. /repos/.../issues is called by collectIssues
// even when 0 issues — the handler still needs to return [].
old := time.Now().AddDate(0, 0, -90).UTC().Format(time.RFC3339)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/fake-org/"+slug+"/issues", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "[]")
})
mux.HandleFunc("/api/v1/repos/fake-org/"+slug, func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"full_name":"fake-org/`+slug+`","updated_at":"`+old+`","empty":false}`)
})
fake := httptest.NewServer(mux)
defer fake.Close()
srv.Gitea = web.NewGiteaDeps(gitea.New(fake.URL, "tok"))
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, management)
values (array['project']::text[], 'stale', $1, ARRAY[$2]::uuid[], ARRAY['mai'])
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, 'gitea-repo', $2, 'tracks')`,
id, repoRef,
); err != nil {
t.Fatalf("seed link: %v", err)
}
h := srv.Routes()
// The Stale card lives on the Tasks tab (Phase 5h folds it under
// Quiet on Tiles — that's a separate slice).
code, body := get(t, h, "/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
if !strings.Contains(body, "card-stale") {
t.Fatalf("expected stale card to render — body lacks 'card-stale'")
}
if !strings.Contains(body, "/i/dev."+slug) {
t.Errorf("expected stale list to include /i/dev.%s", slug)
}
}
// TestDashboardStaleCardSkipsRecentRepo asserts the inverse: an item whose
// linked repo has a recent updated_at is NOT flagged as stale.
func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "fresh-fix-" + stamp
repoRef := "fake-org/" + slug
recent := time.Now().AddDate(0, 0, -3).UTC().Format(time.RFC3339)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/fake-org/"+slug+"/issues", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "[]")
})
mux.HandleFunc("/api/v1/repos/fake-org/"+slug, func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"full_name":"fake-org/`+slug+`","updated_at":"`+recent+`","empty":false}`)
})
fake := httptest.NewServer(mux)
defer fake.Close()
srv.Gitea = web.NewGiteaDeps(gitea.New(fake.URL, "tok"))
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, management)
values (array['project']::text[], 'fresh', $1, ARRAY[$2]::uuid[], ARRAY['mai'])
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, 'gitea-repo', $2, 'tracks')`,
id, repoRef,
); err != nil {
t.Fatalf("seed link: %v", err)
}
h := srv.Routes()
// Match the inverse check against the Tasks tab where Stale lives.
_, body := get(t, h, "/dashboard?view=tasks")
// A recent repo creates a tile (under Tiles view) AND a /i/ link on
// the Stale card-collapsed area would be unexpected. The Tasks tab's
// Stale card is what this guards.
if strings.Contains(body, `class="stale-row"`) && strings.Contains(body, "/i/dev."+slug) {
t.Errorf("recent repo should NOT surface in stale card — body contains stale-row with /i/dev.%s", slug)
}
}
// TestDashboardCacheHitOnSecondLoad asserts the in-memory TTL cache returns
// the same payload (and marks Cached=true) on the second request within 60s.
func TestDashboardCacheHitOnSecondLoad(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, _ = get(t, h, "/dashboard")
code, body := get(t, h, "/dashboard")
if code != 200 {
t.Fatalf("second GET /dashboard → %d", code)
}
if !strings.Contains(body, "cached") {
n := len(body)
if n > 500 {
n = 500
}
t.Errorf("second load should hit cache (look for 'cached' label) — body:\n%s", body[:n])
}
}