Files
projax/docs/plans/views-system.md
mAi 9138dfac59 docs: Phase 5i Views — fold in m's decisions on the 9 open Qs
m answered every open question directly via AskUserQuestion (greenlit
for inventor 2026-05-26 13:12). New §8.5 captures the picks + slice
implications. Inventor picks held on 6 of 9; m differed on Q5 (project
filter descendants) — wants an include-descendants toggle on the chip
rather than always-on, so Slice A grows an `IncludeDescendants` field
on TreeFilter + a toggle on the picker chip.

view_type enum locks at 5 (card/list/calendar/kanban/timeline). All
four out-of-scope items stay parked. No other slice changes.
2026-05-26 13:15:53 +02:00

30 KiB
Raw Blame History

Views system — design plan (Phase 5i)

Status: Phase A design (this doc). Branch: mai/kahn/phase-5i-phase-a-design. Author: kahn (inventor), 2026-05-26. Source request (m, 11:59 2026-05-26): "Generally speaking, I want a project filter on most pages — and of course also other criteria to filter by. I want to be able to set up custom views as well where we save which filters apply and what type of view (card / list / calendar / kanban)."

Sibling work: Phase 5h (fuller) is implementing the narrow Tabbed-Tiles dashboard. That work stays scoped; once both ship, the Tiles dashboard becomes the canonical view_type=card consumer.


§1 — Current state diagnosis

Filter dims that exist today (web/tree_filter.go)

TreeFilter has six dimensions plus search:

field shape semantics
Q string substring across title/slug/content/paths/aliases
Tags []string AND every requested tag must be present
Management []string OR any match passes; synthetic unmanaged matches empty it.Management
Status []string OR default ["active"]; setting to [] resets to active
HasLinks []string AND every requested ref_type must be linked to the item
Public *bool tri-state nil → no filter; otherwise must match item flag
ShowArchived bool when false, archived items hidden even if Status=archived

Round-trip is ParseTreeFilter(url.Values) ↔ QueryString(). Both comma-joined (?tag=a,b) and repeated-param (?tag=a&tag=b) shapes parse to the same struct (fix from the 2026-05-25 multi-select regression).

Pages that consume TreeFilter today

route handler filter usage implicit view shape
/ web/server.go handleTree filters → DAG-as-forest, ancestors kept list (forest of nodes)
/dashboard web/dashboard.go handleDashboard filters → 5 cards (tasks/issues/docs/stale/events) card (pre-5h) → card-tabbed (post-5h)
/timeline web/timeline.go handleTimeline filters → chronological spine timeline (its own shape)
/calendar web/calendar.go handleCalendar filters → month grid calendar
/graph web/graph.go handleGraph filters → SVG with dim-by-default graph (specialised, NOT in this system)
/admin/bulk web/bulk.go handleBulk filters → flat checkbox list (admin tool) flat list (admin, NOT in this system)

What is missing — and what m's signal targets

  1. Project filter dim: no ?project= exists. To scope to "everything under work.upc" today m has no chip; he searches q=upc which is a string match across all fields. Not the same.
  2. View-type is fixed per route: /dashboard is always card-ish; / is always list-forest. m wants to flip a filtered set between card/list/calendar/kanban.
  3. Saved views: a chosen filter set lives in the URL only. To re-open "active mai-managed dev projects with open Gitea issues" m must rebuild the chip sequence or bookmark the URL.
  4. No kanban: status-grouped columns don't exist anywhere in projax. m named it explicitly.

Why the existing pattern can't stretch

Each page handler hard-codes the render shape. Even though TreeFilter.Matches() is shared, the projection into rows (forest, day-bucket, month-cell, chronological day) is per-handler. To make view-type a parameter we need a generalisation: filter produces a set of items + linked context, view-type renders that set.


§2 — Data model: projax.views

Schema (migration 0016_views.sql)

