25 Commits

Author SHA1 Message Date
mAi
1020d60c75 docs: Phase 7 — record m's decisions (§0); Q4 override (show both task sources)
m approved the design + answered all 6 questions (via head). Five matched
the inventor pick; Q4 overridden to (b): a CalDAV-bound project with
mBrian-native tasks renders BOTH as sub-sections rather than hiding the
mBrian ones. §0 captures the picks; §5 updated to the two-section render.
Implementation: t-projax-7b-tasks (gated on the type-field API ask).
2026-06-01 17:28:29 +02:00
mAi
6e4fabfab9 docs: Phase 7 entity-model design — projects/tasks/tasklists + hybrid CalDAV/mBrian task backend
DESIGN PASS (inventor, no code). docs/plans/phase-7-entity-model.md.

Formalizes the entity model on mBrian now that it's the canonical backend:
- ONE new node type (task=['task']); zero new edge rels. Areas/projects
  unchanged. Tasklist/checklist = a container + metadata.projax.render
  hint, NOT a new type (keeps the type vocabulary at two — m's keep-it-
  simple watchword). Sub-tasks/checklist items = tasks nested via child_of.
- Task done-state reuses the existing status lifecycle (done); due/order
  via metadata.projax.* — no new fields, no new API beyond one.
- Hybrid task backend per m's decision: caldav-list link presence selects
  per-project (CalDAV VTODOs vs mBrian-native task nodes); uniform Task
  shape over both (slice-B adapter pattern). CalDAV side already full
  read/write — no change.
- Verified against live system: mBrian has NO task type today (0 nodes);
  POST /api/projax/nodes forces type=['project'] → the ONE cross-repo ask
  is accepting type in {project,task}. Everything else reuses Phase-6
  structures (child_of, status, PATCH-projax partial, projax_origin
  scoping). Supersedes PRD §2.1 'tasks live outside projax' for non-CalDAV
  projects; reconciled in §7.

