feat(views): Phase 5i slice B — view_type URL param + card view on /tree

m's Q1+Q3 picks (2026-05-26): five canonical view_types
(card/list/calendar/kanban/timeline). Slice B introduces the parameter and
the first non-default rendering: card view on /tree shows the filtered set
as a flat tile grid alongside the existing tree forest.

New web/view_type.go owns the enum, per-route allowed set, parser, and
the chip-strip builder. Per the design note, view_type is RENDER state,
not filter state — kept off TreeFilter so the same filter can render as
card or list.

PageViewTypes("/") = {default: list, allowed: [list, card]}.
Dashboard / calendar / timeline are LOCKED to their native shape in
slice B; switching templates on /dashboard for card vs list is mostly
already done via fuller's 5h tabbed-tiles surface and stays as-is for
now (the chip strip surfaces card as the only allowed value there).
Kanban + cross-page list/card swaps land in slice C onwards.

Render:
- handleTree parses `?view_type=` with the per-route catalog, builds
  flatMatchedItems for the card consumer alongside the existing forest.
- tree_section.tmpl gains a view-type chip strip (locked entries shown
  greyed-out with title tooltip) + branches into either `tree-card` or
  the forest based on .ViewType.
- New templates/tree_card.tmpl renders a flat grid of tiles for the
  matched set; per-item field set mirrors the list rendering.
- Hidden `view_type` input added to the search form so chip clicks
  preserve the view choice.

Tests:
- view_type_test.go: parser fallback, per-route catalog, chip strip
  active/locked flags, filter preservation in chip URLs.
- server_test.go: end-to-end dispatch — GET /?view_type=card renders
  tree-card-grid, GET / renders forest, unknown values fall back to
  list. Chip strip present on both views.
This commit is contained in:
mAi
2026-05-26 13:36:28 +02:00
parent 2eba37365b
commit 5f712c68d4
8 changed files with 373 additions and 0 deletions

View File

@@ -169,6 +169,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"templates/layout.tmpl",
"templates/tree.tmpl",
"templates/tree_section.tmpl",
"templates/tree_card.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
@@ -178,6 +179,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
// Standalone tree-section template for HTMX fragment responses.
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS,
"templates/tree_section.tmpl",
"templates/tree_card.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
@@ -440,8 +442,14 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
return
}
filter := ParseTreeFilter(r.URL.Query())
viewSet := PageViewTypes("/")
view := ParseViewType(r.URL.Query(), viewSet)
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
counts := computeChipCounts(items, filter, linkKinds, tags)
// Phase 5i Slice B: the card view renders a flat grid of matched items
// (no tree structure). Build from items + filter directly rather than
// reusing the post-prune `roots` (which still keeps ancestors).
cardItems := flatMatchedItems(items, filter, linkKinds)
data := map[string]any{
"Title": "tree",
"Roots": roots,
@@ -455,6 +463,9 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
"Projects": parentOptionsFromItems(items),
"BasePath": "/",
"ProjectChipTarget": "#tree-section",
"ViewType": view,
"ViewTypeChips": ViewTypeChips("/", filter, view),
"CardItems": cardItems,
// ActiveTags kept for backwards-compat with the old template path; removed
// after the template migrates fully.
"ActiveTags": filter.Tags,

View File

@@ -295,6 +295,41 @@ func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
}
}
// 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_type values fall back to list with the chip-strip showing list as
// active.
func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// List view (default): forest markup expected; tree-card-grid absent.
_, listBody := get(t, h, "/")
if !strings.Contains(listBody, `<ul class="forest">`) {
t.Error("default GET / should render the tree forest")
}
if strings.Contains(listBody, `class="tree-card-grid"`) {
t.Error("default GET / should not render the card grid")
}
if !strings.Contains(listBody, `view-type-chip-row`) {
t.Error("view-type chip strip should appear on every view")
}
// Card view: card grid present, forest absent.
_, cardBody := get(t, h, "/?view_type=card")
if !strings.Contains(cardBody, `class="tree-card-grid"`) {
t.Error("GET /?view_type=card should render the card grid")
}
if strings.Contains(cardBody, `<ul class="forest">`) {
t.Error("GET /?view_type=card should not render the tree forest")
}
// Unknown view_type falls back to list.
_, unknownBody := get(t, h, "/?view_type=junk")
if !strings.Contains(unknownBody, `<ul class="forest">`) {
t.Error("unknown view_type should fall back to list")
}
}
// TestProjectFilterScopesTreeToDescendants verifies the Phase 5i Slice A
// project scope semantics end-to-end: ?project=<path> narrows / to the picked
// item + descendants; ?project_descendants=0 narrows further to the picked

