Merge branch 'mai/kahn/phase-5i-phase-a-design' (phase 5i slice B: view_type URL param + card view on /tree)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
29
web/templates/tree_card.tmpl
Normal file
29
web/templates/tree_card.tmpl
Normal 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}}
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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
153
web/view_type.go
Normal 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 C–E 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
93
web/view_type_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user