Files
projax/web/kanban.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

218 lines
6.0 KiB
Go

package web
import (
"net/url"
"sort"
"strings"
"github.com/m/projax/store"
)
// Phase 5i Slice C — kanban view_type. Read-only: groups the filtered item
// set into columns by the chosen group_by dimension. Drag-to-change-group
// is deliberately out of scope (design.md §3 / slice ordering A→B→D→C→E).
//
// m's Q6 pick (2026-05-26): default group_by = status (active/done/archived).
// Other values (area / tag / management) remain selectable via the
// group_by chip strip.
// Kanban group_by values. The DB CHECK constraint on projax.views.group_by
// is open-ended (text); the validation list lives here so the handler can
// reject typos before they round-trip.
const (
GroupByStatus = "status"
GroupByArea = "area"
GroupByTag = "tag"
GroupByManagement = "management"
)
var allGroupBy = []string{GroupByStatus, GroupByArea, GroupByTag, GroupByManagement}
// ParseGroupBy pulls `group_by` from q. Empty / unknown values fall back to
// the default (status). Lower-cases for case-insensitive matching.
func ParseGroupBy(q url.Values) string {
raw := strings.ToLower(strings.TrimSpace(q.Get("group_by")))
for _, g := range allGroupBy {
if raw == g {
return g
}
}
return GroupByStatus
}
// KanbanColumn is one column in the rendered board.
type KanbanColumn struct {
Key string // the raw group value (status="active", tag="work", …)
Label string // human-readable header
Items []*store.Item // sorted: pinned-first, then updated_at desc, then title
}
// KanbanBoard is the rendered shape. Columns are ordered by the canonical
// per-dimension order (status: active/done/archived; management: mai/self/
// external/unmanaged; area + tag: alphabetical with the items they hold).
type KanbanBoard struct {
GroupBy string
Columns []KanbanColumn
Total int // total cards across columns
}
// BuildKanbanBoard groups the matched items by groupBy. Pure: takes whatever
// list of items the handler filtered, returns the column shape.
func BuildKanbanBoard(items []*store.Item, groupBy string) KanbanBoard {
if groupBy == "" {
groupBy = GroupByStatus
}
keyer := groupByKeyer(groupBy)
byKey := map[string][]*store.Item{}
emittedKeys := []string{}
seen := map[string]bool{}
for _, it := range items {
for _, k := range keyer(it) {
if !seen[k] {
seen[k] = true
emittedKeys = append(emittedKeys, k)
}
byKey[k] = append(byKey[k], it)
}
}
// Stable column ordering per dimension.
switch groupBy {
case GroupByStatus:
emittedKeys = orderKeyedFirst(emittedKeys, []string{"active", "done", "archived"})
case GroupByManagement:
emittedKeys = orderKeyedFirst(emittedKeys, []string{"mai", "self", "external", "unmanaged"})
default:
sort.Strings(emittedKeys)
}
board := KanbanBoard{GroupBy: groupBy}
for _, k := range emittedKeys {
cardItems := byKey[k]
sort.SliceStable(cardItems, func(i, j int) bool {
a, b := cardItems[i], cardItems[j]
if a.Pinned != b.Pinned {
return a.Pinned
}
if !a.UpdatedAt.Equal(b.UpdatedAt) {
return a.UpdatedAt.After(b.UpdatedAt)
}
return a.Title < b.Title
})
board.Columns = append(board.Columns, KanbanColumn{
Key: k,
Label: columnLabel(groupBy, k),
Items: cardItems,
})
board.Total += len(cardItems)
}
return board
}
// groupByKeyer returns a function that maps an item to its column keys (a
// slice because tags can put one item in multiple columns).
func groupByKeyer(groupBy string) func(*store.Item) []string {
switch groupBy {
case GroupByStatus:
return func(it *store.Item) []string {
s := it.Status
if s == "" {
s = "active"
}
return []string{s}
}
case GroupByManagement:
return func(it *store.Item) []string {
if len(it.Management) == 0 {
return []string{"unmanaged"}
}
out := make([]string, 0, len(it.Management))
out = append(out, it.Management...)
return out
}
case GroupByArea:
return func(it *store.Item) []string {
// First segment of the primary path is the area (dev / work / home …).
p := it.PrimaryPath()
if p == "" {
return []string{"—"}
}
if idx := strings.IndexByte(p, '.'); idx > 0 {
return []string{p[:idx]}
}
return []string{p}
}
case GroupByTag:
return func(it *store.Item) []string {
if len(it.Tags) == 0 {
return []string{"untagged"}
}
return append([]string(nil), it.Tags...)
}
}
return func(it *store.Item) []string { return []string{it.Status} }
}
// columnLabel renders the column header. Status / management get title-case
// for legibility; area and tag stay verbatim.
func columnLabel(groupBy, key string) string {
switch groupBy {
case GroupByStatus, GroupByManagement:
if key == "" {
return "—"
}
return strings.ToUpper(key[:1]) + key[1:]
}
return key
}
// orderKeyedFirst returns `actual` with `preferred` entries first (in the
// preferred order, when present in actual), then any remaining actual
// entries appended in input order.
func orderKeyedFirst(actual, preferred []string) []string {
present := map[string]bool{}
for _, k := range actual {
present[k] = true
}
out := make([]string, 0, len(actual))
for _, k := range preferred {
if present[k] {
out = append(out, k)
delete(present, k)
}
}
for _, k := range actual {
if present[k] {
out = append(out, k)
delete(present, k)
}
}
return out
}
// GroupByChip is one entry in the group-by chip strip rendered above the
// kanban board. Active marks the current group_by; URL is the toggle target.
type GroupByChip struct {
Label string
URL string
Active bool
}
// GroupByChips builds the group-by chip strip. base must already include the
// `?view_type=kanban` segment; we append `&group_by=<value>` on top.
func GroupByChips(base string, filter TreeFilter, current string) []GroupByChip {
out := make([]GroupByChip, 0, len(allGroupBy))
for _, g := range allGroupBy {
u := filter.URLOn(base)
if !strings.Contains(u, "?") {
u += "?view_type=kanban&group_by=" + g
} else {
u += "&view_type=kanban&group_by=" + g
}
out = append(out, GroupByChip{
Label: g,
URL: u,
Active: g == current,
})
}
return out
}