Merge branch 'mai/knuth/fix-timeline-filters' (fix: project filter narrows /admin/bulk + timeline multi-value kind)

This commit is contained in:
mAi
2026-05-27 14:27:33 +02:00
4 changed files with 442 additions and 12 deletions

View File

@@ -118,6 +118,29 @@ func bulkMatches(f TreeFilter, it *store.Item, itemLinkKinds map[string]struct{}
return false
}
}
// Phase 5i Slice A: project scope. Same predicate as TreeFilter.Matches —
// at least one of the item's paths must equal ProjectPath, with the
// IncludeDescendants toggle gating the prefix-match for the subtree.
// bulkMatches was a near-clone of Matches() that wasn't updated when
// the project dim landed, so /admin/bulk silently ignored ?project=…
// (and the chip's hidden-input round-trip too).
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
}
}
if f.Q != "" {
q := strings.ToLower(f.Q)
hit := strings.Contains(strings.ToLower(it.Title), q) ||

266
web/project_filter_test.go Normal file
View File

@@ -0,0 +1,266 @@
package web_test
import (
"context"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// projectFixture seeds a subtree shaped:
//
// dev/ (existing)
// <stamp>-root (root of the test subtree)
// <stamp>-child (descendant of root)
// <stamp>-outside (sibling of root under dev — NOT a descendant)
//
// Returns the slugs + primary paths. Callers defer the row cleanup.
type projectFixture struct {
rootSlug, childSlug, outsideSlug string
rootPath, childPath, outsidePath string
rootID, childID, outsideID string
}
func seedProjectFixture(t *testing.T, pool *pgxpool.Pool) projectFixture {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
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)
}
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
fx := projectFixture{
rootSlug: "proj-root-" + stamp,
childSlug: "proj-child-" + stamp,
outsideSlug: "proj-outside-" + stamp,
}
// root + outside both live directly under dev.
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
returning id`,
fx.rootSlug, dev,
).Scan(&fx.rootID); err != nil {
t.Fatalf("seed root: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
returning id`,
fx.outsideSlug, dev,
).Scan(&fx.outsideID); err != nil {
t.Fatalf("seed outside: %v", err)
}
// child lives under root.
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
returning id`,
fx.childSlug, fx.rootID,
).Scan(&fx.childID); err != nil {
t.Fatalf("seed child: %v", err)
}
fx.rootPath = "dev." + fx.rootSlug
fx.childPath = fx.rootPath + "." + fx.childSlug
fx.outsidePath = "dev." + fx.outsideSlug
return fx
}
func cleanupProjectFixture(pool *pgxpool.Pool, fx projectFixture) {
pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID)
}
// TestProjectFilterNarrowsTree exercises the / (tree) handler — applyTreeFilter
// passes the project filter through TreeFilter.Matches, so ?project=<root>
// must show only root + descendants.
func TestProjectFilterNarrowsTree(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
h := srv.Routes()
_, body := get(t, h, "/?project="+fx.rootPath)
if !strings.Contains(body, fx.rootPath) {
t.Errorf("tree ?project=<root> missing root path %q", fx.rootPath)
}
if !strings.Contains(body, fx.childPath) {
t.Errorf("tree ?project=<root> missing child path %q (descendants default ON)", fx.childPath)
}
if strings.Contains(body, fx.outsidePath) {
t.Errorf("tree ?project=<root> leaked outside path %q", fx.outsidePath)
}
}
// TestProjectFilterNarrowsTimeline — buildTimeline funnels items via
// q.Filter.Matches before fan-out, so ?project=<root> must drop the
// creation row for the outside sibling but keep root + child.
func TestProjectFilterNarrowsTimeline(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
h := srv.Routes()
_, body := get(t, h, "/timeline?refresh=1&project="+fx.rootPath)
if !strings.Contains(body, fx.rootPath) {
t.Errorf("timeline ?project=<root> missing root creation row")
}
if !strings.Contains(body, fx.childPath) {
t.Errorf("timeline ?project=<root> missing child creation row")
}
if strings.Contains(body, fx.outsidePath) {
t.Errorf("timeline ?project=<root> leaked outside creation row %q", fx.outsidePath)
}
}
// TestProjectFilterNarrowsCalendar — buildCalendar funnels items via
// q.Filter.Matches; rows surface from dated item_links. Seed a dated link
// on each fixture item, then verify scoping by ?project=<root>.
func TestProjectFilterNarrowsCalendar(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rootNote := "cal-root-" + fx.rootSlug
childNote := "cal-child-" + fx.childSlug
outsideNote := "cal-outside-" + fx.outsideSlug
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
values ($1, 'document', $2, 'contains', $3, current_date),
($4, 'document', $5, 'contains', $6, current_date),
($7, 'document', $8, 'contains', $9, current_date)`,
fx.rootID, "https://example.com/cal-root", rootNote,
fx.childID, "https://example.com/cal-child", childNote,
fx.outsideID, "https://example.com/cal-outside", outsideNote,
); err != nil {
t.Fatalf("seed dated links: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID)
h := srv.Routes()
_, body := get(t, h, "/calendar?refresh=1&project="+fx.rootPath)
if !strings.Contains(body, rootNote) {
t.Errorf("calendar ?project=<root> missing root note %q", rootNote)
}
if !strings.Contains(body, childNote) {
t.Errorf("calendar ?project=<root> missing child note %q (descendants ON)", childNote)
}
if strings.Contains(body, outsideNote) {
t.Errorf("calendar ?project=<root> leaked outside note %q", outsideNote)
}
}
// TestProjectFilterNarrowsDashboard — dashboard filters items via Matches
// when q.Filter.Active() is true. The Stale-projects card is the most
// reliable surface to verify since it iterates the full item set on
// every render.
func TestProjectFilterNarrowsDashboard(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
h := srv.Routes()
_, body := get(t, h, "/dashboard?project="+fx.rootPath)
if !strings.Contains(body, fx.rootPath) {
t.Errorf("dashboard ?project=<root> missing root path %q", fx.rootPath)
}
if strings.Contains(body, fx.outsidePath) {
t.Errorf("dashboard ?project=<root> leaked outside path %q", fx.outsidePath)
}
}
// TestProjectFilterNarrowsBulk reproduces the actual bug: /admin/bulk's
// bulkMatches was a near-clone of TreeFilter.Matches that never picked up
// the Phase 5i Slice A ProjectPath block, so ?project=<root> silently
// ignored the filter. Pre-fix the outside item leaked into the bulk list.
func TestProjectFilterNarrowsBulk(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
h := srv.Routes()
_, body := get(t, h, "/admin/bulk?project="+fx.rootPath)
if !strings.Contains(body, fx.rootPath) {
t.Errorf("bulk ?project=<root> missing root path %q", fx.rootPath)
}
if !strings.Contains(body, fx.childPath) {
t.Errorf("bulk ?project=<root> missing child path %q (descendants ON)", fx.childPath)
}
if strings.Contains(body, fx.outsidePath) {
t.Errorf("BUG: /admin/bulk ?project=<root> leaked outside path %q — bulkMatches missing the ProjectPath gate", fx.outsidePath)
}
}
// TestProjectFilterDescendantsToggle pins m's Q5 pick: the toggle is
// exposed explicitly. With project_descendants=0 the filter narrows to
// the single root item only — the child path must drop out.
func TestProjectFilterDescendantsToggle(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
h := srv.Routes()
// Default (descendants on) — child included.
_, on := get(t, h, "/?project="+fx.rootPath)
if !strings.Contains(on, fx.childPath) {
t.Errorf("descendants=on should include child path %q", fx.childPath)
}
// Toggled off — child dropped, root still in.
_, off := get(t, h, "/?project="+fx.rootPath+"&project_descendants=0")
if !strings.Contains(off, fx.rootPath) {
t.Errorf("descendants=off should still include root %q", fx.rootPath)
}
if strings.Contains(off, fx.childPath) {
t.Errorf("descendants=off leaked child path %q — IncludeDescendants gate not honoured", fx.childPath)
}
}
// TestTimelineKindMultiValueSurvives mirrors the earlier calendar-filter
// fix: <select multiple> chip submission emits `?kind=event&kind=doc`,
// and the timeline's previous q.Get("kind") + comma-split dropped every
// value past the first. parseValues threads BOTH URL shapes through.
func TestTimelineKindMultiValueSurvives(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// We probe at the parse level via the page render: a `?kind=event&kind=doc`
// URL must round-trip both kinds into q.Kinds, so the kind multi-select
// in the rendered form preserves BOTH as selected options.
_, body := get(t, h, "/timeline?kind=event&kind=doc")
// The timeline's chip-strip <select> emits `<option value="x" selected>`
// only when q.Kinds contains "x". Pre-fix only the first value
// survived, so the second option lost its selected attr. The template
// has whitespace padding between value and selected so we anchor on
// the `value="X"` + `selected` pair within a small window — the
// `</option>` for the same X then closes the option.
checkSelected := func(kind string) {
idx := strings.Index(body, `<option value="`+kind+`"`)
if idx < 0 {
t.Errorf("rendered form missing <option value=%q>", kind)
return
}
// Slice until the following </option>; the selected attribute, if
// present, lives in that window.
end := strings.Index(body[idx:], `</option>`)
if end < 0 {
t.Errorf("rendered form malformed near <option value=%q>", kind)
return
}
window := body[idx : idx+end]
if !strings.Contains(window, "selected") {
t.Errorf("?kind=event&kind=doc lost %q selection: window=%q", kind, window)
}
}
checkSelected("event")
checkSelected("doc")
}

View File

@@ -149,20 +149,19 @@ func parseTimelineQuery(r *http.Request, now time.Time) TimelineQuery {
q.From = startOfDay(now)
}
}
if v := strings.TrimSpace(r.URL.Query().Get("kind")); v != "" {
seen := map[string]bool{}
for _, k := range strings.Split(v, ",") {
k = strings.TrimSpace(strings.ToLower(k))
switch k {
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
if !seen[k] {
seen[k] = true
q.Kinds = append(q.Kinds, k)
}
}
// Accept both `?kind=event,doc` (comma-joined) and
// `?kind=event&kind=doc` (HTMX multi-select submission). The earlier
// q.Get + comma-split flavour dropped everything past the first value
// when the chip strip's <select multiple> submitted — same pre-5d
// shape calendar's parser carried before commit 6f0a318. parseValues
// (web/server.go) merges both URL styles into a single slice.
for _, k := range parseValues(r.URL.Query(), "kind") {
switch k {
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
q.Kinds = append(q.Kinds, k)
}
sort.Strings(q.Kinds)
}
sort.Strings(q.Kinds)
return q
}

142
web/timeline_filter_test.go Normal file
View File

@@ -0,0 +1,142 @@
package web_test
import (
"context"
"strings"
"testing"
"time"
)
// TestTimelineFilterNarrowsByTag reproduces m's bug report: `/timeline?tag=work`
// should narrow the spine to only work-tagged items. Pre-fix the page rendered
// every dated row regardless of filter.
func TestTimelineFilterNarrowsByTag(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
tagWork := "tl-bug-work-" + stamp
tagHome := "tl-bug-home-" + 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)
}
type seed struct {
slug, note, tag string
}
seeds := []seed{
{slug: "tl-work-" + stamp, note: "tl-work-note-" + stamp, tag: tagWork},
{slug: "tl-home-" + stamp, note: "tl-home-note-" + stamp, tag: tagHome},
}
var ids []string
for _, s := range seeds {
var id string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, tags)
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], ARRAY[$4]::text[])
returning id`,
s.slug, s.slug, dev, s.tag,
).Scan(&id); err != nil {
t.Fatalf("seed %s: %v", s.slug, err)
}
ids = append(ids, id)
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
values ($1, 'document', $2, 'contains', $3, current_date)`,
id, "https://example.com/tl-"+s.slug, s.note,
); err != nil {
t.Fatalf("seed link %s: %v", s.slug, err)
}
}
for _, id := range ids {
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
}
// Unfiltered: both notes should show.
_, all := get(t, h, "/timeline?refresh=1")
if !strings.Contains(all, seeds[0].note) || !strings.Contains(all, seeds[1].note) {
t.Fatalf("baseline timeline missing seeded notes; body excerpt: %s", truncate(all, 600))
}
// Filtered: ?tag=tagWork should drop the home note.
_, scoped := get(t, h, "/timeline?refresh=1&tag="+tagWork)
if !strings.Contains(scoped, seeds[0].note) {
t.Errorf("filtered timeline missing work note %q", seeds[0].note)
}
if strings.Contains(scoped, seeds[1].note) {
t.Errorf("BUG: /timeline?tag=%s leaked home note %q — filter didn't narrow",
tagWork, seeds[1].note)
}
}
// TestTimelineFilterByKindMultiValue exercises the kind chip via HTMX-style
// repeated-param submission (?kind=todo&kind=doc). Pre-fix the timeline's
// own ?kind parser used q.Get("kind") which dropped everything past the
// first value — same root cause as the calendar's pre-5d kind bug.
func TestTimelineFilterByKindMultiValue(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
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)
}
slug := "tl-kind-" + stamp
noteText := "tl-kind-doc-note-" + stamp
var id string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[])
returning id`,
slug, slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
values ($1, 'document', $2, 'contains', $3, current_date)`,
id, "https://example.com/tl-kind-"+stamp, noteText,
); err != nil {
t.Fatalf("seed link: %v", err)
}
// HTMX-style repeated kind params: doc AND event selected.
// The seeded item only produces a doc row; the event slot is empty
// for this test (no linked calendar). Both kinds must parse so the
// doc row survives.
_, body := get(t, h, "/timeline?refresh=1&kind=doc&kind=event")
if !strings.Contains(body, noteText) {
t.Errorf("expected ?kind=doc&kind=event to include the seeded doc note %q, body excerpt: %s",
noteText, truncate(body, 600))
}
}
// TestTimelineFilterStripFormHasCorrectTarget asserts the chip strip's HTMX
// wiring is intact — `hx-get="/timeline"`, `hx-target="#timeline-section"`,
// `hx-trigger="change from:select"`. A future template edit that drops one
// of these would silently break in-place chip swapping.
func TestTimelineFilterStripFormHasCorrectTarget(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/timeline")
for _, want := range []string{
`id="timeline-filter"`,
`hx-get="/timeline"`,
`hx-target="#timeline-section"`,
`hx-trigger="change from:select"`,
} {
if !strings.Contains(body, want) {
t.Errorf("timeline filter form missing %q", want)
}
}
}