diff --git a/docs/plans/views-redesign.md b/docs/plans/views-redesign.md new file mode 100644 index 0000000..f351158 --- /dev/null +++ b/docs/plans/views-redesign.md @@ -0,0 +1,496 @@ +# Views redesign — paliad-shape first-class views (Phase 5j) + +**Status**: Phase A design (this doc). +**Branch**: `mai/kahn/phase-5j-views-redesign`. +**Author**: kahn (inventor), 2026-05-26. +**Source feedback** (m, 13:19 2026-05-26): *"It's not really what I wanted. It should like the paliad custom views, not of the existing views a variant but individually created views."* + +**Replaces**: Phase 5i. Hours-old, no real data, drop-and-rebuild is the cleanest path. + +--- + +## §1 — Diagnosis: why 5i diverged from intent + +5i modelled views as an **overlay** on top of existing pages. The contract was: + +> User opens `/?view=` → the saved filter+view_type fields onto whatever the existing tree handler renders. + +That choice flowed from m's original phrasing: "view types (card / list / calendar / kanban)" — which sounded like skin-on-top-of-pages. Implementation followed: TreeFilter grew a `ViewID`, an `applySavedView` overlay landed in the tree handler, the sidebar `Views` entry pointed to `/views` as a list-management page, and saved views had no URL of their own. + +m's **actual** mental model, anchored in paliad: a view IS a page. The slug goes in the URL. System defaults (dashboard, calendar, timeline, ...) and user-created views share the same `/views/{slug}` route shape. Nothing is "an overlay" — views are first-class destinations, indexed in the sidebar, with their own editor. + +The fix: tear out the 5i overlay code and rebuild around the paliad model. This redesign mirrors paliad's structure but adapts to projax's constraints (single-user, no auth.uid(), no RLS, existing route surface). + +--- + +## §2 — paliad-shape data model for projax + +### Schema (migration `0017_views_redesign.sql`) + +**Recommendation: hard-replace.** Drop `projax.views` (created hours ago in 5i Slice D), recreate fresh. No real user data lost — at most a couple of throwaway saved-view rows from m's testing. + +```sql +DROP TABLE IF EXISTS projax.views CASCADE; + +CREATE TABLE projax.views ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug text NOT NULL, + name text NOT NULL, + icon text, -- nullable; matches frontend icon registry + filter_json jsonb NOT NULL DEFAULT '{}'::jsonb, + view_type text NOT NULL, -- card | list | calendar | kanban | timeline + sort_field text, + sort_dir text, + group_by text, + sort_order integer NOT NULL DEFAULT 0, + show_count boolean NOT NULL DEFAULT false, + last_used_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + 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_slug_format_chk + CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$') +); + +CREATE UNIQUE INDEX views_slug_uniq ON projax.views (slug); +CREATE INDEX views_sort_order_idx ON projax.views (sort_order, name); + +-- updated_at trigger reused from 0016 (kept under a new name or recreated). +CREATE OR REPLACE FUNCTION projax.views_touch_updated_at() + RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; +DROP TRIGGER IF EXISTS views_touch_updated_at ON projax.views; +CREATE TRIGGER views_touch_updated_at + BEFORE UPDATE ON projax.views + FOR EACH ROW EXECUTE FUNCTION projax.views_touch_updated_at(); +``` + +### Key shifts from 5i + +| field | 5i | 5j | reason | +|---|---|---|---| +| primary key | uuid only | uuid; **slug is the URL key** | paliad parity — URLs use slugs, not uuids | +| slug | absent | required, unique, regex-validated | URL routability | +| icon | absent | nullable text | sidebar icon picker | +| sort_order | absent | server-assigned MAX+1 | drag-reorder; paliad parity | +| show_count | absent | bool, opt-in | sidebar row-count badge; opt-in cost | +| last_used_at | absent | nullable timestamptz | `/views` landing MRU redirect | +| pinned | bool | **dropped** | `sort_order` subsumes the use case | +| is_default_for | text page | **dropped** | per-page-default model gone; MRU replaces it | + +### `filter_json` shape + +Unchanged from 5i (the JSON shape stayed correct). Keys mirror TreeFilter dims: `q`, `tags[]`, `management[]`, `status[]`, `has_links[]`, `public`, `show_archived`, `project_path`, `include_descendants`. The shape is forward-compatible; new TreeFilter dimensions land without migrations. + +`view_type` stays a top-level column (not inside `filter_json`) because the editor + sidebar both read it without needing to parse JSON. + +### Single-user simplifications vs paliad + +- **No `user_id` column** — projax is Tailscale-only single-user. +- **No RLS** — same reason. +- **`UNIQUE (slug)` is global**, not per-user. + +If multi-user ever lands, the column + index gain a `user_id` prefix; the rest of the design holds. + +--- + +## §3 — Reserved slugs (system views) + +The big call: **do existing pages become system views, or do they stay distinct routes?** + +### Three options + +**(a) Keep current routes; add /views/{slug} for user views only.** +- `/`, `/dashboard`, `/calendar`, `/timeline`, `/graph` stay exactly as today. +- `/views/{slug}` is exclusively for user-created views. +- Reserved-slug list is just `{new, edit}` (the literal route segments) + any future top-level URL we'd not want a user view to shadow. +- **Cost**: nothing changes for muscle memory. User views are an additive concept beside existing pages. +- **Drawback**: the conceptual asymmetry m flagged stays — system pages live at `/`/`/dashboard`, user views live at `/views/{slug}`. Two URL families. + +**(b) Full migration. Existing pages become system views at `/views/{slug}`.** +- New URLs: `/views/tree`, `/views/dashboard`, `/views/calendar`, `/views/timeline`, `/views/graph` (or drop graph from the unified shape — see §3.1). +- Legacy `/`, `/dashboard`, etc. become 301 redirects to their `/views/{slug}` counterpart. +- Reserved slugs: `{tree, dashboard, calendar, timeline, graph, new, edit, admin, login, logout, healthz, mcp, static, i, views}` — everything projax owns at the top level. +- **Cost**: every internal link in templates needs updating; bookmarks 301 (fine); browser muscle memory absorbs after one shift. +- **Benefit**: one URL family. The "create a new view" mental model is uniform with how system pages live. + +**(c) Hybrid. Legacy routes stay; `/views/{slug}` aliases system pages and hosts user views.** +- `/` keeps serving the tree; **also** `/views/tree` resolves to the same handler. +- `/dashboard` keeps; also `/views/dashboard`. Etc. +- Reserved slugs match (b) for the same coverage. +- User views land at `/views/{their-slug}` alongside system slugs in one URL family. +- **Cost**: small — system-view handlers register two route entries instead of one. No redirects to maintain. +- **Benefit**: muscle memory + bookmark stability AND first-class /views/{slug} URL family. Two paths to the same render; user picks whichever they remember. If `/views/{slug}` catches on, a future shift can deprecate the legacy URLs cleanly. + +### Inventor pick: (c) hybrid + +**Reasoning**: m's bug report explicitly said "individually created views" — the gap was user-view first-classness, not legacy-URL banishment. (c) closes the gap with zero migration cost. (b) is cleaner architecturally but introduces avoidable churn; the upside (one URL family) doesn't outweigh the risk of breaking some link or muscle-memory in m's daily flow. (a) leaves the two-families asymmetry m's feedback was pointing at. + +This is **Q1 in §9** — head should ratify or override before coder. + +### §3.1 — Graph as a system view? + +Graph is the DAG SVG render. It's NOT in the view_type enum (per 5i design, intentionally — graph is its own visualization, not a "list of items rendered as X"). Recommend: keep `/graph` and `/views/graph` (under (c)) but **graph is not a user-creatable view_type** — the create form omits it. Reserved slug `graph` blocks user views from clobbering it. + +### Reserved-slug list (combining (c) + projax's existing top-level routes) + +```go +var reservedViewSlugs = []string{ + // System pages (also reachable via /views/ as aliases under (c)): + "tree", "dashboard", "calendar", "timeline", "graph", + // /views sub-routes: + "new", "edit", + // Top-level application URLs: + "admin", "login", "logout", "healthz", "mcp", "static", "i", "views", +} +``` + +--- + +## §4 — Routes + +For option (c). Under (b), drop the legacy entries; under (a), drop the `/views/{system-slug}` aliases. + +| route | handler | renders | semantics | +|---|---|---|---| +| `GET /views` | `handleViewsLanding` | 302 to MRU view, else onboarding shell | landing | +| `GET /views/{slug}` | `handleViewRender` | view template per view_type | render saved or system view | +| `GET /views/new` | `handleViewEditor` | editor blank | editor — new | +| `GET /views/{slug}/edit` | `handleViewEditor` | editor pre-filled | editor — edit existing | +| `POST /views` | `handleViewCreate` | redirect to `/views/{slug}` | create | +| `POST /views/{slug}` | `handleViewUpdate` | redirect to `/views/{slug}` | update | +| `POST /views/{slug}/delete` | `handleViewDelete` | redirect to `/views` | delete | +| `POST /views/reorder` | `handleViewReorder` | 204 / HTMX OK | drag-reorder (slice G) | +| `POST /views/{slug}/touch` | `handleViewTouch` | 204 fire-and-forget | bump last_used_at on render | + +The render path (`GET /views/{slug}`): +1. Resolve slug. If a user view → load row. If a reserved system slug → load the corresponding code-resident `SystemView` struct. +2. Touch `last_used_at` (user views only — system views don't track MRU per call). +3. Dispatch to the view_type's renderer (the same per-view-type templates from 5i: `tree_card.tmpl`, `tree_kanban.tmpl`, `tree_section.tmpl` for list, plus the existing `calendar_section.tmpl` and `timeline_section.tmpl`). +4. Apply chip-overlay semantics from the 5i fix — URL chips overlay the saved filter so chip clicks narrow within the view (the one piece of 5i worth keeping; see §7). + +Editor (`GET /views/new` and `GET /views/{slug}/edit`) is a dedicated full-page form, not a modal. Paliad shipped dedicated pages; projax inherits the same shape. + +--- + +## §5 — Sidebar integration + +Replace the single "Views" sidebar entry (5i) with a "Views" section listing every user view. System views stay in the existing main-nav block at the top; they're already the muscle-memory entries (Tree, Dashboard, Calendar, Timeline, Graph). + +ASCII sketch (5g sidebar shape, with 5j additions): + +``` +[ sidebar ] +───────────── + ⌂ Tree + □ Dashboard + ▣ Calendar + ⊿ Timeline + ⨀ Graph +───────────── + Views ← new section header + 📂 Active mai work ← user view (icon + name) + ⏰ This week deadlines ← row-count badge if show_count + ★ Patents kanban ← drag-reorder handle on hover + + New view ← /views/new +───────────── + ⚙ Admin +───────────── + ☾ Theme +``` + +The Views section's entries come from `ListViews()` ordered by `sort_order` ASC, then `name`. Each entry: +- Icon resolved against a small frontend registry (the icon column is a key; the registry maps it to an SVG). Keys: `folder`, `clock`, `star`, `tag`, `file-text`, `box`, `inbox`, etc. Default key: `folder`. +- Optional badge with row count when `show_count=true` — computed by running the view's filter against `ListAll()` (cheap; projax's scale is ~150 items max). +- Active state when the current URL is `/views/{this-slug}` or a legacy alias resolving to it. + +Drag-reorder lands in a later slice (G). Click-to-open is the v1 interaction. + +Mobile bottom-nav drawer (5g slice B) gets the same section. + +--- + +## §6 — Editor surface + +Single editor template (`templates/view_editor.tmpl`) reused for both `/views/new` and `/views/{slug}/edit`. Distinguishes via the presence of `.View` in the data map. + +Fields: +- **Name** — text input, required, max 80 chars. +- **Slug** — text input, regex `^[a-z0-9][a-z0-9-]{0,62}$`, **auto-derived** from name via HTMX on `change` against a `POST /views/derive-slug?name=` helper endpoint OR on the client (simpler: derive on the server side in `handleViewCreate` if the field is empty; provide a "regenerate" link in edit mode). m can hand-edit. +- **Icon** — `` (asc/desc). +- **Group by** — `` | +| `web/templates/views.tmpl` | full rewrite — it's the list-management surface, redesigned in §5 + §6 | +| `web/templates/view_edit.tmpl` | full rewrite to the new editor shape | + +### Code to keep +- `templates/tree_card.tmpl`, `templates/tree_kanban.tmpl` — these are per-view_type renderers, reusable. +- `web/view_type.go` (the 5-value enum + `PageViewTypes` catalog) — still valid as the renderer dispatch table. +- `web/kanban.go` (`BuildKanbanBoard`) — view_type=kanban consumer. +- `templates/project_chip.tmpl` — the project filter chip strip works inside the editor. +- The 5i chip-overlay-on-saved-view fix is the **one piece of substance** worth keeping conceptually: on `/views/{slug}`, URL chip params overlay the saved filter. The overlay function gets a new home (`handleViewRender`'s filter-resolution path) but the rule is the same. + +### Backwards compatibility for the old `?view=` URL + +Two options: +- (i) **404 on `?view=`** for existing pages — the URL never makes sense in the new model. Cost: any stale bookmark dies, but only m used it for hours. +- (ii) **302-redirect `/?view=` to `/views/`** by looking up the slug from the uuid. Smoother for m's recent bookmarks. Cost: one extra DB hit on the redirect path; the redirect can target the slug or, if the uuid no longer resolves (because we hard-recreated the table), 302 to `/views`. + +Inventor pick: (ii) — small code, no broken bookmarks for the brief 5i window. + +### `is_default_for` semantics + +Drop entirely. The MRU mechanism (`last_used_at` → `/views` landing) replaces "what should I see on /views". Per-page defaults are gone; if m wants a specific view to be the landing experience, he opens it once and it becomes MRU. + +If m later wants a "this is my default" hint stronger than MRU (i.e., pinning), `sort_order=0` reserved for a pinned slot + an `is_pinned` flag is the natural extension. **Not in scope for v1.** + +--- + +## §8 — Implementation slicing + +Seven slices; A → B → C → D → E are the critical path; F + G are polish. + +### Slice A — Schema redesign + +- Migration `0017_views_redesign.sql`: `DROP TABLE projax.views CASCADE; CREATE TABLE` with new shape. (See §2 schema.) +- `store/views.go`: rewrite. Rename `View.ID` flow to be slug-driven; `GetView(slug)` instead of `GetView(uuid)`. Keep CRUD shape; add `Touch(slug)` for MRU; add `MostRecent()` returning the MRU view (or nil); add `Reorder([]string slugs)` for slice G. +- Drop `DefaultViewFor` (no longer applicable). +- Tests: round-trip CRUD by slug; reserved-slug rejection at the validator; slug-format regex enforcement; MRU. + +### Slice B — Route migration (paliad-shape) + +- Replace the 5i `/views/` routes with the paliad-shape route table from §4. +- `handleViewsLanding` → MRU redirect or onboarding shell. +- `handleViewRender` → resolve slug (user view first, then system view), apply chip overlay, dispatch to the view_type's renderer. +- `handleViewEditor` → dedicated form page (slug-driven). +- `handleViewCreate` / `handleViewUpdate` / `handleViewDelete` → form POST handlers. +- `handleViewTouch` → fire-and-forget MRU update. +- Wire the legacy `?view=` redirect (per §7-ii) on existing pages. +- Tests: each route hit, slug routing, MRU redirect, onboarding shell on empty state, reserved-slug rejection. + +### Slice C — System views + +- New `web/system_views.go` with `SystemView` struct + `TreeSystemView()`, `DashboardSystemView()`, `CalendarSystemView()`, `TimelineSystemView()`, `AllSystemViews()`, `LookupSystemView(slug)`. +- Each function returns the `(filter_json, view_type, group_by, sort)` tuple matching today's page. +- `handleViewRender` falls back to `LookupSystemView` when the slug isn't in the DB. +- Reserved-slug list (combining system slugs + route segments). +- Under (c) hybrid: legacy routes `/`, `/dashboard`, `/calendar`, `/timeline` each gain a sibling registration so `/views/{system-slug}` resolves to the same handler. (Or: legacy routes 302 to `/views/{slug}` — simpler if m's fine with one canonical URL.) +- Tests: system-view lookup, slug aliases hit the same template, reserved-slug rejection during user-view create. + +### Slice D — Editor surface + +- New `templates/view_editor.tmpl` — full form per §6. +- Slug derivation helper (`POST /views/derive-slug` or server-side fill). +- Icon picker (a `` is trivial. + +### Q7 — Drag-reorder in v1? + +- (a) Yes (slice G in v1). +- (b) v2 — `sort_order` column is server-assigned MAX+1 on create; reorder UI lands later. + +**Inventor pick**: (b). Don't expand v1 scope; reorder is a UX polish that can ship a week after. + +### Q8 — `show_count` badge in v1? + +- (a) Yes — opt-in checkbox in editor + sidebar badge. +- (b) v2 — column lands in the schema; UI lands later. + +**Inventor pick**: (a) — checkbox in editor + 2-line render in sidebar is cheap and answers the "how many things match my view" question m asks naturally. + +### Q9 — Legacy `is_default_for` semantics (§7) + +Inventor picks **dropped entirely**, replaced by MRU. Flag if m wants pin / default semantics back. + +### Q10 — Drop and recreate `projax.views`? + +- (a) Hard-replace via `DROP TABLE ... CASCADE` — inventor pick (table is hours old, ~zero data loss). +- (b) ALTER TABLE migration that adds new columns + drops old ones gracefully — more conservative; preserves any rows m has created. + +**Inventor pick**: (a). The shape change is large enough that a clean re-create is cleaner than a 6-step ALTER. + +### Q11 — `view_type=graph`? + +The graph DAG SVG render isn't in the view_type enum. Should: +- (a) Stay outside the views system — `/graph` and `/views/graph` (system slug) both serve it, user views can't be `view_type=graph`. Inventor pick. +- (b) Add `graph` as a sixth view_type — opens user-creatable graph views. + +**Inventor pick**: (a). Graph layout is single-purpose (DAG); a "graph of my filtered set" doesn't have a clear product story today. + +--- + +## §10 — Risk register + +| risk | likelihood | mitigation | +|---|---|---| +| Slug collision on rename | medium | UNIQUE index + handler maps the unique-violation to a friendly "slug already in use" error | +| URL drift (legacy bookmarks break) | low under (c), high under (b) | (c) keeps legacy URLs; (b) ships with 301 redirects + a session of m verifying his bookmarks | +| MRU thrash on rapid view switches | low | `last_used_at` is fire-and-forget; the worst case is one stale 302 | +| System-view + user-view slug collision | n/a | reserved-list rejection in validator (slice A) | +| sidebar query cost | low | `ListViews()` is one indexed lookup per page render; cache lightly if it shows in profiling | +| Editor's chip strip drifts from the page chip strip | medium | share the same template (project_chip.tmpl already shared); add a dedicated `view_filter_chips.tmpl` if drift bites | + +--- + +## §11 — Test plan headlines + +### Slice A +- `TestViewSlugCRUD` — create/get/update/delete by slug round-trip. +- `TestViewSlugFormatRejected` — uppercase, underscore, leading-digit-allowed but no-leading-dash, length-cap 63. +- `TestViewReservedSlugRejected` — create with slug `tree` / `dashboard` / `admin` / `new` etc. all 400. +- `TestViewTouch` — Touch bumps `last_used_at`. +- `TestViewMostRecent` — MRU returns most recently touched. + +### Slice B +- `TestViewsLandingMRU` — `/views` 302s to MRU view when one exists. +- `TestViewsLandingOnboarding` — `/views` renders shell when no views. +- `TestViewRender` — `/views/{slug}` resolves a user view; renders the right view_type template. +- `TestLegacyOverlayRedirect` — `/?view=` 302s to `/views/{slug}`. + +### Slice C +- `TestSystemViewLookup` — `tree` / `dashboard` / `calendar` / `timeline` / `graph` resolve via `LookupSystemView`. +- `TestSystemViewSlugAlias` — `/views/dashboard` and `/dashboard` produce identical render output. + +### Slice D +- `TestEditorBlank` — `/views/new` renders empty form. +- `TestEditorPrefilled` — `/views/{slug}/edit` reflects every persisted field. +- `TestSlugDerivation` — name "Active mai work" → slug "active-mai-work". + +### Slice E +- `TestSidebarListsViews` — layout includes every user view. +- `TestSidebarActiveState` — `/views/{slug}` marks that entry active. + +### Slice F +- All 5i overlay tests deleted; no residue references TreeFilter.ViewID. + +### Slice G +- `TestReorderUpdatesSortOrder` — POST `/views/reorder` with a sorted slug list updates the column. +- `TestShowCountBadge` — sidebar badge reflects the filter's match count. + +--- + +## §12 — References + +- `~/dev/paliad/internal/db/migrations/056_user_views.up.sql` — schema reference. +- `~/dev/paliad/internal/services/user_view_service.go` — CRUD reference. +- `~/dev/paliad/internal/services/system_views.go` — reserved-slug + system-view registration. +- `~/dev/paliad/internal/handlers/views_pages.go` — route table. +- `~/dev/paliad/frontend/src/{views,views-editor}.tsx` — editor + sidebar reference (UX only; not ported). +- `docs/plans/views-system.md` (5i) — historical record of the wrong-shape implementation. +- `docs/design.md` §4 (Interfaces). + +--- + +## §13 — Status + +- **Phase A (this doc)**: drafted by kahn, 2026-05-26. Awaiting head delegation of §9 questions to m. +- **No chip-picker for 5j** unless head explicitly re-grants per the project's escalation rule. +- **Phase B (coder)**: blocked on m's sign-off via head. Slice ordering A → B → C → D → E → F → G. +- **No code changes** in this branch beyond this doc.