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.
546 lines
16 KiB
Go
546 lines
16 KiB
Go
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
|
||
}
|