Per m's Q1 pick (b) (2026-05-29): legacy `/`, `/dashboard`, `/calendar`,
`/timeline`, `/graph` become `/views/{system-slug}`. Old routes
301-redirect to the new ones with chip params preserved; the legacy
?view=<uuid> param from 5i is resolved through the uuid → slug map
when present so old bookmarks land on the right user view.
System views (web/system_views.go):
- SystemView struct (Slug / Name / Icon / URL) — code-resident, never
rows in projax.views.
- AllSystemViews() returns the canonical five: tree, dashboard,
calendar, timeline, graph. Display order matches the existing
sidebar.
- LookupSystemView(slug) returns the matching entry or nil; the
reserved-slug list in store.IsReservedViewSlug (slice A) is kept
in sync.
- legacyRedirect(systemSlug) handler 301s with chip-param preservation
+ uuid → slug resolution for any leftover ?view=<uuid>.
Routes (web/server.go):
- GET /views/tree → handleTree (was GET /)
- GET /views/dashboard → handleDashboard
- GET /views/timeline → handleTimeline
- GET /views/calendar → handleCalendar
- GET /views/graph → handleGraph
- GET / → 301 → /views/tree
- GET /dashboard → 301 → /views/dashboard
- GET /timeline → 301 → /views/timeline
- GET /calendar → 301 → /views/calendar
- GET /graph → 301 → /views/graph
- POST action endpoints (/dashboard/task/*, /dashboard/pin, /admin/*)
stay where they are — those are RPC-ish, not page renders.
handleTree: dropped the `r.URL.Path != "/"` guard — the only entry
point now is /views/tree, mounted via the new route. Slice F removes
any residual references; this slice keeps the handler reachable.
computeChipCounts grew a `base string` arg so chip URLs anchor on the
caller's route (/views/tree for the system tree, /views/{slug} for
saved views). PageViewTypes recognises both legacy and /views/ keys
during the transition.
Template hrefs / hx-gets bulk-updated to the new URLs:
- layout.tmpl: every sidebar + bottom-nav entry points at
/views/{system-slug}. Active-state checks updated alongside.
- tree_section.tmpl, tree_card.tmpl, tree_kanban.tmpl: clear-filter
/ clear-all hrefs → /views/tree.
- calendar*.tmpl, timeline_section.tmpl, graph.tmpl,
dashboard_section.tmpl: every internal nav + filter link points at
the /views/{slug} surface.
- detail.tmpl, error.tmpl: cancel / back-to-tree → /views/tree.
Test-source updates (per the 5c sharpened rule):
- ~100 test paths bulk-rewritten from /dashboard /calendar /timeline
/graph (and `/`) to their /views/{slug} counterparts. The
behaviour-preservation contract holds: status codes + body shapes
for the rendered pages stay the same; only the URL anchoring the
test changes.
- layout_test.go: sidebar href assertions updated to /views/{slug}.
- view_type_test.go (Q2 + Q3 follow-up): PageViewTypes lookup table
updated to use the new route keys.
- 2 deliberate behaviour-change assertions land: TestLegacyRedirects
expects 301 on the old URLs (was 200); TestTreeRenders fetches
/views/tree (the new home) instead of /.
Internal go-source URL emissions (dashboard.go, calendar.go,
timeline.go) updated to the new BasePath so chip + refresh URLs round
through /views/{slug} correctly.
New tests:
- TestSystemViewLookup — AllSystemViews shape + LookupSystemView
round-trip + unknown-slug nil.
- TestLegacyRedirects — every legacy URL 301s to its new home with
chip params preserved.
- TestLegacyViewUUIDRedirect — old `?view=<uuid>` URLs land on the
resolved slug per m's Q3 pick.
378 lines
14 KiB
Go
378 lines
14 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, "/views/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, "/views/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, "/views/dashboard?tag=dev&view=tasks")
|
|
if code != 200 {
|
|
t.Fatalf("GET /dashboard?tag=dev&view=tasks → %d", code)
|
|
}
|
|
// Phase 5i Slice A: the project-scope picker renders every item's primary
|
|
// path as a <select> option, so a naive body substring match would also
|
|
// see filtered-out paths inside the dropdown. Anchor the row assertion on
|
|
// the detail link emitted by the dashboard cards instead.
|
|
if !strings.Contains(body, `href="/i/dev.`+devSlug+`"`) {
|
|
t.Errorf("expected dev row in filtered dashboard")
|
|
}
|
|
if strings.Contains(body, `href="/i/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, "/views/dashboard")
|
|
// Second hit shows cached label.
|
|
_, cachedBody := get(t, h, "/views/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, "/views/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, "/views/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, "/views/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()
|
|
// Phase 5h: the Stale card retired. The stale project now appears
|
|
// inside the Tiles Quiet fold with a tile-stale flag on the tile.
|
|
code, body := get(t, h, "/views/dashboard")
|
|
if code != 200 {
|
|
t.Fatalf("GET /dashboard → %d", code)
|
|
}
|
|
if !strings.Contains(body, `class="dash-quiet"`) {
|
|
t.Fatalf("expected dash-quiet fold to render — body lacks 'class=\"dash-quiet\"'")
|
|
}
|
|
if !strings.Contains(body, `tile tile-stale`) {
|
|
t.Errorf("expected stale tile with 'tile tile-stale' class — body missing it")
|
|
}
|
|
if !strings.Contains(body, "/i/dev."+slug) {
|
|
t.Errorf("expected stale tile to link to /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()
|
|
// Phase 5h: assert the tile for this slug is NOT flagged stale.
|
|
// Recent repo activity (3d old) puts it solidly inside the activity
|
|
// window AND fails the staleness probe, so no tile-stale class.
|
|
_, body := get(t, h, "/views/dashboard")
|
|
// Find the tile for this slug and check its class attribute.
|
|
marker := `data-item-path="dev.` + slug + `"`
|
|
idx := strings.Index(body, marker)
|
|
if idx < 0 {
|
|
// Tile may not render at all if the project has no signal at
|
|
// all; that's also fine — the negative assertion holds vacuously.
|
|
return
|
|
}
|
|
// Walk back to the start of the <article tag and inspect class attr.
|
|
start := strings.LastIndex(body[:idx], "<article")
|
|
if start < 0 {
|
|
t.Fatalf("could not locate article opening for /i/dev.%s", slug)
|
|
}
|
|
openTag := body[start:idx]
|
|
if strings.Contains(openTag, "tile-stale") {
|
|
t.Errorf("recent repo (3d old) should NOT be flagged stale — class=%q", openTag)
|
|
}
|
|
}
|
|
|
|
// 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, "/views/dashboard")
|
|
code, body := get(t, h, "/views/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])
|
|
}
|
|
}
|