CREATE TABLE projax.views (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name            text NOT NULL,
  description     text,
  filter_json     jsonb NOT NULL DEFAULT '{}'::jsonb,
  view_type       text NOT NULL,
  sort_field      text,           -- e.g. "title", "updated_at", "start_time"
  sort_dir        text,           -- "asc" | "desc"
  group_by        text,           -- "status" | "area" | "tag" | "management" — kanban-required
  pinned          boolean NOT NULL DEFAULT false,
  is_default_for  text,           -- "tree" | "dashboard" | "calendar" | "timeline" | null
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),
  deleted_at      timestamptz,
  CONSTRAINT views_view_type_chk
    CHECK (view_type IN ('card','list','calendar','kanban','timeline')),
  CONSTRAINT views_sort_dir_chk
    CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
  CONSTRAINT views_kanban_needs_group
    CHECK (view_type <> 'kanban' OR group_by IS NOT NULL),
  CONSTRAINT views_default_for_chk
    CHECK (is_default_for IS NULL OR is_default_for IN ('tree','dashboard','calendar','timeline'))
);

CREATE UNIQUE INDEX views_name_uniq
  ON projax.views (lower(name))
  WHERE deleted_at IS NULL;

CREATE UNIQUE INDEX views_default_for_uniq
  ON projax.views (is_default_for)
  WHERE is_default_for IS NOT NULL AND deleted_at IS NULL;

-- Trigger pattern same as items: updated_at on UPDATE, deleted_at semantics for soft delete.

Notes:

  • Single-user v1: no user_id. If multi-user ever lands, add a column + adjust unique indexes.
  • Soft delete: deleted_at mirrors projax.items so the table doesn't lose history. List queries always WHERE deleted_at IS NULL.
  • GIN on filter_json: not added in v1. At m's scale (≤30 views, queried only by id/name) GIN is over-engineered. Filter-introspection queries can scan the table.

filter_json shape

JSON keys mirror the TreeFilter struct, snake_case, optional keys:

{
  "q": "upc",
  "tags": ["work", "patents"],
  "management": ["mai"],
  "status": ["active"],
  "has_links": ["gitea-repo"],
  "public": true,
  "show_archived": false,
  "project_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "project_path": "work.upc.deadlines"
}

Project scoping carries both project_id and project_path:

  • project_id (uuid) is the durable join key — survives slug renames.
  • project_path (text) is for human-readable URL bars and the picker chip label. Resolved against the item's current path at render time; if path moved, the id still works.

When the filter is applied at runtime the id wins. The path is purely a cache/display convenience and refreshes on views.updated_at.

Cross-references

  • views.id is referenced by URL params (?view=<uuid>). Not foreign-keyed from items (views are query-builders, not item attributes).
  • A view's filter_json.project_id is a pointer to projax.items.id, not enforced via FK (filter values are user intent; if the project gets deleted the view should still render an empty set, not 500).

§3 — View types catalog

Five view types proposed (the four m named + timeline as open Q9.1). Each is a renderer for a filtered + sorted + grouped set of items.

card

  • Inputs: filtered item set + linked context (CalDAV todos/events, Gitea issues, dated links).
  • Output: tiles laid out in a responsive grid. Each tile shows item title + one or two contextual badges (open task count, due-today count, last activity). Today's /dashboard is the prototype; Phase 5h's tabbed tiles is the canonical card view.
  • Sort: title (default), updated_at, pinned-first, open-task-count.
  • Group-by: optional. When set → section headers per group, tiles within. Without group-by → one flat grid.
  • Page binding: default for /dashboard. Available on / (alternative to list).
  • Reuse: 5h's dashboard_section.tmpl becomes the card view template (renamed to view_card.tmpl).