6 open questions batched for head→m; 1 cross-repo API ask for mBrian/head.
Parked at the gate per inventor flow.
2026-06-01 17:16:48 +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
70 changed files with 5663 additions and 435 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,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,175 @@
# Phase 7 — entity model on mBrian: projects / tasks / tasklists + hybrid CalDAV-vs-mBrian task backend
**Status**: Phase A design (inventor — no code). Branch `mai/kahn/phase-7a-entity-model`.
**Author**: kahn (inventor), 2026-06-01.
**Predecessor**: Phase 6 — mBrian is now projax's canonical backend (reads direct-DB, writes via mBrian's scoped `/api/projax` HTTP surface; `PROJAX_BACKEND=mbrian` live on 7c84c96). See `docs/plans/mbrian-backend-migration.md` + `docs/plans/slice-c-writepath-contract.md`.
**m's vision (verbatim)**: *"Nestable project components: Projects (completely nestable into sub-projects), tasks, tasklists (as checklists, too), CalDAV connections for tasks."*
**Watchword**: keep the DB simple (m's hard constraint from Phase 6).
---
## §0 — m's decisions (2026-06-01, relayed via head)
m approved the design and answered all six §9 questions. Five matched the inventor pick; Q4 was overridden.
- **Q1 (Tasklist)**: (a) — one container + `metadata.projax.render='checklist'` hint, no new type. *(pick)*
- **Q2 (Done-state)**: (a) — reuse `status='done'`. *(pick)*
- **Q3 (Hybrid selector)**: (a) — `caldav-list` link presence ALONE selects the backend; `management` is orthogonal, NOT a gate. *(pick)*
- **Q4 (CalDAV + mBrian tasks on one project)**: **(b) — OVERRIDE.** Show **both** as separate sub-sections, don't hide the mBrian tasks. *(pick was (a) hide; m wants both visible — so a CalDAV-bound project can still carry mBrian-native tasks alongside its VTODOs, both rendered.)*
- **Q5 (Ordering)**: (a) — `created_at` order only; defer drag-reorder. *(pick)*
- **Q6 (Task edge)**: (a) — `child_of` + render-filter tasks out of `/tree`+`/graph`. *(pick)*
**Cross-repo ask** (POST `/api/projax/nodes` accept `type ∈ {project, task}`): head opened it with mBrian/head (msg 2719); the slug-control fix (2711) is also in flight. mBrian-native task CREATE is gated on the `type` field landing; everything else builds now.
Implementation tracked under `t-projax-7b-tasks-implement` (branch `mai/kahn/phase-7b-tasks`).
---
## §1 — Diagnosis: what exists, what's missing
**Verified against the live system (not docs):**
- mBrian node types today (msupabase, `mbrian.nodes`): `project` (76), `mai-managed` (43 co-type), `event` (242), plus contact/book/idea/note/concept/question/… — **no `task` or `todo` type exists.** projax tasks are not mBrian nodes today.
- projax projects/areas: `type=['project']`; areas additionally carry `metadata.projax.kind='area'`; nesting is arbitrary-depth via `child_of` edges (Phase 6). Confirmed holds.
- **Tasks today live OUTSIDE projax.** PRD §2.1: *"Task — atomic work item. Lives outside projax (CalDAV todos, Gitea issues, mai.tasks). projax references and aggregates them; it does not own them."* The only task-shaped surface projax owns end-to-end is the **CalDAV VTODO read/write** path (PRD §5): a `caldav-list` link (self-edge `rel='projax-caldav-list'`, `metadata.url`) binds a project to a calendar; the detail page + dashboard + timeline aggregate its VTODOs and write them back (complete/reopen/edit/delete/add) with ETag optimistic concurrency.
- `management text[]` = `self` / `mai` / `external` (empty = unmanaged), already per-project.
**The gap Phase 7 closes:** m wants first-class tasks + tasklists/checklists for projects that AREN'T CalDAV-bound, while keeping CalDAV as the task backend where he's already wired a calendar. This **supersedes PRD §2.1's "tasks live outside projax"** — but only for non-CalDAV projects; CalDAV-bound projects keep CalDAV as their task store. §7 reconciles the PRD.
---
## §2 — Entity types + mBrian mapping
The design adds **exactly one new node type** (`task`) and **zero new edge rels** — everything else reuses Phase-6 structures. This is the DB-simple path.
| entity | mBrian shape | new? |
|---|---|---|
| **Area** | `type=['project']` + `metadata.projax.kind='area'`, root-level (no parent `child_of`). | unchanged |
| **Project** | `type=['project']`, nestable via `child_of` edges, arbitrary depth. | unchanged |
| **Task** | `type=['task']`, attached to its parent (project or task) via a `child_of` edge. Done-state reuses the existing `status` lifecycle (`active``done``archived`); `metadata.projax.{due, order, priority}` optional. | **NEW type** |
| **Tasklist / checklist** | **NOT a new type.** Any container (project OR task) with `metadata.projax.render='checklist'` renders its child tasks in compact checklist mode. A "tasklist" is a project (or task) whose children are tasks; "checklist" is a render hint, not a type. | render flag only |
**Why task reuses `status` not a new `done` bool**: the lifecycle `active→done→archived` already exists on every node (`metadata.projax.status`), the PATCH-projax partial already writes it, and the reader already unpacks it. A task is "done" when `status='done'`. Zero new field, zero new API surface. (Open Q2 confirms.)
**Why one container concept, not three types** (m's "tasklists as checklists too" + the parked micro-project idea): projects, tasklists, and checklists are the same structural thing — a container of tasks — differing only in render density. Modelling them as one container + a `render` hint keeps the type vocabulary at two (`project`, `task`) instead of four. A checklist sub-item is just a task nested under a task. (Open Q1 confirms.)
**Task attachment — `child_of` vs a dedicated edge** (inventor pick: `child_of`): tasks attach via the same `child_of` edge projects use. Pros: reuses the reader's graph walk, path derivation, and the write API's existing `child_of` support — no new edge rel (the `/api/projax/edges` allowlist is `child_of | projax-*`). Con: tasks become structural children, so they'd surface in `/tree` and `/graph` unless those renderers filter by type. That filter is cheap — `Item.Kind` already carries `['task']`, so the project surfaces render `type=['project']` only and the Tasks section renders `type=['task']` children. The alternative (a dedicated task edge for hard separation from the project DAG) is cleaner-on-paper but adds a rel + a reader change for no functional gain at m's scale. (Open Q6.)
---
## §3 — The hybrid CalDAV-vs-mBrian task backend (m's decision)
m already decided task storage is **hybrid, segmented per-project** — not uniformly mBrian. His words: *"If we decide to use caldav, we use that - and mBrian for others. We already have the managed classification so we can separate."*
### §3.1 — Per-project backend-selection rule
**The selector is the `caldav-list` link.** A project's task backend is decided per-project, the same way m already decides it — by binding (or not binding) a CalDAV list:
```
project has a caldav-list link → CalDAV-backed (tasks = that calendar's VTODOs)
project has NO caldav-list link → mBrian-native (tasks = type=['task'] child nodes)
```
`management` (`self`/`mai`/`external`) is the **existing classification that makes this clean** (m's "we already have the managed classification so we can separate") — it is orthogonal *who-runs-it* metadata, not the switch itself. The caldav-list link is the switch. (Open Q3 checks whether m wants `management` to ALSO gate — e.g. force mBrian-native for `self`, or suppress task-authoring on `external`.)
### §3.2 — CalDAV sync model (CalDAV-bound projects)
**No change — it already works.** PRD §5's VTODO surface is already full read/write (complete/reopen/edit/delete/add, ETag optimistic concurrency, ICS round-trip). A CalDAV-bound project's tasks ARE its VTODOs; Phase 7 just formalizes "this project is CalDAV-backed" as a first-class concept rather than an aggregation side-effect. No new CalDAV writeback is needed. (The §5 read-only stance on VEVENTs is unchanged — events aren't tasks.)
### §3.3 — Uniform task shape (one shape, two sources)
Both backends present through one `Task` view-shape, exactly the slice-B adapter pattern (one `Item` shape, two backends):
```
Task { ID, Title, Done bool, Due *date, Source "caldav"|"mbrian",
Order int, ParentItemID, Priority? , raw-source-handle }
```
- CalDAV source: materialised from a VTODO (`SUMMARY`→Title, `STATUS`→Done, `DUE`→Due, UID+calendar_url as the handle for writeback).
- mBrian source: materialised from a `type=['task']` node (`title`→Title, `status='done'`→Done, `metadata.projax.due`→Due, node id as the handle).
The detail-page Tasks section, dashboard task rollups, and timeline render the uniform shape — they don't care which backend produced it. Writes dispatch on `Source`: CalDAV → existing VTODO PUT path; mBrian → `/api/projax` node PATCH/POST.
---
## §4 — Nesting + ordering
- **Project nesting**: unchanged — arbitrary depth via `child_of`.
- **Task nesting**: tasks nest under a project (`task child_of project`) and under a task (`subtask child_of task`) — arbitrary depth, reusing `child_of`. Sub-tasks ARE checklist items; a task with a `render='checklist'` hint shows its sub-tasks as a compact checklist. This folds in the parked micro-project/checklist-sub-item idea for free.
- **Ordering within a list**: `metadata.projax.order` (int) for manual reorder; default order is `created_at` when `order` is unset. Drag-to-reorder writes `order` via PATCH-projax. (Open Q5: ship manual reorder in v1, or `created_at`-only and defer drag?)
- **CalDAV task ordering**: VTODOs have no portable order field; CalDAV-backed lists keep their existing sort (due-date / priority / summary). Manual reorder is mBrian-native-only.
---
## §5 — UI / render implications
- **Detail page Tasks section** (`/i/{path}`): today CalDAV-only. Extend to render the uniform Task shape. For a CalDAV-bound project → VTODOs (as now). For an mBrian-native project → its `type=['task']` child nodes, with an "add task" affordance, a done checkbox (→ `status` flip), inline title/due edit, and delete — mirroring the CalDAV affordances so the two sources feel identical. **Per m's Q4=(b): a CalDAV-bound project that ALSO has mBrian-native task nodes renders BOTH — a CalDAV sub-section (VTODOs) and an mBrian sub-section (task nodes) — rather than hiding the mBrian tasks. The backend selector (§3.1) decides where a *newly created* task lands (CalDAV-bound → VTODO), but both sources always render when present.**
- **Checklist render mode**: a container with `metadata.projax.render='checklist'` renders tasks as a dense checkbox list (no per-row chrome) vs the default roomier task rows.
- **Tree / graph / tiles**: filter to `type=['project']` so tasks don't clutter the project DAG. `Item.Kind` already distinguishes — a one-line predicate in each renderer. (If Q6 picks the dedicated-edge alternative, this filtering is unnecessary.)
- **Dashboard / timeline**: task rollups + the chronological spine consume the uniform shape, so mBrian-native tasks appear alongside CalDAV ones with no per-surface special-casing.
- **Task creation**: on a project detail page, "add task" creates a `type=['task']` node `child_of` the project (mBrian-native projects) or a VTODO (CalDAV-bound) — the backend selector (§3.1) decides which, transparently.
---
## §6 — Write-API needs (cross-repo with mBrian/head)
mBrian owns the `/api/projax` write surface + `db.ts`. Phase 7's mBrian-native tasks need **one** real API addition; everything else is already supported.
| need | status |
|---|---|
| **Create a `type=['task']` node** | **NEW ASK.** Today `POST /api/projax/nodes` *forces* `type=['project']` (slice-C contract §2). Ask: accept an optional `type` field allowlisted to `{project, task}` (default `project`; reject anything else to preserve the projax-scoping model). Areas keep `type=['project']`+`metadata.projax.kind='area'`, so no `area` type needed. |
| Task done-toggle | **No ask**`PATCH /api/projax/nodes/{id}` already shallow-merges `projax:{status:'done'}`. |
| Task due / order / priority | **No ask** — same PATCH-projax partial (`metadata.projax.{due,order,priority}`). |
| Task→project / subtask→task edges | **No ask**`POST /api/projax/edges` already allows `rel='child_of'`. |
| Task slug control | **Confirmed-incoming** — head has already opened the explicit-slug fix with mBrian/head (slug on `POST`+`PATCH`); tasks need user-meaningful slugs, so this design assumes slug-control is restored (do not design around the generate-from-title bug). |
| Reader picks up tasks | **No ask** — task nodes carry `metadata.projax_origin` (created via the API), so `MBrianReader`'s `projax_origin` scoping already includes them; `Item.Kind=['task']` flows through. |
**Net cross-repo ask: one field** (`type` on `POST /api/projax/nodes`). Batched to head with the open questions.
---
## §7 — Migration of existing CalDAV-task usage
- **CalDAV-bound projects**: zero migration. Their tasks are VTODOs and keep working through the existing surface; Phase 7 only re-labels them "CalDAV-backed."
- **Non-CalDAV projects**: gain the ability to hold mBrian-native tasks. No data to migrate — there are no mBrian task nodes today (verified: 0 `task`-type nodes).
- **PRD reconciliation**: PRD §2.1's "tasks live outside projax" is amended — tasks are now first-class for non-CalDAV projects, external (CalDAV/Gitea) for the rest. Phase 7 extends, doesn't contradict: the aggregation surfaces (dashboard/timeline) still consume external tasks; they additionally consume mBrian-native ones. A short PRD addendum captures this.
- **No `projax.items` involvement**: tasks are mBrian-only from day one (no legacy projax.items.task rows ever existed). Slice E's table drop is unaffected.
---
## §8 — Implementation slicing
Depends on the §6 cross-repo `type`-field ask landing first (gates A).
- **A — task read adapter + uniform shape**: define the `Task` shape; the reader materialises `type=['task']` nodes; the detail Tasks section renders CalDAV + mBrian tasks uniformly per the §3.1 selector. (Read-only mBrian tasks first — mirrors the slice-B-before-C discipline.)
- **B — mBrian task write path**: create / done-toggle / edit / delete mBrian-native tasks via `/api/projax` (node POST with `type=task`, PATCH `status`/`due`, edge POST for `child_of`, node DELETE). Reuses the slice-C `MBrianWriter`.
- **C — checklist render mode + sub-tasks**: `metadata.projax.render='checklist'`, task-under-task nesting, compact render.
- **D — ordering / drag-reorder**: `metadata.projax.order` + drag affordance (only if Q5 wants it in v1).
- **E — tree/graph/tiles type-filter**: exclude `type=['task']` from project surfaces (skip if Q6 picks the dedicated-edge model).
Slice A is the cheapest win and de-risks the rest. B is the bulk. C/D/E are additive polish.
---
## §9 — Open questions
Batched to **projax/head** (not chip-asked to m — projax routes all forks through head). Inventor pick listed first.
1. **Tasklist model** — one container concept (project/task) + `metadata.projax.render='checklist'` render hint **[pick]**, or a dedicated `type=['tasklist']`? Pick keeps the type vocabulary at two and satisfies "checklists too" via render mode.
2. **Task done-state** — reuse the existing `status` lifecycle (`done`) **[pick]**, or a separate `metadata.projax.done` bool? Pick adds zero fields/API.
3. **Hybrid selector**`caldav-list` link presence alone decides the backend **[pick]**, or does `management` ALSO gate (e.g. force mBrian-native for `self`, suppress authoring on `external`)?
4. **CalDAV-bound project that also has mBrian task nodes** — CalDAV wins for the Tasks section, mBrian tasks hidden there **[pick]**; or show both (two sub-sections); or forbid creating mBrian tasks under a CalDAV-bound project?
5. **Manual task ordering in v1** — ship `metadata.projax.order` + drag-reorder now, or **`created_at` order only and defer drag [pick]** (simpler v1; reorder is additive)?
6. **Task attachment edge**`child_of` + render-filter tasks out of project surfaces **[pick]**, or a dedicated task edge for hard separation from the project DAG? Pick reuses everything; the alternative is cleaner separation at the cost of a new rel + reader change.
**Cross-repo ask (to mBrian/head via head, not m):** extend `POST /api/projax/nodes` to accept `type ∈ {project, task}` (§6) — the one new API field Phase 7 needs.
---
## §10 — Out of scope (Phase 7)
- Recurring tasks (RRULE) — CalDAV app territory; mBrian-native recurrence deferred.
- Task dependencies / blocked-by graph — possible later via a `projax-blocks` edge; not now.
- Gitea issues as first-class tasks — issues stay aggregated (read/writeback) as today, not converted to task nodes.
- Bidirectional CalDAV↔mBrian task mirroring — a project is one backend or the other, never synced both ways.
- Reminders / notifications — Otto-PWA's domain.

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

@@ -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

@@ -46,12 +46,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 +76,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 +89,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 +109,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 +136,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 +149,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 +162,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 +181,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 +191,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 +204,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 +216,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 +236,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 +716,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 +737,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 +755,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 +780,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 +791,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 +801,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 +825,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 +842,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 +857,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,
@@ -874,14 +880,14 @@ func createItemTool(st *store.Store) ToolHandler {
}
}
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 +898,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 +923,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 +1002,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,10 +1021,10 @@ 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)
}
@@ -1028,7 +1034,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 +1045,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 +1058,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 +1069,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 +1098,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 +1117,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 +1138,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 +1146,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 +1158,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 +1167,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 +1180,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 +1200,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 +1210,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 +1241,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)

