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 "/", "tree": return ViewTypeSet{ Default: ViewTypeList, // Slice B: list + card. Slice C: kanban joins. Allowed: []string{ViewTypeList, ViewTypeCard, ViewTypeKanban}, } case "/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", "timeline": return ViewTypeSet{ Default: ViewTypeTimeline, Allowed: []string{ViewTypeTimeline}, } case "/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 }