list

  • Inputs: filtered item set.
  • Output: hierarchical forest with ancestor-keep semantics (today's /), OR flat list when group_by ≠ none.
  • Sort: slug (default; preserves DAG order), title, updated_at, status, start_time.
  • Group-by: optional. Flat-with-headers per group.
  • Page binding: default for /. Available on /dashboard as alternate to card.
  • Reuse: tree_section.tmpl becomes view_list.tmpl (with a flat-mode branch when group_by is set).

calendar

  • Inputs: filtered item set + their linked CalDAV (todos with DUE, events with DTSTART) + dated links.
  • Output: month grid, three rows per cell + overflow link (today's /calendar). Cell shows DAY label + per-row badges.
  • Sort: implicit chronological within cell.
  • Group-by: not applicable.
  • Page binding: default + only view for /calendar. Selectable elsewhere if the filtered set contains any dated content.
  • Reuse: calendar_section.tmplview_calendar.tmpl essentially unchanged.

kanban

  • Inputs: filtered item set.
  • Output: column-per-group-value, item card stacked vertically inside each column. Horizontal scroll on overflow.
  • Sort: within-column: pinned-first then updated_at desc (default), with title fallback.
  • Group-by: required. Sensible defaults: status (active/done/archived → 3 columns), area (top-level area path → N columns), management (mai/self/external/unmanaged → 4 columns), tag (1 column per tag the filtered set carries).
  • Page binding: no default page. Lives behind ?view_type=kanban on / and /dashboard, OR via a saved view URL.
  • Reuse: NEW template view_kanban.tmpl. Cards inside columns reuse a per-item partial (extracted from the tree row + dashboard tile so both kanban + card + list share it).
  • Write path: drag-to-change-group is OUT OF SCOPE for slice 1 (kanban-as-read view). Drag-to-set-status can come as slice C+ once the read side stabilises. Even without drag, kanban-as-grouping is useful — m gets a status snapshot across all "active dev mai-managed" projects without scrolling a forest.

timeline (open question — see §9 Q1)

  • Inputs: filtered item set + dated-context (todos due, events, doc PERs, item creation).
  • Output: chronological spine, today's /timeline.
  • Sort: date (default desc).
  • Group-by: implicit by day.
  • Page binding: default + only view for /timeline if treated as its own view type. Otherwise stays as a route outside the Views system.
  • Recommendation: treat as a fifth view_type. Reason: the user's mental model is "I have a filtered set; render it as X". Timeline-of-filtered-set fits the same shape. Keeping timeline outside the system creates two parallel concepts (saved views + saved timeline-windows) where one would do.

Shared item-row partial

To keep the four item-rendering view types (card, list, kanban, timeline) from drifting into four different copies of the same "title + chips + dates" partial, extract a view_item.tmpl partial that all four include. Per-view-type CSS classes drive layout; the markup stays one source.


§4 — Project filter dim

This is the smallest concrete piece and ships independently of view-types or saved views.

Struct change (web/tree_filter.go)

type TreeFilter struct {
  // …existing fields…
  ProjectID   string  // uuid; if set, scope to this item + descendants
  ProjectPath string  // display-only path string; resolved from ProjectID at parse time
}

Two fields: ProjectID is authoritative for matching; ProjectPath is for human-readable URLs (?project=work.upc) and chip labels.

URL semantics

  • Path-form (?project=work.upc): UI default, human-readable. Parser resolves path → id via s.Store.GetByPath once at parse time; both fields populate.
  • ID-form (?project_id=<uuid>): durable form. Used in saved views' filter_json.project_id. Parser populates id directly; path is best-effort.

If both are present, id wins and path is overwritten from the resolved item. If only the path is present and resolution fails (deleted/renamed), the filter renders a banner "Project not found: work.upc" and falls back to no project filter, so the page still renders rather than 404.

Match semantics (TreeFilter.Matches)

if f.ProjectID != "" {
  // item is in scope iff one of its paths is the project path or starts with project_path + "."
  hit := false
  for _, p := range it.Paths {
    if p == f.ProjectPath || strings.HasPrefix(p, f.ProjectPath+".") {
      hit = true
      break
    }
  }
  if !hit { return false }
}

Alternative: id-based ancestor closure via a precomputed descendantIDs[projectID] = {ids…} map. Cheaper for repeated calls inside a single request, more code to maintain. Decision: start with the path-prefix approach (cheap, readable, matches how it.Paths already encodes the DAG closure); switch to id-closure if a perf issue appears.

UI: project picker chip

A new chip row on every Views-supporting page, above the existing chip rows. Three states:

  1. No project: chip shows "project: any" with a search input next to it.
  2. Active: chip shows "project: work.upc" with an × to clear.
  3. Picking: input expands to a datalist autocomplete sourcing from s.Store.ListAll() (already cached server-side).

HTMX flow: typing the input fires hx-get="/?project=<value>"; picking from the datalist sets the value; ✕ navigates to the URL with ?project= stripped.

Backend touches

  • ParseTreeFilter: read project (path) + project_id (uuid); resolve to both.
  • TreeFilter.Matches: add the prefix check above.
  • TreeFilter.QueryString: emit project=<path> when set; emit project_id=<uuid> only if path is empty (path is friendlier in URL bar history).
  • TreeFilter.Active: include project filter.
  • Cache keys: untouched — QueryString() already produces the right key automatically once project is in the emit set.
  • computeChipCounts: add a Project ChipCount slot if we want a "with project filter on/off" indicator. v1: skip. The chip's presence is its own indicator.

Risk surface

  • Stale project_path in a saved view after a slug rename. Solved by project_id being authoritative and re-resolving on each render (one DB hit per request, cheap).
  • "Descendants" definition assumes the path-prefix encodes the DAG. The Phase 1.5 paths text[] migration already guarantees this (one entry per ancestor lineage).

§5 — Page bindings

route default view_type alternatives locked?
/ list card, kanban no
/dashboard card (5h tabbed-tiles) list, kanban no
/timeline timeline yes (if timeline is a view_type)
/calendar calendar yes
/graph (graph) NOT in Views system
/admin/bulk (flat checklist) NOT in Views system — admin tool
/admin/classify, /admin/caldav (admin) NOT in Views system

When a saved view is opened by URL (?view=<uuid>), it carries its own view_type, which overrides the page's default. So /dashboard?view=<uuid-of-kanban-view> renders kanban.

When a non-default view_type is selected on a page that supports it, the URL gains ?view_type=card|list|kanban|…. View_type chip strip rendered on every Views-supporting page next to the project filter chip.


§6 — Saved views UX

Picker placement: sidebar section under main nav

The Phase 5g sidebar already has space for a new section. Proposed shape:

[ sidebar ]
─────────────
  tree
  dashboard
  calendar
  timeline
  graph
─────────────
  Views ▾
    ★ active mai (pinned)
    work.upc kanban
    home + sport
    + new view…
─────────────
  admin

Each entry: name (linked to /views/<uuid> which redirects to the right page with ?view=<uuid>), star icon for pinned, hover-edit icon for rename/delete.

Why sidebar (vs Linear-style picker dropdown vs chip strip):

  • The sidebar is the natural "places I go" list in the new 5g nav. Views are persistent destinations, not transient toggles.
  • Saves the chip strip from getting another row. Chips on each page stay focused on filter dimensions (project, tag, mgmt, status, …) plus a view_type toggle row.
  • Mobile bottom-nav drawer (5g slice B) gets the same Views section so they're reachable everywhere.

Save / edit / delete

  • Save current state as new view: every Views-supporting page gets a "Save view…" button next to the clear-filters link. Modal asks name + (optional) description + which view_type + group_by/sort options + "default for this page" checkbox. POST to POST /views.
  • Edit existing view: opening a saved view shows "Edit view name + filters" link in the page header. Submitting updates views.filter_json to the current filter state (so editing == "save my tweaks back").
  • Delete: from the sidebar entry's hover menu. Soft delete (sets deleted_at).
  • Set as default for page: checkbox on the save/edit modal. The views_default_for_uniq index enforces at most one default per page; saving a new default clears the previous one in the same transaction.

URL shape

  • Page-level: /dashboard?project=work.upc&tag=patents&view_type=kanban&group_by=status
  • Saved view: /views/<uuid> redirects to the right page + the right ?view=<uuid> short form.
  • Short form: ?view=<uuid> on a page resolves the view, sets filter + view_type from the row, and the rest of the URL is empty. Mixing ?view=<uuid> + extra chips → the chip overrides the view's value for that dim (user is "tweaking" the saved view without saving back).

Sharing

  • Out of scope v1 (single-user, Tailscale-only).
  • Future: a saved view is just a row in a table; "export view" → JSON blob and "import view" → POST that JSON. Trivial to add when needed. Not building now.

§7 — Implementation slicing

Five slices, each independently shippable:

Slice A — Project filter dim (smallest first)

  • Add ProjectID + ProjectPath fields to TreeFilter.
  • Extend ParseTreeFilter / QueryString / Active / Matches.
  • Add project picker chip to all six Views-supporting filter strips (tree, dashboard, calendar, timeline + bulk + reuse pattern).
  • Tests: parse round-trip, prefix-match semantics, descendant inclusion, rename safety (use project_id to bypass path resolution).
  • Ships without any view-type or saved-view concept. Useful from day 1.

Slice B — View_type URL param + page bindings

  • Add ViewType string to a new neutral web/views_query.go or extend TreeFilter (decide during implementation; leaning toward separate struct so TreeFilter stays focused on filtering).
  • Each Views-supporting page accepts ?view_type=card|list|calendar|timeline|kanban and dispatches to the right template.
  • Default-per-page (hard-coded in slice B; saved-default lands in slice D).
  • Extract view_item.tmpl partial; refactor tree_section.tmpl, dashboard_section.tmpl, timeline_section.tmpl, calendar_section.tmpl to use it where they show item rows.
  • Tests: per-page view_type acceptance, default routing, partial render parity.
  • Depends on: nothing (can ship in parallel with A, but logically after A so the picker shows up alongside the view-type toggle).

Slice C — Kanban view_type

  • New view_kanban.tmpl.
  • Required group_by enum: status | area | tag | management.
  • Read-only first. No drag-to-change-group.
  • Adds the group-by chip strip (only shown when view_type=kanban).
  • Tests: kanban renders for each group_by value; empty filtered set renders zero-column message; unknown group_by → fallback to status with warning banner.

Slice D — Saved views schema + CRUD + sidebar picker

  • Migration 0016_views.sql.
  • store/views.go: ListViews, GetView, CreateView, UpdateView, SoftDeleteView, GetDefaultForPage.
  • Handlers: GET /views (list), POST /views (create), GET /views/<uuid> (redirect to page + ?view=), POST /views/<uuid> (update), POST /views/<uuid>/delete (soft delete).
  • Sidebar section + save-view modal + edit-view inline.
  • MCP read tools: list_views, get_view. Write tools deferred — m saves views via the UI, not via MCP.
  • Tests: schema migration round-trip, name-uniqueness, default-per-page-uniqueness, view JSON round-trip (filter_json ↔ TreeFilter).

Slice E — Default-view-per-page + URL shortening

  • On each Views-supporting page, after parsing URL params, if no explicit filter/view is set, look up views.is_default_for=<page> and apply it. Visible toggle "viewing default: · clear" lets the user opt out.
  • /views/<uuid> redirect.
  • Tests: default applied when no params; explicit params override default; clearing default resets.

Dep DAG

A ──┬─→ B ──┬─→ C
    │      └─→ D ──→ E
    └─→ D (slice D can also ship after A alone if B is delayed,
          since saved views can store view_type even before B's
          URL param exists — the view's view_type drives render)

Recommended order: A → B → D → C → E. Slice C is highest novelty (new template + drag-write later), so let it cook on a stable read foundation.


§8 — Recommendation

Ship in this order:

  1. Slice A — project filter dim. One concrete win, no UI surprise, useful day 1.
  2. Slice B — view-type toggle. Generalises existing pages without writing new templates (extract + reuse).
  3. Slice D — saved views. Once view-type is a parameter, persistence has somewhere to point at.
  4. Slice C — kanban template. New ground; depends on the view-type plumbing being solid.
  5. Slice E — default-per-page + URL shortener. Polish layer; ships when the rest works.

Each slice is its own coder shift, its own branch, its own PR. No bundled big-bang.


§8.5 — m's decisions (2026-05-26)

All open questions answered by m directly via AskUserQuestion (m greenlit chip-picker for inventor 2026-05-26 13:12). Decisions captured below; §9 stays as the historical record of what was open.

# header m picked matches inventor pick?
Q1 Timeline Fifth view_type yes
Q2 View scope Page-agnostic yes
Q3 enum size 5 values (follows Q1) yes
Q4 Picker Sidebar section under main nav yes
Q5 Proj scope Toggle on the chip (include-descendants on/off) no — inventor picked "always include descendants"; m wants explicit control
Q6 Kanban grp status (active/done/archived) yes
Q7 Save UI HTMX modal yes
Q8 rename safety (already resolved in §4: id authoritative, path display-only) n/a
Q9 Pull back into scope none — all four stay parked yes

Implications for the slicing

  • Q5 changes Slice A: project filter chip needs an include-descendants boolean toggle next to the picker. Default state and TreeFilter field: IncludeDescendants bool (default true to match the most common case; toggle flips to false for single-item scope). URL param: ?project_descendants=0 when off (default-elided, so URLs stay short in the common case). Cache key implication: covered automatically by QueryString() once the field is in the emit set.
  • Q1 + Q3 lock the view_type enum at 5: card | list | calendar | kanban | timeline. Migration 0016_views.sql CHECK constraint uses all five.
  • Q9 confirms all four parked: drag-to-change-group on kanban, multi-user/sharing, per-view notifications, cross-view diffs all stay out of Phase 5i. Slice C stays read-only as designed.
  • Q2 + Q4 + Q7 all match inventor picks: design ships as drafted on those fronts.

Concrete edits to the slicing in §7

  • Slice A adds: IncludeDescendants field on TreeFilter, default true; toggle chip rendered next to the project picker on every Views-supporting page; Matches falls back to "primary path equality only" when off; round-trip test for the toggle.
  • Slice D schema: view_type CHECK now includes timeline. No other change.
  • All other slices unchanged.

§9 — Open questions for head delegation

These are forks where data + code alone can't resolve the design. Single delegation to head with these batched; head surfaces to m; head relays answers.

Q1 — Is timeline a view_type or its own concept?

Options:

  • (a) Treat timeline as a fifth view_type alongside card/list/calendar/kanban. Cleaner mental model.
  • (b) Keep timeline as a standalone surface (today's behaviour). Views system stays four-typed as m named.

Inventor pick: (a) — kahn

Reasoning: m's mental model is "filtered set rendered as X". Timeline-of-filtered-set fits that shape. Keeps the system orthogonal.

Q2 — Should saved views be page-bound or page-agnostic?

Options:

  • (a) Page-agnostic: a view is just (filter, view_type, sort, group_by). It renders on any page that supports the view_type. Listed once in the sidebar.
  • (b) Page-bound: a view explicitly targets /dashboard or /timeline. Cannot be used elsewhere.

Inventor pick: (a)

Reasoning: simpler model, fewer redundant rows. The is_default_for column already handles "this is the default for page X" without making the view itself bound.

Q3 — View_type enum: 4 or 5 values?

Tied to Q1. If timeline is in: 5. If not: 4.

Q4 — Where does the saved-views picker live?

Options:

  • (a) Sidebar section under main nav (inventor pick — fits 5g sidebar).
  • (b) Top-of-page picker dropdown (Linear-style).
  • (c) Chip strip above filter chips.

Inventor pick: (a)

Q5 — Project filter: descendants always included?

Options:

  • (a) Always include descendants (path-prefix match). One picked project shows the whole sub-DAG. Inventor pick.
  • (b) Single-item scope. Show only that one item, no descendants.
  • (c) Toggle on the chip (include-descendants on/off).

Inventor pick: (a)

Reasoning: m's use case is "scope to work.upc" → meaning the whole subtree. Single-item scope is what the item detail page already does (/i/<path>).

Q6 — Kanban group_by default

Options:

  • (a) status (active/done/archived) — small fixed column set, mirrors most public kanbans.
  • (b) area (top-level path segment) — uses the existing area taxonomy.
  • (c) management (mai/self/external/unmanaged).

Inventor pick: (a)

Reasoning: a kanban with 3 status columns is the most legible default. Other group_by values are still selectable via the chip strip when view_type=kanban.

Q7 — Save-view modal vs inline form?

Options:

  • (a) Modal (HTMX-loaded, blocks the page until saved/cancelled).
  • (b) Inline form revealed below the filter strip on "Save view…" click.

Inventor pick: (a) — modal. Reason: saving a view is a deliberate action; the modal frames it as "you're naming and persisting this set".

Q8 — Slug-rename safety: store path or id in filter_json.project_*?

Already answered in §4: both, id authoritative. Flagging here so head knows it's been resolved, not pending.

Q9 — Out-of-scope confirmation

For head to confirm out-of-scope for Phase 5i:

  • Multi-user / sharing.
  • Drag-to-change-group on kanban (writes).
  • Per-view notifications / scheduled re-rendering.
  • Cross-view diffs ("what changed since last open").

Inventor: all four out, parked for future phases. Confirm with m via head.


§10 — Risk register

risk likelihood mitigation
view_item.tmpl extraction breaks one of four call sites medium parallel tests per existing template; ship slice B with a regression-screen check against existing visual output
Project filter prefix match wrong for multi-parent items low item.paths is the source of truth (sorted, deduped, one per lineage); test cases cover dev.paliad + work.paliad
Saved views proliferate and clutter the sidebar low pinned ★ + collapsing the section under a toggle keeps it tame; m owns deletes
Kanban write path drifts into scope creep medium slice C is read-only by contract; "drag-to-change-status" is its own future slice F if m wants it
URL shortener (/views/<uuid>) leaks short-lived views into shareable links n/a single-user, Tailscale-only — irrelevant in v1

§11 — Test plan headlines

Slice A

  • TestTreeFilterProjectScope — path-prefix match on items with single and multi-parent paths.
  • TestParseTreeFilterProjectFallback?project=missing.path produces a banner state, not a 500.
  • TestTreeFilterQueryStringRoundTrip — project_id + project_path round-trip in both URL forms.

Slice B

  • TestPageDispatchByViewType — each Views-supporting route serves the right template per ?view_type=.
  • TestViewItemPartialParity — visual diff harness: the four item-rendering view types include view_item.tmpl and don't regress existing markup.

Slice C

  • TestKanbanGroupBy — for each group_by value (status, area, tag, management), assert column set and per-column item membership.
  • TestKanbanRequiresGroupBy — without group_by, falls back to status and surfaces a warning banner.

Slice D

  • TestViewsCRUD — full lifecycle (create / read / list / update / soft delete) round-trip via store + handlers.
  • TestViewsNameUniqueness — duplicate names rejected (case-insensitive).
  • TestViewsDefaultUniqueness — setting a new default for a page clears the prior default in the same transaction.
  • TestViewsJSONRoundTrip — filter_json ↔ TreeFilter for every dim.

Slice E

  • TestDefaultViewAppliedOnEmptyURL — landing on /dashboard with no params applies the saved default.
  • TestExplicitParamsOverrideDefault?status=done on /dashboard with a saved default overrides without persisting.

§12 — References

  • web/tree_filter.go — current TreeFilter source.
  • web/server.go — handler registration, route table.
  • web/dashboard.go, web/calendar.go, web/timeline.go — current view implementations.
  • web/templates/{tree,dashboard,calendar,timeline}_section.tmpl — current view templates.
  • docs/design.md §4 (Interfaces), §17 (Calendar view), §12 (Timeline view), §18 (Sidebar nav).
  • docs/plans/aggregator-refactor.md — Phase 5a fan-out pattern, reused by the multi-view aggregator if needed in slice C.
  • mBrian saved-search patterns (~/dev/mBrian/src/lib/savedSearches.svelte, QuickSwitcher.svelte) — surveyed for UX inspiration; not ported.
  • Phase 5h (fuller) Tabbed-Tiles dashboard — first canonical view_type=card consumer; lands in parallel.

§13 — Status

  • Phase A (this doc): drafted by kahn, 2026-05-26. Awaiting m sign-off via head on §9 questions.
  • Phase B (coder): not started. Slices A → B → D → C → E. Each slice = one shift, one branch, one PR.
  • No code changes in this branch beyond this doc.