36 Commits

Author SHA1 Message Date
mAi
a619768cca Merge branch 'mai/kahn/phase-7c-unify-tasks' (Phase 7c step 2: unify mBrian+CalDAV tasks into one collected list) 2026-06-01 18:28:46 +02:00
mAi
ef507b4e1b feat(web): Phase 7c STEP 2 — unify mBrian + CalDAV tasks into ONE list
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).
2026-06-01 18:27:49 +02:00
mAi
018665fee7 Merge Phase 7c step 1 (7287882): load vendored HTMX so hx-post task/tree/dashboard/bulk/classify forms work 2026-06-01 18:20:54 +02:00
mAi
728788225c fix(web): load HTMX so task (+tree/dashboard/bulk/classify) forms work
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.
2026-06-01 18:19:53 +02:00
mAi
b72744b567 Merge branch 'mai/kahn/phase-7b-tasks' (Phase 7b: first-class mBrian tasks + CalDAV hybrid two-section + checklist render) 2026-06-01 18:01:40 +02:00
mAi
63a2e13036 test(store): Phase 7b — live end-to-end task round-trip integration test
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'.
2026-06-01 17:59:20 +02:00
mAi
e65385e609 feat(web): Phase 7b — mBrian-native tasks on the detail page (two-section, Q4b)
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.
2026-06-01 17:56:04 +02:00
mAi
0dfa0e2ab7 feat(store): Phase 7b — uniform Task shape + mBrian-native task read/write
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.
2026-06-01 17:50:48 +02:00
mAi
6436b524d6 Merge branch 'mai/kahn/fix-slug-control' (restore projax-side slug control: explicit slug on create+rename via mBrian API, surface 409) 2026-06-01 17:36:59 +02:00
mAi
eaecd3944e fix(slug): restore projax-side slug control via mBrian's slug API
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.
2026-06-01 17:35:13 +02:00
mAi
7c84c96f8b Merge branch 'mai/kahn/phase-6-sliceC' (Phase 6 Slice C: write-path to mBrian via scoped HTTP API; PROJAX_BACKEND flips reads+writes atomically) 2026-06-01 12:40:13 +02:00
mAi
e133f51706 fix(store): Phase 6 Slice C — reader reads pinned/archived + note from metadata (G3, G2)
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.
2026-06-01 12:38:54 +02:00
mAi
663f21bdb0 docs: Phase 6 Slice C write-path contract + API gap register
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.
2026-06-01 12:34:40 +02:00
mAi
bc56733bc8 feat(mcp+cmd): Phase 6 Slice C — atomic PROJAX_BACKEND flip across web + MCP
- 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.
2026-06-01 12:33:46 +02:00
mAi
e43055b670 feat(store): Phase 6 Slice C — MBrianWriter HTTP client against the scoped write API
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.
2026-06-01 12:25:00 +02:00
mAi
67577396a2 feat(web): Phase 6 Slice C — route every web write + validator through the adapter
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.
2026-06-01 12:18:03 +02:00
mAi
d0ec02cb63 feat(adapter): Phase 6 Slice C scaffold — ItemWriter interface + Server.Writes + MBrianWriter stub
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.
2026-06-01 12:11:53 +02:00
mAi
307a898dbd Merge branch 'mai/kahn/phase-6-sliceB' (Phase 6 Slice B: mBrian-backed read path behind PROJAX_BACKEND switch, parity-green) 2026-05-31 22:22:40 +02:00
mAi
b22f50ca7b feat(adapter): Phase 6 Slice B — mBrian-backed read path live
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.
2026-05-31 22:20:38 +02:00
mAi
4fdeca8269 Merge branch 'mai/kahn/phase-6-sliceB-prep' (Phase 6: slice-B adapter interface contract + skeleton, no impl) 2026-05-29 15:18:15 +02:00
mAi
9607d4b307 docs+skeleton: Phase 6 Slice B prep — read-path adapter interface contract
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.
2026-05-29 15:17:24 +02:00
mAi
38182df651 Merge branch 'mai/kahn/phase-6a-mbrian-design' (Phase 6: mBrian-backend migration design + slice 0 snapshot helper) 2026-05-29 14:03:27 +02:00
mAi
2702c699d1 feat(snapshot): Phase 6 slice 0 — projax_snapshot.json export helper
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.
2026-05-29 14:02:16 +02:00
mAi
a5b0971b9d docs: Phase 6 plan re-baseline against live mBrian schema + m's answers
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.
2026-05-29 13:56:50 +02:00
mAi
b3e7183478 docs: Phase 6 mBrian-as-backend migration design plan
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.
2026-05-29 12:49:48 +02:00
mAi
a44edf3917 Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice G: show_count badges + icon registry) 2026-05-29 12:08:01 +02:00
mAi
9a8ea8f31e feat(views): Phase 5j slice G — show_count badges + icon registry
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.
2026-05-29 12:07:54 +02:00
mAi
df83ab7255 Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice E: sidebar Views section with user views) 2026-05-29 12:03:53 +02:00
mAi
1f8c626aed feat(views): Phase 5j slice E — sidebar Views section with user views
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}.
2026-05-29 12:03:47 +02:00
mAi
4918f48b51 Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice C: full URL migration + system views) 2026-05-29 11:59:31 +02:00
mAi
f820fa5830 feat(views): Phase 5j slice C — full URL migration + system views
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.
2026-05-29 11:59:26 +02:00
mAi
0ad610d018 Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice B: paliad-shape route family + render) 2026-05-29 11:47:39 +02:00
mAi
e305f0e0ae feat(views): Phase 5j slice B — paliad-shape route family + render
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.
2026-05-29 11:47:33 +02:00
mAi
a9f062a67e Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice A: paliad-shape schema redesign) 2026-05-29 11:41:34 +02:00
mAi
173d7ddbb2 feat(views): Phase 5j slice A — paliad-shape schema redesign
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).
2026-05-29 11:41:28 +02:00
mAi
590bb28063 docs: Phase 5j Views-redesign plan — paliad-shape first-class views
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.
2026-05-26 15:23:35 +02:00
87 changed files with 7818 additions and 1440 deletions

View File

@@ -0,0 +1,139 @@
// projax-remap-views rewrites projax.views.filter_json.project_id from
// the OLD projax.items uuid to the new mBrian node uuid using the audit
// map mBrian dropped after the migration. Dry-run by default; pass
// --apply to commit.
//
// Phase 6 Slice B gap remediation — see
// docs/plans/slice-b-views-projectid-gap.md for the surrounding context.
//
// Usage:
//
// projax-remap-views --map /path/to/uuid-map.json # dry-run
// projax-remap-views --map /path/to/uuid-map.json --apply # commit
//
// Map shape: {"<old-uuid>": "<new-uuid>", ...}
//
// Env: PROJAX_DB_URL or SUPABASE_DATABASE_URL — direct postgres URL.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
mapPath := flag.String("map", "", "JSON file with {old-uuid: new-uuid} map")
apply := flag.Bool("apply", false, "commit changes (default: dry-run)")
flag.Parse()
if *mapPath == "" {
die("--map is required")
}
mp, err := loadMap(*mapPath)
if err != nil {
die("load map: %v", err)
}
fmt.Fprintf(os.Stderr, "loaded %d uuid mappings\n", len(mp))
dbURL := os.Getenv("PROJAX_DB_URL")
if dbURL == "" {
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
}
if dbURL == "" {
die("set PROJAX_DB_URL or SUPABASE_DATABASE_URL")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
die("pool: %v", err)
}
defer pool.Close()
rows, err := pool.Query(ctx,
`SELECT slug, filter_json::text FROM projax.views WHERE filter_json ? 'project_id'`)
if err != nil {
die("query: %v", err)
}
defer rows.Close()
type view struct {
slug string
filter map[string]any
}
var pending []view
for rows.Next() {
var slug, raw string
if err := rows.Scan(&slug, &raw); err != nil {
die("scan: %v", err)
}
var fj map[string]any
if err := json.Unmarshal([]byte(raw), &fj); err != nil {
fmt.Fprintf(os.Stderr, "skip %s: invalid JSON: %v\n", slug, err)
continue
}
oldID, _ := fj["project_id"].(string)
newID, ok := mp[oldID]
if !ok {
fmt.Fprintf(os.Stderr, "skip %s: project_id %q not in map (possibly already remapped)\n", slug, oldID)
continue
}
fj["project_id"] = newID
pending = append(pending, view{slug: slug, filter: fj})
fmt.Fprintf(os.Stderr, " %s: %s → %s\n", slug, oldID, newID)
}
if len(pending) == 0 {
fmt.Fprintln(os.Stderr, "nothing to remap")
return
}
if !*apply {
fmt.Fprintf(os.Stderr, "DRY RUN — %d view(s) would be remapped; pass --apply to commit\n", len(pending))
return
}
tx, err := pool.Begin(ctx)
if err != nil {
die("begin: %v", err)
}
defer tx.Rollback(ctx)
for _, v := range pending {
payload, err := json.Marshal(v.filter)
if err != nil {
die("marshal %s: %v", v.slug, err)
}
if _, err := tx.Exec(ctx,
`UPDATE projax.views SET filter_json = $1::jsonb WHERE slug = $2`,
string(payload), v.slug); err != nil {
die("update %s: %v", v.slug, err)
}
}
if err := tx.Commit(ctx); err != nil {
die("commit: %v", err)
}
fmt.Fprintf(os.Stderr, "committed %d remap(s)\n", len(pending))
}
func loadMap(path string) (map[string]string, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
out := map[string]string{}
if err := json.Unmarshal(b, &out); err != nil {
return nil, err
}
return out, nil
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}

349
cmd/projax-snapshot/main.go Normal file
View File

@@ -0,0 +1,349 @@
// projax-snapshot dumps the current projax.items + projax.item_links state
// to a JSON file so the mBrian-side migration script (m/mBrian#73) can
// consume it. Read-only; no schema changes; idempotent across runs.
//
// Phase 6 Slice 0 — first projax-side step in the mBrian-backend migration.
// See docs/plans/mbrian-backend-migration.md §7 + §8 for the surrounding
// context. The file shape is documented in the m/mBrian#73 issue body
// (the two-pass node-then-edge layout the migration script expects).
//
// Usage:
//
// projax-snapshot # write ./projax_snapshot.json
// projax-snapshot --out path/to/file.json # custom output path
//
// Env: PROJAX_DB_URL or SUPABASE_DATABASE_URL — direct postgres URL into
// msupabase (same conventions as the main projax binary).
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Snapshot is the top-level JSON shape mBrian-side consumes.
type Snapshot struct {
Version string `json:"version"` // doc-evolution marker; bump on shape changes
GeneratedAt time.Time `json:"generated_at"`
GitCommit string `json:"git_commit,omitempty"` // optional build-time injection
Items []Item `json:"items"`
Links []ItemLink `json:"links"`
SpotChecks []SpotCheck `json:"spot_checks"` // 5 representative items per m/mBrian#73 §3
}
// Item mirrors every column on projax.items as of this commit. Field
// order matches the SQL projection; types are JSON-friendly (uuid →
// string, jsonb → map). Anything nullable surfaces as omitempty / *T.
type Item struct {
ID string `json:"id"`
Kind []string `json:"kind"`
Title string `json:"title"`
Slug string `json:"slug"`
Paths []string `json:"paths"`
ParentIDs []string `json:"parent_ids"`
ContentMD string `json:"content_md"`
Aliases []string `json:"aliases"`
Metadata map[string]any `json:"metadata"`
Status string `json:"status"`
Pinned bool `json:"pinned"`
Archived bool `json:"archived"`
StartTime *time.Time `json:"start_time,omitempty"`
EndTime *time.Time `json:"end_time,omitempty"`
Tags []string `json:"tags"`
Management []string `json:"management"`
Public bool `json:"public"`
PublicDescription string `json:"public_description,omitempty"`
PublicLiveURL string `json:"public_live_url,omitempty"`
PublicSourceURL string `json:"public_source_url,omitempty"`
PublicScreenshots []string `json:"public_screenshots,omitempty"`
TimelineExclude []string `json:"timeline_exclude,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ItemLink mirrors projax.item_links. ref_type values become projax-*
// edge rel names on the mBrian side; the payload lands in edges.metadata
// per the issue body §1.
type ItemLink struct {
ID string `json:"id"`
ItemID string `json:"item_id"`
RefType string `json:"ref_type"`
RefID string `json:"ref_id"`
Rel string `json:"rel"`
Note *string `json:"note,omitempty"`
Metadata map[string]any `json:"metadata"`
EventDate *time.Time `json:"event_date,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// SpotCheck names one of the 5 representative items the mBrian-side
// script verifies post-migration. The reason text is mirrored from
// m/mBrian#73 §3 so future readers don't need to cross-reference.
type SpotCheck struct {
ItemID string `json:"item_id"`
Slug string `json:"slug"`
Title string `json:"title"`
Reason string `json:"reason"`
}
func main() {
out := flag.String("out", "projax_snapshot.json", "output JSON path")
flag.Parse()
dbURL := os.Getenv("PROJAX_DB_URL")
if dbURL == "" {
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
}
if dbURL == "" {
die("set PROJAX_DB_URL or SUPABASE_DATABASE_URL")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
die("pool: %v", err)
}
defer pool.Close()
items, err := loadItems(ctx, pool)
if err != nil {
die("load items: %v", err)
}
links, err := loadLinks(ctx, pool)
if err != nil {
die("load links: %v", err)
}
spots := pickSpotChecks(items, links)
snap := Snapshot{
Version: "1",
GeneratedAt: time.Now().UTC(),
Items: items,
Links: links,
SpotChecks: spots,
}
buf, err := json.MarshalIndent(snap, "", " ")
if err != nil {
die("marshal: %v", err)
}
if err := os.WriteFile(*out, buf, 0644); err != nil {
die("write %s: %v", *out, err)
}
fmt.Fprintf(os.Stderr,
"wrote %s — %d items, %d links, %d spot-checks\n",
*out, len(items), len(links), len(spots))
}
func loadItems(ctx context.Context, pool *pgxpool.Pool) ([]Item, error) {
rows, err := pool.Query(ctx, `
SELECT id, kind, title, slug, paths, parent_ids, content_md, aliases,
metadata, status, pinned, archived, start_time, end_time,
tags, management,
public, coalesce(public_description, ''),
coalesce(public_live_url, ''),
coalesce(public_source_url, ''),
public_screenshots,
timeline_exclude,
created_at, updated_at
FROM projax.items
WHERE deleted_at IS NULL
ORDER BY paths NULLS FIRST, slug`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Item{}
for rows.Next() {
var it Item
if err := rows.Scan(
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs,
&it.ContentMD, &it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
&it.StartTime, &it.EndTime, &it.Tags, &it.Management,
&it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL,
&it.PublicScreenshots, &it.TimelineExclude, &it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
}
// Normalise empty slices: pgx hands back nil for empty array
// columns, which renders as `null` in JSON. Coerce to [] for
// downstream-script ergonomics.
if it.Kind == nil {
it.Kind = []string{}
}
if it.Paths == nil {
it.Paths = []string{}
}
if it.ParentIDs == nil {
it.ParentIDs = []string{}
}
if it.Aliases == nil {
it.Aliases = []string{}
}
if it.Tags == nil {
it.Tags = []string{}
}
if it.Management == nil {
it.Management = []string{}
}
if it.PublicScreenshots == nil {
it.PublicScreenshots = []string{}
}
if it.TimelineExclude == nil {
it.TimelineExclude = []string{}
}
if it.Metadata == nil {
it.Metadata = map[string]any{}
}
out = append(out, it)
}
return out, rows.Err()
}
func loadLinks(ctx context.Context, pool *pgxpool.Pool) ([]ItemLink, error) {
rows, err := pool.Query(ctx, `
SELECT id, item_id, ref_type, ref_id, rel, note, metadata,
event_date, created_at
FROM projax.item_links
ORDER BY item_id, ref_type, created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ItemLink{}
for rows.Next() {
var l ItemLink
if err := rows.Scan(
&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note,
&l.Metadata, &l.EventDate, &l.CreatedAt,
); err != nil {
return nil, err
}
if l.Metadata == nil {
l.Metadata = map[string]any{}
}
out = append(out, l)
}
return out, rows.Err()
}
// pickSpotChecks selects the 5 representative items the mBrian-side
// migration script verifies post-migration, per m/mBrian#73 §3:
//
// 1. A simple root area (dev).
// 2. A single-parent project (dev.paliad — or whichever single-parent
// project we can find).
// 3. A multi-parent project (any item with >1 parent_id).
// 4. A project with a caldav-list link.
// 5. A project with public=true and public_description / public_live_url
// populated.
//
// Failures to find any one of the 5 are non-fatal — the SpotChecks slice
// just shrinks. mBrian-side script logs whatever's missing.
func pickSpotChecks(items []Item, links []ItemLink) []SpotCheck {
byID := map[string]*Item{}
for i := range items {
byID[items[i].ID] = &items[i]
}
caldavItems := map[string]bool{}
for _, l := range links {
if l.RefType == "caldav-list" {
caldavItems[l.ItemID] = true
}
}
out := []SpotCheck{}
// 1. Root area "dev" if present.
for _, it := range items {
if it.Slug == "dev" && len(it.ParentIDs) == 0 {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: "root area (dev) — verify type=['project'] + metadata.projax.kind='area' round-trip",
})
break
}
}
// 2. Single-parent project — prefer dev.paliad if present, else any.
added2 := false
for _, it := range items {
if it.Slug == "paliad" && len(it.ParentIDs) == 1 {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: "single-parent project (dev.paliad) — verify one child_of edge",
})
added2 = true
break
}
}
if !added2 {
for _, it := range items {
if len(it.ParentIDs) == 1 && !containsString(it.Kind, "mai-managed") {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: "single-parent project — verify one child_of edge",
})
break
}
}
}
// 3. Multi-parent project — any item with cardinality(parent_ids) > 1.
for _, it := range items {
if len(it.ParentIDs) > 1 {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: fmt.Sprintf("multi-parent project (%d parents) — verify all child_of edges land", len(it.ParentIDs)),
})
break
}
}
// 4. Project with a caldav-list link.
for _, it := range items {
if caldavItems[it.ID] {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: "caldav-list-linked project — verify edges.metadata.url payload round-trip",
})
break
}
}
// 5. Project with public=true + public_description populated.
for _, it := range items {
if it.Public && it.PublicDescription != "" {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: "public-listing project — verify metadata.projax.public.* bundle preserved for flexsiebels renderer",
})
break
}
}
// Stable order for deterministic output.
sort.SliceStable(out, func(i, j int) bool { return out[i].Slug < out[j].Slug })
return out
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -65,12 +66,47 @@ func main() {
logger.Info("migrations applied")
}
srv, err := web.New(store.New(pool), logger)
st := store.New(pool)
srv, err := web.New(st, logger)
if err != nil {
logger.Error("server init", "err", err)
os.Exit(1)
}
srv.Version = gitCommit
// Phase 6 Slice C — backend selector. PROJAX_BACKEND=mbrian flips BOTH
// the read path (srv.Items → MBrianReader, direct DB) AND the write path
// (srv.Writes → MBrianWriter, scoped HTTP API) together; default keeps
// the legacy pgx-against-projax.items path on both so production
// rollback is one env flip.
//
// The flip MUST be atomic: slice B flipped only the reader, so a
// read-then-write round-trip read an mBrian uuid then wrote it against
// projax.items and was rejected. Reads and writes now always share a
// backend — never one without the other.
backend := strings.ToLower(strings.TrimSpace(os.Getenv("PROJAX_BACKEND")))
switch backend {
case "mbrian":
apiURL := strings.TrimSpace(os.Getenv("PROJAX_MBRIAN_API_URL"))
apiToken := strings.TrimSpace(os.Getenv("PROJAX_MBRIAN_API_TOKEN"))
srv.Items = store.NewMBrianReader(pool)
srv.Writes = store.NewMBrianWriter(apiURL, apiToken, pool)
if apiURL == "" || apiToken == "" {
// Reads work direct-DB without these, but writes fail closed
// (clean 503-style error) until both are set. Warn loudly rather
// than exit — head sets them in Dokploy at cutover and the writer
// surfaces a legible error if a write lands first.
logger.Warn("backend=mbrian but PROJAX_MBRIAN_API_URL/PROJAX_MBRIAN_API_TOKEN not both set — writes will fail closed until configured")
}
logger.Info("backend=mbrian (reads via store.MBrianReader, writes via store.MBrianWriter HTTP API)", "api_url", apiURL)
case "", "store":
// Default — srv.Items and srv.Writes are both the *Store from web.New.
logger.Info("backend=store (reads + writes via legacy *store.Store)")
default:
logger.Error("unknown PROJAX_BACKEND value", "value", backend)
os.Exit(1)
}
logger.Info("startup", "version", gitCommit)
if supaURL := os.Getenv("SUPABASE_URL"); supaURL != "" {
@@ -119,7 +155,11 @@ func main() {
// shared *aggregate.Aggregator instead of pointing back at
// *web.Server. Passing nil here disables the `timeline` tool
// cleanly; the rest of the projax MCP toolset stays usable.
mcp.RegisterProjaxTools(mcpSrv, store.New(pool), srv.Aggregator())
// MCP flips with the backend too: pass the same reader+writer the web
// handlers use (srv.Items / srv.Writes, already set by the
// PROJAX_BACKEND switch above). The `timeline` tool keeps the legacy
// *Store (st) + aggregator — out of slice-C scope.
mcp.RegisterProjaxTools(mcpSrv, srv.Items, srv.Writes, st, srv.Aggregator())
mcpMux := http.NewServeMux()
mcpSrv.Routes(mcpMux)
srv.MCP = mcpMux

View File

@@ -0,0 +1,101 @@
-- 0017_views_redesign.sql
--
-- Phase 5j Slice A: paliad-shape redesign of projax.views.
--
-- 5i (0016) modelled views as overlays on existing pages keyed by uuid.
-- m's feedback: that's the wrong shape — views should be first-class
-- pages at /views/{slug}, mirroring paliad's user_views model.
--
-- This migration HARD-REPLACES the 5i table. m's pick on Q10 (2026-05-29):
-- hard-replace is fine because 5i was hours old with no persisted user
-- data of value. Any rows present get dropped along with the table.
--
-- m's other picks worth marking inline:
-- Q2 (2026-05-29): view_type lives INSIDE filter_json, not as a
-- top-level column with a CHECK constraint. Keeps the
-- schema lean — the renderer parses the JSON anyway.
-- Q9 (2026-05-29): is_default_for column dropped entirely. MRU
-- (last_used_at) replaces the per-page-default model.
-- Q11 (2026-05-29): graph stays outside the views enum; no graph
-- view_type ever lands in filter_json.
DROP TABLE IF EXISTS projax.views CASCADE;
CREATE TABLE projax.views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- URL-routable identifier. Application-layer validator enforces the
-- regex `^[a-z0-9][a-z0-9-]{0,62}$` + a reserved-slug list (system
-- slugs + top-level route segments). Globally unique — single-user
-- v1; no user_id prefix.
slug text NOT NULL,
-- Display name. Free-form; user picks whatever language they think in.
-- Rendered verbatim in the sidebar.
name text NOT NULL,
-- Frontend icon-registry key. NULL → default folder glyph. Length cap
-- keeps stored value sane even if the registry is bypassed.
icon text,
-- Canonical view definition. Includes view_type (per m's Q2 pick),
-- plus the standard TreeFilter dimensions (q, tags, management, …),
-- plus optional sort/group hints. Renderer parses the JSON; the DB
-- never has to look inside.
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Sort + grouping hints used by the renderers (list/card/kanban).
-- Kept as top-level columns so the editor can index them quickly,
-- though they're conceptually part of the render spec.
sort_field text,
sort_dir text,
group_by text,
-- Sidebar ordering. Server-assigned MAX+1 on create so two parallel
-- inserts don't collide. Drag-reorder UI lands in slice G; this
-- column is wired now so the data shape is stable.
sort_order integer NOT NULL DEFAULT 0,
-- Opt-in count badge on the sidebar entry. Defaults false so casual
-- views don't pay the COUNT(*) cost.
show_count boolean NOT NULL DEFAULT false,
-- MRU landing on /views — `handleViewsLanding` 302s here when set.
-- Touched fire-and-forget on every render.
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT views_sort_dir_chk
CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
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);
CREATE INDEX views_last_used_idx ON projax.views (last_used_at DESC NULLS LAST);
-- updated_at trigger. Re-created here (CREATE OR REPLACE on the function)
-- because 0016 dropped with CASCADE above.
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();
DO $own$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'projax_admin') THEN
EXECUTE 'ALTER TABLE projax.views OWNER TO projax_admin';
EXECUTE 'ALTER FUNCTION projax.views_touch_updated_at() OWNER TO projax_admin';
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON projax.views TO projax_admin';
END IF;
END $own$;

View File

@@ -0,0 +1,434 @@
# mBrian-as-backend migration — Phase 6 design
**Status**: Phase A design — re-baselined against live mBrian schema (2026-05-29).
**Branch**: `mai/kahn/phase-6a-mbrian-design`.
**Author**: kahn (inventor), 2026-05-29.
**Source decision** (m, issue m/projax#5, 12:43 2026-05-29): Option A — full backend migration. *"I think we need the project-management element inside of mBrian for it to be the complete 2nd Brain experience. The data itself is not too important yet."*
**m's overriding directive** (2026-05-29 via head): *"keep the database simple so it remains easily modifiable."*
**Constraint**: data-loss tolerant on the 47 current `projax.items`.
**m's answers on §10 (2026-05-29)**: every inventor pick confirmed.
> Q1=reuse 'project' / Q2=(b) handler bridge / Q3=(a) clients projax-side / Q4=(a) file Gitea on m/mBrian via otto/head — m: *"mbrian must own the migration"* / Q5=(a) views stay projax-resident / Q6=(a) per-user slug / Q7=(a) hard-cut / Q8=(a) tags in metadata / Q9=(a) projax-side cycle detection / Q10=(a) keep projax MCP via adapter / Q11=keep `projax_origin` audit metadata.
**Re-baseline note**: §3's original ask was built off a stale `db/001_initial_schema.sql` read. Head verified the live mBrian schema after m's answers. Three of the six asks (MB-A, MB-C, MB-D) turned out already-satisfied — `edges.metadata` exists since `db/010_flexsiebels_compat.sql`, `'project'` type exists since `db/033`, the per-user slug unique index ships in `db/001`. The remaining mBrian-side artifact is small. §3 + §8 now reflect that. The big shift: **mBrian owns the one-shot data-migration script** — that's what "mbrian must own the migration" means — while projax owns the read+write rewiring on its own side afterward.
---
## §1 — Diagnosis
projax today stores its own structured data in `projax.items` + `projax.item_links` (msupabase, schema `projax`). It's a parallel knowledge surface to mBrian's main graph — both store nodes-with-content-and-edges, both speak SQL+jsonb, both ship MCP. The duplication has cost: project context (held by projax) is invisible to mBrian's reasoning paths; mBrian's relationship graph (held by mbrian) is invisible to projax's tile / timeline aggregations.
m's call closes the gap by making mBrian canonical. Projax keeps its UI — the /views routes, the Tiles dashboard, the calendar grid, the timeline spine, the /tree forest, the just-shipped /views/{slug} family, and the system-view chrome — but every read and write goes through mBrian instead of `projax.items`. Same surface, single source.
End-state contract:
- One node graph. Every project, task-context, area, link bundle lives in `mbrian.nodes` + `mbrian.edges`.
- projax's UI is a structured editor + aggregation surface over that graph (think paliad-shape views, mBrian-shape data).
- mBrian's existing surfaces (the web editor, the trackers, the synthesis filings) keep working unchanged — projax data appears alongside everything else.
- CalDAV / Gitea / mai.projects integrations stay projax-handled at the consumption layer; the items they hang off of live in mBrian.
- The 47-item migration is one-shot. Anything lossy gets logged + flagged for manual repair; we don't preserve at all costs.
---
## §2 — Schema mapping (the load-bearing section)
### Per-column map: `projax.items` → mBrian shape
| projax column | mBrian destination | notes |
|---|---|---|
| `id` (uuid) | `nodes.id` | new uuids on migration; legacy ids never round-trip |
| `kind` (text[]) | `nodes.type` | direct shape match; projax `'project'` becomes mBrian `'project'` (already in live schema, mig 033). **Areas keep `type=['project']` + `metadata.projax.kind='area'`** — per m's "keep the database simple" directive, no new mBrian type. Zero DDL. |
| `title` | `nodes.title` | 1:1 |
| `slug` | `nodes.slug` | mBrian = unique per user; projax = unique per parent — see §2.1 |
| `paths` (text[]) | derived from `child_of` edges + `nodes.path` cache | DAG resolution via edge walk; see §2.2 |
| `parent_ids` (uuid[]) | edges `(source=this, rel='child_of', target=parent)` | one edge per parent; preserves multi-parent |
| `content_md` | `nodes.content_md` | 1:1 |
| `aliases` (text[]) | `nodes.aliases` | 1:1 |
| `metadata` (jsonb) | `nodes.metadata` | merge; projax metadata keeps its existing shape under a `projax` sub-key to avoid colliding with mBrian's metadata schema |
| `status` (text) | `nodes.metadata.projax.status` | active/done/archived; mBrian's `archived` bool covers part of it but loses the active/done split |
| `pinned` (bool) | `nodes.pinned` | 1:1 |
| `archived` (bool) | `nodes.archived` | 1:1; status='archived' implies this too |
| `start_time`, `end_time` (timestamptz) | `nodes.metadata.projax.start_time` / `end_time` | mBrian has no first-class start/end |
| `tags` (text[]) | `nodes.metadata.projax.tags` | mBrian convention puts tags as separate `[tag]` nodes joined via `tagged` edges; we keep tags in metadata for the migration window then optionally re-shape — see Q8 |
| `management` (text[]) | `nodes.metadata.projax.management` | mai/self/external/unmanaged — projax-specific concept; stays in metadata |
| `public`, `public_description`, `public_live_url`, `public_source_url`, `public_screenshots` | `nodes.metadata.projax.public.{...}` | mBrian's `visibility` is a different model (personal/public/...); we keep projax's bundle in metadata so the flexsiebels portfolio renderer keeps working |
| `timeline_exclude` (text[]) | `nodes.metadata.projax.timeline_exclude` | projax-only concept |
| `created_at` | `nodes.created_at` | 1:1 |
| `updated_at` | `nodes.updated_at` | trigger-maintained on both sides |
| `deleted_at` | `nodes.deleted_at` | 1:1 |
### §2.1 — Slug uniqueness (settled)
projax today enforces slug uniqueness **per parent**. mBrian's live schema has `CREATE UNIQUE INDEX idx_nodes_slug ON mbrian.nodes (user_id, slug)` — uniqueness **per user**. Per m's Q6=(a), projax adopts mBrian's model: one `paliad` node total, connected to both `dev` and `work` via two `child_of` edges. The DAG-as-multiple-paths view is a render-time concept; the storage is one node.
projax handlers' itemwrite validator (Phase 5c) loses its per-parent slug rule, gains a per-user check (against the projax-managed subset of nodes). This is **stricter** — m can't have two different "paliad" projects under different roots. Settled per m's answer.
**Pre-migration dedup**: the 47-item migration script (which lives mBrian-side, see §3+§7) scans for slug collisions across the projax dataset and folds collisions into one node with multiple `child_of` edges. Skip-with-log on anything weirder.
### §2.2 — paths array vs single path
projax's `paths text[]` is computed from `parent_ids` (one path per ancestor lineage). mBrian's `path text` is a single denormalized cache; the canonical structure is `child_of` edges.
For projax UI to keep showing multi-paths ("Also at: work.paliad"), the store-adapter layer (§4) re-derives `paths[]` from the edge graph on each fetch. Cheap at m's scale (≤200 nodes); cache lightly if profiling bites.
### `projax.item_links` → mBrian edges
Each `item_links` row becomes a mBrian edge with a typed `rel`. The `ref_id` semantics differ:
| projax ref_type | mBrian shape | notes |
|---|---|---|
| `caldav-list` | edge `rel='projax-caldav-list'`, `metadata.url=...` | external URL — no target node exists; edge carries the URL in `note` or `metadata` |
| `gitea-repo` | edge `rel='projax-gitea-repo'`, metadata={owner, repo} | same shape |
| `gitea-issue` | edge `rel='projax-gitea-issue'`, metadata={owner, repo, number} | same |
| `mai-project` | edge `rel='projax-mai-project'`, metadata={mai_project_id} | bridge for the Phase 1.5 bidirectional sync |
| `mbrian-node` | edge `(source=this, rel='related_to', target=<mbrian uuid>)` | already mBrian — this becomes a regular node-to-node edge |
| `url` | edge `rel='projax-url'`, metadata={url} | unstructured link |
| `document`, `note` | edge `rel='projax-doc'`, metadata={...} | PER day-granular dated artifacts |
mBrian edges support `note text` plus an `auto bool` flag. Both used by projax: `auto=false` for human-added links, `note` carries human annotation. The structured payload (URL, repo info, etc.) lands in a metadata jsonb that we add via a new `edges.metadata` column — see §3.
### Open question on edge payloads
mBrian's `edges` table today has no `metadata jsonb` column — only `rel`, `note`, `sort_order`, `node_id`, `auto`. For projax's typed external-ref payloads (caldav URLs, gitea repo names), we need either:
- (a) Add `metadata jsonb` to `mbrian.edges` (mBrian-side schema work, see §3 Q-A).
- (b) Use the `node_id` "complex edge" feature: the edge points at a third node that holds the metadata. Heavier per-link cost; one node per external ref.
- (c) Stash structured payload inside `note text` as JSON. Hacky; loses index-ability.
**Inventor pick: (a)** — adds one nullable column to `edges`, indexes optionally, keeps the simple shape and matches projax's existing item_links model.
---
## §3 — mBrian-side requirements (re-baselined against live schema)
Head verified the live mBrian schema after m's answers. Three of the original six asks turned out already-satisfied. What's actually needed reduces to one [schema] convention node + ownership of the one-shot data-migration script. Per m's Q4=(a), this lands as a Gitea issue on `m/mBrian` with the "blocks projax phase 6" tag; head files it.
### Already satisfied (no DDL needed)
| original ask | live-schema status |
|---|---|
| MB-A — `edges.metadata jsonb` column | **Already exists** — added in `db/010_flexsiebels_compat.sql`: `ALTER TABLE mbrian.edges ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'` plus GIN `idx_edges_metadata`. Already used by mig 039/040. projax link payloads land here directly. |
| MB-C — `'project'` type registration | **Already exists** — confirmed in `db/033` + inbox tests. m's Q1=(a) reuses it. |
| MB-C — `'area'` type registration | **NOT needed** — per m's "keep the database simple," areas reuse `type=['project']` with `metadata.projax.kind='area'`. Zero DDL. |
| MB-D — per-user slug uniqueness | **Already enforced**`CREATE UNIQUE INDEX idx_nodes_slug ON mbrian.nodes (user_id, slug)` in `db/001`. Handles the bulk migration as-is, modulo the pre-write dedup pass in the script (§7). |
| MB-E — read MCP coverage | **Confirmed** by head — type-array filter, edge query by `rel` + source/target, FTS search all present in mBrian's MCP today. Optional bulk "node + outbound edges" endpoint may improve adapter perf, but v1 ships without it. |
| MB-F — write MCP coverage | **Confirmed** by head — create_node, update_node, soft-delete, create_edge, delete_edge all present. |
### Remaining mBrian-side artifact
**MB-B — projax-integration `[schema]` convention node.** One new mBrian node, no DDL. Lives under a new `[topic]` hub `projax-integration`. Documents:
1. The projax edge relations: `child_of` (already in use everywhere), `projax-caldav-list`, `projax-gitea-repo`, `projax-gitea-issue`, `projax-mai-project`, `projax-url`, `projax-doc`. Each entry: rel name + the metadata jsonb shape (e.g. `projax-caldav-list` carries `{url: text}`).
2. The projax type usage: `'project'` for both projects and areas; `metadata.projax.kind` distinguishes (`area` vs default `project`). `'mai-managed'` as a co-type marker for nodes mirroring `mai.projects` rows.
3. The projax metadata shape: `metadata.projax.{status, tags, management, public, timeline_exclude, start_time, end_time, kind}` — the subset of projax columns that don't have a first-class mBrian counterpart.
4. A pointer to `projax_origin` audit metadata (set per migrated node, per m's Q11=keep).
mBrian-side coder writes this node by creating it via mBrian's editor or MCP. No migration file needed.
### mBrian owns the data-migration script
Per m's directive "mbrian must own the migration," the one-shot script that creates the 47 nodes + their edges lives in `m/mBrian` (likely `scripts/migrate-from-projax.ts` or similar — mBrian's stack picks). projax-side provides:
- A frozen snapshot of `projax.items` + `projax.item_links` rows (CSV or JSON dump produced by a projax-side helper).
- The mapping rules from §2 + §2.2 in a form mBrian-side can implement against (this plan doc is the canonical source).
- A spot-check checklist (5 representative items) for post-migration validation.
The script's blast radius lives on mBrian's side; projax-side blocks on its successful run before slice C kicks off.
### Cross-repo coordination shape
One Gitea issue on `m/mBrian` (filed by head), tagged "blocks projax phase 6". The issue body covers MB-B + script ownership + the snapshot-handoff protocol. Body draft delivered to head with this re-baseline (see Phase A workflow §14).
---
## §4 — projax-side read-path replacement
The store package becomes a thin adapter over mBrian. Consumers stay shape-stable: `*store.Item` still exposes Kind / Title / Slug / Paths / ParentIDs / ContentMD / Aliases / Metadata / Status / Pinned / Archived / Tags / Management / Public* / TimelineExclude / etc. Internally those come from mBrian nodes + metadata + edge-walks.
| projax call site | new implementation |
|---|---|
| `store.Store.ListAll(ctx)` | mBrian: `SELECT FROM mbrian.nodes WHERE 'projax' = ANY(metadata.projax_origin) ... ORDER BY title` (or via MCP `list_nodes`). Returns []*Item adapted from each node. |
| `store.Store.GetByPath(ctx, path)` | resolve path → leaf node by walking `child_of` edges from the path's root segment; cache hits during render |
| `store.Store.GetByID(ctx, id)` | direct mBrian fetch |
| `store.Store.LinksByRefType(ctx, t)` | edge query `rel='projax-<t>'` over all projax-managed nodes |
| `store.Store.AllTags(ctx)` | aggregate over `metadata.projax.tags` arrays across projax nodes |
| `store.Store.MaiOrphans(ctx)` | mBrian: find projax-managed nodes with no `child_of` edge + `metadata.projax.management contains 'mai'` |
| `store.Store.DatedLinks(ctx, id)` | edge query `rel IN ('projax-doc', 'projax-url')` for the node, filtered to those with `metadata.event_date` set |
The aggregator (`internal/aggregate/`) doesn't see mBrian — it gets `[]*store.Item` from the adapter. CalDAV + Gitea external fetches stay where they are.
Views (Phase 5j `projax.views` table) decision point — see Q5.
### Adapter layer surface
```go
package store
type Store struct {
mb *mbrian.Client // MCP-style client or direct SQL
}
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) { ... }
// every existing method keeps its signature; bodies rewrite to mBrian calls
```
The Item struct stays unchanged. Tests against the adapter assert "given this mBrian state, ListAll returns these items". Existing aggregator + handler tests stay green because they only see `*Item`.
---
## §5 — projax-side write-path replacement
Every projax write rewires to mBrian.
| projax handler | new behaviour |
|---|---|
| `POST /i/{path}` (detail edit, `handleDetailWrite`) | mBrian update_node + edge re-write for `parent_ids` changes |
| `POST /new` (`handleNewSubmit`) | mBrian create_node + `child_of` edges |
| `POST /i/{path}/reparent` (`handleReparent`) | edge delete + re-create for `child_of` |
| `/admin/bulk` (`handleBulkApply`, `handleBulkChip`) | bulk mBrian updates; one mBrian write per row |
| `/admin/classify` (`handleClassify`) | mBrian update + add `child_of` edge |
| `POST /views/...` (5j editor) | unchanged if views stay in `projax.views`; rewired if they move (Q5) |
| MCP `create_item` / `update_item` / `delete_item` | mBrian MCP create / update / soft_delete |
| MCP `add_link` / `remove_link` | mBrian create_edge / delete_edge |
### Validation (Phase 5c itemwrite package)
The pre-flight validator stays as projax-handler logic — projax UI / MCP still surface friendly errors for `KindInvalidSlugFormat` / `KindSlugCollision` / `KindCycle` / etc. before round-tripping. The DB-level enforcement moves to mBrian's per-user unique index on slug (covers collision) + projax's `paths` recomputation (covers cycle detection). Trigger-level cycle detection on mBrian's edges is a mBrian-side ask (mb-G optional).
### Cycle + slug-collision semantics
Per §2.1: projax loses per-parent slug uniqueness; per-user uniqueness wins. The validator's KindSlugCollision rule needs updating to reject any duplicate slug across the whole projax-managed set, not just under the same parent.
Cycle detection: projax today does it via the path trigger (cycle = self-ancestor). After migration, projax fetches all projax nodes + their child_of edges, walks the closure on every write, rejects cycles. Cheap at m's scale.
---
## §6 — Integrations (CalDAV / Gitea / mai.projects)
### CalDAV + Gitea
The link bundle (per §2.2) moves to mBrian edges with structured metadata. The CalDAV / Gitea **clients** + their caches stay projax-side (the aggregator owns these). The render path queries mBrian for "which items have caldav-list edges + what URLs," then fans out to the existing CalDAV client. Net effect: the fan-out stays where it is; only the source of "what to fan out for" changes.
### mai.projects bidirectional sync (Phase 1.5)
The Phase 1.5 trigger pair (mai.projects ↔ projax.items) is the most fragile piece of the integration today. After Phase 6:
- (a) **Keep the trigger pair**, pointing the mai.projects view at the migrated mBrian nodes. Requires rewriting the trigger functions to read from mBrian; significant complexity because mai.projects expects projax.items columns.
- (b) **Move the bridge to projax handler layer**: a sync worker watches mai.projects changes + writes mBrian; mBrian node changes flow back via a webhook or periodic poll. Slower but decoupled.
- (c) **Drop the bridge entirely**: mai.projects becomes legacy; mai workers consume mBrian directly via MCP. Cleanest, but requires mai-side work to migrate workers/tasks/sessions FKs.
**Inventor pick: (b)** — the bridge stays operational without bleeding mBrian schema details into mai.projects code, and m can sunset it gradually. (c) is the right long-term shape but it's another migration project; out of scope for Phase 6.
This is **Q2** for m.
---
## §7 — Migration mechanics (mBrian-owned)
Per m's Q7=(a) hard-cut + Q4=(a) "mbrian must own the migration": the one-shot script lives in `m/mBrian`. projax-side provides the input snapshot + the rules in this doc; mBrian-side owns the execution.
### projax-side input snapshot
A helper command in `cmd/projax-snapshot/main.go` produces a `projax_snapshot.json` containing every live `projax.items` row + every `projax.item_links` row, shaped for direct consumption by the mBrian-side script. One file, deterministic, round-trippable. Ships in slice 0 (the snapshot handoff, see §8).
### mBrian-side script outline (for the m/mBrian issue body)
1. Load `projax_snapshot.json`.
2. Two-pass: pass 1 creates every node; pass 2 writes every edge (parent edges + item_links → projax-* edges).
3. For each item:
a. New mBrian uuid OR preserve the projax uuid (mBrian-side picks; either works given m's Q11 audit metadata is the durable reference).
b. INSERT into `mbrian.nodes` with `type=['project']` (or `['project']` + co-type per `kind`), `title`, `slug`, `aliases`, `metadata={projax: {...}, projax_origin: <old_id>}`.
c. Where projax had multiple paths (same node under multiple parents), DEDUPE by slug — one node, multiple `child_of` edges.
4. For each parent edge: INSERT `mbrian.edges (source=new_id, target=parent_new_id, rel='child_of')`.
5. For each item_links row: INSERT `mbrian.edges` with `rel='projax-<ref_type>'` and `metadata` carrying the structured payload per §2.2.
6. For projax.views (5j): NOT migrated — per m's Q5=(a), the views table stays projax-resident.
7. Smoke check: count(mbrian.nodes WHERE metadata->>'projax_origin' is not null) == count(items in snapshot).
8. Hand off to projax with the new uuid map (`{old_uuid: new_uuid}`) so projax-side caches can warm.
### Idempotency
Pre-flight: the script checks `metadata.projax_origin` and skips already-migrated origins on re-run. m can re-run safely if the script aborts mid-way.
### Lossy bits (acceptable per m's stance)
- `paths text[]` array is not preserved — projax-side adapter recomputes from edges per §4.
- mai.projects mirror rows: per Q2=(b), a handler-layer bridge worker re-syncs after migration; the Phase 1.5 trigger pair stays disabled.
### Blast-radius containment
mBrian-side runs the script with triggers paused, smoke-checks the count + spot-checks the 5 representative items in projax's checklist, then commits + signals projax-side to start slice C (read-path).
---
## §8 — Implementation slicing (re-baselined)
Six slices. The big shift from the original draft: the mBrian-side ask compresses to one [schema] convention node + one migration script (both mBrian-owned per m's Q4). Slice 0 is a small projax-side helper that ships the snapshot. The hard gate is the migration landing — projax-side B reads it as the trigger to start.
- **0. projax-side snapshot helper** — `cmd/projax-snapshot/main.go`. Dumps live `projax.items` + `projax.item_links` to `projax_snapshot.json`. Ships first; minimal risk; deliverable mBrian needs.
- **A. mBrian-side: [schema] convention node + data-migration script** — m/mBrian owns. The [schema] node lives under a new `[topic]` hub `projax-integration`. The script consumes the snapshot from slice 0 and writes 47ish nodes + their edges per §7. mBrian-side post-flight: smoke-check count + spot-check 5 items per the projax checklist.
- **B. projax-side read-path adapter** — projax-side. `store/` package rewired against mBrian's MCP / SQL surface. The `Item` struct stays; method bodies rewrite. All UI + aggregator tests stay green (they only see Item shape). Per-request snapshot cache to avoid N+1 calls. Reads-only soak before slice C.
- **C. projax-side write-path** — projax-side. Every handler + MCP write rewires through the adapter to mBrian. itemwrite validator updates for the per-user slug rule (Q6). Cycle detection on the in-memory closure (Q9).
- **D. mai.projects bridge worker** — projax-side (Q2=(b)). Disable the Phase 1.5 trigger pair; ship a small worker that observes mai.projects writes + reflects them into mBrian, and vice versa. Decoupled, killable.
- **E. Drop `projax.items` + `projax.item_links`** — projax-side. Migration `0018_drop_projax_items.sql`. Triggers off after one shift's stable read+write soak on mBrian. `projax.views` stays (Q5).
Dependency graph:
```
0 (projax snapshot) ──→ A (mBrian [schema] node + migration script run)
B (projax read-path) ──→ C (projax write-path)
├──→ D (mai bridge worker)
E (drop projax tables)
```
Slice 0 unblocks A. A is mBrian-owned and the hard gate for everything else. B → C can ship together if green; otherwise B-first soak.
CalDAV / Gitea integrations stay where they are (Q3=(a)) — no slice F needed in the original sense.
---
## §9 — Cross-repo coordination (settled)
Per m's Q4=(a) + his words *"mbrian must own the migration"*:
1. **Protocol**: file a Gitea issue on `m/mBrian` with "blocks projax phase 6" tag. Routed via otto/head per global Channel Routing. Head files it; kahn drafts the body.
2. **Ownership split**:
- mBrian-side owns: the `[schema]` convention node (MB-B) + the one-shot data-migration script.
- projax-side owns: the snapshot helper (slice 0), the read-path adapter (slice B), the write-path (slice C), the mai bridge (slice D), the table drop (slice E).
3. **Sequencing**: slice 0 produces the snapshot → mBrian-side A consumes it + runs the migration → mBrian-side signals back → projax-side starts B. The Gitea issue is the durable trace; the delegation reply chain is the real-time signal.
4. **Design-doc sharing**: this plan stays in `m/projax`. The m/mBrian issue body (drafted alongside this re-baseline, delivered to head) excerpts §2 (schema mapping), §3 (the one [schema] node ask), §7 (the script outline), and the spot-check checklist.
---
## §10 — Open questions (all answered 2026-05-29)
All 11 questions resolved. m confirmed every inventor pick. Section retained as the historical record + so a future hand can audit the decision rationale.
The 8 from issue #5 plus what surfaced during this survey.
**Q1 — mBrian node type for projax items**
- (a) Reuse existing `'project'` type, add `'area'` if missing, multi-typed for both. — **inventor pick** (existing type minimises mBrian-side churn).
- (b) New dedicated `'projax-item'` / `'work-item'` type.
**Q2 — mai.projects bidirectional sync disposition** (§6)
- (a) Keep the trigger pair (rewrite to read from mBrian).
- (b) Move to projax handler-layer bridge worker. — **inventor pick** (clean decoupling).
- (c) Drop entirely; migrate mai-side FKs.
**Q3 — CalDAV + Gitea integration ownership** (§6)
- (a) Clients + caches stay projax-side; only the "which items have these links" lookup moves to mBrian. — **inventor pick** (minimal change to aggregator).
- (b) Migrate CalDAV/Gitea ownership to mBrian edges + projax becomes a pure renderer.
**Q4 — mBrian head contact protocol** (§9)
- (a) Through otto/head per Channel Routing (default per global rule). — **inventor pick**.
- (b) Direct to a future mBrian/head worker.
- (c) m himself owns mBrian schema work — file Gitea issue on m/mBrian.
**Q5 — projax.views (5j) disposition**
- (a) Keep as projax-resident table — views are projax-UI state, not graph data. — **inventor pick**.
- (b) Migrate to mBrian nodes with type=`[view]`; one node per saved view.
- (c) Drop the table; user views become a derived shape from mBrian metadata on the items themselves.
**Q6 — Slug uniqueness model**
- (a) Adopt mBrian's per-user unique (loses "two paliads under different roots" case). — **inventor pick** (simpler; m hasn't used the per-parent split in practice).
- (b) Keep projax's per-parent rule via projax-handler validator + mBrian per-user check disabled for projax nodes (requires mBrian-side scoped-uniqueness work).
**Q7 — Migration mechanics** (§7)
- (a) Hard-cut, one script, accept data loss. — **inventor pick** (matches m's stance).
- (b) Phased dual-write + soak.
**Q8 — Tags model**
- (a) Keep tags in `metadata.projax.tags` (projax sees them as before; mBrian doesn't index them). — **inventor pick** for v1.
- (b) Lift each tag to a `[tag]` node + `tagged` edges (mBrian convention).
- (c) Hybrid — keep metadata for projax compatibility AND wire tagged-edges for mBrian visibility.
Q8(c) is the "right" long-term shape but doubles the write surface in slice D. Recommend deferring to a Phase 7 polish.
**Q9 — Cycle detection placement**
- (a) projax-handler-side via in-memory closure walk before write. — **inventor pick** (cheap at m's scale).
- (b) mBrian-side via trigger on `edges` (mb-G ask).
**Q10 — Projax MCP surface**
- (a) Keep projax MCP tools (`mcp__projax__*`); they now route through the adapter. — **inventor pick** (no MCP client change).
- (b) Sunset projax MCP; users call mBrian MCP directly.
**Q11 — `projax_origin` audit metadata** (§7)
Per the migration script, every migrated node carries `metadata.projax_origin = <old uuid>`. Keep indefinitely (audit trail), purge after one shift (cleanup), or never write it (trust). **Inventor pick**: keep indefinitely.
---
## §11 — Risk register
| risk | likelihood | mitigation |
|---|---|---|
| mBrian-side schema work (slice A) blocks projax indefinitely | medium | clear delegation + Gitea issue with "blocks projax phase 6" tag; m can dispatch fast-track |
| 47-item migration script silently drops fields | low | smoke check (item count parity) + spot-check 5 items post-migration before slice C |
| Slug collision on multi-rooted items (e.g. two `paliad`s) | medium | pre-migration script: detect collisions, dedupe to one node with multiple `child_of` edges, log skips |
| mai.projects trigger pair breaks mid-migration | medium | turn off the triggers before migration, rebuild post-migration (Q2 (b) bridge takes over) |
| Adapter introduces N+1 mBrian calls during render | medium | one ListAll + one LinksByRef query per request, cached per-request; profile after slice C |
| Phase 5j views surface breaks | low | views stay projax-resident per inventor pick on Q5; no migration cost |
| flexsiebels.de public-listing renderer breaks | medium | metadata.projax.public.* bundle preserves the shape; spot-test before slice E |
| Cross-repo coordination delay | medium | filed as Gitea issue (durable) + delegation (real-time signal); both paths active |
---
## §12 — Test plan headlines
### Slice B (migration script)
- `TestMigrateScriptSmokes` — 5 hand-crafted projax.items + 3 item_links → mBrian nodes + edges; count parity assertion.
- `TestMigrateScriptIdempotent` — second run = no new nodes.
- `TestMigrateScriptSlugCollision` — two multi-rooted items same slug → one node with two `child_of` edges, log entry.
### Slice C (read-path)
- `TestAdapterListAllReturnsItemsFromMBrian` — seed mBrian nodes with `projax_origin`, ListAll returns matching Items.
- `TestAdapterGetByPathResolvesEdges``dev.paliad` walks `child_of` edges to leaf node.
- `TestAdapterPathsArrayMultiRoot` — node with two `child_of` edges produces 2 entries in `it.Paths`.
### Slice D (write-path)
- `TestHandleDetailWriteUpdatesMBrian` — POST /i/dev.paliad updates the mBrian node's title.
- `TestHandleReparentRewritesChildOf` — POST /i/dev.paliad/reparent deletes old edge + creates new one.
- `TestSlugCollisionRejected` — second create with same slug rejected with KindSlugCollision.
### Slice E (drop)
- migration `0018_drop_projax_items.sql` smoke test: `\dt projax.*` returns only `projax.views` + `projax.schema_migrations`.
### Slice F (integrations)
- per Q2 answer — bridge-worker test (Option b) OR mai-FK migration test (Option c).
---
## §13 — References
- `~/dev/mBrian/db/001_initial_schema.sql` — mBrian schema baseline.
- `~/dev/mBrian/docs/schema.md` — schema doc.
- `~/dev/mBrian/CLAUDE.md` — mBrian conventions + relation to flexsiebels.
- `projax/store/store.go` — current Item struct + projax store API.
- `projax/store/views.go` — Phase 5j views table.
- `projax/docs/design.md` — current PRD.
- `projax/docs/plans/views-redesign.md` — Phase 5j design.
- `m/projax` issue #5 — m's Option A pick.
---
## §14 — Status
- **Phase A (this doc)**: drafted by kahn 2026-05-29, re-baselined same day against live mBrian schema after m's 11 answers landed. All §10 questions resolved.
- **m/mBrian Gitea issue**: body drafted; head files it under "blocks projax phase 6" tag.
- **Phase B (projax-side coder)**: blocked on (1) slice 0 snapshot helper ships + (2) mBrian-side migration runs + signals back. NO coder flip yet.
- **Slice 0 (projax-side snapshot helper)**: scoped, not yet built. Smallest first-step on projax-side; ready when head greenlights.
- **No code changes** in this branch beyond this doc.

View File

@@ -0,0 +1,228 @@
# Phase 6 Slice B — read-path adapter contract
**Status**: prep work (this doc). No implementation.
**Branch**: `mai/kahn/phase-6-sliceB-prep`.
**Author**: kahn (coder, prep mode), 2026-05-29.
**Parent plan**: `docs/plans/mbrian-backend-migration.md` (on `main`).
**Scope boundary**: contract + compile-checking skeleton only. The mBrian-backed implementation waits on m/mBrian#73 landing the migration + handing over the uuid map.
---
## §1 — Consumer inventory
Every read-path call site against `*store.Store` and the projax-shaped `Item` / `ItemLink` types. The interface (§2) is the union of these.
### §1.1 — `*store.Store` read methods (source: `store/store.go`)
| method | signature | semantics |
|---|---|---|
| `ListAll` | `(ctx) ([]*Item, error)` | every live item, ordered by `paths NULLS FIRST, slug` |
| `GetByID` | `(ctx, id) (*Item, error)` | single item by uuid |
| `GetByPath` | `(ctx, path) (*Item, error)` | resolve `dev.paliad` style path to leaf item |
| `GetByPathOrSlug` | `(ctx, key) (*Item, error)` | path first, fall back to bare slug |
| `Roots` | `(ctx) ([]*Item, error)` | items with `cardinality(parent_ids) = 0` |
| `MaiOrphans` | `(ctx) ([]*Item, error)` | mai-managed root items needing classify |
| `ListByFilters` | `(ctx, SearchFilters) ([]*Item, error)` | structured search (status / mgmt / has-link / paths-prefix) |
| `Search` | `(ctx, q, limit) ([]*Item, error)` | trigram + FTS title/content/aliases |
| `AllTags` | `(ctx) ([]string, error)` | union of every item's tags |
| `LinksByType` | `(ctx, itemID, refType) ([]*ItemLink, error)` | one item's links of a given `ref_type` (empty = all) |
| `LinksByRefType` | `(ctx, refType) ([]*ItemLink, error)` | every link of a given ref_type across items |
| `DatedLinks` | `(ctx, itemID) ([]*ItemLink, error)` | one item's links anchored to a date (PER artifacts) |
| `DatedLinksRange` | `(ctx, from, to) ([]*ItemLinkWithItem, error)` | dated links within window, joined with their item |
| `RecentDocuments` | `(ctx, since, limit) ([]*ItemLinkWithItem, error)` | recent dated docs, joined with their item |
| `ItemsCreatedInRange` | `(ctx, from, to) ([]*Item, error)` | items created within window |
### §1.2 — Consumer call sites (by file)
Each row = one read-path call site. Direct Pool access (admin.go counts, bulk.go filter-tx, links.go event-date update) is flagged separately at the bottom — those rework targets are out of slice B's read-path scope.
| consumer | method | use case |
|---|---|---|
| `web/server.go handleTree` | `ListAll`, `AllTags`, `linkKindsByItem` (LinksByRefType ×N) | render /views/tree with chip-counted forest |
| `web/server.go handleDetail` | `GetByPath` ×2 (PER fallback), `LinksByType` (caldav), `DatedLinks` | render /i/{path} detail page |
| `web/server.go parentOptions` | `ListAll` | populate parent <select> on /new + /reparent |
| `web/server.go handleClassify` | `MaiOrphans`, `parentOptions` | render /admin/classify |
| `web/dashboard.go handleDashboard` | `ListAll`, `LinksByRefType` (caldav), `LinksByType` (gitea) ×N, `RecentDocuments` | Tiles + tasks + events + docs cards |
| `web/calendar.go handleCalendar` | `ListAll` | month grid scope |
| `web/timeline.go handleTimeline` + `buildTimeline` | `ListAll`, `linkKindsByItem` | chronological spine |
| `web/graph.go handleGraph` | `ListAll`, `AllTags` | DAG SVG render |
| `web/bulk.go handleBulk` | `ListAll`, `AllTags`, `GetByID` | /admin/bulk filtered checklist |
| `web/caldav.go` (admin + create/unlink) | `ListAll`, `LinksByRefType`, `LinksByType`, `GetByPath` | /admin/caldav surface |
| `web/gitea.go detailIssues` | `LinksByType` (gitea-repo) | /i/{path} issues card |
| `web/gitea_writeback.go` | `GetByPath`, `LinksByType` | issue close/comment/create handlers |
| `web/links.go` (add/remove/list) | `GetByPath`, `DatedLinks` | /i/{path} documents section |
| `web/dashboard_pin.go` | `SetPinned` — WRITE, not slice B | pin toggle (slice C) |
| `web/views.go handleViewRender` | `ListAll`, `AllTags`, `linkKindsByItem` | /views/{slug} render (5j) |
| `web/system_views.go legacyRedirect` | `GetViewByID` — views CRUD (NOT in scope) | legacy 5i uuid → 5j slug redirect |
| `internal/aggregate aggregator.go` | takes `LinkLister` interface (LinksByType + ItemsCreatedInRange) | shared fan-out across tasks/events/issues/docs |
| `mcp/tools.go` (read tools) | `ListByFilters`, `LinksByRefType`, `GetByID`, `GetByPathOrSlug`, `LinksByType`, `ListAll`, `Search`, `RecentDocuments` (via dashboard fan-out reuse) | every read-side MCP tool |
### §1.3 — Direct Pool access (out-of-scope for slice B, flagged for slice C)
These bypass the store API and pull `*pgxpool.Pool` directly. Slice C (write-path) reworks them; flagging here so slice B's interface stays minimal:
- `web/admin.go` — three count queries (`SELECT count(*) FROM projax.items WHERE …`) for the admin index. Either: (a) add `Counts(ctx) (AdminCounts, error)` to the adapter, (b) compute in-handler from `ListAll`. Adapter pick.
- `web/bulk.go handleBulkApply` — multi-row UPDATE inside a tx. Pure write; slice C.
- `web/links.go handleSetEventDate` — single UPDATE on `item_links.event_date`. Pure write; slice C.
### §1.4 — `*Item` + `*ItemLink` shape contract (consumer side)
Adapter MUST return these exact field sets in the result types. Nothing under `metadata.projax.*` in mBrian leaks to consumers; the adapter parses + materialises into the `Item` fields below.
| field | semantics in slice B adapter |
|---|---|
| `Item.ID` | mBrian node uuid (post-migration); preserved old uuid OK per Q11 |
| `Item.Kind` | `[]string{"project", ...}` — mBrian `node.type[]` 1:1 |
| `Item.Title`, `Item.Slug`, `Item.ContentMD`, `Item.Aliases` | mBrian `node.title/slug/content_md/aliases` 1:1 |
| `Item.Paths` | **derived** from `child_of` edge walk + the node's own slug. Adapter computes per-call (cached per-request) |
| `Item.ParentIDs` | **derived** from outbound `child_of` edges |
| `Item.Metadata` | `node.metadata` MINUS the `projax` sub-key (which gets unpacked into the struct fields below) |
| `Item.Status` | `node.metadata.projax.status` (default "active") |
| `Item.Pinned`, `Item.Archived` | `node.pinned`, `node.archived` 1:1 |
| `Item.StartTime`, `Item.EndTime` | `node.metadata.projax.start_time` / `.end_time` (timestamptz strings) |
| `Item.Tags`, `Item.Management`, `Item.TimelineExclude` | `node.metadata.projax.tags` / `.management` / `.timeline_exclude` |
| `Item.Public`, `Item.PublicDescription`, `Item.PublicLiveURL`, `Item.PublicSourceURL`, `Item.PublicScreenshots` | `node.metadata.projax.public.{enabled, description, live_url, source_url, screenshots}` |
| `Item.CreatedAt`, `Item.UpdatedAt` | `node.created_at`, `node.updated_at` 1:1 |
| `Item.Source` | always `"projax"` (legacy field; new adapter sets this to maintain consumer assumption) |
| `Item.SourceRefID` | mai.projects.id from `projax-mai-project` edge metadata when present |
| `ItemLink.ID` | mBrian edge uuid |
| `ItemLink.ItemID` | edge `source_id` (the projax-side end) |
| `ItemLink.RefType` | strip `projax-` prefix from edge `rel` (`projax-caldav-list``caldav-list`) |
| `ItemLink.RefID` | edge `metadata.ref_id` OR derived from rel-specific payload (caldav: `url`; gitea-repo: `owner/repo`; mai-project: `mai_project_id`) — see §3 gaps |
| `ItemLink.Rel` | edge `note` (free-form annotation) OR a constant per rel-type (e.g. `'contains'`) |
| `ItemLink.Metadata` | edge `metadata` MINUS the `ref_id` extraction |
| `ItemLink.EventDate` | edge `metadata.event_date` (date string parsed) |
| `ItemLink.CreatedAt` | edge `created_at` 1:1 |
### §1.5 — Views (Phase 5j) — explicitly NOT in slice B
Per m's Q5=(a), `projax.views` stays projax-resident. All view CRUD methods (`ListViews`, `GetView`, `GetViewByID`, `CreateView`, `UpdateView`, `DeleteView`, `TouchView`, `MostRecentView`, `ReorderViews`) stay on the existing `*Store` and are NOT part of the adapter interface. The `Server` struct uses the adapter for items+links and the existing `Store` for views.
---
## §2 — Adapter interface contract
Defined in `store/adapter.go` (this branch). Pure projax-shaped structs in/out; zero mBrian type leakage. The existing `*store.Store` already satisfies this interface (it's just a subset of its public surface) — the compile-time assertion makes that explicit. Slice B impl ships a second satisfier (`*MBrianReader`) that wraps mBrian access.
```go
// ItemReader is the read-only contract every projax UI handler / aggregator /
// MCP read tool depends on. Slice B implements a second satisfier on top of
// mBrian's MCP/SQL surface.
type ItemReader interface {
// Item lookups
ListAll(ctx context.Context) ([]*Item, error)
GetByID(ctx context.Context, id string) (*Item, error)
GetByPath(ctx context.Context, path string) (*Item, error)
GetByPathOrSlug(ctx context.Context, key string) (*Item, error)
Roots(ctx context.Context) ([]*Item, error)
MaiOrphans(ctx context.Context) ([]*Item, error)
ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error)
Search(ctx context.Context, q string, limit int) ([]*Item, error)
ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error)
AllTags(ctx context.Context) ([]string, error)
// Link lookups
LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error)
LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error)
DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error)
DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error)
RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error)
}
```
### §2.1 — Methods needing edge-walk-derived data
Slice B's mBrian impl must compute these from `child_of` edges + node fields. Cost is one outbound-edges fetch per node OR one bulk edges-by-rel query per request, depending on how the adapter caches.
- `Item.Paths` — every method returning `*Item` or `[]*Item`.
- `Item.ParentIDs` — same.
- `GetByPath` — walks edges to resolve `dev.paliad` to a leaf node.
- `Roots` — filter where no outbound `child_of` edge.
- `MaiOrphans``Roots``metadata.projax.management ⊇ {'mai'}`.
### §2.2 — Methods needing metadata-unpack
Adapter parses `metadata.projax.*` on read; writes (slice C) re-serialise. Affected fields: Status, Tags, Management, TimelineExclude, Public + 4 public_* fields, StartTime, EndTime.
### §2.3 — Methods needing edge.metadata filters
- `LinksByType(itemID, refType)`: WHERE source_id=$1 AND rel = 'projax-' || $2.
- `LinksByRefType(refType)`: WHERE rel = 'projax-' || $1.
- `DatedLinks(itemID)`: source_id=$1 AND metadata ? 'event_date'.
- `DatedLinksRange(from, to)`: metadata->>'event_date' BETWEEN $1 AND $2.
- `RecentDocuments(since, limit)`: dated links since $1 ORDER BY metadata->>'event_date' DESC LIMIT $2.
mBrian's `idx_edges_metadata` GIN index already exists (mig 010); these queries are index-eligible.
---
## §3 — Gap flags
Items the known mBrian schema needs to satisfy cleanly. The migration script handles most; flag here for the slice-B impl + the migration worker as cross-check items.
| gap | shape | status |
|---|---|---|
| **`item_links.rel` (free-form annotation) preservation** | projax has both a typed `ref_type` AND a free-form `rel` text (`"contains"`, `"source"`, etc.) on item_links. mBrian's edge `rel` is the typed name; the free-form annotation maps to `edge.note`. Migration must NOT drop the projax `rel` value. | Add to m/mBrian#73 §1 edge mapping: source `rel` → mBrian `edges.note`. |
| **`ItemLink.RefID` semantics per type** | projax `ref_id` is a typed external pointer (caldav url, gitea `owner/repo`, gitea-issue id, mai project uuid, bare url). mBrian edges carry the payload in metadata. Need a per-rel-type extraction rule. Suggested: `metadata.ref_id` for the canonical reference + leaves structured payload alongside (`url` for caldav, `owner`/`repo` for gitea). | Slice B impl reads back per-rel-type; document in m/mBrian#73 issue for the migration script to write consistently. |
| **`paths text[]` recomputation cost** | Adapter computes paths from `child_of` edge walk per call. For `ListAll` over ~65 items, one bulk edges-by-rel query joined with the node id set is N rows where N = total `child_of` edges. Cheap at m's scale; add per-request memoisation. | Slice B impl. No mBrian-side action. |
| **`AllTags` aggregation** | Union of `metadata.projax.tags[]` across all projax-managed nodes. No mBrian index on metadata-array-element. At m's scale (<200 nodes), full-scan is fine; if we grow, add a derived `[tag]` node graph (m's Q8 deferred to Phase 7). | Slice B impl, no mBrian-side action. |
| **`Roots` / `MaiOrphans` predicate** | "No outbound `child_of` edge" requires a subquery / left-join-where-null pattern. Index-eligible via `idx_edges_source_rel` on `(source_id, rel)`. | Slice B impl. |
| **`ItemsCreatedInRange`** | Direct over `nodes.created_at`; trivial. Scoped to `metadata.projax_origin IS NOT NULL` so non-projax mBrian nodes don't leak into projax surfaces. | Slice B impl + a `metadata GIN` query (already indexed). |
| **`Item.Source` field expectation** | The legacy `Source` field on `Item` reads `"projax"` everywhere consumers check it (some MCP tools branch on it). Adapter sets a constant. | Slice B impl detail, no DB action. |
| **`SourceRefID` for mai bridge** | When a node has a `projax-mai-project` edge, expose its `metadata.mai_project_id` as `Item.SourceRefID`. Slice D (mai bridge worker) writes these edges. | Slice B impl reads existing edges; slice D writes new ones. |
| **`ItemLinkWithItem` join shape** | Used by `DatedLinksRange` and `RecentDocuments`. Adapter does two queries (edges-with-dates + node-by-id batch) + an in-memory join, OR one combined MCP call if mBrian exposes a bulk-edges-with-source-node helper. Both work; pick by perf. | Slice B impl, no mBrian-side change required. |
| **Admin counts (web/admin.go direct Pool)** | Three count(*) queries (total items, total mai-managed, total public). Adapter gains `Counts(ctx) (AdminCounts, error)` small extension. | Add to ItemReader interface in slice B (low-risk; constant-return until impl) OR keep as a separate `AdminReader` interface. Recommend adding to ItemReader for cohesion. |
---
## §4 — Skeleton (this branch)
The Go file `store/adapter.go` ships in this branch with:
1. `ItemReader` interface as in §2.
2. `var _ ItemReader = (*Store)(nil)` compile-time assertion. (Drops in cleanly because `*Store` already exposes every method in the contract.)
3. `MBrianReader` struct with stubbed method bodies that return `errNotImplementedSliceB`. Each stub carries a one-line comment naming the §3 gap it depends on (if any) so slice B's impl-fill knows what to look up.
4. `var _ ItemReader = (*MBrianReader)(nil)` compile-time assertion so the stubs stay aligned with the interface.
`go build ./...` is green with the skeleton in place. No tests, no behaviour, no mBrian client dependency.
The actual mBrian client wiring (whether MCP-over-stdio, direct Postgres against `mbrian.*` schema, or the in-process submodule pattern flexsiebels uses) is the first decision slice-B-impl makes; it stays out of this prep step.
---
## §5 — Wiring shape after slice B impl
For reference of the post-slice-B shape (no code in this slice):
```go
// Server struct keeps two readers: ItemReader (slice-B mBrian-backed) +
// existing *Store (views CRUD only).
type Server struct {
Items ItemReader // slice B: MBrianReader; today: *Store
Store *store.Store // views CRUD only after slice B
// ... rest unchanged
}
```
Every handler that today reads `s.Store.ListAll(...)` becomes `s.Items.ListAll(...)`. Mechanical rename. Slice B impl ships both adapter wiring + the rename across handlers as one diff once the migration completes.
---
## §6 — What's NOT in this prep
- mBrian-MCP client wiring.
- Any test of mBrian-backed behaviour.
- Write-path methods (slice C scope).
- View CRUD migration (Q5=(a) stays projax-resident).
- mai bridge worker (slice D).
- Drop projax tables (slice E).
---
## §7 — References
- `docs/plans/mbrian-backend-migration.md` (on `main`) parent plan.
- `cmd/projax-snapshot/` (slice 0, merged at `38182df`) input for mBrian's migration.
- m/mBrian#73 mBrian-side schema convention node + migration script (in flight).
- `store/store.go` current `*Store` implementation; the interface `*Store` already satisfies.
- `internal/aggregate/aggregator.go` existing `LinkLister` interface precedent (a narrow projection of `*Store`).

View File

@@ -0,0 +1,103 @@
# Slice B gap — `projax.views.filter_json.project_id` uuid-map
**Status**: gap flagged + tooling shipped. Must run remap BEFORE Slice E
drops `projax.items`.
**Branch**: `mai/kahn/phase-6-sliceB`.
**Author**: kahn (coder), 2026-05-31.
**Parent**: `docs/plans/mbrian-backend-migration.md` (§7 lossy bits).
## The gap
Phase 5j saved views (`projax.views.filter_json`) carry `project_id` set
to the **OLD** `projax.items.id` uuid. After Slice B's read path flips
to mBrian, those uuids no longer resolve — a saved view scoped to
`dev.paliad` references an id that mBrian's `paliad` node doesn't have
(the migration script issued a fresh uuid + recorded the old id under
`metadata.projax_origin` per m's Q11).
Symptoms once `PROJAX_BACKEND=mbrian` rolls in production:
- Opening `/views/{slug}` for any saved view that carries
`filter_json.project_id` returns the unfiltered set (the scope filter
silently no-ops).
- The chip rendering still labels the view "scoped to dev.paliad"
because the cached `project_path` is unaffected — the discrepancy
surfaces as "label says scope, content doesn't filter."
This must be fixed before Slice E drops `projax.items` or the old-id
provenance disappears.
## Two viable remediations
### (a) One-shot uuid remap via the migration audit map
mBrian dropped the `{old_projax_uuid → new_mbrian_uuid}` map at a shared
mRiver path (head will relay). One-time SQL:
```sql
UPDATE projax.views
SET filter_json = jsonb_set(
filter_json,
'{project_id}',
to_jsonb(<new>::text)
)
WHERE filter_json->>'project_id' = '<old>';
```
…iterated across the map. Pure data fix, no schema change. Idempotent
(re-running against an already-remapped row is a no-op because the old
uuid no longer matches).
### (b) Resolve by slug instead of id
Change the views resolver to look up `metadata.projax_origin = <id>`
when the new uuid doesn't resolve, OR store the slug in
`filter_json.project_path` (already present) as the canonical pointer.
Heavier change in the views read path; ships in a future slice.
## Recommended path
**Hybrid**: (a) for the existing rows now (idempotent, surgical), and
flag (b) as a follow-up in slice C/D for new view writes (use slug
instead of uuid in the editor — the slug is the durable name post-
migration anyway).
## Tool shipped this slice
`cmd/projax-remap-views/main.go` (this commit). Usage:
```
projax-remap-views --map /path/to/uuid-map.json
```
Map shape:
```json
{
"old-projax-uuid-1": "new-mbrian-uuid-1",
"old-projax-uuid-2": "new-mbrian-uuid-2",
...
}
```
The tool:
- Loads the uuid map from JSON.
- Walks every live `projax.views` row.
- For each row with `filter_json.project_id` matching a map key,
rewrites it to the new uuid.
- Prints a per-row before/after summary; commit only happens on
`--apply`. Default is dry-run.
Idempotent on re-run: rows already pointing at a new uuid don't match
any map key on the second pass, so they pass through untouched.
## When to run
After the mBrian migration completes (already shipped) + after head
relays the uuid-map path. Before any user is on
`PROJAX_BACKEND=mbrian`. The tool runs against msupabase using the same
`SUPABASE_DATABASE_URL` the projax binary uses.
## Validation
Post-remap: pick the spot-check saved views (if any exist), open them
through both `PROJAX_BACKEND=store` and `PROJAX_BACKEND=mbrian`, confirm
the filtered set matches.

View File

@@ -0,0 +1,139 @@
# Phase 6 Slice C — write-path adapter contract
**Status**: implemented, branch `mai/kahn/phase-6-sliceC`, pending head review + cutover.
**Author**: kahn (coder), 2026-06-01.
**Parent plan**: `docs/plans/mbrian-backend-migration.md` (§5 write-path).
**Sibling**: `docs/plans/slice-b-adapter-contract.md` (read-path, merged).
**Write mechanism**: mBrian scoped HTTP write API — m/mBrian#73 issuecomment-10720.
---
## §1 — What slice C fixes
Slice B flipped only the reader: `PROJAX_BACKEND=mbrian` made `srv.Items`
read mBrian while writes still went to `projax.items`. A read-then-write
round-trip (create a child of an mBrian-read parent, reparent, edit) read
an mBrian uuid and then wrote it against `projax.items`, which rejected it
("parent id does not resolve to a live item"). Production was rolled back
to `PROJAX_BACKEND=store`.
Slice C adds the write twin so the env flag selects **both** reader and
writer **atomically**. There is no longer any code path where reads use
one backend and writes the other — on the web UI **or** the MCP surface.
## §2 — The write mechanism (settled by mbrian/head)
Option (c): a scoped HTTP write API on mBrian, NOT direct SQL and NOT
SECURITY DEFINER functions. mBrian's slug generation / collision
resolution / singleton logic lives in `db.ts`; reimplementing it in SQL
would drift. projax writes POST/PATCH/DELETE through the API, which reuses
`db.ts`, so projax-born nodes are byte-identical to UI/MCP/migration nodes.
`MBrianWriter` is therefore an **HTTP client**, not a pgx writer. Reads stay
direct-DB (`MBrianReader`, slice B). The asymmetry is deliberate.
Scoping guard: every node the API touches must carry
`metadata.projax_origin`; non-projax nodes (m's journal/contacts/health)
return 403. New projax nodes mint a fresh `projax_origin` uuid client-side.
Config (projax-side env): `PROJAX_MBRIAN_API_URL` + `PROJAX_MBRIAN_API_TOKEN`.
Base URL `https://mbrian.x.msbls.de`, Tailscale-only, bearer auth.
### Endpoints
| method | path | body | success |
|---|---|---|---|
| POST | `/api/projax/nodes` | `{title, content_md?, aliases?, mai_managed?, projax_origin, projax:{…}}` | 201 `{id, slug}` |
| PATCH | `/api/projax/nodes/{id}` | `{title?, content_md?, aliases?, projax?:{…partial}}` | 200 `{id, slug}` |
| DELETE | `/api/projax/nodes/{id}` | — | 204 (soft-delete) |
| POST | `/api/projax/edges` | `{source, target, rel, metadata?}` | 201/200 `{id}` |
| DELETE | `/api/projax/edges` | `{source, target, rel}` | 204 |
Errors (all `{"error": "..."}`): 400 malformed/disallowed-rel, 401 bad
token, 403 not projax-owned, 404 not found, 500 db.ts error, 503 token not
configured server-side (fail-closed → "write backend not ready").
## §3 — ItemWriter interface (`store/adapter.go`)
Extracted from `*Store`'s write surface; two satisfiers — `*Store`
(legacy) and `*MBrianWriter` (HTTP).
```
Create / Update / Reparent / AddParent / SetPublic / SetPinned /
SoftDelete / SoftDeleteCascade — item writes
AddLink / AddLinkDated / DeleteLink — link writes (projax-* self-edges)
```
`Server.Writes store.ItemWriter` is the twin of `Server.Items`. Both default
to the concrete `*Store` in `web.New`; `main.go` overrides both atomically.
### Per-method mBrian mapping
- **Create** → POST node (mint `projax_origin`), POST `child_of` edge per
parent, then read back via the reader so the returned `Item.ID` is the
live mBrian uuid + derived path (the round-trip fix).
- **Update** → PATCH node fields + `syncParents` (child_of edge diff) + read-back.
- **Reparent / AddParent** → child_of edge diff / idempotent add.
- **SetPublic** → read-modify-write the full `projax.public` object (PATCH
shallow-merge replaces the whole sub-object).
- **SoftDeleteCascade** → projax-side descendant resolution via the reader's
derived paths, then per-node DELETE (HTTP API is single-node; no tx).
- **AddLink/AddLinkDated** → POST self-edge `(source=target=item,
rel='projax-<refType>')`, payload shaped in edge metadata so the reader's
`linkFromEdge` round-trips (typed payload + `projax_rel` + `ref_id` +
`event_date` + `note`).
- **DeleteLink** → resolve `(source,target,rel)` from the edge id via a
direct read-back, then DELETE; guards against deleting >1 edge sharing
the tuple.
### Validator (`internal/itemwrite`)
`ValidateAgainstStore` already takes a `Reader` (GetByID + ListAll), which
`ItemReader` satisfies — handlers now pass `s.Items`, so cycle + collision
checks run against the live backend, not stale `projax.items`. Slug
uniqueness changed per-parent → **per-user-global** (Q6=a, matching
mBrian's `idx_nodes_slug`). Strictly tighter, so still correct on legacy.
### /admin/bulk
`applyBulk` no longer runs a raw single-tx multi-row UPDATE: make_public/
private → `SetPublic`; tag/mgmt/status/timeline-exclude → read-modify-write
via `Update`. Cross-row tx atomicity is dropped (no multi-node tx on the
HTTP API); acceptable at m's bulk-edit scale, one write path both backends.
## §4 — MCP scope correction
The handover deferred MCP read migration. That is incompatible with
atomicity once MCP **writes** move: an MCP client could `get_item` (read)
then `update_item` (write) and hit the slice-B bug on the MCP surface. So
slice C migrates the **whole** MCP item/link surface — `RegisterProjaxTools`
takes `(reader, writer, legacy *Store, agg)`; read tools use the reader,
write tools reader+writer, both flipping with `PROJAX_BACKEND`. The
`timeline` tool keeps the legacy `*Store` + aggregator (out of scope,
consistent with the web dashboard/timeline).
## §5 — Known API gaps (flagged to head; reconcile before relying on them)
Current data hits **none** of the active gaps (verified: 43 mai-project +
37 gitea-repo + 1 caldav link, all one-per-(item,ref_type); no docs/issues).
| id | severity | gap |
|---|---|---|
| **G1** | latent | Edges key on `(source,target,rel)` only. POST is idempotent on that tuple and DELETE removes by it — an item can't hold >1 link of the same ref_type (multiple dated docs, gitea-issues, calendars). 0 such cases today. Needs edge-by-id ops, or `(…,ref_id)` identity, or accept-the-limitation. `DeleteLink` refuses (doesn't multi-delete) if the case ever arises. |
| **G2** | medium | POST `/edges` has no `note` field; `AddLinkDated`'s note rides in `metadata.note`, but the reader reads `ItemLink.Note` from the `edge.note` column. Notes added post-cutover won't surface until the reader also reads `metadata.note` (or the API gains a note field). |
| **G3** | active | PATCH exposes no `pinned`/`archived`; the reader reads them from node columns. The writer captures them in `metadata.projax.{pinned,archived}` so intent isn't lost, but the dashboard star / archive toggle won't round-trip until the reader falls back to `metadata.projax` (recommended — keeps them alongside status/tags) or the API exposes the columns. |
| **G4** | minor | Create has no arbitrary top-level metadata passthrough; `CreateInput.Metadata` keys outside the projax bundle are dropped (no current caller sets them). |
## §6 — The critical test
The read→write→read round-trip the slice-B cutover missed: create a child
of an mBrian-read parent → persists → renders. This needs the live API +
DB, so it is head's cutover step (head holds the credential, sets it in
Dokploy, smoke-tests from projax). Unit tests cover the HTTP mechanics
(`store/mbrian_writer_test.go`): request construction, error mapping
(401/403/404/503), fail-closed-without-token, edge/link shaping. The reader
parity tests cover materialisation.
## §7 — Out of scope (later slices)
- Aggregator still consumes `*Store` (dashboard/timeline rollups) — slice D+.
- Views CRUD stays projax-resident (`*Store`, Q5=a) — not in ItemWriter.
- mai.projects bridge worker — slice D.
- Drop `projax.items` / `projax.item_links` — slice E.
- `web/admin.go` count queries still hit `projax.items` via Pool directly
(read-path, flagged in slice-B doc §1.3) — not slice C write scope.

View File

@@ -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=<uuid>` → 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/<slug> 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=<x>` 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** — `<select>` with the registered icon keys + a visible preview. Slice D ships the form field; the registry SVG additions can grow incrementally.
- **View type** — radio group (5 values: card/list/calendar/kanban/timeline).
- **Filter (chip strip)** — full TreeFilter chip strip inline in the editor: tag, mgmt, status, has, public, project picker + descendants toggle. Each chip click updates a hidden `filter_json` field via HTMX — so the editor's preview pane reflects the saved filter live.
- **Sort field** — text input (`title` / `updated_at` / `start_time`).
- **Sort dir** — `<select>` (asc/desc).
- **Group by** — `<select>` (status/area/tag/management). Required when view_type=kanban.
- **Show count** — checkbox.
A small "Preview" pane next to the form shows the first N items the filter currently matches. Optional in slice D; can land in slice G if scope bites.
Save → 302 to `/views/{slug}`. Cancel → `/views` (or the previous URL if HTMX-loaded).
**Drops the HTMX modal** the 5i fix-shift added — dedicated pages are clearer for a page-level concept and match paliad's pattern.
---
## §7 — Migration from 5i overlay
Specific deletions and salvages:
### Code to delete
| file | what to remove |
|---|---|
| `web/tree_filter.go` | `ViewID` field on TreeFilter; `ParseTreeFilter`/`QueryString` handling |
| `web/views.go` | `applySavedView`, `applyDefaultView`, `overlayURLFields`, `filterQueryToJSON`/`filterJSONToQuery`, the `Prefill` index handler logic |
| `web/server.go` | the `?view=<uuid>` overlay block in `handleTree`; the `DefaultBanner` data map field |
| `web/templates/tree_section.tmpl` | the `default-banner` block; the `<input type="hidden" name="view">` |
| `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=<uuid>` 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 `/<page>?view=<uuid>` to `/views/<slug>`** 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/<uuid>` 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=<uuid>` 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 `<select>` for v1 — frontend registry expansion is incremental).
- Inline chip strip inside the form; HTMX updates a hidden `filter_json` on every chip click.
- Tests: GET /views/new renders blank form; GET /views/{slug}/edit pre-fills; POST creates/updates round-trip.
### Slice E — Sidebar integration
- `templates/layout.tmpl`: insert a "Views" section between main nav and `/admin`.
- Server-side: every page-render pulls `ListViews()` into the layout data map (cached lightly so each request doesn't hit the DB twice).
- Active-state CSS + icon rendering.
- Mobile drawer (5g slice B) gets the same section.
- Tests: sidebar shows user views; clicking navigates to `/views/{slug}`; active state matches URL.
### Slice F — Migration cleanup (delete 5i overlay)
- Remove TreeFilter.ViewID.
- Remove `applySavedView`, `applyDefaultView`, `overlayURLFields`, the default-view banner.
- Remove the 5i `/views/<id>` redirect handler (slice B replaces it).
- Tests adjusted: drop the `ViewID` round-trip test; drop `TestSavedViewAppliedOnQueryParam`, `TestDefaultViewAppliedOnCleanURL`, `TestViewEditFlow` — their slice-A successors cover the new shapes.
### Slice G — Polish
- Drag-reorder UI via HTMX `hx-post="/views/reorder"` with sortable.js or a tiny vanilla drag-handle (m's HTMX-only constraint allows minimal vendored JS if needed).
- `show_count` badge wiring (run filter against `ListAll()`, render the count next to the sidebar entry).
- Preview pane in the editor (optional).
- Icon registry expansion (curated SVGs).
Slices F and G are independent. The implementation chain is **A → B → C → D → E → (F either before or after E) → G**.
---
## §9 — Open questions for head delegation
Inventor picks marked. Process: **NO direct chip-picker** without head's explicit grant for this round.
### Q1 — System-view shape (§3)
(a) Keep current routes only; user views beside them at `/views/{slug}` — current asymmetry stays.
(b) Full migration; existing pages become system views, legacy URLs 301-redirect — paliad parity.
(c) Hybrid; both URL families coexist, system slugs aliased — preserves muscle memory.
**Inventor pick**: (c). Closes the asymmetry m flagged, zero migration cost. (b) is cleaner but risks broken bookmarks for thin upside.
### Q2 — `view_type` field placement
- (a) Top-level column (5j inventor pick — matches 5i, query-able without parsing JSON).
- (b) Inside `filter_json`.
**Inventor pick**: (a).
### Q3 — Legacy `?view=<uuid>` URL handling (§7)
- (a) 404 — clean break.
- (b) 302-redirect to `/views/<slug>` by uuid lookup — smoother for m's recent bookmarks. Inventor pick.
**Inventor pick**: (b).
### Q4 — Editor surface (§6)
- (a) Dedicated pages `/views/new` + `/views/{slug}/edit` — paliad parity, inventor pick.
- (b) Keep the HTMX modal from the 5i fix — less navigation but harder to share/bookmark mid-edit.
**Inventor pick**: (a).
### Q5 — `/views` landing MRU redirect
- (a) 302 to MRU saved view if any, else onboarding shell (paliad model, inventor pick).
- (b) Always show the views index list page.
**Inventor pick**: (a).
### Q6 — Icon picker in v1?
- (a) Yes — small select + 8-12 curated keys; rendered inline in the sidebar entries.
- (b) v2 — ship without icons in v1; sidebar uses a generic folder glyph for every entry.
**Inventor pick**: (a) — the schema column lands either way; UI cost for a `<select>` 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=<uuid>` 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.

View File

@@ -136,8 +136,13 @@ func ValidateFormat(in Input) *ValidationError {
// ValidateAgainstStore adds the DB-aware checks: every parent id must
// resolve to a live item, the proposed parent_ids must not introduce a
// cycle, and no sibling under any common parent already carries this slug.
// Mirrors db/migrations/0010_multi_parent.sql trigger logic in Go.
// cycle, and no other live item already carries this slug (per-user-global
// uniqueness — Phase 6 / Q6=a).
//
// The Reader is satisfied by both the legacy *store.Store and the slice-B
// *store.MBrianReader, so cycle + collision detection runs against
// whichever backend PROJAX_BACKEND selects. Handlers pass s.Items here, not
// s.Store, so a write pre-flight never validates against the wrong dataset.
//
// Callers should run ValidateFormat first — this function assumes the
// pure checks already passed.
@@ -197,45 +202,23 @@ func ValidateAgainstStore(ctx context.Context, r Reader, in Input) *ValidationEr
}
}
// Rules 8/9: slug uniqueness. For each parent_id, check whether any
// sibling under that parent already carries the same slug. Roots
// (parent_ids empty) check against other roots.
if len(in.ParentIDs) == 0 {
for _, it := range items {
if len(it.ParentIDs) != 0 {
continue
}
if it.ID == in.ID {
continue
}
if it.Slug == in.Slug {
return &ValidationError{
Kind: KindSlugCollision,
Path: in.Path,
Detail: fmt.Sprintf("a root item with slug %q already exists", in.Slug),
}
}
// Rules 8/9 (Phase 6 / Q6=a): slug uniqueness is per-user-global, not
// per-parent. mBrian's idx_nodes_slug enforces (user_id, slug)
// uniqueness, so two projax items can never share a slug regardless of
// where they sit in the DAG — the old "two paliads under different
// roots" case no longer holds (m confirmed he doesn't rely on it). This
// is strictly tighter than the legacy per-parent rule, so it stays
// correct on the legacy *Store backend too (the projax.items per-parent
// index is never reached because this pre-flight rejects first).
for _, it := range items {
if it.ID == in.ID {
continue
}
} else {
parentSet := make(map[string]struct{}, len(in.ParentIDs))
for _, pid := range in.ParentIDs {
parentSet[pid] = struct{}{}
}
for _, it := range items {
if it.ID == in.ID {
continue
}
if it.Slug != in.Slug {
continue
}
for _, sibPID := range it.ParentIDs {
if _, common := parentSet[sibPID]; common {
return &ValidationError{
Kind: KindSlugCollision,
Path: in.Path,
Detail: fmt.Sprintf("slug %q already exists under parent %q", in.Slug, sibPID),
}
}
if it.Slug == in.Slug {
return &ValidationError{
Kind: KindSlugCollision,
Path: in.Path,
Detail: fmt.Sprintf("an item with slug %q already exists", in.Slug),
}
}
}

View File

@@ -147,21 +147,33 @@ func TestValidateStoreSlugCollisionRoot(t *testing.T) {
}
}
func TestValidateStoreSlugCollisionSibling(t *testing.T) {
func TestValidateStoreSlugCollisionPerUser(t *testing.T) {
// Phase 6 / Q6=a: slug uniqueness is per-user-global, not per-parent.
r := &stubReader{items: []*store.Item{
mkItem("parent", "parent"),
mkItem("child1", "kid", "parent"),
mkItem("other", "other"),
}}
// New child under same parent with the same slug — reject.
// New child under the same parent with the same slug — reject.
err := ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "kid", ParentIDs: []string{"parent"}})
if err == nil || err.Kind != KindSlugCollision {
t.Fatalf("expected slug-collision under common parent, got %v", err)
}
// Same slug under a different parent is fine.
// Same slug under a DIFFERENT parent now also collides — the old
// per-parent escape ("two paliads under different roots") is gone.
err = ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "kid", ParentIDs: []string{"other"}})
if err == nil || err.Kind != KindSlugCollision {
t.Fatalf("expected per-user slug-collision regardless of parent, got %v", err)
}
// A novel slug under any parent is fine.
err = ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "novel", ParentIDs: []string{"other"}})
if err != nil {
t.Errorf("different-parent slug should pass, got %v", err)
t.Errorf("novel slug should pass, got %v", err)
}
// Updating the existing child to keep its own slug is not a collision.
err = ValidateAgainstStore(context.Background(), r, Input{ID: "child1", Title: "x", Slug: "kid", ParentIDs: []string{"parent"}})
if err != nil {
t.Errorf("self-update keeping own slug should pass, got %v", err)
}
}

View File

@@ -39,10 +39,10 @@ func (stubLinkLister) ItemsCreatedInRange(_ context.Context, _, _ time.Time) ([]
func newToolServer(t *testing.T, agg *aggregate.Aggregator) *Server {
t.Helper()
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
// Pass a nil *store.Store — the timeline tool's store-backed paths
// short-circuit cleanly on errors in this test surface (we just probe
// registration + arg parsing here).
RegisterProjaxTools(srv, nil, agg)
// Pass nil reader/writer/legacy store — the timeline tool's store-backed
// paths short-circuit cleanly on errors in this test surface (we just
// probe registration + arg parsing here).
RegisterProjaxTools(srv, nil, nil, nil, agg)
return srv
}

View File

@@ -31,6 +31,27 @@ func ValidationToolError(ve *itemwrite.ValidationError) *ToolError {
}
}
// slugAwareToolError promotes the adapter's slug sentinels into typed
// validation tool errors (so MCP clients get {kind, detail} on a slug
// collision / invalid slug from the mBrian backend, just like the
// pre-flight validator), and falls back to InternalError otherwise.
func slugAwareToolError(err error) *ToolError {
switch {
case errors.Is(err, store.ErrSlugTaken):
return ValidationToolError(&itemwrite.ValidationError{
Kind: itemwrite.KindSlugCollision,
Detail: "slug already taken (possibly by a deleted item)",
})
case errors.Is(err, store.ErrInvalidSlug):
return ValidationToolError(&itemwrite.ValidationError{
Kind: itemwrite.KindInvalidSlugFormat,
Detail: "invalid slug — lower-case, no dots or whitespace",
})
default:
return InternalError(err)
}
}
// TimelineArgs is the MCP-facing input shape for the `timeline` tool — a
// JSON-friendly equivalent of the URL query string web/timeline.go consumes.
type TimelineArgs struct {
@@ -46,12 +67,18 @@ type TimelineArgs struct {
IncludeExcluded bool `json:"include_excluded"` // ignore per-item timeline_exclude arrays
}
// RegisterProjaxTools wires every projax-flavoured tool onto an *mcp.Server.
// All tools delegate to *store.Store directly so business logic is shared
// with the web UI — no duplication. The optional agg argument adds the
// timeline tool when non-nil (it needs the fan-out aggregator; passing nil
// keeps the rest of the toolset usable without aggregate deps).
func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) {
// RegisterProjaxTools wires the projax MCP toolset. Phase 6 Slice C splits
// the single *Store dependency into a reader (rd) + writer (wr) so the MCP
// item/link surface flips with PROJAX_BACKEND atomically — the same
// reader/writer the web handlers use. If MCP reads stayed on projax.items
// while writes targeted mBrian (or vice-versa) an MCP client could read an
// id from one backend and write it to the other: the exact slice-B
// half-flip bug, just on the MCP surface.
//
// The `timeline` tool stays on the legacy *Store (its companion
// *aggregate.Aggregator is out of slice-C scope), consistent with the web
// dashboard/timeline which also aggregate via *Store.
func RegisterProjaxTools(s *Server, rd store.ItemReader, wr store.ItemWriter, legacy *store.Store, agg *aggregate.Aggregator) {
s.Register(Tool{
Name: "list_items",
Description: "List projax items with optional filters (parent_path, tags, management, kind, status, q, has_repo, has_caldav, public).",
@@ -70,7 +97,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"limit": {"type": "integer", "minimum": 0}
}
}`),
Handler: listItemsTool(st),
Handler: listItemsTool(rd),
})
s.Register(Tool{
Name: "get_item",
@@ -83,7 +110,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"include_links": {"type": "boolean", "description": "Include item_links in the response (default true)"}
}
}`),
Handler: getItemTool(st),
Handler: getItemTool(rd),
})
s.Register(Tool{
Name: "create_item",
@@ -103,7 +130,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"metadata": {"type": "object"}
}
}`),
Handler: createItemTool(st),
Handler: createItemTool(rd, wr),
})
s.Register(Tool{
Name: "update_item",
@@ -130,7 +157,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"timeline_exclude": {"type": "array", "items": {"type": "string", "enum": ["todos","events","docs","creation"]}, "description": "Phase 4f — kinds to hide from /timeline (per item)"}
}
}`),
Handler: updateItemTool(st),
Handler: updateItemTool(rd, wr),
})
s.Register(Tool{
Name: "delete_item",
@@ -143,7 +170,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"cascade": {"type": "boolean", "description": "Soft-delete every descendant too"}
}
}`),
Handler: deleteItemTool(st),
Handler: deleteItemTool(rd, wr),
})
s.Register(Tool{
Name: "list_links",
@@ -156,7 +183,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"ref_type": {"type": "string", "description": "Optional ref_type filter (e.g. 'gitea-repo')"}
}
}`),
Handler: listLinksTool(st),
Handler: listLinksTool(rd),
})
s.Register(Tool{
Name: "add_link",
@@ -175,7 +202,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"metadata": {"type": "object"}
}
}`),
Handler: addLinkTool(st),
Handler: addLinkTool(rd, wr),
})
s.Register(Tool{
Name: "remove_link",
@@ -185,7 +212,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"required": ["link_id"],
"properties": {"link_id": {"type": "string"}}
}`),
Handler: removeLinkTool(st),
Handler: removeLinkTool(wr),
})
s.Register(Tool{
Name: "search",
@@ -198,7 +225,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"limit": {"type": "integer", "minimum": 1, "maximum": 200}
}
}`),
Handler: searchTool(st),
Handler: searchTool(rd),
})
s.Register(Tool{
Name: "tree",
@@ -210,7 +237,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"depth": {"type": "integer", "minimum": 0, "description": "Max depth (0 = unlimited)"}
}
}`),
Handler: treeTool(st),
Handler: treeTool(rd),
})
if agg != nil {
s.Register(Tool{
@@ -230,7 +257,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
"q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"}
}
}`),
Handler: timelineTool(st, agg),
Handler: timelineTool(legacy, agg),
})
}
}
@@ -710,14 +737,14 @@ func mapOr(v map[string]any) map[string]any {
}
// resolveItem turns an id-or-path argument pair into a concrete *store.Item.
func resolveItem(ctx context.Context, st *store.Store, id, path string) (*store.Item, error) {
func resolveItem(ctx context.Context, rd store.ItemReader, id, path string) (*store.Item, error) {
id = strings.TrimSpace(id)
path = strings.TrimSpace(path)
if id != "" {
return st.GetByID(ctx, id)
return rd.GetByID(ctx, id)
}
if path != "" {
return st.GetByPathOrSlug(ctx, path)
return rd.GetByPathOrSlug(ctx, path)
}
return nil, errors.New("either id or path is required")
}
@@ -731,7 +758,7 @@ func parseInput[T any](raw json.RawMessage, dst *T) error {
// --- list_items ---
func listItemsTool(st *store.Store) ToolHandler {
func listItemsTool(rd store.ItemReader) ToolHandler {
type input struct {
ParentPath string `json:"parent_path"`
Tags []string `json:"tags"`
@@ -749,7 +776,7 @@ func listItemsTool(st *store.Store) ToolHandler {
if err := parseInput(raw, &in); err != nil {
return nil, InternalError(fmt.Errorf("bad params: %w", err))
}
items, err := st.ListByFilters(ctx, store.SearchFilters{
items, err := rd.ListByFilters(ctx, store.SearchFilters{
ParentPath: in.ParentPath,
Tags: in.Tags,
Management: in.Management,
@@ -774,7 +801,7 @@ func listItemsTool(st *store.Store) ToolHandler {
// --- get_item ---
func getItemTool(st *store.Store) ToolHandler {
func getItemTool(rd store.ItemReader) ToolHandler {
type input struct {
ID string `json:"id"`
Path string `json:"path"`
@@ -785,7 +812,7 @@ func getItemTool(st *store.Store) ToolHandler {
if err := parseInput(raw, &in); err != nil {
return nil, InternalError(fmt.Errorf("bad params: %w", err))
}
it, err := resolveItem(ctx, st, in.ID, in.Path)
it, err := resolveItem(ctx, rd, in.ID, in.Path)
if err != nil {
return nil, InternalError(err)
}
@@ -795,13 +822,13 @@ func getItemTool(st *store.Store) ToolHandler {
include = *in.IncludeLinks
}
if include {
links, err := st.LinksByType(ctx, it.ID, "") // pass "" → all types
links, err := rd.LinksByType(ctx, it.ID, "") // pass "" → all types
// LinksByType filters by ref_type — empty would return nothing. So
// we explicitly list_all by fanning across the known types.
_ = err
links = nil
for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} {
ll, err := st.LinksByType(ctx, it.ID, t)
ll, err := rd.LinksByType(ctx, it.ID, t)
if err != nil {
continue
}
@@ -819,7 +846,7 @@ func getItemTool(st *store.Store) ToolHandler {
// --- create_item ---
func createItemTool(st *store.Store) ToolHandler {
func createItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
type input struct {
Slug string `json:"slug"`
Title string `json:"title"`
@@ -836,7 +863,7 @@ func createItemTool(st *store.Store) ToolHandler {
if err := parseInput(raw, &in); err != nil {
return nil, InternalError(fmt.Errorf("bad params: %w", err))
}
parentIDs, err := resolveParentPaths(ctx, st, in.ParentPaths)
parentIDs, err := resolveParentPaths(ctx, rd, in.ParentPaths)
if err != nil {
return nil, InternalError(err)
}
@@ -851,12 +878,12 @@ func createItemTool(st *store.Store) ToolHandler {
}); ve != nil {
return nil, ValidationToolError(ve)
}
if ve := itemwrite.ValidateAgainstStore(ctx, st, itemwrite.Input{
if ve := itemwrite.ValidateAgainstStore(ctx, rd, itemwrite.Input{
Title: in.Title, Slug: in.Slug, Status: in.Status, ParentIDs: parentIDs,
}); ve != nil {
return nil, ValidationToolError(ve)
}
it, err := st.Create(ctx, store.CreateInput{
it, err := wr.Create(ctx, store.CreateInput{
Kind: kind,
Title: in.Title,
Slug: in.Slug,
@@ -868,20 +895,20 @@ func createItemTool(st *store.Store) ToolHandler {
Metadata: in.Metadata,
})
if err != nil {
return nil, InternalError(err)
return nil, slugAwareToolError(err)
}
return toItemView(it), nil
}
}
func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([]string, error) {
func resolveParentPaths(ctx context.Context, rd store.ItemReader, paths []string) ([]string, error) {
out := make([]string, 0, len(paths))
for _, p := range paths {
p = strings.TrimSpace(p)
if p == "" {
continue
}
it, err := st.GetByPathOrSlug(ctx, p)
it, err := rd.GetByPathOrSlug(ctx, p)
if err != nil {
return nil, fmt.Errorf("parent path %q: %w", p, err)
}
@@ -892,7 +919,7 @@ func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([
// --- update_item ---
func updateItemTool(st *store.Store) ToolHandler {
func updateItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
type input struct {
ID string `json:"id"`
Path string `json:"path"`
@@ -917,7 +944,7 @@ func updateItemTool(st *store.Store) ToolHandler {
if err := parseInput(raw, &in); err != nil {
return nil, InternalError(fmt.Errorf("bad params: %w", err))
}
it, err := resolveItem(ctx, st, in.ID, in.Path)
it, err := resolveItem(ctx, rd, in.ID, in.Path)
if err != nil {
return nil, InternalError(err)
}
@@ -996,7 +1023,7 @@ func updateItemTool(st *store.Store) ToolHandler {
patch.TimelineExclude = out
}
if in.ParentPaths != nil {
pids, err := resolveParentPaths(ctx, st, *in.ParentPaths)
pids, err := resolveParentPaths(ctx, rd, *in.ParentPaths)
if err != nil {
return nil, InternalError(err)
}
@@ -1015,12 +1042,12 @@ func updateItemTool(st *store.Store) ToolHandler {
if ve := itemwrite.ValidateFormat(validateIn); ve != nil {
return nil, ValidationToolError(ve)
}
if ve := itemwrite.ValidateAgainstStore(ctx, st, validateIn); ve != nil {
if ve := itemwrite.ValidateAgainstStore(ctx, rd, validateIn); ve != nil {
return nil, ValidationToolError(ve)
}
updated, err := st.Update(ctx, it.ID, patch)
updated, err := wr.Update(ctx, it.ID, patch)
if err != nil {
return nil, InternalError(err)
return nil, slugAwareToolError(err)
}
return toItemView(updated), nil
}
@@ -1028,7 +1055,7 @@ func updateItemTool(st *store.Store) ToolHandler {
// --- delete_item ---
func deleteItemTool(st *store.Store) ToolHandler {
func deleteItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
type input struct {
ID string `json:"id"`
Path string `json:"path"`
@@ -1039,11 +1066,11 @@ func deleteItemTool(st *store.Store) ToolHandler {
if err := parseInput(raw, &in); err != nil {
return nil, InternalError(fmt.Errorf("bad params: %w", err))
}
it, err := resolveItem(ctx, st, in.ID, in.Path)
it, err := resolveItem(ctx, rd, in.ID, in.Path)
if err != nil {
return nil, InternalError(err)
}
if err := st.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil {
if err := wr.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil {
return nil, InternalError(err)
}
return map[string]any{"deleted": it.ID, "cascade": in.Cascade}, nil
@@ -1052,7 +1079,7 @@ func deleteItemTool(st *store.Store) ToolHandler {
// --- list_links ---
func listLinksTool(st *store.Store) ToolHandler {
func listLinksTool(rd store.ItemReader) ToolHandler {
type input struct {
ID string `json:"id"`
Path string `json:"path"`
@@ -1063,16 +1090,16 @@ func listLinksTool(st *store.Store) ToolHandler {
if err := parseInput(raw, &in); err != nil {
return nil, InternalError(fmt.Errorf("bad params: %w", err))
}
it, err := resolveItem(ctx, st, in.ID, in.Path)
it, err := resolveItem(ctx, rd, in.ID, in.Path)
if err != nil {
return nil, InternalError(err)
}
var links []*store.ItemLink
if in.RefType != "" {
links, err = st.LinksByType(ctx, it.ID, in.RefType)
links, err = rd.LinksByType(ctx, it.ID, in.RefType)
} else {
for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} {
ll, lerr := st.LinksByType(ctx, it.ID, t)
ll, lerr := rd.LinksByType(ctx, it.ID, t)
if lerr != nil {
continue
}
@@ -1092,7 +1119,7 @@ func listLinksTool(st *store.Store) ToolHandler {
// --- add_link / remove_link ---
func addLinkTool(st *store.Store) ToolHandler {
func addLinkTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
type input struct {
ID string `json:"id"`
Path string `json:"path"`
@@ -1111,7 +1138,7 @@ func addLinkTool(st *store.Store) ToolHandler {
if in.RefType == "" || in.RefID == "" {
return nil, &ToolError{Code: codeInternalError, Msg: "ref_type and ref_id are required"}
}
it, err := resolveItem(ctx, st, in.ID, in.Path)
it, err := resolveItem(ctx, rd, in.ID, in.Path)
if err != nil {
return nil, InternalError(err)
}
@@ -1132,7 +1159,7 @@ func addLinkTool(st *store.Store) ToolHandler {
}
datePtr = &t
}
link, err := st.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md)
link, err := wr.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md)
if err != nil {
return nil, InternalError(err)
}
@@ -1140,7 +1167,7 @@ func addLinkTool(st *store.Store) ToolHandler {
}
}
func removeLinkTool(st *store.Store) ToolHandler {
func removeLinkTool(wr store.ItemWriter) ToolHandler {
type input struct {
LinkID string `json:"link_id"`
}
@@ -1152,7 +1179,7 @@ func removeLinkTool(st *store.Store) ToolHandler {
if in.LinkID == "" {
return nil, &ToolError{Code: codeInternalError, Msg: "link_id is required"}
}
if err := st.DeleteLink(ctx, in.LinkID); err != nil {
if err := wr.DeleteLink(ctx, in.LinkID); err != nil {
return nil, InternalError(err)
}
return map[string]any{"deleted": in.LinkID}, nil
@@ -1161,7 +1188,7 @@ func removeLinkTool(st *store.Store) ToolHandler {
// --- search ---
func searchTool(st *store.Store) ToolHandler {
func searchTool(rd store.ItemReader) ToolHandler {
type input struct {
Query string `json:"query"`
Limit int `json:"limit"`
@@ -1174,7 +1201,7 @@ func searchTool(st *store.Store) ToolHandler {
if in.Query == "" {
return nil, &ToolError{Code: codeInternalError, Msg: "query is required"}
}
items, err := st.Search(ctx, in.Query, in.Limit)
items, err := rd.Search(ctx, in.Query, in.Limit)
if err != nil {
return nil, InternalError(err)
}
@@ -1194,7 +1221,7 @@ type treeNode struct {
Children []*treeNode `json:"children"`
}
func treeTool(st *store.Store) ToolHandler {
func treeTool(rd store.ItemReader) ToolHandler {
type input struct {
RootPath string `json:"root_path"`
Depth int `json:"depth"`
@@ -1204,7 +1231,7 @@ func treeTool(st *store.Store) ToolHandler {
if err := parseInput(raw, &in); err != nil {
return nil, InternalError(fmt.Errorf("bad params: %w", err))
}
items, err := st.ListAll(ctx)
items, err := rd.ListAll(ctx)
if err != nil {
return nil, InternalError(err)
}
@@ -1235,7 +1262,7 @@ func treeTool(st *store.Store) ToolHandler {
}
var out []*treeNode
if in.RootPath != "" {
root, err := st.GetByPathOrSlug(ctx, in.RootPath)
root, err := rd.GetByPathOrSlug(ctx, in.RootPath)
if err != nil {
return nil, InternalError(err)
}

View File

@@ -42,7 +42,7 @@ func mustDBServer(t *testing.T) (*Server, *pgxpool.Pool) {
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
// The MCP tests don't need a real timeline builder — passing nil keeps
// the timeline tool unregistered without requiring a web.Server here.
RegisterProjaxTools(srv, st, nil)
RegisterProjaxTools(srv, st, st, st, nil)
t.Cleanup(func() { pool.Close() })
return srv, pool
}

94
store/adapter.go Normal file
View File

@@ -0,0 +1,94 @@
package store
import (
"context"
"time"
)
// ItemReader is the read-path contract every projax UI handler, the
// internal/aggregate fan-out engine, and the MCP read tools depend on.
// Pure projax-shaped structs in/out; the slice-B mBrian-backed
// implementation translates mBrian nodes/edges into the same shape
// without leaking mBrian types to consumers.
//
// Phase 6 Slice B (live impl) — see store/mbrian.go for the MBrianReader
// implementation against the migrated mbrian.* schema, and
// docs/plans/slice-b-adapter-contract.md for the consumer inventory +
// per-method semantics.
//
// Two satisfiers ship:
// *Store — pgx-backed against projax.items (today; legacy).
// *MBrianReader — pgx-backed against mbrian.{nodes,edges} (slice B).
//
// Selection between them is wired at Server-construction time via
// PROJAX_BACKEND=store|mbrian (defaults to "store" until slice B is
// rolled to production).
type ItemReader interface {
// --- item lookups ---
ListAll(ctx context.Context) ([]*Item, error)
GetByID(ctx context.Context, id string) (*Item, error)
GetByPath(ctx context.Context, path string) (*Item, error)
GetByPathOrSlug(ctx context.Context, key string) (*Item, error)
Roots(ctx context.Context) ([]*Item, error)
MaiOrphans(ctx context.Context) ([]*Item, error)
ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error)
Search(ctx context.Context, q string, limit int) ([]*Item, error)
ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error)
AllTags(ctx context.Context) ([]string, error)
// --- link lookups ---
LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error)
LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error)
DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error)
DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error)
RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error)
}
// Compile-time assertion that the existing pgx-backed *Store satisfies
// ItemReader. Drops in cleanly because every method in the interface is
// already part of *Store's public surface. If a future refactor removes
// or reshapes one of these methods on *Store, the compiler points at
// this line first.
var _ ItemReader = (*Store)(nil)
// ItemWriter is the write-path contract every projax UI write handler, the
// /admin/bulk apply path, and the MCP write tools depend on. Pure
// projax-shaped structs in/out; the slice-C mBrian-backed implementation
// (*MBrianWriter) translates each call into the scoped mBrian HTTP write
// API without leaking mBrian/HTTP types to consumers.
//
// Phase 6 Slice C — see store/mbrian_writer.go for the MBrianWriter
// implementation (an HTTP client against mBrian's /api/projax/* surface)
// and docs/plans/slice-c-writepath-contract.md for the per-method
// semantics + the read→write→read round-trip the slice-B cutover missed.
//
// Two satisfiers ship:
// *Store — pgx-backed against projax.items (today; legacy).
// *MBrianWriter — HTTP client against mBrian's scoped write API (slice C).
//
// Selection between them is wired at Server-construction time via
// PROJAX_BACKEND=store|mbrian. The flag MUST flip BOTH the reader
// (Server.Items) and the writer (Server.Writes) to the same backend —
// the slice-B bug was a half-flip (reads on mBrian, writes on
// projax.items), so a read-then-write round-trip rejected the freshly
// read id. main.go sets both atomically.
type ItemWriter interface {
// --- item writes ---
Create(ctx context.Context, in CreateInput) (*Item, error)
Update(ctx context.Context, id string, in UpdateInput) (*Item, error)
Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error)
AddParent(ctx context.Context, id, parentID string) (*Item, error)
SetPublic(ctx context.Context, ids []string, public bool) error
SetPinned(ctx context.Context, ids []string, pinned bool) error
SoftDelete(ctx context.Context, id string) error
SoftDeleteCascade(ctx context.Context, id string, cascade bool) error
// --- link writes ---
AddLink(ctx context.Context, itemID, refType, refID, rel string, metadata map[string]any) (*ItemLink, error)
AddLinkDated(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error)
DeleteLink(ctx context.Context, id string) error
}
// Compile-time assertion that the existing pgx-backed *Store satisfies
// ItemWriter. Every method is already part of *Store's public surface.
var _ ItemWriter = (*Store)(nil)

1039
store/mbrian.go Normal file

File diff suppressed because it is too large Load Diff

251
store/mbrian_parity_test.go Normal file
View File

@@ -0,0 +1,251 @@
package store_test
// Phase 6 Slice B — parity test between the legacy pgx-against-projax-
// items *Store and the new pgx-against-mbrian *MBrianReader.
//
// Skipped without SUPABASE_DATABASE_URL set. When run against the live
// post-migration database, every comparison should hold: the adapter
// must be a faithful translator of the migrated graph for projax UI
// consumers.
import (
"context"
"errors"
"os"
"sort"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/m/projax/store"
)
func newPair(t *testing.T) (*store.Store, *store.MBrianReader, *pgxpool.Pool) {
t.Helper()
url := os.Getenv("SUPABASE_DATABASE_URL")
if url == "" {
url = os.Getenv("PROJAX_DB_URL")
}
if url == "" {
t.Skip("set SUPABASE_DATABASE_URL")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatalf("pool: %v", err)
}
if err := pool.Ping(ctx); err != nil {
t.Skipf("DB unreachable: %v", err)
}
return store.New(pool), store.NewMBrianReader(pool), pool
}
// TestParityListAll: both readers return the same set of items by slug.
// Field-by-field equality is asserted for slug/title/status/tags/management/
// public/parent count/paths — the consumer-facing surface.
func TestParityListAll(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
leg, err := s.ListAll(ctx)
if err != nil {
t.Fatalf("store ListAll: %v", err)
}
mb, err := r.ListAll(ctx)
if err != nil {
t.Fatalf("mbrian ListAll: %v", err)
}
if len(leg) != len(mb) {
t.Fatalf("count mismatch: store=%d mbrian=%d", len(leg), len(mb))
}
// Per the migration brief, two projax-side squatter slugs were
// renamed-aside (not deleted) so mBrian could take the canonical
// 'work' (area) + 'dania' (project) slugs. Compare every slug that
// resolves in BOTH sets; the squatters surface as legacy-only.
skip := map[string]bool{"work": true, "dania": true}
legBySlug := bySlug(leg)
mbBySlug := bySlug(mb)
for slug, l := range legBySlug {
if skip[slug] {
continue
}
m, ok := mbBySlug[slug]
if !ok {
t.Errorf("slug %q missing in mBrian set", slug)
continue
}
if l.Title != m.Title {
t.Errorf("%s title: store=%q mbrian=%q", slug, l.Title, m.Title)
}
if l.Status != m.Status {
t.Errorf("%s status: store=%q mbrian=%q", slug, l.Status, m.Status)
}
if !sameSet(l.Tags, m.Tags) {
t.Errorf("%s tags: store=%v mbrian=%v", slug, l.Tags, m.Tags)
}
if !sameSet(l.Management, m.Management) {
t.Errorf("%s management: store=%v mbrian=%v", slug, l.Management, m.Management)
}
if l.Public != m.Public {
t.Errorf("%s public: store=%v mbrian=%v", slug, l.Public, m.Public)
}
if len(l.ParentIDs) != len(m.ParentIDs) {
t.Errorf("%s parent count: store=%d mbrian=%d",
slug, len(l.ParentIDs), len(m.ParentIDs))
}
if !sameSet(l.Paths, m.Paths) {
t.Errorf("%s paths: store=%v mbrian=%v", slug, l.Paths, m.Paths)
}
}
}
// TestParitySpotChecks asserts the 5 spot-check items resolve identically
// through both readers — root area / single-parent / multi-parent /
// caldav-linked / public-listing populated.
func TestParitySpotChecks(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
for _, slug := range []string{"dev", "work", "paliad", "services", "mhome", "fdbck", "dania"} {
l, lerr := s.GetByPathOrSlug(ctx, slug)
m, merr := r.GetByPathOrSlug(ctx, slug)
if lerr != nil && merr != nil {
// Both 404 — consistent.
continue
}
if lerr != nil {
t.Errorf("%s: store err=%v but mbrian found", slug, lerr)
continue
}
if merr != nil {
t.Errorf("%s: mbrian err=%v but store found", slug, merr)
continue
}
if l.Slug != m.Slug || l.Title != m.Title {
t.Errorf("%s: shape mismatch store=%+v mbrian=%+v",
slug, l.Slug+"/"+l.Title, m.Slug+"/"+m.Title)
}
}
}
// TestParityCalDAVLinks: the single caldav-list link must round-trip.
func TestParityCalDAVLinks(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
leg, err := s.LinksByRefType(ctx, "caldav-list")
if err != nil {
t.Fatalf("store: %v", err)
}
mb, err := r.LinksByRefType(ctx, "caldav-list")
if err != nil {
t.Fatalf("mbrian: %v", err)
}
if len(leg) != len(mb) {
t.Errorf("count: store=%d mbrian=%d", len(leg), len(mb))
}
// The URLs must round-trip identically. Match by ref_id.
legByRef := map[string]*store.ItemLink{}
for _, l := range leg {
legByRef[l.RefID] = l
}
for _, m := range mb {
l, ok := legByRef[m.RefID]
if !ok {
t.Errorf("mbrian caldav RefID %q not in store set", m.RefID)
continue
}
if l.Rel != m.Rel {
t.Errorf("caldav %s rel: store=%q mbrian=%q", m.RefID, l.Rel, m.Rel)
}
}
}
// TestParityGiteaRepoLinks — same parity check for the 37 gitea-repo edges.
func TestParityGiteaRepoLinks(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
leg, err := s.LinksByRefType(ctx, "gitea-repo")
if err != nil {
t.Fatalf("store: %v", err)
}
mb, err := r.LinksByRefType(ctx, "gitea-repo")
if err != nil {
t.Fatalf("mbrian: %v", err)
}
if len(leg) != len(mb) {
t.Errorf("count: store=%d mbrian=%d", len(leg), len(mb))
}
legSeen := map[string]bool{}
for _, l := range leg {
legSeen[l.RefID] = true
}
for _, m := range mb {
if !legSeen[m.RefID] {
t.Errorf("mbrian gitea-repo RefID %q not in store set", m.RefID)
}
}
}
// TestParityAllTags: tag union must match (modulo ordering).
func TestParityAllTags(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
leg, err := s.AllTags(ctx)
if err != nil {
t.Fatalf("store: %v", err)
}
mb, err := r.AllTags(ctx)
if err != nil {
t.Fatalf("mbrian: %v", err)
}
if !sameSet(leg, mb) {
t.Errorf("AllTags mismatch:\n store=%v\n mbrian=%v", leg, mb)
}
}
// TestParityNotFound: an unknown slug must 404 from both.
func TestParityNotFound(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
_, le := s.GetByPathOrSlug(ctx, "definitely-not-a-real-slug-zzzzz")
_, me := r.GetByPathOrSlug(ctx, "definitely-not-a-real-slug-zzzzz")
if !errors.Is(le, store.ErrNotFound) {
t.Errorf("store should ErrNotFound, got %v", le)
}
if !errors.Is(me, store.ErrNotFound) {
t.Errorf("mbrian should ErrNotFound, got %v", me)
}
}
// --- helpers ---
func bySlug(items []*store.Item) map[string]*store.Item {
out := map[string]*store.Item{}
for _, it := range items {
out[it.Slug] = it
}
return out
}
func sameSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
ac := append([]string{}, a...)
bc := append([]string{}, b...)
sort.Strings(ac)
sort.Strings(bc)
for i := range ac {
if ac[i] != bc[i] {
return false
}
}
return true
}

View File

@@ -0,0 +1,79 @@
package store
import "testing"
// Phase 6 Slice C reader fixes (G3 + G2): the reader reads pinned/archived
// and link note from metadata (where MBrianWriter stows them, since the
// scoped HTTP API can't set those columns/fields) with a fallback to the
// node column / edge.note. These exercise itemFromNode + linkFromEdge as
// pure functions — no DB.
func TestItemFromNodePinnedArchivedCoalesce(t *testing.T) {
// metadata.projax wins when present.
r := &nodeRow{
ID: "n1", Slug: "x", Title: "X",
Pinned: false, Archived: false, // column defaults
Metadata: map[string]any{
"projax": map[string]any{"pinned": true, "archived": true},
},
}
it := itemFromNode(r)
if !it.Pinned || !it.Archived {
t.Errorf("metadata.projax pinned/archived should override columns: pinned=%v archived=%v", it.Pinned, it.Archived)
}
// Absent in metadata → fall back to the column values (migrated data).
r2 := &nodeRow{
ID: "n2", Slug: "y", Title: "Y",
Pinned: true, Archived: true, // column values from migration
Metadata: map[string]any{"projax": map[string]any{}},
}
it2 := itemFromNode(r2)
if !it2.Pinned || !it2.Archived {
t.Errorf("column pinned/archived should survive when metadata is silent: pinned=%v archived=%v", it2.Pinned, it2.Archived)
}
// metadata false explicitly overrides a true column.
r3 := &nodeRow{
ID: "n3", Slug: "z", Title: "Z",
Pinned: true,
Metadata: map[string]any{"projax": map[string]any{"pinned": false}},
}
if itemFromNode(r3).Pinned {
t.Error("explicit metadata.projax.pinned=false should override column pinned=true")
}
}
func TestLinkFromEdgeNoteCoalesce(t *testing.T) {
// edge.note column wins.
col := "from column"
er := &edgeRow{
ID: "e1", SourceID: "i1", Rel: "projax-doc",
Note: &col,
Metadata: map[string]any{"note": "from metadata", "url": "/d.pdf"},
}
l := linkFromEdge(er)
if l.Note == nil || *l.Note != "from column" {
t.Errorf("edge.note column should win: %v", l.Note)
}
if _, dup := l.Metadata["note"]; dup {
t.Error("note must not also appear in consumer metadata (parity with *Store)")
}
// No column note → fall back to metadata.note (post-cutover writes).
er2 := &edgeRow{
ID: "e2", SourceID: "i1", Rel: "projax-doc",
Note: nil,
Metadata: map[string]any{"note": "filed brief", "url": "/d.pdf"},
}
l2 := linkFromEdge(er2)
if l2.Note == nil || *l2.Note != "filed brief" {
t.Errorf("metadata.note should surface when edge.note is empty: %v", l2.Note)
}
// Neither → nil note, no panic.
er3 := &edgeRow{ID: "e3", SourceID: "i1", Rel: "projax-url", Metadata: map[string]any{"url": "https://x"}}
if l3 := linkFromEdge(er3); l3.Note != nil {
t.Errorf("no note anywhere → nil, got %v", *l3.Note)
}
}

76
store/mbrian_tasks.go Normal file
View File

@@ -0,0 +1,76 @@
package store
import (
"context"
"sort"
)
// Phase 7 — mBrian-native task READ path. Task nodes are mbrian.nodes with
// type containing 'task', carrying metadata.projax_origin (so the shared
// reader scoping already includes them) plus metadata.projax.{status,due}.
// They attach to their parent project via a child_of edge, exactly like a
// sub-project — the only structural difference is the type, which is why the
// list-producing reader methods filter them out (nodeIsTask) and this file
// surfaces them through the dedicated Task shape instead.
// Compile-time witness: MBrianReader satisfies TaskReader.
var _ TaskReader = (*MBrianReader)(nil)
// nodeIsTask reports whether a node's type marks it a projax task. Tasks are
// excluded from every project-list surface (ListAll/Roots/MaiOrphans/Search/
// AllTags) so they never clutter the project DAG; they surface only through
// TasksForItem (and remain individually resolvable via GetByID/GetByPath).
func nodeIsTask(r *nodeRow) bool { return containsString(r.Type, "task") }
// TasksForItem returns the mBrian-native tasks attached to itemID via a
// child_of edge, in created-at order (Q5 — created order, no manual reorder).
// One graph build; cheap at m's scale. Each task materialises into the
// uniform Task shape with Source=mbrian.
func (r *MBrianReader) TasksForItem(ctx context.Context, itemID string) ([]*Task, error) {
gc, err := loadAllProjaxNodes(ctx, r.pool)
if err != nil {
return nil, err
}
out := []*Task{}
for _, childID := range gc.childrenOf[itemID] {
n, ok := gc.nodeByID[childID]
if !ok || !nodeIsTask(n) {
continue
}
out = append(out, taskFromNode(n, itemID))
}
// Created-at order; stable tiebreak on slug so equal stamps don't churn.
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].Slug < out[j].Slug
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
return out, nil
}
// taskFromNode hoists a task node row into the uniform Task shape, unpacking
// metadata.projax.{status,due}. parentID is the item the caller resolved this
// task under (the child_of target driving TasksForItem).
func taskFromNode(n *nodeRow, parentID string) *Task {
t := &Task{
ID: n.ID,
Title: n.Title,
Source: TaskSourceMBrian,
Status: "active",
ParentItemID: parentID,
CreatedAt: n.CreatedAt,
NodeID: n.ID,
Slug: n.Slug,
}
if pm, ok := n.Metadata["projax"].(map[string]any); ok {
if v, ok := pm["status"].(string); ok && v != "" {
t.Status = v
}
if due := parseTimeAny(pm["due"]); due != nil {
t.Due = due
}
}
t.Done = t.Status == "done"
return t
}

104
store/mbrian_tasks_test.go Normal file
View File

@@ -0,0 +1,104 @@
package store
import (
"testing"
"time"
)
func TestNodeIsTask(t *testing.T) {
cases := []struct {
name string
typ []string
want bool
}{
{"plain task", []string{"task"}, true},
{"task co-typed mai-managed", []string{"task", "mai-managed"}, true},
{"project", []string{"project"}, false},
{"project co-typed", []string{"project", "mai-managed"}, false},
{"empty", []string{}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := nodeIsTask(&nodeRow{Type: c.typ}); got != c.want {
t.Fatalf("nodeIsTask(%v) = %v, want %v", c.typ, got, c.want)
}
})
}
}
func TestTaskFromNode(t *testing.T) {
created := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
n := &nodeRow{
ID: "task-uuid",
Type: []string{"task"},
Title: "Buy cement",
Slug: "buy-cement",
CreatedAt: created,
Metadata: map[string]any{
"projax": map[string]any{
"status": "done",
"due": "2026-06-15",
},
},
}
got := taskFromNode(n, "parent-uuid")
if got.ID != "task-uuid" || got.NodeID != "task-uuid" {
t.Fatalf("id/nodeID = %q/%q", got.ID, got.NodeID)
}
if got.Title != "Buy cement" || got.Slug != "buy-cement" {
t.Fatalf("title/slug = %q/%q", got.Title, got.Slug)
}
if got.Source != TaskSourceMBrian {
t.Fatalf("source = %q, want %q", got.Source, TaskSourceMBrian)
}
if got.Status != "done" || !got.Done {
t.Fatalf("status/done = %q/%v, want done/true", got.Status, got.Done)
}
if got.ParentItemID != "parent-uuid" {
t.Fatalf("parent = %q", got.ParentItemID)
}
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-06-15" {
t.Fatalf("due = %v, want 2026-06-15", got.Due)
}
if !got.CreatedAt.Equal(created) {
t.Fatalf("createdAt = %v, want %v", got.CreatedAt, created)
}
}
func TestTaskFromNodeDefaults(t *testing.T) {
// No projax metadata → active, not done, no due.
n := &nodeRow{ID: "t", Type: []string{"task"}, Title: "x", Slug: "x"}
got := taskFromNode(n, "p")
if got.Status != "active" || got.Done {
t.Fatalf("default status/done = %q/%v, want active/false", got.Status, got.Done)
}
if got.Due != nil {
t.Fatalf("default due = %v, want nil", got.Due)
}
}
func TestDueToJSON(t *testing.T) {
if got := dueToJSON(nil); got != "" {
t.Fatalf("nil due = %q, want empty", got)
}
dateOnly := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
if got := dueToJSON(&dateOnly); got != "2026-06-15" {
t.Fatalf("date-only = %q, want 2026-06-15", got)
}
withClock := time.Date(2026, 6, 15, 14, 30, 0, 0, time.UTC)
if got := dueToJSON(&withClock); got != "2026-06-15T14:30:00Z" {
t.Fatalf("with-clock = %q, want RFC3339", got)
}
}
func TestItemRendersChecklist(t *testing.T) {
if (&Item{Render: "checklist"}).RendersChecklist() != true {
t.Fatal("checklist render not detected")
}
if (&Item{Render: ""}).RendersChecklist() != false {
t.Fatal("empty render should be false")
}
if (&Item{Render: "card"}).RendersChecklist() != false {
t.Fatal("non-checklist render should be false")
}
}

744
store/mbrian_writer.go Normal file
View File

@@ -0,0 +1,744 @@
package store
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Phase 6 Slice C — MBrianWriter is the live write-path adapter against
// mBrian's scoped HTTP write API. Per mbrian/head's mechanism call
// (option (c), 2026-06-01, m/mBrian#73): projax writes go through an HTTP
// API that reuses mBrian's db.ts slug-generation / collision-resolution /
// singleton logic, NOT raw SQL and NOT SECURITY DEFINER functions —
// reimplementing that logic in SQL would drift.
//
// So this is an HTTP CLIENT, not a pgx writer. Reads stay direct-DB
// (MBrianReader, slice B). The asymmetry is deliberate: reads are pure
// projections; writes must funnel through db.ts so projax-created nodes
// are byte-identical to UI / MCP / migration-script nodes.
//
// The scoped API enforces projax-ownership server-side (every node it
// touches must carry metadata.projax_origin) so a projax bug can never
// corrupt non-projax mBrian nodes (m's journal / contacts / health).
//
// Final surface (m/mBrian#73 issuecomment-10720):
// POST /api/projax/nodes {title, content_md?, aliases?, mai_managed?, projax_origin, projax:{...}} → 201 {id, slug}
// PATCH /api/projax/nodes/{id} {title?, content_md?, aliases?, projax?:{...partial}} → 200 {id, slug}
// DELETE /api/projax/nodes/{id} → 204 (soft-delete)
// POST /api/projax/edges {source, target, rel, metadata?} → 201|200 {id}
// DELETE /api/projax/edges {source, target, rel} → 204
// Bearer-token auth; base URL + token from PROJAX_MBRIAN_API_URL /
// PROJAX_MBRIAN_API_TOKEN (projax-side env names; the mBrian server reads
// the same secret under PROJAX_WRITE_TOKEN). Errors: 400 malformed,
// 401 bad token, 403 not projax-owned, 404 missing, 500 db.ts error,
// 503 token not configured server-side (fail-closed: backend not ready).
//
// KNOWN API GAPS flagged to head (m/mBrian#73), reconcile before relying
// on them in production:
// G1 (latent) — edges key on (source,target,rel) only. POST is
// idempotent on that tuple and DELETE removes by it, so an item
// cannot hold two links of the same ref_type (e.g. two dated docs,
// multiple gitea-issues, multiple calendars). Current data has zero
// such cases (verified), so this is latent, not an active break.
// G2 — POST /edges has no `note` field; AddLinkDated's note is captured
// in edge metadata.note instead of the edge.note column the reader
// reads. Notes added post-cutover won't surface as ItemLink.Note
// until the reader also reads metadata.note (or the API gains note).
// G3 (active) — PATCH exposes no pinned/archived; the reader reads those
// from node columns. This writer captures them in metadata.projax.
// {pinned,archived} so intent isn't lost, but a star/archive toggle
// won't round-trip until the reader falls back to metadata.projax
// (recommended — keeps pinned/archived alongside status/tags) or the
// API exposes the columns.
// G4 (minor) — Create has no arbitrary top-level metadata passthrough;
// CreateInput.Metadata keys outside the projax bundle are dropped
// (no current caller sets them).
// MBrianWriter satisfies store.ItemWriter by calling mBrian's scoped
// /api/projax/* HTTP write API. It holds a direct pool for the narrow
// read-backs the write path needs (materialising the created/updated
// Item, resolving an edge's source/target/rel from its id before a
// DELETE, diffing child_of edges on reparent) — reads stay direct-DB.
type MBrianWriter struct {
baseURL string
token string
http *http.Client
pool *pgxpool.Pool
}
// NewMBrianWriter wires the writer to mBrian's HTTP write API. baseURL is
// the scheme+host (e.g. https://mbrian.x.msbls.de); token is the shared
// bearer. pool is the same msupabase pool the reader uses, for the
// read-backs.
func NewMBrianWriter(baseURL, token string, pool *pgxpool.Pool) *MBrianWriter {
return &MBrianWriter{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
http: &http.Client{Timeout: 15 * time.Second},
pool: pool,
}
}
// Compile-time witness: MBrianWriter satisfies ItemWriter.
var _ ItemWriter = (*MBrianWriter)(nil)
// reader builds a read adapter over the same pool for write-path
// read-backs (materialise created/updated items, etc.).
func (w *MBrianWriter) reader() *MBrianReader { return NewMBrianReader(w.pool) }
// ====================================================================
// HTTP plumbing
// ====================================================================
// APIError is the typed error returned for any non-2xx response from the
// mBrian write API, carrying the HTTP status + server message so handlers
// can render it without substring-matching.
type APIError struct {
Status int
Message string
Op string // e.g. "POST /api/projax/nodes"
}
func (e *APIError) Error() string {
msg := e.Message
if msg == "" {
msg = http.StatusText(e.Status)
}
switch e.Status {
case http.StatusUnauthorized:
return fmt.Sprintf("mBrian write API %s: unauthorized (token missing or wrong)", e.Op)
case http.StatusForbidden:
return fmt.Sprintf("mBrian write API %s: target node is not projax-owned", e.Op)
case http.StatusServiceUnavailable:
return fmt.Sprintf("mBrian write API %s: write backend not ready (token not configured)", e.Op)
}
return fmt.Sprintf("mBrian write API %s: %d %s", e.Op, e.Status, msg)
}
// do issues one JSON request to the mBrian write API and decodes a JSON
// response into out (when out != nil and the response carries a body). A
// non-2xx status becomes an *APIError; a 404 additionally wraps
// ErrNotFound so callers branching on it keep working.
func (w *MBrianWriter) do(ctx context.Context, method, path string, body, out any) error {
op := method + " " + path
if w.baseURL == "" {
return &APIError{Status: http.StatusServiceUnavailable, Message: "PROJAX_MBRIAN_API_URL not set", Op: op}
}
if w.token == "" {
// Never send an empty Bearer — fail closed and legibly.
return &APIError{Status: http.StatusServiceUnavailable, Message: "PROJAX_MBRIAN_API_TOKEN not set", Op: op}
}
var reqBody io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("%s: marshal body: %w", op, err)
}
reqBody = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, w.baseURL+path, reqBody)
if err != nil {
return fmt.Errorf("%s: build request: %w", op, err)
}
req.Header.Set("Authorization", "Bearer "+w.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := w.http.Do(req)
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
apiErr := &APIError{Status: resp.StatusCode, Message: extractAPIMessage(raw), Op: op}
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("%w: %w", ErrNotFound, apiErr)
}
return apiErr
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s: decode response: %w", op, err)
}
}
return nil
}
// extractAPIMessage pulls the {"error": "..."} message the API returns on
// every non-2xx; falls back to the raw body when it isn't that shape.
func extractAPIMessage(raw []byte) string {
var env struct {
Error string `json:"error"`
}
if json.Unmarshal(raw, &env) == nil && env.Error != "" {
return env.Error
}
return strings.TrimSpace(string(raw))
}
// mapSlugWriteErr promotes the slug-relevant statuses on a node
// create/rename into typed sentinels (ErrSlugTaken / ErrInvalidSlug) that
// web handlers + MCP tools branch on to render a clean message, while
// keeping the server's *APIError (with its message) in the chain. Only
// node create/PATCH carry a slug, so 409/400 here are slug outcomes;
// edge ops never route through this.
func mapSlugWriteErr(err error) error {
var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.Status {
case http.StatusConflict: // 409
return fmt.Errorf("%w: %w", ErrSlugTaken, apiErr)
case http.StatusBadRequest: // 400
return fmt.Errorf("%w: %w", ErrInvalidSlug, apiErr)
}
}
return err
}
// nodeWriteResponse is the {id, slug} shape POST/PATCH /nodes return.
type nodeWriteResponse struct {
ID string `json:"id"`
Slug string `json:"slug"`
}
// ====================================================================
// Item writes
// ====================================================================
// Create POSTs a new projax node, then writes its child_of parent edges,
// then materialises the result via the reader so the returned Item.ID is
// the live mBrian uuid + its derived path — the exact round-trip the
// slice-B half-flip broke (create-child against an mBrian-read parent).
func (w *MBrianWriter) Create(ctx context.Context, in CreateInput) (*Item, error) {
if len(in.Kind) == 0 {
return nil, errors.New("kind required")
}
if strings.TrimSpace(in.Title) == "" {
return nil, errors.New("title required")
}
// New (non-migrated) nodes need a non-empty projax_origin so the
// server's ownership gate accepts later PATCH/DELETE/edge ops. Mint a
// fresh uuid — the audit marker for projax-born nodes.
origin, err := newUUIDv4()
if err != nil {
return nil, fmt.Errorf("mint projax_origin: %w", err)
}
body := map[string]any{
"title": in.Title,
"content_md": in.ContentMD,
"mai_managed": containsString(in.Kind, "mai-managed"),
"projax_origin": origin,
"projax": projaxBundleForCreate(in),
}
// Honor an explicit slug (the create form / MCP slug arg). Absent →
// mBrian title-derives + auto-suffixes. Slug is required on the create
// paths, so this is normally always set.
if slug := strings.TrimSpace(in.Slug); slug != "" {
body["slug"] = slug
}
var resp nodeWriteResponse
if err := w.do(ctx, http.MethodPost, "/api/projax/nodes", body, &resp); err != nil {
return nil, mapSlugWriteErr(err)
}
// Parent links are child_of edges. Idempotent POST per parent.
for _, pid := range dedupe(in.ParentIDs) {
if pid == "" {
continue
}
if err := w.postEdge(ctx, resp.ID, pid, "child_of", nil); err != nil {
return nil, fmt.Errorf("create %s: add parent %s: %w", resp.ID, pid, err)
}
}
return w.reader().GetByID(ctx, resp.ID)
}
// Update PATCHes the node's editable fields, then reconciles its child_of
// edges to match in.ParentIDs (the detail-edit form ships parent_ids in
// the same submit), then materialises the updated Item via the reader.
func (w *MBrianWriter) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
body := map[string]any{
"title": in.Title,
"content_md": in.ContentMD,
"projax": projaxBundleForUpdate(in),
}
// Send slug only on a GENUINE rename. The detail-edit form and the bulk
// path both carry the current slug in UpdateInput; sending an unchanged
// slug would trip mBrian's rename-cascade (wikilink rewrite + alias
// append) for no reason on every edit. Read the current slug and include
// it only when it actually changed. A read failure → skip the rename
// (safe: no spurious slug write).
if newSlug := strings.TrimSpace(in.Slug); newSlug != "" {
if cur, err := w.reader().GetByID(ctx, id); err == nil && cur.Slug != newSlug {
body["slug"] = newSlug
}
}
var resp nodeWriteResponse
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, &resp); err != nil {
return nil, mapSlugWriteErr(err)
}
if err := w.syncParents(ctx, id, in.ParentIDs); err != nil {
return nil, fmt.Errorf("update %s: sync parents: %w", id, err)
}
return w.reader().GetByID(ctx, id)
}
// Reparent replaces the node's child_of parent set entirely.
func (w *MBrianWriter) Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error) {
if err := w.syncParents(ctx, id, parentIDs); err != nil {
return nil, fmt.Errorf("reparent %s: %w", id, err)
}
return w.reader().GetByID(ctx, id)
}
// AddParent appends one child_of edge without disturbing existing ones.
// POST /edges is idempotent, so a duplicate parent is a no-op.
func (w *MBrianWriter) AddParent(ctx context.Context, id, parentID string) (*Item, error) {
if err := w.postEdge(ctx, id, parentID, "child_of", nil); err != nil {
return nil, fmt.Errorf("add parent %s→%s: %w", id, parentID, err)
}
return w.reader().GetByID(ctx, id)
}
// SetPublic flips public.enabled on each id. public lives nested under
// metadata.projax.public and PATCH replaces the whole `public` sub-object
// (shallow merge is at the projax-key level), so we read each item and
// re-send its full public bundle with enabled toggled — never clobber the
// description / urls / screenshots.
func (w *MBrianWriter) SetPublic(ctx context.Context, ids []string, public bool) error {
rd := w.reader()
for _, id := range ids {
it, err := rd.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("set public: load %s: %w", id, err)
}
body := map[string]any{
"projax": map[string]any{
"public": publicBundle(public, it.PublicDescription, it.PublicLiveURL, it.PublicSourceURL, it.PublicScreenshots),
},
}
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, nil); err != nil {
return err
}
}
return nil
}
// SetPinned flips pinned on each id. GAP G3: pinned is a node column the
// PATCH surface doesn't expose, so we capture it in metadata.projax.pinned
// (best-effort, won't round-trip through the column-reading reader until
// that's reconciled — see the file header).
func (w *MBrianWriter) SetPinned(ctx context.Context, ids []string, pinned bool) error {
for _, id := range ids {
body := map[string]any{
"projax": map[string]any{"pinned": pinned},
}
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, nil); err != nil {
return err
}
}
return nil
}
// SoftDelete soft-deletes one node (sets deleted_at; edges preserved).
func (w *MBrianWriter) SoftDelete(ctx context.Context, id string) error {
return w.do(ctx, http.MethodDelete, "/api/projax/nodes/"+id, nil, nil)
}
// SoftDeleteCascade soft-deletes the node and, when cascade is true, every
// descendant. Without cascade it refuses if any live descendant exists —
// same contract as *Store.SoftDeleteCascade. Descendants are resolved
// projax-side via the reader's derived paths (cycle-safe, depth-capped),
// then soft-deleted one node at a time (the HTTP API is single-node).
func (w *MBrianWriter) SoftDeleteCascade(ctx context.Context, id string, cascade bool) error {
rd := w.reader()
it, err := rd.GetByID(ctx, id)
if err != nil {
return err
}
all, err := rd.ListAll(ctx)
if err != nil {
return err
}
descendants := descendantsOf(it, all)
if len(descendants) > 0 && !cascade {
return ErrHasLiveChildren
}
for _, d := range descendants {
if err := w.SoftDelete(ctx, d.ID); err != nil {
return fmt.Errorf("cascade soft-delete %s: %w", d.ID, err)
}
}
return w.SoftDelete(ctx, id)
}
// descendantsOf returns every item in all whose path sits strictly under
// one of target's primary paths, plus any direct child naming target as a
// parent. Mirrors *Store.SoftDeleteCascade's predicate without SQL.
func descendantsOf(target *Item, all []*Item) []*Item {
prefixes := make([]string, 0, len(target.Paths))
for _, p := range target.Paths {
prefixes = append(prefixes, p+".")
}
out := []*Item{}
for _, it := range all {
if it.ID == target.ID {
continue
}
hit := containsString(it.ParentIDs, target.ID)
if !hit {
for _, p := range it.Paths {
for _, pfx := range prefixes {
if strings.HasPrefix(p, pfx) {
hit = true
break
}
}
if hit {
break
}
}
}
if hit {
out = append(out, it)
}
}
return out
}
// ====================================================================
// Link writes — projax-* self-edges
// ====================================================================
// AddLink writes an external-reference self-edge (source==target==item,
// rel='projax-<refType>') carrying the typed payload in edge metadata so
// the reader's linkFromEdge can decode it. GAP G1: POST /edges is
// idempotent on (source,target,rel), so a second link of the same
// ref_type on one item returns the existing edge instead of a new one.
func (w *MBrianWriter) AddLink(ctx context.Context, itemID, refType, refID, rel string, metadata map[string]any) (*ItemLink, error) {
return w.addLink(ctx, itemID, refType, refID, rel, nil, nil, metadata)
}
// AddLinkDated is AddLink with an event_date + explicit note. GAP G2: the
// note rides in edge metadata (the API has no note field), so it won't
// surface as ItemLink.Note via the column-reading reader yet.
func (w *MBrianWriter) AddLinkDated(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error) {
return w.addLink(ctx, itemID, refType, refID, rel, note, eventDate, metadata)
}
func (w *MBrianWriter) addLink(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error) {
if rel == "" {
rel = "contains"
}
meta := edgeMetadataForLink(refType, refID, rel, note, eventDate, metadata)
edgeID, err := w.postEdgeReturningID(ctx, itemID, itemID, "projax-"+refType, meta)
if err != nil {
return nil, fmt.Errorf("add link %s on %s: %w", refType, itemID, err)
}
l := &ItemLink{
ID: edgeID,
ItemID: itemID,
RefType: refType,
RefID: refID,
Rel: rel,
Note: note,
Metadata: map[string]any{},
EventDate: eventDate,
CreatedAt: time.Time{}, // server-assigned; re-read via the reader if needed
}
maps.Copy(l.Metadata, metadata)
return l, nil
}
// DeleteLink removes a link self-edge by its edge id. The HTTP API deletes
// by (source,target,rel), so we resolve those from the edge id via a
// direct read-back. GAP G1 guard: if more than one edge shares that
// (source,target,rel) — only possible for >1 same-ref_type links on an
// item, which the current data never has — we refuse rather than delete
// all of them, surfacing the limitation instead of losing siblings.
func (w *MBrianWriter) DeleteLink(ctx context.Context, id string) error {
var source, target, rel string
err := w.pool.QueryRow(ctx,
`SELECT source_id::text, target_id::text, rel FROM mbrian.edges WHERE id = $1`, id).
Scan(&source, &target, &rel)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return fmt.Errorf("delete link %s: %w", id, ErrNotFound)
}
return fmt.Errorf("delete link %s: resolve edge: %w", id, err)
}
var siblings int
if err := w.pool.QueryRow(ctx,
`SELECT count(*) FROM mbrian.edges WHERE source_id = $1 AND target_id = $2 AND rel = $3`,
source, target, rel).Scan(&siblings); err != nil {
return fmt.Errorf("delete link %s: count siblings: %w", id, err)
}
if siblings > 1 {
return fmt.Errorf("delete link %s: %d edges share (source,target,rel=%s); the mBrian edge API cannot delete a single one (gap G1)", id, siblings, rel)
}
return w.deleteEdge(ctx, source, target, rel)
}
// ====================================================================
// Edge helpers
// ====================================================================
func (w *MBrianWriter) postEdge(ctx context.Context, source, target, rel string, metadata map[string]any) error {
_, err := w.postEdgeReturningID(ctx, source, target, rel, metadata)
return err
}
func (w *MBrianWriter) postEdgeReturningID(ctx context.Context, source, target, rel string, metadata map[string]any) (string, error) {
body := map[string]any{"source": source, "target": target, "rel": rel}
if metadata != nil {
body["metadata"] = metadata
}
var resp struct {
ID string `json:"id"`
}
if err := w.do(ctx, http.MethodPost, "/api/projax/edges", body, &resp); err != nil {
return "", err
}
return resp.ID, nil
}
func (w *MBrianWriter) deleteEdge(ctx context.Context, source, target, rel string) error {
body := map[string]any{"source": source, "target": target, "rel": rel}
return w.do(ctx, http.MethodDelete, "/api/projax/edges", body, nil)
}
// childOfTargets returns the current child_of parent ids for a node,
// read direct-DB (a write-path read-back, scoped to projax-managed nodes).
func (w *MBrianWriter) childOfTargets(ctx context.Context, id string) ([]string, error) {
rows, err := w.pool.Query(ctx,
`SELECT target_id::text FROM mbrian.edges WHERE source_id = $1 AND rel = 'child_of'`, id)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// syncParents diffs the node's current child_of edges against the desired
// parent set, deleting removed edges and adding new ones. Reparent and the
// parent-changing part of Update both route through here.
func (w *MBrianWriter) syncParents(ctx context.Context, id string, desired []string) error {
want := map[string]bool{}
for _, p := range dedupe(desired) {
if p != "" {
want[p] = true
}
}
current, err := w.childOfTargets(ctx, id)
if err != nil {
return err
}
have := map[string]bool{}
for _, p := range current {
have[p] = true
}
for p := range have {
if !want[p] {
if err := w.deleteEdge(ctx, id, p, "child_of"); err != nil {
return err
}
}
}
for p := range want {
if !have[p] {
if err := w.postEdge(ctx, id, p, "child_of", nil); err != nil {
return err
}
}
}
return nil
}
// ====================================================================
// Payload shaping
// ====================================================================
// projaxBundleForCreate builds the metadata.projax object for a new node.
// The server stores it verbatim under metadata.projax. pinned is captured
// here per GAP G3.
func projaxBundleForCreate(in CreateInput) map[string]any {
kind := "project"
if containsString(in.Kind, "area") {
kind = "area"
}
status := in.Status
if status == "" {
status = "active"
}
b := map[string]any{
"kind": kind,
"status": status,
"tags": orEmpty(in.Tags),
"management": orEmpty(in.Management),
"public": map[string]any{},
"timeline_exclude": []string{},
"start_time": timePtrToJSON(in.StartTime),
"end_time": timePtrToJSON(in.EndTime),
}
if in.Pinned {
b["pinned"] = true
}
return b
}
// projaxBundleForUpdate builds the partial metadata.projax object for a
// PATCH. PATCH shallow-merges these keys into the existing projax bundle,
// so we send the full set the edit form owns (status/tags/management/
// public/timeline_exclude/start/end) + pinned/archived (GAP G3).
func projaxBundleForUpdate(in UpdateInput) map[string]any {
return map[string]any{
"status": in.Status,
"tags": orEmpty(in.Tags),
"management": orEmpty(in.Management),
"public": publicBundle(in.Public, in.PublicDescription, in.PublicLiveURL, in.PublicSourceURL, in.PublicScreenshots),
"timeline_exclude": orEmpty(in.TimelineExclude),
"start_time": timePtrToJSON(in.StartTime),
"end_time": timePtrToJSON(in.EndTime),
"pinned": in.Pinned,
"archived": in.Archived,
// Phase 7 checklist render hint (Q1). Shallow-merged into
// metadata.projax; "" turns the compact render off.
"render": in.Render,
}
}
// publicBundle mirrors the reader's metadata.projax.public.* shape.
func publicBundle(enabled bool, description, liveURL, sourceURL string, screenshots []string) map[string]any {
return map[string]any{
"enabled": enabled,
"description": description,
"live_url": liveURL,
"source_url": sourceURL,
"screenshots": orEmpty(screenshots),
}
}
// edgeMetadataForLink produces the edge metadata the reader's linkFromEdge
// can decode back into an ItemLink: the typed per-ref_type payload, the
// free-form rel under projax_rel, the canonical ref_id, an optional
// event_date, the note (GAP G2), and any caller-supplied extras.
func edgeMetadataForLink(refType, refID, rel string, note *string, eventDate *time.Time, extra map[string]any) map[string]any {
m := map[string]any{}
maps.Copy(m, extra)
m["projax_rel"] = rel
m["ref_id"] = refID
switch refType {
case "caldav-list":
m["url"] = refID
case "gitea-repo":
if owner, repo, ok := splitOwnerRepo(refID); ok {
m["owner"], m["repo"] = owner, repo
}
case "gitea-issue":
if owner, repo, num, ok := splitOwnerRepoIssue(refID); ok {
m["owner"], m["repo"], m["number"] = owner, repo, num
}
case "mai-project":
m["mai_project_id"] = refID
case "url", "doc", "document", "note":
m["url"] = refID
}
if eventDate != nil {
m["event_date"] = eventDate.Format("2006-01-02")
}
if note != nil && *note != "" {
m["note"] = *note
}
return m
}
func splitOwnerRepo(s string) (string, string, bool) {
parts := strings.SplitN(s, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
func splitOwnerRepoIssue(s string) (string, string, int, bool) {
hash := strings.LastIndex(s, "#")
if hash < 0 {
return "", "", 0, false
}
owner, repo, ok := splitOwnerRepo(s[:hash])
if !ok {
return "", "", 0, false
}
var num int
if _, err := fmt.Sscanf(s[hash+1:], "%d", &num); err != nil || num <= 0 {
return "", "", 0, false
}
return owner, repo, num, true
}
// ====================================================================
// Small utilities
// ====================================================================
// newUUIDv4 mints a random RFC-4122 v4 uuid without pulling a uuid
// dependency — projax only needs the projax_origin audit marker to be a
// non-empty, unique-enough string.
func newUUIDv4() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
func dedupe(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
if seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
func orEmpty(in []string) []string {
if in == nil {
return []string{}
}
return in
}
func timePtrToJSON(t *time.Time) any {
if t == nil {
return nil
}
return t.Format(time.RFC3339)
}

View File

@@ -0,0 +1,118 @@
package store
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// Phase 7 — mBrian-native task WRITE path. Tasks funnel through the same
// scoped /api/projax HTTP surface the slice-C MBrianWriter already uses, so a
// projax-created task node is byte-identical to a UI/MCP/migration node. The
// only new server-side capability this relies on is the `type` field on
// POST /api/projax/nodes (allowlist {project,task}); everything else
// (PATCH-projax partial, POST /edges child_of, DELETE node) already exists.
// Compile-time witness: MBrianWriter satisfies TaskWriter.
var _ TaskWriter = (*MBrianWriter)(nil)
// CreateTask POSTs a type=['task'] node (slug honored, metadata.projax
// carrying status + optional due), attaches it to ParentItemID via a child_of
// edge, and returns the materialised Task. The returned Task is built from the
// known create inputs + the server-assigned id — no read-back round-trip
// needed (the next page render re-reads via TasksForItem).
func (w *MBrianWriter) CreateTask(ctx context.Context, in TaskCreateInput) (*Task, error) {
if strings.TrimSpace(in.Title) == "" {
return nil, errors.New("task title required")
}
if strings.TrimSpace(in.ParentItemID) == "" {
return nil, errors.New("task parent required")
}
origin, err := newUUIDv4()
if err != nil {
return nil, fmt.Errorf("mint projax_origin: %w", err)
}
projax := map[string]any{"status": "active"}
if in.Due != nil {
projax["due"] = dueToJSON(in.Due)
}
body := map[string]any{
"title": in.Title,
"type": "task",
"content_md": "",
"mai_managed": false,
"projax_origin": origin,
"projax": projax,
}
if slug := strings.TrimSpace(in.Slug); slug != "" {
body["slug"] = slug
}
var resp nodeWriteResponse
if err := w.do(ctx, http.MethodPost, "/api/projax/nodes", body, &resp); err != nil {
return nil, mapSlugWriteErr(err)
}
if err := w.postEdge(ctx, resp.ID, in.ParentItemID, "child_of", nil); err != nil {
return nil, fmt.Errorf("create task %s: attach to %s: %w", resp.ID, in.ParentItemID, err)
}
slug := strings.TrimSpace(in.Slug)
if resp.Slug != "" {
slug = resp.Slug // server-resolved (auto-suffix etc.) wins
}
return &Task{
ID: resp.ID,
Title: in.Title,
Done: false,
Due: in.Due,
Source: TaskSourceMBrian,
Status: "active",
ParentItemID: in.ParentItemID,
NodeID: resp.ID,
Slug: slug,
}, nil
}
// SetTaskStatus PATCHes metadata.projax.status. Task done-state reuses the
// existing lifecycle (Q2): done = status "done", reopen = status "active".
func (w *MBrianWriter) SetTaskStatus(ctx context.Context, nodeID, status string) error {
body := map[string]any{"projax": map[string]any{"status": status}}
return w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+nodeID, body, nil)
}
// SetTaskDue PATCHes metadata.projax.due. A nil due writes "" — the reader's
// parseTimeAny treats an empty string as no-due, so this clears it
// deterministically without depending on null-key-delete semantics in the
// shallow-merge PATCH.
func (w *MBrianWriter) SetTaskDue(ctx context.Context, nodeID string, due *time.Time) error {
val := ""
if due != nil {
val = dueToJSON(due)
}
body := map[string]any{"projax": map[string]any{"due": val}}
return w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+nodeID, body, nil)
}
// EditTaskTitle PATCHes the node title.
func (w *MBrianWriter) EditTaskTitle(ctx context.Context, nodeID, title string) error {
body := map[string]any{"title": title}
return w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+nodeID, body, nil)
}
// DeleteTask soft-deletes the task node (reuses the node DELETE endpoint).
func (w *MBrianWriter) DeleteTask(ctx context.Context, nodeID string) error {
return w.SoftDelete(ctx, nodeID)
}
// dueToJSON renders a due date for metadata.projax.due. Date-only (clock at
// midnight) → "2006-01-02"; otherwise RFC3339. parseTimeAny reads both back.
func dueToJSON(t *time.Time) string {
if t == nil {
return ""
}
if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 {
return t.Format("2006-01-02")
}
return t.Format(time.RFC3339)
}

View File

@@ -0,0 +1,151 @@
package store
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestMBrianTaskRoundTrip is the Phase 7b end-to-end verification: it drives a
// full mBrian-native task lifecycle (create → read → done → due → delete)
// against the LIVE mBrian write API + read DB, then asserts the task never
// leaks into the project list surface. It is the deploy-verification gate for
// "tasks live end-to-end" and is skipped unless all three live endpoints are
// configured:
//
// PROJAX_MBRIAN_API_URL e.g. https://mbrian.x.msbls.de
// PROJAX_MBRIAN_API_TOKEN the shared bearer (mBrian-side PROJAX_WRITE_TOKEN)
// SUPABASE_DATABASE_URL the msupabase pool the reader uses
//
// Run with all three set (head/CI):
//
// go test ./store/ -run TestMBrianTaskRoundTrip -v
//
// It is fully self-cleaning: it creates a throwaway parent project, attaches a
// task, and soft-deletes both at the end.
func TestMBrianTaskRoundTrip(t *testing.T) {
apiURL := os.Getenv("PROJAX_MBRIAN_API_URL")
apiToken := os.Getenv("PROJAX_MBRIAN_API_TOKEN")
dbURL := os.Getenv("SUPABASE_DATABASE_URL")
if apiURL == "" || apiToken == "" || dbURL == "" {
t.Skip("set PROJAX_MBRIAN_API_URL + PROJAX_MBRIAN_API_TOKEN + SUPABASE_DATABASE_URL to run the live task round-trip")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
t.Fatalf("pool: %v", err)
}
defer pool.Close()
w := NewMBrianWriter(apiURL, apiToken, pool)
rd := NewMBrianReader(pool)
// Unique-ish suffix without Math.rand/time.Now in the slug logic; the
// nanosecond stamp keeps reruns from colliding on the throwaway slugs.
suffix := time.Now().UTC().Format("20060102t150405.000000000")
// 1. Throwaway parent project.
parent, err := w.Create(ctx, CreateInput{
Kind: []string{"project"},
Title: "phase7b-itest-parent " + suffix,
Slug: "phase7b-itest-parent-" + sanitizeSlugStamp(suffix),
})
if err != nil {
t.Fatalf("create parent: %v", err)
}
t.Cleanup(func() { _ = w.SoftDelete(context.Background(), parent.ID) })
// 2. Create a task under it (slug auto-derived from title).
due := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)
task, err := w.CreateTask(ctx, TaskCreateInput{
Title: "phase7b-itest-task " + suffix,
ParentItemID: parent.ID,
Due: &due,
})
if err != nil {
t.Fatalf("create task: %v", err)
}
t.Cleanup(func() { _ = w.DeleteTask(context.Background(), task.ID) })
if task.Source != TaskSourceMBrian || task.NodeID == "" {
t.Fatalf("created task shape wrong: %+v", task)
}
// 3. Read it back via TasksForItem — present, open, due preserved.
tasks, err := rd.TasksForItem(ctx, parent.ID)
if err != nil {
t.Fatalf("TasksForItem: %v", err)
}
got := findTask(tasks, task.ID)
if got == nil {
t.Fatalf("created task %s not returned by TasksForItem", task.ID)
}
if got.Done {
t.Fatal("fresh task should be open")
}
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-06-30" {
t.Fatalf("due not preserved: %v", got.Due)
}
// 4. The task must NOT leak into the project list surface (Q6 exclusion).
all, err := rd.ListAll(ctx)
if err != nil {
t.Fatalf("ListAll: %v", err)
}
for _, it := range all {
if it.ID == task.ID {
t.Fatalf("task %s leaked into ListAll (project surface)", task.ID)
}
}
// 5. Mark done → reads back done.
if err := w.SetTaskStatus(ctx, task.ID, "done"); err != nil {
t.Fatalf("set done: %v", err)
}
tasks, _ = rd.TasksForItem(ctx, parent.ID)
if got = findTask(tasks, task.ID); got == nil || !got.Done {
t.Fatalf("task not done after SetTaskStatus: %+v", got)
}
// 6. Clear the due date → reads back nil.
if err := w.SetTaskDue(ctx, task.ID, nil); err != nil {
t.Fatalf("clear due: %v", err)
}
tasks, _ = rd.TasksForItem(ctx, parent.ID)
if got = findTask(tasks, task.ID); got == nil || got.Due != nil {
t.Fatalf("due not cleared: %+v", got)
}
// 7. Delete → gone from TasksForItem.
if err := w.DeleteTask(ctx, task.ID); err != nil {
t.Fatalf("delete task: %v", err)
}
tasks, _ = rd.TasksForItem(ctx, parent.ID)
if findTask(tasks, task.ID) != nil {
t.Fatalf("task %s still present after delete", task.ID)
}
}
func findTask(tasks []*Task, id string) *Task {
for _, t := range tasks {
if t.ID == id {
return t
}
}
return nil
}
// sanitizeSlugStamp strips the dot from the nanosecond stamp so the throwaway
// slug stays dot-free (the projax slug invariant).
func sanitizeSlugStamp(s string) string {
out := make([]rune, 0, len(s))
for _, r := range s {
if r == '.' {
continue
}
out = append(out, r)
}
return string(out)
}

323
store/mbrian_writer_test.go Normal file
View File

@@ -0,0 +1,323 @@
package store
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
)
// newTestWriter points an MBrianWriter at an httptest server. pool is nil:
// the HTTP-only paths under test (do, postEdge, deleteEdge, AddLink) never
// touch it. The pool-backed read-backs (Create/Update/Reparent/SetPublic/
// DeleteLink) are exercised by the live cutover round-trip + the reader
// parity tests, not here.
func newTestWriter(baseURL, token string) *MBrianWriter {
return NewMBrianWriter(baseURL, token, nil)
}
func TestMBrianWriterErrorMapping(t *testing.T) {
cases := []struct {
status int
body string
wantNotFn bool // expect errors.Is(err, ErrNotFound)
wantText string
}{
{http.StatusUnauthorized, `{"error":"bad token"}`, false, "unauthorized"},
{http.StatusForbidden, `{"error":"not projax-owned"}`, false, "not projax-owned"},
{http.StatusNotFound, `{"error":"missing"}`, true, ""},
{http.StatusServiceUnavailable, `{"error":"token not configured"}`, false, "write backend not ready"},
{http.StatusInternalServerError, `{"error":"db boom"}`, false, "db boom"},
{http.StatusBadRequest, `{"error":"disallowed rel"}`, false, "disallowed rel"},
}
for _, c := range cases {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(c.status)
io.WriteString(w, c.body)
}))
w := newTestWriter(srv.URL, "tok")
err := w.do(context.Background(), http.MethodDelete, "/api/projax/nodes/x", nil, nil)
srv.Close()
if err == nil {
t.Fatalf("status %d: expected error, got nil", c.status)
}
if c.wantNotFn && !errors.Is(err, ErrNotFound) {
t.Errorf("status %d: expected ErrNotFound wrap, got %v", c.status, err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Errorf("status %d: expected *APIError in chain, got %v", c.status, err)
continue
}
if apiErr.Status != c.status {
t.Errorf("status %d: APIError.Status = %d", c.status, apiErr.Status)
}
if c.wantText != "" && !strings.Contains(err.Error(), c.wantText) {
t.Errorf("status %d: error %q missing %q", c.status, err.Error(), c.wantText)
}
}
}
func TestMBrianWriterFailsClosedWithoutToken(t *testing.T) {
// No token → must not fire a request with an empty Bearer.
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
w := newTestWriter(srv.URL, "")
err := w.do(context.Background(), http.MethodPost, "/api/projax/nodes", map[string]any{"x": 1}, nil)
if err == nil {
t.Fatal("expected fail-closed error with empty token")
}
if called {
t.Error("request was sent despite empty token — must fail closed")
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Status != http.StatusServiceUnavailable {
t.Errorf("expected 503-style APIError, got %v", err)
}
}
func TestMBrianWriterFailsClosedWithoutURL(t *testing.T) {
w := newTestWriter("", "tok")
err := w.do(context.Background(), http.MethodPost, "/api/projax/nodes", nil, nil)
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Status != http.StatusServiceUnavailable {
t.Errorf("expected 503-style APIError for empty base URL, got %v", err)
}
}
func TestMBrianWriterSendsBearerAndJSON(t *testing.T) {
var gotAuth, gotCT, gotMethod, gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotCT = r.Header.Get("Content-Type")
gotMethod = r.Method
gotPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusCreated)
io.WriteString(w, `{"id":"e1"}`)
}))
defer srv.Close()
w := newTestWriter(srv.URL, "sekrit")
var out struct {
ID string `json:"id"`
}
if err := w.do(context.Background(), http.MethodPost, "/api/projax/edges", map[string]any{"rel": "child_of"}, &out); err != nil {
t.Fatalf("do: %v", err)
}
if gotAuth != "Bearer sekrit" {
t.Errorf("Authorization = %q, want Bearer sekrit", gotAuth)
}
if gotCT != "application/json" {
t.Errorf("Content-Type = %q", gotCT)
}
if gotMethod != http.MethodPost || gotPath != "/api/projax/edges" {
t.Errorf("method/path = %s %s", gotMethod, gotPath)
}
if gotBody["rel"] != "child_of" {
t.Errorf("body rel = %v", gotBody["rel"])
}
if out.ID != "e1" {
t.Errorf("decoded id = %q", out.ID)
}
}
func TestMBrianWriterAddLinkConstructsSelfEdge(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/projax/edges" || r.Method != http.MethodPost {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusCreated)
io.WriteString(w, `{"id":"edge-123"}`)
}))
defer srv.Close()
w := newTestWriter(srv.URL, "tok")
link, err := w.AddLink(context.Background(), "item-1", "caldav-list", "https://dav/cal", "contains",
map[string]any{"display_name": "Work"})
if err != nil {
t.Fatalf("AddLink: %v", err)
}
// Self-edge: source == target == item, rel namespaced.
if gotBody["source"] != "item-1" || gotBody["target"] != "item-1" {
t.Errorf("self-edge source/target = %v/%v", gotBody["source"], gotBody["target"])
}
if gotBody["rel"] != "projax-caldav-list" {
t.Errorf("rel = %v, want projax-caldav-list", gotBody["rel"])
}
meta, _ := gotBody["metadata"].(map[string]any)
if meta["url"] != "https://dav/cal" {
t.Errorf("metadata.url = %v (reader decodes caldav RefID from here)", meta["url"])
}
if meta["ref_id"] != "https://dav/cal" {
t.Errorf("metadata.ref_id = %v", meta["ref_id"])
}
if meta["projax_rel"] != "contains" {
t.Errorf("metadata.projax_rel = %v", meta["projax_rel"])
}
if meta["display_name"] != "Work" {
t.Errorf("caller metadata not merged: %v", meta["display_name"])
}
if link.ID != "edge-123" || link.ItemID != "item-1" || link.RefType != "caldav-list" {
t.Errorf("returned link = %+v", link)
}
}
func TestMBrianWriterAddLinkDatedCarriesDateAndNote(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusCreated)
io.WriteString(w, `{"id":"e9"}`)
}))
defer srv.Close()
w := newTestWriter(srv.URL, "tok")
note := "filed brief"
d := time.Date(2026, 3, 14, 0, 0, 0, 0, time.UTC)
_, err := w.AddLinkDated(context.Background(), "i1", "doc", "/docs/brief.pdf", "source", &note, &d, nil)
if err != nil {
t.Fatalf("AddLinkDated: %v", err)
}
meta, _ := gotBody["metadata"].(map[string]any)
if meta["event_date"] != "2026-03-14" {
t.Errorf("event_date = %v", meta["event_date"])
}
if meta["note"] != "filed brief" {
t.Errorf("note = %v (gap G2: rides in metadata, not edge.note)", meta["note"])
}
if meta["url"] != "/docs/brief.pdf" {
t.Errorf("doc url = %v", meta["url"])
}
}
func TestEdgeMetadataForLinkPerRefType(t *testing.T) {
mGitRepo := edgeMetadataForLink("gitea-repo", "m/projax", "contains", nil, nil, nil)
if mGitRepo["owner"] != "m" || mGitRepo["repo"] != "projax" {
t.Errorf("gitea-repo owner/repo = %v/%v", mGitRepo["owner"], mGitRepo["repo"])
}
mIssue := edgeMetadataForLink("gitea-issue", "m/projax#5", "contains", nil, nil, nil)
if mIssue["owner"] != "m" || mIssue["repo"] != "projax" || mIssue["number"] != 5 {
t.Errorf("gitea-issue parse = %v/%v/#%v", mIssue["owner"], mIssue["repo"], mIssue["number"])
}
mMai := edgeMetadataForLink("mai-project", "proj-uuid", "contains", nil, nil, nil)
if mMai["mai_project_id"] != "proj-uuid" {
t.Errorf("mai-project id = %v", mMai["mai_project_id"])
}
}
func TestProjaxBundleForCreateDefaults(t *testing.T) {
b := projaxBundleForCreate(CreateInput{Kind: []string{"project"}, Title: "x"})
if b["kind"] != "project" {
t.Errorf("kind = %v", b["kind"])
}
if b["status"] != "active" {
t.Errorf("status default = %v, want active", b["status"])
}
// area co-kind
ba := projaxBundleForCreate(CreateInput{Kind: []string{"project", "area"}, Title: "x", Status: "done"})
if ba["kind"] != "area" {
t.Errorf("area kind = %v", ba["kind"])
}
if ba["status"] != "done" {
t.Errorf("explicit status = %v", ba["status"])
}
}
func TestProjaxBundleForUpdateNestsPublic(t *testing.T) {
b := projaxBundleForUpdate(UpdateInput{
Status: "active", Public: true, PublicDescription: "desc", PublicLiveURL: "https://x",
})
pub, ok := b["public"].(map[string]any)
if !ok {
t.Fatalf("public not a nested object: %T", b["public"])
}
if pub["enabled"] != true || pub["description"] != "desc" || pub["live_url"] != "https://x" {
t.Errorf("public bundle = %v (must match reader's projax.public.* shape)", pub)
}
}
func TestMBrianWriterCreateSendsSlugAndMaps409(t *testing.T) {
// The server asserts the POST body carries the explicit slug, then
// answers 409 — which returns before Create's pool-backed read-back, so
// no DB is needed. Proves both "slug is sent" and "409 → ErrSlugTaken".
var gotSlug any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotSlug = body["slug"]
w.WriteHeader(http.StatusConflict)
io.WriteString(w, `{"error":"slug 'paliad' already exists"}`)
}))
defer srv.Close()
w := newTestWriter(srv.URL, "tok")
_, err := w.Create(context.Background(), CreateInput{Kind: []string{"project"}, Title: "Paliad", Slug: "paliad"})
if gotSlug != "paliad" {
t.Errorf("create body slug = %v, want paliad (explicit slug must be sent, not title-derived)", gotSlug)
}
if !errors.Is(err, ErrSlugTaken) {
t.Errorf("409 should map to ErrSlugTaken, got %v", err)
}
}
func TestMapSlugWriteErr(t *testing.T) {
cases := []struct {
status int
want error // sentinel expected via errors.Is, nil = passthrough (no slug sentinel)
}{
{http.StatusConflict, ErrSlugTaken},
{http.StatusBadRequest, ErrInvalidSlug},
{http.StatusForbidden, nil},
{http.StatusInternalServerError, nil},
}
for _, c := range cases {
in := &APIError{Status: c.status, Message: "x", Op: "POST /api/projax/nodes"}
got := mapSlugWriteErr(in)
if c.want != nil && !errors.Is(got, c.want) {
t.Errorf("status %d: expected %v, got %v", c.status, c.want, got)
}
if c.want == nil && (errors.Is(got, ErrSlugTaken) || errors.Is(got, ErrInvalidSlug)) {
t.Errorf("status %d: should not map to a slug sentinel, got %v", c.status, got)
}
// The original *APIError must stay in the chain either way.
var apiErr *APIError
if !errors.As(got, &apiErr) || apiErr.Status != c.status {
t.Errorf("status %d: lost the *APIError in the chain: %v", c.status, got)
}
}
// Non-APIError passes through untouched.
plain := errors.New("network down")
if got := mapSlugWriteErr(plain); got != plain {
t.Errorf("non-APIError should pass through unchanged, got %v", got)
}
}
var uuidV4Re = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
func TestNewUUIDv4Format(t *testing.T) {
seen := map[string]bool{}
for range 100 {
u, err := newUUIDv4()
if err != nil {
t.Fatalf("newUUIDv4: %v", err)
}
if !uuidV4Re.MatchString(u) {
t.Fatalf("not a v4 uuid: %q", u)
}
if seen[u] {
t.Fatalf("duplicate uuid %q", u)
}
seen[u] = true
}
}

View File

@@ -46,10 +46,20 @@ type Item struct {
// /timeline aggregation. Values: 'todos' | 'events' | 'docs' | 'creation'.
// Empty array (default) = nothing excluded = current behaviour.
TimelineExclude []string
// Phase 7 render hint. When "checklist", this container's child tasks
// render in compact checklist mode (design Q1 — a "tasklist"/"checklist"
// is any container carrying this hint, not a new type). Empty = the
// default roomy task rows. mBrian-backed only (metadata.projax.render);
// the legacy *Store leaves it "".
Render string
CreatedAt time.Time
UpdatedAt time.Time
}
// RendersChecklist reports whether this container should render its child
// tasks as a compact checklist (Phase 7, Q1).
func (it *Item) RendersChecklist() bool { return it.Render == "checklist" }
// ExcludesTimelineKind reports whether this item's timeline_exclude array
// names the given kind. The aggregator uses the singular form ("todo",
// "event", "doc", "creation"); the persisted values use the plural form
@@ -123,6 +133,17 @@ func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
var ErrNotFound = errors.New("projax: item not found")
// ErrSlugTaken is returned when a create or rename hits a slug collision —
// mBrian's write API answers 409. Covers both a live node and a
// soft-deleted tombstone squatting on the slug (the latter the projax-side
// validator can't see, since it scopes to non-deleted nodes). Handlers +
// MCP branch on it via errors.Is to surface a clean "slug taken" message.
var ErrSlugTaken = errors.New("projax: slug already taken")
// ErrInvalidSlug is returned when the write API rejects a slug as
// malformed or empty (400).
var ErrInvalidSlug = errors.New("projax: invalid slug")
const itemsUnifiedCols = `id, kind, title, slug, paths, parent_ids, content_md, aliases,
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
tags, management, public, public_description, public_live_url, public_source_url,
@@ -312,6 +333,10 @@ type UpdateInput struct {
// Phase 4f timeline-exclude. Full-replace; values 'todos' / 'events' /
// 'docs' / 'creation'.
TimelineExclude []string
// Phase 7 checklist render hint. "checklist" → child tasks render
// compact; "" → default. mBrian-backed only (the legacy *Store.Update
// ignores it — there is no projax.items column for it).
Render string
}
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {

91
store/task.go Normal file
View File

@@ -0,0 +1,91 @@
package store
import (
"context"
"time"
)
// Phase 7 — Task is the uniform view-shape for a unit of work attached to a
// projax item, materialised from EITHER a CalDAV VTODO or an mBrian
// type=['task'] node. One shape, two sources; writes dispatch on Source
// (design §3.3, the slice-B "one Item shape, two backends" pattern applied
// to tasks). Consumers (the detail Tasks section, future dashboard/timeline
// rollups) render the uniform shape and don't care which backend produced it.
type Task struct {
// ID is a stable per-source identifier: the mBrian node uuid for an
// mBrian-native task, the VTODO UID for a CalDAV task. Unique within a
// source; templates key rows on it.
ID string
Title string
Done bool
Due *time.Time
// Source is "mbrian" or "caldav" — write handlers dispatch on it.
Source string
// Status is the raw lifecycle status for mBrian tasks (active|done|
// archived). For CalDAV tasks it carries the VTODO STATUS verbatim
// (NEEDS-ACTION|IN-PROCESS|COMPLETED|CANCELLED) for callers that want it;
// Done is the normalised boolean either way.
Status string
// ParentItemID is the projax item this task hangs under.
ParentItemID string
CreatedAt time.Time
// --- mBrian-source handle (Source == TaskSourceMBrian) ---
// NodeID is the mBrian node uuid (== ID); the write API targets it.
NodeID string
Slug string
// --- CalDAV-source handle (Source == TaskSourceCalDAV) ---
// CalendarURL + UID address the VTODO for ETag-guarded writeback via the
// existing caldav write path.
CalendarURL string
UID string
}
// Task source discriminators.
const (
TaskSourceMBrian = "mbrian"
TaskSourceCalDAV = "caldav"
)
// TaskCreateInput captures the editable surface of a new mBrian-native task.
// CalDAV tasks are created through the existing CalDAV write path (VTODO PUT),
// not this shape — only the mBrian backend creates task nodes.
type TaskCreateInput struct {
Title string
Slug string
ParentItemID string // the project (or task) this task attaches to via child_of
Due *time.Time // optional
}
// TaskReader is the read-path contract for mBrian-native task nodes. It is a
// capability SEPARATE from ItemReader: only the mBrian backend has task nodes,
// so the legacy *Store does not implement it. Web handlers obtain it via a
// type-assertion on the active Items backend (see Server.taskBackend).
type TaskReader interface {
// TasksForItem returns the mBrian-native tasks (type=['task'] nodes)
// attached to itemID via a child_of edge, in created-at order (Q5 —
// created order only; no manual reorder in v1).
TasksForItem(ctx context.Context, itemID string) ([]*Task, error)
}
// TaskWriter is the write-path contract for mBrian-native task nodes. Twin of
// TaskReader; implemented only by the mBrian backend (*MBrianWriter). Writes
// funnel through mBrian's scoped /api/projax HTTP surface so projax-created
// task nodes are byte-identical to UI/MCP/migration nodes (the slice-C
// discipline). Delete reuses the node soft-delete the API already exposes.
type TaskWriter interface {
// CreateTask POSTs a type=['task'] node (slug honored, metadata.projax
// carrying status/due) then attaches it to ParentItemID via a child_of
// edge, and returns the materialised Task.
CreateTask(ctx context.Context, in TaskCreateInput) (*Task, error)
// SetTaskStatus PATCHes metadata.projax.status (done|active|archived) —
// task done-state reuses the existing lifecycle (Q2), no separate field.
SetTaskStatus(ctx context.Context, nodeID, status string) error
// SetTaskDue PATCHes metadata.projax.due; a nil due clears it.
SetTaskDue(ctx context.Context, nodeID string, due *time.Time) error
// EditTaskTitle PATCHes the node title.
EditTaskTitle(ctx context.Context, nodeID, title string) error
// DeleteTask soft-deletes the task node.
DeleteTask(ctx context.Context, nodeID string) error
}

View File

@@ -5,58 +5,108 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// View is one row in projax.views. Phase 5i Slice D — saved views.
//
// FilterJSON carries the persisted filter state as raw JSON so callers can
// freely round-trip into their TreeFilter or another future filter type
// without forcing the store package to depend on web/.
// View is one row in projax.views — a first-class /views/{slug} page.
// Phase 5j paliad-shape: the slug is the user-facing key; URLs and the
// sidebar both index by it. The uuid id stays because it's cheap and
// surfaces in future MCP integrations, but it is NOT exposed in URLs.
type View struct {
ID string
Name string
Description string
FilterJSON []byte // raw jsonb payload
ViewType string
SortField *string
SortDir *string
GroupBy *string
Pinned bool
IsDefaultFor *string
CreatedAt time.Time
UpdatedAt time.Time
ID string
Slug string
Name string
Icon *string
FilterJSON []byte // raw jsonb payload — includes view_type per m's Q2
SortField *string
SortDir *string
GroupBy *string
SortOrder int
ShowCount bool
LastUsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// ErrViewNotFound surfaces from GetView / SoftDeleteView when no row matches.
// ErrViewNotFound surfaces from Get*/Update*/Delete when no row matches.
var ErrViewNotFound = errors.New("view not found")
// ViewInput is the writeable subset of View used by Create / Update.
type ViewInput struct {
Name string
Description string
FilterJSON []byte
ViewType string
SortField string
SortDir string
GroupBy string
Pinned bool
IsDefaultFor string // "" → clear default
// ErrViewSlugTaken is returned by Create / Update when the slug already
// belongs to another view. Web handlers map this to 409.
var ErrViewSlugTaken = errors.New("view slug already exists")
// ErrViewSlugReserved is returned when the caller picks a slug that
// shadows a system slug or a top-level URL segment. Web handlers map
// this to 400 with a friendly message.
var ErrViewSlugReserved = errors.New("view slug is reserved")
// ErrViewSlugFormat is returned when the slug doesn't match the format
// regex. Same mapping as reserved.
var ErrViewSlugFormat = errors.New("view slug must match ^[a-z0-9][a-z0-9-]{0,62}$")
// slugRE is the format guard. Mirrors the SQL CHECK constraint so callers
// get a friendly error before round-tripping to the DB.
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// reservedViewSlugs is the static list of slugs the validator rejects.
// Combines system-view slugs (slice C wires them) with top-level route
// segments the application owns.
var reservedViewSlugs = map[string]struct{}{
// System views (slice C):
"tree": {}, "dashboard": {}, "calendar": {}, "timeline": {}, "graph": {},
// /views sub-routes:
"new": {}, "edit": {},
// Top-level application URLs:
"admin": {}, "login": {}, "logout": {}, "healthz": {}, "mcp": {},
"static": {}, "i": {}, "views": {},
}
// ListViews returns every non-deleted view ordered by pinned-first, then name.
// IsReservedViewSlug reports whether the slug shadows a system slug or a
// top-level URL segment. Exported for the editor's slug-derivation
// helper.
func IsReservedViewSlug(slug string) bool {
_, ok := reservedViewSlugs[strings.ToLower(slug)]
return ok
}
// ValidateSlug runs format + reserved checks. Returns nil for valid slugs.
func ValidateSlug(slug string) error {
if !slugRE.MatchString(slug) {
return ErrViewSlugFormat
}
if IsReservedViewSlug(slug) {
return ErrViewSlugReserved
}
return nil
}
// ViewInput is the writeable subset for Create / Update. Defaults
// applied: nil FilterJSON → {}; SortOrder is server-assigned on Create.
type ViewInput struct {
Slug string
Name string
Icon *string
FilterJSON []byte
SortField string
SortDir string
GroupBy string
ShowCount bool
}
// ListViews returns every view ordered by sort_order ASC then name —
// matches the sidebar rendering order.
func (s *Store) ListViews(ctx context.Context) ([]*View, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, coalesce(description,''), filter_json, view_type,
sort_field, sort_dir, group_by, pinned, is_default_for,
SELECT id, slug, name, icon, filter_json,
sort_field, sort_dir, group_by,
sort_order, show_count, last_used_at,
created_at, updated_at
FROM projax.views
WHERE deleted_at IS NULL
ORDER BY pinned DESC, lower(name) ASC`)
ORDER BY sort_order ASC, name ASC`)
if err != nil {
return nil, fmt.Errorf("list views: %w", err)
}
@@ -72,14 +122,25 @@ ORDER BY pinned DESC, lower(name) ASC`)
return out, rows.Err()
}
// GetView returns one view by id. ErrViewNotFound when missing or soft-deleted.
func (s *Store) GetView(ctx context.Context, id string) (*View, error) {
// GetView returns one view by slug. ErrViewNotFound when missing.
func (s *Store) GetView(ctx context.Context, slug string) (*View, error) {
return s.getView(ctx, `slug = $1`, slug)
}
// GetViewByID returns one view by uuid id. Used by the legacy
// `?view=<uuid>` 302-redirect path during the 5i → 5j cutover.
func (s *Store) GetViewByID(ctx context.Context, id string) (*View, error) {
return s.getView(ctx, `id = $1`, id)
}
func (s *Store) getView(ctx context.Context, where, arg string) (*View, error) {
row := s.Pool.QueryRow(ctx, `
SELECT id, name, coalesce(description,''), filter_json, view_type,
sort_field, sort_dir, group_by, pinned, is_default_for,
SELECT id, slug, name, icon, filter_json,
sort_field, sort_dir, group_by,
sort_order, show_count, last_used_at,
created_at, updated_at
FROM projax.views
WHERE id = $1 AND deleted_at IS NULL`, id)
WHERE `+where, arg)
v, err := scanView(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrViewNotFound
@@ -87,9 +148,29 @@ WHERE id = $1 AND deleted_at IS NULL`, id)
return v, err
}
// CreateView inserts a row. When IsDefaultFor is set, the prior default for
// that page is cleared in the same transaction so the partial unique index
// can't fire after a Postgres rewrite.
// MostRecentView returns the view with the most recent last_used_at. nil
// when no view has been touched yet (or none exist). Drives the /views
// landing redirect.
func (s *Store) MostRecentView(ctx context.Context) (*View, error) {
row := s.Pool.QueryRow(ctx, `
SELECT id, slug, name, icon, filter_json,
sort_field, sort_dir, group_by,
sort_order, show_count, last_used_at,
created_at, updated_at
FROM projax.views
WHERE last_used_at IS NOT NULL
ORDER BY last_used_at DESC
LIMIT 1`)
v, err := scanView(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return v, err
}
// CreateView inserts a new view. SortOrder is server-assigned to
// MAX(existing)+1 inside the same tx so two parallel creates don't
// collide on the index.
func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) {
if err := validateViewInput(in); err != nil {
return nil, err
@@ -97,95 +178,81 @@ func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) {
if in.FilterJSON == nil {
in.FilterJSON = []byte("{}")
}
var id string
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if in.IsDefaultFor != "" {
if _, err := tx.Exec(ctx, `
UPDATE projax.views
SET is_default_for = NULL
WHERE is_default_for = $1 AND deleted_at IS NULL`, in.IsDefaultFor); err != nil {
return nil, fmt.Errorf("clear prior default: %w", err)
}
var nextOrder int
if err := tx.QueryRow(ctx,
`SELECT COALESCE(MAX(sort_order), -1) + 1 FROM projax.views`,
).Scan(&nextOrder); err != nil {
return nil, fmt.Errorf("compute next sort_order: %w", err)
}
var id string
err = tx.QueryRow(ctx, `
INSERT INTO projax.views
(name, description, filter_json, view_type, sort_field, sort_dir, group_by, pinned, is_default_for)
(slug, name, icon, filter_json, sort_field, sort_dir, group_by, sort_order, show_count)
VALUES
($1, NULLIF($2,''), $3::jsonb, $4, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, NULLIF($9,''))
($1, $2, $3, $4::jsonb, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, $9)
RETURNING id`,
in.Name, in.Description, in.FilterJSON, in.ViewType,
in.SortField, in.SortDir, in.GroupBy, in.Pinned, in.IsDefaultFor,
in.Slug, in.Name, in.Icon, in.FilterJSON,
in.SortField, in.SortDir, in.GroupBy, nextOrder, in.ShowCount,
).Scan(&id)
if err != nil {
if isUniqueSlugViolation(err) {
return nil, ErrViewSlugTaken
}
return nil, fmt.Errorf("insert view: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return s.GetView(ctx, id)
return s.GetView(ctx, in.Slug)
}
// UpdateView replaces every writeable field. Same default-clearing semantics
// as CreateView.
func (s *Store) UpdateView(ctx context.Context, id string, in ViewInput) (*View, error) {
// UpdateView replaces every writeable field on the row matching `slug`.
// To rename, pass the desired new slug in `in.Slug`; if it collides with
// another row, ErrViewSlugTaken surfaces.
func (s *Store) UpdateView(ctx context.Context, slug string, in ViewInput) (*View, error) {
if err := validateViewInput(in); err != nil {
return nil, err
}
if in.FilterJSON == nil {
in.FilterJSON = []byte("{}")
}
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if in.IsDefaultFor != "" {
if _, err := tx.Exec(ctx, `
tag, err := s.Pool.Exec(ctx, `
UPDATE projax.views
SET is_default_for = NULL
WHERE is_default_for = $1 AND id <> $2 AND deleted_at IS NULL`,
in.IsDefaultFor, id); err != nil {
return nil, fmt.Errorf("clear prior default: %w", err)
}
}
tag, err := tx.Exec(ctx, `
UPDATE projax.views
SET name = $2,
description = NULLIF($3,''),
filter_json = $4::jsonb,
view_type = $5,
sort_field = NULLIF($6,''),
sort_dir = NULLIF($7,''),
group_by = NULLIF($8,''),
pinned = $9,
is_default_for = NULLIF($10,'')
WHERE id = $1 AND deleted_at IS NULL`,
id, in.Name, in.Description, in.FilterJSON, in.ViewType,
in.SortField, in.SortDir, in.GroupBy, in.Pinned, in.IsDefaultFor,
SET slug = $2,
name = $3,
icon = $4,
filter_json = $5::jsonb,
sort_field = NULLIF($6,''),
sort_dir = NULLIF($7,''),
group_by = NULLIF($8,''),
show_count = $9
WHERE slug = $1`,
slug, in.Slug, in.Name, in.Icon, in.FilterJSON,
in.SortField, in.SortDir, in.GroupBy, in.ShowCount,
)
if err != nil {
if isUniqueSlugViolation(err) {
return nil, ErrViewSlugTaken
}
return nil, fmt.Errorf("update view: %w", err)
}
if tag.RowsAffected() == 0 {
return nil, ErrViewNotFound
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return s.GetView(ctx, id)
return s.GetView(ctx, in.Slug)
}
// SoftDeleteView sets deleted_at on the row. Idempotent (returns ErrViewNotFound
// only when the row never existed; subsequent calls on a soft-deleted row
// silently succeed since deleted_at is just refreshed).
func (s *Store) SoftDeleteView(ctx context.Context, id string) error {
tag, err := s.Pool.Exec(ctx, `
UPDATE projax.views SET deleted_at = now()
WHERE id = $1`, id)
// DeleteView removes a view by slug. Hard delete (no soft-delete column
// in the redesign — single-user, no audit obligation). Idempotent only
// on the second call; first call against a non-existent row returns
// ErrViewNotFound.
func (s *Store) DeleteView(ctx context.Context, slug string) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM projax.views WHERE slug = $1`, slug)
if err != nil {
return fmt.Errorf("delete view: %w", err)
}
@@ -195,79 +262,100 @@ WHERE id = $1`, id)
return nil
}
// DefaultViewFor returns the view that should auto-apply on the named page,
// or nil if none is set.
func (s *Store) DefaultViewFor(ctx context.Context, page string) (*View, error) {
row := s.Pool.QueryRow(ctx, `
SELECT id, name, coalesce(description,''), filter_json, view_type,
sort_field, sort_dir, group_by, pinned, is_default_for,
created_at, updated_at
FROM projax.views
WHERE is_default_for = $1 AND deleted_at IS NULL
LIMIT 1`, page)
v, err := scanView(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
// TouchView bumps last_used_at to now(). Fire-and-forget from the render
// handler — failures are logged but never block the page.
func (s *Store) TouchView(ctx context.Context, slug string) error {
tag, err := s.Pool.Exec(ctx,
`UPDATE projax.views SET last_used_at = now() WHERE slug = $1`, slug)
if err != nil {
return fmt.Errorf("touch view: %w", err)
}
return v, err
if tag.RowsAffected() == 0 {
return ErrViewNotFound
}
return nil
}
// validateViewInput runs the Go-side guards. The DB CHECK constraints provide
// the durable contract; these checks let handlers surface a friendlier error.
// ReorderViews applies a sort_order rewrite where the provided slugs map
// to ascending sort_order values starting at 0. Slugs not present in the
// input keep their existing sort_order. Drives slice G's drag-reorder UI.
func (s *Store) ReorderViews(ctx context.Context, slugs []string) error {
if len(slugs) == 0 {
return nil
}
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
for i, slug := range slugs {
if _, err := tx.Exec(ctx,
`UPDATE projax.views SET sort_order = $1 WHERE slug = $2`,
i, slug,
); err != nil {
return fmt.Errorf("reorder %q: %w", slug, err)
}
}
return tx.Commit(ctx)
}
// validateViewInput runs Go-side guards. The DB CHECK constraints are the
// durable contract; these checks let handlers surface friendlier errors.
func validateViewInput(in ViewInput) error {
if err := ValidateSlug(in.Slug); err != nil {
return err
}
if strings.TrimSpace(in.Name) == "" {
return errors.New("view name is required")
}
switch in.ViewType {
case "card", "list", "calendar", "kanban", "timeline":
default:
return fmt.Errorf("invalid view_type %q (allowed: card list calendar kanban timeline)", in.ViewType)
}
if in.SortDir != "" && in.SortDir != "asc" && in.SortDir != "desc" {
return fmt.Errorf("invalid sort_dir %q", in.SortDir)
}
if in.ViewType == "kanban" && strings.TrimSpace(in.GroupBy) == "" {
return errors.New("kanban view_type requires group_by")
}
if in.IsDefaultFor != "" {
switch in.IsDefaultFor {
case "tree", "dashboard", "calendar", "timeline":
default:
return fmt.Errorf("invalid is_default_for %q", in.IsDefaultFor)
}
if in.Icon != nil && len(*in.Icon) > 64 {
return errors.New("icon key exceeds 64 characters")
}
if len(in.FilterJSON) > 0 {
var dummy any
if err := json.Unmarshal(in.FilterJSON, &dummy); err != nil {
var probe any
if err := json.Unmarshal(in.FilterJSON, &probe); err != nil {
return fmt.Errorf("filter_json is not valid JSON: %w", err)
}
}
return nil
}
// isUniqueSlugViolation matches the postgres unique_violation SQLSTATE
// (23505) on the views_slug_uniq index. We don't import pgconn here to
// avoid widening the package's dep surface; substring match on the
// pgx-formatted error covers both the wire-level codes pgx surfaces.
func isUniqueSlugViolation(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "views_slug_uniq") ||
(strings.Contains(s, "SQLSTATE 23505") && strings.Contains(s, "slug"))
}
type viewScanner interface {
Scan(dest ...any) error
}
func scanView(s viewScanner) (*View, error) {
v := &View{}
var sortField, sortDir, groupBy, isDefaultFor *string
var icon, sortField, sortDir, groupBy *string
var lastUsedAt *time.Time
if err := s.Scan(
&v.ID, &v.Name, &v.Description, &v.FilterJSON, &v.ViewType,
&sortField, &sortDir, &groupBy, &v.Pinned, &isDefaultFor,
&v.ID, &v.Slug, &v.Name, &icon, &v.FilterJSON,
&sortField, &sortDir, &groupBy,
&v.SortOrder, &v.ShowCount, &lastUsedAt,
&v.CreatedAt, &v.UpdatedAt,
); err != nil {
return nil, err
}
v.Icon = icon
v.SortField = sortField
v.SortDir = sortDir
v.GroupBy = groupBy
v.IsDefaultFor = isDefaultFor
v.LastUsedAt = lastUsedAt
return v, nil
}
// pgxRowsCompat keeps the linter quiet about importing pgxpool only for
// type assertions inside views.go. The Pool method on Store already pulls
// pgxpool into the package; nothing to do here, but the unused-import
// shadow doesn't bite.
var _ = pgxpool.Pool{}

246
store/views_test.go Normal file
View File

@@ -0,0 +1,246 @@
package store_test
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/m/projax/store"
)
// connect mirrors db_test's connect helper. The store package owns its own
// integration tests (Phase 5j Slice A introduced this file alongside the
// schema redesign); it shares the same env-var convention to skip when no
// DB is wired up.
func connect(t *testing.T) (*pgxpool.Pool, *store.Store) {
t.Helper()
url := os.Getenv("PROJAX_DB_URL")
if url == "" {
url = os.Getenv("SUPABASE_DATABASE_URL")
}
if url == "" {
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping integration test")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatalf("pool: %v", err)
}
if err := pool.Ping(ctx); err != nil {
t.Skipf("DB unreachable: %v", err)
}
return pool, store.New(pool)
}
// uniqueSlug suffixes a base slug with a timestamp so parallel test runs
// don't collide on the views_slug_uniq index.
func uniqueSlug(prefix string) string {
return prefix + "-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
}
func TestViewSlugCRUD(t *testing.T) {
pool, s := connect(t)
defer pool.Close()
ctx := context.Background()
slug := uniqueSlug("p5j-a-crud")
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug LIKE 'p5j-a-crud-%' OR slug LIKE 'p5j-a-renamed-%'`)
// Create.
created, err := s.CreateView(ctx, store.ViewInput{
Slug: slug,
Name: "Slice A CRUD",
FilterJSON: []byte(`{"view_type":"list","tags":["work"]}`),
})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Slug != slug {
t.Errorf("slug = %q, want %q", created.Slug, slug)
}
if created.ID == "" {
t.Error("ID should be populated on create")
}
if created.SortOrder < 0 {
t.Errorf("sort_order should be >= 0 (server-assigned), got %d", created.SortOrder)
}
// GetView by slug.
got, err := s.GetView(ctx, slug)
if err != nil {
t.Fatalf("get: %v", err)
}
if string(got.FilterJSON) != `{"view_type": "list", "tags": ["work"]}` && string(got.FilterJSON) != `{"tags": ["work"], "view_type": "list"}` {
// Postgres jsonb normalises key order — accept either ordering.
// Verify it round-trips structurally.
if !strings.Contains(string(got.FilterJSON), `"view_type"`) || !strings.Contains(string(got.FilterJSON), `"tags"`) {
t.Errorf("filter_json did not round-trip view_type+tags: %s", got.FilterJSON)
}
}
// GetViewByID (legacy 5i 302-redirect path uses this).
byID, err := s.GetViewByID(ctx, created.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if byID.Slug != slug {
t.Errorf("by-id lookup returned wrong slug: %q", byID.Slug)
}
// Update — rename slug + change filter.
renamed := uniqueSlug("p5j-a-renamed")
updated, err := s.UpdateView(ctx, slug, store.ViewInput{
Slug: renamed,
Name: "Renamed",
FilterJSON: []byte(`{"view_type":"card"}`),
})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Slug != renamed {
t.Errorf("renamed slug = %q, want %q", updated.Slug, renamed)
}
if _, err := s.GetView(ctx, slug); !errors.Is(err, store.ErrViewNotFound) {
t.Errorf("old slug should be ErrViewNotFound after rename, got %v", err)
}
// Delete.
if err := s.DeleteView(ctx, renamed); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := s.GetView(ctx, renamed); !errors.Is(err, store.ErrViewNotFound) {
t.Errorf("post-delete get should be ErrViewNotFound, got %v", err)
}
if err := s.DeleteView(ctx, renamed); !errors.Is(err, store.ErrViewNotFound) {
t.Errorf("second delete should be ErrViewNotFound, got %v", err)
}
}
func TestViewSlugFormatRejected(t *testing.T) {
pool, s := connect(t)
defer pool.Close()
ctx := context.Background()
bad := []string{
"", // empty
"UPPER", // uppercase
"under_score", // underscore
"-leading-dash", // leading dash
"a." + strings.Repeat("x", 100), // too long + invalid char
strings.Repeat("a", 64), // length cap is 63 (1 + 62)
}
for _, slug := range bad {
_, err := s.CreateView(ctx, store.ViewInput{
Slug: slug, Name: "x", FilterJSON: []byte(`{}`),
})
if !errors.Is(err, store.ErrViewSlugFormat) {
t.Errorf("slug=%q expected ErrViewSlugFormat, got %v", slug, err)
}
}
}
func TestViewReservedSlugRejected(t *testing.T) {
_, s := connect(t)
ctx := context.Background()
for _, slug := range []string{"tree", "dashboard", "calendar", "timeline", "graph", "new", "edit", "admin", "views"} {
_, err := s.CreateView(ctx, store.ViewInput{
Slug: slug, Name: "x", FilterJSON: []byte(`{}`),
})
if !errors.Is(err, store.ErrViewSlugReserved) {
t.Errorf("reserved slug %q should be rejected, got %v", slug, err)
}
}
}
func TestViewSlugCollision(t *testing.T) {
pool, s := connect(t)
defer pool.Close()
ctx := context.Background()
slug := uniqueSlug("p5j-a-collision")
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: "First"}); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: "Second"}); !errors.Is(err, store.ErrViewSlugTaken) {
t.Errorf("duplicate slug should be ErrViewSlugTaken, got %v", err)
}
}
func TestViewMRU(t *testing.T) {
pool, s := connect(t)
defer pool.Close()
ctx := context.Background()
a := uniqueSlug("p5j-a-mru-a")
b := uniqueSlug("p5j-a-mru-b")
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug IN ($1, $2)`, a, b)
if _, err := s.CreateView(ctx, store.ViewInput{Slug: a, Name: "A"}); err != nil {
t.Fatalf("create a: %v", err)
}
if _, err := s.CreateView(ctx, store.ViewInput{Slug: b, Name: "B"}); err != nil {
t.Fatalf("create b: %v", err)
}
// MostRecentView with no touches yet — when no view in the table has
// last_used_at set, MRU returns nil. (Other tests may have left their
// own touched views, so we only assert on the slugs we control.)
if err := s.TouchView(ctx, a); err != nil {
t.Fatalf("touch a: %v", err)
}
time.Sleep(20 * time.Millisecond)
if err := s.TouchView(ctx, b); err != nil {
t.Fatalf("touch b: %v", err)
}
mru, err := s.MostRecentView(ctx)
if err != nil {
t.Fatalf("mru: %v", err)
}
// Other tests' touched views may rank higher; we only assert that
// when MRU is one of OURS, the most-recently-touched (b) wins over a.
// To guarantee this test's signal even with contention from other
// suites, check b's last_used_at > a's last_used_at directly.
aV, _ := s.GetView(ctx, a)
bV, _ := s.GetView(ctx, b)
if aV.LastUsedAt == nil || bV.LastUsedAt == nil {
t.Fatal("both views should have last_used_at after touch")
}
if !bV.LastUsedAt.After(*aV.LastUsedAt) {
t.Errorf("b.last_used_at should be after a.last_used_at; a=%v b=%v", aV.LastUsedAt, bV.LastUsedAt)
}
if mru == nil {
t.Error("MostRecentView returned nil even though touches landed")
}
}
func TestViewReorder(t *testing.T) {
pool, s := connect(t)
defer pool.Close()
ctx := context.Background()
a := uniqueSlug("p5j-a-reorder-a")
b := uniqueSlug("p5j-a-reorder-b")
c := uniqueSlug("p5j-a-reorder-c")
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug IN ($1, $2, $3)`, a, b, c)
for _, slug := range []string{a, b, c} {
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: slug}); err != nil {
t.Fatalf("create %s: %v", slug, err)
}
}
// Reorder c → b → a.
if err := s.ReorderViews(ctx, []string{c, b, a}); err != nil {
t.Fatalf("reorder: %v", err)
}
cV, _ := s.GetView(ctx, c)
bV, _ := s.GetView(ctx, b)
aV, _ := s.GetView(ctx, a)
if cV.SortOrder != 0 || bV.SortOrder != 1 || aV.SortOrder != 2 {
t.Errorf("reorder yielded sort_orders c=%d b=%d a=%d, want 0,1,2",
cV.SortOrder, bV.SortOrder, aV.SortOrder)
}
}

View File

@@ -45,7 +45,7 @@ func TestLayoutHasAdminNavLink(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
for _, path := range []string{"/", "/dashboard", "/graph", "/admin/bulk", "/admin/classify"} {
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/graph", "/admin/bulk", "/admin/classify"} {
_, body := get(t, h, path)
if !strings.Contains(body, `href="/admin"`) {
t.Errorf("GET %s: nav missing /admin link", path)

View File

@@ -5,11 +5,10 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/m/projax/internal/itemwrite"
"github.com/m/projax/store"
)
@@ -19,7 +18,7 @@ import (
// matching item, and an action bar that posts to /admin/bulk/apply. The page
// is intentionally desktop-only — m bulk-edits from a keyboard.
func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
items, err := s.Store.ListAll(r.Context())
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -29,7 +28,7 @@ func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
allTags, err := s.Store.AllTags(r.Context())
allTags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -263,83 +262,107 @@ func (s *Server) handleBulkApply(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// applyBulk runs the action against every id in a single transaction so
// partial failures roll back. Each branch is its own UPDATE because Postgres
// array operators cannot be parameterised cleanly across different operations.
// applyBulk applies one action to every id through the write adapter
// (s.Writes), so it targets whichever backend PROJAX_BACKEND selects.
//
// make_public / make_private map straight to the bulk-by-id SetPublic
// writer method. The field-mutating actions (tags / management / status /
// timeline-exclude) are read-modify-write: load each item via the reader,
// apply the single-field change, and write the full row back via Update.
//
// This replaces the previous single-transaction multi-row UPDATE. Phase 6
// moves writes onto mBrian's HTTP write API, which has no cross-node
// transaction, so a mid-batch failure leaves earlier rows already applied
// (the call returns the error; the caller re-renders actual state). That
// trade is acceptable at m's bulk-edit scale and keeps one write path
// across both backends instead of a SQL fast-path that only works on the
// legacy store.
func (s *Server) applyBulk(ctx context.Context, ids []string, a bulkAction) error {
tx, err := s.Store.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin bulk tx: %w", err)
switch a.SetPublic {
case "make_public":
return s.Writes.SetPublic(ctx, ids, true)
case "make_private":
return s.Writes.SetPublic(ctx, ids, false)
}
defer tx.Rollback(ctx)
switch {
case a.AddTag != "":
// array_append guards against duplicate tag with a CASE: only append
// when the tag isn't already present.
_, err = tx.Exec(ctx, `
update projax.items
set tags = case when $2 = any(tags) then tags else array_append(tags, $2) end
where id = any($1::uuid[]) and deleted_at is null`,
ids, a.AddTag)
case a.RemoveTag != "":
_, err = tx.Exec(ctx, `
update projax.items
set tags = array_remove(tags, $2)
where id = any($1::uuid[]) and deleted_at is null`,
ids, a.RemoveTag)
case a.SetMgmt != "":
if a.SetMgmt == "clear" {
_, err = tx.Exec(ctx, `
update projax.items set management = '{}'::text[]
where id = any($1::uuid[]) and deleted_at is null`,
ids)
} else {
for _, id := range ids {
it, err := s.Items.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("bulk %s: load %s: %w", a.describe(), id, err)
}
in := updateInputFromItem(it)
switch {
case a.AddTag != "":
in.Tags = appendUnique(in.Tags, a.AddTag)
case a.RemoveTag != "":
in.Tags = removeValue(in.Tags, a.RemoveTag)
case a.SetMgmt == "clear":
in.Management = []string{}
case a.SetMgmt != "":
// Replace management entirely — single-mode semantics matches
// the chip group on detail.tmpl.
_, err = tx.Exec(ctx, `
update projax.items set management = ARRAY[$2]::text[]
where id = any($1::uuid[]) and deleted_at is null`,
ids, a.SetMgmt)
in.Management = []string{a.SetMgmt}
case a.SetStatus != "":
in.Status = a.SetStatus
case a.TimelineTodos == "exclude":
in.TimelineExclude = appendUnique(in.TimelineExclude, "todos")
case a.TimelineTodos == "include":
in.TimelineExclude = removeValue(in.TimelineExclude, "todos")
default:
return errors.New("bulk: empty action")
}
if _, err := s.Writes.Update(ctx, id, in); err != nil {
return fmt.Errorf("bulk %s on %s: %w", a.describe(), id, err)
}
case a.SetStatus != "":
_, err = tx.Exec(ctx, `
update projax.items set status = $2
where id = any($1::uuid[]) and deleted_at is null`,
ids, a.SetStatus)
case a.SetPublic == "make_public":
_, err = tx.Exec(ctx, `
update projax.items set public = true
where id = any($1::uuid[]) and deleted_at is null`,
ids)
case a.SetPublic == "make_private":
_, err = tx.Exec(ctx, `
update projax.items set public = false
where id = any($1::uuid[]) and deleted_at is null`,
ids)
case a.TimelineTodos == "exclude":
// Idempotent: append 'todos' only when not already in the array.
_, err = tx.Exec(ctx, `
update projax.items
set timeline_exclude = case
when 'todos' = any(timeline_exclude) then timeline_exclude
else array_append(timeline_exclude, 'todos')
end
where id = any($1::uuid[]) and deleted_at is null`,
ids)
case a.TimelineTodos == "include":
_, err = tx.Exec(ctx, `
update projax.items
set timeline_exclude = array_remove(timeline_exclude, 'todos')
where id = any($1::uuid[]) and deleted_at is null`,
ids)
default:
return errors.New("bulk: empty action")
}
if err != nil {
return fmt.Errorf("bulk %s: %w", a.describe(), err)
return nil
}
// updateInputFromItem projects an Item into a full UpdateInput so a
// single-field bulk mutation can round-trip through the full-replace
// Update without clobbering the item's other fields. Metadata is omitted
// deliberately — UpdateInput doesn't carry it and the write path leaves
// node metadata untouched.
func updateInputFromItem(it *store.Item) store.UpdateInput {
return store.UpdateInput{
Title: it.Title,
Slug: it.Slug,
ParentIDs: it.ParentIDs,
ContentMD: it.ContentMD,
Status: it.Status,
Pinned: it.Pinned,
Archived: it.Archived,
StartTime: it.StartTime,
EndTime: it.EndTime,
Tags: it.Tags,
Management: it.Management,
Public: it.Public,
PublicDescription: it.PublicDescription,
PublicLiveURL: it.PublicLiveURL,
PublicSourceURL: it.PublicSourceURL,
PublicScreenshots: it.PublicScreenshots,
TimelineExclude: it.TimelineExclude,
}
return tx.Commit(ctx)
}
// appendUnique appends v to out only when it isn't already present,
// mirroring the old array_append-with-CASE SQL.
func appendUnique(out []string, v string) []string {
if slices.Contains(out, v) {
return out
}
return append(out, v)
}
// removeValue drops every occurrence of v, mirroring array_remove.
func removeValue(in []string, v string) []string {
out := make([]string, 0, len(in))
for _, x := range in {
if x != v {
out = append(out, x)
}
}
return out
}
// normaliseFormStrings deduplicates, lowercases, and trims the slice of
@@ -377,7 +400,7 @@ func normaliseFormStrings(in []string) []string {
// the first. Must use the slice form here or the second+ values silently
// drop on every Apply round-trip.
func (s *Server) renderBulkList(w http.ResponseWriter, r *http.Request, banner string) {
items, err := s.Store.ListAll(r.Context())
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -387,7 +410,7 @@ func (s *Server) renderBulkList(w http.ResponseWriter, r *http.Request, banner s
s.fail(w, r, err)
return
}
allTags, err := s.Store.AllTags(r.Context())
allTags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -445,7 +468,7 @@ func (s *Server) handleBulkChip(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
it, err := s.Store.GetByID(r.Context(), id)
it, err := s.Items.GetByID(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return

View File

@@ -44,11 +44,11 @@ func (s *Server) buildCalDAVOverview(ctx context.Context) (*CalDAVOverview, erro
if err != nil {
return nil, fmt.Errorf("caldav list: %w", err)
}
items, err := s.Store.ListAll(ctx)
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}
links, err := s.Store.LinksByRefType(ctx, refTypeCalDAV)
links, err := s.Items.LinksByRefType(ctx, refTypeCalDAV)
if err != nil {
return nil, err
}
@@ -127,7 +127,7 @@ func (s *Server) handleCalDAVLink(w http.ResponseWriter, r *http.Request) {
"calendar_color": color,
"linked_at": time.Now().UTC().Format(time.RFC3339),
}
if _, err := s.Store.AddLink(r.Context(), itemID, refTypeCalDAV, calURL, "contains", meta); err != nil {
if _, err := s.Writes.AddLink(r.Context(), itemID, refTypeCalDAV, calURL, "contains", meta); err != nil {
s.fail(w, r, err)
return
}
@@ -144,7 +144,7 @@ func (s *Server) handleCalDAVUnlink(w http.ResponseWriter, r *http.Request) {
http.Error(w, "link_id required", http.StatusBadRequest)
return
}
if err := s.Store.DeleteLink(r.Context(), linkID); err != nil {
if err := s.Writes.DeleteLink(r.Context(), linkID); err != nil {
s.fail(w, r, err)
return
}
@@ -194,7 +194,7 @@ func (s *Server) handleCalDAVLinkExisting(w http.ResponseWriter, r *http.Request
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -231,7 +231,7 @@ func (s *Server) handleCalDAVLinkExisting(w http.ResponseWriter, r *http.Request
"calendar_color": matched.Color,
"linked_at": time.Now().UTC().Format(time.RFC3339),
}
if _, err := s.Store.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
if _, err := s.Writes.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
s.fail(w, r, err)
return
}
@@ -245,7 +245,7 @@ func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -260,7 +260,7 @@ func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path
if errors.Is(err, caldav.ErrCalendarExists) {
// Existing calendar — link instead.
meta := map[string]any{"display_name": displayName, "linked_at": time.Now().UTC().Format(time.RFC3339)}
if _, err := s.Store.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
if _, err := s.Writes.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
s.fail(w, r, err)
return
}
@@ -274,7 +274,7 @@ func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path
"display_name": displayName,
"created_at": time.Now().UTC().Format(time.RFC3339),
}
if _, err := s.Store.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
if _, err := s.Writes.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
s.fail(w, r, err)
return
}
@@ -306,7 +306,7 @@ func (s *Server) detailTodos(ctx context.Context, item *store.Item) ([]calendarT
if s.CalDAV == nil {
return nil, nil
}
links, err := s.Store.LinksByType(ctx, item.ID, refTypeCalDAV)
links, err := s.Items.LinksByType(ctx, item.ID, refTypeCalDAV)
if err != nil {
return nil, err
}
@@ -372,7 +372,7 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -388,7 +388,7 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
}
// Guard: the calendar URL must be linked to this item — otherwise a
// crafted form could route writes to arbitrary calendars.
links, err := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
links, err := s.Items.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if err != nil {
s.fail(w, r, err)
return
@@ -505,10 +505,12 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
if s.timeline != nil {
s.timeline.InvalidateAll()
}
// Always re-render the tasks section so HTMX (or a plain redirect for
// non-HTMX clients) sees the post-write state.
// Always re-render the unified tasks section so HTMX (or a plain redirect
// for non-HTMX clients) sees the post-write state. Phase 7c: CalDAV +
// mBrian tasks share ONE section, so a CalDAV write refreshes the merged
// list via the same renderer the mBrian path uses.
if r.Header.Get("HX-Request") == "true" {
s.renderTasksSection(w, r, it, banner)
s.renderUnifiedTasks(w, r, it, banner)
return
}
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
@@ -527,39 +529,9 @@ func caldavBanner(action string, err error) string {
return "Could not " + action + " task: " + err.Error()
}
// renderTasksSection re-runs detailTodos for the item and renders the
// tasks-section template fragment with an optional banner. Used by HTMX
// responses so swap operations stay in-place.
func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it *store.Item, banner string) {
tasks, err := s.detailTodos(r.Context(), it)
if err != nil {
s.fail(w, r, err)
return
}
// HTMX swaps re-render the section in place; the picker needs the same
// AvailableCalendars data the full /i/{path} render computes. Errors
// here are non-fatal — degrade to an empty picker.
var available []caldav.Calendar
if s.CalDAV != nil {
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if lerr != nil {
s.Logger.Warn("tasks-section caldav links", "path", it.PrimaryPath(), "err", lerr)
}
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
if aerr != nil {
s.Logger.Warn("tasks-section available caldav", "path", it.PrimaryPath(), "err", aerr)
}
available = acs
}
data := map[string]any{
"Item": it,
"Tasks": tasks,
"AvailableCalendars": available,
"CalDAVOn": s.CalDAV != nil,
"Banner": banner,
}
s.render(w, r, "tasks_section", data)
}
// (renderTasksSection retired in Phase 7c — the CalDAV and mBrian task
// handlers now both re-render the merged list via Server.renderUnifiedTasks in
// task.go.)
// parseDueInput accepts an HTML5 date-input value (`YYYY-MM-DD`) or a
// datetime-local value (`YYYY-MM-DDTHH:MM`), returning the corresponding UTC

View File

@@ -205,7 +205,7 @@ func TestDetailLinkExistingCalendar(t *testing.T) {
`>Family<`,
`>Travel<`,
`>Vacations 2026<`,
`+ Create new list`,
`+ Create new CalDAV list`,
} {
if !strings.Contains(body, want) {
t.Errorf("unlinked detail page missing %q", want)
@@ -221,17 +221,23 @@ func TestDetailLinkExistingCalendar(t *testing.T) {
}
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1 and ref_id=$2`, id, pickedURL)
// Step 5: picker no longer offers Vacations 2026 (already linked);
// the tasks section now shows the linked calendar's block.
// Step 5: picker no longer offers Vacations 2026 (already linked). Phase 7c
// unified the task UI: there's no per-calendar block anymore, so an empty
// linked calendar shows no header — instead the project becomes
// CalDAV-bound, so the single add-form now targets the linked calendar and
// the "create new" affordance disappears.
_, body = get(t, h, "/i/"+primary)
if strings.Contains(body, `<option value="`+pickedURL+`">Vacations 2026</option>`) {
t.Errorf("picker should NOT offer the already-linked Vacations 2026 URL")
}
if !strings.Contains(body, "Vacations 2026") {
t.Errorf("tasks section should display the linked Vacations 2026 list")
if strings.Contains(body, `+ Create new CalDAV list`) {
t.Errorf("create-new affordance should be gone once a calendar is linked (project is CalDAV-bound)")
}
if !strings.Contains(body, `data-cal="`+pickedURL+`"`) {
t.Errorf("tasks section missing cal-block for the linked URL")
if !strings.Contains(body, `hx-post="/i/`+primary+`/caldav/todo/todo-create"`) {
t.Errorf("unified add-form should POST to the CalDAV create route on a bound project")
}
if !strings.Contains(body, `name="calendar_url" value="`+pickedURL+`"`) {
t.Errorf("unified add-form should target the linked calendar %q", pickedURL)
}
}

View File

@@ -194,7 +194,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
"Query": q,
"Now": now,
"Projects": projects,
"BasePath": "/calendar",
"BasePath": "/views/calendar",
"ProjectChipTarget": "#calendar-section",
}
if r.Header.Get("HX-Request") == "true" {
@@ -209,7 +209,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
// lead/trail cells), bins them into per-day cells, and caps each cell at
// calendarMaxRowsPerCell with the overflow count.
func (s *Server) buildCalendar(ctx context.Context, q calendarQuery, now time.Time) (*calendarPayload, error) {
items, err := s.Store.ListAll(ctx)
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}

View File

@@ -17,7 +17,7 @@ func TestCalendarRendersMonthGrid(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/calendar")
code, body := get(t, h, "/views/calendar")
if code != 200 {
t.Fatalf("GET /calendar → %d body=%s", code, body)
}
@@ -27,7 +27,7 @@ func TestCalendarRendersMonthGrid(t *testing.T) {
`<th scope="col">Mon</th>`,
`<th scope="col">Sun</th>`,
`class="calendar-nav"`,
`href="/calendar?month=`, // prev/next anchors present
`href="/views/calendar?month=`, // prev/next anchors present
} {
if !strings.Contains(body, want) {
t.Errorf("calendar body missing %q", want)
@@ -71,7 +71,7 @@ func TestCalendarSurfacesDatedLink(t *testing.T) {
t.Fatalf("seed link: %v", err)
}
code, body := get(t, h, "/calendar")
code, body := get(t, h, "/views/calendar")
if code != 200 {
t.Fatalf("GET /calendar → %d", code)
}
@@ -130,7 +130,7 @@ func TestCalendarFilterScopeByTag(t *testing.T) {
}
// Unfiltered: both notes show.
_, all := get(t, h, "/calendar?refresh=1")
_, all := get(t, h, "/views/calendar?refresh=1")
if !strings.Contains(all, workNote) {
t.Errorf("unfiltered calendar missing work note %q", workNote)
}
@@ -139,7 +139,7 @@ func TestCalendarFilterScopeByTag(t *testing.T) {
}
// Filtered: only work note shows.
_, scoped := get(t, h, "/calendar?refresh=1&tag=cal-test-work-"+stamp)
_, scoped := get(t, h, "/views/calendar?refresh=1&tag=cal-test-work-"+stamp)
if !strings.Contains(scoped, workNote) {
t.Errorf("filtered calendar missing work note %q", workNote)
}
@@ -157,7 +157,7 @@ func TestCalendarAdjacentMonthDays(t *testing.T) {
h := srv.Routes()
// Pick a month whose first day is NOT a Monday so leading days appear.
// May 2026 starts on a Friday; lead = Apr 27/28/29/30.
_, body := get(t, h, "/calendar?month=2026-05&refresh=1")
_, body := get(t, h, "/views/calendar?month=2026-05&refresh=1")
if !strings.Contains(body, "adjacent-month") {
t.Errorf("expected adjacent-month class on lead-in cells for May 2026, body did not include it")
}
@@ -173,11 +173,11 @@ func TestCalendarNavPrevNextLinks(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
if !strings.Contains(body, `href="/calendar?month=2026-04"`) {
_, body := get(t, h, "/views/calendar?month=2026-05")
if !strings.Contains(body, `href="/views/calendar?month=2026-04"`) {
t.Errorf("expected prev link to 2026-04, body did not include it")
}
if !strings.Contains(body, `href="/calendar?month=2026-06"`) {
if !strings.Contains(body, `href="/views/calendar?month=2026-06"`) {
t.Errorf("expected next link to 2026-06, body did not include it")
}
}
@@ -190,11 +190,11 @@ func TestCalendarFilterChipStripRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
_, body := get(t, h, "/views/calendar?month=2026-05")
for _, want := range []string{
`id="calendar-filterbar"`,
`hx-target="#calendar-section"`,
`hx-get="/calendar"`,
`hx-get="/views/calendar"`,
`<input type="hidden" name="month" value="2026-05">`, // preserves month across chip changes
`name="kind"`,
`name="tag"`,
@@ -213,7 +213,7 @@ func TestCalendarHTMXReturnsSectionOnly(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
req := httptest.NewRequest("GET", "/calendar?month=2026-05", nil)
req := httptest.NewRequest("GET", "/views/calendar?month=2026-05", nil)
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
@@ -294,7 +294,7 @@ func TestCalendarFilterMultiValueTagsFromForm(t *testing.T) {
}
// HTMX-style multi-value submission: two `tag=` params, not comma-joined.
url := "/calendar?refresh=1&tag=" + tagA + "&tag=" + tagB
url := "/views/calendar?refresh=1&tag=" + tagA + "&tag=" + tagB
_, body := get(t, h, url)
// Item AB has BOTH tags — must appear.
@@ -322,7 +322,7 @@ func TestCalendarCellCarriesLongLabel(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
_, body := get(t, h, "/views/calendar?month=2026-05")
// May 4 2026 is a Monday → "Mo., 4. Mai".
if !strings.Contains(body, `Mo., 4. Mai`) {
t.Errorf("expected long label 'Mo., 4. Mai' for 2026-05-04 cell, body did not include it")

View File

@@ -201,7 +201,7 @@ func TestFormatMonthLabel(t *testing.T) {
// month + all-three.
func TestParseCalendarQueryDefaults(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
r := httptest.NewRequest("GET", "/calendar", nil)
r := httptest.NewRequest("GET", "/views/calendar", nil)
q := parseCalendarQuery(r, now)
if q.Month.Format("2006-01") != "2026-05" {
t.Errorf("default month = %s, want 2026-05", q.Month.Format("2006-01"))
@@ -223,7 +223,7 @@ func TestParseCalendarQueryDefaults(t *testing.T) {
// nav writes to this exact key.
func TestParseCalendarQueryMonthParam(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
r := httptest.NewRequest("GET", "/calendar?month=2026-08", nil)
r := httptest.NewRequest("GET", "/views/calendar?month=2026-08", nil)
q := parseCalendarQuery(r, now)
if q.Month.Format("2006-01") != "2026-08" {
t.Errorf("parsed month = %s, want 2026-08", q.Month.Format("2006-01"))
@@ -234,7 +234,7 @@ func TestParseCalendarQueryMonthParam(t *testing.T) {
// kind set and drops unknown values.
func TestParseCalendarQueryKindFilter(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
r := httptest.NewRequest("GET", "/calendar?kind=event,doc,junk,creation", nil)
r := httptest.NewRequest("GET", "/views/calendar?kind=event,doc,junk,creation", nil)
q := parseCalendarQuery(r, now)
got := strings.Join(q.activeKinds(), ",")
want := "doc,event" // sorted alphabetically; creation is excluded by design, junk dropped

View File

@@ -233,7 +233,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
}
refreshQuery += "scope=" + scope
}
refreshURL := "/dashboard?"
refreshURL := "/views/dashboard?"
if refreshQuery != "" {
refreshURL += refreshQuery + "&"
}
@@ -256,7 +256,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
"RefreshURL": refreshURL,
"FilterActive": filter.Active(),
"Projects": projects,
"BasePath": "/dashboard",
"BasePath": "/views/dashboard",
"ProjectChipTarget": "#dashboard-section",
}
if r.Header.Get("HX-Request") == "true" {
@@ -304,9 +304,9 @@ func dashboardScopeToggleURL(view, scope, filterKey string) string {
parts = append(parts, "scope="+next)
}
if len(parts) == 0 {
return "/dashboard"
return "/views/dashboard"
}
return "/dashboard?" + strings.Join(parts, "&")
return "/views/dashboard?" + strings.Join(parts, "&")
}
// dashboardTab is a single entry in the view-switcher strip.
@@ -322,7 +322,7 @@ type dashboardTab struct {
// scope (current) elide from the URL so the address bar stays clean
// on the daily-driver path.
func dashboardTabs(active, filterKey, scope string) []dashboardTab {
prefix := "/dashboard"
prefix := "/views/dashboard"
filterQuery := ""
if filterKey != "__empty__" && filterKey != "" {
filterQuery = filterKey
@@ -357,7 +357,7 @@ func dashboardTabs(active, filterKey, scope string) []dashboardTab {
// shapes AND the new per-project rollup so the rollup costs zero extra
// DAV/Gitea calls.
func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashboardPayload, error) {
items, err := s.Store.ListAll(ctx)
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}
@@ -422,7 +422,7 @@ func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashbo
// --- Recent documents card ---
since := now.AddDate(0, 0, -30)
docRows, err := s.Store.RecentDocuments(ctx, since, 200)
docRows, err := s.Items.RecentDocuments(ctx, since, 200)
if err != nil {
s.Logger.Warn("dashboard docs", "err", err)
}
@@ -486,7 +486,7 @@ func (s *Server) collectStale(ctx context.Context, items []*store.Item, openTask
if openTasks[it.ID] > 0 || openIssues[it.ID] > 0 {
continue
}
links, err := s.Store.LinksByType(ctx, it.ID, refTypeGiteaRepo)
links, err := s.Items.LinksByType(ctx, it.ID, refTypeGiteaRepo)
if err != nil || len(links) == 0 {
continue
}
@@ -868,7 +868,7 @@ func (s *Server) dashboardTaskWrite(w http.ResponseWriter, r *http.Request, acti
// pointing at the given URL. Used as the dashboard's write-side ownership
// guard.
func (s *Server) calendarLinked(ctx context.Context, calURL string) (bool, error) {
links, err := s.Store.LinksByRefType(ctx, refTypeCalDAV)
links, err := s.Items.LinksByRefType(ctx, refTypeCalDAV)
if err != nil {
return false, err
}

View File

@@ -70,9 +70,9 @@ END:VCALENDAR`
h := srv.Routes()
// Inline VTODO writeback rows live on the Tasks tab (Phase 5h).
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
t.Fatalf("GET /views/dashboard?view=tasks → %d", code)
}
for _, want := range []string{
`Edit me please`,

View File

@@ -70,9 +70,9 @@ func TestDashboardEventsCardSurfacesUpcoming(t *testing.T) {
h := srv.Routes()
// The card-events markup lives on the Tasks tab (Phase 5h).
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
t.Fatalf("GET /views/dashboard?view=tasks → %d", code)
}
for _, want := range []string{
`card-events`,
@@ -106,7 +106,7 @@ func TestDashboardEventsCardCollapsesWhenEmpty(t *testing.T) {
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/", "u", "p")}
h := srv.Routes()
_, body := get(t, h, "/dashboard?view=tasks")
_, body := get(t, h, "/views/dashboard?view=tasks")
if !strings.Contains(body, "No upcoming events") {
t.Errorf("expected collapsed Events card with 'No upcoming events' note")
}

View File

@@ -27,7 +27,7 @@ func (s *Server) handleDashboardPin(w http.ResponseWriter, r *http.Request) {
return
}
pinned := parseFormBool(r.FormValue("pin"))
if err := s.Store.SetPinned(r.Context(), []string{id}, pinned); err != nil {
if err := s.Writes.SetPinned(r.Context(), []string{id}, pinned); err != nil {
s.fail(w, r, err)
return
}

View File

@@ -49,7 +49,7 @@ func TestDashboardPinTogglesItem(t *testing.T) {
}
// The re-render should mark the tile as .tile-pinned.
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
tileIdx := strings.Index(body, `data-item-id="`+id+`"`)
if tileIdx < 0 {
t.Fatalf("pinned tile not found in re-render")
@@ -141,7 +141,7 @@ func TestDashboardPinInvalidatesCache(t *testing.T) {
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
// Prime the cache — first GET caches an unpinned tile state.
_, primed := get(t, h, "/dashboard")
_, primed := get(t, h, "/views/dashboard")
tileIdx := strings.Index(primed, `data-item-id="`+id+`"`)
if tileIdx < 0 {
t.Fatalf("seeded tile missing from primed dashboard")
@@ -157,7 +157,7 @@ func TestDashboardPinInvalidatesCache(t *testing.T) {
// Next GET must reflect the new pinned state — proves the cache
// entry for the previous (unpinned) state was invalidated.
_, after := get(t, h, "/dashboard")
_, after := get(t, h, "/views/dashboard")
tileIdx2 := strings.Index(after, `data-item-id="`+id+`"`)
if tileIdx2 < 0 {
t.Fatalf("tile missing from post-pin dashboard")

View File

@@ -23,7 +23,7 @@ func TestDashboardRendersWithoutDeps(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d body=%s", code, body)
}
@@ -77,7 +77,7 @@ func TestDashboardRecentDocsSurfacesDatedLinks(t *testing.T) {
}
// The Recent Documents card lives on the Tasks tab (Phase 5h).
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
@@ -134,7 +134,7 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
}()
// Doc rows surface on the Tasks tab; the filter narrows both views.
code, body := get(t, h, "/dashboard?tag=dev&view=tasks")
code, body := get(t, h, "/views/dashboard?tag=dev&view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?tag=dev&view=tasks → %d", code)
}
@@ -159,9 +159,9 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
h := srv.Routes()
// Prime the cache.
_, _ = get(t, h, "/dashboard")
_, _ = get(t, h, "/views/dashboard")
// Second hit shows cached label.
_, cachedBody := get(t, h, "/dashboard")
_, cachedBody := get(t, h, "/views/dashboard")
if !strings.Contains(cachedBody, "cached") {
n := len(cachedBody)
if n > 600 {
@@ -170,7 +170,7 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
t.Fatalf("setup: second load should be cached, got body:\n%s", cachedBody[:n])
}
// Third hit with ?refresh=1 should be fresh again.
code, body := get(t, h, "/dashboard?refresh=1")
code, body := get(t, h, "/views/dashboard?refresh=1")
if code != 200 {
t.Fatalf("GET /dashboard?refresh=1 → %d", code)
}
@@ -190,7 +190,7 @@ func TestDashboardCollapsesEmptyCardsWhenNoFilter(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
@@ -210,7 +210,7 @@ func TestDashboardFilterKeepsFullCardChrome(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?tag=nothing-matches-zzz&view=tasks")
code, body := get(t, h, "/views/dashboard?tag=nothing-matches-zzz&view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?tag=… → %d", code)
}
@@ -271,7 +271,7 @@ func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) {
h := srv.Routes()
// Phase 5h: the Stale card retired. The stale project now appears
// inside the Tiles Quiet fold with a tile-stale flag on the tile.
code, body := get(t, h, "/dashboard")
code, body := get(t, h, "/views/dashboard")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
}
@@ -335,7 +335,7 @@ func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) {
// Phase 5h: assert the tile for this slug is NOT flagged stale.
// Recent repo activity (3d old) puts it solidly inside the activity
// window AND fails the staleness probe, so no tile-stale class.
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Find the tile for this slug and check its class attribute.
marker := `data-item-path="dev.` + slug + `"`
idx := strings.Index(body, marker)
@@ -362,8 +362,8 @@ func TestDashboardCacheHitOnSecondLoad(t *testing.T) {
defer pool.Close()
h := srv.Routes()
_, _ = get(t, h, "/dashboard")
code, body := get(t, h, "/dashboard")
_, _ = get(t, h, "/views/dashboard")
code, body := get(t, h, "/views/dashboard")
if code != 200 {
t.Fatalf("second GET /dashboard → %d", code)
}

View File

@@ -13,7 +13,7 @@ func TestDashboardDefaultViewIsTiles(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard")
code, body := get(t, h, "/views/dashboard")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
}
@@ -36,9 +36,9 @@ func TestDashboardTabsRenderAllThree(t *testing.T) {
activeTab string
activeLabel string
}{
{"/dashboard", "tiles", "Tiles"},
{"/dashboard?view=tasks", "tasks", "Tasks"},
{"/dashboard?view=events", "events", "Events"},
{"/views/dashboard", "tiles", "Tiles"},
{"/views/dashboard?view=tasks", "tasks", "Tasks"},
{"/views/dashboard?view=events", "events", "Events"},
}
for _, c := range cases {
t.Run(c.activeTab, func(t *testing.T) {
@@ -80,7 +80,7 @@ func TestDashboardTasksViewFallback(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?view=tasks")
_, body := get(t, h, "/views/dashboard?view=tasks")
if strings.Contains(body, `class="dash-tiles"`) {
t.Errorf("view=tasks should NOT render the Tiles grid")
}
@@ -99,7 +99,7 @@ func TestDashboardEventsViewRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?view=events")
_, body := get(t, h, "/views/dashboard?view=events")
if !strings.Contains(body, `class="dash-events-view"`) {
t.Errorf("view=events should render the promoted Events surface")
}
@@ -120,7 +120,7 @@ func TestDashboardUnknownViewFallsBackToTiles(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=gibberish")
code, body := get(t, h, "/views/dashboard?view=gibberish")
if code != 200 {
t.Fatalf("GET /dashboard?view=gibberish → %d", code)
}
@@ -155,7 +155,7 @@ func TestDashboardTilesViewShowsRollupForSeededItem(t *testing.T) {
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
code, body := get(t, h, "/dashboard")
code, body := get(t, h, "/views/dashboard")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
}
@@ -172,19 +172,19 @@ func TestDashboardTilesViewShowsRollupForSeededItem(t *testing.T) {
// TestDashboardCacheKeySeparatesViews ensures the cache layer keys by
// (filter, view): the same filter under different views must hit
// independent cache entries. We prove this by priming /dashboard, then
// /dashboard?view=tasks, and asserting both report "fresh" on their
// /views/dashboard?view=tasks, and asserting both report "fresh" on their
// first call (i.e. they don't share a cache slot).
func TestDashboardCacheKeySeparatesViews(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body1 := get(t, h, "/dashboard")
_, body1 := get(t, h, "/views/dashboard")
if !strings.Contains(body1, "fresh") {
t.Fatalf("first /dashboard load should be fresh")
}
_, body2 := get(t, h, "/dashboard?view=tasks")
_, body2 := get(t, h, "/views/dashboard?view=tasks")
if !strings.Contains(body2, "fresh") {
t.Errorf("first /dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
t.Errorf("first /views/dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
}
}
@@ -195,18 +195,18 @@ func TestDashboardScopeChipRendersOnTilesOnly(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, tiles := get(t, h, "/dashboard")
_, tiles := get(t, h, "/views/dashboard")
if !strings.Contains(tiles, `class="dash-scope-chip"`) {
t.Errorf("Tiles view should render the scope chip")
}
if !strings.Contains(tiles, "◇ current") {
t.Errorf("default scope chip should show '◇ current'")
}
_, tasks := get(t, h, "/dashboard?view=tasks")
_, tasks := get(t, h, "/views/dashboard?view=tasks")
if strings.Contains(tasks, `class="dash-scope-chip"`) {
t.Errorf("Tasks view should NOT render the scope chip")
}
_, events := get(t, h, "/dashboard?view=events")
_, events := get(t, h, "/views/dashboard?view=events")
if strings.Contains(events, `class="dash-scope-chip"`) {
t.Errorf("Events view should NOT render the scope chip")
}
@@ -218,7 +218,7 @@ func TestDashboardScopeAllChipFlipsLabel(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?scope=all")
_, body := get(t, h, "/views/dashboard?scope=all")
if !strings.Contains(body, "○ all") {
t.Errorf("scope=all should render '○ all' chip label")
}
@@ -234,7 +234,7 @@ func TestDashboardScopeAllHidesQuietFold(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?scope=all")
_, body := get(t, h, "/views/dashboard?scope=all")
if strings.Contains(body, `class="dash-quiet"`) {
t.Errorf("scope=all should NOT render the Quiet fold — everything is in the primary grid")
}
@@ -246,12 +246,12 @@ func TestDashboardScopeChipURLFlips(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, defaultBody := get(t, h, "/dashboard")
if !strings.Contains(defaultBody, `href="/dashboard?scope=all"`) {
_, defaultBody := get(t, h, "/views/dashboard")
if !strings.Contains(defaultBody, `href="/views/dashboard?scope=all"`) {
t.Errorf("default scope chip should link to ?scope=all")
}
_, allBody := get(t, h, "/dashboard?scope=all")
if !strings.Contains(allBody, `href="/dashboard"`) {
_, allBody := get(t, h, "/views/dashboard?scope=all")
if !strings.Contains(allBody, `href="/views/dashboard"`) {
t.Errorf("scope=all chip should link back to /dashboard (scope=current is default+elided)")
}
}

View File

@@ -162,7 +162,7 @@ func (s *Server) detailIssues(ctx context.Context, item *store.Item) ([]repoIssu
if s.Gitea == nil {
return nil, nil
}
links, err := s.Store.LinksByType(ctx, item.ID, refTypeGiteaRepo)
links, err := s.Items.LinksByType(ctx, item.ID, refTypeGiteaRepo)
if err != nil {
return nil, err
}

View File

@@ -22,7 +22,7 @@ func (s *Server) handleIssueAction(w http.ResponseWriter, r *http.Request, path,
http.Error(w, "gitea not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -135,7 +135,7 @@ func (s *Server) renderIssuesSection(w http.ResponseWriter, r *http.Request, it
// to this item via a gitea-repo item_link. Prevents form-crafted writeback
// against unrelated repos.
func (s *Server) repoLinkedToItem(ctx context.Context, itemID, repoRef string) bool {
links, err := s.Store.LinksByType(ctx, itemID, refTypeGiteaRepo)
links, err := s.Items.LinksByType(ctx, itemID, refTypeGiteaRepo)
if err != nil {
return false
}

View File

@@ -41,7 +41,7 @@ type graphPayload struct {
}
func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
items, err := s.Store.ListAll(r.Context())
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -51,7 +51,7 @@ func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
allTags, err := s.Store.AllTags(r.Context())
allTags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return

View File

@@ -16,7 +16,7 @@ func TestGraphPageRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/graph")
code, body := get(t, h, "/views/graph")
if code != 200 {
t.Fatalf("GET /graph → %d body=%s", code, body)
}
@@ -42,7 +42,7 @@ func TestGraphFilterDimsNonMatching(t *testing.T) {
h := srv.Routes()
// Use a definitely-unused tag to force every node to mismatch.
code, body := get(t, h, "/graph?tag=ZZZZ-unused-tag")
code, body := get(t, h, "/views/graph?tag=ZZZZ-unused-tag")
if code != 200 {
t.Fatalf("GET /graph?tag=ZZZ → %d", code)
}
@@ -83,7 +83,7 @@ func TestGraphIsolateHidesNonMatching(t *testing.T) {
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
code, body := get(t, h, "/graph?tag="+tag+"&isolate=1")
code, body := get(t, h, "/views/graph?tag="+tag+"&isolate=1")
if code != 200 {
t.Fatalf("GET /graph?isolate → %d", code)
}
@@ -102,7 +102,7 @@ func TestGraphSVGDownload(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
req := httptest.NewRequest(http.MethodGet, "/graph?download=svg", nil)
req := httptest.NewRequest(http.MethodGet, "/views/graph?download=svg", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != 200 {

43
web/icons.go Normal file
View File

@@ -0,0 +1,43 @@
package web
import "html/template"
// Phase 5j slice G — icon registry per m's Q6 pick (2026-05-29). The
// curated set of keys mirrors the editor's <select> options so the round-
// trip works: editor save persists the key string, layout renders the SVG
// at look-up time. Unknown / empty keys fall back to the default folder
// glyph.
//
// Stored as html/template.HTML so layout.tmpl can emit the markup
// directly without html-escaping the angle brackets. Each SVG is sized
// to 18px square and inherits currentColor like the existing nav-icon
// glyphs.
var iconRegistry = map[string]template.HTML{
"folder": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`),
"clock": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`),
"star": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`),
"tag": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>`),
"inbox": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`),
"box": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`),
"file-text": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`),
}
// RenderViewIcon returns the SVG for an icon key, falling back to the
// folder default for nil or unknown keys. Template-callable so
// layout.tmpl can emit `{{renderIcon .Icon}}`.
func RenderViewIcon(icon *string) template.HTML {
key := "folder"
if icon != nil && *icon != "" {
if _, ok := iconRegistry[*icon]; ok {
key = *icon
}
}
return iconRegistry[key]
}
// IconRegistryKeys returns the available icon keys in display order, for
// the editor's <select>. The first key (folder) is the default.
func IconRegistryKeys() []string {
return []string{"folder", "clock", "star", "tag", "inbox", "box", "file-text"}
}

View File

@@ -13,18 +13,18 @@ func TestLayoutSidebarOnDesktop(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
if !strings.Contains(body, `<aside class="projax-sidebar"`) {
t.Fatalf("expected <aside class=\"projax-sidebar\"> in body, got: %s", truncate(body, 400))
}
for _, want := range []struct {
href, label string
}{
{`/`, "Tree"},
{`/dashboard`, "Dashboard"},
{`/calendar`, "Calendar"},
{`/timeline`, "Timeline"},
{`/graph`, "Graph"},
{`/views/tree`, "Tree"},
{`/views/dashboard`, "Dashboard"},
{`/views/calendar`, "Calendar"},
{`/views/timeline`, "Timeline"},
{`/views/graph`, "Graph"},
{`/admin`, "Admin"},
} {
if !strings.Contains(body, `href="`+want.href+`"`) {
@@ -43,12 +43,12 @@ func TestLayoutActiveClass(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Dashboard item should be active.
if !strings.Contains(body, `class="nav-item active" title="Dashboard"`) {
t.Errorf("expected Dashboard nav-item to carry .active on /dashboard, body: %s", truncate(body, 400))
}
// Tree item (href="/") must NOT be active on the /dashboard page.
// Tree item (href="/views/tree") must NOT be active on the /dashboard page.
// The Tree anchor opens with the exact-path active match; on /dashboard
// the substring `class="nav-item" title="Tree"` should be present and
// not its `active` sibling.
@@ -68,7 +68,7 @@ func TestLayoutCollapseScript(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Pre-paint restore script.
if !strings.Contains(body, `localStorage.getItem('projax.sidebar.collapsed')`) {
t.Errorf("expected pre-paint localStorage restore script in layout")
@@ -93,7 +93,7 @@ func TestLayoutNoTopHeader(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Slice out the region between <body> and <main> — that's where the
// pre-5g top header lived. Inside <main> belongs to content templates.
chrome := body
@@ -116,17 +116,17 @@ func TestLayoutBottomNavMarkup(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
if !strings.Contains(body, `<nav class="projax-bottom-nav"`) {
t.Fatalf("expected <nav class=\"projax-bottom-nav\"> in body, got: %s", truncate(body, 400))
}
// 5-slot anchors / details element.
for _, want := range []string{
`<a href="/" class="bottom-nav-item`,
`<a href="/dashboard" class="bottom-nav-item`,
`<a href="/views/tree" class="bottom-nav-item`,
`<a href="/views/dashboard" class="bottom-nav-item`,
`<a href="/new" class="bottom-nav-item capture-btn"`,
`class="capture-circle"`,
`<a href="/calendar" class="bottom-nav-item`,
`<a href="/views/calendar" class="bottom-nav-item`,
`<details class="projax-mobile-drawer"`,
} {
if !strings.Contains(body, want) {
@@ -135,8 +135,8 @@ func TestLayoutBottomNavMarkup(t *testing.T) {
}
// Drawer overflow items: Timeline, Graph, Admin, theme toggle, sign-out.
for _, want := range []string{
`<a href="/timeline" class="drawer-item`,
`<a href="/graph" class="drawer-item`,
`<a href="/views/timeline" class="drawer-item`,
`<a href="/views/graph" class="drawer-item`,
`<a href="/admin" class="drawer-item`,
`id="theme-toggle-drawer"`,
`<form method="post" action="/logout" class="drawer-form">`,
@@ -154,11 +154,11 @@ func TestLayoutBottomNavActiveClass(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar")
if !strings.Contains(body, `<a href="/calendar" class="bottom-nav-item active"`) {
_, body := get(t, h, "/views/calendar")
if !strings.Contains(body, `<a href="/views/calendar" class="bottom-nav-item active"`) {
t.Errorf("expected Calendar bottom-nav-item to carry .active on /calendar")
}
if strings.Contains(body, `<a href="/" class="bottom-nav-item active"`) {
if strings.Contains(body, `<a href="/views/tree" class="bottom-nav-item active"`) {
t.Errorf("Tree bottom-nav-item should NOT be active on /calendar")
}
}
@@ -171,7 +171,7 @@ func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Both buttons present.
if !strings.Contains(body, `id="theme-toggle"`) {
t.Errorf("sidebar theme-toggle button missing")
@@ -185,6 +185,21 @@ func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
}
}
// TestLayoutLoadsHTMX guards against the Phase 7c regression: the task / tree
// / dashboard / bulk / classify forms drive in-place swaps with hx-* attrs,
// which are inert unless htmx is actually loaded. It went unnoticed for many
// phases (every hx-post task form silently no-op'd to a GET-to-self). This
// test fails the moment the vendored htmx <script> drops out of the layout.
func TestLayoutLoadsHTMX(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/views/dashboard")
if !strings.Contains(body, `src="/static/htmx.min.js"`) {
t.Errorf("layout must load vendored htmx (hx-* forms are dead without it); body: %s", truncate(body, 400))
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s

View File

@@ -2,7 +2,6 @@ package web
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
@@ -15,7 +14,7 @@ import (
// note, event_date (YYYY-MM-DD). Anti-forgery isn't a concern at v1 since the
// trust model is Tailscale-only + cookie auth.
func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -46,7 +45,7 @@ func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path str
if noteVal != "" {
notePtr = &noteVal
}
if _, err := s.Store.AddLinkDated(r.Context(), it.ID, refType, refID, "", notePtr, date, nil); err != nil {
if _, err := s.Writes.AddLinkDated(r.Context(), it.ID, refType, refID, "", notePtr, date, nil); err != nil {
banner = fmt.Sprintf("Could not add link: %v", err)
}
}
@@ -57,7 +56,7 @@ func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path str
// handleLinksRemove processes POST /i/{path}/links/remove.
func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -80,7 +79,7 @@ func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path
}
if !owns {
banner = "Link does not belong to this item."
} else if err := s.Store.DeleteLink(r.Context(), linkID); err != nil {
} else if err := s.Writes.DeleteLink(r.Context(), linkID); err != nil {
banner = fmt.Sprintf("Could not remove link: %v", err)
}
}
@@ -106,7 +105,7 @@ func (s *Server) renderDocumentsSection(w http.ResponseWriter, r *http.Request,
http.Redirect(w, r, "/i/"+it.PrimaryPath()+"#documents-section", http.StatusSeeOther)
return
}
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
docs, err := s.Items.DatedLinks(r.Context(), it.ID)
if err != nil {
s.fail(w, r, err)
return
@@ -120,21 +119,20 @@ func (s *Server) renderDocumentsSection(w http.ResponseWriter, r *http.Request,
})
}
// linkBelongsToItem returns true when the link's item_id equals the supplied
// item id. Used as an anti-forgery check before delete.
// linkBelongsToItem returns true when the given link id is one of the
// item's own links. Used as an anti-forgery check before delete. Reads
// through the adapter (s.Items) so it resolves against whichever backend
// is live — a direct projax.item_links query would miss mBrian-backed
// links and reject every delete under PROJAX_BACKEND=mbrian.
func (s *Server) linkBelongsToItem(ctx context.Context, linkID, itemID string) (bool, error) {
var owner string
err := s.Store.Pool.QueryRow(ctx,
`select item_id from projax.item_links where id = $1`, linkID).Scan(&owner)
links, err := s.Items.LinksByType(ctx, itemID, "") // "" → every ref_type
if err != nil {
if isNoRows(err) {
return false, nil
}
return false, err
}
return owner == itemID, nil
}
func isNoRows(err error) bool {
return err != nil && (errors.Is(err, store.ErrNotFound) || err.Error() == "no rows in result set")
for _, l := range links {
if l.ID == linkID {
return true, nil
}
}
return false, nil
}

View File

@@ -245,14 +245,14 @@ func TestTreeFilterPublicNarrows(t *testing.T) {
// filter.
pubLink := `href="/i/dev.` + pubSlug + `"`
prvLink := `href="/i/dev.` + prvSlug + `"`
_, yesBody := get(t, h, "/?public=1")
_, yesBody := get(t, h, "/views/tree?public=1")
if !strings.Contains(yesBody, pubLink) {
t.Errorf("?public=1 should show pub-filt-yes row")
}
if strings.Contains(yesBody, prvLink) {
t.Errorf("?public=1 should hide pub-filt-no row")
}
_, noBody := get(t, h, "/?public=0")
_, noBody := get(t, h, "/views/tree?public=0")
if strings.Contains(noBody, pubLink) {
t.Errorf("?public=0 should hide pub-filt-yes row")
}

View File

@@ -81,7 +81,7 @@ func TestLayoutHasManifestAndAppleTouchIcon(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
for _, want := range []string{
`rel="manifest"`,
`/static/manifest.webmanifest`,

View File

@@ -15,7 +15,6 @@ import (
"strings"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/internal/cache"
"github.com/m/projax/internal/itemwrite"
@@ -34,6 +33,29 @@ func (s *Server) itemWriteFailure(w http.ResponseWriter, r *http.Request, ve *it
s.Logger.Warn("itemwrite reject", "path", r.URL.Path, "kind", ve.Kind, "detail", ve.Detail)
}
// writeFailure renders a write error from the adapter. Slug outcomes from
// the mBrian backend (409 collision / 400 invalid) surface as the same
// friendly itemwrite banners the pre-flight validator uses, so a slug
// taken by a soft-deleted tombstone — which the validator can't see —
// still reads cleanly instead of dumping a raw API error. Everything else
// falls through to the generic failure page.
func (s *Server) writeFailure(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, store.ErrSlugTaken):
s.itemWriteFailure(w, r, &itemwrite.ValidationError{
Kind: itemwrite.KindSlugCollision,
Detail: "That slug is already taken (possibly by a deleted item) — pick another.",
})
case errors.Is(err, store.ErrInvalidSlug):
s.itemWriteFailure(w, r, &itemwrite.ValidationError{
Kind: itemwrite.KindInvalidSlugFormat,
Detail: "Invalid slug — use lower-case, no dots or whitespace.",
})
default:
s.fail(w, r, err)
}
}
// itemWriteBannerCopy maps a ValidationError.Kind to the human-facing
// banner copy. Centralised so web/server.go + web/bulk.go share one
// authoritative phrasing.
@@ -75,12 +97,25 @@ var staticFS embed.FS
// Server bundles handlers, templates, and the store.
type Server struct {
Store *store.Store
Store *store.Store
// Items is the read-path adapter every UI handler / MCP read tool /
// aggregator depends on. Phase 6 Slice B introduces it: today the
// concrete *Store satisfies the ItemReader interface (legacy path);
// after the mBrian backend rollout PROJAX_BACKEND=mbrian wires
// *store.MBrianReader here.
Items store.ItemReader
// Writes is the write-path adapter every UI write handler, the
// /admin/bulk apply path, and the MCP write tools depend on. Phase 6
// Slice C introduces it as the twin of Items: today the concrete
// *Store satisfies ItemWriter (legacy path); PROJAX_BACKEND=mbrian
// wires *store.MBrianWriter here. main.go flips Items + Writes
// together — never one without the other (the slice-B half-flip bug).
Writes store.ItemWriter
pages map[string]*template.Template
Logger *slog.Logger
Auth *AuthConfig // nil → no auth (local dev / tests)
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
Gitea *GiteaDeps // nil → Gitea integration disabled
Auth *AuthConfig // nil → no auth (local dev / tests)
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
Gitea *GiteaDeps // nil → Gitea integration disabled
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
Version string // build-time -ldflags injection; surfaced on /admin
dashboard *cache.TTLCache[*dashboardPayload]
@@ -133,6 +168,10 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) },
"subF": func(a, b any) float64 { return toFloat(a) - toFloat(b) },
"mulF": func(a, b any) float64 { return toFloat(a) * toFloat(b) },
// Phase 5j slice G — sidebar icon registry. layout.tmpl calls
// `renderIcon .View.Icon` to emit the matching SVG, falling back to
// the folder default for nil / unknown keys.
"renderIcon": RenderViewIcon,
"tagToggleURL": func(active []string, tag string, isActive bool) string {
next := []string{}
if isActive {
@@ -152,7 +191,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
},
}
pages := map[string]*template.Template{}
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error", "views", "view_edit"} {
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error", "views_landing", "view_editor"} {
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/"+name+".tmpl",
@@ -189,6 +228,21 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
return nil, fmt.Errorf("parse tree_section: %w", err)
}
pages["tree_section"] = treeSection
// Phase 5j view-render template bundles the tree-section partials so a
// rendered view at /views/{slug} can use the same dispatch (list / card
// / kanban via .ViewType).
viewRender, err := template.New("view_render").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/view_render.tmpl",
"templates/tree_section.tmpl",
"templates/tree_card.tmpl",
"templates/tree_kanban.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse view_render: %w", err)
}
pages["view_render"] = viewRender
// detail bundles the shared tasks-section + issues-section partials so
// HTMX swaps and the initial page render hit the same template definitions.
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
@@ -202,7 +256,8 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
return nil, fmt.Errorf("parse detail: %w", err)
}
pages["detail"] = detailTmpl
// Standalone tasks-section template for HTMX fragment responses.
// Standalone unified-tasks-section template for HTMX fragment responses
// (Phase 7c — CalDAV + mBrian task writes re-render the merged list).
tasksFragment, err := template.New("tasks_section").Funcs(funcs).ParseFS(templatesFS, "templates/tasks_section.tmpl")
if err != nil {
return nil, fmt.Errorf("parse tasks_section: %w", err)
@@ -348,7 +403,12 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
pages["bulk_chip_mgmt"] = bulkChipMgmt
return &Server{
Store: s,
Store: s,
// Default Items + Writes satisfier is *Store itself. main.go can
// override both post-construction, atomically (PROJAX_BACKEND=mbrian
// → MBrianReader + MBrianWriter).
Items: s,
Writes: s,
pages: pages,
Logger: logger,
dashboard: cache.NewTTL[*dashboardPayload](dashboardCacheTTL),
@@ -362,17 +422,26 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.handleTree)
// Phase 5j slice C — full URL migration. The five legacy pages live at
// /views/{system-slug} now; the old top-level URLs 301-redirect to
// their new home (with the legacy ?view=<uuid> param resolved through
// the old uuid → new slug if it still maps to a row).
mux.HandleFunc("GET /views/tree", s.handleTree)
mux.HandleFunc("GET /views/dashboard", s.handleDashboard)
mux.HandleFunc("GET /views/timeline", s.handleTimeline)
mux.HandleFunc("GET /views/calendar", s.handleCalendar)
mux.HandleFunc("GET /views/graph", s.handleGraph)
mux.HandleFunc("GET /", s.legacyRedirect("tree"))
mux.HandleFunc("GET /dashboard", s.legacyRedirect("dashboard"))
mux.HandleFunc("GET /timeline", s.legacyRedirect("timeline"))
mux.HandleFunc("GET /calendar", s.legacyRedirect("calendar"))
mux.HandleFunc("GET /graph", s.legacyRedirect("graph"))
mux.HandleFunc("GET /i/", s.handleDetail)
mux.HandleFunc("POST /i/", s.handleDetailWrite)
mux.HandleFunc("GET /new", s.handleNewForm)
mux.HandleFunc("POST /new", s.handleNewSubmit)
mux.HandleFunc("GET /admin", s.handleAdminIndex)
mux.HandleFunc("GET /admin/classify", s.handleClassify)
mux.HandleFunc("GET /dashboard", s.handleDashboard)
mux.HandleFunc("GET /timeline", s.handleTimeline)
mux.HandleFunc("GET /calendar", s.handleCalendar)
mux.HandleFunc("GET /graph", s.handleGraph)
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
mux.HandleFunc("POST /dashboard/task/edit", s.handleDashboardTaskEdit)
mux.HandleFunc("POST /dashboard/task/delete", s.handleDashboardTaskDelete)
@@ -383,11 +452,18 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
mux.HandleFunc("GET /views", s.handleViewsIndex)
// Phase 5j paliad-shape views routes (slice B). /views = MRU landing
// or onboarding shell; /views/{slug} = render the saved view as its
// own page; /views/new + /views/{slug}/edit = editor. POST CRUD
// rounds out the family; reorder is wired now for slice G's drag UI.
mux.HandleFunc("GET /views", s.handleViewsLanding)
mux.HandleFunc("POST /views", s.handleViewCreate)
mux.HandleFunc("GET /views/{id}/edit", s.handleViewEdit)
mux.HandleFunc("GET /views/", s.handleViewRedirect)
mux.HandleFunc("POST /views/", s.handleViewWrite)
mux.HandleFunc("POST /views/reorder", s.handleViewReorder)
mux.HandleFunc("GET /views/new", s.handleViewEditor)
mux.HandleFunc("GET /views/{slug}", s.handleViewRender)
mux.HandleFunc("GET /views/{slug}/edit", s.handleViewEditor)
mux.HandleFunc("POST /views/{slug}", s.handleViewUpdate)
mux.HandleFunc("POST /views/{slug}/delete", s.handleViewDelete)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
@@ -430,16 +506,15 @@ type treeNode struct {
}
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
items, err := s.Store.ListAll(r.Context())
// Phase 5j slice C: handleTree is reached at /views/tree (system view)
// only. The legacy / route 301-redirects via legacyRedirect — see
// Routes(). Any 404-on-unknown-path responsibility moved with it.
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
tags, err := s.Store.AllTags(r.Context())
tags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -452,32 +527,16 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
filter := ParseTreeFilter(r.URL.Query())
viewSet := PageViewTypes("/")
view := ParseViewType(r.URL.Query(), viewSet)
var defaultBanner *store.View
// Phase 5i Slice D: ?view=<uuid> resolves a saved view's filter +
// view_type into the current request, overriding URL-only chip state.
// Resolution failure (deleted view, malformed payload) is logged and
// silently falls back to the URL-derived filter — the page stays
// renderable rather than 500ing.
if saved, err := s.applySavedView(r, &filter, &view); err == nil && saved != nil {
// Re-validate view_type against the route catalog so a saved
// kanban-view URL opened on / (before slice C ships kanban) lands on
// the default with the chip showing the wanted view as locked.
view = viewSet.Resolve(view)
} else if err != nil {
s.Logger.Warn("applySavedView", "id", r.URL.Query().Get("view"), "err", err)
} else {
// Phase 5i Slice E: no explicit ?view= → check for a page default.
// applyDefaultView returns nil unless the URL is "clean" (no chip
// state) AND a default exists for this page.
if def, err := s.applyDefaultView(r, "tree", &filter, &view); err == nil && def != nil {
view = viewSet.Resolve(view)
defaultBanner = def
} else if err != nil {
s.Logger.Warn("applyDefaultView", "page", "tree", "err", err)
}
}
// Phase 5j: ?view= overlay + is_default_for resolution deleted with the
// 5i shape. /views/{slug} (slice B+) renders saved views as their own
// pages; legacy ?view=<uuid> URLs are 302-redirected from a dedicated
// handler (slice C). handleTree stays focused on the tree-as-tree
// surface and no longer hijacks itself based on a query param.
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
counts := computeChipCounts(items, filter, linkKinds, tags)
// Phase 5j slice C: tree lives at /views/tree now. Chip URLs need to
// anchor on the new base so chip clicks stay on this page.
const treeBase = "/views/tree"
counts := computeChipCounts(items, filter, linkKinds, tags, treeBase)
// Phase 5i Slice B: the card view renders a flat grid of matched items
// (no tree structure). Build from items + filter directly rather than
// reusing the post-prune `roots` (which still keeps ancestors).
@@ -485,7 +544,7 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
// Phase 5i Slice C: kanban groups the same matched set into columns.
groupBy := ParseGroupBy(r.URL.Query())
kanban := BuildKanbanBoard(cardItems, groupBy)
groupByChips := GroupByChips("/", filter, groupBy)
groupByChips := GroupByChips(treeBase, filter, groupBy)
data := map[string]any{
"Title": "tree",
"Roots": roots,
@@ -497,15 +556,14 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
"Filter": filter,
"Counts": counts,
"Projects": parentOptionsFromItems(items),
"BasePath": "/",
"BasePath": treeBase,
"ProjectChipTarget": "#tree-section",
"ViewType": view,
"ViewTypeChips": ViewTypeChips("/", filter, view),
"ViewTypeChips": ViewTypeChips(treeBase, filter, view),
"CardItems": cardItems,
"Kanban": kanban,
"GroupBy": groupBy,
"GroupByChips": groupByChips,
"DefaultBanner": defaultBanner,
// ActiveTags kept for backwards-compat with the old template path; removed
// after the template migrates fully.
"ActiveTags": filter.Tags,
@@ -526,7 +584,7 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
func (s *Server) linkKindsByItem(ctx context.Context) (map[string]map[string]struct{}, error) {
out := map[string]map[string]struct{}{}
for _, t := range []string{"caldav-list", "gitea-repo"} {
links, err := s.Store.LinksByRefType(ctx, t)
links, err := s.Items.LinksByRefType(ctx, t)
if err != nil {
return nil, err
}
@@ -551,11 +609,11 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
// PER URL resolution: try the full path first; if it 404s and the trailing
// segment looks like YYMMDD, retry against the shorter path and surface
// the date as a render hint to scroll/highlight the matching row.
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
var highlight *time.Time
if errors.Is(err, store.ErrNotFound) {
if base, d := parsePER(path); d != nil {
if it2, err2 := s.Store.GetByPath(r.Context(), base); err2 == nil {
if it2, err2 := s.Items.GetByPath(r.Context(), base); err2 == nil {
it, err, highlight = it2, nil, d
}
}
@@ -569,26 +627,6 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
tasks, err := s.detailTodos(r.Context(), it)
if err != nil {
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
}
// Phase 5j: pre-load discoverable CalDAV calendars (minus the ones
// already linked) so the per-item Tasks section can offer a "Link
// existing list" picker alongside the create-new affordance. Errors
// are non-fatal — the section falls back to its pre-5j shape.
var availableCalendars []caldav.Calendar
if s.CalDAV != nil {
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if lerr != nil {
s.Logger.Warn("detail caldav links", "path", it.PrimaryPath(), "err", lerr)
}
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
if aerr != nil {
s.Logger.Warn("detail available caldav", "path", it.PrimaryPath(), "err", aerr)
}
availableCalendars = acs
}
issues, err := s.detailIssues(r.Context(), it)
if err != nil {
s.Logger.Warn("detail issues", "path", it.PrimaryPath(), "err", err)
@@ -597,24 +635,32 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
for _, ri := range issues {
openTotal += ri.OpenCount
}
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
docs, err := s.Items.DatedLinks(r.Context(), it.ID)
if err != nil {
s.Logger.Warn("detail docs", "path", it.PrimaryPath(), "err", err)
}
documents := computePERs(it.PrimaryPath(), docs)
// Phase 7c — ONE unified task list per project: mBrian-native tasks +
// CalDAV VTODOs merged into a single sorted list, each row tagged by
// source, actions dispatched by source. The section shows whenever a task
// backend is available (CalDAV configured or the mBrian task backend live).
unified := s.buildUnifiedTasks(r.Context(), it)
showTasks := unified.CalDAVOn || unified.MBrianOn
tasksOpen := len(unified.Open) > 0
tasksData := unifiedTasksData(it, unified, "")
s.render(w, r, "detail", map[string]any{
"Title": it.Title,
"Item": it,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
"Tasks": tasks,
"AvailableCalendars": availableCalendars,
"CalDAVOn": s.CalDAV != nil,
"Issues": issues,
"IssuesOpenTotal": openTotal,
"GiteaOn": s.Gitea != nil,
"Documents": documents,
"HighlightDate": highlight,
"Title": it.Title,
"Item": it,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
"ShowTasks": showTasks,
"TasksOpen": tasksOpen,
"TasksData": tasksData,
"Issues": issues,
"IssuesOpenTotal": openTotal,
"GiteaOn": s.Gitea != nil,
"Documents": documents,
"HighlightDate": highlight,
})
}
@@ -638,6 +684,13 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
return
}
}
// Phase 7 — mBrian-native task actions.
for _, action := range []string{"create", "done", "reopen", "edit", "due", "delete"} {
if base, ok := strings.CutSuffix(path, "/task/"+action); ok {
s.handleTaskAction(w, r, base, action)
return
}
}
for _, action := range []string{"close", "reopen", "comment", "create"} {
if base, ok := strings.CutSuffix(path, "/issues/"+action); ok {
s.handleIssueAction(w, r, base, action)
@@ -652,7 +705,7 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
s.handleLinksRemove(w, r, base)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -678,7 +731,7 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
s.itemWriteFailure(w, r, ve)
return
}
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Items, itemwrite.Input{
ID: it.ID, Title: title, Slug: slug, Status: status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
}); ve != nil {
s.itemWriteFailure(w, r, ve)
@@ -706,11 +759,14 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
// Phase 4f: timeline-exclude form field is a multi-value checkbox set
// (`name="timeline_exclude" value="todos"`, …). parseTimelineExcludeList
// keeps only the known kinds so a stray value can't poison the array.
TimelineExclude: parseTimelineExcludeList(r.Form["timeline_exclude"]),
TimelineExclude: parseTimelineExcludeList(r.Form["timeline_exclude"]),
// Phase 7 checklist render hint (Q1): the flags checkbox maps to
// metadata.projax.render="checklist"; unchecked clears it ("").
Render: renderHint(r.FormValue("render_checklist") == "1"),
}
updated, err := s.Store.Update(r.Context(), it.ID, in)
updated, err := s.Writes.Update(r.Context(), it.ID, in)
if err != nil {
s.fail(w, r, err)
s.writeFailure(w, r, err)
return
}
http.Redirect(w, r, "/i/"+updated.PrimaryPath(), http.StatusSeeOther)
@@ -720,7 +776,7 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
// a root mai-managed item under a chosen parent without touching other fields.
// HTMX-friendly: returns a fragment when HX-Request is set.
func (s *Server) handleReparent(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -749,13 +805,13 @@ func (s *Server) handleReparent(w http.ResponseWriter, r *http.Request, path str
s.itemWriteFailure(w, r, ve)
return
}
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Items, itemwrite.Input{
ID: it.ID, Title: it.Title, Slug: it.Slug, Status: it.Status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
}); ve != nil {
s.itemWriteFailure(w, r, ve)
return
}
moved, err := s.Store.Reparent(r.Context(), it.ID, parentIDs)
moved, err := s.Writes.Reparent(r.Context(), it.ID, parentIDs)
if err != nil {
s.fail(w, r, err)
return
@@ -875,7 +931,7 @@ func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
parentPath := r.URL.Query().Get("parent")
var parent *store.Item
if parentPath != "" {
p, err := s.Store.GetByPath(r.Context(), parentPath)
p, err := s.Items.GetByPath(r.Context(), parentPath)
if err != nil {
s.fail(w, r, err)
return
@@ -925,7 +981,7 @@ func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
s.itemWriteFailure(w, r, ve)
return
}
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Items, itemwrite.Input{
Title: title, Slug: slug, Status: status, ParentIDs: parentIDs,
}); ve != nil {
s.itemWriteFailure(w, r, ve)
@@ -941,16 +997,16 @@ func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
Tags: parseCSV(r.FormValue("tags")),
Management: parseCSV(r.FormValue("management")),
}
it, err := s.Store.Create(r.Context(), in)
it, err := s.Writes.Create(r.Context(), in)
if err != nil {
s.fail(w, r, err)
s.writeFailure(w, r, err)
return
}
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
}
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
orphans, err := s.Store.MaiOrphans(r.Context())
orphans, err := s.Items.MaiOrphans(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -976,7 +1032,7 @@ type ParentOption struct {
}
func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
items, err := s.Store.ListAll(ctx)
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}
@@ -1026,6 +1082,49 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
if _, set := data["Path"]; !set {
data["Path"] = r.URL.Path
}
// Phase 5j slice E: layout's "Views" sidebar section lists every
// user view. Lookup is one indexed query per render — at m's scale
// (≤30 saved views) the cost is negligible and dwarfed by the
// dashboard/timeline aggregation cards. The login page bypasses the
// layout entirely so we don't fetch for it; stub servers without a
// configured store also skip cleanly.
if _, set := data["UserViews"]; !set && name != "login" && s.Store != nil {
if uv, err := s.Store.ListViews(r.Context()); err == nil {
data["UserViews"] = uv
// Phase 5j slice G — show_count badges. For every view with
// ShowCount=true, run its persisted filter against ListAll and
// pass a slug→count map to the template. Caching is one
// ListAll per render shared across all show-count views.
counts := map[string]int{}
needsCount := false
for _, v := range uv {
if v.ShowCount {
needsCount = true
break
}
}
if needsCount {
items, err := s.Items.ListAll(r.Context())
if err == nil {
linkKinds, _ := s.linkKindsByItem(r.Context())
for _, v := range uv {
if !v.ShowCount {
continue
}
f, _, _ := decodeViewSpec(v.FilterJSON)
n := 0
for _, it := range items {
if f.Matches(it, linkKinds[it.ID]) {
n++
}
}
counts[v.Slug] = n
}
}
}
data["UserViewCounts"] = counts
}
}
entry := "layout"
switch name {
case "login":

View File

@@ -81,9 +81,9 @@ func TestTreeRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/")
code, body := get(t, h, "/views/tree")
if code != 200 {
t.Fatalf("GET / status %d body=%s", code, body)
t.Fatalf("GET /views/tree status %d body=%s", code, body)
}
// /admin/classify used to live in the nav; Phase 3o consolidated all
// admin links under the new /admin index. Assert /admin instead.
@@ -102,7 +102,7 @@ func TestLayoutHasViewportMeta(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
for _, path := range []string{"/", "/dashboard", "/calendar", "/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/calendar", "/views/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
_, body := get(t, h, path)
if !strings.Contains(body, `name="viewport"`) {
t.Errorf("GET %s: missing <meta name=\"viewport\">", path)
@@ -302,7 +302,7 @@ func TestTreeRendersKanbanWhenViewTypeIsKanban(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/?view_type=kanban")
_, body := get(t, h, "/views/tree?view_type=kanban")
if !strings.Contains(body, `class="kanban-board"`) {
t.Error("?view_type=kanban should render the kanban board")
}
@@ -324,7 +324,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
defer pool.Close()
h := srv.Routes()
// List view (default): forest markup expected; tree-card-grid absent.
_, listBody := get(t, h, "/")
_, listBody := get(t, h, "/views/tree")
if !strings.Contains(listBody, `<ul class="forest">`) {
t.Error("default GET / should render the tree forest")
}
@@ -335,7 +335,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
t.Error("view-type chip strip should appear on every view")
}
// Card view: card grid present, forest absent.
_, cardBody := get(t, h, "/?view_type=card")
_, cardBody := get(t, h, "/views/tree?view_type=card")
if !strings.Contains(cardBody, `class="tree-card-grid"`) {
t.Error("GET /?view_type=card should render the card grid")
}
@@ -343,7 +343,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
t.Error("GET /?view_type=card should not render the tree forest")
}
// Unknown view_type falls back to list.
_, unknownBody := get(t, h, "/?view_type=junk")
_, unknownBody := get(t, h, "/views/tree?view_type=junk")
if !strings.Contains(unknownBody, `<ul class="forest">`) {
t.Error("unknown view_type should fall back to list")
}
@@ -393,7 +393,7 @@ func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
siblingLink := `href="/i/dev.` + siblingSlug + `"`
// Descendants on (default): parent + child visible, sibling hidden.
_, withDesc := get(t, h, "/?project="+parentPath)
_, withDesc := get(t, h, "/views/tree?project="+parentPath)
if !strings.Contains(withDesc, parentLink) {
t.Errorf("?project=%s should show parent row", parentPath)
}
@@ -405,7 +405,7 @@ func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
}
// Descendants off: only the picked item, no children.
_, noDesc := get(t, h, "/?project="+parentPath+"&project_descendants=0")
_, noDesc := get(t, h, "/views/tree?project="+parentPath+"&project_descendants=0")
if !strings.Contains(noDesc, parentLink) {
t.Errorf("?project_descendants=0 should still show the picked parent row")
}

1
web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1211,6 +1211,23 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .brand-label {
border-left: 2px solid var(--accent);
padding-left: 14px;
}
/* Phase 5j slice E — Views sub-section: user-view entries sit below the
main nav items, slightly indented + smaller, so the system rows stay
visually anchored. The Views section header (the "Views" main entry)
is unchanged; this just styles the per-saved-view rows. */
.projax-sidebar .sidebar-user-views { display: flex; flex-direction: column; gap: 2px; padding: 4px 0; }
.projax-sidebar .nav-item-user-view { font-size: 0.92em; padding-left: 24px; }
.projax-sidebar .nav-item-user-view.active { padding-left: 22px; }
.projax-sidebar .user-view-icon { width: 1em; text-align: center; }
.projax-sidebar .nav-item-new-view { color: var(--muted); }
.projax-sidebar .nav-badge {
margin-left: auto; font-size: 0.78em; color: var(--muted);
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 0 6px;
}
.projax-sidebar .nav-item-user-view.active .nav-badge {
color: var(--accent); border-color: var(--accent);
}
.projax-sidebar .nav-icon {
width: 18px;
height: 18px;

View File

@@ -12,9 +12,12 @@
// real PWA + keep static assets warm. Mutations (CalDAV / Gitea writeback)
// still require connectivity.
const CACHE_NAME = 'projax-shell-v1';
// v2: htmx.min.js joins the shell so the HTMX-driven forms work offline too
// (the cache name bump purges the v1 asset set on activate).
const CACHE_NAME = 'projax-shell-v2';
const SHELL_ASSETS = [
'/static/style.css',
'/static/htmx.min.js',
'/static/manifest.webmanifest',
'/static/icon-192.png',
'/static/icon-512.png',

87
web/system_views.go Normal file
View File

@@ -0,0 +1,87 @@
package web
import (
"net/http"
"strings"
)
// Phase 5j Slice C — system views. Per m's Q1 pick (b) (2026-05-29):
// FULL MIGRATION of the legacy pages into the /views/{slug} family.
// /, /dashboard, /calendar, /timeline, /graph all 301-redirect to their
// /views/{system-slug} counterparts; the handlers stay (now reachable
// under the new URL).
//
// System views are code-resident — they never appear as rows in
// projax.views. Their slugs are reserved at the validator level (see
// store.IsReservedViewSlug) so user-created views can't shadow them.
// SystemView is a code-resident view definition. The sidebar's Views
// section (slice E) lists every entry returned by AllSystemViews
// alongside user views. The render path for system slugs goes directly
// to the legacy handler (handleTree / handleDashboard / …); the struct
// here is metadata for navigation, not a render spec.
type SystemView struct {
Slug string
Name string
Icon string
URL string // /views/{slug}
}
// AllSystemViews returns every code-resident view in display order. Used
// by the sidebar (slice E) and the reserved-slug validation (slice A
// already pre-seeded the same slugs in store.IsReservedViewSlug — keep
// in sync with this list).
func AllSystemViews() []SystemView {
return []SystemView{
{Slug: "tree", Name: "Tree", Icon: "tree", URL: "/views/tree"},
{Slug: "dashboard", Name: "Dashboard", Icon: "dashboard", URL: "/views/dashboard"},
{Slug: "calendar", Name: "Calendar", Icon: "calendar", URL: "/views/calendar"},
{Slug: "timeline", Name: "Timeline", Icon: "clock", URL: "/views/timeline"},
{Slug: "graph", Name: "Graph", Icon: "graph", URL: "/views/graph"},
}
}
// LookupSystemView returns the SystemView matching slug, or nil. Used by
// handleViewRender's fallback path and by tests that need to assert
// metadata.
func LookupSystemView(slug string) *SystemView {
for _, sv := range AllSystemViews() {
if sv.Slug == slug {
s := sv
return &s
}
}
return nil
}
// legacyRedirect returns a handler that 301s the legacy URL onto its
// /views/{system-slug} counterpart. Per m's Q3 pick (b): when the
// request carries a legacy `?view=<uuid>` param (the 5i overlay scheme)
// the redirect resolves the uuid → current slug so old bookmarks land
// on the user view they pointed at. A miss falls through to the system
// slug.
func (s *Server) legacyRedirect(systemSlug string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// / is a path-prefix in Go's mux; only redirect when the request
// path is exactly "/". Any other root-relative path that fell
// through to GET / (e.g. "/some-unknown") gets a 404.
if systemSlug == "tree" && r.URL.Path != "/" {
http.NotFound(w, r)
return
}
target := "/views/" + systemSlug
if id := strings.TrimSpace(r.URL.Query().Get("view")); id != "" {
if v, err := s.Store.GetViewByID(r.Context(), id); err == nil && v != nil {
target = "/views/" + v.Slug
}
}
// Preserve any non-`view` query params so existing bookmarks
// carrying ?tag=… etc. still narrow the redirected view.
q := r.URL.Query()
q.Del("view")
if encoded := q.Encode(); encoded != "" {
target += "?" + encoded
}
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
}

175
web/system_views_test.go Normal file
View File

@@ -0,0 +1,175 @@
package web_test
import (
"context"
"strings"
"testing"
"time"
"github.com/m/projax/web"
)
// TestSystemViewLookup verifies the code-resident lookup returns the
// expected slugs in display order, and that LookupSystemView round-trips
// each entry.
func TestSystemViewLookup(t *testing.T) {
all := web.AllSystemViews()
wantSlugs := []string{"tree", "dashboard", "calendar", "timeline", "graph"}
if len(all) != len(wantSlugs) {
t.Fatalf("AllSystemViews len = %d, want %d", len(all), len(wantSlugs))
}
for i, sv := range all {
if sv.Slug != wantSlugs[i] {
t.Errorf("position %d: slug = %q, want %q", i, sv.Slug, wantSlugs[i])
}
if sv.URL != "/views/"+sv.Slug {
t.Errorf("position %d: URL = %q, want /views/%s", i, sv.URL, sv.Slug)
}
round := web.LookupSystemView(sv.Slug)
if round == nil || round.Slug != sv.Slug {
t.Errorf("LookupSystemView(%q) round-trip failed", sv.Slug)
}
}
if web.LookupSystemView("not-a-system-slug") != nil {
t.Error("LookupSystemView should return nil for unknown slugs")
}
}
// TestLegacyRedirects verifies the slice C URL migration: each legacy
// route 301-redirects to its /views/{slug} counterpart with chip params
// preserved.
func TestLegacyRedirects(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
cases := []struct {
path, want string
}{
{"/", "/views/tree"},
{"/dashboard", "/views/dashboard"},
{"/calendar", "/views/calendar"},
{"/timeline", "/views/timeline"},
{"/graph", "/views/graph"},
// chip params survive the redirect:
{"/dashboard?tag=work", "/views/dashboard?tag=work"},
{"/timeline?from=2026-05-01", "/views/timeline?from=2026-05-01"},
}
for _, tc := range cases {
code, body := get(t, h, tc.path)
if code != 301 {
t.Errorf("GET %s status=%d body=%q, want 301", tc.path, code, body)
}
if !strings.Contains(body, `href="`+tc.want+`"`) {
t.Errorf("GET %s body=%q, want redirect to %q", tc.path, body, tc.want)
}
}
}
// TestSidebarListsUserViews — slice E: every chrome-bearing page renders
// the saved-view list under the main nav. Each entry links to
// /views/{slug} with the name as the label. Active state fires when the
// current URL matches.
func TestSidebarListsUserViews(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"), ".", "")
slug := "p5j-e-sidebar-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'P5jE Sidebar', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
t.Fatalf("seed: %v", err)
}
_, body := get(t, h, "/views/tree")
if !strings.Contains(body, `href="/views/`+slug+`"`) {
t.Error("sidebar should list saved view as /views/<slug>")
}
if !strings.Contains(body, "P5jE Sidebar") {
t.Error("sidebar should show saved view's display name")
}
if !strings.Contains(body, `href="/views/new"`) {
t.Error("sidebar Views section should include a + New view link")
}
// Active state when the URL matches.
_, onView := get(t, h, "/views/"+slug)
if !strings.Contains(onView, `class="nav-item nav-item-user-view active"`) {
t.Error("user-view nav-item should carry .active when its URL is current")
}
}
// TestSidebarShowCountBadge — slice G: a saved view with show_count=true
// renders a row-count badge in the sidebar reflecting the filter's match
// count against ListAll().
func TestSidebarShowCountBadge(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"), ".", "")
slug := "p5j-g-badge-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
// Seed a view scoped to dev → its count = count of items under dev that
// match status=active (default).
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (slug, name, filter_json, show_count)
VALUES ($1, 'P5jG Badge', '{"view_type":"list","project_path":"dev"}'::jsonb, true)`,
slug); err != nil {
t.Fatalf("seed view: %v", err)
}
_, body := get(t, h, "/views/tree")
if !strings.Contains(body, `class="nav-badge"`) {
t.Error("show_count view should render a nav-badge in the sidebar")
}
}
// TestSidebarIconRenders — slice G: a view with an icon key emits the
// SVG from the registry; missing key falls back to folder default.
func TestSidebarIconRenders(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"), ".", "")
slug := "p5j-g-icon-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (slug, name, filter_json, icon)
VALUES ($1, 'P5jG Icon', '{"view_type":"list"}'::jsonb, 'star')`, slug); err != nil {
t.Fatalf("seed: %v", err)
}
_, body := get(t, h, "/views/tree")
// The star icon's SVG path includes its distinctive 5-point polygon.
if !strings.Contains(body, `polygon points="12 2 15.09 8.26`) {
t.Error("sidebar should render the star icon SVG for icon=star")
}
}
// TestLegacyViewUUIDRedirect — when a legacy URL carries the 5i overlay
// `?view=<uuid>` param, the redirect resolves the uuid to the current
// slug (per m's Q3 pick), so old bookmarks land on the right user view.
func TestLegacyViewUUIDRedirect(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"), ".", "")
slug := "p5j-c-legacy-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
var id string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'Legacy', '{"view_type":"list"}'::jsonb)
RETURNING id`, slug).Scan(&id); err != nil {
t.Fatalf("seed view: %v", err)
}
// Old-style URL: /?view=<uuid>
code, body := get(t, h, "/?view="+id)
if code != 301 {
t.Fatalf("GET /?view=<uuid> status=%d body=%q want 301", code, body)
}
if !strings.Contains(body, "/views/"+slug) {
t.Errorf("redirect should resolve uuid → slug; got body=%q", body)
}
}

410
web/task.go Normal file
View File

@@ -0,0 +1,410 @@
package web
import (
"context"
"net/http"
"sort"
"strings"
"github.com/m/projax/caldav"
"github.com/m/projax/store"
)
// Phase 7c — UNIFIED task surface. The detail page renders ONE task list per
// project that merges mBrian-native tasks (type=['task'] child nodes) AND
// CalDAV tasks (VTODOs from linked calendars) into a single sorted list. Each
// row is subtly tagged by Source so m can tell where a task lives, but they
// read as one list. Actions dispatch to the right backend by Source: CalDAV
// rows POST to /caldav/todo/{action}; mBrian rows POST to /task/{action}.
//
// New tasks default by the §3.1 selector: a CalDAV-bound project (has a
// caldav-list link) creates VTODOs on its linked calendar; an unbound project
// creates mBrian-native task nodes. (Supersedes the Phase 7b two-section split
// per m's request to "collect from mBrian AS WELL AS CalDAV and display
// together".)
// taskBackend returns the task-capable reader + writer when the active backend
// supports mBrian-native tasks (PROJAX_BACKEND=mbrian). The legacy *Store
// backend has no task nodes, so both asserts fail and only CalDAV tasks show.
func (s *Server) taskBackend() (store.TaskReader, store.TaskWriter, bool) {
tr, rok := s.Items.(store.TaskReader)
tw, wok := s.Writes.(store.TaskWriter)
return tr, tw, rok && wok
}
// taskRow wraps a uniform store.Task with a render-only source label (the
// calendar display name for CalDAV tasks, "projax" for mBrian tasks). Kept out
// of store.Task so the store shape stays a pure data view.
type taskRow struct {
*store.Task
SourceLabel string
}
// unifiedTasks is the assembled per-project task surface the section template
// renders: open tasks up top, done below, plus the CalDAV management
// affordances (link/create) and the add-form routing inputs.
type unifiedTasks struct {
Open []taskRow
Done []taskRow
// CalDAVOn reports whether CalDAV integration is configured at all.
CalDAVOn bool
// CalDAVBound reports whether THIS project has ≥1 caldav-list link — the
// §3.1 selector that routes new tasks to CalDAV vs mBrian.
CalDAVBound bool
// LinkedCalendars are this project's bound calendars (url + display name),
// for the add-form target (a <select> when >1) and the row labels.
LinkedCalendars []caldav.Calendar
// AvailableCalendars feeds the "link existing list" picker (discoverable
// minus already-linked). Best-effort; empty on discovery error.
AvailableCalendars []caldav.Calendar
// MBrianOn reports whether the mBrian task backend is available (so the
// add-form can create native tasks on an unbound project).
MBrianOn bool
Banner string
}
// buildUnifiedTasks gathers mBrian-native + CalDAV tasks for the item, merges
// them into one sorted open/done split, and collects the CalDAV management
// data the section needs. Per-source failures degrade to a banner rather than
// blanking the whole list.
func (s *Server) buildUnifiedTasks(ctx context.Context, item *store.Item) unifiedTasks {
u := unifiedTasks{CalDAVOn: s.CalDAV != nil}
banners := []string{}
// --- CalDAV side ---
var linkedLinks []*store.ItemLink
if s.CalDAV != nil {
links, err := s.Items.LinksByType(ctx, item.ID, refTypeCalDAV)
if err != nil {
s.Logger.Warn("unified tasks caldav links", "item", item.ID, "err", err)
}
linkedLinks = links
u.CalDAVBound = len(links) > 0
calName := map[string]string{}
for _, l := range links {
name := linkDisplay(l)
u.LinkedCalendars = append(u.LinkedCalendars, caldav.Calendar{URL: l.RefID, DisplayName: name})
calName[l.RefID] = name
}
sort.Slice(u.LinkedCalendars, func(i, j int) bool {
return u.LinkedCalendars[i].DisplayName < u.LinkedCalendars[j].DisplayName
})
cts, err := s.detailTodos(ctx, item)
if err != nil {
s.Logger.Warn("unified tasks detailTodos", "item", item.ID, "err", err)
banners = append(banners, "Could not load CalDAV tasks: "+err.Error())
}
for _, ct := range cts {
if ct.Error != "" {
banners = append(banners, ct.DisplayName+": "+ct.Error)
}
label := calName[ct.CalendarURL]
if label == "" {
label = "CalDAV"
}
for _, td := range ct.Open {
u.Open = append(u.Open, taskRow{Task: taskFromTodo(td, ct.CalendarURL, item.ID), SourceLabel: label})
}
for _, td := range ct.DoneRecent {
u.Done = append(u.Done, taskRow{Task: taskFromTodo(td, ct.CalendarURL, item.ID), SourceLabel: label})
}
}
if acs, aerr := s.availableCalendarsForItem(ctx, linkedLinks); aerr != nil {
s.Logger.Warn("unified tasks available caldav", "item", item.ID, "err", aerr)
} else {
u.AvailableCalendars = acs
}
}
// --- mBrian side ---
if tr, _, ok := s.taskBackend(); ok {
u.MBrianOn = true
tasks, err := tr.TasksForItem(ctx, item.ID)
if err != nil {
s.Logger.Warn("unified tasks mbrian", "item", item.ID, "err", err)
banners = append(banners, "Could not load projax tasks: "+err.Error())
}
for _, t := range tasks {
row := taskRow{Task: t, SourceLabel: "projax"}
switch {
case t.Done:
u.Done = append(u.Done, row)
case t.Status == "archived":
// hidden
default:
u.Open = append(u.Open, row)
}
}
}
sortTaskRows(u.Open)
sortTaskRows(u.Done)
u.Banner = strings.Join(banners, " · ")
return u
}
// sortTaskRows orders a task slice: earlier due first (undated last), then
// created-at, then title — a stable, sensible merge order across both sources.
func sortTaskRows(rows []taskRow) {
sort.SliceStable(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
// Due: dated before undated; earlier due first.
switch {
case a.Due != nil && b.Due != nil:
if !a.Due.Equal(*b.Due) {
return a.Due.Before(*b.Due)
}
case a.Due != nil && b.Due == nil:
return true
case a.Due == nil && b.Due != nil:
return false
}
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.Before(b.CreatedAt)
}
return a.Title < b.Title
})
}
// addTarget describes where the single add-form creates a new task.
type addTarget struct {
// Mode is "caldav" or "mbrian".
Mode string
// CalendarURL is the default target calendar (CalDAV mode, single
// calendar). Empty when the form shows a calendar <select> (>1 calendar).
CalendarURL string
}
// addTargetFor decides the new-task backend per §3.1: CalDAV-bound → CalDAV
// (default to the sole calendar, or let the form pick when several); unbound →
// mBrian-native (when the backend supports it).
func (u unifiedTasks) AddTarget() addTarget {
if u.CalDAVBound {
t := addTarget{Mode: "caldav"}
if len(u.LinkedCalendars) == 1 {
t.CalendarURL = u.LinkedCalendars[0].URL
}
return t
}
if u.MBrianOn {
return addTarget{Mode: "mbrian"}
}
return addTarget{} // no add affordance
}
// unifiedTasksData builds the template payload for the unified section.
func unifiedTasksData(item *store.Item, u unifiedTasks, banner string) map[string]any {
if banner != "" && u.Banner != "" {
banner = banner + " · " + u.Banner
} else if banner == "" {
banner = u.Banner
}
return map[string]any{
"Item": item,
"Open": u.Open,
"Done": u.Done,
"CalDAVOn": u.CalDAVOn,
"CalDAVBound": u.CalDAVBound,
"LinkedCalendars": u.LinkedCalendars,
"AvailableCalendars": u.AvailableCalendars,
"MBrianOn": u.MBrianOn,
"Checklist": item.RendersChecklist(),
"AddTarget": u.AddTarget(),
"Banner": banner,
}
}
// itemIsCalDAVBound reports whether the item has ≥1 caldav-list link — the
// §3.1 backend selector. Errors degrade to false (treat as mBrian-native).
func (s *Server) itemIsCalDAVBound(ctx context.Context, itemID string) bool {
links, err := s.Items.LinksByType(ctx, itemID, refTypeCalDAV)
if err != nil {
s.Logger.Warn("caldav-bound check", "item", itemID, "err", err)
return false
}
return len(links) > 0
}
// renderUnifiedTasks re-renders the unified tasks fragment for HTMX swaps —
// shared by BOTH the CalDAV and mBrian task action handlers so a write from
// either backend refreshes the same merged list.
func (s *Server) renderUnifiedTasks(w http.ResponseWriter, r *http.Request, it *store.Item, banner string) {
u := s.buildUnifiedTasks(r.Context(), it)
s.render(w, r, "tasks_section", unifiedTasksData(it, u, banner))
}
// handleTaskAction dispatches POST /i/{path}/task/{action} for mBrian-native
// tasks. action ∈ {create, done, reopen, edit, due, delete}. Every mutating
// action on an EXISTING task verifies the node belongs to this item (guards
// against a crafted form targeting an arbitrary node), then writes via the
// TaskWriter and re-renders the unified section.
func (s *Server) handleTaskAction(w http.ResponseWriter, r *http.Request, path, action string) {
tr, tw, ok := s.taskBackend()
if !ok {
http.Error(w, "tasks not supported on this backend", http.StatusServiceUnavailable)
return
}
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
banner := ""
switch action {
case "create":
// New tasks are created mBrian-native only when the project isn't
// CalDAV-bound (§3.1). A crafted create against a CalDAV-bound project
// is refused so the backend selector can't be bypassed.
if s.itemIsCalDAVBound(r.Context(), it.ID) {
http.Error(w, "project is CalDAV-bound — create tasks via the CalDAV list", http.StatusConflict)
return
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
banner = "Cannot create a task with an empty title."
break
}
in := store.TaskCreateInput{Title: title, ParentItemID: it.ID}
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
if t, ok := parseDueInput(dueStr); ok {
in.Due = &t
}
}
if _, err := tw.CreateTask(r.Context(), in); err != nil {
banner = taskBanner("create", err)
}
case "done", "reopen", "edit", "due", "delete":
nodeID := strings.TrimSpace(r.FormValue("node_id"))
if nodeID == "" {
http.Error(w, "node_id required", http.StatusBadRequest)
return
}
if !taskBelongsTo(r.Context(), tr, it.ID, nodeID) {
http.Error(w, "task not attached to this item", http.StatusForbidden)
return
}
switch action {
case "done":
if err := tw.SetTaskStatus(r.Context(), nodeID, "done"); err != nil {
banner = taskBanner("complete", err)
}
case "reopen":
if err := tw.SetTaskStatus(r.Context(), nodeID, "active"); err != nil {
banner = taskBanner("reopen", err)
}
case "edit":
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
banner = "Task title cannot be empty."
break
}
if err := tw.EditTaskTitle(r.Context(), nodeID, title); err != nil {
banner = taskBanner("edit", err)
break
}
// The edit form also carries the due field; apply it in the same
// submit so a single Save persists both.
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
if t, ok := parseDueInput(dueStr); ok {
if err := tw.SetTaskDue(r.Context(), nodeID, &t); err != nil {
banner = taskBanner("edit", err)
}
}
} else if _, present := r.Form["due"]; present {
// Field submitted but blank → user cleared it.
if err := tw.SetTaskDue(r.Context(), nodeID, nil); err != nil {
banner = taskBanner("edit", err)
}
}
case "due":
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
if t, ok := parseDueInput(dueStr); ok {
if err := tw.SetTaskDue(r.Context(), nodeID, &t); err != nil {
banner = taskBanner("set due on", err)
}
}
} else {
if err := tw.SetTaskDue(r.Context(), nodeID, nil); err != nil {
banner = taskBanner("clear due on", err)
}
}
case "delete":
if err := tw.DeleteTask(r.Context(), nodeID); err != nil {
banner = taskBanner("delete", err)
}
}
default:
http.Error(w, "unknown task action: "+action, http.StatusBadRequest)
return
}
// A task write can move it on/off the dashboard + timeline rollups, so
// bust those caches like the CalDAV path does.
if s.dashboard != nil {
s.dashboard.InvalidateAll()
}
if s.timeline != nil {
s.timeline.InvalidateAll()
}
if r.Header.Get("HX-Request") == "true" {
s.renderUnifiedTasks(w, r, it, banner)
return
}
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
}
// renderHint maps the detail form's "render as checklist" checkbox to the
// metadata.projax.render value: "checklist" when on, "" when off.
func renderHint(checklist bool) string {
if checklist {
return "checklist"
}
return ""
}
// taskBelongsTo verifies nodeID is one of itemID's mBrian-native tasks — the
// authorisation guard for every existing-task mutation.
func taskBelongsTo(ctx context.Context, tr store.TaskReader, itemID, nodeID string) bool {
tasks, err := tr.TasksForItem(ctx, itemID)
if err != nil {
return false
}
for _, t := range tasks {
if t.NodeID == nodeID {
return true
}
}
return false
}
// taskBanner formats a user-facing banner for a task write error.
func taskBanner(action string, err error) string {
return "Could not " + action + " task: " + err.Error()
}
// taskFromTodo maps a CalDAV VTODO to the uniform store.Task shape (design
// §3.3 — one shape, two sources). Used by the unified list so CalDAV + mBrian
// tasks render through a single row template.
func taskFromTodo(td caldav.Todo, calURL, parentItemID string) *store.Task {
done := td.Status == "COMPLETED" || td.Status == "CANCELLED"
t := &store.Task{
ID: td.UID,
Title: td.Summary,
Done: done,
Due: td.Due,
Source: store.TaskSourceCalDAV,
Status: td.Status,
ParentItemID: parentItemID,
CalendarURL: calURL,
UID: td.UID,
}
if td.LastModified != nil {
t.CreatedAt = *td.LastModified
}
return t
}

120
web/task_test.go Normal file
View File

@@ -0,0 +1,120 @@
package web
import (
"testing"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/store"
)
func TestTaskFromTodo(t *testing.T) {
due := time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)
mod := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
td := caldav.Todo{
UID: "vtodo-123",
Summary: "Pour foundation",
Status: "NEEDS-ACTION",
Due: &due,
LastModified: &mod,
}
got := taskFromTodo(td, "https://dav/cal/", "item-uuid")
if got.Source != store.TaskSourceCalDAV {
t.Fatalf("source = %q, want caldav", got.Source)
}
if got.ID != "vtodo-123" || got.UID != "vtodo-123" {
t.Fatalf("id/uid = %q/%q", got.ID, got.UID)
}
if got.Title != "Pour foundation" {
t.Fatalf("title = %q", got.Title)
}
if got.Done {
t.Fatal("NEEDS-ACTION should not be done")
}
if got.CalendarURL != "https://dav/cal/" {
t.Fatalf("calURL = %q", got.CalendarURL)
}
if got.ParentItemID != "item-uuid" {
t.Fatalf("parent = %q", got.ParentItemID)
}
if got.Due == nil || !got.Due.Equal(due) {
t.Fatalf("due = %v, want %v", got.Due, due)
}
if !got.CreatedAt.Equal(mod) {
t.Fatalf("createdAt = %v, want last-modified %v", got.CreatedAt, mod)
}
}
func TestTaskFromTodoDoneStates(t *testing.T) {
for _, st := range []string{"COMPLETED", "CANCELLED"} {
got := taskFromTodo(caldav.Todo{UID: "x", Status: st}, "c", "i")
if !got.Done {
t.Fatalf("status %q should map to done", st)
}
}
for _, st := range []string{"NEEDS-ACTION", "IN-PROCESS", ""} {
got := taskFromTodo(caldav.Todo{UID: "x", Status: st}, "c", "i")
if got.Done {
t.Fatalf("status %q should NOT map to done", st)
}
}
}
func TestRenderHint(t *testing.T) {
if renderHint(true) != "checklist" {
t.Fatal("true should map to checklist")
}
if renderHint(false) != "" {
t.Fatal("false should map to empty")
}
}
func TestSortTaskRows(t *testing.T) {
d := func(s string) *time.Time {
tm, _ := time.Parse("2006-01-02", s)
return &tm
}
mk := func(title string, due *time.Time, created string) taskRow {
c, _ := time.Parse("2006-01-02", created)
return taskRow{Task: &store.Task{Title: title, Due: due, CreatedAt: c}}
}
rows := []taskRow{
mk("undated-late", nil, "2026-06-03"),
mk("due-later", d("2026-06-20"), "2026-06-01"),
mk("undated-early", nil, "2026-06-02"),
mk("due-soon", d("2026-06-10"), "2026-06-05"),
}
sortTaskRows(rows)
got := []string{rows[0].Title, rows[1].Title, rows[2].Title, rows[3].Title}
want := []string{"due-soon", "due-later", "undated-early", "undated-late"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sort order = %v, want %v", got, want)
}
}
}
func TestAddTarget(t *testing.T) {
cal := func(url string) caldav.Calendar { return caldav.Calendar{URL: url, DisplayName: url} }
// CalDAV-bound, single calendar → caldav + that URL.
u := unifiedTasks{CalDAVBound: true, LinkedCalendars: []caldav.Calendar{cal("https://c1/")}}
if at := u.AddTarget(); at.Mode != "caldav" || at.CalendarURL != "https://c1/" {
t.Fatalf("single-cal bound = %+v, want caldav+https://c1/", at)
}
// CalDAV-bound, multiple calendars → caldav + empty URL (form shows select).
u = unifiedTasks{CalDAVBound: true, LinkedCalendars: []caldav.Calendar{cal("https://c1/"), cal("https://c2/")}}
if at := u.AddTarget(); at.Mode != "caldav" || at.CalendarURL != "" {
t.Fatalf("multi-cal bound = %+v, want caldav+empty", at)
}
// Unbound + mBrian backend → mbrian.
u = unifiedTasks{CalDAVBound: false, MBrianOn: true}
if at := u.AddTarget(); at.Mode != "mbrian" {
t.Fatalf("unbound+mbrian = %+v, want mbrian", at)
}
// Unbound + no mBrian backend → no add affordance.
u = unifiedTasks{CalDAVBound: false, MBrianOn: false}
if at := u.AddTarget(); at.Mode != "" {
t.Fatalf("unbound+no-backend = %+v, want empty mode", at)
}
}

View File

@@ -3,9 +3,9 @@
<header class="calendar-header">
<h1>{{.P.MonthLabel}}</h1>
<nav class="calendar-nav" aria-label="Monatsnavigation">
<a class="prev" href="/calendar?month={{.P.PrevMonth}}{{with .Filter.QueryString}}&amp;{{.}}{{end}}">&lt; {{.P.PrevMonth}}</a>
<a class="today" href="/calendar{{with .Filter.QueryString}}?{{.}}{{end}}">heute</a>
<a class="next" href="/calendar?month={{.P.NextMonth}}{{with .Filter.QueryString}}&amp;{{.}}{{end}}">{{.P.NextMonth}} &gt;</a>
<a class="prev" href="/views/calendar?month={{.P.PrevMonth}}{{with .Filter.QueryString}}&amp;{{.}}{{end}}">&lt; {{.P.PrevMonth}}</a>
<a class="today" href="/views/calendar{{with .Filter.QueryString}}?{{.}}{{end}}">heute</a>
<a class="next" href="/views/calendar?month={{.P.NextMonth}}{{with .Filter.QueryString}}&amp;{{.}}{{end}}">{{.P.NextMonth}} &gt;</a>
</nav>
</header>
{{template "calendar-section" .}}

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="calendar-filterbar">
<form id="calendar-filter" class="search"
hx-get="/calendar"
hx-get="/views/calendar"
hx-target="#calendar-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
@@ -39,7 +39,7 @@
</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}}
{{if .Filter.Active}}<a class="clear" href="/views/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
@@ -83,7 +83,7 @@
</ul>
{{end}}
{{if gt .ExtraCount 0}}
<a class="cell-more muted" href="/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">+{{.ExtraCount}} more</a>
<a class="cell-more muted" href="/views/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">+{{.ExtraCount}} more</a>
{{end}}
</td>
{{end}}

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="dashboard-filterbar">
<form id="dashboard-filter" class="search"
hx-get="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
hx-get="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
@@ -32,7 +32,7 @@
{{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}}
{{if .Filter.Active}}<a class="clear" href="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}

View File

@@ -72,6 +72,9 @@
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
</label>
<label class="checkbox">
<input type="checkbox" name="render_checklist" value="1" {{if .Item.RendersChecklist}}checked{{end}}> render tasks as checklist
</label>
</section>
<section class="form-group" aria-labelledby="hdr-content">
@@ -122,7 +125,7 @@
<summary class="proj-section-summary">Timeline behaviour {{if .Item.TimelineExclude}}<small class="muted">(hiding {{len .Item.TimelineExclude}})</small>{{end}}</summary>
<fieldset class="timeline-exclude">
<legend class="visually-hidden">Timeline behaviour</legend>
<p class="muted">Check a kind to hide it from <a href="/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
<p class="muted">Check a kind to hide it from <a href="/views/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/views/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
{{$ex := .Item.TimelineExclude}}
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="todos" {{if contains $ex "todos"}}checked{{end}}> exclude todos (VTODOs from linked calendars)</label>
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="events" {{if contains $ex "events"}}checked{{end}}> exclude events (VEVENTs from linked calendars)</label>
@@ -133,7 +136,7 @@
<div class="actions">
<button type="submit">Save</button>
<a class="cancel" href="/">Cancel</a>
<a class="cancel" href="/views/tree">Cancel</a>
</div>
</form>
@@ -142,14 +145,13 @@
<section class="aux-sections" aria-labelledby="hdr-aux">
<h2 id="hdr-aux" class="aux-heading">Related</h2>
{{if .CalDAVOn}}
{{/* Tasks section opens by default when any linked calendar has at least
one open VTODO. */}}
{{$tasksOpen := false}}
{{range .Tasks}}{{if .Open}}{{$tasksOpen = true}}{{end}}{{end}}
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if $tasksOpen}} open{{end}}>
<summary class="proj-section-summary">Tasks {{if $tasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
{{template "tasks-section" .}}
{{/* Phase 7c: ONE unified task list per project — mBrian-native tasks +
CalDAV VTODOs merged and displayed together (m's request). Opens by
default when there's at least one open task from either source. */}}
{{if .ShowTasks}}
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if .TasksOpen}} open{{end}}>
<summary class="proj-section-summary">Tasks {{if .TasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
{{template "tasks-section" .TasksData}}
</details>
{{end}}

View File

@@ -1,5 +1,5 @@
{{define "content"}}
<h1>Error</h1>
<p class="error">{{.Message}}</p>
<p><a href="/">Back to tree</a></p>
<p><a href="/views/tree">Back to tree</a></p>
{{end}}

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="graph-filterbar">
<form id="graph-filter" class="search"
hx-get="/graph"
hx-get="/views/graph"
hx-target="main"
hx-select="main"
hx-swap="outerHTML"
@@ -29,8 +29,8 @@
<input type="checkbox" name="isolate" value="1" {{if .Isolate}}checked{{end}}>
isolate (hide non-matches)
</label>
{{if .Filter.Active}}<a class="clear" href="/graph">clear filters</a>{{end}}
<a class="download" href="/graph?download=svg">download SVG</a>
{{if .Filter.Active}}<a class="clear" href="/views/graph">clear filters</a>{{end}}
<a class="download" href="/views/graph?download=svg">download SVG</a>
</form>
</section>

View File

@@ -13,6 +13,12 @@
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png">
<link rel="stylesheet" href="/static/style.css">
<!-- HTMX powers the in-place fragment swaps on the task / tree / dashboard /
bulk / classify forms (hx-post/hx-get/hx-target/hx-swap). Vendored (not
CDN) — projax is Tailscale-only and ships its assets via go:embed. Loaded
deferred so it executes after parse but before DOMContentLoaded, where
htmx wires every hx-* element. Plain method=post forms are untouched. -->
<script src="/static/htmx.min.js" defer></script>
<script>
// Phase 5g — restore sidebar collapsed state BEFORE first paint so the
// main-content margin doesn't flash from 220px→56px on every navigation.
@@ -42,38 +48,38 @@
{{$path := .Path}}
<aside class="projax-sidebar" aria-label="Primary navigation">
<div class="sidebar-top">
<a href="/" class="brand" title="projax">
<a href="/views/tree" class="brand" title="projax">
<span class="brand-icon" aria-hidden="true">▦</span>
<strong class="brand-label">projax</strong>
</a>
</div>
<nav class="sidebar-nav">
<a href="/" class="nav-item{{if eq $path "/"}} active{{end}}" title="Tree">
<a href="/views/tree" class="nav-item{{if eq $path "/views/tree"}} active{{end}}" title="Tree">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
<span class="nav-label">Tree</span>
</a>
<a href="/dashboard" class="nav-item{{if eq $path "/dashboard"}} active{{end}}" title="Dashboard">
<a href="/views/dashboard" class="nav-item{{if eq $path "/views/dashboard"}} active{{end}}" title="Dashboard">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
</svg>
<span class="nav-label">Dashboard</span>
</a>
<a href="/calendar" class="nav-item{{if eq $path "/calendar"}} active{{end}}" title="Calendar">
<a href="/views/calendar" class="nav-item{{if eq $path "/views/calendar"}} active{{end}}" title="Calendar">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span class="nav-label">Calendar</span>
</a>
<a href="/timeline" class="nav-item{{if eq $path "/timeline"}} active{{end}}" title="Timeline">
<a href="/views/timeline" class="nav-item{{if eq $path "/views/timeline"}} active{{end}}" title="Timeline">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span class="nav-label">Timeline</span>
</a>
<a href="/graph" class="nav-item{{if eq $path "/graph"}} active{{end}}" title="Graph">
<a href="/views/graph" class="nav-item{{if eq $path "/views/graph"}} active{{end}}" title="Graph">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
@@ -89,6 +95,25 @@
</svg>
<span class="nav-label">Views</span>
</a>
{{if .UserViews}}
{{$counts := .UserViewCounts}}
<div class="sidebar-user-views" aria-label="Saved views">
{{range .UserViews}}
{{$slug := .Slug}}
<a href="/views/{{.Slug}}"
class="nav-item nav-item-user-view{{if eq $path (printf "/views/%s" .Slug)}} active{{end}}"
title="{{.Name}}">
{{renderIcon .Icon}}
<span class="nav-label">{{.Name}}</span>
{{if .ShowCount}}<span class="nav-badge" aria-label="Item count">{{index $counts $slug}}</span>{{end}}
</a>
{{end}}
<a href="/views/new" class="nav-item nav-item-user-view nav-item-new-view" title="New view">
<span class="nav-icon" aria-hidden="true"></span>
<span class="nav-label">New view</span>
</a>
</div>
{{end}}
<a href="/admin" class="nav-item{{if eq $path "/admin"}} active{{end}}" title="Admin">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
@@ -124,14 +149,14 @@
{{template "content" .}}
</main>
<nav class="projax-bottom-nav" aria-label="Mobile navigation">
<a href="/" class="bottom-nav-item{{if eq $path "/"}} active{{end}}" aria-label="Tree">
<a href="/views/tree" class="bottom-nav-item{{if eq $path "/views/tree"}} active{{end}}" aria-label="Tree">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
<span>Tree</span>
</a>
<a href="/dashboard" class="bottom-nav-item{{if eq $path "/dashboard"}} active{{end}}" aria-label="Dashboard">
<a href="/views/dashboard" class="bottom-nav-item{{if eq $path "/views/dashboard"}} active{{end}}" aria-label="Dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
</svg>
@@ -144,7 +169,7 @@
</svg>
</span>
</a>
<a href="/calendar" class="bottom-nav-item{{if eq $path "/calendar"}} active{{end}}" aria-label="Calendar">
<a href="/views/calendar" class="bottom-nav-item{{if eq $path "/views/calendar"}} active{{end}}" aria-label="Calendar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
@@ -158,13 +183,13 @@
<span>Menu</span>
</summary>
<div class="drawer-sheet" role="menu">
<a href="/timeline" class="drawer-item{{if eq $path "/timeline"}} active{{end}}" role="menuitem">
<a href="/views/timeline" class="drawer-item{{if eq $path "/views/timeline"}} active{{end}}" role="menuitem">
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span>Timeline</span>
</a>
<a href="/graph" class="drawer-item{{if eq $path "/graph"}} active{{end}}" role="menuitem">
<a href="/views/graph" class="drawer-item{{if eq $path "/views/graph"}} active{{end}}" role="menuitem">
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>

View File

@@ -1,106 +1,128 @@
{{define "tasks-section"}}
<section class="tasks" id="tasks-section">
<section class="tasks unified{{if .Checklist}} checklist{{end}}" id="tasks-section">
<h2>Tasks</h2>
{{if .Banner}}<p class="banner warn" role="alert">{{.Banner}}</p>{{end}}
{{if .Tasks}}
{{$root := .}}
{{range .Tasks}}
{{$calURL := .CalendarURL}}
<div class="cal-block" data-cal="{{$calURL}}">
<h3>{{.DisplayName}}</h3>
{{if .Error}}<p class="banner warn">{{.Error}}</p>{{end}}
<form class="todo-create"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/todo-create"
hx-target="#tasks-section"
hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="text" name="summary" placeholder="Add a task…" required>
<input type="date" name="due" title="due date (optional)">
<button type="submit">Add</button>
</form>
{{$root := .}}
{{if .Open}}
<ul class="todo open">
{{range .Open}}
<li class="todo-row" data-uid="{{.UID}}">
<form class="todo-complete inline"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/complete"
hx-target="#tasks-section"
hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="check" title="Mark complete" aria-label="Mark complete">☐</button>
</form>
<form class="todo-edit inline"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/edit"
hx-target="#tasks-section"
hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<input type="text" name="summary" value="{{.Summary}}" required>
<input type="date" name="due" value="{{if .Due}}{{.Due.Format "2006-01-02"}}{{end}}">
<button type="submit" title="Save edits">Save</button>
</form>
<form class="todo-delete inline"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete"
hx-target="#tasks-section"
hx-swap="outerHTML"
hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
</li>
{{end}}
</ul>
{{/* Single add-form. Backend chosen by the §3.1 selector: a CalDAV-bound
project creates VTODOs on its linked calendar; an unbound project
creates mBrian-native task nodes. */}}
{{with .AddTarget}}
{{if eq .Mode "caldav"}}
<form class="todo-create"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/todo-create"
hx-target="#tasks-section"
hx-swap="outerHTML">
{{if .CalendarURL}}
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
{{else}}
<p class="muted">No open tasks.</p>
<select name="calendar_url" required title="calendar">
{{range $root.LinkedCalendars}}<option value="{{.URL}}">{{.DisplayName}}</option>{{end}}
</select>
{{end}}
{{if .DoneRecent}}
<details>
<summary class="muted">{{len .DoneRecent}} completed in last 30 days</summary>
<ul class="todo done">
{{range .DoneRecent}}
<li class="todo-row" data-uid="{{.UID}}">
<form class="todo-reopen inline"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/reopen"
hx-target="#tasks-section"
hx-swap="outerHTML"
title="Reopen">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="check" aria-label="Reopen">☑</button>
</form>
<span class="summary">{{.Summary}}</span>
<form class="todo-delete inline"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete"
hx-target="#tasks-section"
hx-swap="outerHTML"
hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
</li>
{{end}}
</ul>
</details>
{{end}}
</div>
<input type="text" name="summary" placeholder="Add a task…" required>
<input type="date" name="due" title="due date (optional)">
<button type="submit">Add</button>
</form>
{{else if eq .Mode "mbrian"}}
<form class="todo-create"
hx-post="/i/{{$root.Item.PrimaryPath}}/task/create"
hx-target="#tasks-section"
hx-swap="outerHTML">
<input type="text" name="title" placeholder="Add a task…" required>
<input type="date" name="due" title="due date (optional)">
<button type="submit">Add</button>
</form>
{{end}}
{{else}}
<p class="muted">No CalDAV list linked.</p>
{{end}}
{{/* Phase 5j: per-item picker for sharing an existing list across
multiple projax items (e.g. one "Vacations 2026" list under
several admin.vacations sub-items). Renders in BOTH states:
unlinked items see it next to Create-new; already-linked items
see it as "+ link another" for the multi-list flow. */}}
{{if .Open}}
<ul class="todo open">
{{range .Open}}
<li class="todo-row" data-src="{{.Source}}">
{{/* complete */}}
{{if eq .Source "caldav"}}
<form class="todo-complete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/complete" hx-target="#tasks-section" hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="check" title="Mark complete" aria-label="Mark complete">☐</button>
</form>
<form class="todo-edit inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/edit" hx-target="#tasks-section" hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<input type="text" name="summary" value="{{.Title}}" required>
<input type="date" name="due" value="{{if .Due}}{{.Due.Format "2006-01-02"}}{{end}}">
<button type="submit" title="Save edits">Save</button>
</form>
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
{{else}}
<form class="todo-complete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/done" hx-target="#tasks-section" hx-swap="outerHTML">
<input type="hidden" name="node_id" value="{{.NodeID}}">
<button type="submit" class="check" title="Mark complete" aria-label="Mark complete">☐</button>
</form>
<form class="todo-edit inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/edit" hx-target="#tasks-section" hx-swap="outerHTML">
<input type="hidden" name="node_id" value="{{.NodeID}}">
<input type="text" name="title" value="{{.Title}}" required>
<input type="date" name="due" value="{{if .Due}}{{.Due.Format "2006-01-02"}}{{end}}">
<button type="submit" title="Save edits">Save</button>
</form>
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="node_id" value="{{.NodeID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
{{end}}
<span class="task-src" title="{{if eq .Source "caldav"}}CalDAV — {{end}}{{.SourceLabel}}">{{.SourceLabel}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No open tasks.</p>
{{end}}
{{if .Done}}
<details>
<summary class="muted">{{len .Done}} done</summary>
<ul class="todo done">
{{range .Done}}
<li class="todo-row" data-src="{{.Source}}">
{{if eq .Source "caldav"}}
<form class="todo-reopen inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/reopen" hx-target="#tasks-section" hx-swap="outerHTML" title="Reopen">
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="check" aria-label="Reopen">☑</button>
</form>
<span class="summary">{{.Title}}</span>
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
{{else}}
<form class="todo-reopen inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/reopen" hx-target="#tasks-section" hx-swap="outerHTML" title="Reopen">
<input type="hidden" name="node_id" value="{{.NodeID}}">
<button type="submit" class="check" aria-label="Reopen">☑</button>
</form>
<span class="summary">{{.Title}}</span>
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="node_id" value="{{.NodeID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
{{end}}
<span class="task-src" title="{{if eq .Source "caldav"}}CalDAV — {{end}}{{.SourceLabel}}">{{.SourceLabel}}</span>
</li>
{{end}}
</ul>
</details>
{{end}}
{{/* Project-level CalDAV management: link an existing list, or create a new
one (only when none is bound yet). Unchanged from the pre-7c flow. */}}
{{if .CalDAVOn}}
<div class="caldav-actions">
{{if .AvailableCalendars}}
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/link-existing" class="caldav-link-existing inline">
@@ -112,11 +134,12 @@
<button type="submit">Link</button>
</form>
{{end}}
{{if not .Tasks}}
{{if not .CalDAVBound}}
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
<button type="submit">+ Create new list</button>
<button type="submit">+ Create new CalDAV list</button>
</form>
{{end}}
</div>
{{end}}
</section>
{{end}}

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="timeline-filterbar">
<form id="timeline-filter" class="search"
hx-get="/timeline"
hx-get="/views/timeline"
hx-target="#timeline-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
@@ -45,7 +45,7 @@
</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}}
{{if .Filter.Active}}<a class="clear" href="/views/timeline">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
@@ -62,7 +62,7 @@
<li class="spine-day{{if .Sticky}} sticky-{{.Sticky}}{{end}}" data-date="{{.DateKey}}">
<header class="day-header">
{{if .Sticky}}<span class="sticky-pill">{{.Sticky}}</span>{{end}}
<h2><a class="muted" href="/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">{{.Label}}</a> <small class="muted">({{len .Rows}})</small></h2>
<h2><a class="muted" href="/views/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">{{.Label}}</a> <small class="muted">({{len .Rows}})</small></h2>
</header>
<ul class="day-rows">
{{range .Rows}}

View File

@@ -22,7 +22,7 @@ the visual difference is layout, not data shape.
</article>
{{else}}
<div class="tree-card-empty">
<em>No items match. Try fewer filters or <a href="/">clear all</a>.</em>
<em>No items match. Try fewer filters or <a href="/views/tree">clear all</a>.</em>
</div>
{{end}}
</div>

View File

@@ -40,7 +40,7 @@ set surfaces a friendly empty-state message.
</div>
{{else}}
<div class="kanban-empty muted">
<em>No items match. Try fewer filters or <a href="/?view_type=kanban">clear filters</a>.</em>
<em>No items match. Try fewer filters or <a href="/views/tree?view_type=kanban">clear filters</a>.</em>
</div>
{{end}}
{{end}}

View File

@@ -1,16 +1,9 @@
{{define "tree-section"}}
<section id="tree-section" class="tree-section">
{{if .DefaultBanner}}
<p class="default-banner muted">
Showing default view: <strong>{{.DefaultBanner.Name}}</strong> ·
<a href="/?nodefault=1"
hx-get="/?nodefault=1" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">clear</a>
</p>
{{end}}
<p class="counts">
<strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
{{if .Filter.Active}} · <a class="clear" href="/">clear filters</a>{{end}}
{{if .Filter.Active}} · <a class="clear" href="/views/tree">clear filters</a>{{end}}
</p>
<section class="tagbar" id="tree-filterbar">
@@ -100,7 +93,7 @@
{{template "children" .}}
</li>
{{else}}
<li class="empty"><em>No items match. Try fewer filters or <a href="/">clear all</a>.</em></li>
<li class="empty"><em>No items match. Try fewer filters or <a href="/views/tree">clear all</a>.</em></li>
{{end}}
</ul>
</section>

View File

@@ -1,42 +0,0 @@
{{define "content"}}
<h1>Edit view</h1>
<p class="muted"><a href="/views">← back to views</a></p>
<section class="views-create">
<form method="post" action="/views/{{.View.ID}}">
<label>Name <input type="text" name="name" required maxlength="80" value="{{.View.Name}}"></label>
<label>Description <input type="text" name="description" maxlength="200" value="{{.View.Description}}"></label>
<label>View type
<select name="view_type" required>
{{$cur := .View.ViewType}}
{{range .AllViewTypes}}<option value="{{.}}"{{if eq . $cur}} selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
<label>Default for
<select name="is_default_for">
{{$d := deref .View.IsDefaultFor}}
{{range .DefaultForOptions}}<option value="{{.}}"{{if eq . $d}} selected{{end}}>{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label>Group by
<select name="group_by">
{{$g := deref .View.GroupBy}}
{{range .GroupByOptions}}<option value="{{.}}"{{if eq . $g}} selected{{end}}>{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label>Sort field <input type="text" name="sort_field" placeholder="title / updated_at / start_time" maxlength="40" value="{{deref .View.SortField}}"></label>
<label>Sort dir
<select name="sort_dir">
{{$sd := deref .View.SortDir}}
{{range .SortDirOptions}}<option value="{{.}}"{{if eq . $sd}} selected{{end}}>{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label><input type="checkbox" name="pinned" value="1"{{if .View.Pinned}} checked{{end}}> Pinned</label>
<label>Filter (URL query form)
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.FilterQuery}}">
</label>
<button type="submit">Save changes</button>
<a class="muted" href="/views">cancel</a>
</form>
</section>
{{end}}

View File

@@ -0,0 +1,49 @@
{{define "content"}}
<h1>{{if .View}}Edit {{.View.Name}}{{else}}New view{{end}}</h1>
<p class="muted"><a href="/views">← back to views</a></p>
<form class="view-editor"
method="post"
action="{{if .View}}/views/{{.View.Slug}}{{else}}/views{{end}}">
<label>Name <input type="text" name="name" required maxlength="80" value="{{if .View}}{{.View.Name}}{{end}}"></label>
<label>Slug
<input type="text" name="slug" required maxlength="63"
pattern="^[a-z0-9][a-z0-9-]{0,62}$"
value="{{if .View}}{{.View.Slug}}{{end}}">
<small class="muted">lowercase letters, digits, dashes. No reserved system slugs.</small>
</label>
<label>Icon
<select name="icon">
{{$cur := ""}}
{{if and .View .View.Icon}}{{$cur = deref .View.Icon}}{{end}}
{{range .IconKeys}}
<option value="{{.}}"{{if eq . $cur}} selected{{end}}>{{.}}</option>
{{end}}
</select>
</label>
<fieldset class="view-type-radios">
<legend>View type</legend>
{{range .ViewTypes}}
<label><input type="radio" name="view_type" value="{{.}}" {{if eq . $.CurrentVT}}checked{{end}}> {{.}}</label>
{{end}}
</fieldset>
<label>Group by
<select name="group_by">
{{range .GroupByOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label>Sort field <input type="text" name="sort_field" placeholder="title / updated_at" maxlength="40"></label>
<label>Sort dir
<select name="sort_dir">
{{range .SortDirOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label><input type="checkbox" name="show_count" value="1"
{{if and .View .View.ShowCount}}checked{{end}}> Show row-count badge in sidebar</label>
<label>Filter (URL query form)
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.FilterQuery}}">
</label>
<button type="submit">{{if .View}}Save changes{{else}}Create view{{end}}</button>
<a class="muted" href="/views">cancel</a>
</form>
{{end}}

View File

@@ -0,0 +1,14 @@
{{define "content"}}
<section class="view-header">
<h1>{{.View.Name}}</h1>
<p class="muted view-meta">
<code>/views/{{.View.Slug}}</code> ·
<a href="/views/{{.View.Slug}}/edit">edit</a> ·
<form method="post" action="/views/{{.View.Slug}}/delete" style="display:inline">
<button type="submit" class="link-button" onclick="return confirm('Delete view {{.View.Name}}?')">delete</button>
</form>
</p>
</section>
{{template "tree-section" .}}
{{end}}

View File

@@ -1,70 +0,0 @@
{{define "content"}}
<h1>Views</h1>
<p class="muted">Saved bundles of (filter + view_type + sort + group_by). Page-agnostic — open one to render the saved set on the matching page.</p>
<section class="views-list">
{{if .Views}}
<table>
<thead>
<tr>
<th>★</th><th>Name</th><th>Type</th><th>Default for</th><th>Group by</th><th></th>
</tr>
</thead>
<tbody>
{{range .Views}}
<tr>
<td>{{if .Pinned}}★{{end}}</td>
<td><a href="/views/{{.ID}}">{{.Name}}</a>{{if .Description}}<br><small class="muted">{{.Description}}</small>{{end}}</td>
<td>{{.ViewType}}</td>
<td>{{if .IsDefaultFor}}{{deref .IsDefaultFor}}{{else}}<span class="muted">—</span>{{end}}</td>
<td>{{if .GroupBy}}{{deref .GroupBy}}{{else}}<span class="muted">—</span>{{end}}</td>
<td>
<a href="/views/{{.ID}}/edit">edit</a>
<form method="post" action="/views/{{.ID}}/delete" style="display:inline">
<button type="submit" class="link-button" onclick="return confirm('Delete view {{.Name}}?')">delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty muted"><em>No saved views yet. Create one with the form below or via the "Save view…" link on any Views-supporting page.</em></p>
{{end}}
</section>
<section class="views-create">
<h2>New view</h2>
<form method="post" action="/views">
<label>Name <input type="text" name="name" required maxlength="80"></label>
<label>Description <input type="text" name="description" maxlength="200"></label>
<label>View type
<select name="view_type" required>
{{range .AllViewTypes}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
</label>
<label>Default for
<select name="is_default_for">
{{range .DefaultForOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label>Group by
<select name="group_by">
{{range .GroupByOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label>Sort field <input type="text" name="sort_field" placeholder="title / updated_at / start_time" maxlength="40"></label>
<label>Sort dir
<select name="sort_dir">
{{range .SortDirOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label><input type="checkbox" name="pinned" value="1"> Pinned</label>
<label>Filter (URL query form, e.g. <code>tag=work&amp;mgmt=mai</code>)
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.Prefill.filter}}">
</label>
<button type="submit">Create view</button>
</form>
</section>
{{end}}

View File

@@ -0,0 +1,26 @@
{{define "content"}}
<h1>Views</h1>
<p class="muted">First-class saved pages. Each view has its own URL and renders on its own.</p>
{{if .Views}}
<section class="views-list">
<ul class="views-list-grid">
{{range .Views}}
<li>
<a class="view-card" href="/views/{{.Slug}}">
<span class="view-card-name">{{.Name}}</span>
<span class="view-card-slug muted">/views/{{.Slug}}</span>
</a>
</li>
{{end}}
</ul>
</section>
{{else}}
<section class="views-empty">
<p class="muted"><em>No saved views yet.</em></p>
</section>
{{end}}
<p><a class="view-create-link" href="/views/new">+ New view</a></p>
{{end}}

View File

@@ -14,9 +14,9 @@ func TestThemeDefaultIsDark(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/")
code, body := get(t, h, "/views/tree")
if code != 200 {
t.Fatalf("GET / → %d", code)
t.Fatalf("GET /views/tree → %d", code)
}
for _, want := range []string{
`<html lang="en" data-theme="dark">`,
@@ -42,7 +42,7 @@ func TestThemeCookieRoundTrips(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequest(http.MethodGet, "/views/tree", nil)
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "light"})
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
@@ -66,7 +66,7 @@ func TestThemeCookieUnknownFallsBackToDark(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequest(http.MethodGet, "/views/tree", nil)
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "neon-puke"})
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
@@ -94,7 +94,7 @@ func TestThemeTogglePagesShareSameTheme(t *testing.T) {
body, _ := io.ReadAll(w.Result().Body)
return string(body)
}
for _, path := range []string{"/", "/dashboard", "/timeline", "/graph", "/admin", "/admin/bulk", "/admin/classify"} {
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/timeline", "/views/graph", "/admin", "/admin/bulk", "/admin/classify"} {
dark := probe(path, "")
light := probe(path, "light")
if !strings.Contains(dark, `data-theme="dark"`) {
@@ -112,7 +112,7 @@ func TestThemeToggleScriptPresent(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
for _, want := range []string{
"document.cookie = 'projax_theme=",
`getElementById('theme-toggle')`,
@@ -132,7 +132,7 @@ func TestThemeColorMetaHelper(t *testing.T) {
defer pool.Close()
// Indirect: render a fragment with a Theme override to confirm injection
// does not double-write the meta when caller already populates it.
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
req := httptest.NewRequest(http.MethodGet, "/views/dashboard", nil)
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "light"})
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)

View File

@@ -199,7 +199,7 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
"Query": q,
"Now": now,
"Projects": projects,
"BasePath": "/timeline",
"BasePath": "/views/timeline",
"ProjectChipTarget": "#timeline-section",
}
if r.Header.Get("HX-Request") == "true" {
@@ -212,7 +212,7 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
// buildTimeline gathers every dated source, applies the kind/filter narrowing,
// and groups rows by day in the requested order.
func (s *Server) buildTimeline(ctx context.Context, q TimelineQuery, now time.Time) (*TimelinePayload, error) {
items, err := s.Store.ListAll(ctx)
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}

View File

@@ -108,13 +108,13 @@ END:VCALENDAR`
}
h := srv.Routes()
_, body := get(t, h, "/timeline")
_, body := get(t, h, "/views/timeline")
if strings.Contains(body, "Shopping list item") {
t.Errorf("/timeline should NOT include excluded todo summary; body contained it")
}
// Override: ?include_excluded=1 brings it back.
_, peekBody := get(t, h, "/timeline?include_excluded=1")
_, peekBody := get(t, h, "/views/timeline?include_excluded=1")
if !strings.Contains(peekBody, "Shopping list item") {
t.Errorf("?include_excluded=1 should surface the excluded todo; body lacked it")
}

View File

@@ -20,7 +20,7 @@ func TestTimelineRendersEmpty(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/timeline")
code, body := get(t, h, "/views/timeline")
if code != 200 {
t.Fatalf("GET /timeline → %d body=%s", code, body)
}
@@ -67,7 +67,7 @@ func TestTimelineSurfacesDatedDocs(t *testing.T) {
t.Fatalf("seed link: %v", err)
}
code, body := get(t, h, "/timeline")
code, body := get(t, h, "/views/timeline")
if code != 200 {
t.Fatalf("GET /timeline → %d", code)
}
@@ -115,7 +115,7 @@ func TestTimelineFilterByKindNarrowsRows(t *testing.T) {
}
// Unfiltered: both the creation marker and the dated doc should be present.
_, allBody := get(t, h, "/timeline")
_, allBody := get(t, h, "/views/timeline")
if !strings.Contains(allBody, "added <a class=\"proj\" href=\"/i/dev."+slug) {
t.Errorf("expected creation marker in unfiltered timeline body")
}
@@ -124,7 +124,7 @@ func TestTimelineFilterByKindNarrowsRows(t *testing.T) {
}
// kind=doc only: the doc row stays; the creation marker drops.
_, docOnly := get(t, h, "/timeline?kind=doc")
_, docOnly := get(t, h, "/views/timeline?kind=doc")
if strings.Contains(docOnly, "added <a class=\"proj\" href=\"/i/dev."+slug) {
t.Errorf("kind=doc should hide creation marker")
}
@@ -171,7 +171,7 @@ func TestTimelineOrderToggleReversesDays(t *testing.T) {
older := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, -3).Format("060102")
newer := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, 5).Format("060102")
_, desc := get(t, h, "/timeline")
_, desc := get(t, h, "/views/timeline")
idxNewerDesc := strings.Index(desc, newer)
idxOlderDesc := strings.Index(desc, older)
if idxNewerDesc < 0 || idxOlderDesc < 0 {
@@ -181,7 +181,7 @@ func TestTimelineOrderToggleReversesDays(t *testing.T) {
t.Errorf("default order should be desc (newest first); newer at %d, older at %d", idxNewerDesc, idxOlderDesc)
}
_, asc := get(t, h, "/timeline?order=asc")
_, asc := get(t, h, "/views/timeline?order=asc")
idxNewerAsc := strings.Index(asc, newer)
idxOlderAsc := strings.Index(asc, older)
if !(idxOlderAsc < idxNewerAsc) {
@@ -277,7 +277,7 @@ END:VCALENDAR`
}
h := srv.Routes()
code, body := get(t, h, "/timeline")
code, body := get(t, h, "/views/timeline")
if code != 200 {
t.Fatalf("GET /timeline → %d", code)
}
@@ -337,7 +337,7 @@ func TestTimelineFilterByTagAppliesAcrossKinds(t *testing.T) {
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, devID, homeID)
tag := "tl-tag-work-" + stamp
_, body := get(t, h, "/timeline?tag="+tag)
_, body := get(t, h, "/views/timeline?tag="+tag)
// 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.

View File

@@ -26,12 +26,6 @@ type TreeFilter struct {
// exposes an explicit on/off toggle.
ProjectPath string
IncludeDescendants bool
// Phase 5i fix-shift — saved-view anchor. When set, the URL was
// `?view=<uuid>`; chip clicks need to round-trip the value so the user
// stays inside the saved view while narrowing further. Not a "filter"
// dimension in the matching sense — Matches ignores it — but it lives
// in the URL state and on the struct so QueryString preserves it.
ViewID string
}
// Active reports whether any filter dimension is set to something other than
@@ -70,7 +64,6 @@ func ParseTreeFilter(q url.Values) TreeFilter {
ShowArchived: q.Get("show-archived") == "1",
ProjectPath: strings.TrimSpace(q.Get("project")),
IncludeDescendants: true,
ViewID: strings.TrimSpace(q.Get("view")),
}
if v := strings.TrimSpace(q.Get("public")); v != "" {
// Treat 1/true/yes/on as true; 0/false/no/off as false; anything else nil.
@@ -132,9 +125,6 @@ func (f TreeFilter) QueryString() string {
v.Set("project_descendants", "0")
}
}
if f.ViewID != "" {
v.Set("view", f.ViewID)
}
return v.Encode()
}
@@ -489,7 +479,10 @@ type ChipCounts struct {
// see what they're filtered down to). For an inactive chip the count is what
// they'd get if they added it. At m's scale (≤100 items × ≤30 chips) this is
// trivially cheap; no caching needed.
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string) ChipCounts {
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string, base string) ChipCounts {
if base == "" {
base = "/"
}
count := func(f TreeFilter) int {
// Branch-keep semantics aren't relevant for chip counts — we want a
// raw "how many items match this filter directly" so the chip number
@@ -507,7 +500,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleTag(tag)
out.Tags = append(out.Tags, ChipCount{
Label: tag,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Tags, tag),
})
@@ -516,7 +509,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleManagement(mode)
out.Management = append(out.Management, ChipCount{
Label: mode,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Management, mode),
})
@@ -525,7 +518,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleStatus(st)
out.Status = append(out.Status, ChipCount{
Label: st,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Status, st),
})
@@ -534,7 +527,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleHas(h)
out.Has = append(out.Has, ChipCount{
Label: h,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.HasLinks, h),
})
@@ -543,7 +536,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleShowArchived()
out.ShowArchived = ChipCount{
Label: "show archived",
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: current.ShowArchived,
}

View File

@@ -177,7 +177,7 @@ func TestComputeChipCountsTagCounts(t *testing.T) {
both := &store.Item{ID: "x", Slug: "x", Title: "X", Tags: []string{"work", "dev"}, Status: "active"}
items := []*store.Item{work, dev, both}
f := TreeFilter{Status: []string{"active"}}
counts := computeChipCounts(items, f, map[string]map[string]struct{}{}, []string{"work", "dev"})
counts := computeChipCounts(items, f, map[string]map[string]struct{}{}, []string{"work", "dev"}, "/views/tree")
if len(counts.Tags) != 2 {
t.Fatalf("expected 2 tag chips, got %d", len(counts.Tags))
}

View File

@@ -63,13 +63,13 @@ func (s ViewTypeSet) Resolve(vt string) string {
// this. The narrow tree/dashboard set is the seed; slices CE grow it.
func PageViewTypes(route string) ViewTypeSet {
switch route {
case "/", "tree":
case "/", "/views/tree", "tree":
return ViewTypeSet{
Default: ViewTypeList,
// Slice B: list + card. Slice C: kanban joins.
Allowed: []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
}
case "/dashboard", "dashboard":
case "/dashboard", "/views/dashboard", "dashboard":
// Dashboard is locked to its Phase 5h tabbed-tiles surface in slice B.
// The view_type chip is informational only here; switching templates
// for card vs list on /dashboard is a follow-up slice (the tabbed
@@ -79,12 +79,12 @@ func PageViewTypes(route string) ViewTypeSet {
Default: ViewTypeCard,
Allowed: []string{ViewTypeCard},
}
case "/timeline", "timeline":
case "/timeline", "/views/timeline", "timeline":
return ViewTypeSet{
Default: ViewTypeTimeline,
Allowed: []string{ViewTypeTimeline},
}
case "/calendar", "calendar":
case "/calendar", "/views/calendar", "calendar":
return ViewTypeSet{
Default: ViewTypeCalendar,
Allowed: []string{ViewTypeCalendar},

View File

@@ -18,10 +18,10 @@ func TestParseViewTypeFallsBackOnUnknown(t *testing.T) {
{"/", "list", ViewTypeList}, // explicit default
{"/", "kanban", ViewTypeKanban}, // unlocked in slice C
{"/", "junk", ViewTypeList}, // unknown → default
{"/dashboard", "", ViewTypeCard}, // default for dashboard
{"/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
{"/timeline", "card", ViewTypeTimeline}, // locked
{"/calendar", "kanban", ViewTypeCalendar}, // locked
{"/views/dashboard", "", ViewTypeCard}, // default for dashboard
{"/views/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
{"/views/timeline", "card", ViewTypeTimeline}, // locked
{"/views/calendar", "kanban", ViewTypeCalendar}, // locked
}
for _, tc := range cases {
set := PageViewTypes(tc.route)

View File

@@ -11,39 +11,197 @@ import (
"github.com/m/projax/store"
)
// Phase 5i Slice D — saved views handlers. Page-agnostic: a view bundles a
// filter + view_type + sort/group_by and renders on any page that supports
// that view_type. The sidebar in layout.tmpl lists every saved view; the
// /views index lets m manage them.
// Phase 5j paliad-shape views handlers. Slice B introduces the route
// family; slices CG evolve the render, editor, system-views, sidebar,
// and polish layers.
//
// Route table:
// GET /views → handleViewsLanding (MRU 302 or shell)
// GET /views/{slug} → handleViewRender (saved or system)
// GET /views/new → handleViewEditor (blank)
// GET /views/{slug}/edit → handleViewEditor (existing)
// POST /views → handleViewCreate
// POST /views/{slug} → handleViewUpdate
// POST /views/{slug}/delete → handleViewDelete
// POST /views/reorder → handleViewReorder (slice G — wired now,
// used in v2)
// handleViewsIndex renders the list + create-form page.
func (s *Server) handleViewsIndex(w http.ResponseWriter, r *http.Request) {
// handleViewsLanding implements m's Q5 pick: 302 to the most-recently-used
// view if any, else render the onboarding shell listing every saved view.
func (s *Server) handleViewsLanding(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("nodefault") != "1" {
mr, err := s.Store.MostRecentView(r.Context())
if err != nil {
s.Logger.Warn("views landing: mru", "err", err)
} else if mr != nil {
http.Redirect(w, r, "/views/"+mr.Slug, http.StatusFound)
return
}
}
views, err := s.Store.ListViews(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
// Prefill: a save-from-page link can pass ?prefill_filter=<encoded TreeFilter
// URL query>&prefill_view_type=<vt>&prefill_page=<route> so the form opens
// with the user's current state already typed in.
prefill := map[string]string{
"filter": r.URL.Query().Get("prefill_filter"),
"view_type": r.URL.Query().Get("prefill_view_type"),
"page": r.URL.Query().Get("prefill_page"),
}
s.render(w, r, "views", map[string]any{
"Title": "views",
"Views": views,
"Prefill": prefill,
// Catalog of selectable values for the form selects.
"AllViewTypes": allViewTypes,
"DefaultForOptions": []string{"", "tree", "dashboard", "calendar", "timeline"},
"SortDirOptions": []string{"", "asc", "desc"},
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
s.render(w, r, "views_landing", map[string]any{
"Title": "views",
"Views": views,
})
}
// handleViewCreate accepts the create-view form POST.
// handleViewRender resolves a slug into either a user view (Slice A
// schema) or a system view (Slice C), then renders the appropriate
// template. The render path also fire-and-forgets a TouchView so the
// view climbs the MRU ladder for the next /views landing redirect.
//
// Slice B implementation: only user views are wired; system views
// resolve via LookupSystemView (added in Slice C) and 404 in this slice
// when the slug is unknown.
func (s *Server) handleViewRender(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
http.NotFound(w, r)
return
}
v, err := s.Store.GetView(r.Context(), slug)
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.fail(w, r, err)
return
}
if err := s.Store.TouchView(r.Context(), slug); err != nil {
s.Logger.Warn("touch view", "slug", slug, "err", err)
}
// Parse the saved spec.
filter, viewType, groupBy := decodeViewSpec(v.FilterJSON)
// Allow URL chip overlay so chip clicks inside a saved view narrow
// further. The page chip URLs round-trip ?view= via the URL anchor
// added in slice E's sidebar wiring; here we just respect anything
// the user typed in the query.
urlFilter := ParseTreeFilter(r.URL.Query())
overlayURLOntoSavedFilter(&filter, urlFilter, r.URL.Query())
if raw := strings.TrimSpace(r.URL.Query().Get("view_type")); raw != "" {
viewType = raw
}
if raw := strings.TrimSpace(r.URL.Query().Get("group_by")); raw != "" {
groupBy = raw
}
s.renderViewPage(w, r, v, filter, viewType, groupBy)
}
// renderViewPage runs the shared render path for a resolved view (user
// view or future system view). Slice B reuses the tree handler's
// rendering pieces — list / card / kanban share the tree-section
// dispatch shape. Calendar / timeline view_types fall back to list in
// slice B; slice D wires their dedicated templates.
func (s *Server) renderViewPage(w http.ResponseWriter, r *http.Request, v *store.View, filter TreeFilter, viewType, groupBy string) {
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
tags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
linkKinds, err := s.linkKindsByItem(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
viewSet := PageViewTypes("/")
if viewType == "" {
viewType = viewSet.Default
}
viewType = viewSet.Resolve(viewType)
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
base := "/views/" + v.Slug
counts := computeChipCounts(items, filter, linkKinds, tags, base)
cardItems := flatMatchedItems(items, filter, linkKinds)
if groupBy == "" {
groupBy = ParseGroupBy(r.URL.Query())
}
kanban := BuildKanbanBoard(cardItems, groupBy)
groupByChips := GroupByChips(base, filter, groupBy)
data := map[string]any{
"Title": v.Name,
"View": v,
"Roots": roots,
"Orphans": orphans,
"Total": total,
"OrphanN": orphanN,
"Matched": matched,
"AllTags": tags,
"Filter": filter,
"Counts": counts,
"Projects": parentOptionsFromItems(items),
"BasePath": base,
"ProjectChipTarget": "#tree-section",
"ViewType": viewType,
"ViewTypeChips": ViewTypeChips(base, filter, viewType),
"CardItems": cardItems,
"Kanban": kanban,
"GroupBy": groupBy,
"GroupByChips": groupByChips,
"ActiveTags": filter.Tags,
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "tree_section", data)
return
}
s.render(w, r, "view_render", data)
}
// handleViewEditor renders the create / edit form. Slice B ships a
// minimal placeholder; Slice D rebuilds the form with the chip strip
// + slug derivation + icon picker.
func (s *Server) handleViewEditor(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
var (
view *store.View
err error
title = "new view"
)
if slug != "" {
view, err = s.Store.GetView(r.Context(), slug)
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.fail(w, r, err)
return
}
title = "edit " + view.Name
}
filterQuery := ""
currentViewType := "list"
if view != nil {
f, vt, _ := decodeViewSpec(view.FilterJSON)
filterQuery = f.QueryString()
if vt != "" {
currentViewType = vt
}
}
s.render(w, r, "view_editor", map[string]any{
"Title": title,
"View": view,
"FilterQuery": filterQuery,
"ViewTypes": []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
"CurrentVT": currentViewType,
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
"SortDirOptions": []string{"", "asc", "desc"},
"IconKeys": IconRegistryKeys(),
})
}
// handleViewCreate accepts the create form POST.
func (s *Server) handleViewCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
@@ -56,69 +214,15 @@ func (s *Server) handleViewCreate(w http.ResponseWriter, r *http.Request) {
}
v, err := s.Store.CreateView(r.Context(), in)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
s.writeViewError(w, err)
return
}
http.Redirect(w, r, "/views/"+v.ID, http.StatusSeeOther)
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
}
// handleViewEdit renders the edit form for an existing view, pre-populated
// with the row's current values. Submit posts back to /views/<id>.
func (s *Server) handleViewEdit(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/views/"), "/edit")
if id == "" {
http.NotFound(w, r)
return
}
v, err := s.Store.GetView(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
s.fail(w, r, err)
return
}
filterQuery, err := filterJSONToQuery(v.FilterJSON)
if err != nil {
s.Logger.Warn("filterJSONToQuery", "id", id, "err", err)
}
s.render(w, r, "view_edit", map[string]any{
"Title": "edit view",
"View": v,
"FilterQuery": filterQuery,
"AllViewTypes": allViewTypes,
"DefaultForOptions": []string{"", "tree", "dashboard", "calendar", "timeline"},
"SortDirOptions": []string{"", "asc", "desc"},
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
})
}
// filterJSONToQuery rebuilds a URL-query representation of a stored
// filter_json so the edit form can pre-populate the `filter_query` input
// field. Inverse of filterQueryToJSON.
func filterJSONToQuery(filterJSON []byte) (string, error) {
if len(filterJSON) == 0 {
return "", nil
}
payload := map[string]any{}
if err := json.Unmarshal(filterJSON, &payload); err != nil {
return "", err
}
f := filterFromJSONPayload(payload)
// QueryString re-emits the canonical URL query form; that's exactly the
// shape the form's `filter_query` input expects on round-trip.
return f.QueryString(), nil
}
// handleViewWrite dispatches the /views/<id> POST routes: bare path is
// update; /views/<id>/delete is soft-delete.
func (s *Server) handleViewWrite(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/views/")
if base, ok := strings.CutSuffix(path, "/delete"); ok {
s.handleViewDelete(w, r, base)
return
}
// handleViewUpdate accepts the edit form POST.
func (s *Server) handleViewUpdate(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
@@ -128,48 +232,23 @@ func (s *Server) handleViewWrite(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, err := s.Store.UpdateView(r.Context(), path, in); err != nil {
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/views", http.StatusSeeOther)
}
// handleViewDelete soft-deletes by id.
func (s *Server) handleViewDelete(w http.ResponseWriter, r *http.Request, id string) {
if err := s.Store.SoftDeleteView(r.Context(), id); err != nil {
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/views", http.StatusSeeOther)
}
// handleViewRedirect resolves /views/<uuid> GET into a redirect to the
// appropriate Views-supporting page with ?view=<uuid> appended. The target
// page resolves the saved filter+view_type at render time via
// applySavedView. /views/<id>/edit is dispatched separately via the more
// specific route registered first; this handler ignores the edit suffix
// defensively when the routing pattern doesn't match for some reason.
func (s *Server) handleViewRedirect(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/views/")
if id == "" {
http.NotFound(w, r)
return
}
if strings.HasSuffix(id, "/edit") {
s.handleViewEdit(w, r)
return
}
v, err := s.Store.GetView(r.Context(), id)
v, err := s.Store.UpdateView(r.Context(), slug, in)
if err != nil {
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
s.writeViewError(w, err)
return
}
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
}
// handleViewDelete soft-… nope. New schema is hard-delete (no
// deleted_at). One POST removes the row.
func (s *Server) handleViewDelete(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if err := s.Store.DeleteView(r.Context(), slug); err != nil {
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
@@ -177,64 +256,89 @@ func (s *Server) handleViewRedirect(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
target := targetRouteForViewType(v.ViewType)
q := url.Values{}
q.Set("view", v.ID)
http.Redirect(w, r, target+"?"+q.Encode(), http.StatusSeeOther)
http.Redirect(w, r, "/views", http.StatusSeeOther)
}
// targetRouteForViewType picks a sensible landing route given the view's
// view_type. card/list/kanban land on /; calendar on /calendar; timeline on
// /timeline. Slice E will let `is_default_for` override.
func targetRouteForViewType(vt string) string {
switch vt {
case ViewTypeCalendar:
return "/calendar"
case ViewTypeTimeline:
return "/timeline"
case ViewTypeCard, ViewTypeList, ViewTypeKanban:
return "/"
// handleViewReorder takes a comma-separated slug list and applies new
// sort_order values. Wired now so slice G's drag UI has a target.
func (s *Server) handleViewReorder(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
return "/"
raw := strings.TrimSpace(r.PostForm.Get("slugs"))
if raw == "" {
http.Error(w, "slugs is required", http.StatusBadRequest)
return
}
slugs := strings.Split(raw, ",")
for i, slug := range slugs {
slugs[i] = strings.TrimSpace(slug)
}
if err := s.Store.ReorderViews(r.Context(), slugs); err != nil {
s.fail(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// viewInputFromForm decodes the create/update form. filter_json is accepted
// as either raw JSON (textarea) OR as an encoded query string under
// `filter_query` so the save-from-page workflow can prefill from a TreeFilter
// the user assembled via chips.
// writeViewError maps the typed store errors to friendly HTTP status +
// banner copy. Falls back to 400 for anything else.
func (s *Server) writeViewError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
switch {
case errors.Is(err, store.ErrViewSlugFormat):
http.Error(w, "slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (lowercase, no underscores, no leading dash)", http.StatusBadRequest)
case errors.Is(err, store.ErrViewSlugReserved):
http.Error(w, "slug is reserved (system views and top-level routes shadow it)", http.StatusBadRequest)
case errors.Is(err, store.ErrViewSlugTaken):
http.Error(w, "slug already exists — pick a different one", http.StatusConflict)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
// viewInputFromForm decodes the create/edit form. Slug + name are
// required; the rest defaults sensibly. filter_query is optional and
// canonicalises into filter_json on save (URL-query form is what the
// editor's chip strip emits in slice D).
func viewInputFromForm(form url.Values) (store.ViewInput, error) {
in := store.ViewInput{
Name: strings.TrimSpace(form.Get("name")),
Description: strings.TrimSpace(form.Get("description")),
ViewType: strings.TrimSpace(form.Get("view_type")),
SortField: strings.TrimSpace(form.Get("sort_field")),
SortDir: strings.TrimSpace(form.Get("sort_dir")),
GroupBy: strings.TrimSpace(form.Get("group_by")),
Pinned: form.Get("pinned") == "1",
IsDefaultFor: strings.TrimSpace(form.Get("is_default_for")),
Slug: strings.TrimSpace(form.Get("slug")),
Name: strings.TrimSpace(form.Get("name")),
SortField: strings.TrimSpace(form.Get("sort_field")),
SortDir: strings.TrimSpace(form.Get("sort_dir")),
GroupBy: strings.TrimSpace(form.Get("group_by")),
ShowCount: form.Get("show_count") == "1",
}
// Prefer filter_query when present; otherwise fall back to filter_json.
if fq := strings.TrimSpace(form.Get("filter_query")); fq != "" {
filterJSON, err := filterQueryToJSON(fq)
if err != nil {
return in, fmt.Errorf("filter_query: %w", err)
}
in.FilterJSON = filterJSON
} else if fj := strings.TrimSpace(form.Get("filter_json")); fj != "" {
in.FilterJSON = []byte(fj)
if iconRaw := strings.TrimSpace(form.Get("icon")); iconRaw != "" {
in.Icon = &iconRaw
}
viewType := strings.TrimSpace(form.Get("view_type"))
if viewType == "" {
viewType = ViewTypeList
}
fq := strings.TrimSpace(form.Get("filter_query"))
filterJSON, err := encodeFilterToJSON(fq, viewType)
if err != nil {
return in, fmt.Errorf("filter_query: %w", err)
}
in.FilterJSON = filterJSON
return in, nil
}
// filterQueryToJSON parses a TreeFilter URL query and returns the canonical
// JSON shape stored in `filter_json`. Mirrors the design doc §2 keys.
func filterQueryToJSON(query string) ([]byte, error) {
// encodeFilterToJSON turns a URL-query-form filter + view_type into the
// canonical filter_json shape stored on the view. view_type lives inside
// the JSON per m's Q2 pick.
func encodeFilterToJSON(query, viewType string) ([]byte, error) {
q, err := url.ParseQuery(strings.TrimPrefix(query, "?"))
if err != nil {
return nil, err
}
f := ParseTreeFilter(q)
payload := map[string]any{}
payload := map[string]any{
"view_type": viewType,
}
if f.Q != "" {
payload["q"] = f.Q
}
@@ -265,82 +369,65 @@ func filterQueryToJSON(query string) ([]byte, error) {
return json.Marshal(payload)
}
// applyDefaultView resolves the saved view marked is_default_for=<page>
// when the request URL carries no filter/view-specific params and the user
// has not opted out via ?nodefault=1. Returns the applied view (for banner
// labelling) or nil when no default exists / was applied.
//
// Per design.md §7 Slice E: defaults are a polish layer. They only kick in
// on a "clean" landing — the moment the user types a chip click, the URL
// gains a filter param and the default no longer auto-applies. Same with
// an explicit ?view=<uuid>.
func (s *Server) applyDefaultView(r *http.Request, page string, filter *TreeFilter, viewType *string) (*store.View, error) {
q := r.URL.Query()
if q.Get("nodefault") == "1" {
return nil, nil
// decodeViewSpec parses filter_json into a TreeFilter + view_type +
// group_by. Inverse of encodeFilterToJSON.
func decodeViewSpec(filterJSON []byte) (TreeFilter, string, string) {
f := TreeFilter{
Status: []string{"active"},
IncludeDescendants: true,
}
// Any filter-affecting param means "user is driving" — skip the default.
for _, key := range []string{"q", "tag", "mgmt", "status", "has", "show-archived", "public", "project", "project_id", "project_descendants", "view", "view_type", "group_by"} {
if q.Get(key) != "" {
return nil, nil
}
}
v, err := s.Store.DefaultViewFor(r.Context(), page)
if err != nil || v == nil {
return v, err
viewType := ""
groupBy := ""
if len(filterJSON) == 0 {
return f, viewType, groupBy
}
payload := map[string]any{}
if len(v.FilterJSON) > 0 {
if err := json.Unmarshal(v.FilterJSON, &payload); err != nil {
return v, fmt.Errorf("decode default filter_json: %w", err)
if err := json.Unmarshal(filterJSON, &payload); err != nil {
return f, viewType, groupBy
}
if v, ok := payload["view_type"].(string); ok {
viewType = v
}
if v, ok := payload["group_by"].(string); ok {
groupBy = v
}
if v, ok := payload["q"].(string); ok {
f.Q = v
}
if v, ok := payload["tags"].([]any); ok {
f.Tags = anySliceToStrings(v)
}
if v, ok := payload["management"].([]any); ok {
f.Management = anySliceToStrings(v)
}
if v, ok := payload["status"].([]any); ok {
f.Status = anySliceToStrings(v)
if len(f.Status) == 0 {
f.Status = []string{"active"}
}
}
*filter = filterFromJSONPayload(payload)
*viewType = v.ViewType
return v, nil
if v, ok := payload["has_links"].([]any); ok {
f.HasLinks = anySliceToStrings(v)
}
if v, ok := payload["public"].(bool); ok {
f.Public = &v
}
if v, ok := payload["show_archived"].(bool); ok && v {
f.ShowArchived = true
}
if v, ok := payload["project_path"].(string); ok {
f.ProjectPath = v
}
if v, ok := payload["include_descendants"].(bool); ok {
f.IncludeDescendants = v
}
return f, viewType, groupBy
}
// applySavedView resolves a `?view=<uuid>` reference and folds the persisted
// filter + view_type back into the supplied TreeFilter + view-type slot.
// URL chip params OVERLAY the saved filter — a saved view scoped to
// `dev` with `?tag=work` added narrows further. Transient overlays don't
// auto-save back to the view (the URL is bookmarkable, but to persist the
// drift the user opens /views/<id>/edit).
//
// Returns the saved view (for chip labelling) or nil when no `?view=` was
// given. Errors are logged + returned (handlers can choose to ignore).
func (s *Server) applySavedView(r *http.Request, filter *TreeFilter, viewType *string) (*store.View, error) {
id := strings.TrimSpace(r.URL.Query().Get("view"))
if id == "" {
return nil, nil
}
v, err := s.Store.GetView(r.Context(), id)
if err != nil {
return nil, err
}
payload := map[string]any{}
if len(v.FilterJSON) > 0 {
if err := json.Unmarshal(v.FilterJSON, &payload); err != nil {
return v, fmt.Errorf("decode filter_json: %w", err)
}
}
saved := filterFromJSONPayload(payload)
saved.ViewID = id
q := r.URL.Query()
overlayURLFields(&saved, *filter, q)
*filter = saved
// view_type: URL wins when explicitly set, otherwise the saved value.
if strings.TrimSpace(q.Get("view_type")) == "" {
*viewType = v.ViewType
}
return v, nil
}
// overlayURLFields lets URL-provided chip values override the saved-view
// baseline. The URL filter is the parsed-from-query TreeFilter; q is the
// raw url.Values so we can detect "field was set in the URL" distinct from
// "field's value happens to equal the zero value".
func overlayURLFields(base *TreeFilter, urlFilter TreeFilter, q url.Values) {
// overlayURLOntoSavedFilter applies URL-query chip values on top of the
// saved-view baseline. Same pattern the 5i fix-shift had (URL overrides
// saved); slice B reintroduces it here on the /views/{slug} render path.
func overlayURLOntoSavedFilter(base *TreeFilter, urlFilter TreeFilter, q url.Values) {
if q.Get("q") != "" {
base.Q = urlFilter.Q
}
@@ -370,47 +457,6 @@ func overlayURLFields(base *TreeFilter, urlFilter TreeFilter, q url.Values) {
}
}
// filterFromJSONPayload is the inverse of filterQueryToJSON. Keys absent
// from the payload land at their TreeFilter zero value (Status defaults to
// ["active"] to match ParseTreeFilter).
func filterFromJSONPayload(p map[string]any) TreeFilter {
f := TreeFilter{
Status: []string{"active"},
IncludeDescendants: true,
}
if v, ok := p["q"].(string); ok {
f.Q = v
}
if v, ok := p["tags"].([]any); ok {
f.Tags = anySliceToStrings(v)
}
if v, ok := p["management"].([]any); ok {
f.Management = anySliceToStrings(v)
}
if v, ok := p["status"].([]any); ok {
f.Status = anySliceToStrings(v)
if len(f.Status) == 0 {
f.Status = []string{"active"}
}
}
if v, ok := p["has_links"].([]any); ok {
f.HasLinks = anySliceToStrings(v)
}
if v, ok := p["public"].(bool); ok {
f.Public = &v
}
if v, ok := p["show_archived"].(bool); ok && v {
f.ShowArchived = true
}
if v, ok := p["project_path"].(string); ok {
f.ProjectPath = v
}
if v, ok := p["include_descendants"].(bool); ok {
f.IncludeDescendants = v
}
return f
}
func anySliceToStrings(in []any) []string {
out := make([]string, 0, len(in))
for _, v := range in {

View File

@@ -2,183 +2,152 @@ package web_test
import (
"context"
"encoding/json"
"net/url"
"strings"
"testing"
"time"
)
// TestViewsCRUDRoundTrip covers create → list → open (redirect to scoped page) →
// delete, end-to-end. Requires DB. Slice D — projax.views table CRUD.
func TestViewsCRUDRoundTrip(t *testing.T) {
// TestViewsLandingOnboarding asserts that GET /views with no views and no
// MRU renders the onboarding shell ("No saved views yet" + "+ New view").
func TestViewsLandingOnboarding(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
name := "p5i-D-view-" + stamp
defer pool.Exec(context.Background(),
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
// Create.
form := url.Values{}
form.Set("name", name)
form.Set("view_type", "card")
form.Set("filter_query", "tag=work&mgmt=mai")
code, _ := post(t, h, "/views", form)
if code != 303 {
t.Fatalf("POST /views status=%d, want 303", code)
// Clear any leftover touched views from prior runs so the MRU 302
// doesn't fire and steal the response.
if _, err := pool.Exec(context.Background(),
`UPDATE projax.views SET last_used_at = NULL`); err != nil {
t.Fatalf("reset mru: %v", err)
}
// Also clear ALL views so the onboarding shell renders (othewise the
// landing still ListViews-displays them).
if _, err := pool.Exec(context.Background(), `DELETE FROM projax.views`); err != nil {
t.Fatalf("clear views: %v", err)
}
// List page lists the new view.
code, body := get(t, h, "/views")
if code != 200 {
t.Fatalf("GET /views status=%d", code)
t.Fatalf("GET /views status=%d body=%q", code, body)
}
if !strings.Contains(body, name) {
t.Errorf("GET /views body missing %q", name)
if !strings.Contains(body, "No saved views yet") {
t.Error("onboarding shell should surface the no-views nudge")
}
// Fetch row to grab the id (and validate filter_json round-trip).
var (
id string
filterJSON []byte
viewType string
)
if err := pool.QueryRow(context.Background(),
`SELECT id, filter_json, view_type FROM projax.views WHERE name=$1 AND deleted_at IS NULL`,
name,
).Scan(&id, &filterJSON, &viewType); err != nil {
t.Fatalf("fetch row: %v", err)
}
if viewType != "card" {
t.Errorf("view_type = %q, want 'card'", viewType)
}
var payload map[string]any
if err := json.Unmarshal(filterJSON, &payload); err != nil {
t.Fatalf("filter_json unmarshal: %v", err)
}
if got, _ := payload["tags"].([]any); len(got) != 1 || got[0] != "work" {
t.Errorf("filter_json tags = %v, want [work]", payload["tags"])
}
if got, _ := payload["management"].([]any); len(got) != 1 || got[0] != "mai" {
t.Errorf("filter_json management = %v, want [mai]", payload["management"])
}
// GET /views/<id> redirects to the right page with ?view=<id>.
code, _ = get(t, h, "/views/"+id)
if code != 303 {
t.Errorf("GET /views/<id> status=%d, want 303 redirect", code)
}
// Soft delete.
code, _ = post(t, h, "/views/"+id+"/delete", url.Values{})
if code != 303 {
t.Errorf("POST delete status=%d, want 303", code)
}
var deletedAt *time.Time
if err := pool.QueryRow(context.Background(),
`SELECT deleted_at FROM projax.views WHERE id=$1`, id,
).Scan(&deletedAt); err != nil {
t.Fatalf("post-delete read: %v", err)
}
if deletedAt == nil {
t.Error("expected deleted_at to be set after POST /views/<id>/delete")
if !strings.Contains(body, `href="/views/new"`) {
t.Error("onboarding shell should link to /views/new")
}
}
// TestViewEditFlow exercises the fix for m's bug "we cant edit views yet".
// GET /views/<id>/edit renders the pre-filled form; POST /views/<id> updates
// the row in place. Verifies name + view_type + filter_json round-trip.
func TestViewEditFlow(t *testing.T) {
// TestViewsLandingMRURedirects asserts that GET /views 302s to the most
// recently used view when one exists.
func TestViewsLandingMRURedirects(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"), ".", "")
name := "p5i-fix-edit-" + stamp
defer pool.Exec(context.Background(),
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL OR name = $2`,
name, name+"-renamed")
slug := "p5j-b-landing-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
// Seed + touch.
if _, err := pool.Exec(context.Background(), `
INSERT INTO projax.views (slug, name, filter_json, last_used_at)
VALUES ($1, 'P5j B Landing', '{"view_type":"list"}'::jsonb, now())`, slug); err != nil {
t.Fatalf("seed view: %v", err)
}
code, body := get(t, h, "/views")
if code != 302 {
t.Errorf("GET /views status=%d (want 302 to MRU); body=%q", code, body)
}
}
var id string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.views (name, view_type, filter_json)
VALUES ($1, 'list', $2::jsonb)
RETURNING id`, name, []byte(`{"tags":["dev"]}`)).Scan(&id); err != nil {
// TestViewRenderShowsSavedView asserts that GET /views/{slug} renders the
// view's name + slug in the header and the tree-section body.
func TestViewRenderShowsSavedView(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-b-render-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
if _, err := pool.Exec(context.Background(), `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'P5j B Render', '{"view_type":"card"}'::jsonb)`, slug); err != nil {
t.Fatalf("seed: %v", err)
}
// GET /views/<id>/edit renders the pre-filled form (not the redirect).
code, body := get(t, h, "/views/"+id+"/edit")
code, body := get(t, h, "/views/"+slug)
if code != 200 {
t.Fatalf("GET /views/<id>/edit status=%d, want 200", code)
t.Fatalf("GET /views/<slug> status=%d body=%q", code, body)
}
if !strings.Contains(body, `value="`+name+`"`) {
t.Error("edit form should pre-fill the name input")
if !strings.Contains(body, "P5j B Render") {
t.Error("render should surface the view's name")
}
if !strings.Contains(body, `value="tag=dev"`) {
t.Error("edit form should pre-fill filter_query from filter_json")
if !strings.Contains(body, `/views/`+slug) {
t.Error("render should surface the view's slug in the header")
}
// Index page now shows an edit link per row.
_, idx := get(t, h, "/views")
if !strings.Contains(idx, `/views/`+id+`/edit`) {
t.Error("/views should expose an edit link per row")
}
// POST /views/<id> updates the row.
form := url.Values{}
form.Set("name", name+"-renamed")
form.Set("view_type", "card")
form.Set("filter_query", "tag=work&mgmt=mai")
code, _ = post(t, h, "/views/"+id, form)
if code != 303 {
t.Fatalf("POST /views/<id> status=%d, want 303", code)
}
var newName, newType string
var newFilter []byte
if err := pool.QueryRow(ctx,
`SELECT name, view_type, filter_json FROM projax.views WHERE id = $1`, id,
).Scan(&newName, &newType, &newFilter); err != nil {
t.Fatalf("post-update read: %v", err)
}
if newName != name+"-renamed" {
t.Errorf("name = %q, want %q", newName, name+"-renamed")
}
if newType != "card" {
t.Errorf("view_type = %q, want 'card'", newType)
}
payload := map[string]any{}
_ = json.Unmarshal(newFilter, &payload)
tags, _ := payload["tags"].([]any)
if len(tags) != 1 || tags[0] != "work" {
t.Errorf("filter_json tags = %v, want [work] post-update", payload["tags"])
if !strings.Contains(body, `class="tree-card-grid"`) {
t.Error("view_type=card should render the card grid")
}
}
// TestSavedViewPageFilterApply exercises the fix for m's bug "the filters on
// custom views dont seem to work". A request to /?view=<id>&tag=work narrows
// the saved view further by overlaying the URL chip onto the persisted
// filter_json. Previously the saved filter clobbered the URL chips
// wholesale.
func TestSavedViewPageFilterApply(t *testing.T) {
// TestViewRender404OnUnknownSlug — an unknown slug returns 404, not a
// silent fallback to the tree.
func TestViewRender404OnUnknownSlug(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, _ := get(t, h, "/views/this-slug-does-not-exist-anywhere-9876")
if code != 404 {
t.Errorf("unknown slug should 404, got %d", code)
}
}
// TestViewCreateAndDelete — POST /views creates; POST /views/<slug>/delete
// removes. Verifies the slug-format error path too.
func TestViewCreateAndDelete(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-b-crud-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
form := url.Values{}
form.Set("slug", slug)
form.Set("name", "P5j B CRUD")
form.Set("view_type", "list")
form.Set("filter_query", "tag=work")
code, _ := post(t, h, "/views", form)
if code != 303 {
t.Fatalf("create status=%d want 303", code)
}
// Reserved-slug 400.
form2 := url.Values{}
form2.Set("slug", "dashboard")
form2.Set("name", "Should be rejected")
form2.Set("view_type", "list")
code, body := post(t, h, "/views", form2)
if code != 400 {
t.Errorf("reserved-slug create should 400, got %d body=%q", code, body)
}
// Delete.
code, _ = post(t, h, "/views/"+slug+"/delete", url.Values{})
if code != 303 {
t.Errorf("delete status=%d want 303", code)
}
}
// TestSavedViewFilterOverlay — chip params on /views/<slug>?tag=x narrow
// the saved filter. Verifies the slice B render-path overlay.
func TestSavedViewFilterOverlay(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"), ".", "")
name := "p5i-fix-overlay-" + stamp
devSlug := "p5i-fix-overlay-d-" + stamp
homeSlug := "p5i-fix-overlay-h-" + stamp
defer pool.Exec(context.Background(),
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
slug := "p5j-b-overlay-" + stamp
devSlug := "p5j-b-overlay-d-" + stamp
homeSlug := "p5j-b-overlay-h-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
var dev, home string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
@@ -190,138 +159,39 @@ func TestSavedViewPageFilterApply(t *testing.T) {
var devID, homeID string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
VALUES (array['project']::text[], 'Fix Dev', $1, ARRAY[$2]::uuid[], ARRAY['work'])
VALUES (array['project']::text[], 'P5jB Dev', $1, ARRAY[$2]::uuid[], ARRAY['work'])
RETURNING id`, devSlug, dev).Scan(&devID); err != nil {
t.Fatalf("seed dev item: %v", err)
}
if err := pool.QueryRow(ctx, `
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
VALUES (array['project']::text[], 'Fix Home', $1, ARRAY[$2]::uuid[], ARRAY['home'])
VALUES (array['project']::text[], 'P5jB Home', $1, ARRAY[$2]::uuid[], ARRAY['home'])
RETURNING id`, homeSlug, home).Scan(&homeID); err != nil {
t.Fatalf("seed home item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1,$2)`, devID, homeID)
defer pool.Exec(context.Background(), `DELETE FROM projax.items WHERE id IN ($1,$2)`, devID, homeID)
// Saved view with view_type=list and NO tag filter — both items should pass.
var id string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.views (name, view_type, filter_json)
VALUES ($1, 'list', '{}'::jsonb)
RETURNING id`, name).Scan(&id); err != nil {
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'P5jB Overlay', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
t.Fatalf("seed view: %v", err)
}
devLink := `href="/i/dev.` + devSlug + `"`
homeLink := `href="/i/home.` + homeSlug + `"`
// Open view alone — both rows should appear.
_, baseBody := get(t, h, "/?view="+id)
if !strings.Contains(baseBody, devLink) {
t.Error("saved view without tag filter should show dev row")
_, base := get(t, h, "/views/"+slug)
if !strings.Contains(base, devLink) {
t.Error("saved view without tag should show dev row")
}
if !strings.Contains(baseBody, homeLink) {
t.Error("saved view without tag filter should show home row")
if !strings.Contains(base, homeLink) {
t.Error("saved view without tag should show home row")
}
// Overlay ?tag=work — home row should disappear; dev should remain.
_, narrowedBody := get(t, h, "/?view="+id+"&tag=work")
if !strings.Contains(narrowedBody, devLink) {
t.Error("?view=<id>&tag=work should still show dev row (work-tagged)")
_, narrowed := get(t, h, "/views/"+slug+"?tag=work")
if !strings.Contains(narrowed, devLink) {
t.Error("URL chip tag=work should keep dev (work-tagged)")
}
if strings.Contains(narrowedBody, homeLink) {
t.Error("?view=<id>&tag=work should hide home row — URL chip must overlay saved filter")
}
// Chip URLs inside the saved view must round-trip the view= param so
// chip clicks don't strip the saved view.
if !strings.Contains(narrowedBody, "view="+id) {
t.Error("chip URLs inside a saved view should carry view=<id> forward")
}
}
// TestDefaultViewAppliedOnCleanURL verifies the Slice E behaviour: when /
// is requested with no chip params and a default view exists for the page,
// the saved filter + view_type apply and a "Showing default view: …"
// banner renders. Adding any chip param (?tag=…) bypasses the default.
// ?nodefault=1 is the explicit opt-out.
func TestDefaultViewAppliedOnCleanURL(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"), ".", "")
name := "p5i-E-default-" + stamp
defer pool.Exec(context.Background(),
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (name, view_type, filter_json, is_default_for)
VALUES ($1, 'card', $2::jsonb, 'tree')`,
name, []byte(`{"tags":["work"]}`)); err != nil {
t.Fatalf("seed default view: %v", err)
}
// Clean URL: default applies → card view + banner.
_, body := get(t, h, "/")
if !strings.Contains(body, `class="tree-card-grid"`) {
t.Error("clean / should auto-apply default view (card grid expected)")
}
if !strings.Contains(body, `default-banner`) {
t.Error("default-banner should render when a default applies")
}
if !strings.Contains(body, name) {
t.Error("banner should name the applied default view")
}
// Any chip param bypasses the default → list view (no banner).
_, withChip := get(t, h, "/?tag=dev")
if strings.Contains(withChip, `default-banner`) {
t.Error("default banner should disappear once user types a chip")
}
if !strings.Contains(withChip, `<ul class="forest">`) {
t.Error("?tag=dev should render the forest (default not applied)")
}
// Explicit opt-out via ?nodefault=1.
_, optOut := get(t, h, "/?nodefault=1")
if strings.Contains(optOut, `default-banner`) {
t.Error("?nodefault=1 should suppress the default banner")
}
if !strings.Contains(optOut, `<ul class="forest">`) {
t.Error("?nodefault=1 should render the forest (default suppressed)")
}
}
// TestSavedViewAppliedOnQueryParam verifies that opening / with ?view=<uuid>
// re-applies the saved filter+view_type. We seed a view tagged work=patents
// and assert the rendered tree has the right ProjectChip / chip-on state.
func TestSavedViewAppliedOnQueryParam(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"), ".", "")
name := "p5i-D-saved-" + stamp
defer pool.Exec(context.Background(),
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
// Seed directly via SQL so the assertion focuses on the resolver, not the
// form flow tested above.
var id string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.views (name, view_type, filter_json)
VALUES ($1, 'card', $2::jsonb)
RETURNING id`, name, []byte(`{"project_path":"dev","include_descendants":true}`)).Scan(&id); err != nil {
t.Fatalf("seed view: %v", err)
}
_, body := get(t, h, "/?view="+id)
if !strings.Contains(body, `class="tree-card-grid"`) {
t.Error("?view= should override view_type → card view should render")
}
if !strings.Contains(body, `class="proj-chip chip-on"`) {
t.Error("?view= should apply project filter chip → proj-chip should be on")
if strings.Contains(narrowed, homeLink) {
t.Error("URL chip tag=work should hide home")
}
}