Merge branch 'mai/kahn/phase-5i-phase-a-design' (phase 5i slice A: project filter dim + descendants toggle)

# Conflicts:
#	web/dashboard.go
#	web/server.go
#	web/templates/dashboard_section.tmpl
This commit is contained in:
mAi
2026-05-26 13:29:20 +02:00
17 changed files with 1046 additions and 58 deletions

564
docs/plans/views-system.md Normal file
View File

@@ -0,0 +1,564 @@
# 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`)
```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:
```json
{
"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.tmpl``view_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`)
```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`)
```go
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: <name> · 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.