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.
30 KiB
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
- Project filter dim: no
?project=exists. To scope to "everything underwork.upc" today m has no chip; he searchesq=upcwhich is a string match across all fields. Not the same. - View-type is fixed per route:
/dashboardis always card-ish;/is always list-forest. m wants to flip a filtered set between card/list/calendar/kanban. - 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.
- 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_atmirrorsprojax.itemsso the table doesn't lose history. List queries alwaysWHERE 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.idis referenced by URL params (?view=<uuid>). Not foreign-keyed from items (views are query-builders, not item attributes).- A view's
filter_json.project_idis a pointer toprojax.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
/dashboardis 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.tmplbecomes thecardview template (renamed toview_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/dashboardas alternate to card. - Reuse:
tree_section.tmplbecomesview_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.tmpl→view_calendar.tmplessentially 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=kanbanon/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
/timelineif 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 vias.Store.GetByPathonce 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:
- No project: chip shows "project: any" with a search input next to it.
- Active: chip shows "project: work.upc" with an × to clear.
- 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: readproject(path) +project_id(uuid); resolve to both.TreeFilter.Matches: add the prefix check above.TreeFilter.QueryString: emitproject=<path>when set; emitproject_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 aProject ChipCountslot if we want a "with project filter on/off" indicator. v1: skip. The chip's presence is its own indicator.
Risk surface
- Stale
project_pathin a saved view after a slug rename. Solved byproject_idbeing 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_typetoggle 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_jsonto 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_uniqindex 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+ProjectPathfields toTreeFilter. - 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_idto 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 stringto a new neutralweb/views_query.goor extendTreeFilter(decide during implementation; leaning toward separate struct so TreeFilter stays focused on filtering). - Each Views-supporting page accepts
?view_type=card|list|calendar|timeline|kanbanand dispatches to the right template. - Default-per-page (hard-coded in slice B; saved-default lands in slice D).
- Extract
view_item.tmplpartial; refactortree_section.tmpl,dashboard_section.tmpl,timeline_section.tmpl,calendar_section.tmplto 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_byenum: 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:
- Slice A — project filter dim. One concrete win, no UI surprise, useful day 1.
- Slice B — view-type toggle. Generalises existing pages without writing new templates (extract + reuse).
- Slice D — saved views. Once view-type is a parameter, persistence has somewhere to point at.
- Slice C — kanban template. New ground; depends on the view-type plumbing being solid.
- 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-descendantsboolean toggle next to the picker. Default state and TreeFilter field:IncludeDescendants bool(defaulttrueto match the most common case; toggle flips tofalsefor single-item scope). URL param:?project_descendants=0when off (default-elided, so URLs stay short in the common case). Cache key implication: covered automatically byQueryString()once the field is in the emit set. - Q1 + Q3 lock the view_type enum at 5:
card | list | calendar | kanban | timeline. Migration0016_views.sqlCHECK 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:
IncludeDescendantsfield on TreeFilter, default true; toggle chip rendered next to the project picker on every Views-supporting page;Matchesfalls back to "primary path equality only" when off; round-trip test for the toggle. - Slice D schema:
view_typeCHECK now includestimeline. 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
/dashboardor/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.pathproduces 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 includeview_item.tmpland 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/dashboardwith no params applies the saved default.TestExplicitParamsOverrideDefault—?status=doneon/dashboardwith 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=cardconsumer; 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.