Commit Graph

189 Commits

Author SHA1 Message Date
mAi
ef507b4e1b feat(web): Phase 7c STEP 2 — unify mBrian + CalDAV tasks into ONE list
Supersedes the Phase 7b two-section split (m: 'tasks section should collect
from mBrian AS WELL AS CalDAV and display together'). The detail page now
renders a single merged task list per project:

- buildUnifiedTasks merges mBrian-native tasks (TasksForItem) + CalDAV VTODOs
  (detailTodos → taskFromTodo) into one open/done split via the uniform
  store.Task shape. Each row carries a subtle source label (calendar name for
  CalDAV, 'projax' for mBrian). Sorted: dated-before-undated, earlier due
  first, then created, then title.
- Row actions dispatch by Source: CalDAV rows POST /caldav/todo/{action}
  (calendar_url+uid, ETag writeback); mBrian rows POST /task/{action}
  (node_id). One template, branch on .Source.
- ONE add-form, backend by §3.1 selector (unifiedTasks.AddTarget): CalDAV on a
  bound project (hidden calendar_url for a single list, a <select> when
  several), mBrian-native on an unbound project. New-task default per m's spec.
- Both handlers (handleCalDAVTodoAction + handleTaskAction) now re-render the
  SAME merged fragment via renderUnifiedTasks, so a write from either backend
  refreshes the unified list in place.
- Retired the two-section split: deleted mbrian_tasks_section.tmpl + its
  registration/render-case, rewrote tasks_section.tmpl as the unified list,
  removed renderTasksSection. CalDAV link/create-list affordances preserved.

Unit-tested: sortTaskRows (merge order), AddTarget (backend selection across
bound/unbound × single/multi-calendar). Updated TestDetailLinkExistingCalendar
to the unified UI (no per-calendar block; bound project → add-form targets the
linked calendar, create-new hidden). caldav/gitea/mcp/internal green; the 8
remaining web failures are the pre-existing TestProjectFilter*/TestTimeline*
route-drift (fail on 6436b52).
2026-06-01 18:27:49 +02:00
mAi
728788225c fix(web): load HTMX so task (+tree/dashboard/bulk/classify) forms work
ROOT CAUSE (head-diagnosed, confirmed): projax templates use hx-post/hx-get/
hx-target/hx-swap across the task, tree, dashboard, bulk and classify forms,
but HTMX was NEVER loaded — layout.tmpl had only inline <script> blocks, no
htmx <script src>. So every hx-post form silently no-op'd to a GET-to-self
(logs: GET /i/social.mama on each 'Add' click). m hit it as 'can't add Tasks
(projax), nothing happens'. Both the mBrian AND the older CalDAV task forms
were dead. The style.css even comments 'HTMX-driven' — the script was just
never wired.

FIX: vendor htmx 1.9.12 into web/static (Tailscale-only app → vendored over
CDN, matches the go:embed asset model) + one deferred <script> in layout
<head>. htmx only intercepts hx-* elements, so the existing plain
method=post forms are untouched. The task handlers already return section
fragments for hx-swap, so the flow just works once htmx is present. Added
htmx.min.js to the service-worker shell precache (CACHE_NAME v1→v2).

The server-side write path was already proven green (TestMBrianTaskRoundTrip
PASS with prod creds) — the bug was purely the client form never POSTing.
Loading htmx closes that gap.

Regression guard: TestLayoutLoadsHTMX asserts the script ships in the layout
so this can't silently recur.
2026-06-01 18:19:53 +02:00
mAi
b72744b567 Merge branch 'mai/kahn/phase-7b-tasks' (Phase 7b: first-class mBrian tasks + CalDAV hybrid two-section + checklist render) 2026-06-01 18:01:40 +02:00
mAi
63a2e13036 test(store): Phase 7b — live end-to-end task round-trip integration test
Env-guarded (skips unless PROJAX_MBRIAN_API_URL + PROJAX_MBRIAN_API_TOKEN +
SUPABASE_DATABASE_URL are set) so it runs in CI / by head with the prod token
but is inert locally. Drives the full mBrian-native task lifecycle against the
LIVE write API + read DB:

  create throwaway parent project → CreateTask (slug auto-derived, due set) →
  TasksForItem asserts present+open+due → asserts the task does NOT leak into
  ListAll (Q6 exclusion) → SetTaskStatus done → SetTaskDue nil (clear) →
  DeleteTask → asserts gone. Self-cleaning (soft-deletes task + parent).

This is the deploy-verification gate for 'tasks live end-to-end'.
2026-06-01 17:59:20 +02:00
mAi
e65385e609 feat(web): Phase 7b — mBrian-native tasks on the detail page (two-section, Q4b)
Wires the store task layer into the UI:

- web/task.go: handleTaskAction (create/done/reopen/edit/due/delete) routed
  from handleDetailWrite at /i/{path}/task/{action}; every existing-task
  mutation verifies the node belongs to the item (taskBelongsTo guard, mirrors
  the CalDAV per-item calendar guard). taskBackend() type-asserts the active
  Items/Writes to TaskReader/TaskWriter (nil on the legacy backend → section
  omitted).
- mbrian_tasks_section.tmpl: a SECOND tasks sub-section rendered alongside the
  existing CalDAV one (Q4b — both sources shown, never hide mBrian). The 'add
  task' form appears only on mBrian-native projects (no caldav-list link, §3.1);
  existing tasks stay toggle/editable on any project. HTMX in-place swaps like
  the CalDAV section. Tasks auto-derive their slug from the title (lightweight
  UX); the explicit-slug + 409 machinery stays available via the writer.
- Checklist render: 'render tasks as checklist' flag on the edit form →
  metadata.projax.render; the section renders compact when set (Q1).
- taskFromTodo: CalDAV VTODO → uniform store.Task mapper (the §3.3 'two
  sources, one shape' contract; the detail page keeps the richer CalDAV
  rendering for ETag writeback, so the uniform-rollup consumer is a follow-up).

Unit-tested: taskFromTodo (done-state mapping, due, handle), renderHint.

NOTE to head: pre-existing web test failures (TestProjectFilter*, TestTimeline*
project-filter/timeline-route tests) fail identically on 6436b52 — they
reference the old /timeline route that now 301s to /views/timeline. Not from
this work; flagging as separate tech debt.
2026-06-01 17:56:04 +02:00
mAi
0dfa0e2ab7 feat(store): Phase 7b — uniform Task shape + mBrian-native task read/write
Adds the first-class task layer the Phase 7 design (docs/plans/phase-7-entity-model.md)
calls for, on the mBrian backend:

- store.Task: one uniform view-shape, two sources (CalDAV VTODO | mBrian
  type=['task'] node); TaskReader/TaskWriter capability interfaces (separate
  from ItemReader/ItemWriter — only the mBrian backend has task nodes).
- MBrianReader.TasksForItem: materialises type=['task'] child_of nodes into
  Task, created-at order (Q5).
- Task exclusion at the reader chokepoint: nodeIsTask filters tasks out of
  ListAll/Roots/MaiOrphans/Search/AllTags, so they never leak into ANY project
  surface (tree/graph/dashboard/calendar/timeline + MCP list/tree, which all
  funnel through ListAll/ListByFilters) — implements Q6's intent in one place
  instead of per-surface. GetByID/GetByPath still resolve a task node.
- MBrianWriter task writes: CreateTask (POST type:'task' + slug + child_of
  edge), SetTaskStatus (done=status, Q2), SetTaskDue, EditTaskTitle, DeleteTask.
  Uses the now-live POST /api/projax/nodes 'type' field.
- Item.Render (metadata.projax.render) + RendersChecklist() for compact
  checklist mode (Q1); round-tripped via UpdateInput.Render.

Unit-tested: nodeIsTask, taskFromNode, dueToJSON, RendersChecklist.
Pre-existing TestParityListAll drift (store=65 vs mbrian=66) is unrelated and
tracked separately by head.
2026-06-01 17:50:48 +02:00
mAi
6436b524d6 Merge branch 'mai/kahn/fix-slug-control' (restore projax-side slug control: explicit slug on create+rename via mBrian API, surface 409) 2026-06-01 17:36:59 +02:00
mAi
eaecd3944e fix(slug): restore projax-side slug control via mBrian's slug API
mBrian's write API now honors an explicit slug (POST + PATCH), live on
mbrian.x.msbls.de (m/mBrian#73 issuecomment-10827/10830). Wire the
projax side so m can set + rename slugs again (he hit the generate-from-
title bug for real):

- MBrianWriter.Create: send the explicit slug in the POST body (was
  omitted → mBrian title-derived). Slug is required on the create paths.
- MBrianWriter.Update: send slug ONLY on a genuine rename — read the
  current slug and include it just when it changed, so ordinary edits and
  bulk actions (which carry the unchanged slug in UpdateInput) don't trip
  mBrian's rename cascade (wikilink rewrite + alias append) for nothing.
- mapSlugWriteErr: 409 → store.ErrSlugTaken, 400 → store.ErrInvalidSlug,
  keeping the server's *APIError (message) in the chain. 409 covers a
  soft-deleted tombstone squatting the slug — which the projax-side
  validator can't see (it scopes to live nodes).
- Surface cleanly, never silent: web create/edit forms render the
  friendly itemwrite banners (KindSlugCollision / KindInvalidSlugFormat)
  via s.writeFailure; MCP create/update return typed validation tool
  errors via slugAwareToolError.

Tests: Create sends explicit slug + 409→ErrSlugTaken (httptest, no DB);
mapSlugWriteErr table (409/400/403/500/non-APIError). Build + vet green;
web write tests + mcp green. Rename round-trip (slug→aliases) + live
collision message are head's post-deploy live-verify (needs the prod
token).

Standalone on mai/kahn/fix-slug-control off main so head can merge to
prod immediately — m's waiting on slug control.
2026-06-01 17:35:13 +02:00
mAi
7c84c96f8b Merge branch 'mai/kahn/phase-6-sliceC' (Phase 6 Slice C: write-path to mBrian via scoped HTTP API; PROJAX_BACKEND flips reads+writes atomically) 2026-06-01 12:40:13 +02:00
mAi
e133f51706 fix(store): Phase 6 Slice C — reader reads pinned/archived + note from metadata (G3, G2)
Two pre-cutover reader tweaks head approved (projax-local, no mBrian
change) so writes through the scoped HTTP API round-trip cleanly:

- G3 (required): itemFromNode reads pinned/archived from metadata.projax
  with a fallback to the nodes.pinned/archived columns —
  COALESCE(metadata.projax.X, column). MBrianWriter writes them to
  metadata.projax (PATCH can't set the columns); this makes the dashboard
  star + archive toggle round-trip while preserving any pin state the
  migration wrote to the columns.
- G2: linkFromEdge reads ItemLink.Note as COALESCE(edge.note,
  metadata.note) — migrated notes are in the edge.note column, post-cutover
  AddLinkDated notes land in metadata.note; both now surface. note is also
  dropped from consumer metadata to match *Store (whose note lives in the
  column, never metadata).

Both parity-safe for current data (migrated nodes carry no
metadata.projax.pinned; 0 note-bearing edges) — store parity tests stay
green. Added pure-function unit tests for both COALESCE behaviours.

G1 (multi-link same ref_type) deferred per head — 0 cases, safe-refuse
behaviour stands.
2026-06-01 12:38:54 +02:00
mAi
663f21bdb0 docs: Phase 6 Slice C write-path contract + API gap register
Documents the write mechanism (scoped HTTP API), the ItemWriter interface
+ per-method mBrian mapping, the validator + bulk + MCP changes, and the
four known API gaps (G1 edge identity / G2 edge note / G3 pinned-archived /
G4 create metadata) for head to reconcile with mBrian before relying on
them. Referenced from store/adapter.go + store/mbrian_writer.go.
2026-06-01 12:34:40 +02:00
mAi
bc56733bc8 feat(mcp+cmd): Phase 6 Slice C — atomic PROJAX_BACKEND flip across web + MCP
- cmd/projax/main.go: PROJAX_BACKEND=mbrian now sets BOTH srv.Items
  (reader) AND srv.Writes (writer=MBrianWriter HTTP client, reading
  PROJAX_MBRIAN_API_URL/PROJAX_MBRIAN_API_TOKEN). =store sets both to the
  legacy *Store. The flip is atomic — the slice-B half-flip (reader only)
  was the production bug. Warns (not exits) if mbrian is selected without
  the API env vars: reads work direct-DB, writes fail closed legibly.

- mcp/tools.go: RegisterProjaxTools split from a single *Store into
  (reader, writer, legacy *Store, agg). Read tools take the reader, write
  tools take reader+writer, both flipping with the backend. Leaving MCP
  reads on projax.items while writes targeted mBrian would recreate the
  slice-B bug on the MCP surface (read an id from one backend, write it to
  the other). The timeline tool keeps the legacy *Store + aggregator
  (out of slice-C scope, consistent with the web dashboard). main.go
  passes srv.Items/srv.Writes so MCP follows the same flip as the web UI.

  NOTE: this widens slice C beyond the handover's 'MCP reads deferred' —
  necessary because migrating MCP writes alone is incoherent with
  atomicity. Flagged to head.

- store/mbrian_writer_test.go: httptest-backed unit tests for request
  construction + error mapping (401/403/404→ErrNotFound/503/500/400),
  fail-closed on empty token/URL (no empty Bearer sent), AddLink self-edge
  + metadata shaping, AddLinkDated date+note, per-ref_type edge metadata,
  projax bundle defaults + public nesting, uuid v4 format. Pool-backed
  read-backs (Create/Update round-trip) are covered by head's live cutover
  test + the reader parity tests.

Build + vet green. store/mcp/itemwrite tests pass in isolation.
2026-06-01 12:33:46 +02:00
mAi
e43055b670 feat(store): Phase 6 Slice C — MBrianWriter HTTP client against the scoped write API
Full implementation of the write adapter as an HTTP client over mBrian's
/api/projax/* surface (final spec, m/mBrian#73 issuecomment-10720):

- do(): JSON request/response plumbing; bearer auth from the configured
  token; fails closed (503-style) on empty URL/token rather than sending
  an empty Bearer; maps non-2xx to *APIError (401/403/404/500/503) and
  wraps 404 as ErrNotFound. {error} message extracted from the body.
- Create: POST node (mints a fresh projax_origin uuid via crypto/rand —
  no uuid dep), writes child_of parent edges, materialises via the reader
  so the returned Item.ID is the live mBrian uuid + derived path. This is
  the create-child round-trip the slice-B half-flip broke.
- Update: PATCH node fields + syncParents() edge diff; read-back.
- Reparent/AddParent: child_of edge diff / idempotent add.
- SetPublic: read-modify-write the full public bundle (PATCH replaces the
  whole projax.public object on shallow-merge).
- SoftDeleteCascade: projax-side descendant resolution via the reader's
  derived paths, then per-node soft-delete (HTTP API is single-node).
- AddLink/AddLinkDated/DeleteLink: projax-* self-edges; edge metadata
  shaped so the reader's linkFromEdge round-trips (typed payload +
  projax_rel + ref_id + event_date + note). DeleteLink resolves the edge's
  (source,target,rel) from its id, with a guard that refuses to delete
  when >1 edge shares the tuple.

Documented four API gaps inline + in the file header for head to
reconcile before relying on them (verified current data hits none of the
active ones): G1 edge identity collapses multiple same-ref_type links per
item (latent — 0 in current data); G2 POST /edges has no note field;
G3 PATCH can't set pinned/archived node columns (captured in
metadata.projax, pending reader fallback); G4 no top-level metadata
passthrough on create.

Build + vet green. Writer is wired but unreachable until main.go flips the
backend (next commit) — no behaviour change yet.
2026-06-01 12:25:00 +02:00
mAi
67577396a2 feat(web): Phase 6 Slice C — route every web write + validator through the adapter
Wire all web-side writes to depend on the interfaces (Server.Writes for
writes, Server.Items for the write-pre-flight reads) instead of the
concrete *Store, so PROJAX_BACKEND will flip them with the reader:

- handleDetailWrite / handleReparent / handleNewSubmit: Update / Reparent /
  Create now go through s.Writes; ValidateAgainstStore now reads s.Items
  (was s.Store) so cycle + collision detection runs against the live
  backend, not stale projax.items.
- dashboard_pin: SetPinned via s.Writes.
- links: AddLinkDated / DeleteLink via s.Writes. linkBelongsToItem now
  resolves ownership through s.Items.LinksByType — a direct
  projax.item_links query would reject every delete under the mBrian
  backend. Dropped the now-dead isNoRows + errors import.
- caldav: all four AddLink + the unlink DeleteLink via s.Writes.
- bulk applyBulk: replaced the raw single-tx multi-row UPDATE with
  interface calls — make_public/private map to SetPublic; the field
  mutations (tags/mgmt/status/timeline-exclude) are read-modify-write via
  Update. Cross-row tx atomicity is dropped (mBrian's HTTP write API has
  no multi-node tx); acceptable at m's bulk-edit scale, one write path
  across both backends. Added updateInputFromItem + appendUnique/removeValue.

- itemwrite: slug uniqueness is now per-user-global (Q6=a, matching
  mBrian's idx_nodes_slug) instead of per-parent. Strictly tighter, so
  still correct on the legacy backend. Test updated to assert the new rule.

Build green. Web suite: only the 8 pre-existing failures remain (4
project_filter + TestTimelineKindMultiValueSurvives + 3 timeline_filter,
all /timeline-301 / seeding issues on main, unrelated to slice C). No new
failures from the rewiring.
2026-06-01 12:18:03 +02:00
mAi
d0ec02cb63 feat(adapter): Phase 6 Slice C scaffold — ItemWriter interface + Server.Writes + MBrianWriter stub
Mechanism-independent groundwork for the write-path migration. Mirrors the
slice-B ItemReader pattern:

- store/adapter.go: ItemWriter interface extracted from *Store's write
  surface (Create/Update/Reparent/AddParent/SetPublic/SetPinned/
  SoftDelete/SoftDeleteCascade + AddLink/AddLinkDated/DeleteLink), with a
  compile-time witness that *Store satisfies it.
- store/mbrian_writer.go: MBrianWriter stub — an HTTP client (NOT a pgx
  writer) against mBrian's scoped /api/projax/* write API per head's
  mechanism call (option c). Bodies return errNotImplementedSliceC until
  the HTTP impl + final spec land. Holds a pool for write-path read-backs.
- web/server.go: Server.Writes field (twin of Items), defaulted to the
  concrete *Store in web.New. main.go will flip Items+Writes atomically —
  the slice-B half-flip (reads mBrian, writes projax.items) was the bug.

No behaviour change: both Items and Writes still resolve to *Store.
2026-06-01 12:11:53 +02:00
mAi
307a898dbd Merge branch 'mai/kahn/phase-6-sliceB' (Phase 6 Slice B: mBrian-backed read path behind PROJAX_BACKEND switch, parity-green) 2026-05-31 22:22:40 +02:00
mAi
b22f50ca7b feat(adapter): Phase 6 Slice B — mBrian-backed read path live
Per t-projax-6-sliceB-readpath. mBrian migration (m/mBrian#73) is live
on msupabase with 65 nodes + 78 child_of + 81 projax-* edges. This
commit makes the projax read path source from there behind an env
switch.

CLIENT ARCH: direct pgxpool against mbrian.* schema (same
SUPABASE_DATABASE_URL the projax binary already uses for projax.*) —
matches flexsiebels/head's cross-coupling pattern. No MCP token
plumbing.

CONTRACT (all three honoured)
- External links are SELF-EDGES (source=target=item, rel='projax-*',
  payload in edges.metadata). linkFromEdge reads the node's outbound
  projax-* edges; ref_id derived per ref_type from metadata (caldav
  url, gitea owner/repo, mai-project mai_project_id).
- Slugs finalised: 'work'/'dania' resolve to mBrian's canonical nodes;
  projax-side squatters (renamed-aside, not deleted) are documented in
  the parity test as legacy-only and skipped from field comparison.
- created_at/updated_at NOT preserved — ItemsCreatedInRange orders off
  metadata.projax.start_time when present, fall back to mBrian
  created_at. Aggregator surfaces (timeline / dashboard) read off
  caldav DTSTART + gitea updated_at, so they're unaffected.

NEW FILES
- store/mbrian.go: MBrianReader concrete impl. Bulk-loads projax-
  managed nodes + child_of edges in one pair of queries per call,
  builds a graphContext in memory, derives Paths via ancestor walk
  (depth-capped at 64 like projax's trigger). Implements every
  ItemReader method.
- store/mbrian_parity_test.go: 5 parity tests against the live db —
  ListAll field equality (skipping the renamed squatter slugs),
  spot-check resolves, caldav-list link round-trip, gitea-repo link
  round-trip, AllTags union, NotFound consistency. All 5 GREEN.
- cmd/projax-remap-views/main.go: one-shot tool to rewrite
  projax.views.filter_json.project_id from old projax uuids to new
  mBrian uuids using the audit map mBrian dropped (head will relay
  the path). Dry-run default; --apply commits. Idempotent.
- docs/plans/slice-b-views-projectid-gap.md: surfaces the gap + the
  remediation path. Must run remap BEFORE slice E drops projax.items.

CHANGES
- store/adapter.go: kept the ItemReader interface + *Store assertion;
  removed the prep stub (replaced by mbrian.go).
- web/server.go: Server.Items store.ItemReader field. web.New defaults
  Items to the concrete *Store (legacy path). main.go overrides to
  MBrianReader when PROJAX_BACKEND=mbrian.
- All read-path call sites in web/ swapped from s.Store.<readMethod>(
  to s.Items.<readMethod>( for the 15 ItemReader methods. MCP tools
  unchanged (separate scope; can pivot in a follow-up). Writes still
  flow through s.Store.
- cmd/projax/main.go: PROJAX_BACKEND env switch with "store" (default)
  and "mbrian" values. Logs the choice at startup. Unknown value
  refuses to start.

SMOKE
- go build ./... green; go vet green.
- go test ./store/ -count=1 — all parity tests pass against live data.
- Local server boot with PROJAX_BACKEND=mbrian — backs binding logs
  "backend=mbrian (read path via store.MBrianReader)" and serves
  /views/tree (auth wall protects deeper smoke; parity tests cover
  that surface).

PRE-EXISTING failure NOT addressed in this commit: 3 timeline_filter
tests in web/ already failed on main (legacy /timeline URL hits the
Phase 5j 301 redirect to /views/timeline). No diff vs main in those
test files; out of scope for slice B.

OUT OF SCOPE FOR SLICE B (deferred):
- MCP read tools migration to ItemReader (separate diff, low risk).
- Aggregator's LinkLister wired to ItemReader (currently consumes
  *Store directly through Server.Aggregator()).
- views.filter_json.project_id remap RUN — tool ships here, run waits
  on the head's relay of the audit-map path.
- Slice C write-path. Slice D mai-bridge worker. Slice E drop.
2026-05-31 22:20:38 +02:00
mAi
4fdeca8269 Merge branch 'mai/kahn/phase-6-sliceB-prep' (Phase 6: slice-B adapter interface contract + skeleton, no impl) 2026-05-29 15:18:15 +02:00
mAi
9607d4b307 docs+skeleton: Phase 6 Slice B prep — read-path adapter interface contract
Per head's parallel-prep brief while m/mBrian#73 (migration script +
[schema] node) is being built mBrian-side. NO mBrian-MCP-backed
implementation yet — the migration worker may refine the landed
node/edge shape and building the impl now risks rework.

Built ONLY the parts stable regardless of mBrian internals:

1. CONSUMER INVENTORY (docs/plans/slice-b-adapter-contract.md §1)
   - Every *store.Store read method (15 methods) with signature + semantics
   - Every call site across web/, internal/aggregate/, mcp/ — table form
   - Item / ItemLink field-by-field shape contract: which fields come
     direct from node columns, which from edge-walk, which from
     metadata-unpack
   - Direct pgxpool access flagged out-of-scope (admin counts, bulk
     tx, links event-date update — slice C reworks those)
   - Views (5j) explicitly NOT in scope per m's Q5=(a)

2. INTERFACE CONTRACT (store/adapter.go)
   - ItemReader Go interface — 15 methods, pure projax-shaped structs
     in/out, zero mBrian type leakage
   - var _ ItemReader = (*Store)(nil) compile-time assertion proving the
     existing pgx-backed *Store satisfies the contract today

3. SKELETON (store/adapter.go MBrianReader)
   - Empty struct (mBrian client choice deferred to slice B impl)
   - All 15 methods stubbed, return errNotImplementedSliceB
   - var _ ItemReader = (*MBrianReader)(nil) keeps the stubs in lockstep
     with the interface as slice B grows
   - Each stub carries a one-line comment naming the §3 gap(s) it
     resolves at impl time
   - `go build ./...` green; `go vet ./store/` green

4. GAP FLAGS (docs/plans/slice-b-adapter-contract.md §3)
   - item_links.rel free-form annotation → mBrian edge.note (add to
     m/mBrian#73 §1 for the migration script)
   - ItemLink.RefID per-rel-type extraction rule (caldav URL vs gitea
     owner/repo vs mai project uuid)
   - paths[] recomputation cost (per-request memoisation)
   - AllTags aggregation (full-scan ok at m's scale; tag-graph deferred
     per m's Q8)
   - Roots / MaiOrphans "no outbound child_of edge" predicate
   - ItemsCreatedInRange scoped to projax_origin marker
   - Item.Source / SourceRefID constant + mai-edge-derived fields
   - ItemLinkWithItem join shape (two queries + in-memory join vs bulk
     MCP helper)
   - Admin counts — recommend adding Counts(ctx) to ItemReader for cohesion

Stays parked after this. Slice B IMPL (mBrian-MCP client wiring + per-
method bodies + handler rename from s.Store.X to s.Items.X) waits on
the migration completing and uuid map landing.
2026-05-29 15:17:24 +02:00
mAi
38182df651 Merge branch 'mai/kahn/phase-6a-mbrian-design' (Phase 6: mBrian-backend migration design + slice 0 snapshot helper) 2026-05-29 14:03:27 +02:00
mAi
2702c699d1 feat(snapshot): Phase 6 slice 0 — projax_snapshot.json export helper
Read-only export of projax.items + projax.item_links to a JSON file the
mBrian-side migration script (m/mBrian#73) consumes. First implementation
slice of the Phase 6 mBrian-backend migration.

Tool:
- cmd/projax-snapshot/main.go: standalone binary, takes --out flag
  (default ./projax_snapshot.json). Reads PROJAX_DB_URL or
  SUPABASE_DATABASE_URL like the main projax binary.
- Pure read-only: SELECT FROM projax.items WHERE deleted_at IS NULL
  + SELECT FROM projax.item_links. No writes, no schema changes.
- Re-runnable: each invocation produces a fresh deterministic file;
  no state, no DB side effects.

Output shape (Snapshot struct):
- version: "1" — bumped on shape changes for downstream version-pinning.
- generated_at: timestamp.
- items: every live projax.items row with all columns mapped 1:1 to
  JSON-friendly types (uuid → string, jsonb → map, timestamptz →
  RFC3339). Empty slices coerced to [] so the mBrian-side script doesn't
  see null-array surprises.
- links: every projax.item_links row, ordered by item_id + ref_type
  for stable diffs across runs.
- spot_checks: the 5 representative items the mBrian-side script
  verifies post-migration per m/mBrian#73 §3. Selected at runtime by
  characteristic (root area, single-parent, multi-parent, caldav-linked,
  public-listing-populated) so the picks self-update as the dataset
  evolves.

Smoke-tested against the live msupabase dataset:
  wrote /tmp/projax_snapshot.json — 65 items, 81 links, 5 spot-checks

Selected spot-checks (live):
  dev      — root area
  paliad   — single-parent project
  services — multi-parent (2 parents)
  mhome    — caldav-list-linked
  fdbck    — public-listing populated

Out of scope (slices B+ pick up):
- The mBrian-side script itself lives in m/mBrian per "mbrian must own
  the migration" (Q4=(a)).
- projax-side adapter rewriting waits on the mBrian-side migration run.
- No tests yet: this is a one-off helper against live data; smoke run
  above is the validation surface. A go-test suite can land if the
  snapshot shape needs evolution before mBrian-side consumes it.
2026-05-29 14:02:16 +02:00
mAi
a5b0971b9d docs: Phase 6 plan re-baseline against live mBrian schema + m's answers
m answered all 11 §10 questions; every inventor pick confirmed.
m's overriding directive: "keep the database simple so it remains
easily modifiable."

Head verified the live mBrian schema after m's answers — original §3
was built off stale db/001_initial_schema.sql. Three of the six asks
turned out already-satisfied:

- MB-A (edges.metadata jsonb) — already added in db/010, GIN-indexed,
  used by migs 039/040. Drop the ask.
- MB-C (project type) — already in live schema, mig 033 confirms.
  Drop the ask.
- MB-D (per-user slug uniqueness) — already enforced by idx_nodes_slug
  in db/001. Drop the ask.

Plus 'area' as a separate mBrian type is killed per m's "keep it
simple": areas reuse type=['project'] with metadata.projax.kind='area'.
Zero DDL.

Remaining mBrian-side artifact compresses to ONE [schema] convention
node under a new [topic] projax-integration hub, plus mBrian-side
ownership of the one-shot data-migration script (per m's "mbrian must
own the migration").

Re-sequenced §8: six slices.
  0 (projax snapshot helper) → A (mBrian [schema] node + script run)
  → B (projax read-path adapter) → C (projax write-path)
  → D (mai bridge worker) → E (drop projax tables).

CalDAV/Gitea integrations stay where they are (m's Q3=(a)). No slice
F needed in the original sense.

§2 + §2.1 + §7 + §9 + §10 + §14 updated. §3 fully rewritten.

No code changes; this branch ships docs only. Slice 0 is the smallest
first projax-side step but waits for head's greenlight after the
m/mBrian issue is filed.
2026-05-29 13:56:50 +02:00
mAi
b3e7183478 docs: Phase 6 mBrian-as-backend migration design plan
m's decision on issue m/projax#5 (2026-05-29): Option A — full backend
migration to mBrian. mBrian becomes the canonical store for projax
data; projax UI surfaces stay (Tiles dashboard, calendar grid,
timeline spine, the just-shipped 5j /views routes) but read+write
goes through mBrian instead of projax.items.

The plan covers:
- §1 diagnosis: closing the parallel-knowledge-surface gap
- §2 column-by-column schema mapping (projax.items → mBrian nodes +
  metadata, projax.item_links → mBrian edges + new edges.metadata)
- §3 mBrian-side requirements: schema fragments to add (edges.metadata
  column, projax edge relations + types schema-nodes)
- §4 read-path replacement: store adapter over mBrian, UI shape stable
- §5 write-path replacement: every handler + MCP write rewired
- §6 integrations disposition: CalDAV/Gitea stay projax-handled at
  consumption; mai.projects sync moves to a handler-layer bridge
- §7 migration mechanics: hard-cut script per m's loss tolerance
- §8 six-slice plan: A (mBrian schema) → B (data migration) →
  C (read-path) → D (write-path) → E (drop projax tables) → F
  (integrations)
- §9 cross-repo coordination protocol via otto/head (no mBrian/head
  worker exists today)
- §10 eleven open questions for m, batched for head delegation
- §11 risk register
- §12 test plan headlines

Slice A is mBrian-side and is the hard gate — projax B–F cannot start
until mBrian's schema fragments land. Cross-repo coordination request
filed alongside the m delegation.

No code changes; this branch ships docs only. Coder shifts wait on
m's sign-off on §10 + mBrian-side slice A.
2026-05-29 12:49:48 +02:00
mAi
a44edf3917 Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice G: show_count badges + icon registry) 2026-05-29 12:08:01 +02:00
mAi
9a8ea8f31e feat(views): Phase 5j slice G — show_count badges + icon registry
Per m's v1 picks (2026-05-29):
- Q6 (icon picker): yes, with curated keys + SVG registry.
- Q8 (show_count badge): yes, opt-in checkbox + sidebar badge.

Icon registry (web/icons.go):
- 7 curated keys: folder (default), clock, star, tag, inbox, box,
  file-text. Each maps to a Feather-style 24x24 SVG matching the rest
  of the projax sidebar aesthetic. Returns template.HTML so layout.tmpl
  emits markup verbatim. Unknown / nil keys fall back to folder.
- RenderViewIcon(*string) is template-callable; IconRegistryKeys()
  feeds the editor's <select>.
- Funcs map in web/server.go gains a "renderIcon" entry.

show_count badge (web/server.go + web/templates/layout.tmpl):
- render() now computes per-saved-view counts when ANY view in the
  list has ShowCount=true. One ListAll per render, shared across all
  show-count views; for each opted-in view the persisted filter_json
  is decoded into a TreeFilter and matched against every item.
- Counts pass to the template as UserViewCounts (slug → count). The
  template renders {{index $counts $slug}} inside a nav-badge span
  next to the view's name.

Template updates:
- layout.tmpl: replaces the diamond-glyph placeholder with
  {{renderIcon .Icon}}; show_count views emit a .nav-badge next to
  their name.
- view_editor.tmpl: icon <select> now sourced from IconKeys data
  (the editor handler passes IconRegistryKeys()).

CSS additions:
- nav-badge: muted-color, surface-background, pill-shaped, pushed to
  the right via margin-left:auto so the badge aligns with the row's
  end regardless of name length.
- nav-item-user-view.active .nav-badge: switches to accent border +
  color so the active row's badge stays legible.

Tests:
- TestSidebarShowCountBadge — seeds show_count=true view, asserts
  .nav-badge markup in the sidebar.
- TestSidebarIconRenders — seeds icon=star view, asserts the
  distinctive star polygon path lands in the sidebar SVG.

Drag-reorder UI stays parked (m's Q7=(b) v2). sort_order column is
server-assigned MAX+1 on create; the column was wired in slice A and
ReorderViews is ready for slice G's followup.
2026-05-29 12:07:54 +02:00
mAi
df83ab7255 Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice E: sidebar Views section with user views) 2026-05-29 12:03:53 +02:00
mAi
1f8c626aed feat(views): Phase 5j slice E — sidebar Views section with user views
The Phase 5j sidebar's Views entry already linked to /views; slice E
extends the section to LIST every saved user view with its name + icon
glyph + active state, plus a "+ New view" shortcut at the bottom. The
system views (Tree / Dashboard / Calendar / Timeline / Graph) stay in
the main nav block above so muscle memory holds.

Render plumbing (web/server.go):
- render() pulls ListViews() into the data map under UserViews when
  the template is not "login" (login renders without layout). Stub
  servers without a real Pool skip cleanly via the name guard.
- One indexed lookup per chrome-bearing render. Slice G can add a
  per-request memoisation if profiling bites.

Template (web/templates/layout.tmpl):
- New "Views" sub-section below the main /views entry. Each user view
  emits as a nav-item-user-view link with icon glyph + name. Active
  marker fires when path == /views/<slug>. Bottom anchor: "+ New view"
  link to /views/new for one-click creation from anywhere.
- The icon-glyph stays a placeholder diamond (◆) in this slice; slice
  G ships the registry SVGs.

CSS (web/static/style.css):
- nav-item-user-view: slightly smaller font, indented 24px so user
  views sit visually under the Views section header.
- nav-item-new-view: muted color to distinguish the action from
  navigation.
- sidebar-user-views: flex column with 2px gap matches the existing
  sidebar's spacing rhythm.

Tests:
- TestSidebarListsUserViews — seeds one view, asserts the sidebar
  surfaces /views/{slug} href + display name + the + New view link.
  Active marker fires on /views/{slug}.
2026-05-29 12:03:47 +02:00
mAi
4918f48b51 Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice C: full URL migration + system views) 2026-05-29 11:59:31 +02:00
mAi
f820fa5830 feat(views): Phase 5j slice C — full URL migration + system views
Per m's Q1 pick (b) (2026-05-29): legacy `/`, `/dashboard`, `/calendar`,
`/timeline`, `/graph` become `/views/{system-slug}`. Old routes
301-redirect to the new ones with chip params preserved; the legacy
?view=<uuid> param from 5i is resolved through the uuid → slug map
when present so old bookmarks land on the right user view.

System views (web/system_views.go):
- SystemView struct (Slug / Name / Icon / URL) — code-resident, never
  rows in projax.views.
- AllSystemViews() returns the canonical five: tree, dashboard,
  calendar, timeline, graph. Display order matches the existing
  sidebar.
- LookupSystemView(slug) returns the matching entry or nil; the
  reserved-slug list in store.IsReservedViewSlug (slice A) is kept
  in sync.
- legacyRedirect(systemSlug) handler 301s with chip-param preservation
  + uuid → slug resolution for any leftover ?view=<uuid>.

Routes (web/server.go):
- GET /views/tree      → handleTree     (was GET /)
- GET /views/dashboard → handleDashboard
- GET /views/timeline  → handleTimeline
- GET /views/calendar  → handleCalendar
- GET /views/graph     → handleGraph
- GET /                → 301 → /views/tree
- GET /dashboard       → 301 → /views/dashboard
- GET /timeline        → 301 → /views/timeline
- GET /calendar        → 301 → /views/calendar
- GET /graph           → 301 → /views/graph
- POST action endpoints (/dashboard/task/*, /dashboard/pin, /admin/*)
  stay where they are — those are RPC-ish, not page renders.

handleTree: dropped the `r.URL.Path != "/"` guard — the only entry
point now is /views/tree, mounted via the new route. Slice F removes
any residual references; this slice keeps the handler reachable.

computeChipCounts grew a `base string` arg so chip URLs anchor on the
caller's route (/views/tree for the system tree, /views/{slug} for
saved views). PageViewTypes recognises both legacy and /views/ keys
during the transition.

Template hrefs / hx-gets bulk-updated to the new URLs:
- layout.tmpl: every sidebar + bottom-nav entry points at
  /views/{system-slug}. Active-state checks updated alongside.
- tree_section.tmpl, tree_card.tmpl, tree_kanban.tmpl: clear-filter
  / clear-all hrefs → /views/tree.
- calendar*.tmpl, timeline_section.tmpl, graph.tmpl,
  dashboard_section.tmpl: every internal nav + filter link points at
  the /views/{slug} surface.
- detail.tmpl, error.tmpl: cancel / back-to-tree → /views/tree.

Test-source updates (per the 5c sharpened rule):
- ~100 test paths bulk-rewritten from /dashboard /calendar /timeline
  /graph (and `/`) to their /views/{slug} counterparts. The
  behaviour-preservation contract holds: status codes + body shapes
  for the rendered pages stay the same; only the URL anchoring the
  test changes.
- layout_test.go: sidebar href assertions updated to /views/{slug}.
- view_type_test.go (Q2 + Q3 follow-up): PageViewTypes lookup table
  updated to use the new route keys.
- 2 deliberate behaviour-change assertions land: TestLegacyRedirects
  expects 301 on the old URLs (was 200); TestTreeRenders fetches
  /views/tree (the new home) instead of /.

Internal go-source URL emissions (dashboard.go, calendar.go,
timeline.go) updated to the new BasePath so chip + refresh URLs round
through /views/{slug} correctly.

New tests:
- TestSystemViewLookup — AllSystemViews shape + LookupSystemView
  round-trip + unknown-slug nil.
- TestLegacyRedirects — every legacy URL 301s to its new home with
  chip params preserved.
- TestLegacyViewUUIDRedirect — old `?view=<uuid>` URLs land on the
  resolved slug per m's Q3 pick.
2026-05-29 11:59:26 +02:00
mAi
0ad610d018 Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice B: paliad-shape route family + render) 2026-05-29 11:47:39 +02:00
mAi
e305f0e0ae feat(views): Phase 5j slice B — paliad-shape route family + render
Restores the /views URL family in the paliad shape m asked for:

  GET  /views                  → MRU 302 or onboarding shell
  GET  /views/{slug}           → render saved view as its own page
  GET  /views/new              → editor blank
  GET  /views/{slug}/edit      → editor existing
  POST /views                  → create
  POST /views/{slug}           → update
  POST /views/{slug}/delete    → delete
  POST /views/reorder          → drag-reorder hook (used in slice G)

Render path:
- handleViewRender resolves the slug against user views (slice C adds
  system views), touches last_used_at fire-and-forget so the next /views
  landing 302s here, then dispatches the same view_type renderers the
  tree page uses (list / card / kanban). filter_json is decoded into a
  TreeFilter + view_type + group_by; URL chip params overlay the saved
  filter so chips narrow the view further without losing the saved
  baseline. calendar / timeline view_types fall back to list in slice B;
  slice D wires their dedicated templates.

Editor path:
- handleViewEditor renders templates/view_editor.tmpl, a minimal form
  for slice B (slice D adds the live chip strip, slug auto-derivation,
  and the icon registry). Pre-fills every persisted field on edit.

Templates:
- views_landing.tmpl — index card list + "+ new view" link.
- view_render.tmpl — header (name + slug + edit/delete) + tree-section
  partial. Bundled with tree_section / tree_card / tree_kanban /
  project_chip so the rendered view shares the dispatch chain.
- view_editor.tmpl — form for create + edit.

Encoding:
- encodeFilterToJSON canonicalises (filter_query, view_type) into the
  filter_json shape. view_type lives INSIDE the JSON per m's Q2 pick.
- decodeViewSpec is the inverse — slice C's system-view code reuses it
  to convert SystemView definitions into the same shape.
- overlayURLOntoSavedFilter mirrors the 5i fix-shift pattern: URL chip
  values selectively override the saved baseline (q / tag / mgmt /
  status / has / show-archived / public / project / project_descendants).

Error mapping:
- writeViewError translates the typed store errors (ErrViewSlugFormat /
  Reserved / Taken / NotFound) into 400 / 409 with human-readable
  banners. handlers map ErrViewNotFound to 404 directly.

Tests (HTTP integration):
- TestViewsLandingOnboarding — empty store → shell with "+ New view".
- TestViewsLandingMRURedirects — touched view triggers 302 to it.
- TestViewRenderShowsSavedView — name + slug + view_type=card grid.
- TestViewRender404OnUnknownSlug — unknown slug 404s, no silent
  fall-back to tree.
- TestViewCreateAndDelete — POST /views creates; reserved slug 400s;
  POST /views/<slug>/delete removes the row.
- TestSavedViewFilterOverlay — ?tag=work narrows the saved view; URL
  chip values overlay the persisted filter.
2026-05-29 11:47:33 +02:00
mAi
a9f062a67e Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice A: paliad-shape schema redesign) 2026-05-29 11:41:34 +02:00
mAi
173d7ddbb2 feat(views): Phase 5j slice A — paliad-shape schema redesign
Hard-replaces the 5i projax.views table per m's Q10 pick (2026-05-29):
no real data to preserve after a few hours, and the shape changes are
big enough that a clean recreate beats a 6-step ALTER.

Schema (migration 0017_views_redesign.sql):
- id (uuid), slug (text, format-CHECK'd, UNIQUE), name, icon,
  filter_json (jsonb — INCLUDES view_type per m's Q2), sort_field,
  sort_dir, group_by, sort_order, show_count, last_used_at,
  created_at, updated_at.
- DROPPED: pinned, is_default_for, view_type column. m's Q9 picked
  MRU (last_used_at) over per-page-default; Q2 placed view_type
  inside filter_json so the JSON owns the canonical render spec.
- Constraints: slug regex, sort_dir enum. NO view_type CHECK — the
  JSON-shape validator owns it now.
- Indexes: slug UNIQUE, (sort_order, name), (last_used_at DESC).
- updated_at trigger reused; projax_admin ownership preserved.

Store (store/views.go rewrite):
- View struct: Slug as the user-facing key; uuid kept on ID for the
  legacy `?view=<uuid>` 302-redirect path that lands in slice C.
- ListViews ordered by sort_order, name (matches sidebar).
- GetView(slug) + GetViewByID(uuid). MostRecentView() drives the
  /views landing redirect (slice B).
- TouchView(slug) bumps last_used_at fire-and-forget.
- ReorderViews([]slugs) wires the column for slice G's drag UI.
- CreateView server-assigns sort_order = MAX+1 inside the tx.
- UpdateView replaces every writeable field; renames are supported.
- Validation: slug format regex + reserved-list rejection +
  filter_json JSON well-formed check before round-trip.
- ErrViewNotFound / ErrViewSlugTaken / ErrViewSlugReserved /
  ErrViewSlugFormat surface to handlers as the typed error set.

Cleanup of the 5i overlay (drops what the new shape obsoletes):
- web/views.go: gutted to a stub. applySavedView, applyDefaultView,
  overlayURLFields, filterQueryToJSON, filterJSONToQuery,
  filterFromJSONPayload, anySliceToStrings + every old handler
  (handleViewsIndex, handleViewCreate, handleViewWrite, handleViewEdit,
  handleViewRedirect, handleViewDelete) deleted.
- web/server.go: dropped the /views route registrations and the
  applySavedView + applyDefaultView calls in handleTree.
  DefaultBanner data-map field removed.
- web/tree_filter.go: TreeFilter.ViewID field removed; ParseTreeFilter
  and QueryString stop reading/emitting ?view=.
- web/templates/views.tmpl and view_edit.tmpl deleted.
- web/templates/tree_section.tmpl: default-banner block deleted.
- web/views_test.go: deleted (every test was against the 5i shape).

Between slice A and slice B, /views/* URLs return 404 by design.
Slice B reintroduces the route family in paliad-shape:
  GET /views          → MRU landing
  GET /views/{slug}   → render
  GET /views/new      → editor
  GET /views/{slug}/edit → editor
  POST /views, /views/{slug}, /views/{slug}/delete → CRUD

Tests (store/views_test.go, new):
- TestViewSlugCRUD — create / get-by-slug / get-by-id / rename /
  delete round-trip, including rename-leaves-old-slug-gone.
- TestViewSlugFormatRejected — uppercase, underscore, leading dash,
  length-cap, empty all surface ErrViewSlugFormat.
- TestViewReservedSlugRejected — tree/dashboard/calendar/timeline/graph
  and friends all reject with ErrViewSlugReserved.
- TestViewSlugCollision — duplicate slug surfaces ErrViewSlugTaken.
- TestViewMRU — TouchView + MostRecentView ordering against a
  controlled pair of slugs (resilient to other suites' touched views).
- TestViewReorder — ReorderViews rewrites sort_order ascending.

Web tests stay green (the 5i overlay tests are gone, the rest don't
touch the views shape).
2026-05-29 11:41:28 +02:00
mAi
731f443569 Merge branch 'mai/knuth/new-form-slug-suggest' (feat: /new auto-suggests kebab slug from title) 2026-05-27 14:30:36 +02:00
mAi
157c4e659b feat(new): auto-suggest kebab slug from title
m's request: typing "Mallorca 2026" into the new-item Title should
suggest "mallorca-2026" in the Slug field. Surface-only — server still
validates per itemwrite (^[a-z0-9][a-z0-9-]{0,62}$).

Inline ~25-line vanilla-JS handler on /new:
- normalize('NFD') + strip combining diacritics → ä→a, ñ→n, São→sao
- ß → ss (German sharp-s)
- non-alphanum run → single hyphen
- trim leading/trailing hyphens, collapse runs of hyphens
- slice(0, 63) to match the validator's length cap

Behavioural contract per m's brief:
- Slug syncs from Title on every Title input event UNTIL the user
  edits the slug manually. After that the slug field is locked in
  (`slug.dataset.userEdited === '1'`).
- A pre-filled slug counts as user-edited too — defensive against any
  future flow that lands on /new with a slug already populated.

Scoped to /new only — the detail-page edit form intentionally keeps
manual slug control because auto-sync there would silently rename
existing items.

Template additions:
- Added `id="new-item-form"`, `id="new-title"`, `id="new-slug"` to the
  form + inputs so the script can grab them by id rather than name
  (name="slug" exists on the detail page too and we don't want to
  cross-bind).

Test (web/new_form_test.go):
- TestNewFormHasSlugSuggestScript — asserts the inline script's
  signature fragments (`normalize('NFD')`, `replace(/ß/g, 'ss')`,
  `slice(0, 63)`, `dataset.userEdited`, the input ids) all render on
  /new. Guards against a "harmless cleanup" pass silently stripping
  the script.

Manual verification: typing "Mallorca 2026" updates slug to
"mallorca-2026"; typing in the slug field locks further sync.

Full web suite green.
2026-05-27 14:30:23 +02:00
mAi
547d6f77f6 Merge branch 'mai/knuth/fix-timeline-filters' (fix: project filter narrows /admin/bulk + timeline multi-value kind) 2026-05-27 14:27:33 +02:00
mAi
788479c6cb fix(filters): project dim narrows /admin/bulk + timeline multi-value kind
m reported /timeline filters don't narrow, then clarified that the
project-filter dim added in Phase 5i Slice A (kahn, 13923aa) "doesn't
work ANYWHERE." Systematic reproduction:

  /tree?project=admin         → narrows ✓
  /timeline?project=admin     → narrows ✓
  /calendar?project=admin     → narrows ✓
  /dashboard?project=admin    → narrows ✓
  /admin/bulk?project=admin   → SILENT NO-OP ✗

Plus a small parser bug on /timeline's ?kind=… handling that mirrors
the calendar bug fixed in 6f0a318.

## Root causes

(1) `bulkMatches` in web/bulk.go is a near-clone of `TreeFilter.Matches`
that the Phase 5i Slice A author updated only on Matches itself — the
clone never picked up the ProjectPath block. Filter parses fine, gets
threaded into filterFlat, and silently ignored. `/admin/bulk?project=…`
sees every item.

(2) Timeline's own `?kind=event,doc` parser used
`r.URL.Query().Get("kind")` + comma-split — same shape calendar carried
before commit 6f0a318. When the chip strip's `<select multiple>`
submits `?kind=event&kind=doc`, only the first value lands in q.Kinds.
The user picks two kinds, sees only one applied.

## Fix

bulkMatches gets the ProjectPath block copied verbatim from
TreeFilter.Matches — same predicate, same IncludeDescendants gate,
same multi-parent "ANY path qualifies" semantics.

timeline.parseTimelineQuery's ?kind handling drops the bespoke
Get+Split+dedup-map and uses `parseValues(r.URL.Query(), "kind")` —
the helper already added to web/server.go covers both URL shapes
transparently (`?kind=a,b` and `?kind=a&kind=b`).

## Tests

web/project_filter_test.go (new, 6 tests):
  - TestProjectFilterNarrowsTree
  - TestProjectFilterNarrowsTimeline
  - TestProjectFilterNarrowsCalendar
  - TestProjectFilterNarrowsDashboard
  - TestProjectFilterNarrowsBulk  ← was failing pre-fix
  - TestProjectFilterDescendantsToggle
  - TestTimelineKindMultiValueSurvives  ← was failing pre-fix

The fixture seeds a three-row subtree under dev/ (root + child +
outside sibling) and asserts each surface narrows to root + child
while excluding the outside sibling. The descendants toggle test
flips `?project_descendants=0` and confirms the child drops out.

web/timeline_filter_test.go (new, 3 tests): URL-driven tag narrowing,
multi-value kind parsing, and chip-strip HTMX form target wiring.
These are the immediate "reproduce first" probes athena's brief asked
for; they all PASSED on the pre-fix code (the filter narrowing was
fine on URL paths; the bug was elsewhere) — they stay as defence-in-
depth against future regressions.

## Surfaces double-checked (not broken)

- /graph?project=… dims non-matching nodes instead of narrowing per
  graph.go's explicit comment "the graph deliberately shows the full
  DAG; the filter dims non-matches via opacity unless isolate=1
  hides them." Working as documented.
- The chip strip + project-picker template + Views-page hidden inputs
  all preserve the project value across chip changes — verified by
  template rendering probes.

Full web suite green (76 tests). Pre-existing db/TestBackfillTagsFromArea
unchanged.

Net: +442 / -12.
2026-05-27 14:27:26 +02:00
mAi
a0d6217ebf Merge branch 'mai/knuth/caldav-link-existing' (feat: per-item CalDAV link-existing + projax-tagged VTODOs for shared lists) 2026-05-27 14:16:09 +02:00
mAi
311cf943bc feat(caldav): link-existing picker + projax-tagged VTODOs for shared lists
m's ask: per-item CalDAV linking should support existing lists, not
just create-new. Athena's design update extended it: also tag VTODOs
on create so multiple projax items can SHARE one CalDAV list, with
projax doing tag-based slicing on read.

Three layers, one branch:

## 1. Link-existing picker (the original ask)

- New POST /i/{path}/caldav/link-existing handler validates the
  submitted calendar_url is in the discoverable PROPFIND set (defence
  against crafted forms pointing at arbitrary HTTP servers), then
  inserts the item_link row with display_name + color metadata
  preserved from the discovery payload.
- handleDetail + renderTasksSection pre-load
  availableCalendarsForItem(ctx, links) — calendars from
  s.CalDAV.Client.ListCalendars MINUS the ones already linked to this
  item. Errors degrade to an empty picker (non-fatal).
- tasks_section.tmpl gains a .caldav-actions block rendering the
  picker (<select> of available calendars) when AvailableCalendars
  is non-empty AND the Create-new button (when the item has no
  linked list yet). Same surface serves both the "first link" flow
  and the "+ link another" flow per athena's brief.

## 2. Tag-on-create (CATEGORIES carries projax:<path>)

- caldav package gains Categories []string on Todo + the same on
  VTodoEdit. BuildVTodoICS emits a CATEGORIES line when non-empty;
  parseVTodos parses CATEGORIES comma-list into the slice with per-
  entry unescape per RFC 5545.
- handleCalDAVTodoAction action="todo-create" passes
  `Categories: []{ProjaxCategoryFor(it.PrimaryPath())}` into
  VTodoEdit so every per-item Add submits a tagged VTODO.
- ApplyVTodoEdit intentionally ignores the Categories field —
  edit/complete/delete paths preserve existing CATEGORIES via the
  unknown-property pass-through that's been tested since Phase 5
  (TestApplyVTodoEditPreservesUnknown).

## 3. Per-item filter (managed-vs-legacy)

- detailTodos now calls caldav.AnyTodoHasProjaxTag(todos) to decide
  whether the linked list is projax-managed (any projax: tag
  anywhere) or legacy/unmanaged (zero projax: tags).
  - Managed → filter to VTODOs whose CATEGORIES include this
    item's projax:<path>. Multiple projax: tags are AND-of-OR — a
    VTODO with two projax tags appears on both items per athena's
    multi-tag contract.
  - Legacy → show every VTODO untouched. Existing pre-5j users with
    untagged lists keep seeing everything; the detail page doesn't
    suddenly hide their tasks.

## Helpers (caldav package, exported)

- ProjaxCategoryFor(primaryPath) → "projax:<path>" string
- HasProjaxTag(t) bool → any projax: prefix
- HasProjaxTagFor(t, primaryPath) bool → exact projax:<path>
- AnyTodoHasProjaxTag(todos) bool → list-level signal

## Tests

caldav unit (caldav/projax_tags_test.go):
- TestProjaxCategoryFor / TestHasProjaxTagAndFor /
  TestAnyTodoHasProjaxTag / TestBuildVTodoICSEmitsCategories /
  TestParseVTodosMultiCategory.

web integration (web/caldav_link_existing_test.go) — single fake
CalDAV server (httptest) answering PROPFIND + REPORT + PUT, then
four end-to-end probes:
- TestDetailLinkExistingCalendar — three calendars discoverable,
  picker renders, POST link-existing creates the link, second GET
  drops the linked URL from the picker.
- TestVTodoCreateAttachesProjaxCategory — Add-task POST writes a
  VTODO whose CATEGORIES contains projax:<path>.
- TestDetailFilterByProjaxCategory — one calendar shared between
  Trip A and Trip B with three tagged VTODOs; A sees A+shared,
  B sees B+shared, neither sees the other's tagged-only VTODO.
- TestDetailUntaggedListShowsAll — linked list with zero projax
  tags renders ALL VTODOs (legacy fallback).

Full web + caldav suites green. Pre-existing
db/TestBackfillTagsFromArea failure unchanged.

Net: +795 / -14.
2026-05-27 14:16:04 +02:00
mAi
abb329a686 Merge branch 'mai/knuth/fix-new-parent-prefill' (fix: /new Parents select was empty + missed ?parent= prefill) 2026-05-27 14:04:19 +02:00
mAi
b15c222727 fix(new): populate Parents <select> and pre-select ?parent= match
m's report: /new?parent=admin doesn't pre-select admin. Root cause is
worse than the report — the Parents <select> was COMPLETELY EMPTY: the
handler never passed ParentOptions to the template, so the
`{{range .ParentOptions}}` block iterated nil. There was nothing to
pre-select.

handleNewForm now calls s.parentOptions(r.Context()) the same way
handleClassify already did, and threads the result through the data
map as "ParentOptions". The template's existing pre-select expression
`{{if and $.Parent (eq .ID $.Parent.ID)}}selected{{end}}` already
handles id/path resolution — once the options exist, the `selected`
attribute lands on the right one.

Regression test (web/new_form_test.go):

- TestNewFormPreselectsParent — probes /new?parent=admin against the
  HTTP integration server, asserts (1) <option> tags are rendered in
  the Parents <select>, (2) the admin <option> exists with `selected`
  on its opening tag, (3) other root options (dev) do NOT carry
  `selected`. Confirmed failing pre-fix (no admin option at all),
  passing post-fix.

- TestNewFormNoParentParamRendersAllOptions — bare /new with no
  ?parent= still populates the Parents <select> so the user can pick
  any parent. Belt-and-braces guard.

Full web suite green. Pre-existing db/TestBackfillTagsFromArea failure
unchanged.

Net: +105 / -0.
2026-05-27 14:04:14 +02:00
mAi
590bb28063 docs: Phase 5j Views-redesign plan — paliad-shape first-class views
m's feedback on 5i (verbatim): "It's not really what I wanted. It
should like the paliad custom views, not of the existing views a
variant but individually created views."

5i modelled views as overlays on existing pages (?view=<uuid>). m wants
the paliad model: views are first-class URLs (/views/{slug}), each one
its own page. System defaults (dashboard, calendar, timeline, ...)
share the route shape with reserved slugs; user-created views land
beside them.

Plan covers: schema redesign (slug as URL key, drop is_default_for +
pinned, add icon + sort_order + show_count + last_used_at), four-route
table (landing with MRU redirect, render, editor blank/edit), system-
view shape (hybrid alias recommendation under Q1), editor surface
(dedicated pages, not modal), migration path from 5i (drop table +
delete overlay code; keep view_type enum and per-view_type renderers),
seven-slice implementation chain (A schema → B routes → C system views
→ D editor → E sidebar → F cleanup → G polish).

11 open questions batched in §9 — head delegation pending. NO chip-
picker without head's explicit re-grant (5i permission was one-time).

No code changes; this branch ships docs only. Coder shifts wait on m's
sign-off via head's relay.
2026-05-26 15:23:35 +02:00
mAi
d0e0669fff Merge branch 'mai/kahn/fix-views-edit-filters' (fix: views edit UI + URL chip overlay on saved-view pages) 2026-05-26 15:08:51 +02:00
mAi
59a89ef044 fix(views): edit UI + URL chip overlay on saved-view pages
m's bug (verbatim from /views): "we cant edit views yet. and the filters
on custom views dont seem to work. No apply button and no instant apply"

Two distinct gaps, both surgically fixed.

## Gap 1 — edit UI missing

Slice D shipped POST /views/<id> (update) but no GET form to drive it.
The index page had delete + redirect-open links only.

Fix:
- New handleViewEdit serves GET /views/<id>/edit with the form pre-filled
  from the persisted row.
- New templates/view_edit.tmpl mirrors the create form, selecting the
  current values on each <select>, populating each <input value="">.
- filterJSONToQuery rebuilds the URL-query representation of filter_json
  so the `filter_query` text input round-trips on edit.
- /views index row gets an "edit" link next to delete.
- Route registered before the catch-all GET /views/ so the more specific
  pattern wins. handleViewRedirect also defensively forwards /edit
  suffix in case routing falls through.

## Gap 2 — URL chips clobbered by saved-view filter

applySavedView did `*filter = filterFromJSONPayload(payload)` — wholesale
replace. URL chip params parsed earlier in handleTree were thrown away.
Compounded by chip URLs not preserving `?view=<id>`, so even if the
overlay had worked, chip clicks would have stripped the saved view.

Fix:
- TreeFilter grows a `ViewID` field that round-trips through
  ParseTreeFilter + QueryString. Not a "filter dimension" in the
  matching sense (Matches ignores it); just a URL anchor that
  every chip URL emits forward.
- applySavedView builds the saved filter, then overlayURLFields()
  selectively replaces any dimension the user set via URL chip on top
  (q/tag/mgmt/status/has/show-archived/public/project/project_descendants).
- view_type: URL wins when explicitly set, saved value otherwise.
- Drift is transient — URL bookmarkable as a "narrowed saved view"
  without auto-saving back to the row. To persist, user opens /edit.

## Tests

- TestViewEditFlow — GET /<id>/edit pre-fills name + filter_query; POST
  /<id> updates name + view_type + filter_json round-trip in DB.
- TestSavedViewPageFilterApply — seed two items + an empty saved view;
  /?view=<id> shows both; /?view=<id>&tag=work shows only the work
  one. Also asserts chip URLs contain view=<id> so navigation stays in
  the saved view.

Out of scope (per brief):
- No schema changes.
- No view sharing / multi-user.
- HTMX modal save UI deferred — the existing inline edit page is the
  surgical fix m's bug actually needs.
2026-05-26 15:08:44 +02:00
mAi
93b751d383 Merge branch 'mai/kahn/phase-5i-phase-a-design' (phase 5i slice E: default view-per-page + opt-out banner) 2026-05-26 13:50:47 +02:00
mAi
b9161eba17 feat(views): Phase 5i slice E — default view-per-page + opt-out banner
Closes the Phase 5i implementation chain. When `views.is_default_for=<page>`
is set, opening that page with a "clean" URL (no chip params, no
?view=) auto-applies the saved filter + view_type. A "Showing default
view: <name> · clear" banner makes the swap visible and gives the user
a one-click out. Adding any chip param to the URL bypasses the default;
?nodefault=1 is the explicit opt-out for "I want the bare default tree".

New web/views.go: applyDefaultView gates on the param-cleanness check
+ Store.DefaultViewFor lookup. Resolution + view_type revalidation
mirror the slice D ?view=<uuid> path so a kanban-default opened on a
route that doesn't allow kanban falls back cleanly.

handleTree wires it into the existing slice D else-branch (no default
when ?view= is set). DefaultBanner field passes the applied view to
the template for the banner.

Test:
- TestDefaultViewAppliedOnCleanURL — seeds a tree default with
  filter_json={tags:[work]} + view_type=card, then asserts: clean GET /
  applies (card grid + banner with the view's name); ?tag=dev bypasses
  (forest, no banner); ?nodefault=1 opt-out (forest, no banner).
2026-05-26 13:50:42 +02:00
mAi
773194c1b7 Merge branch 'mai/kahn/phase-5i-phase-a-design' (phase 5i slice C: kanban view_type with group_by chip strip) 2026-05-26 13:47:12 +02:00
mAi
bbc7867a35 feat(views): Phase 5i slice C — kanban view_type with group_by chip strip
m's Q6 pick (2026-05-26): kanban groups the filtered set by `status`
(default) / `area` / `tag` / `management`. Read-only — drag-to-change
is parked. Adds the third view_type render on /tree (alongside list and
card from earlier slices); kanban is now unlocked in PageViewTypes("/").

New web/kanban.go owns BuildKanbanBoard + the per-dimension keyer +
column ordering (status: active/done/archived; management: mai/self/
external/unmanaged; area + tag: alphabetical). Within-column order:
pinned-first → updated_at desc → title.

ParseGroupBy + GroupByChips provide the URL-param hookup and the chip
strip rendered above the board. Multi-tag items appear in every tag
column they belong to (deliberate — the kanban surfaces overlap).

Render:
- handleTree builds the kanban board off the same flatMatchedItems the
  card view consumes; cost is one extra grouping pass, no new DB hits.
- New templates/tree_kanban.tmpl: header chip strip + responsive
  column board (horizontal scroll on overflow). Empty filtered set
  surfaces a friendly nudge.

CSS additions cover the column / card layout; existing chip aesthetics
reused for the group-by toggle.

Test updates:
- view_type_test.go: slice B's "kanban locked on /" assertions tightened
  to "kanban unlocked; calendar + timeline still locked on /" — slice C
  is the unlock event for kanban.
- New kanban_test.go: per-dimension grouping (status, tag, area),
  pinned-first ordering, parser fallback.
- server_test.go: end-to-end render — GET /?view_type=kanban produces
  kanban-board markup + group-by chip strip; forest absent.
2026-05-26 13:47:03 +02:00
mAi
79fc8b34c9 Merge branch 'mai/kahn/phase-5i-phase-a-design' (phase 5i slice D: saved views table + CRUD + sidebar entry) 2026-05-26 13:42:57 +02:00
mAi
2f47b28f39 feat(views): Phase 5i slice D — saved views table + CRUD + sidebar entry
Persists named bundles of (filter + view_type + sort + group_by). Per m's
Q2 pick (2026-05-26), views are page-agnostic — `is_default_for` lets a
view become the auto-applied default for a page, otherwise views render
on whichever page accepts their view_type.

Schema (db/migrations/0016_views.sql):
- projax.views table with check constraints on view_type (5-value enum),
  sort_dir, is_default_for, and the kanban-needs-group rule.
- Case-insensitive unique name index (live rows only).
- One-default-per-page partial unique index.
- updated_at trigger; projax_admin ownership / grants.

Store (store/views.go):
- View struct + ViewInput; ListViews / GetView / CreateView / UpdateView
  / SoftDeleteView / DefaultViewFor.
- CreateView and UpdateView clear the prior default for a page in the
  same transaction when IsDefaultFor is set — defends against the
  partial unique index outside the SECURITY DEFINER path.
- Validation mirrors the DB check constraints so handlers can surface
  friendlier errors before round-tripping.

Handlers (web/views.go) + routes (web/server.go):
- GET  /views            list + create form (templates/views.tmpl).
- POST /views            create (filter_query form field is parsed into
                         canonical filter_json shape — design.md §2).
- GET  /views/<id>       redirect to the target page + ?view=<id>.
- POST /views/<id>       update.
- POST /views/<id>/delete soft delete.

Resolution path:
- handleTree now calls applySavedView when ?view=<uuid> is present;
  fields the saved filter_json + view_type back into the TreeFilter and
  the view-type slot. view_type then revalidates against the route
  catalog so a saved kanban-view URL on / lands on list with kanban
  shown locked until slice C ships it. Failures fall back gracefully
  (log + URL-derived filter), no 500.

UI:
- Sidebar gains a Views entry (4-square icon) next to Admin in
  layout.tmpl.
- /views renders a flat table + inline create form. The form accepts a
  URL-query filter string (e.g. `tag=work&mgmt=mai`) which is canonised
  into filter_json on save.

Tests:
- TestViewsCRUDRoundTrip — full create / list / open-redirect / soft-
  delete cycle via HTTP, plus filter_json shape assertion.
- TestSavedViewAppliedOnQueryParam — seed a card view scoped to dev,
  hit /?view=<id>, assert the page renders card grid + scoped chip-on.

Out of scope for slice D (per design.md §7):
- HTMX modal save UI from any page (the inline-create-on-/views/ form
  works; a modal lands in a polish pass).
- MCP read tools for views (deferred to a follow-up — m manages views
  via the UI).
2026-05-26 13:42:51 +02:00