diff --git a/web/kanban.go b/web/kanban.go new file mode 100644 index 0000000..a0493e7 --- /dev/null +++ b/web/kanban.go @@ -0,0 +1,217 @@ +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 +} diff --git a/web/kanban_test.go b/web/kanban_test.go new file mode 100644 index 0000000..bba1f3a --- /dev/null +++ b/web/kanban_test.go @@ -0,0 +1,105 @@ +package web + +import ( + "net/url" + "testing" + "time" + + "github.com/m/projax/store" +) + +// TestBuildKanbanBoardGroupByStatus exercises the default group_by — three +// columns in canonical order (active/done/archived) populated by status. +func TestBuildKanbanBoardGroupByStatus(t *testing.T) { + items := []*store.Item{ + {ID: "a", Title: "Active1", Status: "active", Paths: []string{"dev.a"}, UpdatedAt: time.Unix(2, 0)}, + {ID: "b", Title: "Active2", Status: "active", Paths: []string{"dev.b"}, UpdatedAt: time.Unix(3, 0), Pinned: true}, + {ID: "c", Title: "Done1", Status: "done", Paths: []string{"dev.c"}, UpdatedAt: time.Unix(1, 0)}, + } + board := BuildKanbanBoard(items, GroupByStatus) + if got, want := len(board.Columns), 2; got != want { + t.Fatalf("columns = %d, want %d", got, want) + } + if board.Columns[0].Key != "active" { + t.Errorf("first column = %q, want active", board.Columns[0].Key) + } + if board.Columns[1].Key != "done" { + t.Errorf("second column = %q, want done", board.Columns[1].Key) + } + if board.Total != 3 { + t.Errorf("Total = %d, want 3", board.Total) + } + // active column: pinned-first (b), then updated_at desc (a). + if board.Columns[0].Items[0].ID != "b" { + t.Errorf("pinned item should be first; got %q", board.Columns[0].Items[0].ID) + } + if board.Columns[0].Items[1].ID != "a" { + t.Errorf("second active item = %q, want a", board.Columns[0].Items[1].ID) + } +} + +// TestBuildKanbanBoardGroupByTag puts an item with multiple tags into multiple +// columns. Columns sort alphabetically (no canonical preference for tags). +func TestBuildKanbanBoardGroupByTag(t *testing.T) { + items := []*store.Item{ + {ID: "a", Title: "A", Status: "active", Paths: []string{"a"}, Tags: []string{"work", "dev"}}, + {ID: "b", Title: "B", Status: "active", Paths: []string{"b"}, Tags: []string{"dev"}}, + {ID: "c", Title: "C", Status: "active", Paths: []string{"c"}, Tags: []string{}}, + } + board := BuildKanbanBoard(items, GroupByTag) + keys := map[string]int{} + for _, col := range board.Columns { + keys[col.Key] = len(col.Items) + } + if keys["dev"] != 2 { + t.Errorf("dev column items = %d, want 2", keys["dev"]) + } + if keys["work"] != 1 { + t.Errorf("work column items = %d, want 1", keys["work"]) + } + if keys["untagged"] != 1 { + t.Errorf("untagged column items = %d, want 1", keys["untagged"]) + } +} + +// TestBuildKanbanBoardGroupByArea uses the first path segment as the area. +func TestBuildKanbanBoardGroupByArea(t *testing.T) { + items := []*store.Item{ + {ID: "a", Title: "A", Status: "active", Paths: []string{"dev.a"}}, + {ID: "b", Title: "B", Status: "active", Paths: []string{"work.upc.b"}}, + {ID: "c", Title: "C", Status: "active", Paths: []string{"work.c"}}, + } + board := BuildKanbanBoard(items, GroupByArea) + keys := map[string]int{} + for _, col := range board.Columns { + keys[col.Key] = len(col.Items) + } + if keys["dev"] != 1 { + t.Errorf("dev column = %d, want 1", keys["dev"]) + } + if keys["work"] != 2 { + t.Errorf("work column = %d, want 2", keys["work"]) + } +} + +// TestParseGroupByFallsBackOnUnknown verifies the parser's defaulting. +func TestParseGroupByFallsBackOnUnknown(t *testing.T) { + cases := map[string]string{ + "": GroupByStatus, + "status": GroupByStatus, + "tag": GroupByTag, + "area": GroupByArea, + "management": GroupByManagement, + "MaNaGeMeNt": GroupByManagement, + "made-up": GroupByStatus, + } + for raw, want := range cases { + q := url.Values{} + if raw != "" { + q.Set("group_by", raw) + } + if got := ParseGroupBy(q); got != want { + t.Errorf("ParseGroupBy(%q) = %q, want %q", raw, got, want) + } + } +} diff --git a/web/server.go b/web/server.go index e4e0783..26eab01 100644 --- a/web/server.go +++ b/web/server.go @@ -170,6 +170,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) { "templates/tree.tmpl", "templates/tree_section.tmpl", "templates/tree_card.tmpl", + "templates/tree_kanban.tmpl", "templates/project_chip.tmpl", ) if err != nil { @@ -180,6 +181,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) { treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS, "templates/tree_section.tmpl", "templates/tree_card.tmpl", + "templates/tree_kanban.tmpl", "templates/project_chip.tmpl", ) if err != nil { @@ -467,6 +469,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { // (no tree structure). Build from items + filter directly rather than // reusing the post-prune `roots` (which still keeps ancestors). cardItems := flatMatchedItems(items, filter, linkKinds) + // Phase 5i Slice C: kanban groups the same matched set into columns. + groupBy := ParseGroupBy(r.URL.Query()) + kanban := BuildKanbanBoard(cardItems, groupBy) + groupByChips := GroupByChips("/", filter, groupBy) data := map[string]any{ "Title": "tree", "Roots": roots, @@ -483,6 +489,9 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { "ViewType": view, "ViewTypeChips": ViewTypeChips("/", filter, view), "CardItems": cardItems, + "Kanban": kanban, + "GroupBy": groupBy, + "GroupByChips": groupByChips, // ActiveTags kept for backwards-compat with the old template path; removed // after the template migrates fully. "ActiveTags": filter.Tags, diff --git a/web/server_test.go b/web/server_test.go index b7c8b85..828bdfc 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -295,6 +295,25 @@ func TestMultiParentBothPathsRouteToSameRow(t *testing.T) { } } +// TestTreeRendersKanbanWhenViewTypeIsKanban verifies the Phase 5i Slice C +// dispatch: GET /?view_type=kanban renders the kanban board (with the +// group-by chip strip) instead of the forest. group_by defaults to status. +func TestTreeRendersKanbanWhenViewTypeIsKanban(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + _, body := get(t, h, "/?view_type=kanban") + if !strings.Contains(body, `class="kanban-board"`) { + t.Error("?view_type=kanban should render the kanban board") + } + if !strings.Contains(body, `class="groupby-chip`) { + t.Error("kanban view should render the group-by chip strip") + } + if strings.Contains(body, `