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:
564
docs/plans/views-system.md
Normal file
564
docs/plans/views-system.md
Normal 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.
|
||||
@@ -182,12 +182,20 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
display := *payload
|
||||
display.Cached = hit
|
||||
|
||||
projects, err := s.parentOptions(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "calendar",
|
||||
"P": display,
|
||||
"Filter": q.Filter,
|
||||
"Query": q,
|
||||
"Now": now,
|
||||
"Title": "calendar",
|
||||
"P": display,
|
||||
"Filter": q.Filter,
|
||||
"Query": q,
|
||||
"Now": now,
|
||||
"Projects": projects,
|
||||
"BasePath": "/calendar",
|
||||
"ProjectChipTarget": "#calendar-section",
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
s.render(w, r, "calendar_section", data)
|
||||
|
||||
@@ -239,17 +239,25 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
refreshURL += "refresh=1"
|
||||
|
||||
projects, err := s.parentOptions(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "dashboard",
|
||||
"P": displayPayload,
|
||||
"Filter": filter,
|
||||
"View": view,
|
||||
"Scope": scope,
|
||||
"Tabs": dashboardTabs(view, filterKey, scope),
|
||||
"ScopeURL": dashboardScopeToggleURL(view, scope, filterKey),
|
||||
"UpdatedRel": updatedRel,
|
||||
"RefreshURL": refreshURL,
|
||||
"FilterActive": filter.Active(),
|
||||
"Title": "dashboard",
|
||||
"P": displayPayload,
|
||||
"Filter": filter,
|
||||
"View": view,
|
||||
"Scope": scope,
|
||||
"Tabs": dashboardTabs(view, filterKey, scope),
|
||||
"ScopeURL": dashboardScopeToggleURL(view, scope, filterKey),
|
||||
"UpdatedRel": updatedRel,
|
||||
"RefreshURL": refreshURL,
|
||||
"FilterActive": filter.Active(),
|
||||
"Projects": projects,
|
||||
"BasePath": "/dashboard",
|
||||
"ProjectChipTarget": "#dashboard-section",
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
s.render(w, r, "dashboard_section", data)
|
||||
|
||||
@@ -138,10 +138,14 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?tag=dev&view=tasks → %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "dev."+devSlug) {
|
||||
// Phase 5i Slice A: the project-scope picker renders every item's primary
|
||||
// path as a <select> option, so a naive body substring match would also
|
||||
// see filtered-out paths inside the dropdown. Anchor the row assertion on
|
||||
// the detail link emitted by the dashboard cards instead.
|
||||
if !strings.Contains(body, `href="/i/dev.`+devSlug+`"`) {
|
||||
t.Errorf("expected dev row in filtered dashboard")
|
||||
}
|
||||
if strings.Contains(body, "home."+homeSlug) {
|
||||
if strings.Contains(body, `href="/i/home.`+homeSlug+`"`) {
|
||||
t.Errorf("home row should be filtered out when ?tag=dev")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,18 +238,25 @@ func TestTreeFilterPublicNarrows(t *testing.T) {
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, pubID, prvID)
|
||||
|
||||
// Phase 5i Slice A: the project picker drops every item path into a
|
||||
// <select> dropdown, so naive substring assertions trip on filtered-out
|
||||
// rows visible in the picker. Anchor the row assertion on the
|
||||
// tree-row link instead — that only renders for items that pass the
|
||||
// filter.
|
||||
pubLink := `href="/i/dev.` + pubSlug + `"`
|
||||
prvLink := `href="/i/dev.` + prvSlug + `"`
|
||||
_, yesBody := get(t, h, "/?public=1")
|
||||
if !strings.Contains(yesBody, pubSlug) {
|
||||
t.Errorf("?public=1 should show pub-filt-yes")
|
||||
if !strings.Contains(yesBody, pubLink) {
|
||||
t.Errorf("?public=1 should show pub-filt-yes row")
|
||||
}
|
||||
if strings.Contains(yesBody, prvSlug) {
|
||||
t.Errorf("?public=1 should hide pub-filt-no")
|
||||
if strings.Contains(yesBody, prvLink) {
|
||||
t.Errorf("?public=1 should hide pub-filt-no row")
|
||||
}
|
||||
_, noBody := get(t, h, "/?public=0")
|
||||
if strings.Contains(noBody, pubSlug) {
|
||||
t.Errorf("?public=0 should hide pub-filt-yes")
|
||||
if strings.Contains(noBody, pubLink) {
|
||||
t.Errorf("?public=0 should hide pub-filt-yes row")
|
||||
}
|
||||
if !strings.Contains(noBody, prvSlug) {
|
||||
t.Errorf("?public=0 should show pub-filt-no")
|
||||
if !strings.Contains(noBody, prvLink) {
|
||||
t.Errorf("?public=0 should show pub-filt-no row")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,18 +162,24 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
pages[name] = t
|
||||
}
|
||||
// tree bundles the tree-section partial so HTMX swaps and the initial
|
||||
// page render share definitions.
|
||||
// page render share definitions. project_chip.tmpl is the Phase 5i Slice
|
||||
// A shared partial that every Views-supporting page includes inside its
|
||||
// filter strip.
|
||||
treeTmpl, err := template.New("tree").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/layout.tmpl",
|
||||
"templates/tree.tmpl",
|
||||
"templates/tree_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tree: %w", err)
|
||||
}
|
||||
pages["tree"] = treeTmpl
|
||||
// Standalone tree-section template for HTMX fragment responses.
|
||||
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS, "templates/tree_section.tmpl")
|
||||
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/tree_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tree_section: %w", err)
|
||||
}
|
||||
@@ -251,6 +257,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
"templates/dashboard.tmpl",
|
||||
"templates/dashboard_section.tmpl",
|
||||
"templates/dashboard_tiles.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dashboard: %w", err)
|
||||
@@ -259,6 +266,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
dashSection, err := template.New("dashboard_section").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/dashboard_section.tmpl",
|
||||
"templates/dashboard_tiles.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dashboard_section: %w", err)
|
||||
@@ -270,12 +278,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
"templates/layout.tmpl",
|
||||
"templates/timeline.tmpl",
|
||||
"templates/timeline_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse timeline: %w", err)
|
||||
}
|
||||
pages["timeline"] = timelineTmpl
|
||||
timelineSection, err := template.New("timeline_section").Funcs(funcs).ParseFS(templatesFS, "templates/timeline_section.tmpl")
|
||||
timelineSection, err := template.New("timeline_section").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/timeline_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse timeline_section: %w", err)
|
||||
}
|
||||
@@ -288,12 +300,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
"templates/layout.tmpl",
|
||||
"templates/calendar.tmpl",
|
||||
"templates/calendar_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse calendar: %w", err)
|
||||
}
|
||||
pages["calendar"] = calTmpl
|
||||
calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS, "templates/calendar_section.tmpl")
|
||||
calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/calendar_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse calendar_section: %w", err)
|
||||
}
|
||||
@@ -427,15 +443,18 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
||||
counts := computeChipCounts(items, filter, linkKinds, tags)
|
||||
data := map[string]any{
|
||||
"Title": "tree",
|
||||
"Roots": roots,
|
||||
"Orphans": orphans,
|
||||
"Total": total,
|
||||
"OrphanN": orphanN,
|
||||
"Matched": matched,
|
||||
"AllTags": tags,
|
||||
"Filter": filter,
|
||||
"Counts": counts,
|
||||
"Title": "tree",
|
||||
"Roots": roots,
|
||||
"Orphans": orphans,
|
||||
"Total": total,
|
||||
"OrphanN": orphanN,
|
||||
"Matched": matched,
|
||||
"AllTags": tags,
|
||||
"Filter": filter,
|
||||
"Counts": counts,
|
||||
"Projects": parentOptionsFromItems(items),
|
||||
"BasePath": "/",
|
||||
"ProjectChipTarget": "#tree-section",
|
||||
// ActiveTags kept for backwards-compat with the old template path; removed
|
||||
// after the template migrates fully.
|
||||
"ActiveTags": filter.Tags,
|
||||
@@ -880,15 +899,20 @@ func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []ParentOption
|
||||
return parentOptionsFromItems(items), nil
|
||||
}
|
||||
|
||||
// parentOptionsFromItems builds the same flat option list parentOptions
|
||||
// returns, but from an already-loaded items slice. Callers that have already
|
||||
// fetched items (handleTree, handleDashboard, …) use this to avoid a second
|
||||
// ListAll round-trip when they only need the picker options.
|
||||
func parentOptionsFromItems(items []*store.Item) []ParentOption {
|
||||
out := make([]ParentOption, 0, len(items))
|
||||
for _, it := range items {
|
||||
// Surface every primary path as a candidate parent — multi-parent
|
||||
// items appear once per parent option using their primary path so the
|
||||
// UI stays unambiguous.
|
||||
out = append(out, ParentOption{ID: it.ID, Path: it.PrimaryPath()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out, nil
|
||||
return out
|
||||
}
|
||||
|
||||
// (buildForest + nodeHasAllTags removed in Phase 3b — superseded by
|
||||
|
||||
@@ -294,3 +294,71 @@ func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterScopesTreeToDescendants verifies the Phase 5i Slice A
|
||||
// project scope semantics end-to-end: ?project=<path> narrows / to the picked
|
||||
// item + descendants; ?project_descendants=0 narrows further to the picked
|
||||
// item alone. Both round-trip through ParseTreeFilter + TreeFilter.Matches +
|
||||
// the tree handler.
|
||||
func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx := context.Background()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
parentSlug := "p5i-parent-" + stamp
|
||||
childSlug := "p5i-child-" + stamp
|
||||
siblingSlug := "p5i-sib-" + 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)
|
||||
}
|
||||
var parentID, childID, siblingID string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Parent', $1, ARRAY[$2]::uuid[]) returning id`,
|
||||
parentSlug, dev).Scan(&parentID); err != nil {
|
||||
t.Fatalf("seed parent: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Child', $1, ARRAY[$2]::uuid[]) returning id`,
|
||||
childSlug, parentID).Scan(&childID); err != nil {
|
||||
t.Fatalf("seed child: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Sib', $1, ARRAY[$2]::uuid[]) returning id`,
|
||||
siblingSlug, dev).Scan(&siblingID); err != nil {
|
||||
t.Fatalf("seed sibling: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1,$2,$3)`, childID, parentID, siblingID)
|
||||
|
||||
parentPath := "dev." + parentSlug
|
||||
parentLink := `href="/i/` + parentPath + `"`
|
||||
childLink := `href="/i/` + parentPath + `.` + childSlug + `"`
|
||||
siblingLink := `href="/i/dev.` + siblingSlug + `"`
|
||||
|
||||
// Descendants on (default): parent + child visible, sibling hidden.
|
||||
_, withDesc := get(t, h, "/?project="+parentPath)
|
||||
if !strings.Contains(withDesc, parentLink) {
|
||||
t.Errorf("?project=%s should show parent row", parentPath)
|
||||
}
|
||||
if !strings.Contains(withDesc, childLink) {
|
||||
t.Errorf("?project=%s should include descendant child row", parentPath)
|
||||
}
|
||||
if strings.Contains(withDesc, siblingLink) {
|
||||
t.Errorf("?project=%s should exclude sibling row", parentPath)
|
||||
}
|
||||
|
||||
// Descendants off: only the picked item, no children.
|
||||
_, noDesc := get(t, h, "/?project="+parentPath+"&project_descendants=0")
|
||||
if !strings.Contains(noDesc, parentLink) {
|
||||
t.Errorf("?project_descendants=0 should still show the picked parent row")
|
||||
}
|
||||
if strings.Contains(noDesc, childLink) {
|
||||
t.Errorf("?project_descendants=0 should hide the child row")
|
||||
}
|
||||
if strings.Contains(noDesc, siblingLink) {
|
||||
t.Errorf("?project_descendants=0 should hide the sibling row")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,19 @@ table.classify input, table.classify select { width: 100%; }
|
||||
.mgmt-chip:hover, .status-chip:hover, .has-chip:hover { color: var(--fg); border-color: var(--accent); }
|
||||
.chip-on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
.chip-on:hover { color: var(--accent-fg); filter: brightness(0.92); }
|
||||
/* Phase 5i Slice A — project scope chip. The picker uses a bare <select>
|
||||
inside a tagbar form; the active state mirrors the mgmt/status/has chips. */
|
||||
.proj-chip, .proj-desc-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 0.78em; padding: 1px 8px; border-radius: 999px;
|
||||
background: var(--surface); border: 1px solid var(--border); color: var(--muted); text-decoration: none;
|
||||
}
|
||||
.proj-chip.chip-on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
.proj-chip .proj-name { font-weight: 500; }
|
||||
.proj-chip .proj-clear { color: inherit; opacity: 0.75; margin-left: 4px; padding: 0 4px; }
|
||||
.proj-chip .proj-clear:hover { opacity: 1; }
|
||||
.proj-desc-chip:hover { color: var(--fg); border-color: var(--accent); }
|
||||
.proj-picker select { font-size: 0.85em; padding: 1px 4px; }
|
||||
#tree-filterbar small { opacity: 0.75; margin-left: 2px; }
|
||||
.tree-section .empty { padding: 24px; color: var(--muted); }
|
||||
.tree-section .clear { color: var(--bad); }
|
||||
|
||||
@@ -37,8 +37,13 @@
|
||||
<option value="doc" {{if contains $selK "doc"}}selected{{end}}>doc</option>
|
||||
</select>
|
||||
</label>
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
|
||||
<p class="counts muted">
|
||||
<small>{{.P.TotalRows}} {{if eq .P.TotalRows 1}}row{{else}}rows{{end}}</small>
|
||||
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">· cached</small>{{else}}<small>· fresh</small>{{end}}
|
||||
|
||||
@@ -30,8 +30,13 @@
|
||||
</select>
|
||||
</label>
|
||||
{{if ne .View "tiles"}}<input type="hidden" name="view" value="{{.View}}">{{end}}
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
|
||||
<p class="counts muted">
|
||||
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">updated {{.UpdatedRel}} · cached</small>
|
||||
{{else}}<small>updated {{.UpdatedRel}} · fresh</small>{{end}}
|
||||
|
||||
58
web/templates/project_chip.tmpl
Normal file
58
web/templates/project_chip.tmpl
Normal file
@@ -0,0 +1,58 @@
|
||||
{{/*
|
||||
Phase 5i Slice A — shared project-scope chip + picker.
|
||||
|
||||
Rendered inside the tagbar on every Views-supporting page (tree, dashboard,
|
||||
timeline, calendar). Two visual states:
|
||||
|
||||
ProjectPath == "" → <select> picker with all items by primary path
|
||||
ProjectPath != "" → active chip with × clear + descendants on/off toggle
|
||||
|
||||
Each caller passes a top-level page-data map with:
|
||||
Filter — TreeFilter
|
||||
Projects — []ParentOption (sorted by path)
|
||||
BasePath — page route, e.g. "/", "/dashboard", "/timeline", "/calendar"
|
||||
ProjectChipTarget — HTMX target selector, e.g. "#tree-section"
|
||||
|
||||
m's Q5 pick (2026-05-26): descendants toggle is on by default but exposed
|
||||
explicitly on the chip, not always-on.
|
||||
*/}}
|
||||
{{define "view-project-chip"}}
|
||||
<div class="chip-row project-chip-row">
|
||||
<span class="muted">project:</span>
|
||||
{{if .Filter.ProjectPath}}
|
||||
{{$cleared := (.Filter.SetProject "").URLOn .BasePath}}
|
||||
{{$toggled := .Filter.ToggleIncludeDescendants.URLOn .BasePath}}
|
||||
<span class="proj-chip chip-on" title="Scoped to {{.Filter.ProjectPath}}">
|
||||
<span class="proj-name">{{.Filter.ProjectPath}}</span>
|
||||
<a class="proj-clear"
|
||||
href="{{$cleared}}"
|
||||
hx-get="{{$cleared}}" hx-target="{{.ProjectChipTarget}}" hx-swap="outerHTML" hx-push-url="true"
|
||||
title="Clear project scope">×</a>
|
||||
</span>
|
||||
<a class="proj-desc-chip {{if .Filter.IncludeDescendants}}chip-on{{end}}"
|
||||
href="{{$toggled}}"
|
||||
hx-get="{{$toggled}}" hx-target="{{.ProjectChipTarget}}" hx-swap="outerHTML" hx-push-url="true"
|
||||
title="Include descendants of {{.Filter.ProjectPath}} in scope">
|
||||
descendants {{if .Filter.IncludeDescendants}}on{{else}}off{{end}}
|
||||
</a>
|
||||
{{else}}
|
||||
<form class="proj-picker"
|
||||
hx-get="{{.BasePath}}"
|
||||
hx-target="{{.ProjectChipTarget}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="change from:select"
|
||||
hx-push-url="true">
|
||||
{{if .Filter.Q}}<input type="hidden" name="q" value="{{.Filter.Q}}">{{end}}
|
||||
{{if .Filter.Tags}}<input type="hidden" name="tag" value="{{join "," .Filter.Tags}}">{{end}}
|
||||
{{if .Filter.Management}}<input type="hidden" name="mgmt" value="{{join "," .Filter.Management}}">{{end}}
|
||||
{{if ne (join "," .Filter.Status) "active"}}<input type="hidden" name="status" value="{{join "," .Filter.Status}}">{{end}}
|
||||
{{if .Filter.HasLinks}}<input type="hidden" name="has" value="{{join "," .Filter.HasLinks}}">{{end}}
|
||||
{{if .Filter.ShowArchived}}<input type="hidden" name="show-archived" value="1">{{end}}
|
||||
<select name="project" autocomplete="off">
|
||||
<option value="">— any —</option>
|
||||
{{range .Projects}}<option value="{{.Path}}">{{.Path}}</option>{{end}}
|
||||
</select>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -43,8 +43,13 @@
|
||||
<option value="asc" {{if eq .P.Order "asc"}}selected{{end}}>oldest first</option>
|
||||
</select>
|
||||
</label>
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/timeline">clear filters</a>{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
|
||||
<p class="counts muted">
|
||||
<small>{{.P.TotalRows}} rows · {{.P.From.Format "2006-01-02"}} → {{.P.ToInclusive.Format "2006-01-02"}}</small>
|
||||
{{if .P.Cached}}<small title="Served from 90s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">· cached</small>{{else}}<small>· fresh</small>{{end}}
|
||||
|
||||
@@ -16,8 +16,12 @@
|
||||
{{if ne (join "," .Filter.Status) "active"}}<input type="hidden" name="status" value="{{join "," .Filter.Status}}">{{end}}
|
||||
{{if .Filter.HasLinks}}<input type="hidden" name="has" value="{{join "," .Filter.HasLinks}}">{{end}}
|
||||
{{if .Filter.ShowArchived}}<input type="hidden" name="show-archived" value="1">{{end}}
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
|
||||
{{if .AllTags}}
|
||||
<div class="chip-row">
|
||||
<span class="muted">tag:</span>
|
||||
|
||||
@@ -188,12 +188,20 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
display := *payload
|
||||
display.Cached = hit
|
||||
|
||||
projects, err := s.parentOptions(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "timeline",
|
||||
"P": display,
|
||||
"Filter": q.Filter,
|
||||
"Query": q,
|
||||
"Now": now,
|
||||
"Title": "timeline",
|
||||
"P": display,
|
||||
"Filter": q.Filter,
|
||||
"Query": q,
|
||||
"Now": now,
|
||||
"Projects": projects,
|
||||
"BasePath": "/timeline",
|
||||
"ProjectChipTarget": "#timeline-section",
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
s.render(w, r, "timeline_section", data)
|
||||
|
||||
@@ -338,10 +338,15 @@ func TestTimelineFilterByTagAppliesAcrossKinds(t *testing.T) {
|
||||
|
||||
tag := "tl-tag-work-" + stamp
|
||||
_, body := get(t, h, "/timeline?tag="+tag)
|
||||
if !strings.Contains(body, "tl-tag-d-"+stamp) {
|
||||
// Phase 5i Slice A: the project picker renders every item path as a
|
||||
// <select> option, so a naive substring match also sees filtered-out
|
||||
// items inside the dropdown. Anchor on the timeline-row link instead.
|
||||
devLink := `href="/i/dev.tl-tag-d-` + stamp + `"`
|
||||
homeLink := `href="/i/home.tl-tag-h-` + stamp + `"`
|
||||
if !strings.Contains(body, devLink) {
|
||||
t.Errorf("?tag=%s should surface dev-tagged item", tag)
|
||||
}
|
||||
if strings.Contains(body, "tl-tag-h-"+stamp) {
|
||||
if strings.Contains(body, homeLink) {
|
||||
t.Errorf("?tag=%s should hide home-tagged item", tag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ type TreeFilter struct {
|
||||
HasLinks []string // ANY of these ref_types must be linked to the item ("caldav-list", "gitea-repo")
|
||||
ShowArchived bool // when false, hide items with archived=true even if Status matches
|
||||
Public *bool // Phase 4d — nil = no filter; true = public only; false = private only
|
||||
// Phase 5i Slice A — project scope.
|
||||
// ProjectPath is the picked project's primary path (e.g. "work.upc"). Empty
|
||||
// means no project filter. IncludeDescendants defaults to true; when false,
|
||||
// only items whose paths include the exact ProjectPath match (no subtree).
|
||||
// Per m's Q5 pick (2026-05-26), descendants are NOT always-on — the chip
|
||||
// exposes an explicit on/off toggle.
|
||||
ProjectPath string
|
||||
IncludeDescendants bool
|
||||
}
|
||||
|
||||
// Active reports whether any filter dimension is set to something other than
|
||||
@@ -26,6 +34,9 @@ func (f TreeFilter) Active() bool {
|
||||
if f.Q != "" || len(f.Tags) > 0 || len(f.Management) > 0 || len(f.HasLinks) > 0 || f.ShowArchived || f.Public != nil {
|
||||
return true
|
||||
}
|
||||
if f.ProjectPath != "" {
|
||||
return true
|
||||
}
|
||||
// Status is the only dimension with a default; treat it as "active" if it
|
||||
// deviates from {"active"}.
|
||||
if len(f.Status) != 1 || f.Status[0] != "active" {
|
||||
@@ -45,12 +56,14 @@ func (f TreeFilter) Active() bool {
|
||||
// TestCalendarFilterMultiValueTagsFromForm for the regression.
|
||||
func ParseTreeFilter(q url.Values) TreeFilter {
|
||||
f := TreeFilter{
|
||||
Q: strings.TrimSpace(q.Get("q")),
|
||||
Tags: parseValues(q, "tag"),
|
||||
Management: parseValues(q, "mgmt"),
|
||||
Status: parseValues(q, "status"),
|
||||
HasLinks: parseValues(q, "has"),
|
||||
ShowArchived: q.Get("show-archived") == "1",
|
||||
Q: strings.TrimSpace(q.Get("q")),
|
||||
Tags: parseValues(q, "tag"),
|
||||
Management: parseValues(q, "mgmt"),
|
||||
Status: parseValues(q, "status"),
|
||||
HasLinks: parseValues(q, "has"),
|
||||
ShowArchived: q.Get("show-archived") == "1",
|
||||
ProjectPath: strings.TrimSpace(q.Get("project")),
|
||||
IncludeDescendants: true,
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("public")); v != "" {
|
||||
// Treat 1/true/yes/on as true; 0/false/no/off as false; anything else nil.
|
||||
@@ -63,6 +76,11 @@ func ParseTreeFilter(q url.Values) TreeFilter {
|
||||
f.Public = &b
|
||||
}
|
||||
}
|
||||
// project_descendants=0 flips the toggle off; any other / missing value
|
||||
// leaves the default (true). Matches the show-archived parsing pattern.
|
||||
if q.Get("project_descendants") == "0" {
|
||||
f.IncludeDescendants = false
|
||||
}
|
||||
if len(f.Status) == 0 {
|
||||
f.Status = []string{"active"}
|
||||
}
|
||||
@@ -99,6 +117,14 @@ func (f TreeFilter) QueryString() string {
|
||||
v.Set("public", "0")
|
||||
}
|
||||
}
|
||||
if f.ProjectPath != "" {
|
||||
v.Set("project", f.ProjectPath)
|
||||
// IncludeDescendants=true is the default — elide. Only emit when the
|
||||
// user has explicitly turned descendants off (the chip's "off" state).
|
||||
if !f.IncludeDescendants {
|
||||
v.Set("project_descendants", "0")
|
||||
}
|
||||
}
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
@@ -120,11 +146,19 @@ func (f TreeFilter) TogglePublic() TreeFilter {
|
||||
|
||||
// URL builds a `/?…` URL for this filter. Empty filter → "/".
|
||||
func (f TreeFilter) URL() string {
|
||||
return f.URLOn("/")
|
||||
}
|
||||
|
||||
// URLOn builds a URL anchored at `base` for this filter. Empty filter →
|
||||
// `base` unchanged. Used by Views-supporting pages (dashboard, timeline,
|
||||
// calendar) to construct chip URLs that stay on the current route, where the
|
||||
// default URL() always lands on "/".
|
||||
func (f TreeFilter) URLOn(base string) string {
|
||||
q := f.QueryString()
|
||||
if q == "" {
|
||||
return "/"
|
||||
return base
|
||||
}
|
||||
return "/?" + q
|
||||
return base + "?" + q
|
||||
}
|
||||
|
||||
// ToggleTag returns a copy with tag added/removed.
|
||||
@@ -166,6 +200,29 @@ func (f TreeFilter) ToggleShowArchived() TreeFilter {
|
||||
return next
|
||||
}
|
||||
|
||||
// SetProject returns a copy scoped to the given primary path. Empty path
|
||||
// clears the scope. IncludeDescendants resets to true (the safe default) when
|
||||
// the project is cleared so a future SetProject doesn't inherit a stale off
|
||||
// state.
|
||||
func (f TreeFilter) SetProject(path string) TreeFilter {
|
||||
next := f
|
||||
next.ProjectPath = strings.TrimSpace(path)
|
||||
if next.ProjectPath == "" {
|
||||
next.IncludeDescendants = true
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// ToggleIncludeDescendants flips the descendants toggle. The chip stays
|
||||
// settable even with no project picked (so the URL bar can carry the user's
|
||||
// preference for the next project they pick), but Matches only consults it
|
||||
// when ProjectPath is set.
|
||||
func (f TreeFilter) ToggleIncludeDescendants() TreeFilter {
|
||||
next := f
|
||||
next.IncludeDescendants = !f.IncludeDescendants
|
||||
return next
|
||||
}
|
||||
|
||||
func toggleString(in []string, val string) []string {
|
||||
found := false
|
||||
out := make([]string, 0, len(in))
|
||||
@@ -229,6 +286,28 @@ func (f TreeFilter) Matches(it *store.Item, itemLinkKinds map[string]struct{}) b
|
||||
if f.Public != nil && *f.Public != it.Public {
|
||||
return false
|
||||
}
|
||||
// Project scope (Phase 5i Slice A). When set, the item must have at least
|
||||
// one path equal to ProjectPath (exact match), and — when
|
||||
// IncludeDescendants is on — paths that are descendants of ProjectPath
|
||||
// (prefix + ".") also match. Multi-parent items are in scope as long as
|
||||
// ANY of their paths qualifies.
|
||||
if f.ProjectPath != "" {
|
||||
prefix := f.ProjectPath + "."
|
||||
hit := false
|
||||
for _, p := range it.Paths {
|
||||
if p == f.ProjectPath {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
if f.IncludeDescendants && strings.HasPrefix(p, prefix) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// q substring match.
|
||||
if f.Q != "" {
|
||||
q := strings.ToLower(f.Q)
|
||||
|
||||
@@ -189,6 +189,129 @@ func TestComputeChipCountsTagCounts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterIncludesDescendants verifies Slice A scope semantics: a
|
||||
// picked ProjectPath matches the item itself plus every descendant in the DAG
|
||||
// closure (any path with the ProjectPath + "." prefix). Multi-parent items
|
||||
// are in scope when any of their paths qualifies.
|
||||
func TestProjectFilterIncludesDescendants(t *testing.T) {
|
||||
work := &store.Item{ID: "work", Slug: "work", Paths: []string{"work"}, Status: "active"}
|
||||
upc := &store.Item{ID: "upc", Slug: "upc", Paths: []string{"work.upc"}, ParentIDs: []string{"work"}, Status: "active"}
|
||||
deadlines := &store.Item{ID: "dl", Slug: "deadlines", Paths: []string{"work.upc.deadlines"}, ParentIDs: []string{"upc"}, Status: "active"}
|
||||
other := &store.Item{ID: "other", Slug: "other", Paths: []string{"work.other"}, ParentIDs: []string{"work"}, Status: "active"}
|
||||
// Multi-parent: lives under both dev.paliad and work.paliad. Setting the
|
||||
// scope to "work" must put it in scope via its work.paliad lineage.
|
||||
paliad := &store.Item{ID: "paliad", Slug: "paliad", Paths: []string{"dev.paliad", "work.paliad"}, Status: "active"}
|
||||
dev := &store.Item{ID: "dev", Slug: "dev", Paths: []string{"dev"}, Status: "active"}
|
||||
|
||||
links := map[string]struct{}{}
|
||||
|
||||
f := TreeFilter{Status: []string{"active"}, ProjectPath: "work.upc", IncludeDescendants: true}
|
||||
if !f.Matches(upc, links) {
|
||||
t.Error("exact-match item should pass")
|
||||
}
|
||||
if !f.Matches(deadlines, links) {
|
||||
t.Error("descendant should pass")
|
||||
}
|
||||
if f.Matches(work, links) {
|
||||
t.Error("ancestor should NOT pass project=work.upc")
|
||||
}
|
||||
if f.Matches(other, links) {
|
||||
t.Error("sibling should NOT pass")
|
||||
}
|
||||
|
||||
// Multi-parent: scope=work should match paliad via the work.paliad path.
|
||||
f2 := TreeFilter{Status: []string{"active"}, ProjectPath: "work", IncludeDescendants: true}
|
||||
if !f2.Matches(paliad, links) {
|
||||
t.Error("multi-parent item should match scope=work via work.paliad path")
|
||||
}
|
||||
if f2.Matches(dev, links) {
|
||||
t.Error("dev (sibling root) should NOT pass scope=work")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterDescendantsOff verifies that flipping IncludeDescendants
|
||||
// off restricts the scope to items whose paths equal ProjectPath exactly. m
|
||||
// asked for this toggle behaviour explicitly in Q5 (2026-05-26): always-on
|
||||
// descendants was the inventor pick; m wants the chip to expose on/off.
|
||||
func TestProjectFilterDescendantsOff(t *testing.T) {
|
||||
upc := &store.Item{ID: "upc", Slug: "upc", Paths: []string{"work.upc"}, Status: "active"}
|
||||
deadlines := &store.Item{ID: "dl", Slug: "deadlines", Paths: []string{"work.upc.deadlines"}, Status: "active"}
|
||||
|
||||
links := map[string]struct{}{}
|
||||
f := TreeFilter{Status: []string{"active"}, ProjectPath: "work.upc", IncludeDescendants: false}
|
||||
if !f.Matches(upc, links) {
|
||||
t.Error("exact-match item should still pass with descendants off")
|
||||
}
|
||||
if f.Matches(deadlines, links) {
|
||||
t.Error("descendant should NOT pass when IncludeDescendants is off")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTreeFilterProjectFields verifies URL-param parsing for the project
|
||||
// scope. IncludeDescendants defaults to true; project_descendants=0 flips it.
|
||||
// project_descendants without an explicit "0" stays at default.
|
||||
func TestParseTreeFilterProjectFields(t *testing.T) {
|
||||
f := parseQS(t, "?project=work.upc")
|
||||
if f.ProjectPath != "work.upc" {
|
||||
t.Errorf("ProjectPath = %q, want %q", f.ProjectPath, "work.upc")
|
||||
}
|
||||
if !f.IncludeDescendants {
|
||||
t.Error("IncludeDescendants should default to true")
|
||||
}
|
||||
if !f.Active() {
|
||||
t.Error("project scope should make filter Active()")
|
||||
}
|
||||
|
||||
off := parseQS(t, "?project=work.upc&project_descendants=0")
|
||||
if off.IncludeDescendants {
|
||||
t.Error("project_descendants=0 should set IncludeDescendants=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTreeFilterProjectRoundTrip verifies that emitting + re-parsing the
|
||||
// project fields yields the same TreeFilter, including the descendants
|
||||
// toggle when it deviates from default.
|
||||
func TestTreeFilterProjectRoundTrip(t *testing.T) {
|
||||
for _, qs := range []string{
|
||||
"?project=work.upc",
|
||||
"?project=work.upc&project_descendants=0",
|
||||
"?project=dev&q=paliad&tag=work&status=done",
|
||||
} {
|
||||
f := parseQS(t, qs)
|
||||
out := f.URL()
|
||||
f2 := parseQS(t, strings.TrimPrefix(out, "/"))
|
||||
if f.URL() != f2.URL() {
|
||||
t.Errorf("round-trip mismatch: %q → %q → %q", qs, out, f2.URL())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetProjectAndToggleHelpers spot-checks the two helpers added in Slice A.
|
||||
func TestSetProjectAndToggleHelpers(t *testing.T) {
|
||||
f := TreeFilter{Status: []string{"active"}, IncludeDescendants: true}
|
||||
scoped := f.SetProject("work.upc")
|
||||
if scoped.ProjectPath != "work.upc" {
|
||||
t.Errorf("SetProject did not set path; got %q", scoped.ProjectPath)
|
||||
}
|
||||
if !scoped.IncludeDescendants {
|
||||
t.Error("SetProject should preserve IncludeDescendants when truthy")
|
||||
}
|
||||
// Toggling descendants flips the bool.
|
||||
off := scoped.ToggleIncludeDescendants()
|
||||
if off.IncludeDescendants {
|
||||
t.Error("ToggleIncludeDescendants should flip true → false")
|
||||
}
|
||||
// Clearing the project resets the toggle to the safe default (true) so a
|
||||
// future SetProject call doesn't inherit the off state.
|
||||
cleared := off.SetProject("")
|
||||
if !cleared.IncludeDescendants {
|
||||
t.Error("clearing project should reset IncludeDescendants to true")
|
||||
}
|
||||
if cleared.ProjectPath != "" {
|
||||
t.Errorf("clearing project should empty path; got %q", cleared.ProjectPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleStatusKeepsActiveDefault(t *testing.T) {
|
||||
f := TreeFilter{Status: []string{"active"}}
|
||||
// Toggling active off when nothing else is on should leave us at the
|
||||
|
||||
Reference in New Issue
Block a user