feat(views): Phase 5i slice A — project filter dim + descendants toggle

m's Q5 pick (2026-05-26): project scope on every Views-supporting page,
with descendants exposed as an explicit on/off chip toggle rather than
always-on. Slice A ships the smallest standalone piece of the Views
system; slices B–E (view_type URL param, kanban, saved-views schema,
defaults) follow on the same branch.

TreeFilter grows two fields:
- ProjectPath: scoped item's primary path; "" = no filter.
- IncludeDescendants: default true; flipped via ?project_descendants=0.

Matching extends to path-prefix across `it.Paths` when ProjectPath is
set; equality-only when IncludeDescendants is off. Multi-parent items
pass when ANY of their paths qualifies.

Picker is a shared partial (templates/project_chip.tmpl) that every
Views-supporting filter strip includes (tree, dashboard, timeline,
calendar). Two states: <select> picker when no project is set; active
chip with × clear + descendants on/off chip when scoped. Hidden
inputs added to each form so non-picker chip clicks preserve the
project state. Graph and admin tools are NOT Views consumers (per
design.md / docs/plans/views-system.md §5) and stay untouched.

Test-source edits (per the 5c sharpened rule):
- dashboard_test.go, public_listing_test.go, timeline_test.go: row
  membership assertions tightened from `Contains(body, slug)` to
  `Contains(body, href="/i/path")`. The picker now renders every
  item's primary path inside a <select>, so coarse slug substring
  matches falsely passed across filtered-out picker options. Behaviour
  preserved (filtered rows still don't render); the impl-detail
  assertion moved to the row link.

New tests: TestProjectFilterIncludesDescendants,
TestProjectFilterDescendantsOff, TestParseTreeFilterProjectFields,
TestTreeFilterProjectRoundTrip, TestSetProjectAndToggleHelpers,
TestProjectFilterScopesTreeToDescendants (end-to-end via /).
This commit is contained in:
mAi
2026-05-26 13:27:37 +02:00
parent 9138dfac59
commit 13923aadb6
16 changed files with 481 additions and 55 deletions

View File

@@ -182,12 +182,20 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
display := *payload
display.Cached = hit
projects, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
data := map[string]any{
"Title": "calendar",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
"Title": "calendar",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
"Projects": projects,
"BasePath": "/calendar",
"ProjectChipTarget": "#calendar-section",
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "calendar_section", data)

View File

@@ -146,13 +146,21 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
refreshURL = "/dashboard?" + cacheKey + "&refresh=1"
}
projects, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
data := map[string]any{
"Title": "dashboard",
"P": displayPayload,
"Filter": filter,
"UpdatedRel": updatedRel,
"RefreshURL": refreshURL,
"FilterActive": filter.Active(),
"Title": "dashboard",
"P": displayPayload,
"Filter": filter,
"UpdatedRel": updatedRel,
"RefreshURL": refreshURL,
"FilterActive": filter.Active(),
"Projects": projects,
"BasePath": "/dashboard",
"ProjectChipTarget": "#dashboard-section",
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "dashboard_section", data)

View File

