m's Q6 pick (2026-05-26): kanban groups the filtered set by `status`
(default) / `area` / `tag` / `management`. Read-only — drag-to-change
is parked. Adds the third view_type render on /tree (alongside list and
card from earlier slices); kanban is now unlocked in PageViewTypes("/").
New web/kanban.go owns BuildKanbanBoard + the per-dimension keyer +
column ordering (status: active/done/archived; management: mai/self/
external/unmanaged; area + tag: alphabetical). Within-column order:
pinned-first → updated_at desc → title.
ParseGroupBy + GroupByChips provide the URL-param hookup and the chip
strip rendered above the board. Multi-tag items appear in every tag
column they belong to (deliberate — the kanban surfaces overlap).
Render:
- handleTree builds the kanban board off the same flatMatchedItems the
card view consumes; cost is one extra grouping pass, no new DB hits.
- New templates/tree_kanban.tmpl: header chip strip + responsive
column board (horizontal scroll on overflow). Empty filtered set
surfaces a friendly nudge.
CSS additions cover the column / card layout; existing chip aesthetics
reused for the group-by toggle.
Test updates:
- view_type_test.go: slice B's "kanban locked on /" assertions tightened
to "kanban unlocked; calendar + timeline still locked on /" — slice C
is the unlock event for kanban.
- New kanban_test.go: per-dimension grouping (status, tag, area),
pinned-first ordering, parser fallback.
- server_test.go: end-to-end render — GET /?view_type=kanban produces
kanban-board markup + group-by chip strip; forest absent.
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 "/", "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
|
||
}
|