Supersedes the Phase 7b two-section split (m: 'tasks section should collect
from mBrian AS WELL AS CalDAV and display together'). The detail page now
renders a single merged task list per project:
- buildUnifiedTasks merges mBrian-native tasks (TasksForItem) + CalDAV VTODOs
(detailTodos → taskFromTodo) into one open/done split via the uniform
store.Task shape. Each row carries a subtle source label (calendar name for
CalDAV, 'projax' for mBrian). Sorted: dated-before-undated, earlier due
first, then created, then title.
- Row actions dispatch by Source: CalDAV rows POST /caldav/todo/{action}
(calendar_url+uid, ETag writeback); mBrian rows POST /task/{action}
(node_id). One template, branch on .Source.
- ONE add-form, backend by §3.1 selector (unifiedTasks.AddTarget): CalDAV on a
bound project (hidden calendar_url for a single list, a <select> when
several), mBrian-native on an unbound project. New-task default per m's spec.
- Both handlers (handleCalDAVTodoAction + handleTaskAction) now re-render the
SAME merged fragment via renderUnifiedTasks, so a write from either backend
refreshes the unified list in place.
- Retired the two-section split: deleted mbrian_tasks_section.tmpl + its
registration/render-case, rewrote tasks_section.tmpl as the unified list,
removed renderTasksSection. CalDAV link/create-list affordances preserved.
Unit-tested: sortTaskRows (merge order), AddTarget (backend selection across
bound/unbound × single/multi-calendar). Updated TestDetailLinkExistingCalendar
to the unified UI (no per-calendar block; bound project → add-form targets the
linked calendar, create-new hidden). caldav/gitea/mcp/internal green; the 8
remaining web failures are the pre-existing TestProjectFilter*/TestTimeline*
route-drift (fail on 6436b52).
ROOT CAUSE (head-diagnosed, confirmed): projax templates use hx-post/hx-get/
hx-target/hx-swap across the task, tree, dashboard, bulk and classify forms,
but HTMX was NEVER loaded — layout.tmpl had only inline <script> blocks, no
htmx <script src>. So every hx-post form silently no-op'd to a GET-to-self
(logs: GET /i/social.mama on each 'Add' click). m hit it as 'can't add Tasks
(projax), nothing happens'. Both the mBrian AND the older CalDAV task forms
were dead. The style.css even comments 'HTMX-driven' — the script was just
never wired.
FIX: vendor htmx 1.9.12 into web/static (Tailscale-only app → vendored over
CDN, matches the go:embed asset model) + one deferred <script> in layout
<head>. htmx only intercepts hx-* elements, so the existing plain
method=post forms are untouched. The task handlers already return section
fragments for hx-swap, so the flow just works once htmx is present. Added
htmx.min.js to the service-worker shell precache (CACHE_NAME v1→v2).
The server-side write path was already proven green (TestMBrianTaskRoundTrip
PASS with prod creds) — the bug was purely the client form never POSTing.
Loading htmx closes that gap.
Regression guard: TestLayoutLoadsHTMX asserts the script ships in the layout
so this can't silently recur.
Env-guarded (skips unless PROJAX_MBRIAN_API_URL + PROJAX_MBRIAN_API_TOKEN +
SUPABASE_DATABASE_URL are set) so it runs in CI / by head with the prod token
but is inert locally. Drives the full mBrian-native task lifecycle against the
LIVE write API + read DB:
create throwaway parent project → CreateTask (slug auto-derived, due set) →
TasksForItem asserts present+open+due → asserts the task does NOT leak into
ListAll (Q6 exclusion) → SetTaskStatus done → SetTaskDue nil (clear) →
DeleteTask → asserts gone. Self-cleaning (soft-deletes task + parent).
This is the deploy-verification gate for 'tasks live end-to-end'.
Wires the store task layer into the UI:
- web/task.go: handleTaskAction (create/done/reopen/edit/due/delete) routed
from handleDetailWrite at /i/{path}/task/{action}; every existing-task
mutation verifies the node belongs to the item (taskBelongsTo guard, mirrors
the CalDAV per-item calendar guard). taskBackend() type-asserts the active
Items/Writes to TaskReader/TaskWriter (nil on the legacy backend → section
omitted).
- mbrian_tasks_section.tmpl: a SECOND tasks sub-section rendered alongside the
existing CalDAV one (Q4b — both sources shown, never hide mBrian). The 'add
task' form appears only on mBrian-native projects (no caldav-list link, §3.1);
existing tasks stay toggle/editable on any project. HTMX in-place swaps like
the CalDAV section. Tasks auto-derive their slug from the title (lightweight
UX); the explicit-slug + 409 machinery stays available via the writer.
- Checklist render: 'render tasks as checklist' flag on the edit form →
metadata.projax.render; the section renders compact when set (Q1).
- taskFromTodo: CalDAV VTODO → uniform store.Task mapper (the §3.3 'two
sources, one shape' contract; the detail page keeps the richer CalDAV
rendering for ETag writeback, so the uniform-rollup consumer is a follow-up).
Unit-tested: taskFromTodo (done-state mapping, due, handle), renderHint.
NOTE to head: pre-existing web test failures (TestProjectFilter*, TestTimeline*
project-filter/timeline-route tests) fail identically on 6436b52 — they
reference the old /timeline route that now 301s to /views/timeline. Not from
this work; flagging as separate tech debt.
Adds the first-class task layer the Phase 7 design (docs/plans/phase-7-entity-model.md)
calls for, on the mBrian backend:
- store.Task: one uniform view-shape, two sources (CalDAV VTODO | mBrian
type=['task'] node); TaskReader/TaskWriter capability interfaces (separate
from ItemReader/ItemWriter — only the mBrian backend has task nodes).
- MBrianReader.TasksForItem: materialises type=['task'] child_of nodes into
Task, created-at order (Q5).
- Task exclusion at the reader chokepoint: nodeIsTask filters tasks out of
ListAll/Roots/MaiOrphans/Search/AllTags, so they never leak into ANY project
surface (tree/graph/dashboard/calendar/timeline + MCP list/tree, which all
funnel through ListAll/ListByFilters) — implements Q6's intent in one place
instead of per-surface. GetByID/GetByPath still resolve a task node.
- MBrianWriter task writes: CreateTask (POST type:'task' + slug + child_of
edge), SetTaskStatus (done=status, Q2), SetTaskDue, EditTaskTitle, DeleteTask.
Uses the now-live POST /api/projax/nodes 'type' field.
- Item.Render (metadata.projax.render) + RendersChecklist() for compact
checklist mode (Q1); round-tripped via UpdateInput.Render.
Unit-tested: nodeIsTask, taskFromNode, dueToJSON, RendersChecklist.
Pre-existing TestParityListAll drift (store=65 vs mbrian=66) is unrelated and
tracked separately by head.
mBrian's write API now honors an explicit slug (POST + PATCH), live on
mbrian.x.msbls.de (m/mBrian#73 issuecomment-10827/10830). Wire the
projax side so m can set + rename slugs again (he hit the generate-from-
title bug for real):
- MBrianWriter.Create: send the explicit slug in the POST body (was
omitted → mBrian title-derived). Slug is required on the create paths.
- MBrianWriter.Update: send slug ONLY on a genuine rename — read the
current slug and include it just when it changed, so ordinary edits and
bulk actions (which carry the unchanged slug in UpdateInput) don't trip
mBrian's rename cascade (wikilink rewrite + alias append) for nothing.
- mapSlugWriteErr: 409 → store.ErrSlugTaken, 400 → store.ErrInvalidSlug,
keeping the server's *APIError (message) in the chain. 409 covers a
soft-deleted tombstone squatting the slug — which the projax-side
validator can't see (it scopes to live nodes).
- Surface cleanly, never silent: web create/edit forms render the
friendly itemwrite banners (KindSlugCollision / KindInvalidSlugFormat)
via s.writeFailure; MCP create/update return typed validation tool
errors via slugAwareToolError.
Tests: Create sends explicit slug + 409→ErrSlugTaken (httptest, no DB);
mapSlugWriteErr table (409/400/403/500/non-APIError). Build + vet green;
web write tests + mcp green. Rename round-trip (slug→aliases) + live
collision message are head's post-deploy live-verify (needs the prod
token).
Standalone on mai/kahn/fix-slug-control off main so head can merge to
prod immediately — m's waiting on slug control.
Two pre-cutover reader tweaks head approved (projax-local, no mBrian
change) so writes through the scoped HTTP API round-trip cleanly:
- G3 (required): itemFromNode reads pinned/archived from metadata.projax
with a fallback to the nodes.pinned/archived columns —
COALESCE(metadata.projax.X, column). MBrianWriter writes them to
metadata.projax (PATCH can't set the columns); this makes the dashboard
star + archive toggle round-trip while preserving any pin state the
migration wrote to the columns.
- G2: linkFromEdge reads ItemLink.Note as COALESCE(edge.note,
metadata.note) — migrated notes are in the edge.note column, post-cutover
AddLinkDated notes land in metadata.note; both now surface. note is also
dropped from consumer metadata to match *Store (whose note lives in the
column, never metadata).
Both parity-safe for current data (migrated nodes carry no
metadata.projax.pinned; 0 note-bearing edges) — store parity tests stay
green. Added pure-function unit tests for both COALESCE behaviours.
G1 (multi-link same ref_type) deferred per head — 0 cases, safe-refuse
behaviour stands.
Documents the write mechanism (scoped HTTP API), the ItemWriter interface
+ per-method mBrian mapping, the validator + bulk + MCP changes, and the
four known API gaps (G1 edge identity / G2 edge note / G3 pinned-archived /
G4 create metadata) for head to reconcile with mBrian before relying on
them. Referenced from store/adapter.go + store/mbrian_writer.go.
- cmd/projax/main.go: PROJAX_BACKEND=mbrian now sets BOTH srv.Items
(reader) AND srv.Writes (writer=MBrianWriter HTTP client, reading
PROJAX_MBRIAN_API_URL/PROJAX_MBRIAN_API_TOKEN). =store sets both to the
legacy *Store. The flip is atomic — the slice-B half-flip (reader only)
was the production bug. Warns (not exits) if mbrian is selected without
the API env vars: reads work direct-DB, writes fail closed legibly.
- mcp/tools.go: RegisterProjaxTools split from a single *Store into
(reader, writer, legacy *Store, agg). Read tools take the reader, write
tools take reader+writer, both flipping with the backend. Leaving MCP
reads on projax.items while writes targeted mBrian would recreate the
slice-B bug on the MCP surface (read an id from one backend, write it to
the other). The timeline tool keeps the legacy *Store + aggregator
(out of slice-C scope, consistent with the web dashboard). main.go
passes srv.Items/srv.Writes so MCP follows the same flip as the web UI.
NOTE: this widens slice C beyond the handover's 'MCP reads deferred' —
necessary because migrating MCP writes alone is incoherent with
atomicity. Flagged to head.
- store/mbrian_writer_test.go: httptest-backed unit tests for request
construction + error mapping (401/403/404→ErrNotFound/503/500/400),
fail-closed on empty token/URL (no empty Bearer sent), AddLink self-edge
+ metadata shaping, AddLinkDated date+note, per-ref_type edge metadata,
projax bundle defaults + public nesting, uuid v4 format. Pool-backed
read-backs (Create/Update round-trip) are covered by head's live cutover
test + the reader parity tests.
Build + vet green. store/mcp/itemwrite tests pass in isolation.
Full implementation of the write adapter as an HTTP client over mBrian's
/api/projax/* surface (final spec, m/mBrian#73 issuecomment-10720):
- do(): JSON request/response plumbing; bearer auth from the configured
token; fails closed (503-style) on empty URL/token rather than sending
an empty Bearer; maps non-2xx to *APIError (401/403/404/500/503) and
wraps 404 as ErrNotFound. {error} message extracted from the body.
- Create: POST node (mints a fresh projax_origin uuid via crypto/rand —
no uuid dep), writes child_of parent edges, materialises via the reader
so the returned Item.ID is the live mBrian uuid + derived path. This is
the create-child round-trip the slice-B half-flip broke.
- Update: PATCH node fields + syncParents() edge diff; read-back.
- Reparent/AddParent: child_of edge diff / idempotent add.
- SetPublic: read-modify-write the full public bundle (PATCH replaces the
whole projax.public object on shallow-merge).
- SoftDeleteCascade: projax-side descendant resolution via the reader's
derived paths, then per-node soft-delete (HTTP API is single-node).
- AddLink/AddLinkDated/DeleteLink: projax-* self-edges; edge metadata
shaped so the reader's linkFromEdge round-trips (typed payload +
projax_rel + ref_id + event_date + note). DeleteLink resolves the edge's
(source,target,rel) from its id, with a guard that refuses to delete
when >1 edge shares the tuple.
Documented four API gaps inline + in the file header for head to
reconcile before relying on them (verified current data hits none of the
active ones): G1 edge identity collapses multiple same-ref_type links per
item (latent — 0 in current data); G2 POST /edges has no note field;
G3 PATCH can't set pinned/archived node columns (captured in
metadata.projax, pending reader fallback); G4 no top-level metadata
passthrough on create.
Build + vet green. Writer is wired but unreachable until main.go flips the
backend (next commit) — no behaviour change yet.
Wire all web-side writes to depend on the interfaces (Server.Writes for
writes, Server.Items for the write-pre-flight reads) instead of the
concrete *Store, so PROJAX_BACKEND will flip them with the reader:
- handleDetailWrite / handleReparent / handleNewSubmit: Update / Reparent /
Create now go through s.Writes; ValidateAgainstStore now reads s.Items
(was s.Store) so cycle + collision detection runs against the live
backend, not stale projax.items.
- dashboard_pin: SetPinned via s.Writes.
- links: AddLinkDated / DeleteLink via s.Writes. linkBelongsToItem now
resolves ownership through s.Items.LinksByType — a direct
projax.item_links query would reject every delete under the mBrian
backend. Dropped the now-dead isNoRows + errors import.
- caldav: all four AddLink + the unlink DeleteLink via s.Writes.
- bulk applyBulk: replaced the raw single-tx multi-row UPDATE with
interface calls — make_public/private map to SetPublic; the field
mutations (tags/mgmt/status/timeline-exclude) are read-modify-write via
Update. Cross-row tx atomicity is dropped (mBrian's HTTP write API has
no multi-node tx); acceptable at m's bulk-edit scale, one write path
across both backends. Added updateInputFromItem + appendUnique/removeValue.
- itemwrite: slug uniqueness is now per-user-global (Q6=a, matching
mBrian's idx_nodes_slug) instead of per-parent. Strictly tighter, so
still correct on the legacy backend. Test updated to assert the new rule.
Build green. Web suite: only the 8 pre-existing failures remain (4
project_filter + TestTimelineKindMultiValueSurvives + 3 timeline_filter,
all /timeline-301 / seeding issues on main, unrelated to slice C). No new
failures from the rewiring.
Mechanism-independent groundwork for the write-path migration. Mirrors the
slice-B ItemReader pattern:
- store/adapter.go: ItemWriter interface extracted from *Store's write
surface (Create/Update/Reparent/AddParent/SetPublic/SetPinned/
SoftDelete/SoftDeleteCascade + AddLink/AddLinkDated/DeleteLink), with a
compile-time witness that *Store satisfies it.
- store/mbrian_writer.go: MBrianWriter stub — an HTTP client (NOT a pgx
writer) against mBrian's scoped /api/projax/* write API per head's
mechanism call (option c). Bodies return errNotImplementedSliceC until
the HTTP impl + final spec land. Holds a pool for write-path read-backs.
- web/server.go: Server.Writes field (twin of Items), defaulted to the
concrete *Store in web.New. main.go will flip Items+Writes atomically —
the slice-B half-flip (reads mBrian, writes projax.items) was the bug.
No behaviour change: both Items and Writes still resolve to *Store.
Per t-projax-6-sliceB-readpath. mBrian migration (m/mBrian#73) is live
on msupabase with 65 nodes + 78 child_of + 81 projax-* edges. This
commit makes the projax read path source from there behind an env
switch.
CLIENT ARCH: direct pgxpool against mbrian.* schema (same
SUPABASE_DATABASE_URL the projax binary already uses for projax.*) —
matches flexsiebels/head's cross-coupling pattern. No MCP token
plumbing.
CONTRACT (all three honoured)
- External links are SELF-EDGES (source=target=item, rel='projax-*',
payload in edges.metadata). linkFromEdge reads the node's outbound
projax-* edges; ref_id derived per ref_type from metadata (caldav
url, gitea owner/repo, mai-project mai_project_id).
- Slugs finalised: 'work'/'dania' resolve to mBrian's canonical nodes;
projax-side squatters (renamed-aside, not deleted) are documented in
the parity test as legacy-only and skipped from field comparison.
- created_at/updated_at NOT preserved — ItemsCreatedInRange orders off
metadata.projax.start_time when present, fall back to mBrian
created_at. Aggregator surfaces (timeline / dashboard) read off
caldav DTSTART + gitea updated_at, so they're unaffected.
NEW FILES
- store/mbrian.go: MBrianReader concrete impl. Bulk-loads projax-
managed nodes + child_of edges in one pair of queries per call,
builds a graphContext in memory, derives Paths via ancestor walk
(depth-capped at 64 like projax's trigger). Implements every
ItemReader method.
- store/mbrian_parity_test.go: 5 parity tests against the live db —
ListAll field equality (skipping the renamed squatter slugs),
spot-check resolves, caldav-list link round-trip, gitea-repo link
round-trip, AllTags union, NotFound consistency. All 5 GREEN.
- cmd/projax-remap-views/main.go: one-shot tool to rewrite
projax.views.filter_json.project_id from old projax uuids to new
mBrian uuids using the audit map mBrian dropped (head will relay
the path). Dry-run default; --apply commits. Idempotent.
- docs/plans/slice-b-views-projectid-gap.md: surfaces the gap + the
remediation path. Must run remap BEFORE slice E drops projax.items.
CHANGES
- store/adapter.go: kept the ItemReader interface + *Store assertion;
removed the prep stub (replaced by mbrian.go).
- web/server.go: Server.Items store.ItemReader field. web.New defaults
Items to the concrete *Store (legacy path). main.go overrides to
MBrianReader when PROJAX_BACKEND=mbrian.
- All read-path call sites in web/ swapped from s.Store.<readMethod>(
to s.Items.<readMethod>( for the 15 ItemReader methods. MCP tools
unchanged (separate scope; can pivot in a follow-up). Writes still
flow through s.Store.
- cmd/projax/main.go: PROJAX_BACKEND env switch with "store" (default)
and "mbrian" values. Logs the choice at startup. Unknown value
refuses to start.
SMOKE
- go build ./... green; go vet green.
- go test ./store/ -count=1 — all parity tests pass against live data.
- Local server boot with PROJAX_BACKEND=mbrian — backs binding logs
"backend=mbrian (read path via store.MBrianReader)" and serves
/views/tree (auth wall protects deeper smoke; parity tests cover
that surface).
PRE-EXISTING failure NOT addressed in this commit: 3 timeline_filter
tests in web/ already failed on main (legacy /timeline URL hits the
Phase 5j 301 redirect to /views/timeline). No diff vs main in those
test files; out of scope for slice B.
OUT OF SCOPE FOR SLICE B (deferred):
- MCP read tools migration to ItemReader (separate diff, low risk).
- Aggregator's LinkLister wired to ItemReader (currently consumes
*Store directly through Server.Aggregator()).
- views.filter_json.project_id remap RUN — tool ships here, run waits
on the head's relay of the audit-map path.
- Slice C write-path. Slice D mai-bridge worker. Slice E drop.
Per head's parallel-prep brief while m/mBrian#73 (migration script +
[schema] node) is being built mBrian-side. NO mBrian-MCP-backed
implementation yet — the migration worker may refine the landed
node/edge shape and building the impl now risks rework.
Built ONLY the parts stable regardless of mBrian internals:
1. CONSUMER INVENTORY (docs/plans/slice-b-adapter-contract.md §1)
- Every *store.Store read method (15 methods) with signature + semantics
- Every call site across web/, internal/aggregate/, mcp/ — table form
- Item / ItemLink field-by-field shape contract: which fields come
direct from node columns, which from edge-walk, which from
metadata-unpack
- Direct pgxpool access flagged out-of-scope (admin counts, bulk
tx, links event-date update — slice C reworks those)
- Views (5j) explicitly NOT in scope per m's Q5=(a)
2. INTERFACE CONTRACT (store/adapter.go)
- ItemReader Go interface — 15 methods, pure projax-shaped structs
in/out, zero mBrian type leakage
- var _ ItemReader = (*Store)(nil) compile-time assertion proving the
existing pgx-backed *Store satisfies the contract today
3. SKELETON (store/adapter.go MBrianReader)
- Empty struct (mBrian client choice deferred to slice B impl)
- All 15 methods stubbed, return errNotImplementedSliceB
- var _ ItemReader = (*MBrianReader)(nil) keeps the stubs in lockstep
with the interface as slice B grows
- Each stub carries a one-line comment naming the §3 gap(s) it
resolves at impl time
- `go build ./...` green; `go vet ./store/` green
4. GAP FLAGS (docs/plans/slice-b-adapter-contract.md §3)
- item_links.rel free-form annotation → mBrian edge.note (add to
m/mBrian#73 §1 for the migration script)
- ItemLink.RefID per-rel-type extraction rule (caldav URL vs gitea
owner/repo vs mai project uuid)
- paths[] recomputation cost (per-request memoisation)
- AllTags aggregation (full-scan ok at m's scale; tag-graph deferred
per m's Q8)
- Roots / MaiOrphans "no outbound child_of edge" predicate
- ItemsCreatedInRange scoped to projax_origin marker
- Item.Source / SourceRefID constant + mai-edge-derived fields
- ItemLinkWithItem join shape (two queries + in-memory join vs bulk
MCP helper)
- Admin counts — recommend adding Counts(ctx) to ItemReader for cohesion
Stays parked after this. Slice B IMPL (mBrian-MCP client wiring + per-
method bodies + handler rename from s.Store.X to s.Items.X) waits on
the migration completing and uuid map landing.
Read-only export of projax.items + projax.item_links to a JSON file the
mBrian-side migration script (m/mBrian#73) consumes. First implementation
slice of the Phase 6 mBrian-backend migration.
Tool:
- cmd/projax-snapshot/main.go: standalone binary, takes --out flag
(default ./projax_snapshot.json). Reads PROJAX_DB_URL or
SUPABASE_DATABASE_URL like the main projax binary.
- Pure read-only: SELECT FROM projax.items WHERE deleted_at IS NULL
+ SELECT FROM projax.item_links. No writes, no schema changes.
- Re-runnable: each invocation produces a fresh deterministic file;
no state, no DB side effects.
Output shape (Snapshot struct):
- version: "1" — bumped on shape changes for downstream version-pinning.
- generated_at: timestamp.
- items: every live projax.items row with all columns mapped 1:1 to
JSON-friendly types (uuid → string, jsonb → map, timestamptz →
RFC3339). Empty slices coerced to [] so the mBrian-side script doesn't
see null-array surprises.
- links: every projax.item_links row, ordered by item_id + ref_type
for stable diffs across runs.
- spot_checks: the 5 representative items the mBrian-side script
verifies post-migration per m/mBrian#73 §3. Selected at runtime by
characteristic (root area, single-parent, multi-parent, caldav-linked,
public-listing-populated) so the picks self-update as the dataset
evolves.
Smoke-tested against the live msupabase dataset:
wrote /tmp/projax_snapshot.json — 65 items, 81 links, 5 spot-checks
Selected spot-checks (live):
dev — root area
paliad — single-parent project
services — multi-parent (2 parents)
mhome — caldav-list-linked
fdbck — public-listing populated
Out of scope (slices B+ pick up):
- The mBrian-side script itself lives in m/mBrian per "mbrian must own
the migration" (Q4=(a)).
- projax-side adapter rewriting waits on the mBrian-side migration run.
- No tests yet: this is a one-off helper against live data; smoke run
above is the validation surface. A go-test suite can land if the
snapshot shape needs evolution before mBrian-side consumes it.
m answered all 11 §10 questions; every inventor pick confirmed.
m's overriding directive: "keep the database simple so it remains
easily modifiable."
Head verified the live mBrian schema after m's answers — original §3
was built off stale db/001_initial_schema.sql. Three of the six asks
turned out already-satisfied:
- MB-A (edges.metadata jsonb) — already added in db/010, GIN-indexed,
used by migs 039/040. Drop the ask.
- MB-C (project type) — already in live schema, mig 033 confirms.
Drop the ask.
- MB-D (per-user slug uniqueness) — already enforced by idx_nodes_slug
in db/001. Drop the ask.
Plus 'area' as a separate mBrian type is killed per m's "keep it
simple": areas reuse type=['project'] with metadata.projax.kind='area'.
Zero DDL.
Remaining mBrian-side artifact compresses to ONE [schema] convention
node under a new [topic] projax-integration hub, plus mBrian-side
ownership of the one-shot data-migration script (per m's "mbrian must
own the migration").
Re-sequenced §8: six slices.
0 (projax snapshot helper) → A (mBrian [schema] node + script run)
→ B (projax read-path adapter) → C (projax write-path)
→ D (mai bridge worker) → E (drop projax tables).
CalDAV/Gitea integrations stay where they are (m's Q3=(a)). No slice
F needed in the original sense.
§2 + §2.1 + §7 + §9 + §10 + §14 updated. §3 fully rewritten.
No code changes; this branch ships docs only. Slice 0 is the smallest
first projax-side step but waits for head's greenlight after the
m/mBrian issue is filed.
m's decision on issue m/projax#5 (2026-05-29): Option A — full backend
migration to mBrian. mBrian becomes the canonical store for projax
data; projax UI surfaces stay (Tiles dashboard, calendar grid,
timeline spine, the just-shipped 5j /views routes) but read+write
goes through mBrian instead of projax.items.
The plan covers:
- §1 diagnosis: closing the parallel-knowledge-surface gap
- §2 column-by-column schema mapping (projax.items → mBrian nodes +
metadata, projax.item_links → mBrian edges + new edges.metadata)
- §3 mBrian-side requirements: schema fragments to add (edges.metadata
column, projax edge relations + types schema-nodes)
- §4 read-path replacement: store adapter over mBrian, UI shape stable
- §5 write-path replacement: every handler + MCP write rewired
- §6 integrations disposition: CalDAV/Gitea stay projax-handled at
consumption; mai.projects sync moves to a handler-layer bridge
- §7 migration mechanics: hard-cut script per m's loss tolerance
- §8 six-slice plan: A (mBrian schema) → B (data migration) →
C (read-path) → D (write-path) → E (drop projax tables) → F
(integrations)
- §9 cross-repo coordination protocol via otto/head (no mBrian/head
worker exists today)
- §10 eleven open questions for m, batched for head delegation
- §11 risk register
- §12 test plan headlines
Slice A is mBrian-side and is the hard gate — projax B–F cannot start
until mBrian's schema fragments land. Cross-repo coordination request
filed alongside the m delegation.
No code changes; this branch ships docs only. Coder shifts wait on
m's sign-off on §10 + mBrian-side slice A.
Per m's v1 picks (2026-05-29):
- Q6 (icon picker): yes, with curated keys + SVG registry.
- Q8 (show_count badge): yes, opt-in checkbox + sidebar badge.
Icon registry (web/icons.go):
- 7 curated keys: folder (default), clock, star, tag, inbox, box,
file-text. Each maps to a Feather-style 24x24 SVG matching the rest
of the projax sidebar aesthetic. Returns template.HTML so layout.tmpl
emits markup verbatim. Unknown / nil keys fall back to folder.
- RenderViewIcon(*string) is template-callable; IconRegistryKeys()
feeds the editor's <select>.
- Funcs map in web/server.go gains a "renderIcon" entry.
show_count badge (web/server.go + web/templates/layout.tmpl):
- render() now computes per-saved-view counts when ANY view in the
list has ShowCount=true. One ListAll per render, shared across all
show-count views; for each opted-in view the persisted filter_json
is decoded into a TreeFilter and matched against every item.
- Counts pass to the template as UserViewCounts (slug → count). The
template renders {{index $counts $slug}} inside a nav-badge span
next to the view's name.
Template updates:
- layout.tmpl: replaces the diamond-glyph placeholder with
{{renderIcon .Icon}}; show_count views emit a .nav-badge next to
their name.
- view_editor.tmpl: icon <select> now sourced from IconKeys data
(the editor handler passes IconRegistryKeys()).
CSS additions:
- nav-badge: muted-color, surface-background, pill-shaped, pushed to
the right via margin-left:auto so the badge aligns with the row's
end regardless of name length.
- nav-item-user-view.active .nav-badge: switches to accent border +
color so the active row's badge stays legible.
Tests:
- TestSidebarShowCountBadge — seeds show_count=true view, asserts
.nav-badge markup in the sidebar.
- TestSidebarIconRenders — seeds icon=star view, asserts the
distinctive star polygon path lands in the sidebar SVG.
Drag-reorder UI stays parked (m's Q7=(b) v2). sort_order column is
server-assigned MAX+1 on create; the column was wired in slice A and
ReorderViews is ready for slice G's followup.
The Phase 5j sidebar's Views entry already linked to /views; slice E
extends the section to LIST every saved user view with its name + icon
glyph + active state, plus a "+ New view" shortcut at the bottom. The
system views (Tree / Dashboard / Calendar / Timeline / Graph) stay in
the main nav block above so muscle memory holds.
Render plumbing (web/server.go):
- render() pulls ListViews() into the data map under UserViews when
the template is not "login" (login renders without layout). Stub
servers without a real Pool skip cleanly via the name guard.
- One indexed lookup per chrome-bearing render. Slice G can add a
per-request memoisation if profiling bites.
Template (web/templates/layout.tmpl):
- New "Views" sub-section below the main /views entry. Each user view
emits as a nav-item-user-view link with icon glyph + name. Active
marker fires when path == /views/<slug>. Bottom anchor: "+ New view"
link to /views/new for one-click creation from anywhere.
- The icon-glyph stays a placeholder diamond (◆) in this slice; slice
G ships the registry SVGs.
CSS (web/static/style.css):
- nav-item-user-view: slightly smaller font, indented 24px so user
views sit visually under the Views section header.
- nav-item-new-view: muted color to distinguish the action from
navigation.
- sidebar-user-views: flex column with 2px gap matches the existing
sidebar's spacing rhythm.
Tests:
- TestSidebarListsUserViews — seeds one view, asserts the sidebar
surfaces /views/{slug} href + display name + the + New view link.
Active marker fires on /views/{slug}.
Per m's Q1 pick (b) (2026-05-29): legacy `/`, `/dashboard`, `/calendar`,
`/timeline`, `/graph` become `/views/{system-slug}`. Old routes
301-redirect to the new ones with chip params preserved; the legacy
?view=<uuid> param from 5i is resolved through the uuid → slug map
when present so old bookmarks land on the right user view.
System views (web/system_views.go):
- SystemView struct (Slug / Name / Icon / URL) — code-resident, never
rows in projax.views.
- AllSystemViews() returns the canonical five: tree, dashboard,
calendar, timeline, graph. Display order matches the existing
sidebar.
- LookupSystemView(slug) returns the matching entry or nil; the
reserved-slug list in store.IsReservedViewSlug (slice A) is kept
in sync.
- legacyRedirect(systemSlug) handler 301s with chip-param preservation
+ uuid → slug resolution for any leftover ?view=<uuid>.
Routes (web/server.go):
- GET /views/tree → handleTree (was GET /)
- GET /views/dashboard → handleDashboard
- GET /views/timeline → handleTimeline
- GET /views/calendar → handleCalendar
- GET /views/graph → handleGraph
- GET / → 301 → /views/tree
- GET /dashboard → 301 → /views/dashboard
- GET /timeline → 301 → /views/timeline
- GET /calendar → 301 → /views/calendar
- GET /graph → 301 → /views/graph
- POST action endpoints (/dashboard/task/*, /dashboard/pin, /admin/*)
stay where they are — those are RPC-ish, not page renders.
handleTree: dropped the `r.URL.Path != "/"` guard — the only entry
point now is /views/tree, mounted via the new route. Slice F removes
any residual references; this slice keeps the handler reachable.
computeChipCounts grew a `base string` arg so chip URLs anchor on the
caller's route (/views/tree for the system tree, /views/{slug} for
saved views). PageViewTypes recognises both legacy and /views/ keys
during the transition.
Template hrefs / hx-gets bulk-updated to the new URLs:
- layout.tmpl: every sidebar + bottom-nav entry points at
/views/{system-slug}. Active-state checks updated alongside.
- tree_section.tmpl, tree_card.tmpl, tree_kanban.tmpl: clear-filter
/ clear-all hrefs → /views/tree.
- calendar*.tmpl, timeline_section.tmpl, graph.tmpl,
dashboard_section.tmpl: every internal nav + filter link points at
the /views/{slug} surface.
- detail.tmpl, error.tmpl: cancel / back-to-tree → /views/tree.
Test-source updates (per the 5c sharpened rule):
- ~100 test paths bulk-rewritten from /dashboard /calendar /timeline
/graph (and `/`) to their /views/{slug} counterparts. The
behaviour-preservation contract holds: status codes + body shapes
for the rendered pages stay the same; only the URL anchoring the
test changes.
- layout_test.go: sidebar href assertions updated to /views/{slug}.
- view_type_test.go (Q2 + Q3 follow-up): PageViewTypes lookup table
updated to use the new route keys.
- 2 deliberate behaviour-change assertions land: TestLegacyRedirects
expects 301 on the old URLs (was 200); TestTreeRenders fetches
/views/tree (the new home) instead of /.
Internal go-source URL emissions (dashboard.go, calendar.go,
timeline.go) updated to the new BasePath so chip + refresh URLs round
through /views/{slug} correctly.
New tests:
- TestSystemViewLookup — AllSystemViews shape + LookupSystemView
round-trip + unknown-slug nil.
- TestLegacyRedirects — every legacy URL 301s to its new home with
chip params preserved.
- TestLegacyViewUUIDRedirect — old `?view=<uuid>` URLs land on the
resolved slug per m's Q3 pick.
Restores the /views URL family in the paliad shape m asked for:
GET /views → MRU 302 or onboarding shell
GET /views/{slug} → render saved view as its own page
GET /views/new → editor blank
GET /views/{slug}/edit → editor existing
POST /views → create
POST /views/{slug} → update
POST /views/{slug}/delete → delete
POST /views/reorder → drag-reorder hook (used in slice G)
Render path:
- handleViewRender resolves the slug against user views (slice C adds
system views), touches last_used_at fire-and-forget so the next /views
landing 302s here, then dispatches the same view_type renderers the
tree page uses (list / card / kanban). filter_json is decoded into a
TreeFilter + view_type + group_by; URL chip params overlay the saved
filter so chips narrow the view further without losing the saved
baseline. calendar / timeline view_types fall back to list in slice B;
slice D wires their dedicated templates.
Editor path:
- handleViewEditor renders templates/view_editor.tmpl, a minimal form
for slice B (slice D adds the live chip strip, slug auto-derivation,
and the icon registry). Pre-fills every persisted field on edit.
Templates:
- views_landing.tmpl — index card list + "+ new view" link.
- view_render.tmpl — header (name + slug + edit/delete) + tree-section
partial. Bundled with tree_section / tree_card / tree_kanban /
project_chip so the rendered view shares the dispatch chain.
- view_editor.tmpl — form for create + edit.
Encoding:
- encodeFilterToJSON canonicalises (filter_query, view_type) into the
filter_json shape. view_type lives INSIDE the JSON per m's Q2 pick.
- decodeViewSpec is the inverse — slice C's system-view code reuses it
to convert SystemView definitions into the same shape.
- overlayURLOntoSavedFilter mirrors the 5i fix-shift pattern: URL chip
values selectively override the saved baseline (q / tag / mgmt /
status / has / show-archived / public / project / project_descendants).
Error mapping:
- writeViewError translates the typed store errors (ErrViewSlugFormat /
Reserved / Taken / NotFound) into 400 / 409 with human-readable
banners. handlers map ErrViewNotFound to 404 directly.
Tests (HTTP integration):
- TestViewsLandingOnboarding — empty store → shell with "+ New view".
- TestViewsLandingMRURedirects — touched view triggers 302 to it.
- TestViewRenderShowsSavedView — name + slug + view_type=card grid.
- TestViewRender404OnUnknownSlug — unknown slug 404s, no silent
fall-back to tree.
- TestViewCreateAndDelete — POST /views creates; reserved slug 400s;
POST /views/<slug>/delete removes the row.
- TestSavedViewFilterOverlay — ?tag=work narrows the saved view; URL
chip values overlay the persisted filter.
Hard-replaces the 5i projax.views table per m's Q10 pick (2026-05-29):
no real data to preserve after a few hours, and the shape changes are
big enough that a clean recreate beats a 6-step ALTER.
Schema (migration 0017_views_redesign.sql):
- id (uuid), slug (text, format-CHECK'd, UNIQUE), name, icon,
filter_json (jsonb — INCLUDES view_type per m's Q2), sort_field,
sort_dir, group_by, sort_order, show_count, last_used_at,
created_at, updated_at.
- DROPPED: pinned, is_default_for, view_type column. m's Q9 picked
MRU (last_used_at) over per-page-default; Q2 placed view_type
inside filter_json so the JSON owns the canonical render spec.
- Constraints: slug regex, sort_dir enum. NO view_type CHECK — the
JSON-shape validator owns it now.
- Indexes: slug UNIQUE, (sort_order, name), (last_used_at DESC).
- updated_at trigger reused; projax_admin ownership preserved.
Store (store/views.go rewrite):
- View struct: Slug as the user-facing key; uuid kept on ID for the
legacy `?view=<uuid>` 302-redirect path that lands in slice C.
- ListViews ordered by sort_order, name (matches sidebar).
- GetView(slug) + GetViewByID(uuid). MostRecentView() drives the
/views landing redirect (slice B).
- TouchView(slug) bumps last_used_at fire-and-forget.
- ReorderViews([]slugs) wires the column for slice G's drag UI.
- CreateView server-assigns sort_order = MAX+1 inside the tx.
- UpdateView replaces every writeable field; renames are supported.
- Validation: slug format regex + reserved-list rejection +
filter_json JSON well-formed check before round-trip.
- ErrViewNotFound / ErrViewSlugTaken / ErrViewSlugReserved /
ErrViewSlugFormat surface to handlers as the typed error set.
Cleanup of the 5i overlay (drops what the new shape obsoletes):
- web/views.go: gutted to a stub. applySavedView, applyDefaultView,
overlayURLFields, filterQueryToJSON, filterJSONToQuery,
filterFromJSONPayload, anySliceToStrings + every old handler
(handleViewsIndex, handleViewCreate, handleViewWrite, handleViewEdit,
handleViewRedirect, handleViewDelete) deleted.
- web/server.go: dropped the /views route registrations and the
applySavedView + applyDefaultView calls in handleTree.
DefaultBanner data-map field removed.
- web/tree_filter.go: TreeFilter.ViewID field removed; ParseTreeFilter
and QueryString stop reading/emitting ?view=.
- web/templates/views.tmpl and view_edit.tmpl deleted.
- web/templates/tree_section.tmpl: default-banner block deleted.
- web/views_test.go: deleted (every test was against the 5i shape).
Between slice A and slice B, /views/* URLs return 404 by design.
Slice B reintroduces the route family in paliad-shape:
GET /views → MRU landing
GET /views/{slug} → render
GET /views/new → editor
GET /views/{slug}/edit → editor
POST /views, /views/{slug}, /views/{slug}/delete → CRUD
Tests (store/views_test.go, new):
- TestViewSlugCRUD — create / get-by-slug / get-by-id / rename /
delete round-trip, including rename-leaves-old-slug-gone.
- TestViewSlugFormatRejected — uppercase, underscore, leading dash,
length-cap, empty all surface ErrViewSlugFormat.
- TestViewReservedSlugRejected — tree/dashboard/calendar/timeline/graph
and friends all reject with ErrViewSlugReserved.
- TestViewSlugCollision — duplicate slug surfaces ErrViewSlugTaken.
- TestViewMRU — TouchView + MostRecentView ordering against a
controlled pair of slugs (resilient to other suites' touched views).
- TestViewReorder — ReorderViews rewrites sort_order ascending.
Web tests stay green (the 5i overlay tests are gone, the rest don't
touch the views shape).
m's request: typing "Mallorca 2026" into the new-item Title should
suggest "mallorca-2026" in the Slug field. Surface-only — server still
validates per itemwrite (^[a-z0-9][a-z0-9-]{0,62}$).
Inline ~25-line vanilla-JS handler on /new:
- normalize('NFD') + strip combining diacritics → ä→a, ñ→n, São→sao
- ß → ss (German sharp-s)
- non-alphanum run → single hyphen
- trim leading/trailing hyphens, collapse runs of hyphens
- slice(0, 63) to match the validator's length cap
Behavioural contract per m's brief:
- Slug syncs from Title on every Title input event UNTIL the user
edits the slug manually. After that the slug field is locked in
(`slug.dataset.userEdited === '1'`).
- A pre-filled slug counts as user-edited too — defensive against any
future flow that lands on /new with a slug already populated.
Scoped to /new only — the detail-page edit form intentionally keeps
manual slug control because auto-sync there would silently rename
existing items.
Template additions:
- Added `id="new-item-form"`, `id="new-title"`, `id="new-slug"` to the
form + inputs so the script can grab them by id rather than name
(name="slug" exists on the detail page too and we don't want to
cross-bind).
Test (web/new_form_test.go):
- TestNewFormHasSlugSuggestScript — asserts the inline script's
signature fragments (`normalize('NFD')`, `replace(/ß/g, 'ss')`,
`slice(0, 63)`, `dataset.userEdited`, the input ids) all render on
/new. Guards against a "harmless cleanup" pass silently stripping
the script.
Manual verification: typing "Mallorca 2026" updates slug to
"mallorca-2026"; typing in the slug field locks further sync.
Full web suite green.
m reported /timeline filters don't narrow, then clarified that the
project-filter dim added in Phase 5i Slice A (kahn, 13923aa) "doesn't
work ANYWHERE." Systematic reproduction:
/tree?project=admin → narrows ✓
/timeline?project=admin → narrows ✓
/calendar?project=admin → narrows ✓
/dashboard?project=admin → narrows ✓
/admin/bulk?project=admin → SILENT NO-OP ✗
Plus a small parser bug on /timeline's ?kind=… handling that mirrors
the calendar bug fixed in 6f0a318.
## Root causes
(1) `bulkMatches` in web/bulk.go is a near-clone of `TreeFilter.Matches`
that the Phase 5i Slice A author updated only on Matches itself — the
clone never picked up the ProjectPath block. Filter parses fine, gets
threaded into filterFlat, and silently ignored. `/admin/bulk?project=…`
sees every item.
(2) Timeline's own `?kind=event,doc` parser used
`r.URL.Query().Get("kind")` + comma-split — same shape calendar carried
before commit 6f0a318. When the chip strip's `<select multiple>`
submits `?kind=event&kind=doc`, only the first value lands in q.Kinds.
The user picks two kinds, sees only one applied.
## Fix
bulkMatches gets the ProjectPath block copied verbatim from
TreeFilter.Matches — same predicate, same IncludeDescendants gate,
same multi-parent "ANY path qualifies" semantics.
timeline.parseTimelineQuery's ?kind handling drops the bespoke
Get+Split+dedup-map and uses `parseValues(r.URL.Query(), "kind")` —
the helper already added to web/server.go covers both URL shapes
transparently (`?kind=a,b` and `?kind=a&kind=b`).
## Tests
web/project_filter_test.go (new, 6 tests):
- TestProjectFilterNarrowsTree
- TestProjectFilterNarrowsTimeline
- TestProjectFilterNarrowsCalendar
- TestProjectFilterNarrowsDashboard
- TestProjectFilterNarrowsBulk ← was failing pre-fix
- TestProjectFilterDescendantsToggle
- TestTimelineKindMultiValueSurvives ← was failing pre-fix
The fixture seeds a three-row subtree under dev/ (root + child +
outside sibling) and asserts each surface narrows to root + child
while excluding the outside sibling. The descendants toggle test
flips `?project_descendants=0` and confirms the child drops out.
web/timeline_filter_test.go (new, 3 tests): URL-driven tag narrowing,
multi-value kind parsing, and chip-strip HTMX form target wiring.
These are the immediate "reproduce first" probes athena's brief asked
for; they all PASSED on the pre-fix code (the filter narrowing was
fine on URL paths; the bug was elsewhere) — they stay as defence-in-
depth against future regressions.
## Surfaces double-checked (not broken)
- /graph?project=… dims non-matching nodes instead of narrowing per
graph.go's explicit comment "the graph deliberately shows the full
DAG; the filter dims non-matches via opacity unless isolate=1
hides them." Working as documented.
- The chip strip + project-picker template + Views-page hidden inputs
all preserve the project value across chip changes — verified by
template rendering probes.
Full web suite green (76 tests). Pre-existing db/TestBackfillTagsFromArea
unchanged.
Net: +442 / -12.
m's ask: per-item CalDAV linking should support existing lists, not
just create-new. Athena's design update extended it: also tag VTODOs
on create so multiple projax items can SHARE one CalDAV list, with
projax doing tag-based slicing on read.
Three layers, one branch:
## 1. Link-existing picker (the original ask)
- New POST /i/{path}/caldav/link-existing handler validates the
submitted calendar_url is in the discoverable PROPFIND set (defence
against crafted forms pointing at arbitrary HTTP servers), then
inserts the item_link row with display_name + color metadata
preserved from the discovery payload.
- handleDetail + renderTasksSection pre-load
availableCalendarsForItem(ctx, links) — calendars from
s.CalDAV.Client.ListCalendars MINUS the ones already linked to this
item. Errors degrade to an empty picker (non-fatal).
- tasks_section.tmpl gains a .caldav-actions block rendering the
picker (<select> of available calendars) when AvailableCalendars
is non-empty AND the Create-new button (when the item has no
linked list yet). Same surface serves both the "first link" flow
and the "+ link another" flow per athena's brief.
## 2. Tag-on-create (CATEGORIES carries projax:<path>)
- caldav package gains Categories []string on Todo + the same on
VTodoEdit. BuildVTodoICS emits a CATEGORIES line when non-empty;
parseVTodos parses CATEGORIES comma-list into the slice with per-
entry unescape per RFC 5545.
- handleCalDAVTodoAction action="todo-create" passes
`Categories: []{ProjaxCategoryFor(it.PrimaryPath())}` into
VTodoEdit so every per-item Add submits a tagged VTODO.
- ApplyVTodoEdit intentionally ignores the Categories field —
edit/complete/delete paths preserve existing CATEGORIES via the
unknown-property pass-through that's been tested since Phase 5
(TestApplyVTodoEditPreservesUnknown).
## 3. Per-item filter (managed-vs-legacy)
- detailTodos now calls caldav.AnyTodoHasProjaxTag(todos) to decide
whether the linked list is projax-managed (any projax: tag
anywhere) or legacy/unmanaged (zero projax: tags).
- Managed → filter to VTODOs whose CATEGORIES include this
item's projax:<path>. Multiple projax: tags are AND-of-OR — a
VTODO with two projax tags appears on both items per athena's
multi-tag contract.
- Legacy → show every VTODO untouched. Existing pre-5j users with
untagged lists keep seeing everything; the detail page doesn't
suddenly hide their tasks.
## Helpers (caldav package, exported)
- ProjaxCategoryFor(primaryPath) → "projax:<path>" string
- HasProjaxTag(t) bool → any projax: prefix
- HasProjaxTagFor(t, primaryPath) bool → exact projax:<path>
- AnyTodoHasProjaxTag(todos) bool → list-level signal
## Tests
caldav unit (caldav/projax_tags_test.go):
- TestProjaxCategoryFor / TestHasProjaxTagAndFor /
TestAnyTodoHasProjaxTag / TestBuildVTodoICSEmitsCategories /
TestParseVTodosMultiCategory.
web integration (web/caldav_link_existing_test.go) — single fake
CalDAV server (httptest) answering PROPFIND + REPORT + PUT, then
four end-to-end probes:
- TestDetailLinkExistingCalendar — three calendars discoverable,
picker renders, POST link-existing creates the link, second GET
drops the linked URL from the picker.
- TestVTodoCreateAttachesProjaxCategory — Add-task POST writes a
VTODO whose CATEGORIES contains projax:<path>.
- TestDetailFilterByProjaxCategory — one calendar shared between
Trip A and Trip B with three tagged VTODOs; A sees A+shared,
B sees B+shared, neither sees the other's tagged-only VTODO.
- TestDetailUntaggedListShowsAll — linked list with zero projax
tags renders ALL VTODOs (legacy fallback).
Full web + caldav suites green. Pre-existing
db/TestBackfillTagsFromArea failure unchanged.
Net: +795 / -14.
m's report: /new?parent=admin doesn't pre-select admin. Root cause is
worse than the report — the Parents <select> was COMPLETELY EMPTY: the
handler never passed ParentOptions to the template, so the
`{{range .ParentOptions}}` block iterated nil. There was nothing to
pre-select.
handleNewForm now calls s.parentOptions(r.Context()) the same way
handleClassify already did, and threads the result through the data
map as "ParentOptions". The template's existing pre-select expression
`{{if and $.Parent (eq .ID $.Parent.ID)}}selected{{end}}` already
handles id/path resolution — once the options exist, the `selected`
attribute lands on the right one.
Regression test (web/new_form_test.go):
- TestNewFormPreselectsParent — probes /new?parent=admin against the
HTTP integration server, asserts (1) <option> tags are rendered in
the Parents <select>, (2) the admin <option> exists with `selected`
on its opening tag, (3) other root options (dev) do NOT carry
`selected`. Confirmed failing pre-fix (no admin option at all),
passing post-fix.
- TestNewFormNoParentParamRendersAllOptions — bare /new with no
?parent= still populates the Parents <select> so the user can pick
any parent. Belt-and-braces guard.
Full web suite green. Pre-existing db/TestBackfillTagsFromArea failure
unchanged.
Net: +105 / -0.
m's feedback on 5i (verbatim): "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."
5i modelled views as overlays on existing pages (?view=<uuid>). m wants
the paliad model: views are first-class URLs (/views/{slug}), each one
its own page. System defaults (dashboard, calendar, timeline, ...)
share the route shape with reserved slugs; user-created views land
beside them.
Plan covers: schema redesign (slug as URL key, drop is_default_for +
pinned, add icon + sort_order + show_count + last_used_at), four-route
table (landing with MRU redirect, render, editor blank/edit), system-
view shape (hybrid alias recommendation under Q1), editor surface
(dedicated pages, not modal), migration path from 5i (drop table +
delete overlay code; keep view_type enum and per-view_type renderers),
seven-slice implementation chain (A schema → B routes → C system views
→ D editor → E sidebar → F cleanup → G polish).
11 open questions batched in §9 — head delegation pending. NO chip-
picker without head's explicit re-grant (5i permission was one-time).
No code changes; this branch ships docs only. Coder shifts wait on m's
sign-off via head's relay.
m's bug (verbatim from /views): "we cant edit views yet. and the filters
on custom views dont seem to work. No apply button and no instant apply"
Two distinct gaps, both surgically fixed.
## Gap 1 — edit UI missing
Slice D shipped POST /views/<id> (update) but no GET form to drive it.
The index page had delete + redirect-open links only.
Fix:
- New handleViewEdit serves GET /views/<id>/edit with the form pre-filled
from the persisted row.
- New templates/view_edit.tmpl mirrors the create form, selecting the
current values on each <select>, populating each <input value="">.
- filterJSONToQuery rebuilds the URL-query representation of filter_json
so the `filter_query` text input round-trips on edit.
- /views index row gets an "edit" link next to delete.
- Route registered before the catch-all GET /views/ so the more specific
pattern wins. handleViewRedirect also defensively forwards /edit
suffix in case routing falls through.
## Gap 2 — URL chips clobbered by saved-view filter
applySavedView did `*filter = filterFromJSONPayload(payload)` — wholesale
replace. URL chip params parsed earlier in handleTree were thrown away.
Compounded by chip URLs not preserving `?view=<id>`, so even if the
overlay had worked, chip clicks would have stripped the saved view.
Fix:
- TreeFilter grows a `ViewID` field that round-trips through
ParseTreeFilter + QueryString. Not a "filter dimension" in the
matching sense (Matches ignores it); just a URL anchor that
every chip URL emits forward.
- applySavedView builds the saved filter, then overlayURLFields()
selectively replaces any dimension the user set via URL chip on top
(q/tag/mgmt/status/has/show-archived/public/project/project_descendants).
- view_type: URL wins when explicitly set, saved value otherwise.
- Drift is transient — URL bookmarkable as a "narrowed saved view"
without auto-saving back to the row. To persist, user opens /edit.
## Tests
- TestViewEditFlow — GET /<id>/edit pre-fills name + filter_query; POST
/<id> updates name + view_type + filter_json round-trip in DB.
- TestSavedViewPageFilterApply — seed two items + an empty saved view;
/?view=<id> shows both; /?view=<id>&tag=work shows only the work
one. Also asserts chip URLs contain view=<id> so navigation stays in
the saved view.
Out of scope (per brief):
- No schema changes.
- No view sharing / multi-user.
- HTMX modal save UI deferred — the existing inline edit page is the
surgical fix m's bug actually needs.
Closes the Phase 5i implementation chain. When `views.is_default_for=<page>`
is set, opening that page with a "clean" URL (no chip params, no
?view=) auto-applies the saved filter + view_type. A "Showing default
view: <name> · clear" banner makes the swap visible and gives the user
a one-click out. Adding any chip param to the URL bypasses the default;
?nodefault=1 is the explicit opt-out for "I want the bare default tree".
New web/views.go: applyDefaultView gates on the param-cleanness check
+ Store.DefaultViewFor lookup. Resolution + view_type revalidation
mirror the slice D ?view=<uuid> path so a kanban-default opened on a
route that doesn't allow kanban falls back cleanly.
handleTree wires it into the existing slice D else-branch (no default
when ?view= is set). DefaultBanner field passes the applied view to
the template for the banner.
Test:
- TestDefaultViewAppliedOnCleanURL — seeds a tree default with
filter_json={tags:[work]} + view_type=card, then asserts: clean GET /
applies (card grid + banner with the view's name); ?tag=dev bypasses
(forest, no banner); ?nodefault=1 opt-out (forest, no banner).
m's Q6 pick (2026-05-26): kanban groups the filtered set by `status`
(default) / `area` / `tag` / `management`. Read-only — drag-to-change
is parked. Adds the third view_type render on /tree (alongside list and
card from earlier slices); kanban is now unlocked in PageViewTypes("/").
New web/kanban.go owns BuildKanbanBoard + the per-dimension keyer +
column ordering (status: active/done/archived; management: mai/self/
external/unmanaged; area + tag: alphabetical). Within-column order:
pinned-first → updated_at desc → title.
ParseGroupBy + GroupByChips provide the URL-param hookup and the chip
strip rendered above the board. Multi-tag items appear in every tag
column they belong to (deliberate — the kanban surfaces overlap).
Render:
- handleTree builds the kanban board off the same flatMatchedItems the
card view consumes; cost is one extra grouping pass, no new DB hits.
- New templates/tree_kanban.tmpl: header chip strip + responsive
column board (horizontal scroll on overflow). Empty filtered set
surfaces a friendly nudge.
CSS additions cover the column / card layout; existing chip aesthetics
reused for the group-by toggle.
Test updates:
- view_type_test.go: slice B's "kanban locked on /" assertions tightened
to "kanban unlocked; calendar + timeline still locked on /" — slice C
is the unlock event for kanban.
- New kanban_test.go: per-dimension grouping (status, tag, area),
pinned-first ordering, parser fallback.
- server_test.go: end-to-end render — GET /?view_type=kanban produces
kanban-board markup + group-by chip strip; forest absent.
Persists named bundles of (filter + view_type + sort + group_by). Per m's
Q2 pick (2026-05-26), views are page-agnostic — `is_default_for` lets a
view become the auto-applied default for a page, otherwise views render
on whichever page accepts their view_type.
Schema (db/migrations/0016_views.sql):
- projax.views table with check constraints on view_type (5-value enum),
sort_dir, is_default_for, and the kanban-needs-group rule.
- Case-insensitive unique name index (live rows only).
- One-default-per-page partial unique index.
- updated_at trigger; projax_admin ownership / grants.
Store (store/views.go):
- View struct + ViewInput; ListViews / GetView / CreateView / UpdateView
/ SoftDeleteView / DefaultViewFor.
- CreateView and UpdateView clear the prior default for a page in the
same transaction when IsDefaultFor is set — defends against the
partial unique index outside the SECURITY DEFINER path.
- Validation mirrors the DB check constraints so handlers can surface
friendlier errors before round-tripping.
Handlers (web/views.go) + routes (web/server.go):
- GET /views list + create form (templates/views.tmpl).
- POST /views create (filter_query form field is parsed into
canonical filter_json shape — design.md §2).
- GET /views/<id> redirect to the target page + ?view=<id>.
- POST /views/<id> update.
- POST /views/<id>/delete soft delete.
Resolution path:
- handleTree now calls applySavedView when ?view=<uuid> is present;
fields the saved filter_json + view_type back into the TreeFilter and
the view-type slot. view_type then revalidates against the route
catalog so a saved kanban-view URL on / lands on list with kanban
shown locked until slice C ships it. Failures fall back gracefully
(log + URL-derived filter), no 500.
UI:
- Sidebar gains a Views entry (4-square icon) next to Admin in
layout.tmpl.
- /views renders a flat table + inline create form. The form accepts a
URL-query filter string (e.g. `tag=work&mgmt=mai`) which is canonised
into filter_json on save.
Tests:
- TestViewsCRUDRoundTrip — full create / list / open-redirect / soft-
delete cycle via HTTP, plus filter_json shape assertion.
- TestSavedViewAppliedOnQueryParam — seed a card view scoped to dev,
hit /?view=<id>, assert the page renders card grid + scoped chip-on.
Out of scope for slice D (per design.md §7):
- HTMX modal save UI from any page (the inline-create-on-/views/ form
works; a modal lands in a polish pass).
- MCP read tools for views (deferred to a follow-up — m manages views
via the UI).