@@ -134,10 +134,14 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
if code != 200 {
t.Fatalf("GET /dashboard?tag=dev → %d", code)
}
if !strings.Contains(body, "dev."+devSlug) {
// Phase 5i Slice A: the project-scope picker renders every item's primary
// path as a <select> option, so a naive body substring match would also
// see filtered-out paths inside the dropdown. Anchor the row assertion on
// the detail link emitted by the dashboard cards instead.
if !strings.Contains(body, `href="/i/dev.`+devSlug+`"`) {
t.Errorf("expected dev row in filtered dashboard")
}
if strings.Contains(body, "home."+homeSlug) {
if strings.Contains(body, `href="/i/home.`+homeSlug+`"`) {
t.Errorf("home row should be filtered out when ?tag=dev")
}
}

View File

@@ -238,18 +238,25 @@ func TestTreeFilterPublicNarrows(t *testing.T) {
}
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, pubID, prvID)
// Phase 5i Slice A: the project picker drops every item path into a
// <select> dropdown, so naive substring assertions trip on filtered-out
// rows visible in the picker. Anchor the row assertion on the
// tree-row link instead — that only renders for items that pass the
// filter.
pubLink := `href="/i/dev.` + pubSlug + `"`
prvLink := `href="/i/dev.` + prvSlug + `"`
_, yesBody := get(t, h, "/?public=1")
if !strings.Contains(yesBody, pubSlug) {
t.Errorf("?public=1 should show pub-filt-yes")
if !strings.Contains(yesBody, pubLink) {
t.Errorf("?public=1 should show pub-filt-yes row")
}
if strings.Contains(yesBody, prvSlug) {
t.Errorf("?public=1 should hide pub-filt-no")
if strings.Contains(yesBody, prvLink) {
t.Errorf("?public=1 should hide pub-filt-no row")
}
_, noBody := get(t, h, "/?public=0")
if strings.Contains(noBody, pubSlug) {
t.Errorf("?public=0 should hide pub-filt-yes")
if strings.Contains(noBody, pubLink) {
t.Errorf("?public=0 should hide pub-filt-yes row")
}
if !strings.Contains(noBody, prvSlug) {
t.Errorf("?public=0 should show pub-filt-no")
if !strings.Contains(noBody, prvLink) {
t.Errorf("?public=0 should show pub-filt-no row")
}
}

View File

@@ -162,18 +162,24 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
pages[name] = t
}
// tree bundles the tree-section partial so HTMX swaps and the initial
// page render share definitions.
// page render share definitions. project_chip.tmpl is the Phase 5i Slice
// A shared partial that every Views-supporting page includes inside its
// filter strip.
treeTmpl, err := template.New("tree").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/tree.tmpl",
"templates/tree_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse tree: %w", err)
}
pages["tree"] = treeTmpl
// Standalone tree-section template for HTMX fragment responses.
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS, "templates/tree_section.tmpl")
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS,
"templates/tree_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse tree_section: %w", err)
}
@@ -248,12 +254,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"templates/layout.tmpl",
"templates/dashboard.tmpl",
"templates/dashboard_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse dashboard: %w", err)
}
pages["dashboard"] = dashTmpl
dashSection, err := template.New("dashboard_section").Funcs(funcs).ParseFS(templatesFS, "templates/dashboard_section.tmpl")
dashSection, err := template.New("dashboard_section").Funcs(funcs).ParseFS(templatesFS,
"templates/dashboard_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse dashboard_section: %w", err)
}
@@ -264,12 +274,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"templates/layout.tmpl",
"templates/timeline.tmpl",
"templates/timeline_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse timeline: %w", err)
}
pages["timeline"] = timelineTmpl
timelineSection, err := template.New("timeline_section").Funcs(funcs).ParseFS(templatesFS, "templates/timeline_section.tmpl")
timelineSection, err := template.New("timeline_section").Funcs(funcs).ParseFS(templatesFS,
"templates/timeline_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse timeline_section: %w", err)
}
@@ -282,12 +296,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"templates/layout.tmpl",
"templates/calendar.tmpl",
"templates/calendar_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse calendar: %w", err)
}
pages["calendar"] = calTmpl
calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS, "templates/calendar_section.tmpl")
calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS,
"templates/calendar_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse calendar_section: %w", err)
}
@@ -420,15 +438,18 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
counts := computeChipCounts(items, filter, linkKinds, tags)
data := map[string]any{
"Title": "tree",
"Roots": roots,
"Orphans": orphans,
"Total": total,
"OrphanN": orphanN,
"Matched": matched,
"AllTags": tags,
"Filter": filter,
"Counts": counts,
"Title": "tree",
"Roots": roots,
"Orphans": orphans,
"Total": total,
"OrphanN": orphanN,
"Matched": matched,
"AllTags": tags,
"Filter": filter,
"Counts": counts,
"Projects": parentOptionsFromItems(items),
"BasePath": "/",
"ProjectChipTarget": "#tree-section",
// ActiveTags kept for backwards-compat with the old template path; removed
// after the template migrates fully.
"ActiveTags": filter.Tags,
@@ -873,15 +894,20 @@ func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
if err != nil {
return nil, err
}
var out []ParentOption
return parentOptionsFromItems(items), nil
}
// parentOptionsFromItems builds the same flat option list parentOptions
// returns, but from an already-loaded items slice. Callers that have already
// fetched items (handleTree, handleDashboard, …) use this to avoid a second
// ListAll round-trip when they only need the picker options.
func parentOptionsFromItems(items []*store.Item) []ParentOption {
out := make([]ParentOption, 0, len(items))
for _, it := range items {
// Surface every primary path as a candidate parent — multi-parent
// items appear once per parent option using their primary path so the
// UI stays unambiguous.
out = append(out, ParentOption{ID: it.ID, Path: it.PrimaryPath()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, nil
return out
}
// (buildForest + nodeHasAllTags removed in Phase 3b — superseded by

View File

@@ -282,3 +282,71 @@ func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
}
}
}
// 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
// item alone. Both round-trip through ParseTreeFilter + TreeFilter.Matches +
// the tree handler.
func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx := context.Background()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
parentSlug := "p5i-parent-" + stamp
childSlug := "p5i-child-" + stamp
siblingSlug := "p5i-sib-" + stamp
var dev string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
var parentID, childID, siblingID string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Parent', $1, ARRAY[$2]::uuid[]) returning id`,
parentSlug, dev).Scan(&parentID); err != nil {
t.Fatalf("seed parent: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Child', $1, ARRAY[$2]::uuid[]) returning id`,
childSlug, parentID).Scan(&childID); err != nil {
t.Fatalf("seed child: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Sib', $1, ARRAY[$2]::uuid[]) returning id`,
siblingSlug, dev).Scan(&siblingID); err != nil {
t.Fatalf("seed sibling: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1,$2,$3)`, childID, parentID, siblingID)
parentPath := "dev." + parentSlug
parentLink := `href="/i/` + parentPath + `"`
childLink := `href="/i/` + parentPath + `.` + childSlug + `"`
siblingLink := `href="/i/dev.` + siblingSlug + `"`
// Descendants on (default): parent + child visible, sibling hidden.
_, withDesc := get(t, h, "/?project="+parentPath)
if !strings.Contains(withDesc, parentLink) {
t.Errorf("?project=%s should show parent row", parentPath)
}
if !strings.Contains(withDesc, childLink) {
t.Errorf("?project=%s should include descendant child row", parentPath)
}
if strings.Contains(withDesc, siblingLink) {
t.Errorf("?project=%s should exclude sibling row", parentPath)
}
// Descendants off: only the picked item, no children.
_, noDesc := get(t, h, "/?project="+parentPath+"&project_descendants=0")
if !strings.Contains(noDesc, parentLink) {
t.Errorf("?project_descendants=0 should still show the picked parent row")
}
if strings.Contains(noDesc, childLink) {
t.Errorf("?project_descendants=0 should hide the child row")
}
if strings.Contains(noDesc, siblingLink) {
t.Errorf("?project_descendants=0 should hide the sibling row")
}
}

View File

@@ -193,6 +193,19 @@ table.classify input, table.classify select { width: 100%; }
.mgmt-chip:hover, .status-chip:hover, .has-chip:hover { color: var(--fg); border-color: var(--accent); }
.chip-on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.chip-on:hover { color: var(--accent-fg); filter: brightness(0.92); }
/* Phase 5i Slice A — project scope chip. The picker uses a bare <select>
inside a tagbar form; the active state mirrors the mgmt/status/has chips. */
.proj-chip, .proj-desc-chip {
display: inline-flex; align-items: center; gap: 4px;
font-size: 0.78em; padding: 1px 8px; border-radius: 999px;
background: var(--surface); border: 1px solid var(--border); color: var(--muted); text-decoration: none;
}
.proj-chip.chip-on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.proj-chip .proj-name { font-weight: 500; }
.proj-chip .proj-clear { color: inherit; opacity: 0.75; margin-left: 4px; padding: 0 4px; }
.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; }
#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

@@ -37,8 +37,13 @@
<option value="doc" {{if contains $selK "doc"}}selected{{end}}>doc</option>
</select>
</label>
{{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 .Filter.Active}}<a class="clear" href="/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
<p class="counts muted">
<small>{{.P.TotalRows}} {{if eq .P.TotalRows 1}}row{{else}}rows{{end}}</small>
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">· cached</small>{{else}}<small>· fresh</small>{{end}}

View File

@@ -29,8 +29,13 @@
<option value="gitea-repo" {{if contains $selH "gitea-repo"}}selected{{end}}>gitea</option>
</select>
</label>
{{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 .Filter.Active}}<a class="clear" href="/dashboard">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
<p class="counts muted">
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">updated {{.UpdatedRel}} · cached</small>
{{else}}<small>updated {{.UpdatedRel}} · fresh</small>{{end}}

View File

@@ -0,0 +1,58 @@
{{/*
Phase 5i Slice A — shared project-scope chip + picker.
Rendered inside the tagbar on every Views-supporting page (tree, dashboard,
timeline, calendar). Two visual states:
ProjectPath == "" → <select> picker with all items by primary path
ProjectPath != "" → active chip with × clear + descendants on/off toggle
Each caller passes a top-level page-data map with:
Filter — TreeFilter
Projects — []ParentOption (sorted by path)
BasePath — page route, e.g. "/", "/dashboard", "/timeline", "/calendar"
ProjectChipTarget — HTMX target selector, e.g. "#tree-section"
m's Q5 pick (2026-05-26): descendants toggle is on by default but exposed
explicitly on the chip, not always-on.
*/}}
{{define "view-project-chip"}}
<div class="chip-row project-chip-row">
<span class="muted">project:</span>
{{if .Filter.ProjectPath}}
{{$cleared := (.Filter.SetProject "").URLOn .BasePath}}
{{$toggled := .Filter.ToggleIncludeDescendants.URLOn .BasePath}}
<span class="proj-chip chip-on" title="Scoped to {{.Filter.ProjectPath}}">
<span class="proj-name">{{.Filter.ProjectPath}}</span>
<a class="proj-clear"
href="{{$cleared}}"
hx-get="{{$cleared}}" hx-target="{{.ProjectChipTarget}}" hx-swap="outerHTML" hx-push-url="true"
title="Clear project scope">×</a>
</span>
<a class="proj-desc-chip {{if .Filter.IncludeDescendants}}chip-on{{end}}"
href="{{$toggled}}"
hx-get="{{$toggled}}" hx-target="{{.ProjectChipTarget}}" hx-swap="outerHTML" hx-push-url="true"
title="Include descendants of {{.Filter.ProjectPath}} in scope">
descendants {{if .Filter.IncludeDescendants}}on{{else}}off{{end}}
</a>
{{else}}
<form class="proj-picker"
hx-get="{{.BasePath}}"
hx-target="{{.ProjectChipTarget}}"
hx-swap="outerHTML"
hx-trigger="change from:select"
hx-push-url="true">
{{if .Filter.Q}}<input type="hidden" name="q" value="{{.Filter.Q}}">{{end}}
{{if .Filter.Tags}}<input type="hidden" name="tag" value="{{join "," .Filter.Tags}}">{{end}}
{{if .Filter.Management}}<input type="hidden" name="mgmt" value="{{join "," .Filter.Management}}">{{end}}
{{if ne (join "," .Filter.Status) "active"}}<input type="hidden" name="status" value="{{join "," .Filter.Status}}">{{end}}
{{if .Filter.HasLinks}}<input type="hidden" name="has" value="{{join "," .Filter.HasLinks}}">{{end}}
{{if .Filter.ShowArchived}}<input type="hidden" name="show-archived" value="1">{{end}}
<select name="project" autocomplete="off">
<option value="">— any —</option>
{{range .Projects}}<option value="{{.Path}}">{{.Path}}</option>{{end}}
</select>
</form>
{{end}}
</div>
{{end}}

View File

@@ -43,8 +43,13 @@
<option value="asc" {{if eq .P.Order "asc"}}selected{{end}}>oldest first</option>
</select>
</label>
{{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 .Filter.Active}}<a class="clear" href="/timeline">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
<p class="counts muted">
<small>{{.P.TotalRows}} rows · {{.P.From.Format "2006-01-02"}} → {{.P.ToInclusive.Format "2006-01-02"}}</small>
{{if .P.Cached}}<small title="Served from 90s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">· cached</small>{{else}}<small>· fresh</small>{{end}}

View File

@@ -16,8 +16,12 @@
{{if ne (join "," .Filter.Status) "active"}}<input type="hidden" name="status" value="{{join "," .Filter.Status}}">{{end}}
{{if .Filter.HasLinks}}<input type="hidden" name="has" value="{{join "," .Filter.HasLinks}}">{{end}}
{{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}}
</form>
{{template "view-project-chip" .}}
{{if .AllTags}}
<div class="chip-row">
<span class="muted">tag:</span>

View File

@@ -188,12 +188,20 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
display := *payload
display.Cached = hit
projects, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
data := map[string]any{
"Title": "timeline",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
"Title": "timeline",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
"Projects": projects,
"BasePath": "/timeline",
"ProjectChipTarget": "#timeline-section",
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "timeline_section", data)

View File

@@ -338,10 +338,15 @@ func TestTimelineFilterByTagAppliesAcrossKinds(t *testing.T) {
tag := "tl-tag-work-" + stamp
_, body := get(t, h, "/timeline?tag="+tag)
if !strings.Contains(body, "tl-tag-d-"+stamp) {
// Phase 5i Slice A: the project picker renders every item path as a
// <select> option, so a naive substring match also sees filtered-out
// items inside the dropdown. Anchor on the timeline-row link instead.
devLink := `href="/i/dev.tl-tag-d-` + stamp + `"`
homeLink := `href="/i/home.tl-tag-h-` + stamp + `"`
if !strings.Contains(body, devLink) {
t.Errorf("?tag=%s should surface dev-tagged item", tag)
}
if strings.Contains(body, "tl-tag-h-"+stamp) {
if strings.Contains(body, homeLink) {
t.Errorf("?tag=%s should hide home-tagged item", tag)
}
}

View File

@@ -18,6 +18,14 @@ type TreeFilter struct {
HasLinks []string // ANY of these ref_types must be linked to the item ("caldav-list", "gitea-repo")
ShowArchived bool // when false, hide items with archived=true even if Status matches
Public *bool // Phase 4d — nil = no filter; true = public only; false = private only
// Phase 5i Slice A — project scope.
// ProjectPath is the picked project's primary path (e.g. "work.upc"). Empty
// means no project filter. IncludeDescendants defaults to true; when false,
// only items whose paths include the exact ProjectPath match (no subtree).
// Per m's Q5 pick (2026-05-26), descendants are NOT always-on — the chip
// exposes an explicit on/off toggle.
ProjectPath string
IncludeDescendants bool
}
// Active reports whether any filter dimension is set to something other than
@@ -26,6 +34,9 @@ func (f TreeFilter) Active() bool {
if f.Q != "" || len(f.Tags) > 0 || len(f.Management) > 0 || len(f.HasLinks) > 0 || f.ShowArchived || f.Public != nil {
return true
}
if f.ProjectPath != "" {
return true
}
// Status is the only dimension with a default; treat it as "active" if it
// deviates from {"active"}.
if len(f.Status) != 1 || f.Status[0] != "active" {
@@ -45,12 +56,14 @@ func (f TreeFilter) Active() bool {
// TestCalendarFilterMultiValueTagsFromForm for the regression.
func ParseTreeFilter(q url.Values) TreeFilter {
f := TreeFilter{
Q: strings.TrimSpace(q.Get("q")),
Tags: parseValues(q, "tag"),
Management: parseValues(q, "mgmt"),
Status: parseValues(q, "status"),
HasLinks: parseValues(q, "has"),
ShowArchived: q.Get("show-archived") == "1",
Q: strings.TrimSpace(q.Get("q")),
Tags: parseValues(q, "tag"),
Management: parseValues(q, "mgmt"),
Status: parseValues(q, "status"),
HasLinks: parseValues(q, "has"),
ShowArchived: q.Get("show-archived") == "1",
ProjectPath: strings.TrimSpace(q.Get("project")),
IncludeDescendants: true,
}
if v := strings.TrimSpace(q.Get("public")); v != "" {
// Treat 1/true/yes/on as true; 0/false/no/off as false; anything else nil.
@@ -63,6 +76,11 @@ func ParseTreeFilter(q url.Values) TreeFilter {
f.Public = &b
}
}
// project_descendants=0 flips the toggle off; any other / missing value
// leaves the default (true). Matches the show-archived parsing pattern.
if q.Get("project_descendants") == "0" {
f.IncludeDescendants = false
}
if len(f.Status) == 0 {
f.Status = []string{"active"}
}
@@ -99,6 +117,14 @@ func (f TreeFilter) QueryString() string {
v.Set("public", "0")
}
}
if f.ProjectPath != "" {
v.Set("project", f.ProjectPath)
// IncludeDescendants=true is the default — elide. Only emit when the
// user has explicitly turned descendants off (the chip's "off" state).
if !f.IncludeDescendants {
v.Set("project_descendants", "0")
}
}
return v.Encode()
}
@@ -120,11 +146,19 @@ func (f TreeFilter) TogglePublic() TreeFilter {
// URL builds a `/?…` URL for this filter. Empty filter → "/".
func (f TreeFilter) URL() string {
return f.URLOn("/")
}
// URLOn builds a URL anchored at `base` for this filter. Empty filter →
// `base` unchanged. Used by Views-supporting pages (dashboard, timeline,
// calendar) to construct chip URLs that stay on the current route, where the
// default URL() always lands on "/".
func (f TreeFilter) URLOn(base string) string {
q := f.QueryString()
if q == "" {
return "/"
return base
}
return "/?" + q
return base + "?" + q
}
// ToggleTag returns a copy with tag added/removed.
@@ -166,6 +200,29 @@ func (f TreeFilter) ToggleShowArchived() TreeFilter {
return next
}
// SetProject returns a copy scoped to the given primary path. Empty path
// clears the scope. IncludeDescendants resets to true (the safe default) when
// the project is cleared so a future SetProject doesn't inherit a stale off
// state.
func (f TreeFilter) SetProject(path string) TreeFilter {
next := f
next.ProjectPath = strings.TrimSpace(path)
if next.ProjectPath == "" {
next.IncludeDescendants = true
}
return next
}
// ToggleIncludeDescendants flips the descendants toggle. The chip stays
// settable even with no project picked (so the URL bar can carry the user's
// preference for the next project they pick), but Matches only consults it
// when ProjectPath is set.
func (f TreeFilter) ToggleIncludeDescendants() TreeFilter {
next := f
next.IncludeDescendants = !f.IncludeDescendants
return next
}
func toggleString(in []string, val string) []string {
found := false
out := make([]string, 0, len(in))
@@ -229,6 +286,28 @@ func (f TreeFilter) Matches(it *store.Item, itemLinkKinds map[string]struct{}) b
if f.Public != nil && *f.Public != it.Public {
return false
}
// Project scope (Phase 5i Slice A). When set, the item must have at least
// one path equal to ProjectPath (exact match), and — when
// IncludeDescendants is on — paths that are descendants of ProjectPath
// (prefix + ".") also match. Multi-parent items are in scope as long as
// ANY of their paths qualifies.
if f.ProjectPath != "" {
prefix := f.ProjectPath + "."
hit := false
for _, p := range it.Paths {
if p == f.ProjectPath {
hit = true
break
}
if f.IncludeDescendants && strings.HasPrefix(p, prefix) {
hit = true
break
}
}
if !hit {
return false
}
}
// q substring match.
if f.Q != "" {
q := strings.ToLower(f.Q)

View File

@@ -189,6 +189,129 @@ func TestComputeChipCountsTagCounts(t *testing.T) {
}
}
// TestProjectFilterIncludesDescendants verifies Slice A scope semantics: a
// picked ProjectPath matches the item itself plus every descendant in the DAG
// closure (any path with the ProjectPath + "." prefix). Multi-parent items
// are in scope when any of their paths qualifies.
func TestProjectFilterIncludesDescendants(t *testing.T) {
work := &store.Item{ID: "work", Slug: "work", Paths: []string{"work"}, Status: "active"}
upc := &store.Item{ID: "upc", Slug: "upc", Paths: []string{"work.upc"}, ParentIDs: []string{"work"}, Status: "active"}
deadlines := &store.Item{ID: "dl", Slug: "deadlines", Paths: []string{"work.upc.deadlines"}, ParentIDs: []string{"upc"}, Status: "active"}
other := &store.Item{ID: "other", Slug: "other", Paths: []string{"work.other"}, ParentIDs: []string{"work"}, Status: "active"}
// Multi-parent: lives under both dev.paliad and work.paliad. Setting the
// scope to "work" must put it in scope via its work.paliad lineage.
paliad := &store.Item{ID: "paliad", Slug: "paliad", Paths: []string{"dev.paliad", "work.paliad"}, Status: "active"}
dev := &store.Item{ID: "dev", Slug: "dev", Paths: []string{"dev"}, Status: "active"}
links := map[string]struct{}{}
f := TreeFilter{Status: []string{"active"}, ProjectPath: "work.upc", IncludeDescendants: true}
if !f.Matches(upc, links) {
t.Error("exact-match item should pass")
}
if !f.Matches(deadlines, links) {
t.Error("descendant should pass")
}
if f.Matches(work, links) {
t.Error("ancestor should NOT pass project=work.upc")
}
if f.Matches(other, links) {
t.Error("sibling should NOT pass")
}
// Multi-parent: scope=work should match paliad via the work.paliad path.
f2 := TreeFilter{Status: []string{"active"}, ProjectPath: "work", IncludeDescendants: true}
if !f2.Matches(paliad, links) {
t.Error("multi-parent item should match scope=work via work.paliad path")
}
if f2.Matches(dev, links) {
t.Error("dev (sibling root) should NOT pass scope=work")
}
}
// TestProjectFilterDescendantsOff verifies that flipping IncludeDescendants
// off restricts the scope to items whose paths equal ProjectPath exactly. m
// asked for this toggle behaviour explicitly in Q5 (2026-05-26): always-on
// descendants was the inventor pick; m wants the chip to expose on/off.
func TestProjectFilterDescendantsOff(t *testing.T) {
upc := &store.Item{ID: "upc", Slug: "upc", Paths: []string{"work.upc"}, Status: "active"}
deadlines := &store.Item{ID: "dl", Slug: "deadlines", Paths: []string{"work.upc.deadlines"}, Status: "active"}
links := map[string]struct{}{}
f := TreeFilter{Status: []string{"active"}, ProjectPath: "work.upc", IncludeDescendants: false}
if !f.Matches(upc, links) {
t.Error("exact-match item should still pass with descendants off")
}
if f.Matches(deadlines, links) {
t.Error("descendant should NOT pass when IncludeDescendants is off")
}
}
// TestParseTreeFilterProjectFields verifies URL-param parsing for the project
// scope. IncludeDescendants defaults to true; project_descendants=0 flips it.
// project_descendants without an explicit "0" stays at default.
func TestParseTreeFilterProjectFields(t *testing.T) {
f := parseQS(t, "?project=work.upc")
if f.ProjectPath != "work.upc" {
t.Errorf("ProjectPath = %q, want %q", f.ProjectPath, "work.upc")
}
if !f.IncludeDescendants {
t.Error("IncludeDescendants should default to true")
}
if !f.Active() {
t.Error("project scope should make filter Active()")
}
off := parseQS(t, "?project=work.upc&project_descendants=0")
if off.IncludeDescendants {
t.Error("project_descendants=0 should set IncludeDescendants=false")
}
}
// TestTreeFilterProjectRoundTrip verifies that emitting + re-parsing the
// project fields yields the same TreeFilter, including the descendants
// toggle when it deviates from default.
func TestTreeFilterProjectRoundTrip(t *testing.T) {
for _, qs := range []string{
"?project=work.upc",
"?project=work.upc&project_descendants=0",
"?project=dev&q=paliad&tag=work&status=done",
} {
f := parseQS(t, qs)
out := f.URL()
f2 := parseQS(t, strings.TrimPrefix(out, "/"))
if f.URL() != f2.URL() {
t.Errorf("round-trip mismatch: %q → %q → %q", qs, out, f2.URL())
}
}
}
// TestSetProjectAndToggleHelpers spot-checks the two helpers added in Slice A.
func TestSetProjectAndToggleHelpers(t *testing.T) {
f := TreeFilter{Status: []string{"active"}, IncludeDescendants: true}
scoped := f.SetProject("work.upc")
if scoped.ProjectPath != "work.upc" {
t.Errorf("SetProject did not set path; got %q", scoped.ProjectPath)
}
if !scoped.IncludeDescendants {
t.Error("SetProject should preserve IncludeDescendants when truthy")
}
// Toggling descendants flips the bool.
off := scoped.ToggleIncludeDescendants()
if off.IncludeDescendants {
t.Error("ToggleIncludeDescendants should flip true → false")
}
// Clearing the project resets the toggle to the safe default (true) so a
// future SetProject call doesn't inherit the off state.
cleared := off.SetProject("")
if !cleared.IncludeDescendants {
t.Error("clearing project should reset IncludeDescendants to true")
}
if cleared.ProjectPath != "" {
t.Errorf("clearing project should empty path; got %q", cleared.ProjectPath)
}
}
func TestToggleStatusKeepsActiveDefault(t *testing.T) {
f := TreeFilter{Status: []string{"active"}}
// Toggling active off when nothing else is on should leave us at the