50 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
9a8ea8f31e feat(views): Phase 5j slice G — show_count badges + icon registry
Per m's v1 picks (2026-05-29):
- Q6 (icon picker): yes, with curated keys + SVG registry.
- Q8 (show_count badge): yes, opt-in checkbox + sidebar badge.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Root causes

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

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

## Fix

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

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

## Tests

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

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

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

## Surfaces double-checked (not broken)

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

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

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

Three layers, one branch:

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

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

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

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

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

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

## Helpers (caldav package, exported)

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

## Tests

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

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

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

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

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

Regression test (web/new_form_test.go):

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

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

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

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

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

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

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

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

Two distinct gaps, both surgically fixed.

## Gap 1 — edit UI missing

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

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

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

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

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

## Tests

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Out of scope for slice D (per design.md §7):
- HTMX modal save UI from any page (the inline-create-on-/views/ form
  works; a modal lands in a polish pass).
- MCP read tools for views (deferred to a follow-up — m manages views
  via the UI).
2026-05-26 13:42:51 +02:00
mAi
0cf630d3aa Merge branch 'mai/kahn/phase-5i-phase-a-design' (phase 5i slice B: view_type URL param + card view on /tree) 2026-05-26 13:36:33 +02:00
mAi
5f712c68d4 feat(views): Phase 5i slice B — view_type URL param + card view on /tree
m's Q1+Q3 picks (2026-05-26): five canonical view_types
(card/list/calendar/kanban/timeline). Slice B introduces the parameter and
the first non-default rendering: card view on /tree shows the filtered set
as a flat tile grid alongside the existing tree forest.

New web/view_type.go owns the enum, per-route allowed set, parser, and
the chip-strip builder. Per the design note, view_type is RENDER state,
not filter state — kept off TreeFilter so the same filter can render as
card or list.

PageViewTypes("/") = {default: list, allowed: [list, card]}.
Dashboard / calendar / timeline are LOCKED to their native shape in
slice B; switching templates on /dashboard for card vs list is mostly
already done via fuller's 5h tabbed-tiles surface and stays as-is for
now (the chip strip surfaces card as the only allowed value there).
Kanban + cross-page list/card swaps land in slice C onwards.

Render:
- handleTree parses `?view_type=` with the per-route catalog, builds
  flatMatchedItems for the card consumer alongside the existing forest.
- tree_section.tmpl gains a view-type chip strip (locked entries shown
  greyed-out with title tooltip) + branches into either `tree-card` or
  the forest based on .ViewType.
- New templates/tree_card.tmpl renders a flat grid of tiles for the
  matched set; per-item field set mirrors the list rendering.
- Hidden `view_type` input added to the search form so chip clicks
  preserve the view choice.

Tests:
- view_type_test.go: parser fallback, per-route catalog, chip strip
  active/locked flags, filter preservation in chip URLs.
- server_test.go: end-to-end dispatch — GET /?view_type=card renders
  tree-card-grid, GET / renders forest, unknown values fall back to
  list. Chip strip present on both views.
2026-05-26 13:36:28 +02:00
mAi
2eba37365b Merge branch 'mai/kahn/phase-5i-phase-a-design' (phase 5i slice A: project filter dim + descendants toggle)
# Conflicts:
#	web/dashboard.go
#	web/server.go
#	web/templates/dashboard_section.tmpl
2026-05-26 13:29:20 +02:00
mAi
13923aadb6 feat(views): Phase 5i slice A — project filter dim + descendants toggle
m's Q5 pick (2026-05-26): project scope on every Views-supporting page,
with descendants exposed as an explicit on/off chip toggle rather than
always-on. Slice A ships the smallest standalone piece of the Views
system; slices B–E (view_type URL param, kanban, saved-views schema,
defaults) follow on the same branch.

TreeFilter grows two fields:
- ProjectPath: scoped item's primary path; "" = no filter.
- IncludeDescendants: default true; flipped via ?project_descendants=0.

Matching extends to path-prefix across `it.Paths` when ProjectPath is
set; equality-only when IncludeDescendants is off. Multi-parent items
pass when ANY of their paths qualifies.

Picker is a shared partial (templates/project_chip.tmpl) that every
Views-supporting filter strip includes (tree, dashboard, timeline,
calendar). Two states: <select> picker when no project is set; active
chip with × clear + descendants on/off chip when scoped. Hidden
inputs added to each form so non-picker chip clicks preserve the
project state. Graph and admin tools are NOT Views consumers (per
design.md / docs/plans/views-system.md §5) and stay untouched.

