19 Commits

Author SHA1 Message Date
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
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
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
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
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
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
17 changed files with 2575 additions and 29 deletions

View File

@@ -55,9 +55,14 @@ type Todo struct {
Due *time.Time
Priority int
LastModified *time.Time
URL string // absolute URL of the .ics resource on the server
ETag string // server-issued ETag; pass to PutTodo/DeleteTodo as If-Match
Raw string // raw VCALENDAR ICS as returned by the server, preserved for in-place edits
// Categories carries the RFC 5545 CATEGORIES property as a flat
// slice (already comma-split, trimmed). Phase 5j uses entries
// prefixed `projax:<primary-path>` to tag VTODOs to projax items —
// see HasProjaxTag + ProjaxCategoryFor in this package.
Categories []string
URL string // absolute URL of the .ics resource on the server
ETag string // server-issued ETag; pass to PutTodo/DeleteTodo as If-Match
Raw string // raw VCALENDAR ICS as returned by the server, preserved for in-place edits
}
// Event is one VEVENT returned by ListEvents. Phase 3l: read-only, no

View File

@@ -54,11 +54,70 @@ func parseVTodos(ics string) []Todo {
if t, ok := parseICalTime(val); ok {
cur.LastModified = &t
}
case "CATEGORIES":
// CATEGORIES is comma-separated per RFC 5545. Some clients emit
// multiple CATEGORIES lines; we merge by appending. The unescape
// is per-entry because commas inside a category value MUST be
// escaped (`\,`), so we split on bare commas only after unescape.
for _, raw := range strings.Split(val, ",") {
t := strings.TrimSpace(unescapeText(raw))
if t == "" {
continue
}
cur.Categories = append(cur.Categories, t)
}
}
}
return out
}
// ProjaxCategoryFor returns the projax-namespaced CATEGORIES entry for
// the given primary-path (e.g. "projax:admin.vacations.greece"). Used by
// both the write side (tag-on-create) and the read side (per-item filter).
func ProjaxCategoryFor(primaryPath string) string {
return "projax:" + primaryPath
}
// HasProjaxTag reports whether the VTODO carries any `projax:` category.
// Used to decide whether the per-item filter kicks in: a list with at
// least one projax: tag is "managed" by projax and the detail page only
// shows todos matching THIS item's path; a list with zero projax: tags
// is a legacy/unmanaged list and the detail page shows everything.
func HasProjaxTag(t Todo) bool {
for _, c := range t.Categories {
if strings.HasPrefix(c, "projax:") {
return true
}
}
return false
}
// HasProjaxTagFor reports whether the VTODO carries the specific
// `projax:<primaryPath>` category. A todo can carry multiple projax: tags
// (when it belongs to multiple projax items) — any match returns true.
func HasProjaxTagFor(t Todo, primaryPath string) bool {
want := ProjaxCategoryFor(primaryPath)
for _, c := range t.Categories {
if c == want {
return true
}
}
return false
}
// AnyTodoHasProjaxTag reports whether the slice contains at least one
// projax-tagged VTODO. The detail page uses this to decide between the
// projax-managed filter (show only matching) and the legacy unmanaged
// path (show all).
func AnyTodoHasProjaxTag(todos []Todo) bool {
for _, t := range todos {
if HasProjaxTag(t) {
return true
}
}
return false
}
// parseVEvents extracts every VEVENT block from a calendar-data string.
// Mirrors parseVTodos but for read-only event listing (no writeback). DTSTART
// with VALUE=DATE marks the event all-day; the parser inspects the raw line
@@ -296,6 +355,13 @@ type VTodoEdit struct {
Due *time.Time
ClearDue bool
Priority *int
// Categories: optional CATEGORIES list. BuildVTodoICS writes them
// directly on a fresh VTODO. ApplyVTodoEdit intentionally ignores
// this field — existing categories pass through unchanged via the
// unknown-property preserve path, which is what every edit/complete/
// delete flow wants. Tag-on-create is the only write path that
// uses it.
Categories []string
}
// BuildVTodoICS serialises a fresh VTODO as a complete VCALENDAR document,
@@ -336,6 +402,15 @@ func BuildVTodoICS(uid string, e VTodoEdit) string {
if e.Priority != nil {
lines = append(lines, fmt.Sprintf("PRIORITY:%d", *e.Priority))
}
if len(e.Categories) > 0 {
// RFC 5545 CATEGORIES — comma-separated, single line. Escape commas
// inside individual entries so the round-trip survives parseVTodos.
escaped := make([]string, 0, len(e.Categories))
for _, c := range e.Categories {
escaped = append(escaped, escapeText(c))
}
lines = append(lines, "CATEGORIES:"+strings.Join(escaped, ","))
}
lines = append(lines, "END:VTODO", "END:VCALENDAR")
return joinICS(lines)
}

114
caldav/projax_tags_test.go Normal file
View File