View File

@@ -206,6 +206,29 @@ table.classify input, table.classify select { width: 100%; }
.proj-chip .proj-clear:hover { opacity: 1; }
.proj-desc-chip:hover { color: var(--fg); border-color: var(--accent); }
.proj-picker select { font-size: 0.85em; padding: 1px 4px; }
/* Phase 5i Slice B — view-type chip strip + card grid. */
.view-type-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;
text-transform: capitalize;
}
.view-type-chip:hover { color: var(--fg); border-color: var(--accent); }
.view-type-chip.chip-locked { opacity: 0.4; }
.view-type-chip.chip-locked:hover { color: var(--muted); border-color: var(--border); cursor: not-allowed; }
.tree-card-grid {
display: grid; gap: 12px; padding: 12px 0;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.tree-card {
border: 1px solid var(--border); border-radius: 6px; background: var(--surface);
padding: 10px 12px;
}
.tree-card-head { display: flex; flex-direction: column; gap: 2px; margin-bottom: 6px; }
.tree-card-title { font-weight: 500; color: var(--fg); text-decoration: none; }
.tree-card-title:hover { color: var(--accent); }
.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); }
#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,29 @@
{{/*
Phase 5i Slice B — card view for /tree. Renders the filtered item set as a
flat tile grid (no forest, no ancestor-keep). One tile per matched item,
ordered by primary path. Reuses the per-item field set the list view emits;
the visual difference is layout, not data shape.
*/}}
{{define "tree-card"}}
<div class="tree-card-grid">
{{range .CardItems}}
<article class="tree-card">
<header class="tree-card-head">
<a class="tree-card-title" href="/i/{{.PrimaryPath}}">{{.Title}}</a>
<span class="tree-card-slug muted">{{.PrimaryPath}}</span>
</header>
<p class="tree-card-meta">
{{range .Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
{{if .Pinned}}<span class="pinned" title="Pinned">★</span>{{end}}
{{if eq .Status "done"}}<span class="status status-done">done</span>{{end}}
{{if .Archived}}<span class="status status-archived">archived</span>{{end}}
</p>
</article>
{{else}}
<div class="tree-card-empty">
<em>No items match. Try fewer filters or <a href="/">clear all</a>.</em>
</div>
{{end}}
</div>
{{end}}

View File

@@ -18,10 +18,21 @@
{{if .Filter.ShowArchived}}<input type="hidden" name="show-archived" value="1">{{end}}
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
{{if ne .ViewType "list"}}<input type="hidden" name="view_type" value="{{.ViewType}}">{{end}}
</form>
{{template "view-project-chip" .}}
<div class="chip-row view-type-chip-row">
<span class="muted">view:</span>
{{range .ViewTypeChips}}
<a class="view-type-chip{{if .Active}} chip-on{{end}}{{if .Locked}} chip-locked{{end}}"
href="{{.URL}}"
hx-get="{{.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true"
{{if .Locked}}title="{{.Label}} view lands in a future slice; clicks fall back to the default."{{end}}>{{.Label}}</a>
{{end}}
</div>
{{if .AllTags}}
<div class="chip-row">
<span class="muted">tag:</span>
@@ -65,6 +76,9 @@
</div>
</section>
{{if eq .ViewType "card"}}
{{template "tree-card" .}}
{{else}}
<section class="tree">
<ul class="forest">
{{range .Roots}}
@@ -81,6 +95,7 @@
{{end}}
</ul>
</section>
{{end}}
</section>
{{end}}

View File

@@ -439,6 +439,20 @@ func sortItems(in []*store.Item) {
sort.Slice(in, func(i, j int) bool { return in[i].Slug < in[j].Slug })
}
// flatMatchedItems returns every item that passes the filter directly — no
// ancestor-keep, no DAG shape. Used by Phase 5i Slice B's card view: a flat
// grid of tiles for the filtered set. Stable order by primary path.
func flatMatchedItems(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) []*store.Item {
out := make([]*store.Item, 0, len(items))
for _, it := range items {
if f.Matches(it, linkKinds[it.ID]) {
out = append(out, it)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].PrimaryPath() < out[j].PrimaryPath() })
return out
}
// ChipCount packages a chip label, the URL that toggles it, the count it
// would yield if it were toggled on (or current count if already on), and a
// flag for whether it's currently active. Used by the template for every

153
web/view_type.go Normal file
View File

@@ -0,0 +1,153 @@
package web
import (
"net/url"
"strings"
)
// View type enum — Phase 5i Slice B. Five values per m's Q1 + Q3 picks
// (2026-05-26): timeline is a first-class view_type alongside the four m
// originally named.
const (
ViewTypeCard = "card"
ViewTypeList = "list"
ViewTypeCalendar = "calendar"
ViewTypeKanban = "kanban"
ViewTypeTimeline = "timeline"
)
// allViewTypes is the canonical ordered set used by validators and template
// rendering. Adding a value here is one of the few places that needs to stay
// in lockstep with the `view_type` CHECK constraint in migration 0016
// (lands in slice D).
var allViewTypes = []string{
ViewTypeCard,
ViewTypeList,
ViewTypeCalendar,
ViewTypeKanban,
ViewTypeTimeline,
}
// ViewTypeSet is the per-route catalog: which view types each Views-supporting
// page accepts. Tree supports list (default) + card today; kanban joins in
// slice C. Dashboard, calendar, and timeline are locked to their native shape
// for slice B — accepting a different view_type silently falls back to the
// default (no errors; the chip-strip surface signals "this view is locked").
type ViewTypeSet struct {
Default string
Allowed []string
}
// Has reports whether vt is part of the route's allowed set.
func (s ViewTypeSet) Has(vt string) bool {
for _, v := range s.Allowed {
if v == vt {
return true
}
}
return false
}
// Resolve returns vt if it is in the allowed set, otherwise the default. Used
// by handlers when parsing `?view_type=`; unknown / forbidden values fall back
// gracefully without 4xx.
func (s ViewTypeSet) Resolve(vt string) string {
if s.Has(vt) {
return vt
}
return s.Default
}
// PageViewTypes returns the catalog for the named route. Routes outside the
// Views system (graph, admin/*) get an empty set; their handlers don't call
// this. The narrow tree/dashboard set is the seed; slices CE grow it.
func PageViewTypes(route string) ViewTypeSet {
switch route {
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},
}
case "/dashboard", "dashboard":
// Dashboard is locked to its Phase 5h tabbed-tiles surface in slice B.
// The view_type chip is informational only here; switching templates
// for card vs list on /dashboard is a follow-up slice (the tabbed
// tiles ARE the card view conceptually, so the work is mostly
// renaming labels).
return ViewTypeSet{
Default: ViewTypeCard,
Allowed: []string{ViewTypeCard},
}
case "/timeline", "timeline":
return ViewTypeSet{
Default: ViewTypeTimeline,
Allowed: []string{ViewTypeTimeline},
}
case "/calendar", "calendar":
return ViewTypeSet{
Default: ViewTypeCalendar,
Allowed: []string{ViewTypeCalendar},
}
}
return ViewTypeSet{}
}
// ParseViewType pulls `view_type` from q and falls back to the route's
// default. Unknown values map to the default (no error path for the user).
func ParseViewType(q url.Values, set ViewTypeSet) string {
raw := strings.ToLower(strings.TrimSpace(q.Get("view_type")))
if raw == "" {
return set.Default
}
return set.Resolve(raw)
}
// ViewTypeChip is one entry in the view-type chip strip rendered above the
// section. Active marks the currently-rendered view; URL is the toggle target.
type ViewTypeChip struct {
Label string
URL string
Active bool
// Locked is true for view types that aren't in the route's allowed set
// today (e.g. kanban on /tree before slice C). Rendered greyed-out with a
// "coming soon" title attribute. Clicking still navigates (so the URL
// remains shareable), but lands on the default with the chip strip
// showing the desired view as un-toggled.
Locked bool
}
// ViewTypeChips builds the chip strip for `route` given the current filter
// and view. Currently emits chips for every value in allViewTypes; entries
// outside the route's allowed set surface as Locked.
func ViewTypeChips(route string, filter TreeFilter, current string) []ViewTypeChip {
set := PageViewTypes(route)
base := route
if base == "tree" {
base = "/"
}
out := make([]ViewTypeChip, 0, len(allViewTypes))
for _, vt := range allViewTypes {
urlStr := filter.URLOn(base)
// Embed the chosen view_type into the URL. We use a tiny query
// rewrite because the filter does NOT carry the view_type — keeping
// it out of TreeFilter (the design doc's call: render state, not
// filter state).
if vt != set.Default {
if strings.Contains(urlStr, "?") {
urlStr += "&view_type=" + vt
} else {
urlStr += "?view_type=" + vt
}
}
out = append(out, ViewTypeChip{
Label: vt,
URL: urlStr,
Active: vt == current,
Locked: !set.Has(vt),
})
}
return out
}