Test-source edits (per the 5c sharpened rule):
- dashboard_test.go, public_listing_test.go, timeline_test.go: row
  membership assertions tightened from `Contains(body, slug)` to
  `Contains(body, href="/i/path")`. The picker now renders every
  item's primary path inside a <select>, so coarse slug substring
  matches falsely passed across filtered-out picker options. Behaviour
  preserved (filtered rows still don't render); the impl-detail
  assertion moved to the row link.

New tests: TestProjectFilterIncludesDescendants,
TestProjectFilterDescendantsOff, TestParseTreeFilterProjectFields,
TestTreeFilterProjectRoundTrip, TestSetProjectAndToggleHelpers,
TestProjectFilterScopesTreeToDescendants (end-to-end via /).
2026-05-26 13:27:37 +02:00
mAi
9138dfac59 docs: Phase 5i Views — fold in m's decisions on the 9 open Qs
m answered every open question directly via AskUserQuestion (greenlit
for inventor 2026-05-26 13:12). New §8.5 captures the picks + slice
implications. Inventor picks held on 6 of 9; m differed on Q5 (project
filter descendants) — wants an include-descendants toggle on the chip
rather than always-on, so Slice A grows an `IncludeDescendants` field
on TreeFilter + a toggle on the picker chip.

view_type enum locks at 5 (card/list/calendar/kanban/timeline). All
four out-of-scope items stay parked. No other slice changes.
2026-05-26 13:15:53 +02:00
mAi
4e520f44b2 Merge branch 'mai/knuth/detail-page-order' (feat: detail-page field ordering + auxiliary section break) 2026-05-26 13:15:43 +02:00
mAi
1af0990108 feat(detail): reorder fields general→specific, divider before auxiliary
m's report: detail page (/i/{path}) shows Tasks / Issues / Documents
above the edit form, and the form's 9 flat fields read as a wall of
labels rather than a flow. He wants the form first, fields grouped, then
auxiliary read-only sections below a clear visual break.

Reordered top-to-bottom flow:

  h1 + meta
  ▸ form
      General        — Title → Slug → Parents → Status
      Classification — Tags → Management
      Flags          — pinned + archived (inline pair)
      Content        — markdown textarea
      Public listing <details>            (stays inside form: save coherence)
      Timeline behaviour <details>        (stays inside form: save coherence)
      Save / Cancel actions
  ◂ /form
  <hr class="aux-divider">
  ▸ section.aux-sections "Related"
      Tasks <details>      (was above form)
      Issues <details>     (was above form)
      Documents <details>  (was above form)
      reset section state link

web/templates/detail.tmpl:
- Three <section class="form-group"> blocks each with a <h2
  class="form-group-heading"> ID-anchored for aria-labelledby + the
  ordering test. The headings render as small uppercase muted labels —
  visual hierarchy without screaming "FORM".
- Form-bound collapsibles (Public Listing + Timeline behaviour) stay
  inside the form; moving them out would require a separate POST
  endpoint, which the brief explicitly puts out of scope.
- Tasks / Issues / Documents collapsibles moved out of the form, into a
  new <section class="aux-sections"> after a thematic <hr>.
- Reset-section-state link relocated to .aux-reset under the auxiliary
  section since that's where most collapsible state lives now.
- All data-section / data-item-id / proj-section class hooks preserved
  exactly — Phase 4e smart-default + localStorage state semantics
  unchanged.

web/static/style.css:
- .detail-form: column flex, gap 20px between groups for breathing room.
- .form-group-heading: 0.78em uppercase muted with dotted-border-bottom
  separator — looks like an admin-form group header without being
  shouty.
- .form-group-flags: row-flex so pinned + archived sit inline.
- .aux-divider: full-width 1px solid border-top with 32px margin above,
  16px below — the explicit "this is where editable ends" break.
- .aux-sections + .aux-heading + .aux-reset: matched flex layout +
  small "Related" header so the change-of-mode reads without
  squinting.

Tests:
- TestDetailFieldsRenderInOrder (new) — strict-greater index walk
  through every documented anchor: General → Title → Slug → Parents →
  Status → Classification → Tags → Management → Flags → pinned →
  archived → Content → content_md → Save → aux-divider → Related →
  Documents. Catches any future regression that re-tangles the order.
- TestDetailFormGroupHeadings (new) — pins the five visible group
  headings (General / Classification / Flags / Content / Related) so
  a string-cleanup pass can't silently strip them.
- TestDetailAuxSectionsAfterForm (new) — Documents <details> lives
  AFTER the detail form's </form>, while Public listing stays INSIDE
  the form for save-coherence. Skips the sidebar's logout-form </form>
  by anchoring on the detail-form's action="/i/dev" start tag.
- TestDetailIncludesSectionToggleScript / TestDetailSectionsWrappedInDetails /
  TestDetailDocumentsClosedDefaultsWhenManyItems still pass — the
  Phase 4e collapsible semantics are untouched.

Net: +298 / -92.
2026-05-26 13:15:39 +02:00
mAi
084fd7973b Merge commit '63f5ed1' (phase 5h slice 8: design.md addendum — Dashboard overhaul §19) 2026-05-26 12:38:01 +02:00
mAi
bad877ae69 Merge commit 'a46f73f' (phase 5h slice 7: mobile polish — Tiles tab strip + touch targets) 2026-05-26 12:36:31 +02:00
mAi
9692b86a4b Merge commit 'fee3251' (phase 5h slice 5: polish Events tab — summary header, fuller day labels) 2026-05-26 12:35:15 +02:00
mAi
ccaae32f39 Merge commit 'c4a4ba0' (phase 5h hotfix: contain Tiles grid to prevent horizontal scroll) 2026-05-26 12:33:26 +02:00
mAi
252b424d2c Merge commit '2925c43' (phase 5h slice 4: pin toggle on tiles + handleDashboardPin) 2026-05-26 12:33:26 +02:00
mAi
d75d9a10ce Merge branch 'mai/fuller/phase-5h-phase-a-design' (phase 5h slice 3: scope chip + Quiet fold + Stale folded into Tiles) 2026-05-26 12:27:37 +02:00
mAi
f234c72f50 Merge branch 'mai/fuller/phase-5h-phase-a-design' (phase 5h slices 1-2: rollup model + Tiles tab) 2026-05-26 12:23:07 +02:00
mAi
c6a350f6a0 docs: Phase 5i Views-system design plan
Phase A (design) of Phase 5i — project filter dim, view-type as a
parameter, saved views, and per-page bindings. Five-slice implementation
plan (A: project filter → B: view-type URL → D: saved-views schema → C:
kanban → E: defaults). Nine open questions for m batched in §9 ready
for head delegation.

No code changes; this branch ships docs only. Coder shifts wait on m's
sign-off via head.
2026-05-26 12:10:08 +02:00
mAi
88fd77b439 Merge branch 'mai/knuth/fix-calendar-filters' (fix: <select multiple> filter strips drop values past first) 2026-05-26 11:56:46 +02:00
mAi
6f0a318979 fix(filters): preserve every value from <select multiple> filter strips
Symptom (m-reported): /calendar filters don't work.

Root cause: ParseTreeFilter and calendar's ?kind parser both used
`r.URL.Query().Get(key)` to read tag/mgmt/has/status/kind. `Get()`
returns ONLY the first value when a URL has the same key repeated, and
the HTMX filter-strip forms (calendar_section.tmpl, timeline_section,
dashboard_section, graph, bulk) all use `<select multiple name="tag">`
which the browser serialises as `?tag=foo&tag=bar` — repeated params,
not the comma-joined `?tag=foo,bar` the tree page emits from its hidden
input. Every second-and-beyond chip silently dropped on every filter
submission across every page with a multi-select strip; m happened to
catch it on /calendar.

Fix (single helper, four call-site swaps):

- web/server.go parseValues(q, key): collects q[key] (the full slice of
  values), joins on comma, runs parseCSV. Accepts both URL shapes:
    ?tag=foo,bar          → ["foo", "bar"]
    ?tag=foo&tag=bar      → ["foo", "bar"]
    ?tag=foo,bar&tag=baz  → ["foo", "bar", "baz"]

- web/tree_filter.go ParseTreeFilter: tag / mgmt / status / has all
  switch from `parseCSV(q.Get(...))` to `parseValues(q, ...)`. q / show-
  archived / public stay on `q.Get` — they're single-value by design.

- web/calendar.go parseCalendarQuery: ?kind handling drops the bespoke
  q.Get + strings.Split + dedup-map and uses `parseValues(..., "kind")`
  for the same reason. Behaviour preserved for legacy comma-joined
  `?kind=event,doc` AND new repeated-param submission.

Regression test:

- TestCalendarFilterMultiValueTagsFromForm seeds three items — one with
  both test tags (A+B), one with only A, one with only B — drops a
  dated link on each, then probes `/calendar?tag=A&tag=B`. Before the
  fix the A-only note leaked through (the parser kept just tag=A);
  after, only the A+B item appears per the AND-across-tags contract.

Full web suite green. Pre-existing db/TestBackfillTagsFromArea failure
unchanged (independent of this change).

Same fix transparently repairs /timeline, /dashboard, /graph, /bulk —
they all consume ParseTreeFilter and shared the bug.
2026-05-26 11:56:42 +02:00
69 changed files with 7475 additions and 351 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,70 @@
-- 0016_views.sql
--
-- Phase 5i Slice D: persistent saved views.
--
-- A saved view bundles (filter + view_type + sort + group_by) under a
-- name. Page-agnostic per m's Q2 pick (2026-05-26) — the view doesn't
-- own a route; `is_default_for` lets one view become the auto-applied
-- default for a given page.
--
-- Singleton user; no `user_id` column. If multi-user ever lands, the
-- two partial unique indexes below need a `(user_id, …)` prefix.
CREATE TABLE IF NOT EXISTS projax.views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
description text,
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
view_type text NOT NULL,
sort_field text,
sort_dir text,
group_by text,
pinned boolean NOT NULL DEFAULT false,
is_default_for text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT views_view_type_chk
CHECK (view_type IN ('card','list','calendar','kanban','timeline')),
CONSTRAINT views_sort_dir_chk
CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
CONSTRAINT views_kanban_needs_group
CHECK (view_type <> 'kanban' OR group_by IS NOT NULL),
CONSTRAINT views_default_for_chk
CHECK (is_default_for IS NULL OR is_default_for IN ('tree','dashboard','calendar','timeline'))
);
-- Case-insensitive uniqueness on the visible name. Soft-deleted rows are
-- exempt so a re-create after delete doesn't collide.
CREATE UNIQUE INDEX IF NOT EXISTS views_name_uniq
ON projax.views (lower(name))
WHERE deleted_at IS NULL;
-- One default view per page. The handler should clear the prior default in
-- the same transaction as setting a new one; the index defends against any
-- code path that forgets.
CREATE UNIQUE INDEX IF NOT EXISTS views_default_for_uniq
ON projax.views (is_default_for)
WHERE is_default_for IS NOT NULL AND deleted_at IS NULL;
-- updated_at trigger mirrors the items table pattern.
CREATE OR REPLACE FUNCTION projax.views_touch_updated_at()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS views_touch_updated_at ON projax.views;
CREATE TRIGGER views_touch_updated_at
BEFORE UPDATE ON projax.views
FOR EACH ROW EXECUTE FUNCTION projax.views_touch_updated_at();
DO $own$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'projax_admin') THEN
EXECUTE 'ALTER TABLE projax.views OWNER TO projax_admin';
EXECUTE 'ALTER FUNCTION projax.views_touch_updated_at() OWNER TO projax_admin';
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON projax.views TO projax_admin';
END IF;
END $own$;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,496 @@
# Views redesign — paliad-shape first-class views (Phase 5j)
**Status**: Phase A design (this doc).
**Branch**: `mai/kahn/phase-5j-views-redesign`.
**Author**: kahn (inventor), 2026-05-26.
**Source feedback** (m, 13:19 2026-05-26): *"It's not really what I wanted. It should like the paliad custom views, not of the existing views a variant but individually created views."*
**Replaces**: Phase 5i. Hours-old, no real data, drop-and-rebuild is the cleanest path.
---
## §1 — Diagnosis: why 5i diverged from intent
5i modelled views as an **overlay** on top of existing pages. The contract was:
> User opens `/?view=<uuid>` → the saved filter+view_type fields onto whatever the existing tree handler renders.
That choice flowed from m's original phrasing: "view types (card / list / calendar / kanban)" — which sounded like skin-on-top-of-pages. Implementation followed: TreeFilter grew a `ViewID`, an `applySavedView` overlay landed in the tree handler, the sidebar `Views` entry pointed to `/views` as a list-management page, and saved views had no URL of their own.
m's **actual** mental model, anchored in paliad: a view IS a page. The slug goes in the URL. System defaults (dashboard, calendar, timeline, ...) and user-created views share the same `/views/{slug}` route shape. Nothing is "an overlay" — views are first-class destinations, indexed in the sidebar, with their own editor.
The fix: tear out the 5i overlay code and rebuild around the paliad model. This redesign mirrors paliad's structure but adapts to projax's constraints (single-user, no auth.uid(), no RLS, existing route surface).
---
## §2 — paliad-shape data model for projax
### Schema (migration `0017_views_redesign.sql`)
**Recommendation: hard-replace.** Drop `projax.views` (created hours ago in 5i Slice D), recreate fresh. No real user data lost — at most a couple of throwaway saved-view rows from m's testing.
```sql
DROP TABLE IF EXISTS projax.views CASCADE;
CREATE TABLE projax.views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL,
name text NOT NULL,
icon text, -- nullable; matches frontend icon registry
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
view_type text NOT NULL, -- card | list | calendar | kanban | timeline
sort_field text,
sort_dir text,
group_by text,
sort_order integer NOT NULL DEFAULT 0,
show_count boolean NOT NULL DEFAULT false,
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT views_view_type_chk
CHECK (view_type IN ('card','list','calendar','kanban','timeline')),
CONSTRAINT views_sort_dir_chk
CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
CONSTRAINT views_kanban_needs_group
CHECK (view_type <> 'kanban' OR group_by IS NOT NULL),
CONSTRAINT views_slug_format_chk
CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$')
);
CREATE UNIQUE INDEX views_slug_uniq ON projax.views (slug);
CREATE INDEX views_sort_order_idx ON projax.views (sort_order, name);
-- updated_at trigger reused from 0016 (kept under a new name or recreated).
CREATE OR REPLACE FUNCTION projax.views_touch_updated_at()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS views_touch_updated_at ON projax.views;
CREATE TRIGGER views_touch_updated_at
BEFORE UPDATE ON projax.views
FOR EACH ROW EXECUTE FUNCTION projax.views_touch_updated_at();
```
### Key shifts from 5i
| field | 5i | 5j | reason |
|---|---|---|---|
| primary key | uuid only | uuid; **slug is the URL key** | paliad parity — URLs use slugs, not uuids |
| slug | absent | required, unique, regex-validated | URL routability |
| icon | absent | nullable text | sidebar icon picker |
| sort_order | absent | server-assigned MAX+1 | drag-reorder; paliad parity |
| show_count | absent | bool, opt-in | sidebar row-count badge; opt-in cost |
| last_used_at | absent | nullable timestamptz | `/views` landing MRU redirect |
| pinned | bool | **dropped** | `sort_order` subsumes the use case |
| is_default_for | text page | **dropped** | per-page-default model gone; MRU replaces it |
### `filter_json` shape
Unchanged from 5i (the JSON shape stayed correct). Keys mirror TreeFilter dims: `q`, `tags[]`, `management[]`, `status[]`, `has_links[]`, `public`, `show_archived`, `project_path`, `include_descendants`. The shape is forward-compatible; new TreeFilter dimensions land without migrations.
`view_type` stays a top-level column (not inside `filter_json`) because the editor + sidebar both read it without needing to parse JSON.
### Single-user simplifications vs paliad
- **No `user_id` column** — projax is Tailscale-only single-user.
- **No RLS** — same reason.
- **`UNIQUE (slug)` is global**, not per-user.
If multi-user ever lands, the column + index gain a `user_id` prefix; the rest of the design holds.
---
## §3 — Reserved slugs (system views)
The big call: **do existing pages become system views, or do they stay distinct routes?**
### Three options
**(a) Keep current routes; add /views/{slug} for user views only.**
- `/`, `/dashboard`, `/calendar`, `/timeline`, `/graph` stay exactly as today.
- `/views/{slug}` is exclusively for user-created views.
- Reserved-slug list is just `{new, edit}` (the literal route segments) + any future top-level URL we'd not want a user view to shadow.
- **Cost**: nothing changes for muscle memory. User views are an additive concept beside existing pages.
- **Drawback**: the conceptual asymmetry m flagged stays — system pages live at `/`/`/dashboard`, user views live at `/views/{slug}`. Two URL families.
**(b) Full migration. Existing pages become system views at `/views/{slug}`.**
- New URLs: `/views/tree`, `/views/dashboard`, `/views/calendar`, `/views/timeline`, `/views/graph` (or drop graph from the unified shape — see §3.1).
- Legacy `/`, `/dashboard`, etc. become 301 redirects to their `/views/{slug}` counterpart.
- Reserved slugs: `{tree, dashboard, calendar, timeline, graph, new, edit, admin, login, logout, healthz, mcp, static, i, views}` — everything projax owns at the top level.
- **Cost**: every internal link in templates needs updating; bookmarks 301 (fine); browser muscle memory absorbs after one shift.
- **Benefit**: one URL family. The "create a new view" mental model is uniform with how system pages live.
**(c) Hybrid. Legacy routes stay; `/views/{slug}` aliases system pages and hosts user views.**
- `/` keeps serving the tree; **also** `/views/tree` resolves to the same handler.
- `/dashboard` keeps; also `/views/dashboard`. Etc.
- Reserved slugs match (b) for the same coverage.
- User views land at `/views/{their-slug}` alongside system slugs in one URL family.
- **Cost**: small — system-view handlers register two route entries instead of one. No redirects to maintain.
- **Benefit**: muscle memory + bookmark stability AND first-class /views/{slug} URL family. Two paths to the same render; user picks whichever they remember. If `/views/{slug}` catches on, a future shift can deprecate the legacy URLs cleanly.
### Inventor pick: (c) hybrid
**Reasoning**: m's bug report explicitly said "individually created views" — the gap was user-view first-classness, not legacy-URL banishment. (c) closes the gap with zero migration cost. (b) is cleaner architecturally but introduces avoidable churn; the upside (one URL family) doesn't outweigh the risk of breaking some link or muscle-memory in m's daily flow. (a) leaves the two-families asymmetry m's feedback was pointing at.
This is **Q1 in §9** — head should ratify or override before coder.
### §3.1 — Graph as a system view?
Graph is the DAG SVG render. It's NOT in the view_type enum (per 5i design, intentionally — graph is its own visualization, not a "list of items rendered as X"). Recommend: keep `/graph` and `/views/graph` (under (c)) but **graph is not a user-creatable view_type** — the create form omits it. Reserved slug `graph` blocks user views from clobbering it.
### Reserved-slug list (combining (c) + projax's existing top-level routes)
```go
var reservedViewSlugs = []string{
// System pages (also reachable via /views/<slug> as aliases under (c)):
"tree", "dashboard", "calendar", "timeline", "graph",
// /views sub-routes:
"new", "edit",
// Top-level application URLs:
"admin", "login", "logout", "healthz", "mcp", "static", "i", "views",
}
```
---
## §4 — Routes
For option (c). Under (b), drop the legacy entries; under (a), drop the `/views/{system-slug}` aliases.
| route | handler | renders | semantics |
|---|---|---|---|
| `GET /views` | `handleViewsLanding` | 302 to MRU view, else onboarding shell | landing |
| `GET /views/{slug}` | `handleViewRender` | view template per view_type | render saved or system view |
| `GET /views/new` | `handleViewEditor` | editor blank | editor — new |
| `GET /views/{slug}/edit` | `handleViewEditor` | editor pre-filled | editor — edit existing |
| `POST /views` | `handleViewCreate` | redirect to `/views/{slug}` | create |
| `POST /views/{slug}` | `handleViewUpdate` | redirect to `/views/{slug}` | update |
| `POST /views/{slug}/delete` | `handleViewDelete` | redirect to `/views` | delete |
| `POST /views/reorder` | `handleViewReorder` | 204 / HTMX OK | drag-reorder (slice G) |
| `POST /views/{slug}/touch` | `handleViewTouch` | 204 fire-and-forget | bump last_used_at on render |
The render path (`GET /views/{slug}`):
1. Resolve slug. If a user view → load row. If a reserved system slug → load the corresponding code-resident `SystemView` struct.
2. Touch `last_used_at` (user views only — system views don't track MRU per call).
3. Dispatch to the view_type's renderer (the same per-view-type templates from 5i: `tree_card.tmpl`, `tree_kanban.tmpl`, `tree_section.tmpl` for list, plus the existing `calendar_section.tmpl` and `timeline_section.tmpl`).
4. Apply chip-overlay semantics from the 5i fix — URL chips overlay the saved filter so chip clicks narrow within the view (the one piece of 5i worth keeping; see §7).
Editor (`GET /views/new` and `GET /views/{slug}/edit`) is a dedicated full-page form, not a modal. Paliad shipped dedicated pages; projax inherits the same shape.
---
## §5 — Sidebar integration
Replace the single "Views" sidebar entry (5i) with a "Views" section listing every user view. System views stay in the existing main-nav block at the top; they're already the muscle-memory entries (Tree, Dashboard, Calendar, Timeline, Graph).
ASCII sketch (5g sidebar shape, with 5j additions):
```
[ sidebar ]
─────────────
⌂ Tree
□ Dashboard
▣ Calendar
⊿ Timeline
⨀ Graph
─────────────
Views ← new section header
📂 Active mai work ← user view (icon + name)
⏰ This week deadlines ← row-count badge if show_count
★ Patents kanban ← drag-reorder handle on hover
+ New view ← /views/new
─────────────
⚙ Admin
─────────────
☾ Theme
```
The Views section's entries come from `ListViews()` ordered by `sort_order` ASC, then `name`. Each entry:
- Icon resolved against a small frontend registry (the icon column is a key; the registry maps it to an SVG). Keys: `folder`, `clock`, `star`, `tag`, `file-text`, `box`, `inbox`, etc. Default key: `folder`.
- Optional badge with row count when `show_count=true` — computed by running the view's filter against `ListAll()` (cheap; projax's scale is ~150 items max).
- Active state when the current URL is `/views/{this-slug}` or a legacy alias resolving to it.
Drag-reorder lands in a later slice (G). Click-to-open is the v1 interaction.
Mobile bottom-nav drawer (5g slice B) gets the same section.
---
## §6 — Editor surface
Single editor template (`templates/view_editor.tmpl`) reused for both `/views/new` and `/views/{slug}/edit`. Distinguishes via the presence of `.View` in the data map.
Fields:
- **Name** — text input, required, max 80 chars.
- **Slug** — text input, regex `^[a-z0-9][a-z0-9-]{0,62}$`, **auto-derived** from name via HTMX on `change` against a `POST /views/derive-slug?name=<x>` helper endpoint OR on the client (simpler: derive on the server side in `handleViewCreate` if the field is empty; provide a "regenerate" link in edit mode). m can hand-edit.
- **Icon** — `<select>` with the registered icon keys + a visible preview. Slice D ships the form field; the registry SVG additions can grow incrementally.
- **View type** — radio group (5 values: card/list/calendar/kanban/timeline).
- **Filter (chip strip)** — full TreeFilter chip strip inline in the editor: tag, mgmt, status, has, public, project picker + descendants toggle. Each chip click updates a hidden `filter_json` field via HTMX — so the editor's preview pane reflects the saved filter live.
- **Sort field** — text input (`title` / `updated_at` / `start_time`).
- **Sort dir** — `<select>` (asc/desc).
- **Group by** — `<select>` (status/area/tag/management). Required when view_type=kanban.
- **Show count** — checkbox.
A small "Preview" pane next to the form shows the first N items the filter currently matches. Optional in slice D; can land in slice G if scope bites.
Save → 302 to `/views/{slug}`. Cancel → `/views` (or the previous URL if HTMX-loaded).
**Drops the HTMX modal** the 5i fix-shift added — dedicated pages are clearer for a page-level concept and match paliad's pattern.
---
## §7 — Migration from 5i overlay
Specific deletions and salvages:
### Code to delete
| file | what to remove |
|---|---|
| `web/tree_filter.go` | `ViewID` field on TreeFilter; `ParseTreeFilter`/`QueryString` handling |
| `web/views.go` | `applySavedView`, `applyDefaultView`, `overlayURLFields`, `filterQueryToJSON`/`filterJSONToQuery`, the `Prefill` index handler logic |
| `web/server.go` | the `?view=<uuid>` overlay block in `handleTree`; the `DefaultBanner` data map field |
| `web/templates/tree_section.tmpl` | the `default-banner` block; the `<input type="hidden" name="view">` |
| `web/templates/views.tmpl` | full rewrite — it's the list-management surface, redesigned in §5 + §6 |
| `web/templates/view_edit.tmpl` | full rewrite to the new editor shape |
### Code to keep
- `templates/tree_card.tmpl`, `templates/tree_kanban.tmpl` — these are per-view_type renderers, reusable.
- `web/view_type.go` (the 5-value enum + `PageViewTypes` catalog) — still valid as the renderer dispatch table.
- `web/kanban.go` (`BuildKanbanBoard`) — view_type=kanban consumer.
- `templates/project_chip.tmpl` — the project filter chip strip works inside the editor.
- The 5i chip-overlay-on-saved-view fix is the **one piece of substance** worth keeping conceptually: on `/views/{slug}`, URL chip params overlay the saved filter. The overlay function gets a new home (`handleViewRender`'s filter-resolution path) but the rule is the same.
### Backwards compatibility for the old `?view=<uuid>` URL
Two options:
- (i) **404 on `?view=`** for existing pages — the URL never makes sense in the new model. Cost: any stale bookmark dies, but only m used it for hours.
- (ii) **302-redirect `/<page>?view=<uuid>` to `/views/<slug>`** by looking up the slug from the uuid. Smoother for m's recent bookmarks. Cost: one extra DB hit on the redirect path; the redirect can target the slug or, if the uuid no longer resolves (because we hard-recreated the table), 302 to `/views`.
Inventor pick: (ii) — small code, no broken bookmarks for the brief 5i window.
### `is_default_for` semantics
Drop entirely. The MRU mechanism (`last_used_at``/views` landing) replaces "what should I see on /views". Per-page defaults are gone; if m wants a specific view to be the landing experience, he opens it once and it becomes MRU.
If m later wants a "this is my default" hint stronger than MRU (i.e., pinning), `sort_order=0` reserved for a pinned slot + an `is_pinned` flag is the natural extension. **Not in scope for v1.**
---
## §8 — Implementation slicing
Seven slices; A → B → C → D → E are the critical path; F + G are polish.
### Slice A — Schema redesign
- Migration `0017_views_redesign.sql`: `DROP TABLE projax.views CASCADE; CREATE TABLE` with new shape. (See §2 schema.)
- `store/views.go`: rewrite. Rename `View.ID` flow to be slug-driven; `GetView(slug)` instead of `GetView(uuid)`. Keep CRUD shape; add `Touch(slug)` for MRU; add `MostRecent()` returning the MRU view (or nil); add `Reorder([]string slugs)` for slice G.
- Drop `DefaultViewFor` (no longer applicable).
- Tests: round-trip CRUD by slug; reserved-slug rejection at the validator; slug-format regex enforcement; MRU.
### Slice B — Route migration (paliad-shape)
- Replace the 5i `/views/<uuid>` routes with the paliad-shape route table from §4.
- `handleViewsLanding` → MRU redirect or onboarding shell.
- `handleViewRender` → resolve slug (user view first, then system view), apply chip overlay, dispatch to the view_type's renderer.
- `handleViewEditor` → dedicated form page (slug-driven).
- `handleViewCreate` / `handleViewUpdate` / `handleViewDelete` → form POST handlers.
- `handleViewTouch` → fire-and-forget MRU update.
- Wire the legacy `?view=<uuid>` redirect (per §7-ii) on existing pages.
- Tests: each route hit, slug routing, MRU redirect, onboarding shell on empty state, reserved-slug rejection.
### Slice C — System views
- New `web/system_views.go` with `SystemView` struct + `TreeSystemView()`, `DashboardSystemView()`, `CalendarSystemView()`, `TimelineSystemView()`, `AllSystemViews()`, `LookupSystemView(slug)`.
- Each function returns the `(filter_json, view_type, group_by, sort)` tuple matching today's page.
- `handleViewRender` falls back to `LookupSystemView` when the slug isn't in the DB.
- Reserved-slug list (combining system slugs + route segments).
- Under (c) hybrid: legacy routes `/`, `/dashboard`, `/calendar`, `/timeline` each gain a sibling registration so `/views/{system-slug}` resolves to the same handler. (Or: legacy routes 302 to `/views/{slug}` — simpler if m's fine with one canonical URL.)
- Tests: system-view lookup, slug aliases hit the same template, reserved-slug rejection during user-view create.
### Slice D — Editor surface
- New `templates/view_editor.tmpl` — full form per §6.
- Slug derivation helper (`POST /views/derive-slug` or server-side fill).
- Icon picker (a `<select>` for v1 — frontend registry expansion is incremental).
- Inline chip strip inside the form; HTMX updates a hidden `filter_json` on every chip click.
- Tests: GET /views/new renders blank form; GET /views/{slug}/edit pre-fills; POST creates/updates round-trip.
### Slice E — Sidebar integration
- `templates/layout.tmpl`: insert a "Views" section between main nav and `/admin`.
- Server-side: every page-render pulls `ListViews()` into the layout data map (cached lightly so each request doesn't hit the DB twice).
- Active-state CSS + icon rendering.
- Mobile drawer (5g slice B) gets the same section.
- Tests: sidebar shows user views; clicking navigates to `/views/{slug}`; active state matches URL.
### Slice F — Migration cleanup (delete 5i overlay)
- Remove TreeFilter.ViewID.
- Remove `applySavedView`, `applyDefaultView`, `overlayURLFields`, the default-view banner.
- Remove the 5i `/views/<id>` redirect handler (slice B replaces it).
- Tests adjusted: drop the `ViewID` round-trip test; drop `TestSavedViewAppliedOnQueryParam`, `TestDefaultViewAppliedOnCleanURL`, `TestViewEditFlow` — their slice-A successors cover the new shapes.
### Slice G — Polish
- Drag-reorder UI via HTMX `hx-post="/views/reorder"` with sortable.js or a tiny vanilla drag-handle (m's HTMX-only constraint allows minimal vendored JS if needed).
- `show_count` badge wiring (run filter against `ListAll()`, render the count next to the sidebar entry).
- Preview pane in the editor (optional).
- Icon registry expansion (curated SVGs).
Slices F and G are independent. The implementation chain is **A → B → C → D → E → (F either before or after E) → G**.
---
## §9 — Open questions for head delegation
Inventor picks marked. Process: **NO direct chip-picker** without head's explicit grant for this round.
### Q1 — System-view shape (§3)
(a) Keep current routes only; user views beside them at `/views/{slug}` — current asymmetry stays.
(b) Full migration; existing pages become system views, legacy URLs 301-redirect — paliad parity.
(c) Hybrid; both URL families coexist, system slugs aliased — preserves muscle memory.
**Inventor pick**: (c). Closes the asymmetry m flagged, zero migration cost. (b) is cleaner but risks broken bookmarks for thin upside.
### Q2 — `view_type` field placement
- (a) Top-level column (5j inventor pick — matches 5i, query-able without parsing JSON).
- (b) Inside `filter_json`.
**Inventor pick**: (a).
### Q3 — Legacy `?view=<uuid>` URL handling (§7)
- (a) 404 — clean break.
- (b) 302-redirect to `/views/<slug>` by uuid lookup — smoother for m's recent bookmarks. Inventor pick.
**Inventor pick**: (b).
### Q4 — Editor surface (§6)
- (a) Dedicated pages `/views/new` + `/views/{slug}/edit` — paliad parity, inventor pick.
- (b) Keep the HTMX modal from the 5i fix — less navigation but harder to share/bookmark mid-edit.
**Inventor pick**: (a).
### Q5 — `/views` landing MRU redirect
- (a) 302 to MRU saved view if any, else onboarding shell (paliad model, inventor pick).
- (b) Always show the views index list page.
**Inventor pick**: (a).
### Q6 — Icon picker in v1?
- (a) Yes — small select + 8-12 curated keys; rendered inline in the sidebar entries.
- (b) v2 — ship without icons in v1; sidebar uses a generic folder glyph for every entry.
**Inventor pick**: (a) — the schema column lands either way; UI cost for a `<select>` is trivial.
### Q7 — Drag-reorder in v1?
- (a) Yes (slice G in v1).
- (b) v2 — `sort_order` column is server-assigned MAX+1 on create; reorder UI lands later.
**Inventor pick**: (b). Don't expand v1 scope; reorder is a UX polish that can ship a week after.
### Q8 — `show_count` badge in v1?
- (a) Yes — opt-in checkbox in editor + sidebar badge.
- (b) v2 — column lands in the schema; UI lands later.
**Inventor pick**: (a) — checkbox in editor + 2-line render in sidebar is cheap and answers the "how many things match my view" question m asks naturally.
### Q9 — Legacy `is_default_for` semantics (§7)
Inventor picks **dropped entirely**, replaced by MRU. Flag if m wants pin / default semantics back.
### Q10 — Drop and recreate `projax.views`?
- (a) Hard-replace via `DROP TABLE ... CASCADE` — inventor pick (table is hours old, ~zero data loss).
- (b) ALTER TABLE migration that adds new columns + drops old ones gracefully — more conservative; preserves any rows m has created.
**Inventor pick**: (a). The shape change is large enough that a clean re-create is cleaner than a 6-step ALTER.
### Q11 — `view_type=graph`?
The graph DAG SVG render isn't in the view_type enum. Should:
- (a) Stay outside the views system — `/graph` and `/views/graph` (system slug) both serve it, user views can't be `view_type=graph`. Inventor pick.
- (b) Add `graph` as a sixth view_type — opens user-creatable graph views.
**Inventor pick**: (a). Graph layout is single-purpose (DAG); a "graph of my filtered set" doesn't have a clear product story today.
---
## §10 — Risk register
| risk | likelihood | mitigation |
|---|---|---|
| Slug collision on rename | medium | UNIQUE index + handler maps the unique-violation to a friendly "slug already in use" error |
| URL drift (legacy bookmarks break) | low under (c), high under (b) | (c) keeps legacy URLs; (b) ships with 301 redirects + a session of m verifying his bookmarks |
| MRU thrash on rapid view switches | low | `last_used_at` is fire-and-forget; the worst case is one stale 302 |
| System-view + user-view slug collision | n/a | reserved-list rejection in validator (slice A) |
| sidebar query cost | low | `ListViews()` is one indexed lookup per page render; cache lightly if it shows in profiling |
| Editor's chip strip drifts from the page chip strip | medium | share the same template (project_chip.tmpl already shared); add a dedicated `view_filter_chips.tmpl` if drift bites |
---
## §11 — Test plan headlines
### Slice A
- `TestViewSlugCRUD` — create/get/update/delete by slug round-trip.
- `TestViewSlugFormatRejected` — uppercase, underscore, leading-digit-allowed but no-leading-dash, length-cap 63.
- `TestViewReservedSlugRejected` — create with slug `tree` / `dashboard` / `admin` / `new` etc. all 400.
- `TestViewTouch` — Touch bumps `last_used_at`.
- `TestViewMostRecent` — MRU returns most recently touched.
### Slice B
- `TestViewsLandingMRU``/views` 302s to MRU view when one exists.
- `TestViewsLandingOnboarding``/views` renders shell when no views.
- `TestViewRender``/views/{slug}` resolves a user view; renders the right view_type template.
- `TestLegacyOverlayRedirect``/?view=<uuid>` 302s to `/views/{slug}`.
### Slice C
- `TestSystemViewLookup``tree` / `dashboard` / `calendar` / `timeline` / `graph` resolve via `LookupSystemView`.
- `TestSystemViewSlugAlias``/views/dashboard` and `/dashboard` produce identical render output.
### Slice D
- `TestEditorBlank``/views/new` renders empty form.
- `TestEditorPrefilled``/views/{slug}/edit` reflects every persisted field.
- `TestSlugDerivation` — name "Active mai work" → slug "active-mai-work".
### Slice E
- `TestSidebarListsViews` — layout includes every user view.
- `TestSidebarActiveState``/views/{slug}` marks that entry active.
### Slice F
- All 5i overlay tests deleted; no residue references TreeFilter.ViewID.
### Slice G
- `TestReorderUpdatesSortOrder` — POST `/views/reorder` with a sorted slug list updates the column.
- `TestShowCountBadge` — sidebar badge reflects the filter's match count.
---
## §12 — References
- `~/dev/paliad/internal/db/migrations/056_user_views.up.sql` — schema reference.
- `~/dev/paliad/internal/services/user_view_service.go` — CRUD reference.
- `~/dev/paliad/internal/services/system_views.go` — reserved-slug + system-view registration.
- `~/dev/paliad/internal/handlers/views_pages.go` — route table.
- `~/dev/paliad/frontend/src/{views,views-editor}.tsx` — editor + sidebar reference (UX only; not ported).
- `docs/plans/views-system.md` (5i) — historical record of the wrong-shape implementation.
- `docs/design.md` §4 (Interfaces).
---
## §13 — Status
- **Phase A (this doc)**: drafted by kahn, 2026-05-26. Awaiting head delegation of §9 questions to m.
- **No chip-picker for 5j** unless head explicitly re-grants per the project's escalation rule.
- **Phase B (coder)**: blocked on m's sign-off via head. Slice ordering A → B → C → D → E → F → G.
- **No code changes** in this branch beyond this doc.

564
docs/plans/views-system.md Normal file
View File

@@ -0,0 +1,564 @@
# Views system — design plan (Phase 5i)
**Status**: Phase A design (this doc).
**Branch**: `mai/kahn/phase-5i-phase-a-design`.
**Author**: kahn (inventor), 2026-05-26.
**Source request** (m, 11:59 2026-05-26): *"Generally speaking, I want a project filter on most pages — and of course also other criteria to filter by. I want to be able to set up custom views as well where we save which filters apply and what type of view (card / list / calendar / kanban)."*
**Sibling work**: Phase 5h (fuller) is implementing the narrow Tabbed-Tiles dashboard. That work stays scoped; once both ship, the Tiles dashboard becomes the canonical `view_type=card` consumer.
---
## §1 — Current state diagnosis
### Filter dims that exist today (`web/tree_filter.go`)
`TreeFilter` has six dimensions plus search:
| field | shape | semantics |
|---|---|---|
| `Q` | string | substring across title/slug/content/paths/aliases |
| `Tags` | `[]string` AND | every requested tag must be present |
| `Management` | `[]string` OR | any match passes; synthetic `unmanaged` matches empty `it.Management` |
| `Status` | `[]string` OR | default `["active"]`; setting to `[]` resets to active |
| `HasLinks` | `[]string` AND | every requested ref_type must be linked to the item |
| `Public` | `*bool` tri-state | nil → no filter; otherwise must match item flag |
| `ShowArchived` | bool | when false, archived items hidden even if Status=archived |
Round-trip is `ParseTreeFilter(url.Values) ↔ QueryString()`. Both comma-joined (`?tag=a,b`) and repeated-param (`?tag=a&tag=b`) shapes parse to the same struct (fix from the 2026-05-25 multi-select regression).
### Pages that consume TreeFilter today
| route | handler | filter usage | implicit view shape |
|---|---|---|---|
| `/` | `web/server.go handleTree` | filters → DAG-as-forest, ancestors kept | **list** (forest of nodes) |
| `/dashboard` | `web/dashboard.go handleDashboard` | filters → 5 cards (tasks/issues/docs/stale/events) | **card** (pre-5h) → **card-tabbed** (post-5h) |
| `/timeline` | `web/timeline.go handleTimeline` | filters → chronological spine | **timeline** (its own shape) |
| `/calendar` | `web/calendar.go handleCalendar` | filters → month grid | **calendar** |
| `/graph` | `web/graph.go handleGraph` | filters → SVG with dim-by-default | **graph** (specialised, NOT in this system) |
| `/admin/bulk` | `web/bulk.go handleBulk` | filters → flat checkbox list (admin tool) | flat list (admin, NOT in this system) |
### What is missing — and what m's signal targets
1. **Project filter dim**: no `?project=` exists. To scope to "everything under `work.upc`" today m has no chip; he searches `q=upc` which is a string match across all fields. Not the same.
2. **View-type is fixed per route**: `/dashboard` is always card-ish; `/` is always list-forest. m wants to flip a filtered set between card/list/calendar/kanban.
3. **Saved views**: a chosen filter set lives in the URL only. To re-open "active mai-managed dev projects with open Gitea issues" m must rebuild the chip sequence or bookmark the URL.
4. **No kanban**: status-grouped columns don't exist anywhere in projax. m named it explicitly.
### Why the existing pattern can't stretch
Each page handler hard-codes the render shape. Even though `TreeFilter.Matches()` is shared, the projection into rows (forest, day-bucket, month-cell, chronological day) is per-handler. To make view-type a parameter we need a generalisation: filter produces a *set of items + linked context*, view-type renders that set.
---
## §2 — Data model: `projax.views`
### Schema (migration `0016_views.sql`)
```sql
CREATE TABLE projax.views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
description text,
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
view_type text NOT NULL,
sort_field text, -- e.g. "title", "updated_at", "start_time"
sort_dir text, -- "asc" | "desc"
group_by text, -- "status" | "area" | "tag" | "management" — kanban-required
pinned boolean NOT NULL DEFAULT false,
is_default_for text, -- "tree" | "dashboard" | "calendar" | "timeline" | null
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT views_view_type_chk
CHECK (view_type IN ('card','list','calendar','kanban','timeline')),
CONSTRAINT views_sort_dir_chk
CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
CONSTRAINT views_kanban_needs_group
CHECK (view_type <> 'kanban' OR group_by IS NOT NULL),
CONSTRAINT views_default_for_chk
CHECK (is_default_for IS NULL OR is_default_for IN ('tree','dashboard','calendar','timeline'))
);
CREATE UNIQUE INDEX views_name_uniq
ON projax.views (lower(name))
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX views_default_for_uniq
ON projax.views (is_default_for)
WHERE is_default_for IS NOT NULL AND deleted_at IS NULL;
-- Trigger pattern same as items: updated_at on UPDATE, deleted_at semantics for soft delete.
```
Notes:
- **Single-user v1**: no `user_id`. If multi-user ever lands, add a column + adjust unique indexes.
- **Soft delete**: `deleted_at` mirrors `projax.items` so the table doesn't lose history. List queries always `WHERE deleted_at IS NULL`.
- **GIN on filter_json**: not added in v1. At m's scale (≤30 views, queried only by id/name) GIN is over-engineered. Filter-introspection queries can scan the table.
### `filter_json` shape
JSON keys mirror the TreeFilter struct, snake_case, optional keys:
```json
{
"q": "upc",
"tags": ["work", "patents"],
"management": ["mai"],
"status": ["active"],
"has_links": ["gitea-repo"],
"public": true,
"show_archived": false,
"project_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"project_path": "work.upc.deadlines"
}
```
**Project scoping carries both `project_id` and `project_path`**:
- `project_id` (uuid) is the durable join key — survives slug renames.
- `project_path` (text) is for human-readable URL bars and the picker chip label. Resolved against the item's current path at render time; if path moved, the id still works.
When the filter is applied at runtime the id wins. The path is purely a cache/display convenience and refreshes on `views.updated_at`.
### Cross-references
- `views.id` is referenced by URL params (`?view=<uuid>`). Not foreign-keyed from items (views are query-builders, not item attributes).
- A view's `filter_json.project_id` is a pointer to `projax.items.id`, not enforced via FK (filter values are user intent; if the project gets deleted the view should still render an empty set, not 500).
---
## §3 — View types catalog
Five view types proposed (the four m named + `timeline` as open Q9.1). Each is a renderer for a filtered + sorted + grouped set of items.
### card
- **Inputs**: filtered item set + linked context (CalDAV todos/events, Gitea issues, dated links).
- **Output**: tiles laid out in a responsive grid. Each tile shows item title + one or two contextual badges (open task count, due-today count, last activity). Today's `/dashboard` is the prototype; Phase 5h's tabbed tiles is the canonical card view.
- **Sort**: title (default), updated_at, pinned-first, open-task-count.
- **Group-by**: optional. When set → section headers per group, tiles within. Without group-by → one flat grid.
- **Page binding**: default for `/dashboard`. Available on `/` (alternative to list).
- **Reuse**: 5h's `dashboard_section.tmpl` becomes the `card` view template (renamed to `view_card.tmpl`).
### list
- **Inputs**: filtered item set.
- **Output**: hierarchical forest with ancestor-keep semantics (today's `/`), OR flat list when group_by ≠ none.
- **Sort**: slug (default; preserves DAG order), title, updated_at, status, start_time.
- **Group-by**: optional. Flat-with-headers per group.
- **Page binding**: default for `/`. Available on `/dashboard` as alternate to card.
- **Reuse**: `tree_section.tmpl` becomes `view_list.tmpl` (with a flat-mode branch when group_by is set).
### calendar
- **Inputs**: filtered item set + their linked CalDAV (todos with DUE, events with DTSTART) + dated links.
- **Output**: month grid, three rows per cell + overflow link (today's `/calendar`). Cell shows DAY label + per-row badges.
- **Sort**: implicit chronological within cell.
- **Group-by**: not applicable.
- **Page binding**: default + only view for `/calendar`. Selectable elsewhere if the filtered set contains any dated content.
- **Reuse**: `calendar_section.tmpl``view_calendar.tmpl` essentially unchanged.
### kanban
- **Inputs**: filtered item set.
- **Output**: column-per-group-value, item card stacked vertically inside each column. Horizontal scroll on overflow.
- **Sort**: within-column: pinned-first then updated_at desc (default), with title fallback.
- **Group-by**: **required**. Sensible defaults: `status` (active/done/archived → 3 columns), `area` (top-level area path → N columns), `management` (mai/self/external/unmanaged → 4 columns), `tag` (1 column per tag the filtered set carries).
- **Page binding**: no default page. Lives behind `?view_type=kanban` on `/` and `/dashboard`, OR via a saved view URL.
- **Reuse**: NEW template `view_kanban.tmpl`. Cards inside columns reuse a per-item partial (extracted from the tree row + dashboard tile so both kanban + card + list share it).
- **Write path**: drag-to-change-group is OUT OF SCOPE for slice 1 (kanban-as-read view). Drag-to-set-status can come as slice C+ once the read side stabilises. Even without drag, kanban-as-grouping is useful — m gets a status snapshot across all "active dev mai-managed" projects without scrolling a forest.
### timeline (open question — see §9 Q1)
- **Inputs**: filtered item set + dated-context (todos due, events, doc PERs, item creation).
- **Output**: chronological spine, today's `/timeline`.
- **Sort**: date (default desc).
- **Group-by**: implicit by day.
- **Page binding**: default + only view for `/timeline` if treated as its own view type. Otherwise stays as a route outside the Views system.
- **Recommendation**: treat as a fifth view_type. Reason: the user's mental model is "I have a filtered set; render it as X". Timeline-of-filtered-set fits the same shape. Keeping timeline outside the system creates two parallel concepts (saved views + saved timeline-windows) where one would do.
### Shared item-row partial
To keep the four item-rendering view types (card, list, kanban, timeline) from drifting into four different copies of the same "title + chips + dates" partial, extract a `view_item.tmpl` partial that all four include. Per-view-type CSS classes drive layout; the markup stays one source.
---
## §4 — Project filter dim
This is the smallest concrete piece and ships independently of view-types or saved views.
### Struct change (`web/tree_filter.go`)
```go
type TreeFilter struct {
// …existing fields…
ProjectID string // uuid; if set, scope to this item + descendants
ProjectPath string // display-only path string; resolved from ProjectID at parse time
}
```
Two fields: `ProjectID` is authoritative for matching; `ProjectPath` is for human-readable URLs (`?project=work.upc`) and chip labels.
### URL semantics
- Path-form (`?project=work.upc`): UI default, human-readable. Parser resolves path → id via `s.Store.GetByPath` once at parse time; both fields populate.
- ID-form (`?project_id=<uuid>`): durable form. Used in saved views' `filter_json.project_id`. Parser populates id directly; path is best-effort.
If both are present, **id wins** and path is overwritten from the resolved item. If only the path is present and resolution fails (deleted/renamed), the filter renders a banner "Project not found: work.upc" and falls back to no project filter, so the page still renders rather than 404.
### Match semantics (`TreeFilter.Matches`)
```go
if f.ProjectID != "" {
// item is in scope iff one of its paths is the project path or starts with project_path + "."
hit := false
for _, p := range it.Paths {
if p == f.ProjectPath || strings.HasPrefix(p, f.ProjectPath+".") {
hit = true
break
}
}
if !hit { return false }
}
```
Alternative: id-based ancestor closure via a precomputed `descendantIDs[projectID] = {ids…}` map. Cheaper for repeated calls inside a single request, more code to maintain. **Decision**: start with the path-prefix approach (cheap, readable, matches how `it.Paths` already encodes the DAG closure); switch to id-closure if a perf issue appears.
### UI: project picker chip
A new chip row on every Views-supporting page, above the existing chip rows. Three states:
1. **No project**: chip shows "project: any" with a search input next to it.
2. **Active**: chip shows "project: work.upc" with an × to clear.
3. **Picking**: input expands to a datalist autocomplete sourcing from `s.Store.ListAll()` (already cached server-side).
HTMX flow: typing the input fires `hx-get="/?project=<value>"`; picking from the datalist sets the value; ✕ navigates to the URL with `?project=` stripped.
### Backend touches
- `ParseTreeFilter`: read `project` (path) + `project_id` (uuid); resolve to both.
- `TreeFilter.Matches`: add the prefix check above.
- `TreeFilter.QueryString`: emit `project=<path>` when set; emit `project_id=<uuid>` only if path is empty (path is friendlier in URL bar history).
- `TreeFilter.Active`: include project filter.
- Cache keys: untouched — `QueryString()` already produces the right key automatically once project is in the emit set.
- `computeChipCounts`: add a `Project ChipCount` slot if we want a "with project filter on/off" indicator. v1: skip. The chip's presence is its own indicator.
### Risk surface
- Stale `project_path` in a saved view after a slug rename. Solved by `project_id` being authoritative and re-resolving on each render (one DB hit per request, cheap).
- "Descendants" definition assumes the path-prefix encodes the DAG. The Phase 1.5 `paths text[]` migration already guarantees this (one entry per ancestor lineage).
---
## §5 — Page bindings
| route | default view_type | alternatives | locked? |
|---|---|---|---|
| `/` | list | card, kanban | no |
| `/dashboard` | card (5h tabbed-tiles) | list, kanban | no |
| `/timeline` | timeline | — | yes (if timeline is a view_type) |
| `/calendar` | calendar | — | yes |
| `/graph` | (graph) | — | **NOT in Views system** |
| `/admin/bulk` | (flat checklist) | — | NOT in Views system — admin tool |
| `/admin/classify`, `/admin/caldav` | (admin) | — | NOT in Views system |
When a saved view is opened by URL (`?view=<uuid>`), it carries its own view_type, which overrides the page's default. So `/dashboard?view=<uuid-of-kanban-view>` renders kanban.
When a non-default view_type is selected on a page that supports it, the URL gains `?view_type=card|list|kanban|…`. View_type chip strip rendered on every Views-supporting page next to the project filter chip.
---
## §6 — Saved views UX
### Picker placement: sidebar section under main nav
The Phase 5g sidebar already has space for a new section. Proposed shape:
```
[ sidebar ]
─────────────
tree
dashboard
calendar
timeline
graph
─────────────
Views ▾
★ active mai (pinned)
work.upc kanban
home + sport
+ new view…
─────────────
admin
```
Each entry: name (linked to `/views/<uuid>` which redirects to the right page with `?view=<uuid>`), star icon for pinned, hover-edit icon for rename/delete.
Why sidebar (vs Linear-style picker dropdown vs chip strip):
- The sidebar is the natural "places I go" list in the new 5g nav. Views are persistent destinations, not transient toggles.
- Saves the chip strip from getting another row. Chips on each page stay focused on filter dimensions (project, tag, mgmt, status, …) plus a `view_type` toggle row.
- Mobile bottom-nav drawer (5g slice B) gets the same Views section so they're reachable everywhere.
### Save / edit / delete
- **Save current state as new view**: every Views-supporting page gets a "Save view…" button next to the clear-filters link. Modal asks name + (optional) description + which view_type + group_by/sort options + "default for this page" checkbox. POST to `POST /views`.
- **Edit existing view**: opening a saved view shows "Edit view name + filters" link in the page header. Submitting updates `views.filter_json` to the current filter state (so editing == "save my tweaks back").
- **Delete**: from the sidebar entry's hover menu. Soft delete (sets `deleted_at`).
- **Set as default for page**: checkbox on the save/edit modal. The `views_default_for_uniq` index enforces at most one default per page; saving a new default clears the previous one in the same transaction.
### URL shape
- Page-level: `/dashboard?project=work.upc&tag=patents&view_type=kanban&group_by=status`
- Saved view: `/views/<uuid>` redirects to the right page + the right `?view=<uuid>` short form.
- Short form: `?view=<uuid>` on a page resolves the view, sets filter + view_type from the row, and the rest of the URL is empty. Mixing `?view=<uuid>` + extra chips → the chip overrides the view's value for that dim (user is "tweaking" the saved view without saving back).
### Sharing
- Out of scope v1 (single-user, Tailscale-only).
- Future: a saved view is just a row in a table; "export view" → JSON blob and "import view" → POST that JSON. Trivial to add when needed. Not building now.
---
## §7 — Implementation slicing
Five slices, each independently shippable:
### Slice A — Project filter dim (smallest first)
- Add `ProjectID` + `ProjectPath` fields to `TreeFilter`.
- Extend `ParseTreeFilter` / `QueryString` / `Active` / `Matches`.
- Add project picker chip to all six Views-supporting filter strips (tree, dashboard, calendar, timeline + bulk + reuse pattern).
- Tests: parse round-trip, prefix-match semantics, descendant inclusion, rename safety (use `project_id` to bypass path resolution).
- **Ships without** any view-type or saved-view concept. Useful from day 1.
### Slice B — View_type URL param + page bindings
- Add `ViewType string` to a new neutral `web/views_query.go` or extend `TreeFilter` (decide during implementation; leaning toward separate struct so TreeFilter stays focused on filtering).
- Each Views-supporting page accepts `?view_type=card|list|calendar|timeline|kanban` and dispatches to the right template.
- Default-per-page (hard-coded in slice B; saved-default lands in slice D).
- Extract `view_item.tmpl` partial; refactor `tree_section.tmpl`, `dashboard_section.tmpl`, `timeline_section.tmpl`, `calendar_section.tmpl` to use it where they show item rows.
- Tests: per-page view_type acceptance, default routing, partial render parity.
- **Depends on**: nothing (can ship in parallel with A, but logically after A so the picker shows up alongside the view-type toggle).
### Slice C — Kanban view_type
- New `view_kanban.tmpl`.
- Required `group_by` enum: status | area | tag | management.
- Read-only first. No drag-to-change-group.
- Adds the group-by chip strip (only shown when view_type=kanban).
- Tests: kanban renders for each group_by value; empty filtered set renders zero-column message; unknown group_by → fallback to status with warning banner.
### Slice D — Saved views schema + CRUD + sidebar picker
- Migration `0016_views.sql`.
- `store/views.go`: ListViews, GetView, CreateView, UpdateView, SoftDeleteView, GetDefaultForPage.
- Handlers: `GET /views` (list), `POST /views` (create), `GET /views/<uuid>` (redirect to page + ?view=), `POST /views/<uuid>` (update), `POST /views/<uuid>/delete` (soft delete).
- Sidebar section + save-view modal + edit-view inline.
- MCP read tools: `list_views`, `get_view`. Write tools deferred — m saves views via the UI, not via MCP.
- Tests: schema migration round-trip, name-uniqueness, default-per-page-uniqueness, view JSON round-trip (filter_json ↔ TreeFilter).
### Slice E — Default-view-per-page + URL shortening
- On each Views-supporting page, after parsing URL params, if no explicit filter/view is set, look up `views.is_default_for=<page>` and apply it. Visible toggle "viewing default: <name> · clear" lets the user opt out.
- `/views/<uuid>` redirect.
- Tests: default applied when no params; explicit params override default; clearing default resets.
### Dep DAG
```
A ──┬─→ B ──┬─→ C
│ └─→ D ──→ E
└─→ D (slice D can also ship after A alone if B is delayed,
since saved views can store view_type even before B's
URL param exists — the view's view_type drives render)
```
Recommended order: **A → B → D → C → E**. Slice C is highest novelty (new template + drag-write later), so let it cook on a stable read foundation.
---
## §8 — Recommendation
Ship in this order:
1. **Slice A** — project filter dim. One concrete win, no UI surprise, useful day 1.
2. **Slice B** — view-type toggle. Generalises existing pages without writing new templates (extract + reuse).
3. **Slice D** — saved views. Once view-type is a parameter, persistence has somewhere to point at.
4. **Slice C** — kanban template. New ground; depends on the view-type plumbing being solid.
5. **Slice E** — default-per-page + URL shortener. Polish layer; ships when the rest works.
Each slice is its own coder shift, its own branch, its own PR. No bundled big-bang.
---
## §8.5 — m's decisions (2026-05-26)
All open questions answered by m directly via AskUserQuestion (m greenlit chip-picker for inventor 2026-05-26 13:12). Decisions captured below; §9 stays as the historical record of what was open.
| # | header | m picked | matches inventor pick? |
|---|---|---|---|
| Q1 | Timeline | Fifth view_type | yes |
| Q2 | View scope | Page-agnostic | yes |
| Q3 | enum size | 5 values (follows Q1) | yes |
| Q4 | Picker | Sidebar section under main nav | yes |
| Q5 | Proj scope | **Toggle on the chip** (include-descendants on/off) | **no** — inventor picked "always include descendants"; m wants explicit control |
| Q6 | Kanban grp | status (active/done/archived) | yes |
| Q7 | Save UI | HTMX modal | yes |
| Q8 | rename safety | (already resolved in §4: id authoritative, path display-only) | n/a |
| Q9 | Pull back into scope | none — all four stay parked | yes |
### Implications for the slicing
- **Q5 changes Slice A**: project filter chip needs an `include-descendants` boolean toggle next to the picker. Default state and TreeFilter field: `IncludeDescendants bool` (default `true` to match the most common case; toggle flips to `false` for single-item scope). URL param: `?project_descendants=0` when off (default-elided, so URLs stay short in the common case). Cache key implication: covered automatically by `QueryString()` once the field is in the emit set.
- **Q1 + Q3 lock the view_type enum at 5**: `card | list | calendar | kanban | timeline`. Migration `0016_views.sql` CHECK constraint uses all five.
- **Q9 confirms all four parked**: drag-to-change-group on kanban, multi-user/sharing, per-view notifications, cross-view diffs all stay out of Phase 5i. Slice C stays read-only as designed.
- **Q2 + Q4 + Q7 all match inventor picks**: design ships as drafted on those fronts.
### Concrete edits to the slicing in §7
- **Slice A** adds: `IncludeDescendants` field on TreeFilter, default true; toggle chip rendered next to the project picker on every Views-supporting page; `Matches` falls back to "primary path equality only" when off; round-trip test for the toggle.
- **Slice D** schema: `view_type` CHECK now includes `timeline`. No other change.
- All other slices unchanged.
---
## §9 — Open questions for head delegation
These are forks where data + code alone can't resolve the design. Single delegation to head with these batched; head surfaces to m; head relays answers.
### Q1 — Is `timeline` a view_type or its own concept?
**Options:**
- (a) Treat timeline as a fifth view_type alongside card/list/calendar/kanban. Cleaner mental model.
- (b) Keep timeline as a standalone surface (today's behaviour). Views system stays four-typed as m named.
**Inventor pick**: (a) — kahn
**Reasoning**: m's mental model is "filtered set rendered as X". Timeline-of-filtered-set fits that shape. Keeps the system orthogonal.
### Q2 — Should saved views be page-bound or page-agnostic?
**Options:**
- (a) Page-agnostic: a view is just `(filter, view_type, sort, group_by)`. It renders on any page that supports the view_type. Listed once in the sidebar.
- (b) Page-bound: a view explicitly targets `/dashboard` or `/timeline`. Cannot be used elsewhere.
**Inventor pick**: (a)
**Reasoning**: simpler model, fewer redundant rows. The `is_default_for` column already handles "this is the default for page X" without making the view itself bound.
### Q3 — View_type enum: 4 or 5 values?
Tied to Q1. If timeline is in: 5. If not: 4.
### Q4 — Where does the saved-views picker live?
**Options:**
- (a) Sidebar section under main nav (inventor pick — fits 5g sidebar).
- (b) Top-of-page picker dropdown (Linear-style).
- (c) Chip strip above filter chips.
**Inventor pick**: (a)
### Q5 — Project filter: descendants always included?
**Options:**
- (a) Always include descendants (path-prefix match). One picked project shows the whole sub-DAG. Inventor pick.
- (b) Single-item scope. Show only that one item, no descendants.
- (c) Toggle on the chip (include-descendants on/off).
**Inventor pick**: (a)
**Reasoning**: m's use case is "scope to work.upc" → meaning the whole subtree. Single-item scope is what the item detail page already does (`/i/<path>`).
### Q6 — Kanban group_by default
**Options:**
- (a) status (active/done/archived) — small fixed column set, mirrors most public kanbans.
- (b) area (top-level path segment) — uses the existing area taxonomy.
- (c) management (mai/self/external/unmanaged).
**Inventor pick**: (a)
**Reasoning**: a kanban with 3 status columns is the most legible default. Other group_by values are still selectable via the chip strip when view_type=kanban.
### Q7 — Save-view modal vs inline form?
**Options:**
- (a) Modal (HTMX-loaded, blocks the page until saved/cancelled).
- (b) Inline form revealed below the filter strip on "Save view…" click.
**Inventor pick**: (a) — modal. Reason: saving a view is a deliberate action; the modal frames it as "you're naming and persisting this set".
### Q8 — Slug-rename safety: store path or id in `filter_json.project_*`?
Already answered in §4: **both**, id authoritative. Flagging here so head knows it's been resolved, not pending.
### Q9 — Out-of-scope confirmation
For head to confirm out-of-scope for Phase 5i:
- Multi-user / sharing.
- Drag-to-change-group on kanban (writes).
- Per-view notifications / scheduled re-rendering.
- Cross-view diffs ("what changed since last open").
**Inventor**: all four out, parked for future phases. Confirm with m via head.
---
## §10 — Risk register
| risk | likelihood | mitigation |
|---|---|---|
| `view_item.tmpl` extraction breaks one of four call sites | medium | parallel tests per existing template; ship slice B with a regression-screen check against existing visual output |
| Project filter prefix match wrong for multi-parent items | low | item.paths is the source of truth (sorted, deduped, one per lineage); test cases cover `dev.paliad` + `work.paliad` |
| Saved views proliferate and clutter the sidebar | low | pinned ★ + collapsing the section under a toggle keeps it tame; m owns deletes |
| Kanban write path drifts into scope creep | medium | slice C is read-only by contract; "drag-to-change-status" is its own future slice F if m wants it |
| URL shortener (`/views/<uuid>`) leaks short-lived views into shareable links | n/a | single-user, Tailscale-only — irrelevant in v1 |
---
## §11 — Test plan headlines
### Slice A
- `TestTreeFilterProjectScope` — path-prefix match on items with single and multi-parent paths.
- `TestParseTreeFilterProjectFallback``?project=missing.path` produces a banner state, not a 500.
- `TestTreeFilterQueryStringRoundTrip` — project_id + project_path round-trip in both URL forms.
### Slice B
- `TestPageDispatchByViewType` — each Views-supporting route serves the right template per `?view_type=`.
- `TestViewItemPartialParity` — visual diff harness: the four item-rendering view types include `view_item.tmpl` and don't regress existing markup.
### Slice C
- `TestKanbanGroupBy` — for each group_by value (status, area, tag, management), assert column set and per-column item membership.
- `TestKanbanRequiresGroupBy` — without group_by, falls back to status and surfaces a warning banner.
### Slice D
- `TestViewsCRUD` — full lifecycle (create / read / list / update / soft delete) round-trip via store + handlers.
- `TestViewsNameUniqueness` — duplicate names rejected (case-insensitive).
- `TestViewsDefaultUniqueness` — setting a new default for a page clears the prior default in the same transaction.
- `TestViewsJSONRoundTrip` — filter_json ↔ TreeFilter for every dim.
### Slice E
- `TestDefaultViewAppliedOnEmptyURL` — landing on `/dashboard` with no params applies the saved default.
- `TestExplicitParamsOverrideDefault``?status=done` on `/dashboard` with a saved default overrides without persisting.
---
## §12 — References
- `web/tree_filter.go` — current TreeFilter source.
- `web/server.go` — handler registration, route table.
- `web/dashboard.go`, `web/calendar.go`, `web/timeline.go` — current view implementations.
- `web/templates/{tree,dashboard,calendar,timeline}_section.tmpl` — current view templates.
- `docs/design.md` §4 (Interfaces), §17 (Calendar view), §12 (Timeline view), §18 (Sidebar nav).
- `docs/plans/aggregator-refactor.md` — Phase 5a fan-out pattern, reused by the multi-view aggregator if needed in slice C.
- mBrian saved-search patterns (`~/dev/mBrian/src/lib/savedSearches.svelte`, `QuickSwitcher.svelte`) — surveyed for UX inspiration; not ported.
- Phase 5h (fuller) Tabbed-Tiles dashboard — first canonical `view_type=card` consumer; lands in parallel.
---
## §13 — Status
- **Phase A (this doc)**: drafted by kahn, 2026-05-26. Awaiting m sign-off via head on §9 questions.
- **Phase B (coder)**: not started. Slices A → B → D → C → E. Each slice = one shift, one branch, one PR.
- **No code changes** in this branch beyond this doc.

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
}

361
store/views.go Normal file
View File

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

246
store/views_test.go Normal file
View File

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

View File

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

View File

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

View File

@@ -128,20 +128,18 @@ func parseCalendarQuery(r *http.Request, now time.Time) calendarQuery {
q.Month = startOfMonth(t.In(now.Location()))
}
}
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 calendarKindEvent, calendarKindTodo, calendarKindDoc:
if !seen[k] {
seen[k] = true
q.Kinds = append(q.Kinds, k)
}
}
// Accept both `?kind=event,doc` (single param, comma-joined) and
// `?kind=event&kind=doc` (repeated param, HTMX multi-select form
// submission). The latter is what the calendar_section.tmpl form
// emits when the user clicks more than one option in the kind chip;
// the prior q.Get call dropped everything past the first value.
for _, k := range parseValues(r.URL.Query(), "kind") {
switch k {
case calendarKindEvent, calendarKindTodo, calendarKindDoc:
q.Kinds = append(q.Kinds, k)
}
sort.Strings(q.Kinds)
}
sort.Strings(q.Kinds)
return q
}
@@ -184,12 +182,20 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
display := *payload
display.Cached = hit
projects, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
data := map[string]any{
"Title": "calendar",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
"Title": "calendar",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
"Projects": projects,
"BasePath": "/views/calendar",
"ProjectChipTarget": "#calendar-section",
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "calendar_section", data)

View File

@@ -17,7 +17,7 @@ func TestCalendarRendersMonthGrid(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/calendar")
code, body := get(t, h, "/views/calendar")
if code != 200 {
t.Fatalf("GET /calendar → %d body=%s", code, body)
}
@@ -27,7 +27,7 @@ func TestCalendarRendersMonthGrid(t *testing.T) {
`<th scope="col">Mon</th>`,
`<th scope="col">Sun</th>`,
`class="calendar-nav"`,
`href="/calendar?month=`, // prev/next anchors present
`href="/views/calendar?month=`, // prev/next anchors present
} {
if !strings.Contains(body, want) {
t.Errorf("calendar body missing %q", want)
@@ -71,7 +71,7 @@ func TestCalendarSurfacesDatedLink(t *testing.T) {
t.Fatalf("seed link: %v", err)
}
code, body := get(t, h, "/calendar")
code, body := get(t, h, "/views/calendar")
if code != 200 {
t.Fatalf("GET /calendar → %d", code)
}
@@ -130,7 +130,7 @@ func TestCalendarFilterScopeByTag(t *testing.T) {
}
// Unfiltered: both notes show.
_, all := get(t, h, "/calendar?refresh=1")
_, all := get(t, h, "/views/calendar?refresh=1")
if !strings.Contains(all, workNote) {
t.Errorf("unfiltered calendar missing work note %q", workNote)
}
@@ -139,7 +139,7 @@ func TestCalendarFilterScopeByTag(t *testing.T) {
}
// Filtered: only work note shows.
_, scoped := get(t, h, "/calendar?refresh=1&tag=cal-test-work-"+stamp)
_, scoped := get(t, h, "/views/calendar?refresh=1&tag=cal-test-work-"+stamp)
if !strings.Contains(scoped, workNote) {
t.Errorf("filtered calendar missing work note %q", workNote)
}
@@ -157,7 +157,7 @@ func TestCalendarAdjacentMonthDays(t *testing.T) {
h := srv.Routes()
// Pick a month whose first day is NOT a Monday so leading days appear.
// May 2026 starts on a Friday; lead = Apr 27/28/29/30.
_, body := get(t, h, "/calendar?month=2026-05&refresh=1")
_, body := get(t, h, "/views/calendar?month=2026-05&refresh=1")
if !strings.Contains(body, "adjacent-month") {
t.Errorf("expected adjacent-month class on lead-in cells for May 2026, body did not include it")
}
@@ -173,11 +173,11 @@ func TestCalendarNavPrevNextLinks(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
if !strings.Contains(body, `href="/calendar?month=2026-04"`) {
_, body := get(t, h, "/views/calendar?month=2026-05")
if !strings.Contains(body, `href="/views/calendar?month=2026-04"`) {
t.Errorf("expected prev link to 2026-04, body did not include it")
}
if !strings.Contains(body, `href="/calendar?month=2026-06"`) {
if !strings.Contains(body, `href="/views/calendar?month=2026-06"`) {
t.Errorf("expected next link to 2026-06, body did not include it")
}
}
@@ -190,11 +190,11 @@ func TestCalendarFilterChipStripRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
_, body := get(t, h, "/views/calendar?month=2026-05")
for _, want := range []string{
`id="calendar-filterbar"`,
`hx-target="#calendar-section"`,
`hx-get="/calendar"`,
`hx-get="/views/calendar"`,
`<input type="hidden" name="month" value="2026-05">`, // preserves month across chip changes
`name="kind"`,
`name="tag"`,
@@ -213,7 +213,7 @@ func TestCalendarHTMXReturnsSectionOnly(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
req := httptest.NewRequest("GET", "/calendar?month=2026-05", nil)
req := httptest.NewRequest("GET", "/views/calendar?month=2026-05", nil)
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
@@ -236,6 +236,84 @@ func TestCalendarHTMXReturnsSectionOnly(t *testing.T) {
}
}
// TestCalendarFilterMultiValueTagsFromForm reproduces m's bug report —
// /calendar filters don't work when the chip-strip form submits a
// multi-select. <select multiple name="tag"> serialises as
// `?tag=foo&tag=bar` (two URL params with the same key); the prior
// ParseTreeFilter implementation used `r.URL.Query().Get("tag")` which
// returns only the FIRST value, so the second tag silently dropped and
// items matching only the first tag bled through. AND-across-tags is
// the contract per TreeFilter.Matches.
func TestCalendarFilterMultiValueTagsFromForm(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"), ".", "")
tagA := "cal-multibug-a-" + stamp
tagB := "cal-multibug-b-" + 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 string
tags []string
}
now := time.Now().UTC().Format("150405")
seeds := []seed{
{slug: "ab-" + stamp, note: "cal-AB-note-" + now, tags: []string{tagA, tagB}},
{slug: "a-" + stamp, note: "cal-A-note-" + now, tags: []string{tagA}},
{slug: "b-" + stamp, note: "cal-B-note-" + now, tags: []string{tagB}},
}
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[], $4::text[])
returning id`,
s.slug, s.slug, dev, s.tags,
).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/cal-multibug-"+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)
}
// HTMX-style multi-value submission: two `tag=` params, not comma-joined.
url := "/views/calendar?refresh=1&tag=" + tagA + "&tag=" + tagB
_, body := get(t, h, url)
// Item AB has BOTH tags — must appear.
if !strings.Contains(body, seeds[0].note) {
t.Errorf("expected AB note %q to appear with tag=%s&tag=%s (AND match), got body excerpt: %s",
seeds[0].note, tagA, tagB, truncate(body, 600))
}
// Item A has only tagA — must NOT appear (AND semantics fail tagB).
if strings.Contains(body, seeds[1].note) {
t.Errorf("BUG: A-only note %q leaked through tag=%s&tag=%s — second tag silently dropped by q.Get(\"tag\")",
seeds[1].note, tagA, tagB)
}
// Item B has only tagB — must NOT appear (AND semantics fail tagA).
if strings.Contains(body, seeds[2].note) {
t.Errorf("BUG: B-only note %q leaked through tag=%s&tag=%s — second tag silently dropped by q.Get(\"tag\")",
seeds[2].note, tagA, tagB)
}
}
// TestCalendarCellCarriesLongLabel proves the per-cell long German label
// is in the rendered HTML so the mobile breakpoint CSS (≤480px) can
// reveal it. The label compensates for the column-header weekday strip
@@ -244,7 +322,7 @@ func TestCalendarCellCarriesLongLabel(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
_, body := get(t, h, "/views/calendar?month=2026-05")
// May 4 2026 is a Monday → "Mo., 4. Mai".
if !strings.Contains(body, `Mo., 4. Mai`) {
t.Errorf("expected long label 'Mo., 4. Mai' for 2026-05-04 cell, body did not include it")

View File

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

View File

@@ -233,23 +233,31 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
}
refreshQuery += "scope=" + scope
}
refreshURL := "/dashboard?"
refreshURL := "/views/dashboard?"
if refreshQuery != "" {
refreshURL += refreshQuery + "&"
}
refreshURL += "refresh=1"
projects, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
data := map[string]any{
"Title": "dashboard",
"P": displayPayload,
"Filter": filter,
"View": view,
"Scope": scope,
"Tabs": dashboardTabs(view, filterKey, scope),
"ScopeURL": dashboardScopeToggleURL(view, scope, filterKey),
"UpdatedRel": updatedRel,
"RefreshURL": refreshURL,
"FilterActive": filter.Active(),
"Title": "dashboard",
"P": displayPayload,
"Filter": filter,
"View": view,
"Scope": scope,
"Tabs": dashboardTabs(view, filterKey, scope),
"ScopeURL": dashboardScopeToggleURL(view, scope, filterKey),
"UpdatedRel": updatedRel,
"RefreshURL": refreshURL,
"FilterActive": filter.Active(),
"Projects": projects,
"BasePath": "/views/dashboard",
"ProjectChipTarget": "#dashboard-section",
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "dashboard_section", data)
@@ -296,9 +304,9 @@ func dashboardScopeToggleURL(view, scope, filterKey string) string {
parts = append(parts, "scope="+next)
}
if len(parts) == 0 {
return "/dashboard"
return "/views/dashboard"
}
return "/dashboard?" + strings.Join(parts, "&")
return "/views/dashboard?" + strings.Join(parts, "&")
}
// dashboardTab is a single entry in the view-switcher strip.
@@ -314,7 +322,7 @@ type dashboardTab struct {
// scope (current) elide from the URL so the address bar stays clean
// on the daily-driver path.
func dashboardTabs(active, filterKey, scope string) []dashboardTab {
prefix := "/dashboard"
prefix := "/views/dashboard"
filterQuery := ""
if filterKey != "__empty__" && filterKey != "" {
filterQuery = filterKey

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ func TestDashboardRendersWithoutDeps(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d body=%s", code, body)
}
@@ -77,7 +77,7 @@ func TestDashboardRecentDocsSurfacesDatedLinks(t *testing.T) {
}
// The Recent Documents card lives on the Tasks tab (Phase 5h).
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
@@ -134,14 +134,18 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
}()
// Doc rows surface on the Tasks tab; the filter narrows both views.
code, body := get(t, h, "/dashboard?tag=dev&view=tasks")
code, body := get(t, h, "/views/dashboard?tag=dev&view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?tag=dev&view=tasks → %d", code)
}
if !strings.Contains(body, "dev."+devSlug) {
// Phase 5i Slice A: the project-scope picker renders every item's primary
// path as a <select> option, so a naive body substring match would also
// see filtered-out paths inside the dropdown. Anchor the row assertion on
// the detail link emitted by the dashboard cards instead.
if !strings.Contains(body, `href="/i/dev.`+devSlug+`"`) {
t.Errorf("expected dev row in filtered dashboard")
}
if strings.Contains(body, "home."+homeSlug) {
if strings.Contains(body, `href="/i/home.`+homeSlug+`"`) {
t.Errorf("home row should be filtered out when ?tag=dev")
}
}
@@ -155,9 +159,9 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
h := srv.Routes()
// Prime the cache.
_, _ = get(t, h, "/dashboard")
_, _ = get(t, h, "/views/dashboard")
// Second hit shows cached label.
_, cachedBody := get(t, h, "/dashboard")
_, cachedBody := get(t, h, "/views/dashboard")
if !strings.Contains(cachedBody, "cached") {
n := len(cachedBody)
if n > 600 {
@@ -166,7 +170,7 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
t.Fatalf("setup: second load should be cached, got body:\n%s", cachedBody[:n])
}
// Third hit with ?refresh=1 should be fresh again.
code, body := get(t, h, "/dashboard?refresh=1")
code, body := get(t, h, "/views/dashboard?refresh=1")
if code != 200 {
t.Fatalf("GET /dashboard?refresh=1 → %d", code)
}
@@ -186,7 +190,7 @@ func TestDashboardCollapsesEmptyCardsWhenNoFilter(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=tasks")
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
@@ -206,7 +210,7 @@ func TestDashboardFilterKeepsFullCardChrome(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?tag=nothing-matches-zzz&view=tasks")
code, body := get(t, h, "/views/dashboard?tag=nothing-matches-zzz&view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?tag=… → %d", code)
}
@@ -267,7 +271,7 @@ func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) {
h := srv.Routes()
// Phase 5h: the Stale card retired. The stale project now appears
// inside the Tiles Quiet fold with a tile-stale flag on the tile.
code, body := get(t, h, "/dashboard")
code, body := get(t, h, "/views/dashboard")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
}
@@ -331,7 +335,7 @@ func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) {
// Phase 5h: assert the tile for this slug is NOT flagged stale.
// Recent repo activity (3d old) puts it solidly inside the activity
// window AND fails the staleness probe, so no tile-stale class.
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Find the tile for this slug and check its class attribute.
marker := `data-item-path="dev.` + slug + `"`
idx := strings.Index(body, marker)
@@ -358,8 +362,8 @@ func TestDashboardCacheHitOnSecondLoad(t *testing.T) {
defer pool.Close()
h := srv.Routes()
_, _ = get(t, h, "/dashboard")
code, body := get(t, h, "/dashboard")
_, _ = get(t, h, "/views/dashboard")
code, body := get(t, h, "/views/dashboard")
if code != 200 {
t.Fatalf("second GET /dashboard → %d", code)
}

View File

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

134
web/detail_order_test.go Normal file
View File

@@ -0,0 +1,134 @@
package web_test
import (
"strings"
"testing"
)
// TestDetailFieldsRenderInOrder pins m's requested top-to-bottom flow on
// the /i/{path} edit form: form-then-auxiliaries, with form fields
// grouped general → specific. The test slices the rendered body into the
// documented anchor strings (form labels, group headings, divider, aux
// heading, first auxiliary <details>) and confirms each anchor's first
// index is strictly greater than the previous one.
//
// Anchors deliberately picked to be robust:
// - Form field markup (name="title" / name="slug" / etc.) — won't drift
// unless the form is re-architected.
// - Group-heading IDs (hdr-general / hdr-classification / hdr-flags /
// hdr-content / hdr-aux) — emitted by the Phase-5i template.
// - The aux-divider <hr> — the explicit visual break m asked for.
//
// If a future refactor moves fields around inside a group, this test
// still passes as long as the cross-group order holds.
func TestDetailFieldsRenderInOrder(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/i/dev")
if code != 200 {
t.Fatalf("GET /i/dev → %d", code)
}
anchors := []struct {
label string
needle string
}{
{"General heading", `id="hdr-general"`},
{"Title field", `name="title"`},
{"Slug field", `name="slug"`},
{"Parents field", `name="parent_ids"`},
{"Status field", `name="status"`},
{"Classification heading", `id="hdr-classification"`},
{"Tags field", `name="tags"`},
{"Management field", `name="management"`},
{"Flags heading", `id="hdr-flags"`},
{"pinned field", `name="pinned"`},
{"archived field", `name="archived"`},
{"Content heading", `id="hdr-content"`},
{"Content textarea", `name="content_md"`},
{"Save button", `<button type="submit">Save</button>`},
{"Auxiliary divider", `class="aux-divider"`},
{"Auxiliary heading", `id="hdr-aux"`},
{"Documents section", `data-section="documents"`},
}
prevIdx := -1
prevLabel := "(start)"
for _, a := range anchors {
idx := strings.Index(body, a.needle)
if idx < 0 {
t.Errorf("%s anchor %q not found in body", a.label, a.needle)
continue
}
if idx <= prevIdx {
t.Errorf("order violation: %s (idx %d) must come AFTER %s (idx %d)",
a.label, idx, prevLabel, prevIdx)
}
prevIdx = idx
prevLabel = a.label
}
}
// TestDetailFormGroupHeadings proves the three group subheadings render
// with the expected human-readable copy. Hardcoded so a future "clean
// up the strings" pass doesn't silently strip the visual hierarchy m
// asked for.
func TestDetailFormGroupHeadings(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev")
for _, want := range []string{
`>General</h2>`,
`>Classification</h2>`,
`>Flags</h2>`,
`>Content</h2>`,
`>Related</h2>`,
} {
if !strings.Contains(body, want) {
t.Errorf("detail page missing group heading %q", want)
}
}
}
// TestDetailAuxSectionsAfterForm proves the read-only auxiliary
// <details> sections (Tasks / Issues / Documents) live BELOW the
// form's </form> tag — that's the load-bearing visual contract from
// m's report. Public-listing + Timeline-behaviour stay INSIDE the form
// (form-bound, saved by the main Save) — this test asserts only the
// purely read-only sections moved.
func TestDetailAuxSectionsAfterForm(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev")
// The layout has a <form action="/logout"> in the sidebar — its </form>
// would match first. Anchor on the detail form's start tag, then look
// for </form> AFTER that point so we're measuring the right boundary.
formStart := strings.Index(body, `<form method="post" action="/i/dev"`)
if formStart < 0 {
t.Fatalf("detail form start tag not found in body")
}
formEnd := strings.Index(body[formStart:], "</form>")
if formEnd < 0 {
t.Fatalf("</form> tag not found after detail form start")
}
formEnd += formStart
docs := strings.Index(body, `data-section="documents"`)
if docs < 0 {
t.Fatalf("documents section not found")
}
if docs <= formEnd {
t.Errorf("Documents section (idx %d) must appear AFTER </form> (idx %d)", docs, formEnd)
}
// Public listing stays inside the form — confirm the contract holds.
publicSection := strings.Index(body, `data-section="public"`)
if publicSection < 0 {
t.Fatalf("public section not found")
}
if publicSection >= formEnd {
t.Errorf("Public listing section (idx %d) should remain INSIDE the form (before </form> at idx %d) for save coherence",
publicSection, formEnd)
}
}

View File

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

43
web/icons.go Normal file
View File

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

217
web/kanban.go Normal file
View File

@@ -0,0 +1,217 @@
package web
import (
"net/url"
"sort"
"strings"
"github.com/m/projax/store"
)
// Phase 5i Slice C — kanban view_type. Read-only: groups the filtered item
// set into columns by the chosen group_by dimension. Drag-to-change-group
// is deliberately out of scope (design.md §3 / slice ordering A→B→D→C→E).
//
// m's Q6 pick (2026-05-26): default group_by = status (active/done/archived).
// Other values (area / tag / management) remain selectable via the
// group_by chip strip.
// Kanban group_by values. The DB CHECK constraint on projax.views.group_by
// is open-ended (text); the validation list lives here so the handler can
// reject typos before they round-trip.
const (
GroupByStatus = "status"
GroupByArea = "area"
GroupByTag = "tag"
GroupByManagement = "management"
)
var allGroupBy = []string{GroupByStatus, GroupByArea, GroupByTag, GroupByManagement}
// ParseGroupBy pulls `group_by` from q. Empty / unknown values fall back to
// the default (status). Lower-cases for case-insensitive matching.
func ParseGroupBy(q url.Values) string {
raw := strings.ToLower(strings.TrimSpace(q.Get("group_by")))
for _, g := range allGroupBy {
if raw == g {
return g
}
}
return GroupByStatus
}
// KanbanColumn is one column in the rendered board.
type KanbanColumn struct {
Key string // the raw group value (status="active", tag="work", …)
Label string // human-readable header
Items []*store.Item // sorted: pinned-first, then updated_at desc, then title
}
// KanbanBoard is the rendered shape. Columns are ordered by the canonical
// per-dimension order (status: active/done/archived; management: mai/self/
// external/unmanaged; area + tag: alphabetical with the items they hold).
type KanbanBoard struct {
GroupBy string
Columns []KanbanColumn
Total int // total cards across columns
}
// BuildKanbanBoard groups the matched items by groupBy. Pure: takes whatever
// list of items the handler filtered, returns the column shape.
func BuildKanbanBoard(items []*store.Item, groupBy string) KanbanBoard {
if groupBy == "" {
groupBy = GroupByStatus
}
keyer := groupByKeyer(groupBy)
byKey := map[string][]*store.Item{}
emittedKeys := []string{}
seen := map[string]bool{}
for _, it := range items {
for _, k := range keyer(it) {
if !seen[k] {
seen[k] = true
emittedKeys = append(emittedKeys, k)
}
byKey[k] = append(byKey[k], it)
}
}
// Stable column ordering per dimension.
switch groupBy {
case GroupByStatus:
emittedKeys = orderKeyedFirst(emittedKeys, []string{"active", "done", "archived"})
case GroupByManagement:
emittedKeys = orderKeyedFirst(emittedKeys, []string{"mai", "self", "external", "unmanaged"})
default:
sort.Strings(emittedKeys)
}
board := KanbanBoard{GroupBy: groupBy}
for _, k := range emittedKeys {
cardItems := byKey[k]
sort.SliceStable(cardItems, func(i, j int) bool {
a, b := cardItems[i], cardItems[j]
if a.Pinned != b.Pinned {
return a.Pinned
}
if !a.UpdatedAt.Equal(b.UpdatedAt) {
return a.UpdatedAt.After(b.UpdatedAt)
}
return a.Title < b.Title
})
board.Columns = append(board.Columns, KanbanColumn{
Key: k,
Label: columnLabel(groupBy, k),
Items: cardItems,
})
board.Total += len(cardItems)
}
return board
}
// groupByKeyer returns a function that maps an item to its column keys (a
// slice because tags can put one item in multiple columns).
func groupByKeyer(groupBy string) func(*store.Item) []string {
switch groupBy {
case GroupByStatus:
return func(it *store.Item) []string {
s := it.Status
if s == "" {
s = "active"
}
return []string{s}
}
case GroupByManagement:
return func(it *store.Item) []string {
if len(it.Management) == 0 {
return []string{"unmanaged"}
}
out := make([]string, 0, len(it.Management))
out = append(out, it.Management...)
return out
}
case GroupByArea:
return func(it *store.Item) []string {
// First segment of the primary path is the area (dev / work / home …).
p := it.PrimaryPath()
if p == "" {
return []string{"—"}
}
if idx := strings.IndexByte(p, '.'); idx > 0 {
return []string{p[:idx]}
}
return []string{p}
}
case GroupByTag:
return func(it *store.Item) []string {
if len(it.Tags) == 0 {
return []string{"untagged"}
}
return append([]string(nil), it.Tags...)
}
}
return func(it *store.Item) []string { return []string{it.Status} }
}
// columnLabel renders the column header. Status / management get title-case
// for legibility; area and tag stay verbatim.
func columnLabel(groupBy, key string) string {
switch groupBy {
case GroupByStatus, GroupByManagement:
if key == "" {
return "—"
}
return strings.ToUpper(key[:1]) + key[1:]
}
return key
}
// orderKeyedFirst returns `actual` with `preferred` entries first (in the
// preferred order, when present in actual), then any remaining actual
// entries appended in input order.
func orderKeyedFirst(actual, preferred []string) []string {
present := map[string]bool{}
for _, k := range actual {
present[k] = true
}
out := make([]string, 0, len(actual))
for _, k := range preferred {
if present[k] {
out = append(out, k)
delete(present, k)
}
}
for _, k := range actual {
if present[k] {
out = append(out, k)
delete(present, k)
}
}
return out
}
// GroupByChip is one entry in the group-by chip strip rendered above the
// kanban board. Active marks the current group_by; URL is the toggle target.
type GroupByChip struct {
Label string
URL string
Active bool
}
// GroupByChips builds the group-by chip strip. base must already include the
// `?view_type=kanban` segment; we append `&group_by=<value>` on top.
func GroupByChips(base string, filter TreeFilter, current string) []GroupByChip {
out := make([]GroupByChip, 0, len(allGroupBy))
for _, g := range allGroupBy {
u := filter.URLOn(base)
if !strings.Contains(u, "?") {
u += "?view_type=kanban&group_by=" + g
} else {
u += "&view_type=kanban&group_by=" + g
}
out = append(out, GroupByChip{
Label: g,
URL: u,
Active: g == current,
})
}
return out
}

105
web/kanban_test.go Normal file
View File

@@ -0,0 +1,105 @@
package web
import (
"net/url"
"testing"
"time"
"github.com/m/projax/store"
)
// TestBuildKanbanBoardGroupByStatus exercises the default group_by — three
// columns in canonical order (active/done/archived) populated by status.
func TestBuildKanbanBoardGroupByStatus(t *testing.T) {
items := []*store.Item{
{ID: "a", Title: "Active1", Status: "active", Paths: []string{"dev.a"}, UpdatedAt: time.Unix(2, 0)},
{ID: "b", Title: "Active2", Status: "active", Paths: []string{"dev.b"}, UpdatedAt: time.Unix(3, 0), Pinned: true},
{ID: "c", Title: "Done1", Status: "done", Paths: []string{"dev.c"}, UpdatedAt: time.Unix(1, 0)},
}
board := BuildKanbanBoard(items, GroupByStatus)
if got, want := len(board.Columns), 2; got != want {
t.Fatalf("columns = %d, want %d", got, want)
}
if board.Columns[0].Key != "active" {
t.Errorf("first column = %q, want active", board.Columns[0].Key)
}
if board.Columns[1].Key != "done" {
t.Errorf("second column = %q, want done", board.Columns[1].Key)
}
if board.Total != 3 {
t.Errorf("Total = %d, want 3", board.Total)
}
// active column: pinned-first (b), then updated_at desc (a).
if board.Columns[0].Items[0].ID != "b" {
t.Errorf("pinned item should be first; got %q", board.Columns[0].Items[0].ID)
}
if board.Columns[0].Items[1].ID != "a" {
t.Errorf("second active item = %q, want a", board.Columns[0].Items[1].ID)
}
}
// TestBuildKanbanBoardGroupByTag puts an item with multiple tags into multiple
// columns. Columns sort alphabetically (no canonical preference for tags).
func TestBuildKanbanBoardGroupByTag(t *testing.T) {
items := []*store.Item{
{ID: "a", Title: "A", Status: "active", Paths: []string{"a"}, Tags: []string{"work", "dev"}},
{ID: "b", Title: "B", Status: "active", Paths: []string{"b"}, Tags: []string{"dev"}},
{ID: "c", Title: "C", Status: "active", Paths: []string{"c"}, Tags: []string{}},
}
board := BuildKanbanBoard(items, GroupByTag)
keys := map[string]int{}
for _, col := range board.Columns {
keys[col.Key] = len(col.Items)
}
if keys["dev"] != 2 {
t.Errorf("dev column items = %d, want 2", keys["dev"])
}
if keys["work"] != 1 {
t.Errorf("work column items = %d, want 1", keys["work"])
}
if keys["untagged"] != 1 {
t.Errorf("untagged column items = %d, want 1", keys["untagged"])
}
}
// TestBuildKanbanBoardGroupByArea uses the first path segment as the area.
func TestBuildKanbanBoardGroupByArea(t *testing.T) {
items := []*store.Item{
{ID: "a", Title: "A", Status: "active", Paths: []string{"dev.a"}},
{ID: "b", Title: "B", Status: "active", Paths: []string{"work.upc.b"}},
{ID: "c", Title: "C", Status: "active", Paths: []string{"work.c"}},
}
board := BuildKanbanBoard(items, GroupByArea)
keys := map[string]int{}
for _, col := range board.Columns {
keys[col.Key] = len(col.Items)
}
if keys["dev"] != 1 {
t.Errorf("dev column = %d, want 1", keys["dev"])
}
if keys["work"] != 2 {
t.Errorf("work column = %d, want 2", keys["work"])
}
}
// TestParseGroupByFallsBackOnUnknown verifies the parser's defaulting.
func TestParseGroupByFallsBackOnUnknown(t *testing.T) {
cases := map[string]string{
"": GroupByStatus,
"status": GroupByStatus,
"tag": GroupByTag,
"area": GroupByArea,
"management": GroupByManagement,
"MaNaGeMeNt": GroupByManagement,
"made-up": GroupByStatus,
}
for raw, want := range cases {
q := url.Values{}
if raw != "" {
q.Set("group_by", raw)
}
if got := ParseGroupBy(q); got != want {
t.Errorf("ParseGroupBy(%q) = %q, want %q", raw, got, want)
}
}
}

View File

@@ -13,18 +13,18 @@ func TestLayoutSidebarOnDesktop(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
if !strings.Contains(body, `<aside class="projax-sidebar"`) {
t.Fatalf("expected <aside class=\"projax-sidebar\"> in body, got: %s", truncate(body, 400))
}
for _, want := range []struct {
href, label string
}{
{`/`, "Tree"},
{`/dashboard`, "Dashboard"},
{`/calendar`, "Calendar"},
{`/timeline`, "Timeline"},
{`/graph`, "Graph"},
{`/views/tree`, "Tree"},
{`/views/dashboard`, "Dashboard"},
{`/views/calendar`, "Calendar"},
{`/views/timeline`, "Timeline"},
{`/views/graph`, "Graph"},
{`/admin`, "Admin"},
} {
if !strings.Contains(body, `href="`+want.href+`"`) {
@@ -43,12 +43,12 @@ func TestLayoutActiveClass(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Dashboard item should be active.
if !strings.Contains(body, `class="nav-item active" title="Dashboard"`) {
t.Errorf("expected Dashboard nav-item to carry .active on /dashboard, body: %s", truncate(body, 400))
}
// Tree item (href="/") must NOT be active on the /dashboard page.
// Tree item (href="/views/tree") must NOT be active on the /dashboard page.
// The Tree anchor opens with the exact-path active match; on /dashboard
// the substring `class="nav-item" title="Tree"` should be present and
// not its `active` sibling.
@@ -68,7 +68,7 @@ func TestLayoutCollapseScript(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Pre-paint restore script.
if !strings.Contains(body, `localStorage.getItem('projax.sidebar.collapsed')`) {
t.Errorf("expected pre-paint localStorage restore script in layout")
@@ -93,7 +93,7 @@ func TestLayoutNoTopHeader(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Slice out the region between <body> and <main> — that's where the
// pre-5g top header lived. Inside <main> belongs to content templates.
chrome := body
@@ -116,17 +116,17 @@ func TestLayoutBottomNavMarkup(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
if !strings.Contains(body, `<nav class="projax-bottom-nav"`) {
t.Fatalf("expected <nav class=\"projax-bottom-nav\"> in body, got: %s", truncate(body, 400))
}
// 5-slot anchors / details element.
for _, want := range []string{
`<a href="/" class="bottom-nav-item`,
`<a href="/dashboard" class="bottom-nav-item`,
`<a href="/views/tree" class="bottom-nav-item`,
`<a href="/views/dashboard" class="bottom-nav-item`,
`<a href="/new" class="bottom-nav-item capture-btn"`,
`class="capture-circle"`,
`<a href="/calendar" class="bottom-nav-item`,
`<a href="/views/calendar" class="bottom-nav-item`,
`<details class="projax-mobile-drawer"`,
} {
if !strings.Contains(body, want) {
@@ -135,8 +135,8 @@ func TestLayoutBottomNavMarkup(t *testing.T) {
}
// Drawer overflow items: Timeline, Graph, Admin, theme toggle, sign-out.
for _, want := range []string{
`<a href="/timeline" class="drawer-item`,
`<a href="/graph" class="drawer-item`,
`<a href="/views/timeline" class="drawer-item`,
`<a href="/views/graph" class="drawer-item`,
`<a href="/admin" class="drawer-item`,
`id="theme-toggle-drawer"`,
`<form method="post" action="/logout" class="drawer-form">`,
@@ -154,11 +154,11 @@ func TestLayoutBottomNavActiveClass(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar")
if !strings.Contains(body, `<a href="/calendar" class="bottom-nav-item active"`) {
_, body := get(t, h, "/views/calendar")
if !strings.Contains(body, `<a href="/views/calendar" class="bottom-nav-item active"`) {
t.Errorf("expected Calendar bottom-nav-item to carry .active on /calendar")
}
if strings.Contains(body, `<a href="/" class="bottom-nav-item active"`) {
if strings.Contains(body, `<a href="/views/tree" class="bottom-nav-item active"`) {
t.Errorf("Tree bottom-nav-item should NOT be active on /calendar")
}
}
@@ -171,7 +171,7 @@ func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/views/dashboard")
// Both buttons present.
if !strings.Contains(body, `id="theme-toggle"`) {
t.Errorf("sidebar theme-toggle button missing")

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

@@ -238,18 +238,25 @@ func TestTreeFilterPublicNarrows(t *testing.T) {
}
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, pubID, prvID)
_, yesBody := get(t, h, "/?public=1")
if !strings.Contains(yesBody, pubSlug) {
t.Errorf("?public=1 should show pub-filt-yes")
// Phase 5i Slice A: the project picker drops every item path into a
// <select> dropdown, so naive substring assertions trip on filtered-out
// rows visible in the picker. Anchor the row assertion on the
// tree-row link instead — that only renders for items that pass the
// filter.
pubLink := `href="/i/dev.` + pubSlug + `"`
prvLink := `href="/i/dev.` + prvSlug + `"`
_, yesBody := get(t, h, "/views/tree?public=1")
if !strings.Contains(yesBody, pubLink) {
t.Errorf("?public=1 should show pub-filt-yes row")
}
if strings.Contains(yesBody, prvSlug) {
t.Errorf("?public=1 should hide pub-filt-no")
if strings.Contains(yesBody, prvLink) {
t.Errorf("?public=1 should hide pub-filt-no row")
}
_, noBody := get(t, h, "/?public=0")
if strings.Contains(noBody, pubSlug) {
t.Errorf("?public=0 should hide pub-filt-yes")
_, noBody := get(t, h, "/views/tree?public=0")
if strings.Contains(noBody, pubLink) {
t.Errorf("?public=0 should hide pub-filt-yes row")
}
if !strings.Contains(noBody, prvSlug) {
t.Errorf("?public=0 should show pub-filt-no")
if !strings.Contains(noBody, prvLink) {
t.Errorf("?public=0 should show pub-filt-no row")
}
}

View File

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

View File

@@ -10,10 +10,12 @@ import (
"log/slog"
"mime"
"net/http"
"net/url"
"sort"
"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"
@@ -131,6 +133,10 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) },
"subF": func(a, b any) float64 { return toFloat(a) - toFloat(b) },
"mulF": func(a, b any) float64 { return toFloat(a) * toFloat(b) },
// Phase 5j slice G — sidebar icon registry. layout.tmpl calls
// `renderIcon .View.Icon` to emit the matching SVG, falling back to
// the folder default for nil / unknown keys.
"renderIcon": RenderViewIcon,
"tagToggleURL": func(active []string, tag string, isActive bool) string {
next := []string{}
if isActive {
@@ -150,7 +156,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
},
}
pages := map[string]*template.Template{}
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error"} {
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error", "views_landing", "view_editor"} {
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/"+name+".tmpl",
@@ -161,22 +167,47 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
pages[name] = t
}
// tree bundles the tree-section partial so HTMX swaps and the initial
// page render share definitions.
// page render share definitions. project_chip.tmpl is the Phase 5i Slice
// A shared partial that every Views-supporting page includes inside its
// filter strip.
treeTmpl, err := template.New("tree").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/tree.tmpl",
"templates/tree_section.tmpl",
"templates/tree_card.tmpl",
"templates/tree_kanban.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse tree: %w", err)
}
pages["tree"] = treeTmpl
// Standalone tree-section template for HTMX fragment responses.
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS, "templates/tree_section.tmpl")
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS,
"templates/tree_section.tmpl",
"templates/tree_card.tmpl",
"templates/tree_kanban.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse tree_section: %w", err)
}
pages["tree_section"] = treeSection
// Phase 5j view-render template bundles the tree-section partials so a
// rendered view at /views/{slug} can use the same dispatch (list / card
// / kanban via .ViewType).
viewRender, err := template.New("view_render").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/view_render.tmpl",
"templates/tree_section.tmpl",
"templates/tree_card.tmpl",
"templates/tree_kanban.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse view_render: %w", err)
}
pages["view_render"] = viewRender
// detail bundles the shared tasks-section + issues-section partials so
// HTMX swaps and the initial page render hit the same template definitions.
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
@@ -250,6 +281,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"templates/dashboard.tmpl",
"templates/dashboard_section.tmpl",
"templates/dashboard_tiles.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse dashboard: %w", err)
@@ -258,6 +290,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
dashSection, err := template.New("dashboard_section").Funcs(funcs).ParseFS(templatesFS,
"templates/dashboard_section.tmpl",
"templates/dashboard_tiles.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse dashboard_section: %w", err)
@@ -269,12 +302,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"templates/layout.tmpl",
"templates/timeline.tmpl",
"templates/timeline_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse timeline: %w", err)
}
pages["timeline"] = timelineTmpl
timelineSection, err := template.New("timeline_section").Funcs(funcs).ParseFS(templatesFS, "templates/timeline_section.tmpl")
timelineSection, err := template.New("timeline_section").Funcs(funcs).ParseFS(templatesFS,
"templates/timeline_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse timeline_section: %w", err)
}
@@ -287,12 +324,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"templates/layout.tmpl",
"templates/calendar.tmpl",
"templates/calendar_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse calendar: %w", err)
}
pages["calendar"] = calTmpl
calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS, "templates/calendar_section.tmpl")
calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS,
"templates/calendar_section.tmpl",
"templates/project_chip.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse calendar_section: %w", err)
}
@@ -340,17 +381,26 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.handleTree)
// Phase 5j slice C — full URL migration. The five legacy pages live at
// /views/{system-slug} now; the old top-level URLs 301-redirect to
// their new home (with the legacy ?view=<uuid> param resolved through
// the old uuid → new slug if it still maps to a row).
mux.HandleFunc("GET /views/tree", s.handleTree)
mux.HandleFunc("GET /views/dashboard", s.handleDashboard)
mux.HandleFunc("GET /views/timeline", s.handleTimeline)
mux.HandleFunc("GET /views/calendar", s.handleCalendar)
mux.HandleFunc("GET /views/graph", s.handleGraph)
mux.HandleFunc("GET /", s.legacyRedirect("tree"))
mux.HandleFunc("GET /dashboard", s.legacyRedirect("dashboard"))
mux.HandleFunc("GET /timeline", s.legacyRedirect("timeline"))
mux.HandleFunc("GET /calendar", s.legacyRedirect("calendar"))
mux.HandleFunc("GET /graph", s.legacyRedirect("graph"))
mux.HandleFunc("GET /i/", s.handleDetail)
mux.HandleFunc("POST /i/", s.handleDetailWrite)
mux.HandleFunc("GET /new", s.handleNewForm)
mux.HandleFunc("POST /new", s.handleNewSubmit)
mux.HandleFunc("GET /admin", s.handleAdminIndex)
mux.HandleFunc("GET /admin/classify", s.handleClassify)
mux.HandleFunc("GET /dashboard", s.handleDashboard)
mux.HandleFunc("GET /timeline", s.handleTimeline)
mux.HandleFunc("GET /calendar", s.handleCalendar)
mux.HandleFunc("GET /graph", s.handleGraph)
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
mux.HandleFunc("POST /dashboard/task/edit", s.handleDashboardTaskEdit)
mux.HandleFunc("POST /dashboard/task/delete", s.handleDashboardTaskDelete)
@@ -361,6 +411,18 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
// Phase 5j paliad-shape views routes (slice B). /views = MRU landing
// or onboarding shell; /views/{slug} = render the saved view as its
// own page; /views/new + /views/{slug}/edit = editor. POST CRUD
// rounds out the family; reorder is wired now for slice G's drag UI.
mux.HandleFunc("GET /views", s.handleViewsLanding)
mux.HandleFunc("POST /views", s.handleViewCreate)
mux.HandleFunc("POST /views/reorder", s.handleViewReorder)
mux.HandleFunc("GET /views/new", s.handleViewEditor)
mux.HandleFunc("GET /views/{slug}", s.handleViewRender)
mux.HandleFunc("GET /views/{slug}/edit", s.handleViewEditor)
mux.HandleFunc("POST /views/{slug}", s.handleViewUpdate)
mux.HandleFunc("POST /views/{slug}/delete", s.handleViewDelete)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
@@ -403,10 +465,9 @@ type treeNode struct {
}
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
// Phase 5j slice C: handleTree is reached at /views/tree (system view)
// only. The legacy / route 301-redirects via legacyRedirect — see
// Routes(). Any 404-on-unknown-path responsibility moved with it.
items, err := s.Store.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
@@ -423,18 +484,45 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
return
}
filter := ParseTreeFilter(r.URL.Query())
viewSet := PageViewTypes("/")
view := ParseViewType(r.URL.Query(), viewSet)
// Phase 5j: ?view= overlay + is_default_for resolution deleted with the
// 5i shape. /views/{slug} (slice B+) renders saved views as their own
// pages; legacy ?view=<uuid> URLs are 302-redirected from a dedicated
// handler (slice C). handleTree stays focused on the tree-as-tree
// surface and no longer hijacks itself based on a query param.
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
counts := computeChipCounts(items, filter, linkKinds, tags)
// Phase 5j slice C: tree lives at /views/tree now. Chip URLs need to
// anchor on the new base so chip clicks stay on this page.
const treeBase = "/views/tree"
counts := computeChipCounts(items, filter, linkKinds, tags, treeBase)
// Phase 5i Slice B: the card view renders a flat grid of matched items
// (no tree structure). Build from items + filter directly rather than
// reusing the post-prune `roots` (which still keeps ancestors).
cardItems := flatMatchedItems(items, filter, linkKinds)
// Phase 5i Slice C: kanban groups the same matched set into columns.
groupBy := ParseGroupBy(r.URL.Query())
kanban := BuildKanbanBoard(cardItems, groupBy)
groupByChips := GroupByChips(treeBase, filter, groupBy)
data := map[string]any{
"Title": "tree",
"Roots": roots,
"Orphans": orphans,
"Total": total,
"OrphanN": orphanN,
"Matched": matched,
"AllTags": tags,
"Filter": filter,
"Counts": counts,
"Title": "tree",
"Roots": roots,
"Orphans": orphans,
"Total": total,
"OrphanN": orphanN,
"Matched": matched,
"AllTags": tags,
"Filter": filter,
"Counts": counts,
"Projects": parentOptionsFromItems(items),
"BasePath": treeBase,
"ProjectChipTarget": "#tree-section",
"ViewType": view,
"ViewTypeChips": ViewTypeChips(treeBase, filter, view),
"CardItems": cardItems,
"Kanban": kanban,
"GroupBy": groupBy,
"GroupByChips": groupByChips,
// ActiveTags kept for backwards-compat with the old template path; removed
// after the template migrates fully.
"ActiveTags": filter.Tags,
@@ -502,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)
@@ -520,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,
@@ -540,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)
@@ -740,6 +849,23 @@ func parseTimelineExcludeList(raw []string) []string {
// parseCSV splits a comma/space-delimited chip input into a deduplicated,
// trimmed lowercase string slice. Empty input → []string{} (nil avoided so
// JSON/SQL writes get an explicit empty array).
// parseValues collects every value for `key` from a url.Values map and
// splits each on the same comma/whitespace separators parseCSV accepts.
// Handles both filter-strip styles:
// - `?tag=foo,bar` (tree page hidden-input chip pattern)
// - `?tag=foo&tag=bar` (HTMX multi-select form submission)
//
// Mixed shapes work too (`?tag=foo,bar&tag=baz` → [foo bar baz]).
// Without this, `q.Get(key)` returned only the first value, so the
// second tag/mgmt/has selection from any <select multiple> filter strip
// silently dropped.
func parseValues(q url.Values, key string) []string {
if vs, ok := q[key]; ok {
return parseCSV(strings.Join(vs, ","))
}
return []string{}
}
func parseCSV(raw string) []string {
if strings.TrimSpace(raw) == "" {
return []string{}
@@ -773,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"},
})
}
@@ -862,15 +997,20 @@ func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
if err != nil {
return nil, err
}
var out []ParentOption
return parentOptionsFromItems(items), nil
}
// parentOptionsFromItems builds the same flat option list parentOptions
// returns, but from an already-loaded items slice. Callers that have already
// fetched items (handleTree, handleDashboard, …) use this to avoid a second
// ListAll round-trip when they only need the picker options.
func parentOptionsFromItems(items []*store.Item) []ParentOption {
out := make([]ParentOption, 0, len(items))
for _, it := range items {
// Surface every primary path as a candidate parent — multi-parent
// items appear once per parent option using their primary path so the
// UI stays unambiguous.
out = append(out, ParentOption{ID: it.ID, Path: it.PrimaryPath()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, nil
return out
}
// (buildForest + nodeHasAllTags removed in Phase 3b — superseded by
@@ -903,6 +1043,49 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
if _, set := data["Path"]; !set {
data["Path"] = r.URL.Path
}
// Phase 5j slice E: layout's "Views" sidebar section lists every
// user view. Lookup is one indexed query per render — at m's scale
// (≤30 saved views) the cost is negligible and dwarfed by the
// dashboard/timeline aggregation cards. The login page bypasses the
// layout entirely so we don't fetch for it; stub servers without a
// configured store also skip cleanly.
if _, set := data["UserViews"]; !set && name != "login" && s.Store != nil {
if uv, err := s.Store.ListViews(r.Context()); err == nil {
data["UserViews"] = uv
// Phase 5j slice G — show_count badges. For every view with
// ShowCount=true, run its persisted filter against ListAll and
// pass a slug→count map to the template. Caching is one
// ListAll per render shared across all show-count views.
counts := map[string]int{}
needsCount := false
for _, v := range uv {
if v.ShowCount {
needsCount = true
break
}
}
if needsCount {
items, err := s.Store.ListAll(r.Context())
if err == nil {
linkKinds, _ := s.linkKindsByItem(r.Context())
for _, v := range uv {
if !v.ShowCount {
continue
}
f, _, _ := decodeViewSpec(v.FilterJSON)
n := 0
for _, it := range items {
if f.Matches(it, linkKinds[it.ID]) {
n++
}
}
counts[v.Slug] = n
}
}
}
data["UserViewCounts"] = counts
}
}
entry := "layout"
switch name {
case "login":

View File

@@ -81,9 +81,9 @@ func TestTreeRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/")
code, body := get(t, h, "/views/tree")
if code != 200 {
t.Fatalf("GET / status %d body=%s", code, body)
t.Fatalf("GET /views/tree status %d body=%s", code, body)
}
// /admin/classify used to live in the nav; Phase 3o consolidated all
// admin links under the new /admin index. Assert /admin instead.
@@ -102,7 +102,7 @@ func TestLayoutHasViewportMeta(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
for _, path := range []string{"/", "/dashboard", "/calendar", "/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/calendar", "/views/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
_, body := get(t, h, path)
if !strings.Contains(body, `name="viewport"`) {
t.Errorf("GET %s: missing <meta name=\"viewport\">", path)
@@ -294,3 +294,125 @@ func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
}
}
}
// TestTreeRendersKanbanWhenViewTypeIsKanban verifies the Phase 5i Slice C
// dispatch: GET /?view_type=kanban renders the kanban board (with the
// group-by chip strip) instead of the forest. group_by defaults to status.
func TestTreeRendersKanbanWhenViewTypeIsKanban(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/views/tree?view_type=kanban")
if !strings.Contains(body, `class="kanban-board"`) {
t.Error("?view_type=kanban should render the kanban board")
}
if !strings.Contains(body, `class="groupby-chip`) {
t.Error("kanban view should render the group-by chip strip")
}
if strings.Contains(body, `<ul class="forest">`) {
t.Error("kanban view should not render the tree forest")
}
}
// TestTreeRendersCardGridWhenViewTypeIsCard verifies Phase 5i Slice B
// dispatch: `?view_type=card` renders the flat tile grid instead of the
// forest, and the view-type chip strip is present in either view. Unknown
// view_type values fall back to list with the chip-strip showing list as
// active.
func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// List view (default): forest markup expected; tree-card-grid absent.
_, listBody := get(t, h, "/views/tree")
if !strings.Contains(listBody, `<ul class="forest">`) {
t.Error("default GET / should render the tree forest")
}
if strings.Contains(listBody, `class="tree-card-grid"`) {
t.Error("default GET / should not render the card grid")
}
if !strings.Contains(listBody, `view-type-chip-row`) {
t.Error("view-type chip strip should appear on every view")
}
// Card view: card grid present, forest absent.
_, cardBody := get(t, h, "/views/tree?view_type=card")
if !strings.Contains(cardBody, `class="tree-card-grid"`) {
t.Error("GET /?view_type=card should render the card grid")
}
if strings.Contains(cardBody, `<ul class="forest">`) {
t.Error("GET /?view_type=card should not render the tree forest")
}
// Unknown view_type falls back to list.
_, unknownBody := get(t, h, "/views/tree?view_type=junk")
if !strings.Contains(unknownBody, `<ul class="forest">`) {
t.Error("unknown view_type should fall back to list")
}
}
// TestProjectFilterScopesTreeToDescendants verifies the Phase 5i Slice A
// project scope semantics end-to-end: ?project=<path> narrows / to the picked
// item + descendants; ?project_descendants=0 narrows further to the picked
// item alone. Both round-trip through ParseTreeFilter + TreeFilter.Matches +
// the tree handler.
func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx := context.Background()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
parentSlug := "p5i-parent-" + stamp
childSlug := "p5i-child-" + stamp
siblingSlug := "p5i-sib-" + 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)
}
var parentID, childID, siblingID string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Parent', $1, ARRAY[$2]::uuid[]) returning id`,
parentSlug, dev).Scan(&parentID); err != nil {
t.Fatalf("seed parent: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Child', $1, ARRAY[$2]::uuid[]) returning id`,
childSlug, parentID).Scan(&childID); err != nil {
t.Fatalf("seed child: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Sib', $1, ARRAY[$2]::uuid[]) returning id`,
siblingSlug, dev).Scan(&siblingID); err != nil {
t.Fatalf("seed sibling: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1,$2,$3)`, childID, parentID, siblingID)
parentPath := "dev." + parentSlug
parentLink := `href="/i/` + parentPath + `"`
childLink := `href="/i/` + parentPath + `.` + childSlug + `"`
siblingLink := `href="/i/dev.` + siblingSlug + `"`
// Descendants on (default): parent + child visible, sibling hidden.
_, withDesc := get(t, h, "/views/tree?project="+parentPath)
if !strings.Contains(withDesc, parentLink) {
t.Errorf("?project=%s should show parent row", parentPath)
}
if !strings.Contains(withDesc, childLink) {
t.Errorf("?project=%s should include descendant child row", parentPath)
}
if strings.Contains(withDesc, siblingLink) {
t.Errorf("?project=%s should exclude sibling row", parentPath)
}
// Descendants off: only the picked item, no children.
_, noDesc := get(t, h, "/views/tree?project="+parentPath+"&project_descendants=0")
if !strings.Contains(noDesc, parentLink) {
t.Errorf("?project_descendants=0 should still show the picked parent row")
}
if strings.Contains(noDesc, childLink) {
t.Errorf("?project_descendants=0 should hide the child row")
}
if strings.Contains(noDesc, siblingLink) {
t.Errorf("?project_descendants=0 should hide the sibling row")
}
}

View File

@@ -193,6 +193,81 @@ table.classify input, table.classify select { width: 100%; }
.mgmt-chip:hover, .status-chip:hover, .has-chip:hover { color: var(--fg); border-color: var(--accent); }
.chip-on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.chip-on:hover { color: var(--accent-fg); filter: brightness(0.92); }
/* Phase 5i Slice A — project scope chip. The picker uses a bare <select>
inside a tagbar form; the active state mirrors the mgmt/status/has chips. */
.proj-chip, .proj-desc-chip {
display: inline-flex; align-items: center; gap: 4px;
font-size: 0.78em; padding: 1px 8px; border-radius: 999px;
background: var(--surface); border: 1px solid var(--border); color: var(--muted); text-decoration: none;
}
.proj-chip.chip-on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.proj-chip .proj-name { font-weight: 500; }
.proj-chip .proj-clear { color: inherit; opacity: 0.75; margin-left: 4px; padding: 0 4px; }
.proj-chip .proj-clear:hover { opacity: 1; }
.proj-desc-chip:hover { color: var(--fg); border-color: var(--accent); }
.proj-picker select { font-size: 0.85em; padding: 1px 4px; }
/* Phase 5i Slice B — view-type chip strip + card grid. */
.view-type-chip {
display: inline-block; font-size: 0.78em; padding: 1px 8px; border-radius: 999px;
background: var(--surface); border: 1px solid var(--border); color: var(--muted); text-decoration: none;
text-transform: capitalize;
}
.view-type-chip:hover { color: var(--fg); border-color: var(--accent); }
.view-type-chip.chip-locked { opacity: 0.4; }
.view-type-chip.chip-locked:hover { color: var(--muted); border-color: var(--border); cursor: not-allowed; }
.tree-card-grid {
display: grid; gap: 12px; padding: 12px 0;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.tree-card {
border: 1px solid var(--border); border-radius: 6px; background: var(--surface);
padding: 10px 12px;
}
.tree-card-head { display: flex; flex-direction: column; gap: 2px; margin-bottom: 6px; }
.tree-card-title { font-weight: 500; color: var(--fg); text-decoration: none; }
.tree-card-title:hover { color: var(--accent); }
.tree-card-slug { font-size: 0.78em; }
.tree-card-meta { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; font-size: 0.78em; }
.tree-card-empty { grid-column: 1 / -1; padding: 24px; color: var(--muted); }
/* Phase 5i Slice C — kanban columns + cards. */
.kanban-controls { margin: 8px 0; }
.groupby-chip {
display: inline-block; font-size: 0.78em; padding: 1px 8px; border-radius: 999px;
background: var(--surface); border: 1px solid var(--border); color: var(--muted); text-decoration: none;
}
.groupby-chip:hover { color: var(--fg); border-color: var(--accent); }
.kanban-board {
display: grid; gap: 12px; padding: 12px 0; overflow-x: auto;
grid-auto-flow: column; grid-auto-columns: minmax(220px, 280px);
}
.kanban-column {
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
padding: 8px 10px; display: flex; flex-direction: column; gap: 8px;
min-height: 120px;
}
.kanban-col-head {
display: flex; justify-content: space-between; align-items: baseline;
border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
.kanban-col-label { font-weight: 500; }
.kanban-cards { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
.kanban-card {
background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
padding: 6px 8px;
}
.kanban-card-title { font-weight: 500; color: var(--fg); text-decoration: none; display: block; }
.kanban-card-title:hover { color: var(--accent); }
.kanban-card-meta { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; font-size: 0.78em; }
.kanban-empty { padding: 24px; }
/* Phase 5i Slice E — default-view banner. Sits above the counts line on
any Views-supporting page when a default view is auto-applied. */
.default-banner {
background: var(--surface); border: 1px solid var(--border);
border-left: 3px solid var(--accent);
padding: 6px 10px; border-radius: 4px;
font-size: 0.85em; margin: 4px 0 8px;
}
.default-banner a { color: var(--bad); }
#tree-filterbar small { opacity: 0.75; margin-left: 2px; }
.tree-section .empty { padding: 24px; color: var(--muted); }
.tree-section .clear { color: var(--bad); }
@@ -1136,6 +1211,23 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .brand-label {
border-left: 2px solid var(--accent);
padding-left: 14px;
}
/* Phase 5j slice E — Views sub-section: user-view entries sit below the
main nav items, slightly indented + smaller, so the system rows stay
visually anchored. The Views section header (the "Views" main entry)
is unchanged; this just styles the per-saved-view rows. */
.projax-sidebar .sidebar-user-views { display: flex; flex-direction: column; gap: 2px; padding: 4px 0; }
.projax-sidebar .nav-item-user-view { font-size: 0.92em; padding-left: 24px; }
.projax-sidebar .nav-item-user-view.active { padding-left: 22px; }
.projax-sidebar .user-view-icon { width: 1em; text-align: center; }
.projax-sidebar .nav-item-new-view { color: var(--muted); }
.projax-sidebar .nav-badge {
margin-left: auto; font-size: 0.78em; color: var(--muted);
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 0 6px;
}
.projax-sidebar .nav-item-user-view.active .nav-badge {
color: var(--accent); border-color: var(--accent);
}
.projax-sidebar .nav-icon {
width: 18px;
height: 18px;
@@ -1304,3 +1396,42 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .collapse-icon {
padding-bottom: calc(56px + 1rem + env(safe-area-inset-bottom, 0px));
}
}
/* --- Detail page: form-group ordering polish (Phase 5i, detail-page-order) --- */
.detail-form { display: flex; flex-direction: column; gap: 20px; max-width: 720px; }
.detail-form .form-group { display: flex; flex-direction: column; gap: 12px; margin: 0; padding: 0; }
.detail-form .form-group-heading {
margin: 0;
font-size: 0.78em;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px dotted var(--border);
padding-bottom: 4px;
}
.detail-form .form-group-flags { flex-direction: row; flex-wrap: wrap; gap: 18px 24px; align-items: baseline; }
.detail-form .form-group-flags .form-group-heading { flex-basis: 100%; }
.detail-form .form-group-content-label > textarea { font-family: ui-monospace, SFMono-Regular, monospace; }
.detail-form .form-group-content-label { gap: 0; }
.detail-form > details.proj-section { margin-top: 4px; }
/* Divider between the editable form and the read-only auxiliary
collapsibles. <hr> is semantically a thematic break — matches the
intent. The "Related" heading below it makes the change-of-mode
obvious without leaning on the line alone. */
.aux-divider {
border: 0;
border-top: 1px solid var(--border);
margin: 32px 0 16px;
}
.aux-sections { display: flex; flex-direction: column; gap: 4px; max-width: 720px; }
.aux-heading {
margin: 0 0 8px;
font-size: 0.95em;
font-weight: 600;
color: var(--muted);
}
.aux-reset { margin: 12px 0 0; font-size: 0.85em; }
.aux-reset .proj-section-reset { color: var(--muted); }
.aux-reset .proj-section-reset:hover { color: var(--bad); }

87
web/system_views.go Normal file
View File

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

175
web/system_views_test.go Normal file
View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="calendar-filterbar">
<form id="calendar-filter" class="search"
hx-get="/calendar"
hx-get="/views/calendar"
hx-target="#calendar-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
@@ -37,8 +37,13 @@
<option value="doc" {{if contains $selK "doc"}}selected{{end}}>doc</option>
</select>
</label>
{{if .Filter.Active}}<a class="clear" href="/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
{{if .Filter.Active}}<a class="clear" href="/views/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
<p class="counts muted">
<small>{{.P.TotalRows}} {{if eq .P.TotalRows 1}}row{{else}}rows{{end}}</small>
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">· cached</small>{{else}}<small>· fresh</small>{{end}}
@@ -78,7 +83,7 @@
</ul>
{{end}}
{{if gt .ExtraCount 0}}
<a class="cell-more muted" href="/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">+{{.ExtraCount}} more</a>
<a class="cell-more muted" href="/views/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">+{{.ExtraCount}} more</a>
{{end}}
</td>
{{end}}

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="dashboard-filterbar">
<form id="dashboard-filter" class="search"
hx-get="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
hx-get="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
@@ -30,8 +30,13 @@
</select>
</label>
{{if ne .View "tiles"}}<input type="hidden" name="view" value="{{.View}}">{{end}}
{{if .Filter.Active}}<a class="clear" href="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
{{if .Filter.Active}}<a class="clear" href="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
<p class="counts muted">
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">updated {{.UpdatedRel}} · cached</small>
{{else}}<small>updated {{.UpdatedRel}} · fresh</small>{{end}}

View File

@@ -13,118 +13,116 @@
<p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
{{end}}
{{/*
Phase 4e: collapsible sections. Each detail-page section is wrapped in a
<details> element with a smart default for the `open` attribute based on
the count of items inside. The inline JS at the bottom of the page
overrides those defaults from localStorage so m's per-item collapse
state survives reloads. Data-section keys are stable strings; data-count
surfaces the count so the JS doesn't have to re-walk children to label
the summary.
*/}}
{{$itemID := .Item.ID}}
{{if .CalDAVOn}}
{{/* Tasks section opens by default when any linked calendar has at least
one open VTODO. hasOpenTasks template helper would be cleaner but the
range-with-flag style avoids registering a new func. */}}
{{$tasksOpen := false}}
{{range .Tasks}}{{if .Open}}{{$tasksOpen = true}}{{end}}{{end}}
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if $tasksOpen}} open{{end}}>
<summary class="proj-section-summary">Tasks {{if $tasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
{{template "tasks-section" .}}
</details>
{{end}}
{{/*
Phase 5i: reordered general → specific. Form first (Title → Slug →
Parents → Status → Classification → Flags → Content → form-bound
collapsibles) so the always-edit fields sit at the top, then a divider
before the auxiliary read-only collapsibles (Tasks / Issues /
Documents). Field grouping (General / Classification / Flags) reads as
three groups instead of nine flat labels per m's "pimped a bit" ask.
{{if and .GiteaOn .Issues}}
{{$open := le .IssuesOpenTotal 10}}
<details class="proj-section" data-section="issues" data-item-id="{{$itemID}}"{{if $open}} open{{end}}>
<summary class="proj-section-summary">Issues <small class="muted">({{.IssuesOpenTotal}} open)</small></summary>
{{template "issues-section" .}}
</details>
{{end}}
Public listing + Timeline behaviour stay INSIDE the form so they save
with the main Save button — moving them out would require a separate
POST endpoint, which is out of scope for this pass.
{{$docOpen := le (len .Documents) 5}}
<details class="proj-section" data-section="documents" data-item-id="{{$itemID}}"{{if $docOpen}} open{{end}}>
<summary class="proj-section-summary">Documents <small class="muted">({{len .Documents}})</small></summary>
{{template "documents-section" .}}
</details>
Phase 4e collapsibles smart-default + localStorage state preserved
exactly: same data-section keys, same proj-section CSS class, same
inline JS.
*/}}
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
<select name="parent_ids" multiple size="6">
{{range .ParentOptions}}
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
{{end}}
</select>
</label>
<label>Tags
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
</label>
<label>Management
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
</label>
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
</label>
<label>Content
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
</label>
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit detail-form">
<section class="form-group" aria-labelledby="hdr-general">
<h2 id="hdr-general" class="form-group-heading">General</h2>
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
<select name="parent_ids" multiple size="6">
{{range .ParentOptions}}
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
{{end}}
</select>
</label>
</section>
<section class="form-group" aria-labelledby="hdr-classification">
<h2 id="hdr-classification" class="form-group-heading">Classification</h2>
<label>Tags
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
</label>
<label>Management
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
</label>
</section>
<section class="form-group form-group-flags" aria-labelledby="hdr-flags">
<h2 id="hdr-flags" class="form-group-heading">Flags</h2>
<label class="checkbox">
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
</label>
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
</label>
</section>
<section class="form-group" aria-labelledby="hdr-content">
<h2 id="hdr-content" class="form-group-heading">Content</h2>
<label class="form-group-content-label">
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
</label>
</section>
<details class="proj-section" data-section="public" data-item-id="{{$itemID}}">
<summary class="proj-section-summary">Public listing {{if .Item.Public}}<small class="muted">(on)</small>{{end}}</summary>
<fieldset class="public-listing">
<legend class="visually-hidden">Public listing</legend>
<p class="muted">When public is on, flexsiebels.de (and any other portfolio
consumer) can pull these fields via the projax MCP. The values are
preserved when public is off — toggling never destroys them.</p>
<label class="checkbox">
<input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public
</label>
<label>Public description
<textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea>
</label>
<label>Live URL
<input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev">
</label>
<label>Source URL
<input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack">
</label>
<label>Screenshots <small class="muted">(one URL per row; order is the display order)</small>
<div class="public-screenshots" id="public-screenshots">
{{range .Item.PublicScreenshots}}
<summary class="proj-section-summary">Public listing {{if .Item.Public}}<small class="muted">(on)</small>{{end}}</summary>
<fieldset class="public-listing">
<legend class="visually-hidden">Public listing</legend>
<p class="muted">When public is on, flexsiebels.de (and any other portfolio
consumer) can pull these fields via the projax MCP. The values are
preserved when public is off — toggling never destroys them.</p>
<label class="checkbox">
<input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public
</label>
<label>Public description
<textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea>
</label>
<label>Live URL
<input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev">
</label>
<label>Source URL
<input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack">
</label>
<label>Screenshots <small class="muted">(one URL per row; order is the display order)</small>
<div class="public-screenshots" id="public-screenshots">
{{range .Item.PublicScreenshots}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="{{.}}" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
{{end}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="{{.}}" placeholder="https://…">
<input name="public_screenshots" type="url" value="" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
{{end}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
</div>
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
</label>
</fieldset>
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
</label>
</fieldset>
</details>
<details class="proj-section" data-section="timeline-behaviour" data-item-id="{{$itemID}}"{{if .Item.TimelineExclude}} open{{end}}>
<summary class="proj-section-summary">Timeline behaviour {{if .Item.TimelineExclude}}<small class="muted">(hiding {{len .Item.TimelineExclude}})</small>{{end}}</summary>
<fieldset class="timeline-exclude">
<legend class="visually-hidden">Timeline behaviour</legend>
<p class="muted">Check a kind to hide it from <a href="/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
<p class="muted">Check a kind to hide it from <a href="/views/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/views/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
{{$ex := .Item.TimelineExclude}}
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="todos" {{if contains $ex "todos"}}checked{{end}}> exclude todos (VTODOs from linked calendars)</label>
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="events" {{if contains $ex "events"}}checked{{end}}> exclude events (VEVENTs from linked calendars)</label>
@@ -135,10 +133,45 @@
<div class="actions">
<button type="submit">Save</button>
<a class="cancel" href="/">Cancel</a>
<a class="proj-section-reset" href="#" data-item-id="{{$itemID}}">reset section state</a>
<a class="cancel" href="/views/tree">Cancel</a>
</div>
</form>
<hr class="aux-divider" aria-hidden="true">
<section class="aux-sections" aria-labelledby="hdr-aux">
<h2 id="hdr-aux" class="aux-heading">Related</h2>
{{if .CalDAVOn}}
{{/* Tasks section opens by default when any linked calendar has at least
one open VTODO. */}}
{{$tasksOpen := false}}
{{range .Tasks}}{{if .Open}}{{$tasksOpen = true}}{{end}}{{end}}
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if $tasksOpen}} open{{end}}>
<summary class="proj-section-summary">Tasks {{if $tasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
{{template "tasks-section" .}}
</details>
{{end}}
{{if and .GiteaOn .Issues}}
{{$open := le .IssuesOpenTotal 10}}
<details class="proj-section" data-section="issues" data-item-id="{{$itemID}}"{{if $open}} open{{end}}>
<summary class="proj-section-summary">Issues <small class="muted">({{.IssuesOpenTotal}} open)</small></summary>
{{template "issues-section" .}}
</details>
{{end}}
{{$docOpen := le (len .Documents) 5}}
<details class="proj-section" data-section="documents" data-item-id="{{$itemID}}"{{if $docOpen}} open{{end}}>
<summary class="proj-section-summary">Documents <small class="muted">({{len .Documents}})</small></summary>
{{template "documents-section" .}}
</details>
<p class="aux-reset">
<a class="proj-section-reset muted" href="#" data-item-id="{{$itemID}}">reset section state</a>
</p>
</section>
<script>
// Phase 4e collapsible-section persistence. Each <details data-section data-item-id>
// reads its open state from localStorage on boot (user choice wins over the

View File

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

View File

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

View File

@@ -42,44 +42,72 @@
{{$path := .Path}}
<aside class="projax-sidebar" aria-label="Primary navigation">
<div class="sidebar-top">
<a href="/" class="brand" title="projax">
<a href="/views/tree" class="brand" title="projax">
<span class="brand-icon" aria-hidden="true">▦</span>
<strong class="brand-label">projax</strong>
</a>
</div>
<nav class="sidebar-nav">
<a href="/" class="nav-item{{if eq $path "/"}} active{{end}}" title="Tree">
<a href="/views/tree" class="nav-item{{if eq $path "/views/tree"}} active{{end}}" title="Tree">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
<span class="nav-label">Tree</span>
</a>
<a href="/dashboard" class="nav-item{{if eq $path "/dashboard"}} active{{end}}" title="Dashboard">
<a href="/views/dashboard" class="nav-item{{if eq $path "/views/dashboard"}} active{{end}}" title="Dashboard">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
</svg>
<span class="nav-label">Dashboard</span>
</a>
<a href="/calendar" class="nav-item{{if eq $path "/calendar"}} active{{end}}" title="Calendar">
<a href="/views/calendar" class="nav-item{{if eq $path "/views/calendar"}} active{{end}}" title="Calendar">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span class="nav-label">Calendar</span>
</a>
<a href="/timeline" class="nav-item{{if eq $path "/timeline"}} active{{end}}" title="Timeline">
<a href="/views/timeline" class="nav-item{{if eq $path "/views/timeline"}} active{{end}}" title="Timeline">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span class="nav-label">Timeline</span>
</a>
<a href="/graph" class="nav-item{{if eq $path "/graph"}} active{{end}}" title="Graph">
<a href="/views/graph" class="nav-item{{if eq $path "/views/graph"}} active{{end}}" title="Graph">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
<span class="nav-label">Graph</span>
</a>
<a href="/views" class="nav-item{{if eq $path "/views"}} active{{end}}" title="Views">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
<span class="nav-label">Views</span>
</a>
{{if .UserViews}}
{{$counts := .UserViewCounts}}
<div class="sidebar-user-views" aria-label="Saved views">
{{range .UserViews}}
{{$slug := .Slug}}
<a href="/views/{{.Slug}}"
class="nav-item nav-item-user-view{{if eq $path (printf "/views/%s" .Slug)}} active{{end}}"
title="{{.Name}}">
{{renderIcon .Icon}}
<span class="nav-label">{{.Name}}</span>
{{if .ShowCount}}<span class="nav-badge" aria-label="Item count">{{index $counts $slug}}</span>{{end}}
</a>
{{end}}
<a href="/views/new" class="nav-item nav-item-user-view nav-item-new-view" title="New view">
<span class="nav-icon" aria-hidden="true"></span>
<span class="nav-label">New view</span>
</a>
</div>
{{end}}
<a href="/admin" class="nav-item{{if eq $path "/admin"}} active{{end}}" title="Admin">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
@@ -115,14 +143,14 @@
{{template "content" .}}
</main>
<nav class="projax-bottom-nav" aria-label="Mobile navigation">
<a href="/" class="bottom-nav-item{{if eq $path "/"}} active{{end}}" aria-label="Tree">
<a href="/views/tree" class="bottom-nav-item{{if eq $path "/views/tree"}} active{{end}}" aria-label="Tree">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
<span>Tree</span>
</a>
<a href="/dashboard" class="bottom-nav-item{{if eq $path "/dashboard"}} active{{end}}" aria-label="Dashboard">
<a href="/views/dashboard" class="bottom-nav-item{{if eq $path "/views/dashboard"}} active{{end}}" aria-label="Dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
</svg>
@@ -135,7 +163,7 @@
</svg>
</span>
</a>
<a href="/calendar" class="bottom-nav-item{{if eq $path "/calendar"}} active{{end}}" aria-label="Calendar">
<a href="/views/calendar" class="bottom-nav-item{{if eq $path "/views/calendar"}} active{{end}}" aria-label="Calendar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
@@ -149,13 +177,13 @@
<span>Menu</span>
</summary>
<div class="drawer-sheet" role="menu">
<a href="/timeline" class="drawer-item{{if eq $path "/timeline"}} active{{end}}" role="menuitem">
<a href="/views/timeline" class="drawer-item{{if eq $path "/views/timeline"}} active{{end}}" role="menuitem">
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span>Timeline</span>
</a>
<a href="/graph" class="drawer-item{{if eq $path "/graph"}} active{{end}}" role="menuitem">
<a href="/views/graph" class="drawer-item{{if eq $path "/views/graph"}} active{{end}}" role="menuitem">
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>

View File

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

@@ -0,0 +1,58 @@
{{/*
Phase 5i Slice A — shared project-scope chip + picker.
Rendered inside the tagbar on every Views-supporting page (tree, dashboard,
timeline, calendar). Two visual states:
ProjectPath == "" → <select> picker with all items by primary path
ProjectPath != "" → active chip with × clear + descendants on/off toggle
Each caller passes a top-level page-data map with:
Filter — TreeFilter
Projects — []ParentOption (sorted by path)
BasePath — page route, e.g. "/", "/dashboard", "/timeline", "/calendar"
ProjectChipTarget — HTMX target selector, e.g. "#tree-section"
m's Q5 pick (2026-05-26): descendants toggle is on by default but exposed
explicitly on the chip, not always-on.
*/}}
{{define "view-project-chip"}}
<div class="chip-row project-chip-row">
<span class="muted">project:</span>
{{if .Filter.ProjectPath}}
{{$cleared := (.Filter.SetProject "").URLOn .BasePath}}
{{$toggled := .Filter.ToggleIncludeDescendants.URLOn .BasePath}}
<span class="proj-chip chip-on" title="Scoped to {{.Filter.ProjectPath}}">
<span class="proj-name">{{.Filter.ProjectPath}}</span>
<a class="proj-clear"
href="{{$cleared}}"
hx-get="{{$cleared}}" hx-target="{{.ProjectChipTarget}}" hx-swap="outerHTML" hx-push-url="true"
title="Clear project scope">×</a>
</span>
<a class="proj-desc-chip {{if .Filter.IncludeDescendants}}chip-on{{end}}"
href="{{$toggled}}"
hx-get="{{$toggled}}" hx-target="{{.ProjectChipTarget}}" hx-swap="outerHTML" hx-push-url="true"
title="Include descendants of {{.Filter.ProjectPath}} in scope">
descendants {{if .Filter.IncludeDescendants}}on{{else}}off{{end}}
</a>
{{else}}
<form class="proj-picker"
hx-get="{{.BasePath}}"
hx-target="{{.ProjectChipTarget}}"
hx-swap="outerHTML"
hx-trigger="change from:select"
hx-push-url="true">
{{if .Filter.Q}}<input type="hidden" name="q" value="{{.Filter.Q}}">{{end}}
{{if .Filter.Tags}}<input type="hidden" name="tag" value="{{join "," .Filter.Tags}}">{{end}}
{{if .Filter.Management}}<input type="hidden" name="mgmt" value="{{join "," .Filter.Management}}">{{end}}
{{if ne (join "," .Filter.Status) "active"}}<input type="hidden" name="status" value="{{join "," .Filter.Status}}">{{end}}
{{if .Filter.HasLinks}}<input type="hidden" name="has" value="{{join "," .Filter.HasLinks}}">{{end}}
{{if .Filter.ShowArchived}}<input type="hidden" name="show-archived" value="1">{{end}}
<select name="project" autocomplete="off">
<option value="">— any —</option>
{{range .Projects}}<option value="{{.Path}}">{{.Path}}</option>{{end}}
</select>
</form>
{{end}}
</div>
{{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

@@ -3,7 +3,7 @@
<section class="tagbar" id="timeline-filterbar">
<form id="timeline-filter" class="search"
hx-get="/timeline"
hx-get="/views/timeline"
hx-target="#timeline-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
@@ -43,8 +43,13 @@
<option value="asc" {{if eq .P.Order "asc"}}selected{{end}}>oldest first</option>
</select>
</label>
{{if .Filter.Active}}<a class="clear" href="/timeline">clear filters</a>{{end}}
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
{{if .Filter.Active}}<a class="clear" href="/views/timeline">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
<p class="counts muted">
<small>{{.P.TotalRows}} rows · {{.P.From.Format "2006-01-02"}} → {{.P.ToInclusive.Format "2006-01-02"}}</small>
{{if .P.Cached}}<small title="Served from 90s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">· cached</small>{{else}}<small>· fresh</small>{{end}}
@@ -57,7 +62,7 @@
<li class="spine-day{{if .Sticky}} sticky-{{.Sticky}}{{end}}" data-date="{{.DateKey}}">
<header class="day-header">
{{if .Sticky}}<span class="sticky-pill">{{.Sticky}}</span>{{end}}
<h2><a class="muted" href="/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">{{.Label}}</a> <small class="muted">({{len .Rows}})</small></h2>
<h2><a class="muted" href="/views/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">{{.Label}}</a> <small class="muted">({{len .Rows}})</small></h2>
</header>
<ul class="day-rows">
{{range .Rows}}

View File

@@ -0,0 +1,29 @@
{{/*
Phase 5i Slice B — card view for /tree. Renders the filtered item set as a
flat tile grid (no forest, no ancestor-keep). One tile per matched item,
ordered by primary path. Reuses the per-item field set the list view emits;
the visual difference is layout, not data shape.
*/}}
{{define "tree-card"}}
<div class="tree-card-grid">
{{range .CardItems}}
<article class="tree-card">
<header class="tree-card-head">
<a class="tree-card-title" href="/i/{{.PrimaryPath}}">{{.Title}}</a>
<span class="tree-card-slug muted">{{.PrimaryPath}}</span>
</header>
<p class="tree-card-meta">
{{range .Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
{{if .Pinned}}<span class="pinned" title="Pinned">★</span>{{end}}
{{if eq .Status "done"}}<span class="status status-done">done</span>{{end}}
{{if .Archived}}<span class="status status-archived">archived</span>{{end}}
</p>
</article>
{{else}}
<div class="tree-card-empty">
<em>No items match. Try fewer filters or <a href="/views/tree">clear all</a>.</em>
</div>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,46 @@
{{/*
Phase 5i Slice C — kanban view for /tree. Columns by group_by value, cards
inside each column. Read-only: no drag-to-change (deferred). Empty filtered
set surfaces a friendly empty-state message.
*/}}
{{define "tree-kanban"}}
<div class="kanban-controls chip-row">
<span class="muted">group&nbsp;by:</span>
{{range .GroupByChips}}
<a class="groupby-chip{{if .Active}} chip-on{{end}}"
href="{{.URL}}"
hx-get="{{.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">{{.Label}}</a>
{{end}}
<small class="muted">{{.Kanban.Total}} card{{if ne .Kanban.Total 1}}s{{end}}</small>
</div>
{{if .Kanban.Columns}}
<div class="kanban-board">
{{range .Kanban.Columns}}
<section class="kanban-column" data-key="{{.Key}}">
<header class="kanban-col-head">
<span class="kanban-col-label">{{.Label}}</span>
<small class="muted">{{len .Items}}</small>
</header>
<ul class="kanban-cards">
{{range .Items}}
<li class="kanban-card">
<a class="kanban-card-title" href="/i/{{.PrimaryPath}}">{{.Title}}</a>
<p class="kanban-card-meta">
<span class="muted">{{.PrimaryPath}}</span>
{{range .Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
{{if .Pinned}}<span class="pinned" title="Pinned">★</span>{{end}}
</p>
</li>
{{end}}
</ul>
</section>
{{end}}
</div>
{{else}}
<div class="kanban-empty muted">
<em>No items match. Try fewer filters or <a href="/views/tree?view_type=kanban">clear filters</a>.</em>
</div>
{{end}}
{{end}}

View File

@@ -3,7 +3,7 @@
<p class="counts">
<strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
{{if .Filter.Active}} · <a class="clear" href="/">clear filters</a>{{end}}
{{if .Filter.Active}} · <a class="clear" href="/views/tree">clear filters</a>{{end}}
</p>
<section class="tagbar" id="tree-filterbar">
@@ -16,8 +16,23 @@
{{if ne (join "," .Filter.Status) "active"}}<input type="hidden" name="status" value="{{join "," .Filter.Status}}">{{end}}
{{if .Filter.HasLinks}}<input type="hidden" name="has" value="{{join "," .Filter.HasLinks}}">{{end}}
{{if .Filter.ShowArchived}}<input type="hidden" name="show-archived" value="1">{{end}}
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
{{if ne .ViewType "list"}}<input type="hidden" name="view_type" value="{{.ViewType}}">{{end}}
</form>
{{template "view-project-chip" .}}
<div class="chip-row view-type-chip-row">
<span class="muted">view:</span>
{{range .ViewTypeChips}}
<a class="view-type-chip{{if .Active}} chip-on{{end}}{{if .Locked}} chip-locked{{end}}"
href="{{.URL}}"
hx-get="{{.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true"
{{if .Locked}}title="{{.Label}} view lands in a future slice; clicks fall back to the default."{{end}}>{{.Label}}</a>
{{end}}
</div>
{{if .AllTags}}
<div class="chip-row">
<span class="muted">tag:</span>
@@ -61,6 +76,11 @@
</div>
</section>
{{if eq .ViewType "card"}}
{{template "tree-card" .}}
{{else if eq .ViewType "kanban"}}
{{template "tree-kanban" .}}
{{else}}
<section class="tree">
<ul class="forest">
{{range .Roots}}
@@ -73,10 +93,11 @@
{{template "children" .}}
</li>
{{else}}
<li class="empty"><em>No items match. Try fewer filters or <a href="/">clear all</a>.</em></li>
<li class="empty"><em>No items match. Try fewer filters or <a href="/views/tree">clear all</a>.</em></li>
{{end}}
</ul>
</section>
{{end}}
</section>
{{end}}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}
@@ -188,12 +187,20 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
display := *payload
display.Cached = hit
projects, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
data := map[string]any{
"Title": "timeline",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
"Title": "timeline",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
"Projects": projects,
"BasePath": "/views/timeline",
"ProjectChipTarget": "#timeline-section",
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "timeline_section", data)

View File

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

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

View File

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

View File

@@ -18,6 +18,14 @@ type TreeFilter struct {
HasLinks []string // ANY of these ref_types must be linked to the item ("caldav-list", "gitea-repo")
ShowArchived bool // when false, hide items with archived=true even if Status matches
Public *bool // Phase 4d — nil = no filter; true = public only; false = private only
// Phase 5i Slice A — project scope.
// ProjectPath is the picked project's primary path (e.g. "work.upc"). Empty
// means no project filter. IncludeDescendants defaults to true; when false,
// only items whose paths include the exact ProjectPath match (no subtree).
// Per m's Q5 pick (2026-05-26), descendants are NOT always-on — the chip
// exposes an explicit on/off toggle.
ProjectPath string
IncludeDescendants bool
}
// Active reports whether any filter dimension is set to something other than
@@ -26,6 +34,9 @@ func (f TreeFilter) Active() bool {
if f.Q != "" || len(f.Tags) > 0 || len(f.Management) > 0 || len(f.HasLinks) > 0 || f.ShowArchived || f.Public != nil {
return true
}
if f.ProjectPath != "" {
return true
}
// Status is the only dimension with a default; treat it as "active" if it
// deviates from {"active"}.
if len(f.Status) != 1 || f.Status[0] != "active" {
@@ -36,14 +47,23 @@ func (f TreeFilter) Active() bool {
// ParseTreeFilter pulls the filter state from a URL query.
// Defaults: Status=["active"], ShowArchived=false. Other dimensions empty.
// Multi-value dimensions (tag/mgmt/status/has) use parseValues so BOTH the
// comma-joined hidden-input style (`?tag=foo,bar`, tree page) AND the
// repeated-param HTMX multi-select style (`?tag=foo&tag=bar`, every
// filter-strip form) round-trip into the same TreeFilter shape. The
// prior `q.Get(key)` calls silently dropped every second-and-beyond
// value from any multi-select submission — see
// TestCalendarFilterMultiValueTagsFromForm for the regression.
func ParseTreeFilter(q url.Values) TreeFilter {
f := TreeFilter{
Q: strings.TrimSpace(q.Get("q")),
Tags: parseCSV(q.Get("tag")),
Management: parseCSV(q.Get("mgmt")),
Status: parseCSV(q.Get("status")),
HasLinks: parseCSV(q.Get("has")),
ShowArchived: q.Get("show-archived") == "1",
Q: strings.TrimSpace(q.Get("q")),
Tags: parseValues(q, "tag"),
Management: parseValues(q, "mgmt"),
Status: parseValues(q, "status"),
HasLinks: parseValues(q, "has"),
ShowArchived: q.Get("show-archived") == "1",
ProjectPath: strings.TrimSpace(q.Get("project")),
IncludeDescendants: true,
}
if v := strings.TrimSpace(q.Get("public")); v != "" {
// Treat 1/true/yes/on as true; 0/false/no/off as false; anything else nil.
@@ -56,6 +76,11 @@ func ParseTreeFilter(q url.Values) TreeFilter {
f.Public = &b
}
}
// project_descendants=0 flips the toggle off; any other / missing value
// leaves the default (true). Matches the show-archived parsing pattern.
if q.Get("project_descendants") == "0" {
f.IncludeDescendants = false
}
if len(f.Status) == 0 {
f.Status = []string{"active"}
}
@@ -92,6 +117,14 @@ func (f TreeFilter) QueryString() string {
v.Set("public", "0")
}
}
if f.ProjectPath != "" {
v.Set("project", f.ProjectPath)
// IncludeDescendants=true is the default — elide. Only emit when the
// user has explicitly turned descendants off (the chip's "off" state).
if !f.IncludeDescendants {
v.Set("project_descendants", "0")
}
}
return v.Encode()
}
@@ -113,11 +146,19 @@ func (f TreeFilter) TogglePublic() TreeFilter {
// URL builds a `/?…` URL for this filter. Empty filter → "/".
func (f TreeFilter) URL() string {
return f.URLOn("/")
}
// URLOn builds a URL anchored at `base` for this filter. Empty filter →
// `base` unchanged. Used by Views-supporting pages (dashboard, timeline,
// calendar) to construct chip URLs that stay on the current route, where the
// default URL() always lands on "/".
func (f TreeFilter) URLOn(base string) string {
q := f.QueryString()
if q == "" {
return "/"
return base
}
return "/?" + q
return base + "?" + q
}
// ToggleTag returns a copy with tag added/removed.
@@ -159,6 +200,29 @@ func (f TreeFilter) ToggleShowArchived() TreeFilter {
return next
}
// SetProject returns a copy scoped to the given primary path. Empty path
// clears the scope. IncludeDescendants resets to true (the safe default) when
// the project is cleared so a future SetProject doesn't inherit a stale off
// state.
func (f TreeFilter) SetProject(path string) TreeFilter {
next := f
next.ProjectPath = strings.TrimSpace(path)
if next.ProjectPath == "" {
next.IncludeDescendants = true
}
return next
}
// ToggleIncludeDescendants flips the descendants toggle. The chip stays
// settable even with no project picked (so the URL bar can carry the user's
// preference for the next project they pick), but Matches only consults it
// when ProjectPath is set.
func (f TreeFilter) ToggleIncludeDescendants() TreeFilter {
next := f
next.IncludeDescendants = !f.IncludeDescendants
return next
}
func toggleString(in []string, val string) []string {
found := false
out := make([]string, 0, len(in))
@@ -222,6 +286,28 @@ func (f TreeFilter) Matches(it *store.Item, itemLinkKinds map[string]struct{}) b
if f.Public != nil && *f.Public != it.Public {
return false
}
// Project scope (Phase 5i Slice A). When set, the item must have at least
// one path equal to ProjectPath (exact match), and — when
// IncludeDescendants is on — paths that are descendants of ProjectPath
// (prefix + ".") also match. Multi-parent items are in scope as long as
// ANY of their paths qualifies.
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
}
}
// q substring match.
if f.Q != "" {
q := strings.ToLower(f.Q)
@@ -353,6 +439,20 @@ func sortItems(in []*store.Item) {
sort.Slice(in, func(i, j int) bool { return in[i].Slug < in[j].Slug })
}
// flatMatchedItems returns every item that passes the filter directly — no
// ancestor-keep, no DAG shape. Used by Phase 5i Slice B's card view: a flat
// grid of tiles for the filtered set. Stable order by primary path.
func flatMatchedItems(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) []*store.Item {
out := make([]*store.Item, 0, len(items))
for _, it := range items {
if f.Matches(it, linkKinds[it.ID]) {
out = append(out, it)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].PrimaryPath() < out[j].PrimaryPath() })
return out
}
// ChipCount packages a chip label, the URL that toggles it, the count it
// would yield if it were toggled on (or current count if already on), and a
// flag for whether it's currently active. Used by the template for every
@@ -379,7 +479,10 @@ type ChipCounts struct {
// see what they're filtered down to). For an inactive chip the count is what
// they'd get if they added it. At m's scale (≤100 items × ≤30 chips) this is
// trivially cheap; no caching needed.
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string) ChipCounts {
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string, base string) ChipCounts {
if base == "" {
base = "/"
}
count := func(f TreeFilter) int {
// Branch-keep semantics aren't relevant for chip counts — we want a
// raw "how many items match this filter directly" so the chip number
@@ -397,7 +500,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleTag(tag)
out.Tags = append(out.Tags, ChipCount{
Label: tag,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Tags, tag),
})
@@ -406,7 +509,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleManagement(mode)
out.Management = append(out.Management, ChipCount{
Label: mode,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Management, mode),
})
@@ -415,7 +518,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleStatus(st)
out.Status = append(out.Status, ChipCount{
Label: st,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.Status, st),
})
@@ -424,7 +527,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleHas(h)
out.Has = append(out.Has, ChipCount{
Label: h,
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: contains(current.HasLinks, h),
})
@@ -433,7 +536,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
next := current.ToggleShowArchived()
out.ShowArchived = ChipCount{
Label: "show archived",
URL: next.URL(),
URL: next.URLOn(base),
Count: count(next),
Active: current.ShowArchived,
}

View File

@@ -177,7 +177,7 @@ func TestComputeChipCountsTagCounts(t *testing.T) {
both := &store.Item{ID: "x", Slug: "x", Title: "X", Tags: []string{"work", "dev"}, Status: "active"}
items := []*store.Item{work, dev, both}
f := TreeFilter{Status: []string{"active"}}
counts := computeChipCounts(items, f, map[string]map[string]struct{}{}, []string{"work", "dev"})
counts := computeChipCounts(items, f, map[string]map[string]struct{}{}, []string{"work", "dev"}, "/views/tree")
if len(counts.Tags) != 2 {
t.Fatalf("expected 2 tag chips, got %d", len(counts.Tags))
}
@@ -189,6 +189,129 @@ func TestComputeChipCountsTagCounts(t *testing.T) {
}
}
// TestProjectFilterIncludesDescendants verifies Slice A scope semantics: a
// picked ProjectPath matches the item itself plus every descendant in the DAG
// closure (any path with the ProjectPath + "." prefix). Multi-parent items
// are in scope when any of their paths qualifies.
func TestProjectFilterIncludesDescendants(t *testing.T) {
work := &store.Item{ID: "work", Slug: "work", Paths: []string{"work"}, Status: "active"}
upc := &store.Item{ID: "upc", Slug: "upc", Paths: []string{"work.upc"}, ParentIDs: []string{"work"}, Status: "active"}
deadlines := &store.Item{ID: "dl", Slug: "deadlines", Paths: []string{"work.upc.deadlines"}, ParentIDs: []string{"upc"}, Status: "active"}
other := &store.Item{ID: "other", Slug: "other", Paths: []string{"work.other"}, ParentIDs: []string{"work"}, Status: "active"}
// Multi-parent: lives under both dev.paliad and work.paliad. Setting the
// scope to "work" must put it in scope via its work.paliad lineage.
paliad := &store.Item{ID: "paliad", Slug: "paliad", Paths: []string{"dev.paliad", "work.paliad"}, Status: "active"}
dev := &store.Item{ID: "dev", Slug: "dev", Paths: []string{"dev"}, Status: "active"}
links := map[string]struct{}{}
f := TreeFilter{Status: []string{"active"}, ProjectPath: "work.upc", IncludeDescendants: true}
if !f.Matches(upc, links) {
t.Error("exact-match item should pass")
}
if !f.Matches(deadlines, links) {
t.Error("descendant should pass")
}
if f.Matches(work, links) {
t.Error("ancestor should NOT pass project=work.upc")
}
if f.Matches(other, links) {
t.Error("sibling should NOT pass")
}
// Multi-parent: scope=work should match paliad via the work.paliad path.
f2 := TreeFilter{Status: []string{"active"}, ProjectPath: "work", IncludeDescendants: true}
if !f2.Matches(paliad, links) {
t.Error("multi-parent item should match scope=work via work.paliad path")
}
if f2.Matches(dev, links) {
t.Error("dev (sibling root) should NOT pass scope=work")
}
}
// TestProjectFilterDescendantsOff verifies that flipping IncludeDescendants
// off restricts the scope to items whose paths equal ProjectPath exactly. m
// asked for this toggle behaviour explicitly in Q5 (2026-05-26): always-on
// descendants was the inventor pick; m wants the chip to expose on/off.
func TestProjectFilterDescendantsOff(t *testing.T) {
upc := &store.Item{ID: "upc", Slug: "upc", Paths: []string{"work.upc"}, Status: "active"}
deadlines := &store.Item{ID: "dl", Slug: "deadlines", Paths: []string{"work.upc.deadlines"}, Status: "active"}
links := map[string]struct{}{}
f := TreeFilter{Status: []string{"active"}, ProjectPath: "work.upc", IncludeDescendants: false}
if !f.Matches(upc, links) {
t.Error("exact-match item should still pass with descendants off")
}
if f.Matches(deadlines, links) {
t.Error("descendant should NOT pass when IncludeDescendants is off")
}
}
// TestParseTreeFilterProjectFields verifies URL-param parsing for the project
// scope. IncludeDescendants defaults to true; project_descendants=0 flips it.
// project_descendants without an explicit "0" stays at default.
func TestParseTreeFilterProjectFields(t *testing.T) {
f := parseQS(t, "?project=work.upc")
if f.ProjectPath != "work.upc" {
t.Errorf("ProjectPath = %q, want %q", f.ProjectPath, "work.upc")
}
if !f.IncludeDescendants {
t.Error("IncludeDescendants should default to true")
}
if !f.Active() {
t.Error("project scope should make filter Active()")
}
off := parseQS(t, "?project=work.upc&project_descendants=0")
if off.IncludeDescendants {
t.Error("project_descendants=0 should set IncludeDescendants=false")
}
}
// TestTreeFilterProjectRoundTrip verifies that emitting + re-parsing the
// project fields yields the same TreeFilter, including the descendants
// toggle when it deviates from default.
func TestTreeFilterProjectRoundTrip(t *testing.T) {
for _, qs := range []string{
"?project=work.upc",
"?project=work.upc&project_descendants=0",
"?project=dev&q=paliad&tag=work&status=done",
} {
f := parseQS(t, qs)
out := f.URL()
f2 := parseQS(t, strings.TrimPrefix(out, "/"))
if f.URL() != f2.URL() {
t.Errorf("round-trip mismatch: %q → %q → %q", qs, out, f2.URL())
}
}
}
// TestSetProjectAndToggleHelpers spot-checks the two helpers added in Slice A.
func TestSetProjectAndToggleHelpers(t *testing.T) {
f := TreeFilter{Status: []string{"active"}, IncludeDescendants: true}
scoped := f.SetProject("work.upc")
if scoped.ProjectPath != "work.upc" {
t.Errorf("SetProject did not set path; got %q", scoped.ProjectPath)
}
if !scoped.IncludeDescendants {
t.Error("SetProject should preserve IncludeDescendants when truthy")
}
// Toggling descendants flips the bool.
off := scoped.ToggleIncludeDescendants()
if off.IncludeDescendants {
t.Error("ToggleIncludeDescendants should flip true → false")
}
// Clearing the project resets the toggle to the safe default (true) so a
// future SetProject call doesn't inherit the off state.
cleared := off.SetProject("")
if !cleared.IncludeDescendants {
t.Error("clearing project should reset IncludeDescendants to true")
}
if cleared.ProjectPath != "" {
t.Errorf("clearing project should empty path; got %q", cleared.ProjectPath)
}
}
func TestToggleStatusKeepsActiveDefault(t *testing.T) {
f := TreeFilter{Status: []string{"active"}}
// Toggling active off when nothing else is on should leave us at the

151
web/view_type.go Normal file
View File

@@ -0,0 +1,151 @@
package web
import (
"net/url"
"strings"
)
// View type enum — Phase 5i Slice B. Five values per m's Q1 + Q3 picks
// (2026-05-26): timeline is a first-class view_type alongside the four m
// originally named.
const (
ViewTypeCard = "card"
ViewTypeList = "list"
ViewTypeCalendar = "calendar"
ViewTypeKanban = "kanban"
ViewTypeTimeline = "timeline"
)
// allViewTypes is the canonical ordered set used by validators and template
// rendering. Adding a value here is one of the few places that needs to stay
// in lockstep with the `view_type` CHECK constraint in migration 0016
// (lands in slice D).
var allViewTypes = []string{
ViewTypeCard,
ViewTypeList,
ViewTypeCalendar,
ViewTypeKanban,
ViewTypeTimeline,
}
// ViewTypeSet is the per-route catalog: which view types each Views-supporting
// page accepts. Tree supports list (default) + card today; kanban joins in
// slice C. Dashboard, calendar, and timeline are locked to their native shape
// for slice B — accepting a different view_type silently falls back to the
// default (no errors; the chip-strip surface signals "this view is locked").
type ViewTypeSet struct {
Default string
Allowed []string
}
// Has reports whether vt is part of the route's allowed set.
func (s ViewTypeSet) Has(vt string) bool {
for _, v := range s.Allowed {
if v == vt {
return true
}
}
return false
}
// Resolve returns vt if it is in the allowed set, otherwise the default. Used
// by handlers when parsing `?view_type=`; unknown / forbidden values fall back
// gracefully without 4xx.
func (s ViewTypeSet) Resolve(vt string) string {
if s.Has(vt) {
return vt
}
return s.Default
}
// PageViewTypes returns the catalog for the named route. Routes outside the
// Views system (graph, admin/*) get an empty set; their handlers don't call
// this. The narrow tree/dashboard set is the seed; slices CE grow it.
func PageViewTypes(route string) ViewTypeSet {
switch route {
case "/", "/views/tree", "tree":
return ViewTypeSet{
Default: ViewTypeList,
// Slice B: list + card. Slice C: kanban joins.
Allowed: []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
}
case "/dashboard", "/views/dashboard", "dashboard":
// Dashboard is locked to its Phase 5h tabbed-tiles surface in slice B.
// The view_type chip is informational only here; switching templates
// for card vs list on /dashboard is a follow-up slice (the tabbed
// tiles ARE the card view conceptually, so the work is mostly
// renaming labels).
return ViewTypeSet{
Default: ViewTypeCard,
Allowed: []string{ViewTypeCard},
}
case "/timeline", "/views/timeline", "timeline":
return ViewTypeSet{
Default: ViewTypeTimeline,
Allowed: []string{ViewTypeTimeline},
}
case "/calendar", "/views/calendar", "calendar":
return ViewTypeSet{
Default: ViewTypeCalendar,
Allowed: []string{ViewTypeCalendar},
}
}
return ViewTypeSet{}
}
// ParseViewType pulls `view_type` from q and falls back to the route's
// default. Unknown values map to the default (no error path for the user).
func ParseViewType(q url.Values, set ViewTypeSet) string {
raw := strings.ToLower(strings.TrimSpace(q.Get("view_type")))
if raw == "" {
return set.Default
}
return set.Resolve(raw)
}
// ViewTypeChip is one entry in the view-type chip strip rendered above the
// section. Active marks the currently-rendered view; URL is the toggle target.
type ViewTypeChip struct {
Label string
URL string
Active bool
// Locked is true for view types that aren't in the route's allowed set
// today (e.g. kanban on /tree before slice C). Rendered greyed-out with a
// "coming soon" title attribute. Clicking still navigates (so the URL
// remains shareable), but lands on the default with the chip strip
// showing the desired view as un-toggled.
Locked bool
}
// ViewTypeChips builds the chip strip for `route` given the current filter
// and view. Currently emits chips for every value in allViewTypes; entries
// outside the route's allowed set surface as Locked.
func ViewTypeChips(route string, filter TreeFilter, current string) []ViewTypeChip {
set := PageViewTypes(route)
base := route
if base == "tree" {
base = "/"
}
out := make([]ViewTypeChip, 0, len(allViewTypes))
for _, vt := range allViewTypes {
urlStr := filter.URLOn(base)
// Embed the chosen view_type into the URL. We use a tiny query
// rewrite because the filter does NOT carry the view_type — keeping
// it out of TreeFilter (the design doc's call: render state, not
// filter state).
if vt != set.Default {
if strings.Contains(urlStr, "?") {
urlStr += "&view_type=" + vt
} else {
urlStr += "?view_type=" + vt
}
}
out = append(out, ViewTypeChip{
Label: vt,
URL: urlStr,
Active: vt == current,
Locked: !set.Has(vt),
})
}
return out
}

97
web/view_type_test.go Normal file
View File

@@ -0,0 +1,97 @@
package web
import (
"net/url"
"testing"
)
// TestParseViewTypeFallsBackOnUnknown verifies that ParseViewType returns the
// route's default for empty / unknown / forbidden values, and the requested
// value when allowed. Slice B routes its picks through PageViewTypes for the
// per-route allowed set.
func TestParseViewTypeFallsBackOnUnknown(t *testing.T) {
cases := []struct {
route, raw, want string
}{
{"/", "", ViewTypeList}, // default for tree
{"/", "card", ViewTypeCard}, // allowed on tree
{"/", "list", ViewTypeList}, // explicit default
{"/", "kanban", ViewTypeKanban}, // unlocked in slice C
{"/", "junk", ViewTypeList}, // unknown → default
{"/views/dashboard", "", ViewTypeCard}, // default for dashboard
{"/views/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
{"/views/timeline", "card", ViewTypeTimeline}, // locked
{"/views/calendar", "kanban", ViewTypeCalendar}, // locked
}
for _, tc := range cases {
set := PageViewTypes(tc.route)
q := url.Values{}
if tc.raw != "" {
q.Set("view_type", tc.raw)
}
got := ParseViewType(q, set)
if got != tc.want {
t.Errorf("route=%s raw=%q → %q, want %q", tc.route, tc.raw, got, tc.want)
}
}
}
// TestViewTypeChipsMarkLockedAndActive asserts the chip strip emits one entry
// per canonical view_type and that the locked + active flags follow from the
// route's allowed set + the current pick.
func TestViewTypeChipsMarkLockedAndActive(t *testing.T) {
filter := TreeFilter{Status: []string{"active"}}
chips := ViewTypeChips("/", filter, ViewTypeCard)
if len(chips) != 5 {
t.Fatalf("expected 5 chips (one per view_type), got %d", len(chips))
}
byLabel := map[string]ViewTypeChip{}
for _, c := range chips {
byLabel[c.Label] = c
}
if !byLabel[ViewTypeCard].Active {
t.Error("card chip should be Active when current=card")
}
if byLabel[ViewTypeList].Active {
t.Error("list chip should not be Active when current=card")
}
for _, allowed := range []string{ViewTypeList, ViewTypeCard, ViewTypeKanban} {
if byLabel[allowed].Locked {
t.Errorf("%s is allowed on /; chip should not be Locked", allowed)
}
}
// Slice C only unlocks kanban; calendar + timeline stay locked on /tree
// until cross-route view_type swaps land in a future slice.
for _, locked := range []string{ViewTypeCalendar, ViewTypeTimeline} {
if !byLabel[locked].Locked {
t.Errorf("%s should still be Locked on /", locked)
}
}
}
// TestViewTypeChipURLPreservesFilter ensures that a chip click on a filtered
// tree carries the filter forward (so flipping to card view doesn't lose
// `?tag=work`).
func TestViewTypeChipURLPreservesFilter(t *testing.T) {
filter := TreeFilter{Status: []string{"active"}, Tags: []string{"work"}}
chips := ViewTypeChips("/", filter, ViewTypeList)
for _, c := range chips {
if c.Label == ViewTypeCard {
// Card URL must include tag=work AND view_type=card.
if !contains([]string{c.URL}, c.URL) || !urlContains(c.URL, "tag=work") || !urlContains(c.URL, "view_type=card") {
t.Errorf("card chip URL missing filter or view_type: %q", c.URL)
}
return
}
}
t.Fatal("card chip missing from set")
}
func urlContains(u, needle string) bool {
for i := 0; i+len(needle) <= len(u); i++ {
if u[i:i+len(needle)] == needle {
return true
}
}
return false
}

468
web/views.go Normal file
View File

@@ -0,0 +1,468 @@
package web
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/m/projax/store"
)
// Phase 5j paliad-shape views handlers. Slice B introduces the route
// family; slices CG evolve the render, editor, system-views, sidebar,
// and polish layers.
//
// Route table:
// GET /views → handleViewsLanding (MRU 302 or shell)
// GET /views/{slug} → handleViewRender (saved or system)
// GET /views/new → handleViewEditor (blank)
// GET /views/{slug}/edit → handleViewEditor (existing)
// POST /views → handleViewCreate
// POST /views/{slug} → handleViewUpdate
// POST /views/{slug}/delete → handleViewDelete
// POST /views/reorder → handleViewReorder (slice G — wired now,
// used in v2)
// handleViewsLanding implements m's Q5 pick: 302 to the most-recently-used
// view if any, else render the onboarding shell listing every saved view.
func (s *Server) handleViewsLanding(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("nodefault") != "1" {
mr, err := s.Store.MostRecentView(r.Context())
if err != nil {
s.Logger.Warn("views landing: mru", "err", err)
} else if mr != nil {
http.Redirect(w, r, "/views/"+mr.Slug, http.StatusFound)
return
}
}
views, err := s.Store.ListViews(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "views_landing", map[string]any{
"Title": "views",
"Views": views,
})
}
// handleViewRender resolves a slug into either a user view (Slice A
// schema) or a system view (Slice C), then renders the appropriate
// template. The render path also fire-and-forgets a TouchView so the
// view climbs the MRU ladder for the next /views landing redirect.
//
// Slice B implementation: only user views are wired; system views
// resolve via LookupSystemView (added in Slice C) and 404 in this slice
// when the slug is unknown.
func (s *Server) handleViewRender(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
http.NotFound(w, r)
return
}
v, err := s.Store.GetView(r.Context(), slug)
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.fail(w, r, err)
return
}
if err := s.Store.TouchView(r.Context(), slug); err != nil {
s.Logger.Warn("touch view", "slug", slug, "err", err)
}
// Parse the saved spec.
filter, viewType, groupBy := decodeViewSpec(v.FilterJSON)
// Allow URL chip overlay so chip clicks inside a saved view narrow
// further. The page chip URLs round-trip ?view= via the URL anchor
// added in slice E's sidebar wiring; here we just respect anything
// the user typed in the query.
urlFilter := ParseTreeFilter(r.URL.Query())
overlayURLOntoSavedFilter(&filter, urlFilter, r.URL.Query())
if raw := strings.TrimSpace(r.URL.Query().Get("view_type")); raw != "" {
viewType = raw
}
if raw := strings.TrimSpace(r.URL.Query().Get("group_by")); raw != "" {
groupBy = raw
}
s.renderViewPage(w, r, v, filter, viewType, groupBy)
}
// renderViewPage runs the shared render path for a resolved view (user
// view or future system view). Slice B reuses the tree handler's
// rendering pieces — list / card / kanban share the tree-section
// dispatch shape. Calendar / timeline view_types fall back to list in
// slice B; slice D wires their dedicated templates.
func (s *Server) renderViewPage(w http.ResponseWriter, r *http.Request, v *store.View, filter TreeFilter, viewType, groupBy string) {
items, err := s.Store.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
tags, err := s.Store.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
linkKinds, err := s.linkKindsByItem(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
viewSet := PageViewTypes("/")
if viewType == "" {
viewType = viewSet.Default
}
viewType = viewSet.Resolve(viewType)
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
base := "/views/" + v.Slug
counts := computeChipCounts(items, filter, linkKinds, tags, base)
cardItems := flatMatchedItems(items, filter, linkKinds)
if groupBy == "" {
groupBy = ParseGroupBy(r.URL.Query())
}
kanban := BuildKanbanBoard(cardItems, groupBy)
groupByChips := GroupByChips(base, filter, groupBy)
data := map[string]any{
"Title": v.Name,
"View": v,
"Roots": roots,
"Orphans": orphans,
"Total": total,
"OrphanN": orphanN,
"Matched": matched,
"AllTags": tags,
"Filter": filter,
"Counts": counts,
"Projects": parentOptionsFromItems(items),
"BasePath": base,
"ProjectChipTarget": "#tree-section",
"ViewType": viewType,
"ViewTypeChips": ViewTypeChips(base, filter, viewType),
"CardItems": cardItems,
"Kanban": kanban,
"GroupBy": groupBy,
"GroupByChips": groupByChips,
"ActiveTags": filter.Tags,
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "tree_section", data)
return
}
s.render(w, r, "view_render", data)
}
// handleViewEditor renders the create / edit form. Slice B ships a
// minimal placeholder; Slice D rebuilds the form with the chip strip
// + slug derivation + icon picker.
func (s *Server) handleViewEditor(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
var (
view *store.View
err error
title = "new view"
)
if slug != "" {
view, err = s.Store.GetView(r.Context(), slug)
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.fail(w, r, err)
return
}
title = "edit " + view.Name
}
filterQuery := ""
currentViewType := "list"
if view != nil {
f, vt, _ := decodeViewSpec(view.FilterJSON)
filterQuery = f.QueryString()
if vt != "" {
currentViewType = vt
}
}
s.render(w, r, "view_editor", map[string]any{
"Title": title,
"View": view,
"FilterQuery": filterQuery,
"ViewTypes": []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
"CurrentVT": currentViewType,
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
"SortDirOptions": []string{"", "asc", "desc"},
"IconKeys": IconRegistryKeys(),
})
}
// handleViewCreate accepts the create form POST.
func (s *Server) handleViewCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
in, err := viewInputFromForm(r.PostForm)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
v, err := s.Store.CreateView(r.Context(), in)
if err != nil {
s.writeViewError(w, err)
return
}
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
}
// handleViewUpdate accepts the edit form POST.
func (s *Server) handleViewUpdate(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
in, err := viewInputFromForm(r.PostForm)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
v, err := s.Store.UpdateView(r.Context(), slug, in)
if err != nil {
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
s.writeViewError(w, err)
return
}
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
}
// handleViewDelete soft-… nope. New schema is hard-delete (no
// deleted_at). One POST removes the row.
func (s *Server) handleViewDelete(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if err := s.Store.DeleteView(r.Context(), slug); err != nil {
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/views", http.StatusSeeOther)
}
// handleViewReorder takes a comma-separated slug list and applies new
// sort_order values. Wired now so slice G's drag UI has a target.
func (s *Server) handleViewReorder(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
raw := strings.TrimSpace(r.PostForm.Get("slugs"))
if raw == "" {
http.Error(w, "slugs is required", http.StatusBadRequest)
return
}
slugs := strings.Split(raw, ",")
for i, slug := range slugs {
slugs[i] = strings.TrimSpace(slug)
}
if err := s.Store.ReorderViews(r.Context(), slugs); err != nil {
s.fail(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeViewError maps the typed store errors to friendly HTTP status +
// banner copy. Falls back to 400 for anything else.
func (s *Server) writeViewError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
switch {
case errors.Is(err, store.ErrViewSlugFormat):
http.Error(w, "slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (lowercase, no underscores, no leading dash)", http.StatusBadRequest)
case errors.Is(err, store.ErrViewSlugReserved):
http.Error(w, "slug is reserved (system views and top-level routes shadow it)", http.StatusBadRequest)
case errors.Is(err, store.ErrViewSlugTaken):
http.Error(w, "slug already exists — pick a different one", http.StatusConflict)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
// viewInputFromForm decodes the create/edit form. Slug + name are
// required; the rest defaults sensibly. filter_query is optional and
// canonicalises into filter_json on save (URL-query form is what the
// editor's chip strip emits in slice D).
func viewInputFromForm(form url.Values) (store.ViewInput, error) {
in := store.ViewInput{
Slug: strings.TrimSpace(form.Get("slug")),
Name: strings.TrimSpace(form.Get("name")),
SortField: strings.TrimSpace(form.Get("sort_field")),
SortDir: strings.TrimSpace(form.Get("sort_dir")),
GroupBy: strings.TrimSpace(form.Get("group_by")),
ShowCount: form.Get("show_count") == "1",
}
if iconRaw := strings.TrimSpace(form.Get("icon")); iconRaw != "" {
in.Icon = &iconRaw
}
viewType := strings.TrimSpace(form.Get("view_type"))
if viewType == "" {
viewType = ViewTypeList
}
fq := strings.TrimSpace(form.Get("filter_query"))
filterJSON, err := encodeFilterToJSON(fq, viewType)
if err != nil {
return in, fmt.Errorf("filter_query: %w", err)
}
in.FilterJSON = filterJSON
return in, nil
}
// encodeFilterToJSON turns a URL-query-form filter + view_type into the
// canonical filter_json shape stored on the view. view_type lives inside
// the JSON per m's Q2 pick.
func encodeFilterToJSON(query, viewType string) ([]byte, error) {
q, err := url.ParseQuery(strings.TrimPrefix(query, "?"))
if err != nil {
return nil, err
}
f := ParseTreeFilter(q)
payload := map[string]any{
"view_type": viewType,
}
if f.Q != "" {
payload["q"] = f.Q
}
if len(f.Tags) > 0 {
payload["tags"] = f.Tags
}
if len(f.Management) > 0 {
payload["management"] = f.Management
}
if !(len(f.Status) == 1 && f.Status[0] == "active") {
payload["status"] = f.Status
}
if len(f.HasLinks) > 0 {
payload["has_links"] = f.HasLinks
}
if f.Public != nil {
payload["public"] = *f.Public
}
if f.ShowArchived {
payload["show_archived"] = true
}
if f.ProjectPath != "" {
payload["project_path"] = f.ProjectPath
if !f.IncludeDescendants {
payload["include_descendants"] = false
}
}
return json.Marshal(payload)
}
// decodeViewSpec parses filter_json into a TreeFilter + view_type +
// group_by. Inverse of encodeFilterToJSON.
func decodeViewSpec(filterJSON []byte) (TreeFilter, string, string) {
f := TreeFilter{
Status: []string{"active"},
IncludeDescendants: true,
}
viewType := ""
groupBy := ""
if len(filterJSON) == 0 {
return f, viewType, groupBy
}
payload := map[string]any{}
if err := json.Unmarshal(filterJSON, &payload); err != nil {
return f, viewType, groupBy
}
if v, ok := payload["view_type"].(string); ok {
viewType = v
}
if v, ok := payload["group_by"].(string); ok {
groupBy = v
}
if v, ok := payload["q"].(string); ok {
f.Q = v
}
if v, ok := payload["tags"].([]any); ok {
f.Tags = anySliceToStrings(v)
}
if v, ok := payload["management"].([]any); ok {
f.Management = anySliceToStrings(v)
}
if v, ok := payload["status"].([]any); ok {
f.Status = anySliceToStrings(v)
if len(f.Status) == 0 {
f.Status = []string{"active"}
}
}
if v, ok := payload["has_links"].([]any); ok {
f.HasLinks = anySliceToStrings(v)
}
if v, ok := payload["public"].(bool); ok {
f.Public = &v
}
if v, ok := payload["show_archived"].(bool); ok && v {
f.ShowArchived = true
}
if v, ok := payload["project_path"].(string); ok {
f.ProjectPath = v
}
if v, ok := payload["include_descendants"].(bool); ok {
f.IncludeDescendants = v
}
return f, viewType, groupBy
}
// overlayURLOntoSavedFilter applies URL-query chip values on top of the
// saved-view baseline. Same pattern the 5i fix-shift had (URL overrides
// saved); slice B reintroduces it here on the /views/{slug} render path.
func overlayURLOntoSavedFilter(base *TreeFilter, urlFilter TreeFilter, q url.Values) {
if q.Get("q") != "" {
base.Q = urlFilter.Q
}
if _, ok := q["tag"]; ok {
base.Tags = urlFilter.Tags
}
if _, ok := q["mgmt"]; ok {
base.Management = urlFilter.Management
}
if _, ok := q["status"]; ok {
base.Status = urlFilter.Status
}
if _, ok := q["has"]; ok {
base.HasLinks = urlFilter.HasLinks
}
if q.Get("show-archived") != "" {
base.ShowArchived = urlFilter.ShowArchived
}
if q.Get("public") != "" {
base.Public = urlFilter.Public
}
if q.Get("project") != "" {
base.ProjectPath = urlFilter.ProjectPath
}
if q.Get("project_descendants") != "" {
base.IncludeDescendants = urlFilter.IncludeDescendants
}
}
func anySliceToStrings(in []any) []string {
out := make([]string, 0, len(in))
for _, v := range in {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}

197
web/views_test.go Normal file
View File

@@ -0,0 +1,197 @@
package web_test
import (
"context"
"net/url"
"strings"
"testing"
"time"
)
// TestViewsLandingOnboarding asserts that GET /views with no views and no
// MRU renders the onboarding shell ("No saved views yet" + "+ New view").
func TestViewsLandingOnboarding(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// Clear any leftover touched views from prior runs so the MRU 302
// doesn't fire and steal the response.
if _, err := pool.Exec(context.Background(),
`UPDATE projax.views SET last_used_at = NULL`); err != nil {
t.Fatalf("reset mru: %v", err)
}
// Also clear ALL views so the onboarding shell renders (othewise the
// landing still ListViews-displays them).
if _, err := pool.Exec(context.Background(), `DELETE FROM projax.views`); err != nil {
t.Fatalf("clear views: %v", err)
}
code, body := get(t, h, "/views")
if code != 200 {
t.Fatalf("GET /views status=%d body=%q", code, body)
}
if !strings.Contains(body, "No saved views yet") {
t.Error("onboarding shell should surface the no-views nudge")
}
if !strings.Contains(body, `href="/views/new"`) {
t.Error("onboarding shell should link to /views/new")
}
}
// TestViewsLandingMRURedirects asserts that GET /views 302s to the most
// recently used view when one exists.
func TestViewsLandingMRURedirects(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-b-landing-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
// Seed + touch.
if _, err := pool.Exec(context.Background(), `
INSERT INTO projax.views (slug, name, filter_json, last_used_at)
VALUES ($1, 'P5j B Landing', '{"view_type":"list"}'::jsonb, now())`, slug); err != nil {
t.Fatalf("seed view: %v", err)
}
code, body := get(t, h, "/views")
if code != 302 {
t.Errorf("GET /views status=%d (want 302 to MRU); body=%q", code, body)
}
}
// TestViewRenderShowsSavedView asserts that GET /views/{slug} renders the
// view's name + slug in the header and the tree-section body.
func TestViewRenderShowsSavedView(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-b-render-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
if _, err := pool.Exec(context.Background(), `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'P5j B Render', '{"view_type":"card"}'::jsonb)`, slug); err != nil {
t.Fatalf("seed: %v", err)
}
code, body := get(t, h, "/views/"+slug)
if code != 200 {
t.Fatalf("GET /views/<slug> status=%d body=%q", code, body)
}
if !strings.Contains(body, "P5j B Render") {
t.Error("render should surface the view's name")
}
if !strings.Contains(body, `/views/`+slug) {
t.Error("render should surface the view's slug in the header")
}
if !strings.Contains(body, `class="tree-card-grid"`) {
t.Error("view_type=card should render the card grid")
}
}
// TestViewRender404OnUnknownSlug — an unknown slug returns 404, not a
// silent fallback to the tree.
func TestViewRender404OnUnknownSlug(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, _ := get(t, h, "/views/this-slug-does-not-exist-anywhere-9876")
if code != 404 {
t.Errorf("unknown slug should 404, got %d", code)
}
}
// TestViewCreateAndDelete — POST /views creates; POST /views/<slug>/delete
// removes. Verifies the slug-format error path too.
func TestViewCreateAndDelete(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-b-crud-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
form := url.Values{}
form.Set("slug", slug)
form.Set("name", "P5j B CRUD")
form.Set("view_type", "list")
form.Set("filter_query", "tag=work")
code, _ := post(t, h, "/views", form)
if code != 303 {
t.Fatalf("create status=%d want 303", code)
}
// Reserved-slug 400.
form2 := url.Values{}
form2.Set("slug", "dashboard")
form2.Set("name", "Should be rejected")
form2.Set("view_type", "list")
code, body := post(t, h, "/views", form2)
if code != 400 {
t.Errorf("reserved-slug create should 400, got %d body=%q", code, body)
}
// Delete.
code, _ = post(t, h, "/views/"+slug+"/delete", url.Values{})
if code != 303 {
t.Errorf("delete status=%d want 303", code)
}
}
// TestSavedViewFilterOverlay — chip params on /views/<slug>?tag=x narrow
// the saved filter. Verifies the slice B render-path overlay.
func TestSavedViewFilterOverlay(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx := context.Background()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-b-overlay-" + stamp
devSlug := "p5j-b-overlay-d-" + stamp
homeSlug := "p5j-b-overlay-h-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
var dev, home string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil {
t.Fatalf("home: %v", err)
}
var devID, homeID string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
VALUES (array['project']::text[], 'P5jB Dev', $1, ARRAY[$2]::uuid[], ARRAY['work'])
RETURNING id`, devSlug, dev).Scan(&devID); err != nil {
t.Fatalf("seed dev item: %v", err)
}
if err := pool.QueryRow(ctx, `
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
VALUES (array['project']::text[], 'P5jB Home', $1, ARRAY[$2]::uuid[], ARRAY['home'])
RETURNING id`, homeSlug, home).Scan(&homeID); err != nil {
t.Fatalf("seed home item: %v", err)
}
defer pool.Exec(context.Background(), `DELETE FROM projax.items WHERE id IN ($1,$2)`, devID, homeID)
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'P5jB Overlay', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
t.Fatalf("seed view: %v", err)
}
devLink := `href="/i/dev.` + devSlug + `"`
homeLink := `href="/i/home.` + homeSlug + `"`
_, base := get(t, h, "/views/"+slug)
if !strings.Contains(base, devLink) {
t.Error("saved view without tag should show dev row")
}
if !strings.Contains(base, homeLink) {
t.Error("saved view without tag should show home row")
}
_, narrowed := get(t, h, "/views/"+slug+"?tag=work")
if !strings.Contains(narrowed, devLink) {
t.Error("URL chip tag=work should keep dev (work-tagged)")
}
if strings.Contains(narrowed, homeLink) {
t.Error("URL chip tag=work should hide home")
}
}