Files
projax/web/view_type.go
mAi f820fa5830 feat(views): Phase 5j slice C — full URL migration + system views
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.
2026-05-29 11:59:26 +02:00

152 lines
4.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 CE 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
}