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.
218 lines
6.0 KiB
Go
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
|
|
}
|