1014
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)
}
}

705
store/mbrian_writer.go Normal file
View File

@@ -0,0 +1,705 @@
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))
}
// 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),
}
var resp nodeWriteResponse
if err := w.do(ctx, http.MethodPost, "/api/projax/nodes", body, &resp); err != nil {
return nil, 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),
}
var resp nodeWriteResponse
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, &resp); err != nil {
return nil, 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,
}
}
// 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)
}

268
store/mbrian_writer_test.go Normal file
View File

@@ -0,0 +1,268 @@
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)
}
}
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

@@ -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
@@ -541,7 +541,7 @@ func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it *
// 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)
caldavLinks, lerr := s.Items.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if lerr != nil {
s.Logger.Warn("tasks-section caldav links", "path", it.PrimaryPath(), "err", lerr)
}

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")

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

@@ -75,7 +75,20 @@ 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)
@@ -133,6 +146,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 +169,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"} {
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 +206,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,
@@ -348,7 +380,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 +399,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,9 +429,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)
// /views routes land in slice B (paliad-shape: GET /views, GET
// /views/{slug}, GET /views/new, GET /views/{slug}/edit, plus POST CRUD).
// Between slice A and slice B these URLs 404 by design.
// 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("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)
@@ -428,16 +483,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
@@ -456,7 +510,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
// 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).
@@ -464,7 +521,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,
@@ -476,10 +533,10 @@ 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,
@@ -504,7 +561,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
}
@@ -529,11 +586,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
}
}
@@ -557,7 +614,7 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
// 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)
caldavLinks, lerr := s.Items.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if lerr != nil {
s.Logger.Warn("detail caldav links", "path", it.PrimaryPath(), "err", lerr)
}
@@ -575,7 +632,7 @@ 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)
}
@@ -630,7 +687,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
@@ -656,7 +713,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)
@@ -686,7 +743,7 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
// keeps only the known kinds so a stray value can't poison the array.
TimelineExclude: parseTimelineExcludeList(r.Form["timeline_exclude"]),
}
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)
return
@@ -698,7 +755,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
@@ -727,13 +784,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
@@ -853,7 +910,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
@@ -903,7 +960,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)
@@ -919,7 +976,7 @@ 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)
return
@@ -928,7 +985,7 @@ func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
}
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
@@ -954,7 +1011,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
}
@@ -1004,6 +1061,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")
}

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;

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)
}
}

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

