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

projax

m's personal data backbone for self-management — areas of life, projects within them, and aggregated views over tasks that live elsewhere. Subsumes scattered state currently held in mai.projects, CalDAV task lists, Gitea issues, and mBrian topic hubs.

Spec: docs/design.md. Project conventions: CLAUDE.md.

Run locally

export PROJAX_DB_URL=postgres://postgres:<pw>@<msupabase-host>:6789/postgres?sslmode=disable
go run ./cmd/projax

Defaults:

  • PROJAX_LISTEN_ADDR=:8080
  • PROJAX_AUTO_MIGRATE=on (set to off to skip on-start migration apply)
  • SUPABASE_URL + SUPABASE_ANON_KEY enable projax's own /login. Same Supabase backend as the rest of the m/* fleet, but every tool runs its own login page and scopes cookies per-host. Leave both unset for local dev — every request is anonymous.
  • DAV_URL + DAV_USER + DAV_PASSWORD enable the CalDAV integration: /admin/caldav discovery, the Tasks section on item detail pages, and the "Create CalDAV list" action. Leave unset to disable (admin page shows a "not configured" notice).

Visit http://localhost:8080/. Routes:

Route Purpose
GET / Tree of areas + projects, plus orphan mai.projects
GET /i/{path} Item detail; editable for projax, read-only for mai
POST /i/{path} Save edits to a projax-native item
POST /i/{path}/promote Promote a mai.projects orphan into a projax item
GET /new?parent={path} Create a new item (area at root, project under parent)
POST /new Submit
GET /admin/classify Orphan list with inline HTMX promote
GET /login Sign-in form (open)
POST /login Sign-in submit (open)
POST /logout Clear cookies, redirect to /login
GET /healthz DB ping (open)
GET /static/style.css Embedded CSS

Test

DB-backed integration tests are skipped automatically when no PROJAX_DB_URL / SUPABASE_DATABASE_URL is set:

SUPABASE_DATABASE_URL=postgres://... go test ./...

Covers: migration idempotency, path-trigger semantics (nest, rename, re-parent, cycle, structural rules), items_unified source split + promotion hiding, every HTTP handler, and a Promote round-trip.

Deploy (Dokploy on mlake)

0. Manual prerequisite — create the dedicated DB role (run once)

The binary connects as a dedicated projax_admin role so its blast radius is bounded to the projax schema (cannot reach mai.workers, otto.*, vault.*, etc.). The role lives outside the migrations because it carries credentials.

As a superuser on msupabase (e.g. via the Supabase SQL editor):

CREATE ROLE projax_admin WITH LOGIN PASSWORD '<choose-strong-pw>';

-- Cross-schema read of mai.projects (consumed by projax.items_unified):
GRANT USAGE  ON SCHEMA mai   TO projax_admin;
GRANT SELECT ON mai.projects TO projax_admin;

-- mai.projects has RLS enabled; standard SELECT grants aren't enough.
-- Add an explicit policy so projax_admin sees every row:
CREATE POLICY projax_read ON mai.projects FOR SELECT TO projax_admin USING (true);

Then store the credential in .env.age and surface it to Dokploy as the secret PROJAX_DB_URL:

PROJAX_DB_URL=postgres://projax_admin:<pw>@<msupabase-tailscale-host>:6789/postgres?sslmode=disable

After this, migration 0005_reown_to_projax_admin.sql will detect the role on the next deploy and transfer ownership of every projax-namespaced object. Migrations before/after that point are idempotent.

1. Dokploy app

deploy/dokploy.yaml is a reference manifest. Translate to the Dokploy UI:

  1. Create an app projax with Dockerfile build context = repo root.
  2. Set domain projax.msbls.de (public via Traefik + Let's Encrypt — auth gating is at the application layer, see Trust model).
  3. Secret PROJAX_DB_URL from step 0.
  4. Env SUPABASE_URL=https://supa.flexsiebels.de, secret SUPABASE_ANON_KEY (from .env.age).
  5. Health check path /healthz.
  6. Single replica.

The image is a distroless static container running as nonroot. Total image size is well under 20 MiB because everything (templates, CSS, migrations) is embed-bundled.

Trust model (v1)

Single-user. Public over HTTPS, gated by projax's own Supabase login. No anonymous routes except /healthz (Dokploy/Traefik probe), /login and /logout.

  • Browser arrives without a session → 302 /login?redirectTo=<safe-path>.
  • /login posts to <SUPABASE_URL>/auth/v1/token?grant_type=password with the m/* user account. On success projax sets access_token and refresh_token cookies (HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age=1y, no Domain attribute so they are scoped to projax.msbls.de only).
  • Every request after that validates the cookie against /auth/v1/user. On expiry, projax silently refreshes via /auth/v1/token?grant_type=refresh_token and rotates both cookies. The middleware also accepts Authorization: Bearer <token> for scripted clients.
  • /logout clears both cookies and bounces to /login.
  • redirectTo is path-only (/-prefixed, no //, no escape sequences). Cross-host bounces are rejected and fall back to /.
  • Same Supabase backend as the rest of the m/* fleet (mBrian, flexsiebels, …); each tool keeps its own login + cookie scope.
  • DB role is projax_admin — full rights on projax.*, read-only on mai.projects via an explicit RLS policy, blocked on every other schema (see deploy step 0).
  • PROJAX_DB_URL + SUPABASE_ANON_KEY live in Dokploy secrets, never the repo.

If projax later needs auth (multi-device, shared with people, etc.), the natural fit is the same Supabase auth used by flexsiebels — defer until projax has actually outgrown the Tailscale fence.

Schema

projax.items        (id, kind[], title, slug, path, parent_id, content_md,
                     aliases[], metadata jsonb, status, pinned, archived,
                     start_time, end_time, created_at, updated_at, deleted_at)
projax.item_links   (item_id, ref_type, ref_id, rel, note, metadata, created_at)
projax.items_unified VIEW = projax.items UNION ALL adapter over mai.projects

A BEFORE trigger maintains items.path via parent walk and enforces structural rules (areas at root, projects not at root, no cycles). An AFTER trigger rewrites descendant paths on rename / re-parent.

A mai.projects row drops out of items_unified as soon as any projax.item_links row with ref_type='mai-project' points back at it — that's how the Promote flow makes the duplicate disappear without ever mutating mai.projects.

Migrations live in db/migrations/, are embedded into the binary, and applied lexicographically on boot.

Description
m's personal project + self-management system. Data backbone with multiple interfaces (Otto-PWA, web UI, Excalidraw views, CalDAV tasks).
Readme 775 KiB
Languages
Go 88.4%
CSS 6.6%
PLpgSQL 4.7%
JavaScript 0.2%