Merge branch 'mai/kahn/phase-5i-phase-a-design' (phase 5i slice C: kanban view_type with group_by chip strip)

This commit is contained in:
mAi
2026-05-26 13:47:12 +02:00
9 changed files with 439 additions and 9 deletions

217
web/kanban.go Normal file
View File

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

105
web/kanban_test.go Normal file
View File

@@ -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)
}
}
}

View File

@@ -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,

View File

@@ -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, `<ul class="forest">`) {
t.Error("kanban view should not render the tree forest")
}
}
// TestTreeRendersCardGridWhenViewTypeIsCard verifies Phase 5i Slice B
// dispatch: `?view_type=card` renders the flat tile grid instead of the
// forest, and the view-type chip strip is present in either view. Unknown

View File

@@ -229,6 +229,36 @@ table.classify input, table.classify select { width: 100%; }
.tree-card-slug { font-size: 0.78em; }
.tree-card-meta { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; font-size: 0.78em; }
.tree-card-empty { grid-column: 1 / -1; padding: 24px; color: var(--muted); }
/* Phase 5i Slice C — kanban columns + cards. */
.kanban-controls { margin: 8px 0; }
.groupby-chip {
display: inline-block; font-size: 0.78em; padding: 1px 8px; border-radius: 999px;
background: var(--surface); border: 1px solid var(--border); color: var(--muted); text-decoration: none;
}
.groupby-chip:hover { color: var(--fg); border-color: var(--accent); }
.kanban-board {
display: grid; gap: 12px; padding: 12px 0; overflow-x: auto;
grid-auto-flow: column; grid-auto-columns: minmax(220px, 280px);
}
.kanban-column {
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
padding: 8px 10px; display: flex; flex-direction: column; gap: 8px;
min-height: 120px;
}
.kanban-col-head {
display: flex; justify-content: space-between; align-items: baseline;
border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
.kanban-col-label { font-weight: 500; }
.kanban-cards { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
.kanban-card {
background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
padding: 6px 8px;
}
.kanban-card-title { font-weight: 500; color: var(--fg); text-decoration: none; display: block; }
.kanban-card-title:hover { color: var(--accent); }
.kanban-card-meta { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; font-size: 0.78em; }
.kanban-empty { padding: 24px; }
#tree-filterbar small { opacity: 0.75; margin-left: 2px; }
.tree-section .empty { padding: 24px; color: var(--muted); }
.tree-section .clear { color: var(--bad); }

View File

@@ -0,0 +1,46 @@
{{/*
Phase 5i Slice C — kanban view for /tree. Columns by group_by value, cards
inside each column. Read-only: no drag-to-change (deferred). Empty filtered
set surfaces a friendly empty-state message.
*/}}
{{define "tree-kanban"}}
<div class="kanban-controls chip-row">
<span class="muted">group&nbsp;by:</span>
{{range .GroupByChips}}
<a class="groupby-chip{{if .Active}} chip-on{{end}}"
href="{{.URL}}"
hx-get="{{.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">{{.Label}}</a>
{{end}}
<small class="muted">{{.Kanban.Total}} card{{if ne .Kanban.Total 1}}s{{end}}</small>
</div>
{{if .Kanban.Columns}}
<div class="kanban-board">
{{range .Kanban.Columns}}
<section class="kanban-column" data-key="{{.Key}}">
<header class="kanban-col-head">
<span class="kanban-col-label">{{.Label}}</span>
<small class="muted">{{len .Items}}</small>
</header>
<ul class="kanban-cards">
{{range .Items}}
<li class="kanban-card">
<a class="kanban-card-title" href="/i/{{.PrimaryPath}}">{{.Title}}</a>
<p class="kanban-card-meta">
<span class="muted">{{.PrimaryPath}}</span>
{{range .Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
{{if .Pinned}}<span class="pinned" title="Pinned">★</span>{{end}}
</p>
</li>
{{end}}
</ul>
</section>
{{end}}
</div>
{{else}}
<div class="kanban-empty muted">
<em>No items match. Try fewer filters or <a href="/?view_type=kanban">clear filters</a>.</em>
</div>
{{end}}
{{end}}

View File

@@ -78,6 +78,8 @@
{{if eq .ViewType "card"}}
{{template "tree-card" .}}
{{else if eq .ViewType "kanban"}}
{{template "tree-kanban" .}}
{{else}}
<section class="tree">
<ul class="forest">

View File

@@ -66,10 +66,8 @@ func PageViewTypes(route string) ViewTypeSet {
case "/", "tree":
return ViewTypeSet{
Default: ViewTypeList,
// Card joins in slice B; kanban lands in slice C — until then,
// `?view_type=kanban` falls back to list with the chip strip
// labelling it "coming soon" via the template.
Allowed: []string{ViewTypeList, ViewTypeCard},
// 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.

View File

@@ -16,7 +16,7 @@ func TestParseViewTypeFallsBackOnUnknown(t *testing.T) {
{"/", "", ViewTypeList}, // default for tree
{"/", "card", ViewTypeCard}, // allowed on tree
{"/", "list", ViewTypeList}, // explicit default
{"/", "kanban", ViewTypeList}, // not allowed yet → default
{"/", "kanban", ViewTypeKanban}, // unlocked in slice C
{"/", "junk", ViewTypeList}, // unknown → default
{"/dashboard", "", ViewTypeCard}, // default for dashboard
{"/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
@@ -55,12 +55,16 @@ func TestViewTypeChipsMarkLockedAndActive(t *testing.T) {
if byLabel[ViewTypeList].Active {
t.Error("list chip should not be Active when current=card")
}
if byLabel[ViewTypeList].Locked {
t.Error("list is allowed on /; chip should not be Locked")
for _, allowed := range []string{ViewTypeList, ViewTypeCard, ViewTypeKanban} {
if byLabel[allowed].Locked {
t.Errorf("%s is allowed on /; chip should not be Locked", allowed)
}
}
for _, locked := range []string{ViewTypeCalendar, ViewTypeKanban, ViewTypeTimeline} {
// Slice C only unlocks kanban; calendar + timeline stay locked on /tree
// until cross-route view_type swaps land in a future slice.
for _, locked := range []string{ViewTypeCalendar, ViewTypeTimeline} {
if !byLabel[locked].Locked {
t.Errorf("%s should be Locked on / in slice B (not yet implemented)", locked)
t.Errorf("%s should still be Locked on /", locked)
}
}
}