@@ -0,0 +1,114 @@
package caldav
import (
"strings"
"testing"
)
// TestProjaxCategoryFor pins the tag string format. The format is part
// of the projax↔CalDAV contract — `projax:<primary-path>` — and other
// tooling (admin triage, future migration scripts) will rely on the
// prefix. A typo here silently breaks the per-item filter.
func TestProjaxCategoryFor(t *testing.T) {
got := ProjaxCategoryFor("admin.vacations.greece")
want := "projax:admin.vacations.greece"
if got != want {
t.Errorf("ProjaxCategoryFor = %q, want %q", got, want)
}
}
// TestHasProjaxTagAndFor exercises the two read-side helpers that drive
// the per-item filter on the detail page: HasProjaxTag (any projax: tag
// at all) and HasProjaxTagFor (matches THIS path).
func TestHasProjaxTagAndFor(t *testing.T) {
tagged := Todo{Categories: []string{"home", "projax:admin.vacations.greece", "errands"}}
if !HasProjaxTag(tagged) {
t.Errorf("HasProjaxTag should fire for any projax: category")
}
if !HasProjaxTagFor(tagged, "admin.vacations.greece") {
t.Errorf("HasProjaxTagFor should match exact projax:<path>")
}
if HasProjaxTagFor(tagged, "admin.vacations.spain") {
t.Errorf("HasProjaxTagFor should NOT match a different path")
}
multi := Todo{Categories: []string{"projax:work.proj1", "projax:work.proj2"}}
if !HasProjaxTagFor(multi, "work.proj1") {
t.Errorf("multi-tag todo should match first projax: tag")
}
if !HasProjaxTagFor(multi, "work.proj2") {
t.Errorf("multi-tag todo should match second projax: tag")
}
untagged := Todo{Categories: []string{"home", "errands"}}
if HasProjaxTag(untagged) {
t.Errorf("HasProjaxTag should be false on a no-projax: list")
}
if HasProjaxTagFor(untagged, "anything") {
t.Errorf("HasProjaxTagFor must be false when no projax: tag exists")
}
}
// TestAnyTodoHasProjaxTag drives the list-level managed-vs-legacy
// decision in detailTodos. Untagged lists keep their pre-5j show-all
// behaviour; one tagged todo flips the entire list into managed mode.
func TestAnyTodoHasProjaxTag(t *testing.T) {
none := []Todo{
{Categories: []string{"home"}},
{Categories: nil},
}
if AnyTodoHasProjaxTag(none) {
t.Errorf("untagged list should NOT be projax-managed")
}
mixed := []Todo{
{Categories: []string{"home"}},
{Categories: []string{"projax:admin.vacations.greece"}},
}
if !AnyTodoHasProjaxTag(mixed) {
t.Errorf("list with one projax-tagged todo should be projax-managed")
}
}
// TestBuildVTodoICSEmitsCategories proves the tag-on-create path. The
// Phase 5j write side passes Categories into VTodoEdit; BuildVTodoICS
// must render the CATEGORIES line so the server-side round-trip
// (parseVTodos picks it back up) carries the tag through.
func TestBuildVTodoICSEmitsCategories(t *testing.T) {
summary := "Buy gear"
ics := BuildVTodoICS("uid-tagged", VTodoEdit{
Summary: &summary,
Categories: []string{"projax:admin.vacations.greece"},
})
if !strings.Contains(ics, "CATEGORIES:projax:admin.vacations.greece") {
t.Errorf("BuildVTodoICS should emit CATEGORIES line, got:\n%s", ics)
}
// Round-trip: parse it back, the Categories slice must be populated.
todos := parseVTodos(ics)
if len(todos) != 1 {
t.Fatalf("parseVTodos round-trip expected 1 todo, got %d", len(todos))
}
if !HasProjaxTagFor(todos[0], "admin.vacations.greece") {
t.Errorf("round-trip lost CATEGORIES: %#v", todos[0].Categories)
}
}
// TestParseVTodosMultiCategory proves the parser handles RFC 5545
// comma-separated CATEGORIES correctly (a single CATEGORIES line with
// multiple values, not multiple CATEGORIES lines). This is the wire
// shape Apple Calendar + Thunderbird + Radicale all emit.
func TestParseVTodosMultiCategory(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:multi\r\nSUMMARY:Multi\r\nSTATUS:NEEDS-ACTION\r\nCATEGORIES:home,projax:admin.vacations.greece,projax:work.someproj,errands\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
todos := parseVTodos(ics)
if len(todos) != 1 {
t.Fatalf("expected 1 todo, got %d", len(todos))
}
want := []string{"home", "projax:admin.vacations.greece", "projax:work.someproj", "errands"}
if len(todos[0].Categories) != len(want) {
t.Fatalf("Categories = %v, want %v", todos[0].Categories, want)
}
for i, c := range todos[0].Categories {
if c != want[i] {
t.Errorf("Categories[%d] = %q, want %q", i, c, want[i])
}
}
}

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

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

View File

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

158
store/adapter.go Normal file
View File

@@ -0,0 +1,158 @@
package store
import (
"context"
"errors"
"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 prep — see docs/plans/slice-b-adapter-contract.md.
// The existing *Store already satisfies this interface (the compile-time
// assertion below pins that). Slice B impl ships a second satisfier
// (MBrianReader) once m/mBrian#73's migration completes and hands the
// uuid map over.
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)
// errNotImplementedSliceB is the placeholder return from every method on
// the slice-B-prep stub. Slice B's impl replaces each return with the
// real mBrian-backed body.
var errNotImplementedSliceB = errors.New("not implemented: Phase 6 Slice B (mBrian-backed reader) — waits on m/mBrian#73 migration")
// MBrianReader is the slice-B implementation target. Every method body
// returns errNotImplementedSliceB during prep; slice B's coder fills
// each in once the migration completes and the uuid map lands. The type
// holds no mBrian client today — the client decision (MCP-over-stdio /
// direct pgxpool against mbrian.* / in-process submodule) is the first
// thing slice B's impl chooses, then this struct grows the
// corresponding field.
//
// Per-method comments name the §3 gaps in the contract doc each method
// will need to resolve at impl time.
type MBrianReader struct {
// Reserved for slice-B's mBrian client. Empty struct today so the
// compile-time assertion below stays meaningful.
}
// Compile-time assertion that MBrianReader satisfies ItemReader. Keeps
// the stubs in lockstep with the interface as slice B grows methods.
var _ ItemReader = (*MBrianReader)(nil)
// --- item lookups ---
// ListAll: §2.1 — edge-walk for Item.Paths + Item.ParentIDs per item;
// §2.2 — metadata-unpack across all returned items; §3 — Item.Source
// constant.
func (*MBrianReader) ListAll(ctx context.Context) ([]*Item, error) {
return nil, errNotImplementedSliceB
}
// GetByID: §2.2 metadata-unpack.
func (*MBrianReader) GetByID(ctx context.Context, id string) (*Item, error) {
return nil, errNotImplementedSliceB
}
// GetByPath: §2.1 — walks child_of edges from path's root segment to
// resolve a leaf node. Per-request cache reduces N+1.
func (*MBrianReader) GetByPath(ctx context.Context, path string) (*Item, error) {
return nil, errNotImplementedSliceB
}
// GetByPathOrSlug: GetByPath, fall back to slug lookup.
func (*MBrianReader) GetByPathOrSlug(ctx context.Context, key string) (*Item, error) {
return nil, errNotImplementedSliceB
}
// Roots: §2.1 — "no outbound child_of edge" predicate.
func (*MBrianReader) Roots(ctx context.Context) ([]*Item, error) {
return nil, errNotImplementedSliceB
}
// MaiOrphans: §2.1 — Roots ∩ metadata.projax.management ⊇ {'mai'}.
func (*MBrianReader) MaiOrphans(ctx context.Context) ([]*Item, error) {
return nil, errNotImplementedSliceB
}
// ListByFilters: structured search; status/management/has-link/paths-prefix
// dimensions map to metadata.projax.* predicates + edge existence checks.
func (*MBrianReader) ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error) {
return nil, errNotImplementedSliceB
}
// Search: mBrian already has trigram + FTS on title + content_md
// (idx_nodes_fts). Adapter narrows to projax-managed nodes.
func (*MBrianReader) Search(ctx context.Context, q string, limit int) ([]*Item, error) {
return nil, errNotImplementedSliceB
}
// ItemsCreatedInRange: direct over nodes.created_at, scoped to
// metadata.projax_origin IS NOT NULL (or whatever projax-managed marker
// the migration settles on).
func (*MBrianReader) ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error) {
return nil, errNotImplementedSliceB
}
// AllTags: §3 — union over metadata.projax.tags[]. Full-scan at m's
// scale; tag-graph deferred to Phase 7 (m's Q8).
func (*MBrianReader) AllTags(ctx context.Context) ([]string, error) {
return nil, errNotImplementedSliceB
}
// --- link lookups ---
// LinksByType: §2.3 — WHERE source_id=$1 AND rel='projax-'||$2.
func (*MBrianReader) LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error) {
return nil, errNotImplementedSliceB
}
// LinksByRefType: §2.3 — WHERE rel='projax-'||$1.
func (*MBrianReader) LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error) {
return nil, errNotImplementedSliceB
}
// DatedLinks: §2.3 — one item's edges with metadata ? 'event_date'.
func (*MBrianReader) DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error) {
return nil, errNotImplementedSliceB
}
// DatedLinksRange: §2.3 — metadata->>'event_date' BETWEEN $1 AND $2,
// joined with source node for the ItemLinkWithItem shape.
func (*MBrianReader) DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error) {
return nil, errNotImplementedSliceB
}
// RecentDocuments: dated links since $1 ORDER BY event_date DESC LIMIT $2.
func (*MBrianReader) RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error) {
return nil, errNotImplementedSliceB
}

