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.
152 lines
4.7 KiB
Go
152 lines
4.7 KiB
Go
package web
|
||
|
||
import (
|
||
"net/url"
|
||
"strings"
|
||
)
|
||
|
||
// View type enum — Phase 5i Slice B. Five values per m's Q1 + Q3 picks
|
||
// (2026-05-26): timeline is a first-class view_type alongside the four m
|
||
// originally named.
|
||
const (
|
||
ViewTypeCard = "card"
|
||
ViewTypeList = "list"
|
||
ViewTypeCalendar = "calendar"
|
||
ViewTypeKanban = "kanban"
|
||
ViewTypeTimeline = "timeline"
|
||
)
|
||
|
||
// allViewTypes is the canonical ordered set used by validators and template
|
||
// rendering. Adding a value here is one of the few places that needs to stay
|
||
// in lockstep with the `view_type` CHECK constraint in migration 0016
|
||
// (lands in slice D).
|
||
var allViewTypes = []string{
|
||
ViewTypeCard,
|
||
ViewTypeList,
|
||
ViewTypeCalendar,
|
||
ViewTypeKanban,
|
||
ViewTypeTimeline,
|
||
}
|
||
|
||
// ViewTypeSet is the per-route catalog: which view types each Views-supporting
|
||
// page accepts. Tree supports list (default) + card today; kanban joins in
|
||
// slice C. Dashboard, calendar, and timeline are locked to their native shape
|
||
// for slice B — accepting a different view_type silently falls back to the
|
||
// default (no errors; the chip-strip surface signals "this view is locked").
|
||
type ViewTypeSet struct {
|
||
Default string
|
||
Allowed []string
|
||
}
|
||
|
||
// Has reports whether vt is part of the route's allowed set.
|
||
func (s ViewTypeSet) Has(vt string) bool {
|
||
for _, v := range s.Allowed {
|
||
if v == vt {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Resolve returns vt if it is in the allowed set, otherwise the default. Used
|
||
// by handlers when parsing `?view_type=`; unknown / forbidden values fall back
|
||
// gracefully without 4xx.
|
||
func (s ViewTypeSet) Resolve(vt string) string {
|
||
if s.Has(vt) {
|
||
return vt
|
||
}
|
||
return s.Default
|
||
}
|
||
|
||
// PageViewTypes returns the catalog for the named route. Routes outside the
|
||
// Views system (graph, admin/*) get an empty set; their handlers don't call
|
||
// this. The narrow tree/dashboard set is the seed; slices C–E grow it.
|
||
func PageViewTypes(route string) ViewTypeSet {
|
||
switch route {
|
||
case "/", "/views/tree", "tree":
|
||
return ViewTypeSet{
|
||
Default: ViewTypeList,
|
||
// Slice B: list + card. Slice C: kanban joins.
|
||
Allowed: []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
|
||
}
|
||
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
|
||
// tiles ARE the card view conceptually, so the work is mostly
|
||
// renaming labels).
|
||
return ViewTypeSet{
|
||
Default: ViewTypeCard,
|
||
Allowed: []string{ViewTypeCard},
|
||
}
|
||
case "/timeline", "/views/timeline", "timeline":
|
||
return ViewTypeSet{
|
||
Default: ViewTypeTimeline,
|
||
Allowed: []string{ViewTypeTimeline},
|
||
}
|
||
case "/calendar", "/views/calendar", "calendar":
|
||
return ViewTypeSet{
|
||
Default: ViewTypeCalendar,
|
||
Allowed: []string{ViewTypeCalendar},
|
||
}
|
||
}
|
||
return ViewTypeSet{}
|
||
}
|
||
|
||
// ParseViewType pulls `view_type` from q and falls back to the route's
|
||
// default. Unknown values map to the default (no error path for the user).
|
||
func ParseViewType(q url.Values, set ViewTypeSet) string {
|
||
raw := strings.ToLower(strings.TrimSpace(q.Get("view_type")))
|
||
if raw == "" {
|
||
return set.Default
|
||
}
|
||
return set.Resolve(raw)
|
||
}
|
||
|
||
// ViewTypeChip is one entry in the view-type chip strip rendered above the
|
||
// section. Active marks the currently-rendered view; URL is the toggle target.
|
||
type ViewTypeChip struct {
|
||
Label string
|
||
URL string
|
||
Active bool
|
||
// Locked is true for view types that aren't in the route's allowed set
|
||
// today (e.g. kanban on /tree before slice C). Rendered greyed-out with a
|
||
// "coming soon" title attribute. Clicking still navigates (so the URL
|
||
// remains shareable), but lands on the default with the chip strip
|
||
// showing the desired view as un-toggled.
|
||
Locked bool
|
||
}
|
||
|
||
// ViewTypeChips builds the chip strip for `route` given the current filter
|
||
// and view. Currently emits chips for every value in allViewTypes; entries
|
||
// outside the route's allowed set surface as Locked.
|
||
func ViewTypeChips(route string, filter TreeFilter, current string) []ViewTypeChip {
|
||
set := PageViewTypes(route)
|
||
base := route
|
||
if base == "tree" {
|
||
base = "/"
|
||
}
|
||
out := make([]ViewTypeChip, 0, len(allViewTypes))
|
||
for _, vt := range allViewTypes {
|
||
urlStr := filter.URLOn(base)
|
||
// Embed the chosen view_type into the URL. We use a tiny query
|
||
// rewrite because the filter does NOT carry the view_type — keeping
|
||
// it out of TreeFilter (the design doc's call: render state, not
|
||
// filter state).
|
||
if vt != set.Default {
|
||
if strings.Contains(urlStr, "?") {
|
||
urlStr += "&view_type=" + vt
|
||
} else {
|
||
urlStr += "?view_type=" + vt
|
||
}
|
||
}
|
||
out = append(out, ViewTypeChip{
|
||
Label: vt,
|
||
URL: urlStr,
|
||
Active: vt == current,
|
||
Locked: !set.Has(vt),
|
||
})
|
||
}
|
||
return out
|
||
}
|