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=` 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 }