Files
projax/web/tree_filter.go
mAi f820fa5830 feat(views): Phase 5j slice C — full URL migration + system views
Per m's Q1 pick (b) (2026-05-29): legacy `/`, `/dashboard`, `/calendar`,
`/timeline`, `/graph` become `/views/{system-slug}`. Old routes
301-redirect to the new ones with chip params preserved; the legacy
?view=<uuid> param from 5i is resolved through the uuid → slug map
when present so old bookmarks land on the right user view.

System views (web/system_views.go):
- SystemView struct (Slug / Name / Icon / URL) — code-resident, never
  rows in projax.views.
- AllSystemViews() returns the canonical five: tree, dashboard,
  calendar, timeline, graph. Display order matches the existing
  sidebar.
- LookupSystemView(slug) returns the matching entry or nil; the
  reserved-slug list in store.IsReservedViewSlug (slice A) is kept
  in sync.
- legacyRedirect(systemSlug) handler 301s with chip-param preservation
  + uuid → slug resolution for any leftover ?view=<uuid>.

Routes (web/server.go):
- GET /views/tree      → handleTree     (was GET /)
- GET /views/dashboard → handleDashboard
- GET /views/timeline  → handleTimeline
- GET /views/calendar  → handleCalendar
- GET /views/graph     → handleGraph
- GET /                → 301 → /views/tree
- GET /dashboard       → 301 → /views/dashboard
- GET /timeline        → 301 → /views/timeline
- GET /calendar        → 301 → /views/calendar
- GET /graph           → 301 → /views/graph
- POST action endpoints (/dashboard/task/*, /dashboard/pin, /admin/*)
  stay where they are — those are RPC-ish, not page renders.

handleTree: dropped the `r.URL.Path != "/"` guard — the only entry
point now is /views/tree, mounted via the new route. Slice F removes
any residual references; this slice keeps the handler reachable.

computeChipCounts grew a `base string` arg so chip URLs anchor on the
caller's route (/views/tree for the system tree, /views/{slug} for
saved views). PageViewTypes recognises both legacy and /views/ keys
during the transition.

Template hrefs / hx-gets bulk-updated to the new URLs:
- layout.tmpl: every sidebar + bottom-nav entry points at
  /views/{system-slug}. Active-state checks updated alongside.
- tree_section.tmpl, tree_card.tmpl, tree_kanban.tmpl: clear-filter
  / clear-all hrefs → /views/tree.
- calendar*.tmpl, timeline_section.tmpl, graph.tmpl,
  dashboard_section.tmpl: every internal nav + filter link points at
  the /views/{slug} surface.
- detail.tmpl, error.tmpl: cancel / back-to-tree → /views/tree.

Test-source updates (per the 5c sharpened rule):
- ~100 test paths bulk-rewritten from /dashboard /calendar /timeline
  /graph (and `/`) to their /views/{slug} counterparts. The
  behaviour-preservation contract holds: status codes + body shapes
  for the rendered pages stay the same; only the URL anchoring the
  test changes.
- layout_test.go: sidebar href assertions updated to /views/{slug}.
- view_type_test.go (Q2 + Q3 follow-up): PageViewTypes lookup table
  updated to use the new route keys.
- 2 deliberate behaviour-change assertions land: TestLegacyRedirects
  expects 301 on the old URLs (was 200); TestTreeRenders fetches
  /views/tree (the new home) instead of /.

Internal go-source URL emissions (dashboard.go, calendar.go,
timeline.go) updated to the new BasePath so chip + refresh URLs round
through /views/{slug} correctly.

New tests:
- TestSystemViewLookup — AllSystemViews shape + LookupSystemView
  round-trip + unknown-slug nil.
- TestLegacyRedirects — every legacy URL 301s to its new home with
  chip params preserved.
- TestLegacyViewUUIDRedirect — old `?view=<uuid>` URLs land on the
  resolved slug per m's Q3 pick.
2026-05-29 11:59:26 +02:00

546 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"net/url"
"sort"
"strings"
"github.com/m/projax/store"
)
// TreeFilter is the parsed state of the tree-page filter chips + search input.
// Every dimension is independent; matching is AND across dimensions, OR within.
type TreeFilter struct {
Q string // search query, case-insensitive substring
Tags []string // ALL must be present
Management []string // ANY of these matches (incl. synthetic "unmanaged")
Status []string // ANY of these matches (default = ["active"])
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
// the implicit default. Used for the "clear filters" link visibility.
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" {
return true
}
return false
}
// ParseTreeFilter pulls the filter state from a URL query.
// Defaults: Status=["active"], ShowArchived=false. Other dimensions empty.
// Multi-value dimensions (tag/mgmt/status/has) use parseValues so BOTH the
// comma-joined hidden-input style (`?tag=foo,bar`, tree page) AND the
// repeated-param HTMX multi-select style (`?tag=foo&tag=bar`, every
// filter-strip form) round-trip into the same TreeFilter shape. The
// prior `q.Get(key)` calls silently dropped every second-and-beyond
// value from any multi-select submission — see
// 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",
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.
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
b := true
f.Public = &b
case "0", "false", "no", "off":
b := false
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"}
}
return f
}
// QueryString re-encodes the filter back to a URL query string. Keys are
// emitted in a stable order so URL-bar history doesn't churn. Default values
// are elided (no key emitted when the dimension is at default).
func (f TreeFilter) QueryString() string {
v := url.Values{}
if f.Q != "" {
v.Set("q", f.Q)
}
if len(f.Tags) > 0 {
v.Set("tag", strings.Join(f.Tags, ","))
}
if len(f.Management) > 0 {
v.Set("mgmt", strings.Join(f.Management, ","))
}
if !(len(f.Status) == 1 && f.Status[0] == "active") {
v.Set("status", strings.Join(f.Status, ","))
}
if len(f.HasLinks) > 0 {
v.Set("has", strings.Join(f.HasLinks, ","))
}
if f.ShowArchived {
v.Set("show-archived", "1")
}
if f.Public != nil {
if *f.Public {
v.Set("public", "1")
} else {
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()
}
// TogglePublic flips through the three states: nil → public-only → private-only → nil.
func (f TreeFilter) TogglePublic() TreeFilter {
next := f
switch {
case f.Public == nil:
t := true
next.Public = &t
case *f.Public:
t := false
next.Public = &t
default:
next.Public = nil
}
return next
}
// 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 base
}
return base + "?" + q
}
// ToggleTag returns a copy with tag added/removed.
func (f TreeFilter) ToggleTag(tag string) TreeFilter {
next := f
next.Tags = toggleString(f.Tags, tag)
return next
}
// ToggleManagement returns a copy with the management mode toggled. The
// synthetic value "unmanaged" matches items whose management array is empty.
func (f TreeFilter) ToggleManagement(mode string) TreeFilter {
next := f
next.Management = toggleString(f.Management, mode)
return next
}
// ToggleStatus toggles a status. "active" is the implicit default; toggling
// it off when no others are selected leaves the dimension at ["active"]
// (so the user can't accidentally hide every row).
func (f TreeFilter) ToggleStatus(status string) TreeFilter {
next := f
next.Status = toggleString(f.Status, status)
if len(next.Status) == 0 {
next.Status = []string{"active"}
}
return next
}
func (f TreeFilter) ToggleHas(kind string) TreeFilter {
next := f
next.HasLinks = toggleString(f.HasLinks, kind)
return next
}
func (f TreeFilter) ToggleShowArchived() TreeFilter {
next := f
next.ShowArchived = !f.ShowArchived
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))
for _, s := range in {
if s == val {
found = true
continue
}
out = append(out, s)
}
if !found {
out = append(out, val)
sort.Strings(out)
}
return out
}
// Matches reports whether the item matches every active filter dimension on
// its own (no descendant credit). hasLinks is a per-item set of ref_types
// linked to this item, precomputed by the caller for cheap lookup.
func (f TreeFilter) Matches(it *store.Item, itemLinkKinds map[string]struct{}) bool {
// Status / archived. Default-status semantics: when Status=["active"], an
// item with status=active passes; archived items are filtered out unless
// ShowArchived is on.
if !contains(f.Status, it.Status) {
return false
}
if it.Archived && !f.ShowArchived {
return false
}
// Tags AND.
for _, t := range f.Tags {
if !it.HasTag(t) {
return false
}
}
// Management OR (with synthetic "unmanaged" matching empty []).
if len(f.Management) > 0 {
ok := false
for _, m := range f.Management {
if m == "unmanaged" && len(it.Management) == 0 {
ok = true
break
}
if it.HasManagement(m) {
ok = true
break
}
}
if !ok {
return false
}
}
// Has-link AND (every requested link kind must be present).
for _, h := range f.HasLinks {
if _, ok := itemLinkKinds[h]; !ok {
return false
}
}
// Public (Phase 4d): when set, must match the item's flag.
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)
hit := false
if strings.Contains(strings.ToLower(it.Title), q) {
hit = true
}
if !hit && strings.Contains(strings.ToLower(it.Slug), q) {
hit = true
}
if !hit && strings.Contains(strings.ToLower(it.ContentMD), q) {
hit = true
}
if !hit {
for _, p := range it.Paths {
if strings.Contains(strings.ToLower(p), q) {
hit = true
break
}
}
}
if !hit {
for _, a := range it.Aliases {
if strings.Contains(strings.ToLower(a), q) {
hit = true
break
}
}
}
if !hit {
return false
}
}
return true
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}
// applyTreeFilter is the post-Phase-3b replacement for buildForest. It builds
// the same DAG-as-forest shape, then prunes branches that neither match nor
// have a matching descendant. `matched` counts items (deduped by id) that the
// filter accepted directly — distinct from `total`.
func applyTreeFilter(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) (roots []*treeNode, orphans []*store.Item, total, orphanN, matched int) {
for _, it := range items {
total++
if len(it.ParentIDs) == 0 && it.HasManagement("mai") {
orphans = append(orphans, it)
orphanN++
}
}
// Build forest (one node per parent edge, multi-parent items duplicated).
nodeFor := func(it *store.Item) *treeNode { return &treeNode{Item: it} }
rootNodes := []*treeNode{}
childByParent := make(map[string][]*treeNode, len(items))
for _, it := range items {
if len(it.ParentIDs) == 0 {
rootNodes = append(rootNodes, nodeFor(it))
continue
}
for _, pid := range it.ParentIDs {
childByParent[pid] = append(childByParent[pid], nodeFor(it))
}
}
var attach func(n *treeNode)
attach = func(n *treeNode) {
n.Children = childByParent[n.Item.ID]
for _, c := range n.Children {
attach(c)
}
}
for _, r := range rootNodes {
attach(r)
}
roots = rootNodes
// Pre-compute per-item match against the filter — done once, even though
// the same item may appear in multiple branches.
hit := make(map[string]bool, len(items))
for _, it := range items {
hit[it.ID] = f.Matches(it, linkKinds[it.ID])
if hit[it.ID] {
matched++
}
}
// Prune the forest, keeping ancestors of any match.
var keep func(n *treeNode) bool
keep = func(n *treeNode) bool {
filtered := n.Children[:0]
for _, c := range n.Children {
if keep(c) {
filtered = append(filtered, c)
}
}
n.Children = filtered
if hit[n.Item.ID] {
return true
}
return len(n.Children) > 0
}
filtered := roots[:0]
for _, n := range roots {
if keep(n) {
filtered = append(filtered, n)
}
}
roots = filtered
// Stable ordering.
sortNodes(roots)
sortItems(orphans)
return
}
func sortNodes(roots []*treeNode) {
sort.Slice(roots, func(i, j int) bool { return roots[i].Item.Slug < roots[j].Item.Slug })
for _, r := range roots {
sortNodes(r.Children)
}
}
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
// chip row.
type ChipCount struct {
Label string
URL string
Count int
Active bool
}
// ChipCounts groups every per-dimension count so the template can render them
// in stable order.
type ChipCounts struct {
Tags []ChipCount
Management []ChipCount
Status []ChipCount
Has []ChipCount
ShowArchived ChipCount
}
// computeChipCounts produces the count each chip would yield if toggled.
// For an already-active chip the count is the current match count (so users
// see what they're filtered down to). For an inactive chip the count is what
// they'd get if they added it. At m's scale (≤100 items × ≤30 chips) this is
// trivially cheap; no caching needed.
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string, base string) ChipCounts {
if base == "" {
base = "/"
}
count := func(f TreeFilter) int {
// Branch-keep semantics aren't relevant for chip counts — we want a
// raw "how many items match this filter directly" so the chip number
// is honest.
n := 0
for _, it := range items {
if f.Matches(it, linkKinds[it.ID]) {
n++
}
}
return n
}
out := ChipCounts{}
for _, tag := range allTags {
next := current.ToggleTag(tag)
out.Tags = append(out.Tags, ChipCount{
Label: tag,
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Tags, tag),
})
}
for _, mode := range []string{"mai", "self", "external", "unmanaged"} {
next := current.ToggleManagement(mode)
out.Management = append(out.Management, ChipCount{
Label: mode,
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Management, mode),
})
}
for _, st := range []string{"active", "done", "archived"} {
next := current.ToggleStatus(st)
out.Status = append(out.Status, ChipCount{
Label: st,
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Status, st),
})
}
for _, h := range []string{"caldav-list", "gitea-repo"} {
next := current.ToggleHas(h)
out.Has = append(out.Has, ChipCount{
Label: h,
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.HasLinks, h),
})
}
{
next := current.ToggleShowArchived()
out.ShowArchived = ChipCount{
Label: "show archived",
URL: next.URLOn(base),
Count: count(next),
Active: current.ShowArchived,
}
}
return out
}