View File

@@ -118,6 +118,29 @@ func bulkMatches(f TreeFilter, it *store.Item, itemLinkKinds map[string]struct{}
return false
}
}
// Phase 5i Slice A: project scope. Same predicate as TreeFilter.Matches —
// at least one of the item's paths must equal ProjectPath, with the
// IncludeDescendants toggle gating the prefix-match for the subtree.
// bulkMatches was a near-clone of Matches() that wasn't updated when
// the project dim landed, so /admin/bulk silently ignored ?project=…
// (and the chip's hidden-input round-trip too).
if f.ProjectPath != "" {
prefix := f.ProjectPath + "."
hit := false
for _, p := range it.Paths {
if p == f.ProjectPath {
hit = true
break
}
if f.IncludeDescendants && strings.HasPrefix(p, prefix) {
hit = true
break
}
}
if !hit {
return false
}
}
if f.Q != "" {
q := strings.ToLower(f.Q)
hit := strings.Contains(strings.ToLower(it.Title), q) ||

View File

@@ -151,6 +151,93 @@ func (s *Server) handleCalDAVUnlink(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/caldav", http.StatusSeeOther)
}
// availableCalendarsForItem returns the discoverable CalDAV calendars
// minus the ones already linked to this item — feeds the per-item
// "Link existing list" picker on the detail page. Errors during
// discovery (network, auth, parse) are surfaced to the caller; callers
// downgrade to an empty list so the rest of the page still renders.
//
// "Already linked" is computed by the caller's `links` slice rather
// than a fresh fetch, since handleDetail/renderTasksSection already
// loaded the per-item caldav-list links inside detailTodos and we
// avoid a second LinksByType round-trip.
func (s *Server) availableCalendarsForItem(ctx context.Context, links []*store.ItemLink) ([]caldav.Calendar, error) {
if s.CalDAV == nil {
return nil, nil
}
cals, err := s.CalDAV.Client.ListCalendars(ctx)
if err != nil {
return nil, err
}
linkedURLs := map[string]struct{}{}
for _, l := range links {
linkedURLs[l.RefID] = struct{}{}
}
out := make([]caldav.Calendar, 0, len(cals))
for _, c := range cals {
if _, already := linkedURLs[c.URL]; already {
continue
}
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].DisplayName < out[j].DisplayName })
return out, nil
}
// handleCalDAVLinkExisting handles POST /i/{path}/caldav/link-existing —
// the per-item picker for sharing an existing CalDAV list across
// multiple projax items. Re-runs ListCalendars to validate that the
// submitted URL is genuinely discoverable (defence against a crafted
// form pointing at an arbitrary URL), then inserts the item_link.
func (s *Server) handleCalDAVLinkExisting(w http.ResponseWriter, r *http.Request, path string) {
if s.CalDAV == nil {
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
calURL := strings.TrimSpace(r.FormValue("calendar_url"))
if calURL == "" {
http.Error(w, "calendar_url required", http.StatusBadRequest)
return
}
// Validate the URL is in the discoverable set — a malicious form must
// not be able to seed an item_link pointing at arbitrary HTTP servers.
cals, err := s.CalDAV.Client.ListCalendars(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
var matched *caldav.Calendar
for i := range cals {
if cals[i].URL == calURL {
matched = &cals[i]
break
}
}
if matched == nil {
http.Error(w, "calendar not in discoverable set", http.StatusBadRequest)
return
}
meta := map[string]any{
"display_name": matched.DisplayName,
"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 {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
}
// handleCalDAVCreate handles POST /i/{path}/caldav/create — MKCALENDAR on
// dav.msbls.de derived from the item slug, then the item_link insert.
func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path string) {
@@ -231,6 +318,22 @@ func (s *Server) detailTodos(ctx context.Context, item *store.Item) ([]calendarT
s.Logger.Warn("caldav todos", "calendar", l.RefID, "err", err)
continue
}
// Phase 5j per-item filter: when the linked list contains ANY
// projax-tagged VTODO it's a managed list — narrow to entries
// carrying this item's `projax:<path>` tag. A list with zero
// projax tags is a legacy/unmanaged list and renders unfiltered
// (existing pre-5j behaviour, untouched). The cutoff still
// applies to DoneRecent on the post-filter slice.
if caldav.AnyTodoHasProjaxTag(todos) {
want := item.PrimaryPath()
filtered := todos[:0:0]
for _, td := range todos {
if caldav.HasProjaxTagFor(td, want) {
filtered = append(filtered, td)
}
}
todos = filtered
}
ct := calendarTasks{
CalendarURL: l.RefID,
DisplayName: linkDisplay(l),
@@ -310,7 +413,14 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
banner = "Cannot create task with empty summary."
break
}
edit := caldav.VTodoEdit{Summary: &summary}
// Phase 5j tag-on-create: every VTODO created from a per-item Add
// form gets `projax:<primary-path>` in CATEGORIES so multiple
// projax items can share one CalDAV list and the per-item filter
// only surfaces the right ones.
edit := caldav.VTodoEdit{
Summary: &summary,
Categories: []string{caldav.ProjaxCategoryFor(it.PrimaryPath())},
}
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
if t, ok := parseDueInput(dueStr); ok {
edit.Due = &t
@@ -426,11 +536,27 @@ func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it *
s.fail(w, r, err)
return
}
// HTMX swaps re-render the section in place; the picker needs the same
// AvailableCalendars data the full /i/{path} render computes. Errors
// here are non-fatal — degrade to an empty picker.
var available []caldav.Calendar
if s.CalDAV != nil {
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if lerr != nil {
s.Logger.Warn("tasks-section caldav links", "path", it.PrimaryPath(), "err", lerr)
}
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
if aerr != nil {
s.Logger.Warn("tasks-section available caldav", "path", it.PrimaryPath(), "err", aerr)
}
available = acs
}
data := map[string]any{
"Item": it,
"Tasks": tasks,
"CalDAVOn": s.CalDAV != nil,
"Banner": banner,
"Item": it,
"Tasks": tasks,
"AvailableCalendars": available,
"CalDAVOn": s.CalDAV != nil,
"Banner": banner,
}
s.render(w, r, "tasks_section", data)
}

View File

@@ -0,0 +1,419 @@
package web_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/m/projax/caldav"
"github.com/m/projax/web"
)
// fakeCalDAVServer is a minimal in-memory CalDAV server: a PROPFIND on
// /dav/calendars/m/ returns a fixed two-calendar list, REPORT on each
// calendar returns whichever VTODOs the test seeded into todos[url],
// and PUT to a calendar URL captures the body so the test can assert
// on what projax wrote. Mirrors the pattern in dashboard_events_test.go
// but tailored to the Phase 5j flows.
type fakeCalDAVServer struct {
mu sync.Mutex
srv *httptest.Server
calendars []caldav.Calendar
todos map[string][]string // calendarURL → list of VTODO ICS docs
puts map[string]string // url → body of the latest PUT to that url
}
func newFakeCalDAVServer(t *testing.T, cals []caldav.Calendar) *fakeCalDAVServer {
t.Helper()
f := &fakeCalDAVServer{
todos: map[string][]string{},
puts: map[string]string{},
}
mux := http.NewServeMux()
mux.HandleFunc("/dav/calendars/m/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PROPFIND" {
f.mu.Lock()
cs := f.calendars
f.mu.Unlock()
w.WriteHeader(207)
_, _ = io.WriteString(w, propfindMultistatus(cs))
return
}
http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed)
})
// Per-calendar handler. Keyed by URL PATH so both the registration
// loop and the test's seed lookup (`fake.todos[calURL]`) resolve to
// the same map entry regardless of how the httptest host gets baked
// into the full URL.
for _, c := range cals {
path := urlPathOf(c.URL)
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "REPORT":
f.mu.Lock()
body := buildReportMultistatus(path, f.todos[path])
f.mu.Unlock()
w.WriteHeader(207)
_, _ = io.WriteString(w, body)
case "PUT":
body, _ := io.ReadAll(r.Body)
f.mu.Lock()
f.puts[r.URL.String()] = string(body)
f.todos[path] = append(f.todos[path], string(body))
f.mu.Unlock()
w.Header().Set("ETag", `"fresh"`)
w.WriteHeader(http.StatusCreated)
default:
http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed)
}
})
}
f.srv = httptest.NewServer(mux)
f.calendars = make([]caldav.Calendar, len(cals))
// Rewrite URLs to point at the httptest server's host.
for i, c := range cals {
f.calendars[i] = caldav.Calendar{
URL: f.srv.URL + urlPathOf(c.URL),
HRef: urlPathOf(c.URL),
DisplayName: c.DisplayName,
Color: c.Color,
}
}
t.Cleanup(f.srv.Close)
return f
}
func urlPathOf(absURL string) string {
u, _ := url.Parse(absURL)
return u.Path
}
// propfindMultistatus builds the PROPFIND response for the slice of
// calendars. Includes the collection itself + each calendar entry, plus
// an "inbox" non-calendar that ListCalendars must filter out.
func propfindMultistatus(cals []caldav.Calendar) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
b.WriteString(`<d:response><d:href>/dav/calendars/m/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
for _, c := range cals {
b.WriteString(`<d:response><d:href>` + urlPathOf(c.URL) + `</d:href><d:propstat><d:prop><d:displayname>` + c.DisplayName + `</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
}
b.WriteString(`</d:multistatus>`)
return b.String()
}
// buildReportMultistatus wraps a slice of VTODO ICS docs into a REPORT
// multistatus body, one <d:response> per VTODO.
func buildReportMultistatus(calPath string, vtodos []string) string {
if len(vtodos) == 0 {
return `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav"></d:multistatus>`
}
var b strings.Builder
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
for i, ics := range vtodos {
b.WriteString(`<d:response><d:href>` + calPath + "t" + itoa(i) + `.ics</d:href><d:propstat><d:prop><d:getetag>"e` + itoa(i) + `"</d:getetag><cal:calendar-data>`)
b.WriteString(ics)
b.WriteString(`</cal:calendar-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
}
b.WriteString(`</d:multistatus>`)
return b.String()
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
neg := false
if n < 0 {
neg = true
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// seedItemUnderDev inserts a fresh projax item under dev and returns
// its id + primary path. Callers defer cleanup.
func seedItemUnderDev(t *testing.T, pool *pgxpool.Pool, slug, title string) (id, primaryPath string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var dev 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,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[])
returning id`,
title, slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
return id, "dev." + slug
}
// TestDetailLinkExistingCalendar walks the original ask end-to-end:
// 1. Fake CalDAV server exposes 3 calendars + zero VTODOs.
// 2. Seed an unlinked projax item under dev.
// 3. GET /i/{path} — assert the "link existing" <select> renders with
// all 3 calendars.
// 4. POST /i/{path}/caldav/link-existing with one URL.
// 5. GET /i/{path} again — assert the linked URL is gone from the
// picker (already linked) but appears in the tasks section.
func TestDetailLinkExistingCalendar(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
cals := []caldav.Calendar{
{URL: "https://dav.test/dav/calendars/m/Family/", DisplayName: "Family"},
{URL: "https://dav.test/dav/calendars/m/Travel/", DisplayName: "Travel"},
{URL: "https://dav.test/dav/calendars/m/Vacations-2026/", DisplayName: "Vacations 2026"},
}
fake := newFakeCalDAVServer(t, cals)
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "caldav-link-" + stamp
id, primary := seedItemUnderDev(t, pool, slug, "Caldav link test")
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
h := srv.Routes()
// Step 3: picker renders with three calendars.
_, body := get(t, h, "/i/"+primary)
for _, want := range []string{
`action="/i/` + primary + `/caldav/link-existing"`,
`>Family<`,
`>Travel<`,
`>Vacations 2026<`,
`+ Create new list`,
} {
if !strings.Contains(body, want) {
t.Errorf("unlinked detail page missing %q", want)
}
}
// Step 4: POST link-existing. Pick the Vacations 2026 calendar.
pickedURL := fake.calendars[2].URL
form := url.Values{"calendar_url": {pickedURL}}
resp, _ := post(t, h, "/i/"+primary+"/caldav/link-existing", form)
if resp != http.StatusSeeOther {
t.Fatalf("link-existing POST → %d, want 303", resp)
}
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1 and ref_id=$2`, id, pickedURL)
// Step 5: picker no longer offers Vacations 2026 (already linked);
// the tasks section now shows the linked calendar's block.
_, body = get(t, h, "/i/"+primary)
if strings.Contains(body, `<option value="`+pickedURL+`">Vacations 2026</option>`) {
t.Errorf("picker should NOT offer the already-linked Vacations 2026 URL")
}
if !strings.Contains(body, "Vacations 2026") {
t.Errorf("tasks section should display the linked Vacations 2026 list")
}
if !strings.Contains(body, `data-cal="`+pickedURL+`"`) {
t.Errorf("tasks section missing cal-block for the linked URL")
}
}
// TestVTodoCreateAttachesProjaxCategory exercises the tag-on-create
// half of Phase 5j. Posting the Add-task form from /i/{path} must send
// a VTODO whose CATEGORIES contains `projax:<path>` so a shared list
// can later be filtered per-item.
func TestVTodoCreateAttachesProjaxCategory(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
cals := []caldav.Calendar{
{URL: "https://dav.test/dav/calendars/m/Shared/", DisplayName: "Shared"},
}
fake := newFakeCalDAVServer(t, cals)
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "caldav-tag-" + stamp
id, primary := seedItemUnderDev(t, pool, slug, "Tag-on-create test")
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
calURL := fake.calendars[0].URL
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
values ($1, 'caldav-list', $2, 'contains')`,
id, calURL,
); err != nil {
t.Fatalf("seed link: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1`, id)
h := srv.Routes()
form := url.Values{
"calendar_url": {calURL},
"summary": {"Buy travel gear"},
}
resp, _ := post(t, h, "/i/"+primary+"/caldav/todo/todo-create", form)
if resp != http.StatusSeeOther && resp != http.StatusOK {
t.Fatalf("todo-create POST → %d", resp)
}
// Inspect what the fake CalDAV server received.
fake.mu.Lock()
defer fake.mu.Unlock()
if len(fake.puts) == 0 {
t.Fatalf("expected at least one PUT to the fake CalDAV server")
}
var got string
for _, body := range fake.puts {
got = body
break
}
wantTag := "projax:" + primary
if !strings.Contains(got, "CATEGORIES:"+wantTag) {
t.Errorf("PUT body missing CATEGORIES tag %q. Body:\n%s", wantTag, got)
}
}
// TestDetailFilterByProjaxCategory exercises the read-side filter:
// when the linked list has ANY projax: tag, the detail page only shows
// the VTODOs whose CATEGORIES include THIS item's tag. VTODOs tagged
// for OTHER items must NOT leak through.
func TestDetailFilterByProjaxCategory(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
cals := []caldav.Calendar{
{URL: "https://dav.test/dav/calendars/m/Vacations-2026/", DisplayName: "Vacations 2026"},
}
fake := newFakeCalDAVServer(t, cals)
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
calURL := fake.calendars[0].URL
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
idA, primaryA := seedItemUnderDev(t, pool, "trip-a-"+stamp, "Trip A")
idB, primaryB := seedItemUnderDev(t, pool, "trip-b-"+stamp, "Trip B")
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, idA, idB)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, id := range []string{idA, idB} {
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
values ($1, 'caldav-list', $2, 'contains')`,
id, calURL,
); err != nil {
t.Fatalf("seed link: %v", err)
}
}
defer pool.Exec(context.Background(), `delete from projax.item_links where ref_id=$1`, calURL)
// Three VTODOs on the SHARED list: one tagged for A, one for B, one
// for both.
tagA := "projax:" + primaryA
tagB := "projax:" + primaryB
fake.mu.Lock()
fake.todos[urlPathOf(calURL)] = []string{
todoICS("uid-only-a", "Book flight A", []string{tagA}),
todoICS("uid-only-b", "Book flight B", []string{tagB}),
todoICS("uid-shared", "Travel insurance", []string{tagA, tagB}),
}
fake.mu.Unlock()
h := srv.Routes()
_, body := get(t, h, "/i/"+primaryA)
if !strings.Contains(body, "Book flight A") {
t.Errorf("Trip A detail missing tagged-A summary")
}
if strings.Contains(body, "Book flight B") {
t.Errorf("Trip A detail leaked tagged-B summary — filter broken")
}
if !strings.Contains(body, "Travel insurance") {
t.Errorf("Trip A detail missing dual-tagged summary (multi-tag contract)")
}
// Trip B sees the mirror image: B + shared, not A.
_, body = get(t, h, "/i/"+primaryB)
if strings.Contains(body, "Book flight A") {
t.Errorf("Trip B detail leaked tagged-A summary")
}
if !strings.Contains(body, "Book flight B") {
t.Errorf("Trip B detail missing tagged-B summary")
}
if !strings.Contains(body, "Travel insurance") {
t.Errorf("Trip B detail missing dual-tagged summary")
}
}
// TestDetailUntaggedListShowsAll proves the legacy fallback: a linked
// list with ZERO projax: tags is treated as unmanaged — every VTODO
// renders, untouched. Without this users with pre-5j lists would see
// the detail page suddenly hide all their existing tasks.
func TestDetailUntaggedListShowsAll(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
cals := []caldav.Calendar{
{URL: "https://dav.test/dav/calendars/m/Home/", DisplayName: "Home"},
}
fake := newFakeCalDAVServer(t, cals)
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
calURL := fake.calendars[0].URL
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
id, primary := seedItemUnderDev(t, pool, "home-legacy-"+stamp, "Home legacy")
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
values ($1, 'caldav-list', $2, 'contains')`,
id, calURL,
); err != nil {
t.Fatalf("seed link: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1`, id)
fake.mu.Lock()
fake.todos[urlPathOf(calURL)] = []string{
todoICS("legacy-1", "Pick up bread", nil),
todoICS("legacy-2", "Call dentist", []string{"home", "errands"}),
}
fake.mu.Unlock()
h := srv.Routes()
_, body := get(t, h, "/i/"+primary)
if !strings.Contains(body, "Pick up bread") {
t.Errorf("untagged-list detail missing legacy todo 'Pick up bread'")
}
if !strings.Contains(body, "Call dentist") {
t.Errorf("untagged-list detail missing legacy todo with non-projax categories")
}
}
// todoICS builds a minimal VTODO ICS doc with optional CATEGORIES.
func todoICS(uid, summary string, categories []string) string {
cat := ""
if len(categories) > 0 {
cat = "CATEGORIES:" + strings.Join(categories, ",") + "\r\n"
}
return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + uid + "\r\nSUMMARY:" + summary + "\r\nSTATUS:NEEDS-ACTION\r\n" + cat + "END:VTODO\r\nEND:VCALENDAR"
}

123
web/new_form_test.go Normal file
View File

@@ -0,0 +1,123 @@
package web_test
import (
"strings"
"testing"
)
// TestNewFormPreselectsParent reproduces m's bug report: GET /new?parent=admin
// must render the Parents <select> populated with the full project list AND
// pre-select the option whose value matches admin's item id. Pre-fix the
// handler passed no ParentOptions to the template, so the <select> was empty
// and there was nothing to pre-select.
func TestNewFormPreselectsParent(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/new?parent=admin")
if code != 200 {
t.Fatalf("GET /new?parent=admin → %d body=%s", code, body)
}
// The Parents <select> must be populated. admin is a root area present
// in every projax instance — its option should be there.
if !strings.Contains(body, `<option value="`) {
t.Fatalf("Parents <select> is empty — no <option> rendered. Body excerpt: %s",
body[strings.Index(body, "parent_ids"):min(len(body), strings.Index(body, "parent_ids")+800)])
}
if !strings.Contains(body, `>admin</option>`) {
t.Errorf("expected an <option>...>admin</option> in the Parents <select>")
}
// The admin option must be the selected one — that's the prefill contract.
// We anchor on the path (rendered as the option label) since the id is a
// uuid we'd otherwise have to look up.
adminIdx := strings.Index(body, `>admin</option>`)
if adminIdx < 0 {
t.Fatalf("admin option not found in rendered Parents select")
}
// Look back ~200 chars to the <option ... selected> opening tag.
from := adminIdx - 200
if from < 0 {
from = 0
}
openingTag := body[from:adminIdx]
if !strings.Contains(openingTag, "selected") {
t.Errorf("admin <option> not marked selected; opening tag was: %s", openingTag)
}
// And other unrelated options must NOT be selected. Pick `dev` (another
// root area) as the counter-anchor.
devIdx := strings.Index(body, `>dev</option>`)
if devIdx >= 0 {
from := devIdx - 200
if from < 0 {
from = 0
}
devTag := body[from:devIdx]
if strings.Contains(devTag, "selected") {
t.Errorf("dev <option> should NOT be selected when ?parent=admin; opening tag was: %s", devTag)
}
}
}
// TestNewFormHasSlugSuggestScript pins the Phase 5k slug auto-suggest:
// the new-item template ships an inline <script> that derives a
// kebab-case slug from the title as the user types and stops syncing
// once the slug is edited manually. Without this guard a future
// template refactor could silently strip the script.
func TestNewFormHasSlugSuggestScript(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/new")
for _, want := range []string{
`id="new-title"`,
`id="new-slug"`,
// Algorithm signatures we don't want a "harmless cleanup" pass
// to drop quietly.
"normalize('NFD')",
"replace(/ß/g, 'ss')",
"replace(/[^a-z0-9]+/g, '-')",
"slice(0, 63)",
"dataset.userEdited",
} {
if !strings.Contains(body, want) {
t.Errorf("new-item template missing slug-suggest fragment %q", want)
}
}
}
// TestNewFormNoParentParamRendersAllOptions confirms the Parents <select>
// is populated even when no ?parent= is supplied — clicking "+ New" from the
// nav should still let the user pick any parent.
func TestNewFormNoParentParamRendersAllOptions(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/new")
if code != 200 {
t.Fatalf("GET /new → %d", code)
}
// At least one option exists.
if !strings.Contains(body, `<option value="`) {
t.Fatalf("Parents <select> is empty on /new (no ?parent= param)")
}
// Nothing pre-selected.
if strings.Contains(body, `<option value="`) && strings.Contains(body, `" selected>`) {
// Make sure no Parents <select> option is selected — Status options
// might use selected for the default, so anchor on parent_ids context.
pIdx := strings.Index(body, `name="parent_ids"`)
if pIdx >= 0 {
selectClose := strings.Index(body[pIdx:], `</select>`)
if selectClose > 0 {
parentBlock := body[pIdx : pIdx+selectClose]
if strings.Contains(parentBlock, "selected") {
t.Errorf("no Parents option should be selected on bare /new, but block contains 'selected': %s", parentBlock)
}
}
}
}
}

266
web/project_filter_test.go Normal file
View File

@@ -0,0 +1,266 @@
package web_test
import (
"context"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// projectFixture seeds a subtree shaped:
//
// dev/ (existing)
// <stamp>-root (root of the test subtree)
// <stamp>-child (descendant of root)
// <stamp>-outside (sibling of root under dev — NOT a descendant)
//
// Returns the slugs + primary paths. Callers defer the row cleanup.
type projectFixture struct {
rootSlug, childSlug, outsideSlug string
rootPath, childPath, outsidePath string
rootID, childID, outsideID string
}
func seedProjectFixture(t *testing.T, pool *pgxpool.Pool) projectFixture {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var dev 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)
}
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
fx := projectFixture{
rootSlug: "proj-root-" + stamp,
childSlug: "proj-child-" + stamp,
outsideSlug: "proj-outside-" + stamp,
}
// root + outside both live directly under dev.
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
returning id`,
fx.rootSlug, dev,
).Scan(&fx.rootID); err != nil {
t.Fatalf("seed root: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
returning id`,
fx.outsideSlug, dev,
).Scan(&fx.outsideID); err != nil {
t.Fatalf("seed outside: %v", err)
}
// child lives under root.
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
returning id`,
fx.childSlug, fx.rootID,
).Scan(&fx.childID); err != nil {
t.Fatalf("seed child: %v", err)
}
fx.rootPath = "dev." + fx.rootSlug
fx.childPath = fx.rootPath + "." + fx.childSlug
fx.outsidePath = "dev." + fx.outsideSlug
return fx
}
func cleanupProjectFixture(pool *pgxpool.Pool, fx projectFixture) {
pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID)
}
// TestProjectFilterNarrowsTree exercises the / (tree) handler — applyTreeFilter
// passes the project filter through TreeFilter.Matches, so ?project=<root>
// must show only root + descendants.
func TestProjectFilterNarrowsTree(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
h := srv.Routes()
_, body := get(t, h, "/?project="+fx.rootPath)
if !strings.Contains(body, fx.rootPath) {
t.Errorf("tree ?project=<root> missing root path %q", fx.rootPath)
}
if !strings.Contains(body, fx.childPath) {
t.Errorf("tree ?project=<root> missing child path %q (descendants default ON)", fx.childPath)
}
if strings.Contains(body, fx.outsidePath) {
t.Errorf("tree ?project=<root> leaked outside path %q", fx.outsidePath)
}
}
// TestProjectFilterNarrowsTimeline — buildTimeline funnels items via
// q.Filter.Matches before fan-out, so ?project=<root> must drop the
// creation row for the outside sibling but keep root + child.
func TestProjectFilterNarrowsTimeline(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
h := srv.Routes()
_, body := get(t, h, "/timeline?refresh=1&project="+fx.rootPath)
if !strings.Contains(body, fx.rootPath) {
t.Errorf("timeline ?project=<root> missing root creation row")
}
if !strings.Contains(body, fx.childPath) {
t.Errorf("timeline ?project=<root> missing child creation row")
}
if strings.Contains(body, fx.outsidePath) {
t.Errorf("timeline ?project=<root> leaked outside creation row %q", fx.outsidePath)
}
}
// TestProjectFilterNarrowsCalendar — buildCalendar funnels items via
// q.Filter.Matches; rows surface from dated item_links. Seed a dated link
// on each fixture item, then verify scoping by ?project=<root>.
func TestProjectFilterNarrowsCalendar(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rootNote := "cal-root-" + fx.rootSlug
childNote := "cal-child-" + fx.childSlug
outsideNote := "cal-outside-" + fx.outsideSlug
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
values ($1, 'document', $2, 'contains', $3, current_date),
($4, 'document', $5, 'contains', $6, current_date),
($7, 'document', $8, 'contains', $9, current_date)`,
fx.rootID, "https://example.com/cal-root", rootNote,
fx.childID, "https://example.com/cal-child", childNote,
fx.outsideID, "https://example.com/cal-outside", outsideNote,
); err != nil {
t.Fatalf("seed dated links: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID)
h := srv.Routes()
_, body := get(t, h, "/calendar?refresh=1&project="+fx.rootPath)
if !strings.Contains(body, rootNote) {
t.Errorf("calendar ?project=<root> missing root note %q", rootNote)
}
if !strings.Contains(body, childNote) {
t.Errorf("calendar ?project=<root> missing child note %q (descendants ON)", childNote)
}
if strings.Contains(body, outsideNote) {
t.Errorf("calendar ?project=<root> leaked outside note %q", outsideNote)
}
}
// TestProjectFilterNarrowsDashboard — dashboard filters items via Matches
// when q.Filter.Active() is true. The Stale-projects card is the most
// reliable surface to verify since it iterates the full item set on
// every render.
func TestProjectFilterNarrowsDashboard(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
h := srv.Routes()
_, body := get(t, h, "/dashboard?project="+fx.rootPath)
if !strings.Contains(body, fx.rootPath) {
t.Errorf("dashboard ?project=<root> missing root path %q", fx.rootPath)
}
if strings.Contains(body, fx.outsidePath) {
t.Errorf("dashboard ?project=<root> leaked outside path %q", fx.outsidePath)
}
}
// TestProjectFilterNarrowsBulk reproduces the actual bug: /admin/bulk's
// bulkMatches was a near-clone of TreeFilter.Matches that never picked up
// the Phase 5i Slice A ProjectPath block, so ?project=<root> silently
// ignored the filter. Pre-fix the outside item leaked into the bulk list.
func TestProjectFilterNarrowsBulk(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
h := srv.Routes()
_, body := get(t, h, "/admin/bulk?project="+fx.rootPath)
if !strings.Contains(body, fx.rootPath) {
t.Errorf("bulk ?project=<root> missing root path %q", fx.rootPath)
}
if !strings.Contains(body, fx.childPath) {
t.Errorf("bulk ?project=<root> missing child path %q (descendants ON)", fx.childPath)
}
if strings.Contains(body, fx.outsidePath) {
t.Errorf("BUG: /admin/bulk ?project=<root> leaked outside path %q — bulkMatches missing the ProjectPath gate", fx.outsidePath)
}
}
// TestProjectFilterDescendantsToggle pins m's Q5 pick: the toggle is
// exposed explicitly. With project_descendants=0 the filter narrows to
// the single root item only — the child path must drop out.
func TestProjectFilterDescendantsToggle(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
fx := seedProjectFixture(t, pool)
defer cleanupProjectFixture(pool, fx)
h := srv.Routes()
// Default (descendants on) — child included.
_, on := get(t, h, "/?project="+fx.rootPath)
if !strings.Contains(on, fx.childPath) {
t.Errorf("descendants=on should include child path %q", fx.childPath)
}
// Toggled off — child dropped, root still in.
_, off := get(t, h, "/?project="+fx.rootPath+"&project_descendants=0")
if !strings.Contains(off, fx.rootPath) {
t.Errorf("descendants=off should still include root %q", fx.rootPath)
}
if strings.Contains(off, fx.childPath) {
t.Errorf("descendants=off leaked child path %q — IncludeDescendants gate not honoured", fx.childPath)
}
}
// TestTimelineKindMultiValueSurvives mirrors the earlier calendar-filter
// fix: <select multiple> chip submission emits `?kind=event&kind=doc`,
// and the timeline's previous q.Get("kind") + comma-split dropped every
// value past the first. parseValues threads BOTH URL shapes through.
func TestTimelineKindMultiValueSurvives(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// We probe at the parse level via the page render: a `?kind=event&kind=doc`
// URL must round-trip both kinds into q.Kinds, so the kind multi-select
// in the rendered form preserves BOTH as selected options.
_, body := get(t, h, "/timeline?kind=event&kind=doc")
// The timeline's chip-strip <select> emits `<option value="x" selected>`
// only when q.Kinds contains "x". Pre-fix only the first value
// survived, so the second option lost its selected attr. The template
// has whitespace padding between value and selected so we anchor on
// the `value="X"` + `selected` pair within a small window — the
// `</option>` for the same X then closes the option.
checkSelected := func(kind string) {
idx := strings.Index(body, `<option value="`+kind+`"`)
if idx < 0 {
t.Errorf("rendered form missing <option value=%q>", kind)
return
}
// Slice until the following </option>; the selected attribute, if
// present, lives in that window.
end := strings.Index(body[idx:], `</option>`)
if end < 0 {
t.Errorf("rendered form malformed near <option value=%q>", kind)
return
}
window := body[idx : idx+end]
if !strings.Contains(window, "selected") {
t.Errorf("?kind=event&kind=doc lost %q selection: window=%q", kind, window)
}
}
checkSelected("event")
checkSelected("doc")
}

View File

@@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/internal/cache"
"github.com/m/projax/internal/itemwrite"
@@ -589,6 +590,22 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
if err != nil {
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
}
// Phase 5j: pre-load discoverable CalDAV calendars (minus the ones
// already linked) so the per-item Tasks section can offer a "Link
// existing list" picker alongside the create-new affordance. Errors
// are non-fatal — the section falls back to its pre-5j shape.
var availableCalendars []caldav.Calendar
if s.CalDAV != nil {
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if lerr != nil {
s.Logger.Warn("detail caldav links", "path", it.PrimaryPath(), "err", lerr)
}
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
if aerr != nil {
s.Logger.Warn("detail available caldav", "path", it.PrimaryPath(), "err", aerr)
}
availableCalendars = acs
}
issues, err := s.detailIssues(r.Context(), it)
if err != nil {
s.Logger.Warn("detail issues", "path", it.PrimaryPath(), "err", err)
@@ -607,9 +624,10 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
"Item": it,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
"Tasks": tasks,
"CalDAVOn": s.CalDAV != nil,
"Issues": issues,
"Tasks": tasks,
"AvailableCalendars": availableCalendars,
"CalDAVOn": s.CalDAV != nil,
"Issues": issues,
"IssuesOpenTotal": openTotal,
"GiteaOn": s.Gitea != nil,
"Documents": documents,
@@ -627,6 +645,10 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
s.handleCalDAVCreate(w, r, base)
return
}
if base, ok := strings.CutSuffix(path, "/caldav/link-existing"); ok {
s.handleCalDAVLinkExisting(w, r, base)
return
}
for _, action := range []string{"complete", "reopen", "edit", "delete", "todo-create"} {
if base, ok := strings.CutSuffix(path, "/caldav/todo/"+action); ok {
s.handleCalDAVTodoAction(w, r, base, action)
@@ -877,9 +899,18 @@ func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
}
parent = p
}
// new.tmpl iterates {{range .ParentOptions}} to render the Parents
// <select>. Without this the dropdown was empty and `?parent=admin`
// had nothing to pre-select — the symptom m hit.
parents, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "new", map[string]any{
"Title": "new",
"Parent": parent,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
})
}

View File

@@ -2,10 +2,10 @@
<h1>New item</h1>
<p class="meta">Suggested parent: <strong>{{if .Parent}}{{.Parent.PrimaryPath}}{{else}}(root){{end}}</strong></p>
<form method="post" action="/new" class="edit">
<form method="post" action="/new" class="edit" id="new-item-form">
<input type="hidden" name="kind" value="project">
<label>Title <input name="title" required></label>
<label>Slug <input name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
<label>Title <input id="new-title" name="title" required></label>
<label>Slug <input id="new-slug" name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — leave empty for a root item)</small>
<select name="parent_ids" multiple size="6">
{{range .ParentOptions}}
@@ -32,4 +32,38 @@
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.PrimaryPath}}{{else}}/{{end}}">Cancel</a>
</div>
</form>
<script>
// Phase 5k: auto-suggest a kebab-case slug from Title as the user types.
// Strips diacritics (Müller → muller, São → sao), German ß → ss, collapses
// any non-alphanumeric run into a single hyphen, trims edge hyphens, caps
// at the 63-char limit the itemwrite validator enforces. Once the user
// edits the slug manually, the sync stops — typing in Title no longer
// clobbers their override. A pre-filled slug also counts as user-edited
// (rare for /new but defensive).
(function() {
var title = document.getElementById('new-title');
var slug = document.getElementById('new-slug');
if (!title || !slug) return;
function kebab(s) {
return s
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-')
.slice(0, 63);
}
if (slug.value && slug.value.length > 0) {
slug.dataset.userEdited = '1';
}
title.addEventListener('input', function() {
if (slug.dataset.userEdited === '1') return;
slug.value = kebab(title.value);
});
slug.addEventListener('input', function() {
slug.dataset.userEdited = '1';
});
})();
</script>
{{end}}

