Files
projax/web/view_type.go
mAi bbc7867a35 feat(views): Phase 5i slice C — kanban view_type with group_by chip strip
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.
2026-05-26 13:47:03 +02:00

152 lines
4.7 KiB
Go
Raw 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 "/", "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
}