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).
358 lines
13 KiB
Go
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])
|
|
}
|
|
}
|