93
web/view_type_test.go Normal file
View File

@@ -0,0 +1,93 @@
package web
import (
"net/url"
"testing"
)
// TestParseViewTypeFallsBackOnUnknown verifies that ParseViewType returns the
// route's default for empty / unknown / forbidden values, and the requested
// value when allowed. Slice B routes its picks through PageViewTypes for the
// per-route allowed set.
func TestParseViewTypeFallsBackOnUnknown(t *testing.T) {
cases := []struct {
route, raw, want string
}{
{"/", "", ViewTypeList}, // default for tree
{"/", "card", ViewTypeCard}, // allowed on tree
{"/", "list", ViewTypeList}, // explicit default
{"/", "kanban", ViewTypeList}, // not allowed yet → default
{"/", "junk", ViewTypeList}, // unknown → default
{"/dashboard", "", ViewTypeCard}, // default for dashboard
{"/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
{"/timeline", "card", ViewTypeTimeline}, // locked
{"/calendar", "kanban", ViewTypeCalendar}, // locked
}
for _, tc := range cases {
set := PageViewTypes(tc.route)
q := url.Values{}
if tc.raw != "" {
q.Set("view_type", tc.raw)
}
got := ParseViewType(q, set)
if got != tc.want {
t.Errorf("route=%s raw=%q → %q, want %q", tc.route, tc.raw, got, tc.want)
}
}
}
// TestViewTypeChipsMarkLockedAndActive asserts the chip strip emits one entry
// per canonical view_type and that the locked + active flags follow from the
// route's allowed set + the current pick.
func TestViewTypeChipsMarkLockedAndActive(t *testing.T) {
filter := TreeFilter{Status: []string{"active"}}
chips := ViewTypeChips("/", filter, ViewTypeCard)
if len(chips) != 5 {
t.Fatalf("expected 5 chips (one per view_type), got %d", len(chips))
}
byLabel := map[string]ViewTypeChip{}
for _, c := range chips {
byLabel[c.Label] = c
}
if !byLabel[ViewTypeCard].Active {
t.Error("card chip should be Active when current=card")
}
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 _, locked := range []string{ViewTypeCalendar, ViewTypeKanban, ViewTypeTimeline} {
if !byLabel[locked].Locked {
t.Errorf("%s should be Locked on / in slice B (not yet implemented)", locked)
}
}
}
// TestViewTypeChipURLPreservesFilter ensures that a chip click on a filtered
// tree carries the filter forward (so flipping to card view doesn't lose
// `?tag=work`).
func TestViewTypeChipURLPreservesFilter(t *testing.T) {
filter := TreeFilter{Status: []string{"active"}, Tags: []string{"work"}}
chips := ViewTypeChips("/", filter, ViewTypeList)
for _, c := range chips {
if c.Label == ViewTypeCard {
// Card URL must include tag=work AND view_type=card.
if !contains([]string{c.URL}, c.URL) || !urlContains(c.URL, "tag=work") || !urlContains(c.URL, "view_type=card") {
t.Errorf("card chip URL missing filter or view_type: %q", c.URL)
}
return
}
}
t.Fatal("card chip missing from set")
}
func urlContains(u, needle string) bool {
for i := 0; i+len(needle) <= len(u); i++ {
if u[i:i+len(needle)] == needle {
return true
}
}
return false
}