@@ -122,7 +122,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 +133,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>

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

@@ -42,38 +42,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 +89,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 +143,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 +163,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 +177,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

@@ -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

@@ -3,7 +3,7 @@
<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">
@@ -93,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

@@ -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

@@ -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

@@ -479,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
@@ -497,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),
})
@@ -506,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),
})
@@ -515,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),
})
@@ -524,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),
})
@@ -533,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

@@ -1,10 +1,468 @@
package web
// Phase 5j Slice A — paliad-shape redesign. The 5i overlay handlers
// (handleViewsIndex / handleViewCreate / handleViewWrite / handleViewEdit
// / handleViewRedirect / applySavedView / applyDefaultView / friends)
// are deleted here. The new /views/{slug} route family lands in slice B;
// system-view migration lands in slice C.
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/m/projax/store"
)
// Phase 5j paliad-shape views handlers. Slice B introduces the route
// family; slices CG evolve the render, editor, system-views, sidebar,
// and polish layers.
//
// Between slices A and B the /views URLs return 404 — by design, no real
// user data was on the old shape (hours-old after the 5i ship).
// 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)
// 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
}
s.render(w, r, "views_landing", map[string]any{
"Title": "views",
"Views": views,
})
}
// 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)
return
}
in, err := viewInputFromForm(r.PostForm)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
v, err := s.Store.CreateView(r.Context(), in)
if err != nil {
s.writeViewError(w, err)
return
}
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
}
// 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
}
in, err := viewInputFromForm(r.PostForm)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
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
}
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/views", http.StatusSeeOther)
}
// 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
}
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)
}
// 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{
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",
}
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
}
// 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{
"view_type": viewType,
}
if f.Q != "" {
payload["q"] = f.Q
}
if len(f.Tags) > 0 {
payload["tags"] = f.Tags
}
if len(f.Management) > 0 {
payload["management"] = f.Management
}
if !(len(f.Status) == 1 && f.Status[0] == "active") {
payload["status"] = f.Status
}
if len(f.HasLinks) > 0 {
payload["has_links"] = f.HasLinks
}
if f.Public != nil {
payload["public"] = *f.Public
}
if f.ShowArchived {
payload["show_archived"] = true
}
if f.ProjectPath != "" {
payload["project_path"] = f.ProjectPath
if !f.IncludeDescendants {
payload["include_descendants"] = false
}
}
return json.Marshal(payload)
}
// 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,
}
viewType := ""
groupBy := ""
if len(filterJSON) == 0 {
return f, viewType, groupBy
}
payload := map[string]any{}
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"}
}
}
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
}
// 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
}
if _, ok := q["tag"]; ok {
base.Tags = urlFilter.Tags
}
if _, ok := q["mgmt"]; ok {
base.Management = urlFilter.Management
}
if _, ok := q["status"]; ok {
base.Status = urlFilter.Status
}
if _, ok := q["has"]; ok {
base.HasLinks = urlFilter.HasLinks
}
if q.Get("show-archived") != "" {
base.ShowArchived = urlFilter.ShowArchived
}
if q.Get("public") != "" {
base.Public = urlFilter.Public
}
if q.Get("project") != "" {
base.ProjectPath = urlFilter.ProjectPath
}
if q.Get("project_descendants") != "" {
base.IncludeDescendants = urlFilter.IncludeDescendants
}
}
func anySliceToStrings(in []any) []string {
out := make([]string, 0, len(in))
for _, v := range in {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}

197
web/views_test.go Normal file
View File

@@ -0,0 +1,197 @@
package web_test
import (
"context"
"net/url"
"strings"
"testing"
"time"
)
// 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()
// 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)
}
code, body := get(t, h, "/views")
if code != 200 {
t.Fatalf("GET /views status=%d body=%q", code, body)
}
if !strings.Contains(body, "No saved views yet") {
t.Error("onboarding shell should surface the no-views nudge")
}
if !strings.Contains(body, `href="/views/new"`) {
t.Error("onboarding shell should link to /views/new")
}
}
// 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()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
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)
}
}
// 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)
}
code, body := get(t, h, "/views/"+slug)
if code != 200 {
t.Fatalf("GET /views/<slug> status=%d body=%q", code, body)
}
if !strings.Contains(body, "P5j B Render") {
t.Error("render should surface the view's name")
}
if !strings.Contains(body, `/views/`+slug) {
t.Error("render should surface the view's slug in the header")
}
if !strings.Contains(body, `class="tree-card-grid"`) {
t.Error("view_type=card should render the card grid")
}
}
// 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"), ".", "")
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 {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil {
t.Fatalf("home: %v", err)
}
var devID, homeID string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
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[], '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)
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 + `"`
_, base := get(t, h, "/views/"+slug)
if !strings.Contains(base, devLink) {
t.Error("saved view without tag should show dev row")
}
if !strings.Contains(base, homeLink) {
t.Error("saved view without tag should show home row")
}
_, 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(narrowed, homeLink) {
t.Error("URL chip tag=work should hide home")
}
}