View File

@@ -94,9 +94,29 @@
{{end}}
{{else}}
<p class="muted">No CalDAV list linked.</p>
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
<button type="submit">Create CalDAV list</button>
</form>
{{end}}
{{/* Phase 5j: per-item picker for sharing an existing list across
multiple projax items (e.g. one "Vacations 2026" list under
several admin.vacations sub-items). Renders in BOTH states:
unlinked items see it next to Create-new; already-linked items
see it as "+ link another" for the multi-list flow. */}}
<div class="caldav-actions">
{{if .AvailableCalendars}}
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/link-existing" class="caldav-link-existing inline">
<label class="visually-hidden" for="caldav-link-existing-select">Link existing CalDAV list</label>
<select id="caldav-link-existing-select" name="calendar_url" required>
<option value="">— link existing list —</option>
{{range .AvailableCalendars}}<option value="{{.URL}}">{{.DisplayName}}</option>{{end}}
</select>
<button type="submit">Link</button>
</form>
{{end}}
{{if not .Tasks}}
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
<button type="submit">+ Create new list</button>
</form>
{{end}}
</div>
</section>
{{end}}

View File

@@ -149,20 +149,19 @@ func parseTimelineQuery(r *http.Request, now time.Time) TimelineQuery {
q.From = startOfDay(now)
}
}
if v := strings.TrimSpace(r.URL.Query().Get("kind")); v != "" {
seen := map[string]bool{}
for _, k := range strings.Split(v, ",") {
k = strings.TrimSpace(strings.ToLower(k))
switch k {
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
if !seen[k] {
seen[k] = true
q.Kinds = append(q.Kinds, k)
}
}
// Accept both `?kind=event,doc` (comma-joined) and
// `?kind=event&kind=doc` (HTMX multi-select submission). The earlier
// q.Get + comma-split flavour dropped everything past the first value
// when the chip strip's <select multiple> submitted — same pre-5d
// shape calendar's parser carried before commit 6f0a318. parseValues
// (web/server.go) merges both URL styles into a single slice.
for _, k := range parseValues(r.URL.Query(), "kind") {
switch k {
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
q.Kinds = append(q.Kinds, k)
}
sort.Strings(q.Kinds)
}
sort.Strings(q.Kinds)
return q
}

142
web/timeline_filter_test.go Normal file
View File

@@ -0,0 +1,142 @@
package web_test
import (
"context"
"strings"
"testing"
"time"
)
// TestTimelineFilterNarrowsByTag reproduces m's bug report: `/timeline?tag=work`
// should narrow the spine to only work-tagged items. Pre-fix the page rendered
// every dated row regardless of filter.
func TestTimelineFilterNarrowsByTag(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
tagWork := "tl-bug-work-" + stamp
tagHome := "tl-bug-home-" + stamp
var dev 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)
}
type seed struct {
slug, note, tag string
}
seeds := []seed{
{slug: "tl-work-" + stamp, note: "tl-work-note-" + stamp, tag: tagWork},
{slug: "tl-home-" + stamp, note: "tl-home-note-" + stamp, tag: tagHome},
}
var ids []string
for _, s := range seeds {
var id string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, tags)
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], ARRAY[$4]::text[])
returning id`,
s.slug, s.slug, dev, s.tag,
).Scan(&id); err != nil {
t.Fatalf("seed %s: %v", s.slug, err)
}
ids = append(ids, id)
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
values ($1, 'document', $2, 'contains', $3, current_date)`,
id, "https://example.com/tl-"+s.slug, s.note,
); err != nil {
t.Fatalf("seed link %s: %v", s.slug, err)
}
}
for _, id := range ids {
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
}
// Unfiltered: both notes should show.
_, all := get(t, h, "/timeline?refresh=1")
if !strings.Contains(all, seeds[0].note) || !strings.Contains(all, seeds[1].note) {
t.Fatalf("baseline timeline missing seeded notes; body excerpt: %s", truncate(all, 600))
}
// Filtered: ?tag=tagWork should drop the home note.
_, scoped := get(t, h, "/timeline?refresh=1&tag="+tagWork)
if !strings.Contains(scoped, seeds[0].note) {
t.Errorf("filtered timeline missing work note %q", seeds[0].note)
}
if strings.Contains(scoped, seeds[1].note) {
t.Errorf("BUG: /timeline?tag=%s leaked home note %q — filter didn't narrow",
tagWork, seeds[1].note)
}
}
// TestTimelineFilterByKindMultiValue exercises the kind chip via HTMX-style
// repeated-param submission (?kind=todo&kind=doc). Pre-fix the timeline's
// own ?kind parser used q.Get("kind") which dropped everything past the
// first value — same root cause as the calendar's pre-5d kind bug.
func TestTimelineFilterByKindMultiValue(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
var dev 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)
}
slug := "tl-kind-" + stamp
noteText := "tl-kind-doc-note-" + stamp
var id string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[])
returning id`,
slug, slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
values ($1, 'document', $2, 'contains', $3, current_date)`,
id, "https://example.com/tl-kind-"+stamp, noteText,
); err != nil {
t.Fatalf("seed link: %v", err)
}
// HTMX-style repeated kind params: doc AND event selected.
// The seeded item only produces a doc row; the event slot is empty
// for this test (no linked calendar). Both kinds must parse so the
// doc row survives.
_, body := get(t, h, "/timeline?refresh=1&kind=doc&kind=event")
if !strings.Contains(body, noteText) {
t.Errorf("expected ?kind=doc&kind=event to include the seeded doc note %q, body excerpt: %s",
noteText, truncate(body, 600))
}
}
// TestTimelineFilterStripFormHasCorrectTarget asserts the chip strip's HTMX
// wiring is intact — `hx-get="/timeline"`, `hx-target="#timeline-section"`,
// `hx-trigger="change from:select"`. A future template edit that drops one
// of these would silently break in-place chip swapping.
func TestTimelineFilterStripFormHasCorrectTarget(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/timeline")
for _, want := range []string{
`id="timeline-filter"`,
`hx-get="/timeline"`,
`hx-target="#timeline-section"`,
`hx-trigger="change from:select"`,
} {
if !strings.Contains(body, want) {
t.Errorf("timeline filter form missing %q", want)
}
}
}