Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice C: full URL migration + system views)

This commit is contained in:
mAi
2026-05-29 11:59:31 +02:00
38 changed files with 379 additions and 183 deletions

View File

@@ -45,7 +45,7 @@ func TestLayoutHasAdminNavLink(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
for _, path := range []string{"/", "/dashboard", "/graph", "/admin/bulk", "/admin/classify"} {
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/graph", "/admin/bulk", "/admin/classify"} {
_, body := get(t, h, path)
if !strings.Contains(body, `href="/admin"`) {
t.Errorf("GET %s: nav missing /admin link", path)

View File

@@ -194,7 +194,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
"Query": q,
"Now": now,
"Projects": projects,
"BasePath": "/calendar",
"BasePath": "/views/calendar",
"ProjectChipTarget": "#calendar-section",
}
if r.Header.Get("HX-Request") == "true" {

View File

@@ -17,7 +17,7 @@ func TestCalendarRendersMonthGrid(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/calendar")
code, body := get(t, h, "/views/calendar")
if code != 200 {
t.Fatalf("GET /calendar → %d body=%s", code, body)
}
@@ -27,7 +27,7 @@ func TestCalendarRendersMonthGrid(t *testing.T) {
`<th scope="col">Mon</th>`,
`<th scope="col">Sun</th>`,
`class="calendar-nav"`,
`href="/calendar?month=`, // prev/next anchors present
`href="/views/calendar?month=`, // prev/next anchors present
} {
if !strings.Contains(body, want) {
t.Errorf("calendar body missing %q", want)
@@ -71,7 +71,7 @@ func TestCalendarSurfacesDatedLink(t *testing.T) {
t.Fatalf("seed link: %v", err)
}
code, body := get(t, h, "/calendar")
code, body := get(t, h, "/views/calendar")
if code != 200 {
t.Fatalf("GET /calendar → %d", code)
}
@@ -130,7 +130,7 @@ func TestCalendarFilterScopeByTag(t *testing.T) {
}
// Unfiltered: both notes show.
_, all := get(t, h, "/calendar?refresh=1")
_, all := get(t, h, "/views/calendar?refresh=1")
if !strings.Contains(all, workNote) {
t.Errorf("unfiltered calendar missing work note %q", workNote)
}
@@ -139,7 +139,7 @@ func TestCalendarFilterScopeByTag(t *testing.T) {
}
// Filtered: only work note shows.
_, scoped := get(t, h, "/calendar?refresh=1&tag=cal-test-work-"+stamp)
_, scoped := get(t, h, "/views/calendar?refresh=1&tag=cal-test-work-"+stamp)
if !strings.Contains(scoped, workNote) {
t.Errorf("filtered calendar missing work note %q", workNote)
}
@@ -157,7 +157,7 @@ func TestCalendarAdjacentMonthDays(t *testing.T) {
h := srv.Routes()
// Pick a month whose first day is NOT a Monday so leading days appear.
// May 2026 starts on a Friday; lead = Apr 27/28/29/30.
_, body := get(t, h, "/calendar?month=2026-05&refresh=1")
_, body := get(t, h, "/views/calendar?month=2026-05&refresh=1")
if !strings.Contains(body, "adjacent-month") {
t.Errorf("expected adjacent-month class on lead-in cells for May 2026, body did not include it")
}
@@ -173,11 +173,11 @@ func TestCalendarNavPrevNextLinks(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
if !strings.Contains(body, `href="/calendar?month=2026-04"`) {
_, body := get(t, h, "/views/calendar?month=2026-05")
if !strings.Contains(body, `href="/views/calendar?month=2026-04"`) {
t.Errorf("expected prev link to 2026-04, body did not include it")
}
if !strings.Contains(body, `href="/calendar?month=2026-06"`) {
if !strings.Contains(body, `href="/views/calendar?month=2026-06"`) {
t.Errorf("expected next link to 2026-06, body did not include it")
}
}
@@ -190,11 +190,11 @@ func TestCalendarFilterChipStripRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
_, body := get(t, h, "/views/calendar?month=2026-05")
for _, want := range []string{
`id="calendar-filterbar"`,
`hx-target="#calendar-section"`,
`hx-get="/calendar"`,
`hx-get="/views/calendar"`,
`<input type="hidden" name="month" value="2026-05">`, // preserves month across chip changes
`name="kind"`,
`name="tag"`,
@@ -213,7 +213,7 @@ func TestCalendarHTMXReturnsSectionOnly(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
req := httptest.NewRequest("GET", "/calendar?month=2026-05", nil)
req := httptest.NewRequest("GET", "/views/calendar?month=2026-05", nil)
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
@@ -294,7 +294,7 @@ func TestCalendarFilterMultiValueTagsFromForm(t *testing.T) {
}
// HTMX-style multi-value submission: two `tag=` params, not comma-joined.
url := "/calendar?refresh=1&tag=" + tagA + "&tag=" + tagB
url := "/views/calendar?refresh=1&tag=" + tagA + "&tag=" + tagB
_, body := get(t, h, url)
// Item AB has BOTH tags — must appear.
@@ -322,7 +322,7 @@ func TestCalendarCellCarriesLongLabel(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
_, body := get(t, h, "/views/calendar?month=2026-05")
// May 4 2026 is a Monday → "Mo., 4. Mai".
if !strings.Contains(body, `Mo., 4. Mai`) {
t.Errorf("expected long label 'Mo., 4. Mai' for 2026-05-04 cell, body did not include it")

View File

@@ -201,7 +201,7 @@ func TestFormatMonthLabel(t *testing.T) {
// month + all-three.
func TestParseCalendarQueryDefaults(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
r := httptest.NewRequest("GET", "/calendar", nil)
r := httptest.NewRequest("GET", "/views/calendar", nil)
q := parseCalendarQuery(r, now)
if q.Month.Format("2006-01") != "2026-05" {
t.Errorf("default month = %s, want 2026-05", q.Month.Format("2006-01"))
@@ -223,7 +223,7 @@ func TestParseCalendarQueryDefaults(t *testing.T) {
// nav writes to this exact key.
func TestParseCalendarQueryMonthParam(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
r := httptest.NewRequest("GET", "/calendar?month=2026-08", nil)
r := httptest.NewRequest("GET", "/views/calendar?month=2026-08", nil)
q := parseCalendarQuery(r, now)
if q.Month.Format("2006-01") != "2026-08" {
t.Errorf("parsed month = %s, want 2026-08", q.Month.Format("2006-01"))
@@ -234,7 +234,7 @@ func TestParseCalendarQueryMonthParam(t *testing.T) {
// kind set and drops unknown values.
func TestParseCalendarQueryKindFilter(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
r := httptest.NewRequest("GET", "/calendar?kind=event,doc,junk,creation", nil)
r := httptest.NewRequest("GET", "/views/calendar?kind=event,doc,junk,creation", nil)
q := parseCalendarQuery(r, now)
got := strings.Join(q.activeKinds(), ",")
want := "doc,event" // sorted alphabetically; creation is excluded by design, junk dropped

View File

@@ -233,7 +233,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
}
refreshQuery += "scope=" + scope
}
refreshURL := "/dashboard?"
refreshURL := "/views/dashboard?"
if refreshQuery != "" {
refreshURL += refreshQuery + "&"
}
@@ -256,7 +256,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
"RefreshURL": refreshURL,
"FilterActive": filter.Active(),
"Projects": projects,
"BasePath": "/dashboard",
"BasePath": "/views/dashboard",
"ProjectChipTarget": "#dashboard-section",
}
if r.Header.Get("HX-Request") == "true" {
@@ -304,9 +304,9 @@ func dashboardScopeToggleURL(view, scope, filterKey string) string {
parts = append(parts, "scope="+next)
}
if len(parts) == 0 {
return "/dashboard"
return "/views/dashboard"
}
return "/dashboard?" + strings.Join(parts, "&")
return "/views/dashboard?" + strings.Join(parts, "&")
}
// dashboardTab is a single entry in the view-switcher strip.
@@ -322,7 +322,7 @@ type dashboardTab struct {
// scope (current) elide from the URL so the address bar stays clean
// on the daily-driver path.
func dashboardTabs(active, filterKey, scope string) []dashboardTab {
prefix := "/dashboard"
prefix := "/views/dashboard"
filterQuery := ""
if filterKey != "__empty__" && filterKey != "" {
filterQuery = filterKey

View File

@@ -70,9 +70,9 @@ END:VCALENDAR`
h := srv.Routes()
// Inline VTODO writeback rows live on the Tasks tab (Phase 5h).
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
t.Fatalf("GET /views/dashboard?view=tasks → %d", code)
}
for _, want := range []string{
`Edit me please`,

View File

@@ -70,9 +70,9 @@ func TestDashboardEventsCardSurfacesUpcoming(t *testing.T) {
h := srv.Routes()
// The card-events markup lives on the Tasks tab (Phase 5h).
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
t.Fatalf("GET /views/dashboard?view=tasks → %d", code)
}
for _, want := range []string{
`card-events`,
@@ -106,7 +106,7 @@ func TestDashboardEventsCardCollapsesWhenEmpty(t *testing.T) {
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/", "u", "p")}
h := srv.Routes()
_, body := get(t, h, "/dashboard?view=tasks")
_, body := get(t, h, "/views/dashboard?view=tasks")
if !strings.Contains(body, "No upcoming events") {
t.Errorf("expected collapsed Events card with 'No upcoming events' note")
}

View File

@@ -49,7 +49,7 @@ func TestDashboardPinTogglesItem(t *testing.T) {
}
// The re-render should mark the tile as .tile-pinned.
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
tileIdx := strings.Index(body, `data-item-id="`+id+`"`)
if tileIdx < 0 {
t.Fatalf("pinned tile not found in re-render")
@@ -141,7 +141,7 @@ func TestDashboardPinInvalidatesCache(t *testing.T) {
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
// Prime the cache — first GET caches an unpinned tile state.
_, primed := get(t, h, "/dashboard")
_, primed := get(t, h, "/views/dashboard")
tileIdx := strings.Index(primed, `data-item-id="`+id+`"`)
if tileIdx < 0 {
t.Fatalf("seeded tile missing from primed dashboard")
@@ -157,7 +157,7 @@ func TestDashboardPinInvalidatesCache(t *testing.T) {
// Next GET must reflect the new pinned state — proves the cache
// entry for the previous (unpinned) state was invalidated.
_, after := get(t, h, "/dashboard")
_, after := get(t, h, "/views/dashboard")
tileIdx2 := strings.Index(after, `data-item-id="`+id+`"`)
if tileIdx2 < 0 {
t.Fatalf("tile missing from post-pin dashboard")

View File

@@ -23,7 +23,7 @@ func TestDashboardRendersWithoutDeps(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d body=%s", code, body)
}
@@ -77,7 +77,7 @@ func TestDashboardRecentDocsSurfacesDatedLinks(t *testing.T) {
}
// The Recent Documents card lives on the Tasks tab (Phase 5h).
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
@@ -134,7 +134,7 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
}()
// Doc rows surface on the Tasks tab; the filter narrows both views.
code, body := get(t, h, "/dashboard?tag=dev&view=tasks")
code, body := get(t, h, "/views/dashboard?tag=dev&view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?tag=dev&view=tasks → %d", code)
}
@@ -159,9 +159,9 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
h := srv.Routes()
// Prime the cache.
_, _ = get(t, h, "/dashboard")
_, _ = get(t, h, "/views/dashboard")
// Second hit shows cached label.
_, cachedBody := get(t, h, "/dashboard")
_, cachedBody := get(t, h, "/views/dashboard")
if !strings.Contains(cachedBody, "cached") {
n := len(cachedBody)
if n > 600 {
@@ -170,7 +170,7 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
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")
code, body := get(t, h, "/views/dashboard?refresh=1")
if code != 200 {
t.Fatalf("GET /dashboard?refresh=1 → %d", code)
}
@@ -190,7 +190,7 @@ func TestDashboardCollapsesEmptyCardsWhenNoFilter(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
@@ -210,7 +210,7 @@ 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")
code, body := get(t, h, "/views/dashboard?tag=nothing-matches-zzz&view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?tag=… → %d", code)
}
@@ -271,7 +271,7 @@ func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) {
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, "/dashboard")
code, body := get(t, h, "/views/dashboard")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
}
@@ -335,7 +335,7 @@ func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) {
// 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, "/dashboard")
_, 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)
@@ -362,8 +362,8 @@ func TestDashboardCacheHitOnSecondLoad(t *testing.T) {
defer pool.Close()
h := srv.Routes()
_, _ = get(t, h, "/dashboard")
code, body := get(t, h, "/dashboard")
_, _ = get(t, h, "/views/dashboard")
code, body := get(t, h, "/views/dashboard")
if code != 200 {
t.Fatalf("second GET /dashboard → %d", code)
}

View File

@@ -13,7 +13,7 @@ func TestDashboardDefaultViewIsTiles(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard")
code, body := get(t, h, "/views/dashboard")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
}
@@ -36,9 +36,9 @@ func TestDashboardTabsRenderAllThree(t *testing.T) {
activeTab string
activeLabel string
}{
{"/dashboard", "tiles", "Tiles"},
{"/dashboard?view=tasks", "tasks", "Tasks"},
{"/dashboard?view=events", "events", "Events"},
{"/views/dashboard", "tiles", "Tiles"},
{"/views/dashboard?view=tasks", "tasks", "Tasks"},
{"/views/dashboard?view=events", "events", "Events"},
}
for _, c := range cases {
t.Run(c.activeTab, func(t *testing.T) {
@@ -80,7 +80,7 @@ func TestDashboardTasksViewFallback(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?view=tasks")
_, body := get(t, h, "/views/dashboard?view=tasks")
if strings.Contains(body, `class="dash-tiles"`) {
t.Errorf("view=tasks should NOT render the Tiles grid")
}
@@ -99,7 +99,7 @@ func TestDashboardEventsViewRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?view=events")
_, body := get(t, h, "/views/dashboard?view=events")
if !strings.Contains(body, `class="dash-events-view"`) {
t.Errorf("view=events should render the promoted Events surface")
}
@@ -120,7 +120,7 @@ func TestDashboardUnknownViewFallsBackToTiles(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=gibberish")
code, body := get(t, h, "/views/dashboard?view=gibberish")
if code != 200 {
t.Fatalf("GET /dashboard?view=gibberish → %d", code)
}
@@ -155,7 +155,7 @@ func TestDashboardTilesViewShowsRollupForSeededItem(t *testing.T) {
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
code, body := get(t, h, "/dashboard")
code, body := get(t, h, "/views/dashboard")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
}
@@ -172,19 +172,19 @@ func TestDashboardTilesViewShowsRollupForSeededItem(t *testing.T) {
// 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
// /views/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")
_, body1 := get(t, h, "/views/dashboard")
if !strings.Contains(body1, "fresh") {
t.Fatalf("first /dashboard load should be fresh")
}
_, body2 := get(t, h, "/dashboard?view=tasks")
_, body2 := get(t, h, "/views/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")
t.Errorf("first /views/dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
}
}
@@ -195,18 +195,18 @@ func TestDashboardScopeChipRendersOnTilesOnly(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, tiles := get(t, h, "/dashboard")
_, tiles := get(t, h, "/views/dashboard")
if !strings.Contains(tiles, `class="dash-scope-chip"`) {
t.Errorf("Tiles view should render the scope chip")
}
if !strings.Contains(tiles, "◇ current") {
t.Errorf("default scope chip should show '◇ current'")
}
_, tasks := get(t, h, "/dashboard?view=tasks")
_, tasks := get(t, h, "/views/dashboard?view=tasks")
if strings.Contains(tasks, `class="dash-scope-chip"`) {
t.Errorf("Tasks view should NOT render the scope chip")
}
_, events := get(t, h, "/dashboard?view=events")
_, events := get(t, h, "/views/dashboard?view=events")
if strings.Contains(events, `class="dash-scope-chip"`) {
t.Errorf("Events view should NOT render the scope chip")
}
@@ -218,7 +218,7 @@ func TestDashboardScopeAllChipFlipsLabel(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?scope=all")
_, body := get(t, h, "/views/dashboard?scope=all")
if !strings.Contains(body, "○ all") {
t.Errorf("scope=all should render '○ all' chip label")
}
@@ -234,7 +234,7 @@ func TestDashboardScopeAllHidesQuietFold(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?scope=all")
_, body := get(t, h, "/views/dashboard?scope=all")
if strings.Contains(body, `class="dash-quiet"`) {
t.Errorf("scope=all should NOT render the Quiet fold — everything is in the primary grid")
}
@@ -246,12 +246,12 @@ func TestDashboardScopeChipURLFlips(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, defaultBody := get(t, h, "/dashboard")
if !strings.Contains(defaultBody, `href="/dashboard?scope=all"`) {
_, defaultBody := get(t, h, "/views/dashboard")
if !strings.Contains(defaultBody, `href="/views/dashboard?scope=all"`) {
t.Errorf("default scope chip should link to ?scope=all")
}
_, allBody := get(t, h, "/dashboard?scope=all")
if !strings.Contains(allBody, `href="/dashboard"`) {
_, allBody := get(t, h, "/views/dashboard?scope=all")
if !strings.Contains(allBody, `href="/views/dashboard"`) {
t.Errorf("scope=all chip should link back to /dashboard (scope=current is default+elided)")
}
}

View File

@@ -16,7 +16,7 @@ func TestGraphPageRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/graph")
code, body := get(t, h, "/views/graph")
if code != 200 {
t.Fatalf("GET /graph → %d body=%s", code, body)
}
@@ -42,7 +42,7 @@ func TestGraphFilterDimsNonMatching(t *testing.T) {
h := srv.Routes()
// Use a definitely-unused tag to force every node to mismatch.
code, body := get(t, h, "/graph?tag=ZZZZ-unused-tag")
code, body := get(t, h, "/views/graph?tag=ZZZZ-unused-tag")
if code != 200 {
t.Fatalf("GET /graph?tag=ZZZ → %d", code)
}
@@ -83,7 +83,7 @@ func TestGraphIsolateHidesNonMatching(t *testing.T) {
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
code, body := get(t, h, "/graph?tag="+tag+"&isolate=1")
code, body := get(t, h, "/views/graph?tag="+tag+"&isolate=1")
if code != 200 {
t.Fatalf("GET /graph?isolate → %d", code)
}
@@ -102,7 +102,7 @@ func TestGraphSVGDownload(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
req := httptest.NewRequest(http.MethodGet, "/graph?download=svg", nil)
req := httptest.NewRequest(http.MethodGet, "/views/graph?download=svg", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != 200 {

View File

@@ -13,18 +13,18 @@ func TestLayoutSidebarOnDesktop(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
if !strings.Contains(body, `<aside class="projax-sidebar"`) {
t.Fatalf("expected <aside class=\"projax-sidebar\"> in body, got: %s", truncate(body, 400))
}
for _, want := range []struct {
href, label string
}{
{`/`, "Tree"},
{`/dashboard`, "Dashboard"},
{`/calendar`, "Calendar"},
{`/timeline`, "Timeline"},
{`/graph`, "Graph"},
{`/views/tree`, "Tree"},
{`/views/dashboard`, "Dashboard"},
{`/views/calendar`, "Calendar"},
{`/views/timeline`, "Timeline"},
{`/views/graph`, "Graph"},
{`/admin`, "Admin"},
} {
if !strings.Contains(body, `href="`+want.href+`"`) {
@@ -43,12 +43,12 @@ func TestLayoutActiveClass(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Dashboard item should be active.
if !strings.Contains(body, `class="nav-item active" title="Dashboard"`) {
t.Errorf("expected Dashboard nav-item to carry .active on /dashboard, body: %s", truncate(body, 400))
}
// Tree item (href="/") must NOT be active on the /dashboard page.
// Tree item (href="/views/tree") must NOT be active on the /dashboard page.
// The Tree anchor opens with the exact-path active match; on /dashboard
// the substring `class="nav-item" title="Tree"` should be present and
// not its `active` sibling.
@@ -68,7 +68,7 @@ func TestLayoutCollapseScript(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Pre-paint restore script.
if !strings.Contains(body, `localStorage.getItem('projax.sidebar.collapsed')`) {
t.Errorf("expected pre-paint localStorage restore script in layout")
@@ -93,7 +93,7 @@ func TestLayoutNoTopHeader(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Slice out the region between <body> and <main> — that's where the
// pre-5g top header lived. Inside <main> belongs to content templates.
chrome := body
@@ -116,17 +116,17 @@ func TestLayoutBottomNavMarkup(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
if !strings.Contains(body, `<nav class="projax-bottom-nav"`) {
t.Fatalf("expected <nav class=\"projax-bottom-nav\"> in body, got: %s", truncate(body, 400))
}
// 5-slot anchors / details element.
for _, want := range []string{
`<a href="/" class="bottom-nav-item`,
`<a href="/dashboard" class="bottom-nav-item`,
`<a href="/views/tree" class="bottom-nav-item`,
`<a href="/views/dashboard" class="bottom-nav-item`,
`<a href="/new" class="bottom-nav-item capture-btn"`,
`class="capture-circle"`,
`<a href="/calendar" class="bottom-nav-item`,
`<a href="/views/calendar" class="bottom-nav-item`,
`<details class="projax-mobile-drawer"`,
} {
if !strings.Contains(body, want) {
@@ -135,8 +135,8 @@ func TestLayoutBottomNavMarkup(t *testing.T) {
}
// Drawer overflow items: Timeline, Graph, Admin, theme toggle, sign-out.
for _, want := range []string{
`<a href="/timeline" class="drawer-item`,
`<a href="/graph" class="drawer-item`,
`<a href="/views/timeline" class="drawer-item`,
`<a href="/views/graph" class="drawer-item`,
`<a href="/admin" class="drawer-item`,
`id="theme-toggle-drawer"`,
`<form method="post" action="/logout" class="drawer-form">`,
@@ -154,11 +154,11 @@ func TestLayoutBottomNavActiveClass(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar")
if !strings.Contains(body, `<a href="/calendar" class="bottom-nav-item active"`) {
_, body := get(t, h, "/views/calendar")
if !strings.Contains(body, `<a href="/views/calendar" class="bottom-nav-item active"`) {
t.Errorf("expected Calendar bottom-nav-item to carry .active on /calendar")
}
if strings.Contains(body, `<a href="/" class="bottom-nav-item active"`) {
if strings.Contains(body, `<a href="/views/tree" class="bottom-nav-item active"`) {
t.Errorf("Tree bottom-nav-item should NOT be active on /calendar")
}
}
@@ -171,7 +171,7 @@ func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Both buttons present.
if !strings.Contains(body, `id="theme-toggle"`) {
t.Errorf("sidebar theme-toggle button missing")

View File

@@ -245,14 +245,14 @@ func TestTreeFilterPublicNarrows(t *testing.T) {
// filter.
pubLink := `href="/i/dev.` + pubSlug + `"`
prvLink := `href="/i/dev.` + prvSlug + `"`
_, yesBody := get(t, h, "/?public=1")
_, yesBody := get(t, h, "/views/tree?public=1")
if !strings.Contains(yesBody, pubLink) {
t.Errorf("?public=1 should show pub-filt-yes row")
}
if strings.Contains(yesBody, prvLink) {
t.Errorf("?public=1 should hide pub-filt-no row")
}
_, noBody := get(t, h, "/?public=0")
_, noBody := get(t, h, "/views/tree?public=0")
if strings.Contains(noBody, pubLink) {
t.Errorf("?public=0 should hide pub-filt-yes row")
}

View File

@@ -81,7 +81,7 @@ func TestLayoutHasManifestAndAppleTouchIcon(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
for _, want := range []string{
`rel="manifest"`,
`/static/manifest.webmanifest`,

View File

@@ -377,17 +377,26 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.handleTree)
// Phase 5j slice C — full URL migration. The five legacy pages live at
// /views/{system-slug} now; the old top-level URLs 301-redirect to
// their new home (with the legacy ?view=<uuid> param resolved through
// the old uuid → new slug if it still maps to a row).
mux.HandleFunc("GET /views/tree", s.handleTree)
mux.HandleFunc("GET /views/dashboard", s.handleDashboard)
mux.HandleFunc("GET /views/timeline", s.handleTimeline)
mux.HandleFunc("GET /views/calendar", s.handleCalendar)
mux.HandleFunc("GET /views/graph", s.handleGraph)
mux.HandleFunc("GET /", s.legacyRedirect("tree"))
mux.HandleFunc("GET /dashboard", s.legacyRedirect("dashboard"))
mux.HandleFunc("GET /timeline", s.legacyRedirect("timeline"))
mux.HandleFunc("GET /calendar", s.legacyRedirect("calendar"))
mux.HandleFunc("GET /graph", s.legacyRedirect("graph"))
mux.HandleFunc("GET /i/", s.handleDetail)
mux.HandleFunc("POST /i/", s.handleDetailWrite)
mux.HandleFunc("GET /new", s.handleNewForm)
mux.HandleFunc("POST /new", s.handleNewSubmit)
mux.HandleFunc("GET /admin", s.handleAdminIndex)
mux.HandleFunc("GET /admin/classify", s.handleClassify)
mux.HandleFunc("GET /dashboard", s.handleDashboard)
mux.HandleFunc("GET /timeline", s.handleTimeline)
mux.HandleFunc("GET /calendar", s.handleCalendar)
mux.HandleFunc("GET /graph", s.handleGraph)
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
mux.HandleFunc("POST /dashboard/task/edit", s.handleDashboardTaskEdit)
mux.HandleFunc("POST /dashboard/task/delete", s.handleDashboardTaskDelete)
@@ -452,10 +461,9 @@ type treeNode struct {
}
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
// Phase 5j slice C: handleTree is reached at /views/tree (system view)
// only. The legacy / route 301-redirects via legacyRedirect — see
// Routes(). Any 404-on-unknown-path responsibility moved with it.
items, err := s.Store.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
@@ -480,7 +488,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
// handler (slice C). handleTree stays focused on the tree-as-tree
// surface and no longer hijacks itself based on a query param.
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
counts := computeChipCounts(items, filter, linkKinds, tags)
// Phase 5j slice C: tree lives at /views/tree now. Chip URLs need to
// anchor on the new base so chip clicks stay on this page.
const treeBase = "/views/tree"
counts := computeChipCounts(items, filter, linkKinds, tags, treeBase)
// Phase 5i Slice B: the card view renders a flat grid of matched items
// (no tree structure). Build from items + filter directly rather than
// reusing the post-prune `roots` (which still keeps ancestors).
@@ -488,7 +499,7 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
// Phase 5i Slice C: kanban groups the same matched set into columns.
groupBy := ParseGroupBy(r.URL.Query())
kanban := BuildKanbanBoard(cardItems, groupBy)
groupByChips := GroupByChips("/", filter, groupBy)
groupByChips := GroupByChips(treeBase, filter, groupBy)
data := map[string]any{
"Title": "tree",
"Roots": roots,
@@ -500,10 +511,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
"Filter": filter,
"Counts": counts,
"Projects": parentOptionsFromItems(items),
"BasePath": "/",
"BasePath": treeBase,
"ProjectChipTarget": "#tree-section",
"ViewType": view,
"ViewTypeChips": ViewTypeChips("/", filter, view),
"ViewTypeChips": ViewTypeChips(treeBase, filter, view),
"CardItems": cardItems,
"Kanban": kanban,
"GroupBy": groupBy,

View File

@@ -81,9 +81,9 @@ func TestTreeRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/")
code, body := get(t, h, "/views/tree")
if code != 200 {
t.Fatalf("GET / status %d body=%s", code, body)
t.Fatalf("GET /views/tree status %d body=%s", code, body)
}
// /admin/classify used to live in the nav; Phase 3o consolidated all
// admin links under the new /admin index. Assert /admin instead.
@@ -102,7 +102,7 @@ func TestLayoutHasViewportMeta(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
for _, path := range []string{"/", "/dashboard", "/calendar", "/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/calendar", "/views/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
_, body := get(t, h, path)
if !strings.Contains(body, `name="viewport"`) {
t.Errorf("GET %s: missing <meta name=\"viewport\">", path)
@@ -302,7 +302,7 @@ func TestTreeRendersKanbanWhenViewTypeIsKanban(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/?view_type=kanban")
_, body := get(t, h, "/views/tree?view_type=kanban")
if !strings.Contains(body, `class="kanban-board"`) {
t.Error("?view_type=kanban should render the kanban board")
}
@@ -324,7 +324,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
defer pool.Close()
h := srv.Routes()
// List view (default): forest markup expected; tree-card-grid absent.
_, listBody := get(t, h, "/")
_, listBody := get(t, h, "/views/tree")
if !strings.Contains(listBody, `<ul class="forest">`) {
t.Error("default GET / should render the tree forest")
}
@@ -335,7 +335,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
t.Error("view-type chip strip should appear on every view")
}
// Card view: card grid present, forest absent.
_, cardBody := get(t, h, "/?view_type=card")
_, cardBody := get(t, h, "/views/tree?view_type=card")
if !strings.Contains(cardBody, `class="tree-card-grid"`) {
t.Error("GET /?view_type=card should render the card grid")
}
@@ -343,7 +343,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
t.Error("GET /?view_type=card should not render the tree forest")
}
// Unknown view_type falls back to list.
_, unknownBody := get(t, h, "/?view_type=junk")
_, unknownBody := get(t, h, "/views/tree?view_type=junk")
if !strings.Contains(unknownBody, `<ul class="forest">`) {
t.Error("unknown view_type should fall back to list")
}
@@ -393,7 +393,7 @@ func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
siblingLink := `href="/i/dev.` + siblingSlug + `"`
// Descendants on (default): parent + child visible, sibling hidden.
_, withDesc := get(t, h, "/?project="+parentPath)
_, withDesc := get(t, h, "/views/tree?project="+parentPath)
if !strings.Contains(withDesc, parentLink) {
t.Errorf("?project=%s should show parent row", parentPath)
}
@@ -405,7 +405,7 @@ func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
}
// Descendants off: only the picked item, no children.
_, noDesc := get(t, h, "/?project="+parentPath+"&project_descendants=0")
_, noDesc := get(t, h, "/views/tree?project="+parentPath+"&project_descendants=0")
if !strings.Contains(noDesc, parentLink) {
t.Errorf("?project_descendants=0 should still show the picked parent row")
}

87
web/system_views.go Normal file
View File

@@ -0,0 +1,87 @@
package web
import (
"net/http"
"strings"
)
// Phase 5j Slice C — system views. Per m's Q1 pick (b) (2026-05-29):
// FULL MIGRATION of the legacy pages into the /views/{slug} family.
// /, /dashboard, /calendar, /timeline, /graph all 301-redirect to their
// /views/{system-slug} counterparts; the handlers stay (now reachable
// under the new URL).
//
// System views are code-resident — they never appear as rows in
// projax.views. Their slugs are reserved at the validator level (see
// store.IsReservedViewSlug) so user-created views can't shadow them.
// SystemView is a code-resident view definition. The sidebar's Views
// section (slice E) lists every entry returned by AllSystemViews
// alongside user views. The render path for system slugs goes directly
// to the legacy handler (handleTree / handleDashboard / …); the struct
// here is metadata for navigation, not a render spec.
type SystemView struct {
Slug string
Name string
Icon string
URL string // /views/{slug}
}
// AllSystemViews returns every code-resident view in display order. Used
// by the sidebar (slice E) and the reserved-slug validation (slice A
// already pre-seeded the same slugs in store.IsReservedViewSlug — keep
// in sync with this list).
func AllSystemViews() []SystemView {
return []SystemView{
{Slug: "tree", Name: "Tree", Icon: "tree", URL: "/views/tree"},
{Slug: "dashboard", Name: "Dashboard", Icon: "dashboard", URL: "/views/dashboard"},
{Slug: "calendar", Name: "Calendar", Icon: "calendar", URL: "/views/calendar"},
{Slug: "timeline", Name: "Timeline", Icon: "clock", URL: "/views/timeline"},
{Slug: "graph", Name: "Graph", Icon: "graph", URL: "/views/graph"},
}
}
// LookupSystemView returns the SystemView matching slug, or nil. Used by
// handleViewRender's fallback path and by tests that need to assert
// metadata.
func LookupSystemView(slug string) *SystemView {
for _, sv := range AllSystemViews() {
if sv.Slug == slug {
s := sv
return &s
}
}
return nil
}
// legacyRedirect returns a handler that 301s the legacy URL onto its
// /views/{system-slug} counterpart. Per m's Q3 pick (b): when the
// request carries a legacy `?view=<uuid>` param (the 5i overlay scheme)
// the redirect resolves the uuid → current slug so old bookmarks land
// on the user view they pointed at. A miss falls through to the system
// slug.
func (s *Server) legacyRedirect(systemSlug string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// / is a path-prefix in Go's mux; only redirect when the request
// path is exactly "/". Any other root-relative path that fell
// through to GET / (e.g. "/some-unknown") gets a 404.
if systemSlug == "tree" && r.URL.Path != "/" {
http.NotFound(w, r)
return
}
target := "/views/" + systemSlug
if id := strings.TrimSpace(r.URL.Query().Get("view")); id != "" {
if v, err := s.Store.GetViewByID(r.Context(), id); err == nil && v != nil {
target = "/views/" + v.Slug
}
}
// Preserve any non-`view` query params so existing bookmarks
// carrying ?tag=… etc. still narrow the redirected view.
q := r.URL.Query()
q.Del("view")
if encoded := q.Encode(); encoded != "" {
target += "?" + encoded
}
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
}

94
web/system_views_test.go Normal file
View File

@@ -0,0 +1,94 @@
package web_test
import (
"context"
"strings"
"testing"
"time"
"github.com/m/projax/web"
)
// TestSystemViewLookup verifies the code-resident lookup returns the
// expected slugs in display order, and that LookupSystemView round-trips
// each entry.
func TestSystemViewLookup(t *testing.T) {
all := web.AllSystemViews()
wantSlugs := []string{"tree", "dashboard", "calendar", "timeline", "graph"}
if len(all) != len(wantSlugs) {
t.Fatalf("AllSystemViews len = %d, want %d", len(all), len(wantSlugs))
}
for i, sv := range all {
if sv.Slug != wantSlugs[i] {
t.Errorf("position %d: slug = %q, want %q", i, sv.Slug, wantSlugs[i])
}
if sv.URL != "/views/"+sv.Slug {
t.Errorf("position %d: URL = %q, want /views/%s", i, sv.URL, sv.Slug)
}
round := web.LookupSystemView(sv.Slug)
if round == nil || round.Slug != sv.Slug {
t.Errorf("LookupSystemView(%q) round-trip failed", sv.Slug)
}
}
if web.LookupSystemView("not-a-system-slug") != nil {
t.Error("LookupSystemView should return nil for unknown slugs")
}
}
// TestLegacyRedirects verifies the slice C URL migration: each legacy
// route 301-redirects to its /views/{slug} counterpart with chip params
// preserved.
func TestLegacyRedirects(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
cases := []struct {
path, want string
}{
{"/", "/views/tree"},
{"/dashboard", "/views/dashboard"},
{"/calendar", "/views/calendar"},
{"/timeline", "/views/timeline"},
{"/graph", "/views/graph"},
// chip params survive the redirect:
{"/dashboard?tag=work", "/views/dashboard?tag=work"},
{"/timeline?from=2026-05-01", "/views/timeline?from=2026-05-01"},
}
for _, tc := range cases {
code, body := get(t, h, tc.path)
if code != 301 {
t.Errorf("GET %s status=%d body=%q, want 301", tc.path, code, body)
}
if !strings.Contains(body, `href="`+tc.want+`"`) {
t.Errorf("GET %s body=%q, want redirect to %q", tc.path, body, tc.want)
}
}
}
// TestLegacyViewUUIDRedirect — when a legacy URL carries the 5i overlay
// `?view=<uuid>` param, the redirect resolves the uuid to the current
// slug (per m's Q3 pick), so old bookmarks land on the right user view.
func TestLegacyViewUUIDRedirect(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx := context.Background()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-c-legacy-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
var id string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'Legacy', '{"view_type":"list"}'::jsonb)
RETURNING id`, slug).Scan(&id); err != nil {
t.Fatalf("seed view: %v", err)
}
// Old-style URL: /?view=<uuid>
code, body := get(t, h, "/?view="+id)
if code != 301 {
t.Fatalf("GET /?view=<uuid> status=%d body=%q want 301", code, body)
}
if !strings.Contains(body, "/views/"+slug) {
t.Errorf("redirect should resolve uuid → slug; got body=%q", body)
}
}

View File

@@ -3,9 +3,9 @@
<header class="calendar-header">
<h1>{{.P.MonthLabel}}</h1>
<nav class="calendar-nav" aria-label="Monatsnavigation">
<a class="prev" href="/calendar?month={{.P.PrevMonth}}{{with .Filter.QueryString}}&amp;{{.}}{{end}}">&lt; {{.P.PrevMonth}}</a>
<a class="today" href="/calendar{{with .Filter.QueryString}}?{{.}}{{end}}">heute</a>
<a class="next" href="/calendar?month={{.P.NextMonth}}{{with .Filter.QueryString}}&amp;{{.}}{{end}}">{{.P.NextMonth}} &gt;</a>
<a class="prev" href="/views/calendar?month={{.P.PrevMonth}}{{with .Filter.QueryString}}&amp;{{.}}{{end}}">&lt; {{.P.PrevMonth}}</a>
<a class="today" href="/views/calendar{{with .Filter.QueryString}}?{{.}}{{end}}">heute</a>
<a class="next" href="/views/calendar?month={{.P.NextMonth}}{{with .Filter.QueryString}}&amp;{{.}}{{end}}">{{.P.NextMonth}} &gt;</a>
</nav>
</header>
{{template "calendar-section" .}}

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="calendar-filterbar">
<form id="calendar-filter" class="search"
hx-get="/calendar"
hx-get="/views/calendar"
hx-target="#calendar-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
@@ -39,7 +39,7 @@
</label>
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
{{if .Filter.Active}}<a class="clear" href="/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
{{if .Filter.Active}}<a class="clear" href="/views/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
@@ -83,7 +83,7 @@
</ul>
{{end}}
{{if gt .ExtraCount 0}}
<a class="cell-more muted" href="/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">+{{.ExtraCount}} more</a>
<a class="cell-more muted" href="/views/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">+{{.ExtraCount}} more</a>
{{end}}
</td>
{{end}}

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="dashboard-filterbar">
<form id="dashboard-filter" class="search"
hx-get="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
hx-get="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
@@ -32,7 +32,7 @@
{{if ne .View "tiles"}}<input type="hidden" name="view" value="{{.View}}">{{end}}
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
{{if .Filter.Active}}<a class="clear" href="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
{{if .Filter.Active}}<a class="clear" href="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}

View File

@@ -122,7 +122,7 @@
<summary class="proj-section-summary">Timeline behaviour {{if .Item.TimelineExclude}}<small class="muted">(hiding {{len .Item.TimelineExclude}})</small>{{end}}</summary>
<fieldset class="timeline-exclude">
<legend class="visually-hidden">Timeline behaviour</legend>
<p class="muted">Check a kind to hide it from <a href="/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
<p class="muted">Check a kind to hide it from <a href="/views/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/views/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
{{$ex := .Item.TimelineExclude}}
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="todos" {{if contains $ex "todos"}}checked{{end}}> exclude todos (VTODOs from linked calendars)</label>
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="events" {{if contains $ex "events"}}checked{{end}}> exclude events (VEVENTs from linked calendars)</label>
@@ -133,7 +133,7 @@
<div class="actions">
<button type="submit">Save</button>
<a class="cancel" href="/">Cancel</a>
<a class="cancel" href="/views/tree">Cancel</a>
</div>
</form>

View File

@@ -1,5 +1,5 @@
{{define "content"}}
<h1>Error</h1>
<p class="error">{{.Message}}</p>
<p><a href="/">Back to tree</a></p>
<p><a href="/views/tree">Back to tree</a></p>
{{end}}

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="graph-filterbar">
<form id="graph-filter" class="search"
hx-get="/graph"
hx-get="/views/graph"
hx-target="main"
hx-select="main"
hx-swap="outerHTML"
@@ -29,8 +29,8 @@
<input type="checkbox" name="isolate" value="1" {{if .Isolate}}checked{{end}}>
isolate (hide non-matches)
</label>
{{if .Filter.Active}}<a class="clear" href="/graph">clear filters</a>{{end}}
<a class="download" href="/graph?download=svg">download SVG</a>
{{if .Filter.Active}}<a class="clear" href="/views/graph">clear filters</a>{{end}}
<a class="download" href="/views/graph?download=svg">download SVG</a>
</form>
</section>

View File

@@ -42,38 +42,38 @@
{{$path := .Path}}
<aside class="projax-sidebar" aria-label="Primary navigation">
<div class="sidebar-top">
<a href="/" class="brand" title="projax">
<a href="/views/tree" class="brand" title="projax">
<span class="brand-icon" aria-hidden="true">▦</span>
<strong class="brand-label">projax</strong>
</a>
</div>
<nav class="sidebar-nav">
<a href="/" class="nav-item{{if eq $path "/"}} active{{end}}" title="Tree">
<a href="/views/tree" class="nav-item{{if eq $path "/views/tree"}} active{{end}}" title="Tree">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
<span class="nav-label">Tree</span>
</a>
<a href="/dashboard" class="nav-item{{if eq $path "/dashboard"}} active{{end}}" title="Dashboard">
<a href="/views/dashboard" class="nav-item{{if eq $path "/views/dashboard"}} active{{end}}" title="Dashboard">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
</svg>
<span class="nav-label">Dashboard</span>
</a>
<a href="/calendar" class="nav-item{{if eq $path "/calendar"}} active{{end}}" title="Calendar">
<a href="/views/calendar" class="nav-item{{if eq $path "/views/calendar"}} active{{end}}" title="Calendar">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span class="nav-label">Calendar</span>
</a>
<a href="/timeline" class="nav-item{{if eq $path "/timeline"}} active{{end}}" title="Timeline">
<a href="/views/timeline" class="nav-item{{if eq $path "/views/timeline"}} active{{end}}" title="Timeline">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span class="nav-label">Timeline</span>
</a>
<a href="/graph" class="nav-item{{if eq $path "/graph"}} active{{end}}" title="Graph">
<a href="/views/graph" class="nav-item{{if eq $path "/views/graph"}} active{{end}}" title="Graph">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
@@ -124,14 +124,14 @@
{{template "content" .}}
</main>
<nav class="projax-bottom-nav" aria-label="Mobile navigation">
<a href="/" class="bottom-nav-item{{if eq $path "/"}} active{{end}}" aria-label="Tree">
<a href="/views/tree" class="bottom-nav-item{{if eq $path "/views/tree"}} active{{end}}" aria-label="Tree">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
<span>Tree</span>
</a>
<a href="/dashboard" class="bottom-nav-item{{if eq $path "/dashboard"}} active{{end}}" aria-label="Dashboard">
<a href="/views/dashboard" class="bottom-nav-item{{if eq $path "/views/dashboard"}} active{{end}}" aria-label="Dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
</svg>
@@ -144,7 +144,7 @@
</svg>
</span>
</a>
<a href="/calendar" class="bottom-nav-item{{if eq $path "/calendar"}} active{{end}}" aria-label="Calendar">
<a href="/views/calendar" class="bottom-nav-item{{if eq $path "/views/calendar"}} active{{end}}" aria-label="Calendar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
@@ -158,13 +158,13 @@
<span>Menu</span>
</summary>
<div class="drawer-sheet" role="menu">
<a href="/timeline" class="drawer-item{{if eq $path "/timeline"}} active{{end}}" role="menuitem">
<a href="/views/timeline" class="drawer-item{{if eq $path "/views/timeline"}} active{{end}}" role="menuitem">
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span>Timeline</span>
</a>
<a href="/graph" class="drawer-item{{if eq $path "/graph"}} active{{end}}" role="menuitem">
<a href="/views/graph" class="drawer-item{{if eq $path "/views/graph"}} active{{end}}" role="menuitem">
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="timeline-filterbar">
<form id="timeline-filter" class="search"
hx-get="/timeline"
hx-get="/views/timeline"
hx-target="#timeline-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
@@ -45,7 +45,7 @@
</label>
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
{{if .Filter.Active}}<a class="clear" href="/timeline">clear filters</a>{{end}}
{{if .Filter.Active}}<a class="clear" href="/views/timeline">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
@@ -62,7 +62,7 @@
<li class="spine-day{{if .Sticky}} sticky-{{.Sticky}}{{end}}" data-date="{{.DateKey}}">
<header class="day-header">
{{if .Sticky}}<span class="sticky-pill">{{.Sticky}}</span>{{end}}
<h2><a class="muted" href="/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">{{.Label}}</a> <small class="muted">({{len .Rows}})</small></h2>
<h2><a class="muted" href="/views/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">{{.Label}}</a> <small class="muted">({{len .Rows}})</small></h2>
</header>
<ul class="day-rows">
{{range .Rows}}

View File

@@ -22,7 +22,7 @@ the visual difference is layout, not data shape.
</article>
{{else}}
<div class="tree-card-empty">
<em>No items match. Try fewer filters or <a href="/">clear all</a>.</em>
<em>No items match. Try fewer filters or <a href="/views/tree">clear all</a>.</em>
</div>
{{end}}
</div>

View File

@@ -40,7 +40,7 @@ set surfaces a friendly empty-state message.
</div>
{{else}}
<div class="kanban-empty muted">
<em>No items match. Try fewer filters or <a href="/?view_type=kanban">clear filters</a>.</em>
<em>No items match. Try fewer filters or <a href="/views/tree?view_type=kanban">clear filters</a>.</em>
</div>
{{end}}
{{end}}

View File

@@ -3,7 +3,7 @@
<p class="counts">
<strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
{{if .Filter.Active}} · <a class="clear" href="/">clear filters</a>{{end}}
{{if .Filter.Active}} · <a class="clear" href="/views/tree">clear filters</a>{{end}}
</p>
<section class="tagbar" id="tree-filterbar">
@@ -93,7 +93,7 @@
{{template "children" .}}
</li>
{{else}}
<li class="empty"><em>No items match. Try fewer filters or <a href="/">clear all</a>.</em></li>
<li class="empty"><em>No items match. Try fewer filters or <a href="/views/tree">clear all</a>.</em></li>
{{end}}
</ul>
</section>

View File

@@ -14,9 +14,9 @@ func TestThemeDefaultIsDark(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/")
code, body := get(t, h, "/views/tree")
if code != 200 {
t.Fatalf("GET / → %d", code)
t.Fatalf("GET /views/tree → %d", code)
}
for _, want := range []string{
`<html lang="en" data-theme="dark">`,
@@ -42,7 +42,7 @@ func TestThemeCookieRoundTrips(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequest(http.MethodGet, "/views/tree", nil)
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "light"})
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
@@ -66,7 +66,7 @@ func TestThemeCookieUnknownFallsBackToDark(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequest(http.MethodGet, "/views/tree", nil)
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "neon-puke"})
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
@@ -94,7 +94,7 @@ func TestThemeTogglePagesShareSameTheme(t *testing.T) {
body, _ := io.ReadAll(w.Result().Body)
return string(body)
}
for _, path := range []string{"/", "/dashboard", "/timeline", "/graph", "/admin", "/admin/bulk", "/admin/classify"} {
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/timeline", "/views/graph", "/admin", "/admin/bulk", "/admin/classify"} {
dark := probe(path, "")
light := probe(path, "light")
if !strings.Contains(dark, `data-theme="dark"`) {
@@ -112,7 +112,7 @@ func TestThemeToggleScriptPresent(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
for _, want := range []string{
"document.cookie = 'projax_theme=",
`getElementById('theme-toggle')`,
@@ -132,7 +132,7 @@ func TestThemeColorMetaHelper(t *testing.T) {
defer pool.Close()
// Indirect: render a fragment with a Theme override to confirm injection
// does not double-write the meta when caller already populates it.
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
req := httptest.NewRequest(http.MethodGet, "/views/dashboard", nil)
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "light"})
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)

View File

@@ -199,7 +199,7 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
"Query": q,
"Now": now,
"Projects": projects,
"BasePath": "/timeline",
"BasePath": "/views/timeline",
"ProjectChipTarget": "#timeline-section",
}
if r.Header.Get("HX-Request") == "true" {

View File

@@ -108,13 +108,13 @@ END:VCALENDAR`
}
h := srv.Routes()
_, body := get(t, h, "/timeline")
_, body := get(t, h, "/views/timeline")
if strings.Contains(body, "Shopping list item") {
t.Errorf("/timeline should NOT include excluded todo summary; body contained it")
}
// Override: ?include_excluded=1 brings it back.
_, peekBody := get(t, h, "/timeline?include_excluded=1")
_, peekBody := get(t, h, "/views/timeline?include_excluded=1")
if !strings.Contains(peekBody, "Shopping list item") {
t.Errorf("?include_excluded=1 should surface the excluded todo; body lacked it")
}

View File

@@ -20,7 +20,7 @@ func TestTimelineRendersEmpty(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/timeline")
code, body := get(t, h, "/views/timeline")
if code != 200 {
t.Fatalf("GET /timeline → %d body=%s", code, body)
}
@@ -67,7 +67,7 @@ func TestTimelineSurfacesDatedDocs(t *testing.T) {
t.Fatalf("seed link: %v", err)
}
code, body := get(t, h, "/timeline")
code, body := get(t, h, "/views/timeline")
if code != 200 {
t.Fatalf("GET /timeline → %d", code)
}
@@ -115,7 +115,7 @@ func TestTimelineFilterByKindNarrowsRows(t *testing.T) {
}
// Unfiltered: both the creation marker and the dated doc should be present.
_, allBody := get(t, h, "/timeline")
_, allBody := get(t, h, "/views/timeline")
if !strings.Contains(allBody, "added <a class=\"proj\" href=\"/i/dev."+slug) {
t.Errorf("expected creation marker in unfiltered timeline body")
}
@@ -124,7 +124,7 @@ func TestTimelineFilterByKindNarrowsRows(t *testing.T) {
}
// kind=doc only: the doc row stays; the creation marker drops.
_, docOnly := get(t, h, "/timeline?kind=doc")
_, docOnly := get(t, h, "/views/timeline?kind=doc")
if strings.Contains(docOnly, "added <a class=\"proj\" href=\"/i/dev."+slug) {
t.Errorf("kind=doc should hide creation marker")
}
@@ -171,7 +171,7 @@ func TestTimelineOrderToggleReversesDays(t *testing.T) {
older := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, -3).Format("060102")
newer := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, 5).Format("060102")
_, desc := get(t, h, "/timeline")
_, desc := get(t, h, "/views/timeline")
idxNewerDesc := strings.Index(desc, newer)
idxOlderDesc := strings.Index(desc, older)
if idxNewerDesc < 0 || idxOlderDesc < 0 {
@@ -181,7 +181,7 @@ func TestTimelineOrderToggleReversesDays(t *testing.T) {
t.Errorf("default order should be desc (newest first); newer at %d, older at %d", idxNewerDesc, idxOlderDesc)
}
_, asc := get(t, h, "/timeline?order=asc")
_, asc := get(t, h, "/views/timeline?order=asc")
idxNewerAsc := strings.Index(asc, newer)
idxOlderAsc := strings.Index(asc, older)
if !(idxOlderAsc < idxNewerAsc) {
@@ -277,7 +277,7 @@ END:VCALENDAR`
}
h := srv.Routes()
code, body := get(t, h, "/timeline")
code, body := get(t, h, "/views/timeline")
if code != 200 {
t.Fatalf("GET /timeline → %d", code)
}
@@ -337,7 +337,7 @@ func TestTimelineFilterByTagAppliesAcrossKinds(t *testing.T) {
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, devID, homeID)
tag := "tl-tag-work-" + stamp
_, body := get(t, h, "/timeline?tag="+tag)
_, body := get(t, h, "/views/timeline?tag="+tag)
// Phase 5i Slice A: the project picker renders every item path as a
// <select> option, so a naive substring match also sees filtered-out
// items inside the dropdown. Anchor on the timeline-row link instead.

View File

@@ -479,7 +479,10 @@ type ChipCounts struct {
// see what they're filtered down to). For an inactive chip the count is what
// they'd get if they added it. At m's scale (≤100 items × ≤30 chips) this is
// trivially cheap; no caching needed.
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string) ChipCounts {
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string, base string) ChipCounts {
if base == "" {
base = "/"
}
count := func(f TreeFilter) int {
// Branch-keep semantics aren't relevant for chip counts — we want a
// raw "how many items match this filter directly" so the chip number
@@ -497,7 +500,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleTag(tag)
out.Tags = append(out.Tags, ChipCount{
Label: tag,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Tags, tag),
})
@@ -506,7 +509,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleManagement(mode)
out.Management = append(out.Management, ChipCount{
Label: mode,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Management, mode),
})
@@ -515,7 +518,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleStatus(st)
out.Status = append(out.Status, ChipCount{
Label: st,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Status, st),
})
@@ -524,7 +527,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleHas(h)
out.Has = append(out.Has, ChipCount{
Label: h,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.HasLinks, h),
})
@@ -533,7 +536,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleShowArchived()
out.ShowArchived = ChipCount{
Label: "show archived",
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: current.ShowArchived,
}

View File

@@ -177,7 +177,7 @@ func TestComputeChipCountsTagCounts(t *testing.T) {
both := &store.Item{ID: "x", Slug: "x", Title: "X", Tags: []string{"work", "dev"}, Status: "active"}
items := []*store.Item{work, dev, both}
f := TreeFilter{Status: []string{"active"}}
counts := computeChipCounts(items, f, map[string]map[string]struct{}{}, []string{"work", "dev"})
counts := computeChipCounts(items, f, map[string]map[string]struct{}{}, []string{"work", "dev"}, "/views/tree")
if len(counts.Tags) != 2 {
t.Fatalf("expected 2 tag chips, got %d", len(counts.Tags))
}

View File

@@ -63,13 +63,13 @@ func (s ViewTypeSet) Resolve(vt string) string {
// this. The narrow tree/dashboard set is the seed; slices CE grow it.
func PageViewTypes(route string) ViewTypeSet {
switch route {
case "/", "tree":
case "/", "/views/tree", "tree":
return ViewTypeSet{
Default: ViewTypeList,
// Slice B: list + card. Slice C: kanban joins.
Allowed: []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
}
case "/dashboard", "dashboard":
case "/dashboard", "/views/dashboard", "dashboard":
// Dashboard is locked to its Phase 5h tabbed-tiles surface in slice B.
// The view_type chip is informational only here; switching templates
// for card vs list on /dashboard is a follow-up slice (the tabbed
@@ -79,12 +79,12 @@ func PageViewTypes(route string) ViewTypeSet {
Default: ViewTypeCard,
Allowed: []string{ViewTypeCard},
}
case "/timeline", "timeline":
case "/timeline", "/views/timeline", "timeline":
return ViewTypeSet{
Default: ViewTypeTimeline,
Allowed: []string{ViewTypeTimeline},
}
case "/calendar", "calendar":
case "/calendar", "/views/calendar", "calendar":
return ViewTypeSet{
Default: ViewTypeCalendar,
Allowed: []string{ViewTypeCalendar},

View File

@@ -18,10 +18,10 @@ func TestParseViewTypeFallsBackOnUnknown(t *testing.T) {
{"/", "list", ViewTypeList}, // explicit default
{"/", "kanban", ViewTypeKanban}, // unlocked in slice C
{"/", "junk", ViewTypeList}, // unknown → default
{"/dashboard", "", ViewTypeCard}, // default for dashboard
{"/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
{"/timeline", "card", ViewTypeTimeline}, // locked
{"/calendar", "kanban", ViewTypeCalendar}, // locked
{"/views/dashboard", "", ViewTypeCard}, // default for dashboard
{"/views/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
{"/views/timeline", "card", ViewTypeTimeline}, // locked
{"/views/calendar", "kanban", ViewTypeCalendar}, // locked
}
for _, tc := range cases {
set := PageViewTypes(tc.route)

View File

@@ -121,13 +121,14 @@ func (s *Server) renderViewPage(w http.ResponseWriter, r *http.Request, v *store
}
viewType = viewSet.Resolve(viewType)
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
counts := computeChipCounts(items, filter, linkKinds, tags)
base := "/views/" + v.Slug
counts := computeChipCounts(items, filter, linkKinds, tags, base)
cardItems := flatMatchedItems(items, filter, linkKinds)
if groupBy == "" {
groupBy = ParseGroupBy(r.URL.Query())
}
kanban := BuildKanbanBoard(cardItems, groupBy)
groupByChips := GroupByChips("/views/"+v.Slug, filter, groupBy)
groupByChips := GroupByChips(base, filter, groupBy)
data := map[string]any{
"Title": v.Name,
"View": v,
@@ -140,10 +141,10 @@ func (s *Server) renderViewPage(w http.ResponseWriter, r *http.Request, v *store
"Filter": filter,
"Counts": counts,
"Projects": parentOptionsFromItems(items),
"BasePath": "/views/" + v.Slug,
"BasePath": base,
"ProjectChipTarget": "#tree-section",
"ViewType": viewType,
"ViewTypeChips": ViewTypeChips("/", filter, viewType),
"ViewTypeChips": ViewTypeChips(base, filter, viewType),
"CardItems": cardItems,
"Kanban": kanban,
"GroupBy": groupBy,