Commit Graph

16 Commits

Author SHA1 Message Date
mAi
838793ee69 fix(phase 3n bulk): un-nest chip-add form, inline banner for empty Apply, multi-value filter preserved
Three structural bugs from Phase 3d caught by m's "doesn't work" report:

1. The chip-add <form class="chip-add" ...> was rendered INSIDE the outer
   <form id="bulk-actions" ...> in bulk_section.tmpl. HTML forbids
   nested forms — browsers silently flatten them, so the chip-add's
   hx-trigger="submit" never fired and pressing Enter in any chip-add
   input dispatched the outer Apply form instead. Replaced the inner
   <form> with a <span class="chip-add"> wrapping an input that fires
   hx-post directly on Enter (hx-trigger="keyup[key=='Enter']") plus an
   explicit + button. No more nested forms. New TestBulkPageHasNoNested
   Forms regression-guards via a substring check on the rendered HTML.

2. handleBulkApply 400'd on empty ids OR empty action via http.Error,
   which HTMX swapped into #bulk-section as a plain-text error page —
   the page chrome vanished and the user saw "no action chosen". Now
   the handler validates inputs, sets a banner string, and falls through
   to renderBulkList (the section re-renders with the banner inline).
   Banner copy is task-specific so m can tell what he missed.

3. renderBulkList read filter values with r.FormValue, which returns
   ONLY the first value for multi-value names. Multi-select tag/mgmt/
   status filters dropped their 2nd+ values on every Apply round-trip.
   Switched to r.Form["..."] + a new normaliseFormStrings helper that
   dedupes / lowercases / trims the slice. TestBulkApplyRendersWithFilter
   Preserved regression-guards.

All 3 bugs caught by tests written first (TestBulkPageHasNoNestedForms,
TestBulkApplyEmpty{Action,Ids}RendersInlineBanner, TestBulkApplyRenders
WithFilterPreserved). Existing 4 bulk tests still green; full test suite
green.
2026-05-16 01:25:48 +02:00
mAi
d49ad219a4 feat(phase 3l vevents): VEVENT support on dashboard — closes mgmt-parity gap
caldav package:
- Event struct: UID, Summary, Start, End, AllDay, Location, Description,
  Recurring, URL — read-only, no writeback
- ListEvents(ctx, calendarURL, ListEventsOpts{TimeMin, TimeMax}) issues
  REPORT calendar-query with server-side <c:time-range> filter
- parseVEvents handles DATE vs DATE-TIME (via hasDateOnlyParam since
  splitLine strips ;VALUE=DATE), RRULE-present → Recurring=true with NO
  expansion (literal DTSTART only)
- 2 unit tests: full parse (DATE-TIME, all-day, recurring), hasDateOnlyParam

web dashboard:
- dashboardEvent / dashboardEventGroup types
- collectEvents fans out 4-worker pool across every caldav-list link,
  fixed 7-day window from now, sort start-asc, cap 50, group by day
- dayLabelFor: Today / Tomorrow / weekday-day-month
- Events card on /dashboard between Tasks and Issues, with empty-collapse
- 2 integration tests with stubbed CalDAV: surfaces upcoming + DATE/RRULE
  rendering; empty-collapse with no links

design.md §5 (CalDAV) + §Dashboard updated; mgmt-teardown plan's one
blocking gap is now closed.
2026-05-16 00:57:52 +02:00
mAi
1d5db0fe7b feat(phase 3j pwa): manifest + service worker + icons → installable PWA
- web/static/manifest.webmanifest: name/short_name/start_url=/dashboard/
  display=standalone/theme_color/background_color + three icons (192, 512,
  512-maskable with ~12% safe-zone padding)
- web/static/sw.js: minimal SW — install caches /static/* shell assets,
  fetch is network-first with cache fallback on GETs only, skips /mcp/
  and non-GETs entirely. CACHE_NAME versioned for clean activate-time
  prune.
- cmd/icongen: stdlib-only generator that produces the three PNG icons
  from a stylised "p" monogram. Run once at brand-change, commit output.
- web.init() registers .webmanifest → application/manifest+json with
  mime.AddExtensionType so Chrome accepts the manifest at all
- layout.tmpl + login.tmpl: manifest link, apple-touch-icon, theme-color,
  apple-mobile-web-app-* metas, inline SW-register on load (silent on
  failure — older browsers still work)
- design.md gets §"PWA install (Phase 3j)"; CLAUDE.md "Out of scope"
  drops the Phase-3j line and adds push/background-sync as the
  remaining Otto-PWA territory
- 4 new tests cover manifest MIME, sw.js delivery, all 3 icons, layout
  meta tags
2026-05-15 19:32:48 +02:00
mAi
522b7489d3 feat(phase 3i mobile): responsive CSS across all projax pages
- viewport meta on layout.tmpl + login.tmpl (iOS won't render legibly without)
- two breakpoints: tablet (≤768px), phone (≤480px)
- chip strips: horizontal-scroll with sticky labels instead of wrapping
- tables → card lists: classify + bulk render as stacked cards on mobile
- forms: single column on phone; min 44px touch targets on buttons
- dashboard: cards already 1-col, polish for narrow widths; grid jumps to
  2 columns at ≥1280px with stale card spanning both
- /graph: SVG scrolls inside .graph-canvas (max-width 100vw, max-height
  75vh, overflow auto); "fit to screen" toggle flips natural vs viewport
- TestLayoutHasViewportMeta verifies every chrome-bearing route ships the
  meta tag
- CLAUDE.md "Out of scope" drops mobile/Otto-PWA exclusion (head approved
  on m/mAi#1861); replaced with native-PWA-install line for Phase 3j
- design.md adds §"Mobile responsiveness" with breakpoint + principle notes
2026-05-15 19:27:07 +02:00
mAi
5a56ad91e5 feat(phase 3h gitea writeback): close/reopen/comment/create from projax
- gitea pkg: CloseIssue, ReopenIssue, CreateIssue, AddComment + ErrForbidden
  classification on 401/403. Client.do sets Content-Type on non-empty bodies.
- web handler: POST /i/{path}/issues/{close|reopen|comment|create}
  - authorisation guard: repo form value must match a gitea-repo item_link
    on the target item (rejects form-crafted writes to unrelated repos)
  - HTMX re-renders issues_section partial after each action
  - busts gitea per-repo cache (open + closed-recent) and dashboard 60s TTL
- templates: ✓ close button + reopen + collapsible comment box on every
  issue row; "+ new issue" disclosure per repo
- design.md §6 retitled "Phase 2.d read; 3h writeback" with auth/perm
  semantics + parked list
- 5 unit tests in gitea/, 5 integration tests in web/ covering happy paths
  + 403 → inline banner fallback
2026-05-15 19:22:11 +02:00
mAi
0c3507c6d7 feat(phase 3g dashboard polish): stale-projects card + refresh button + empty-collapse
- gitea.GetRepo returns FullName + UpdatedAt for the stale-card probe
- dashboard collectStale: mai-managed items + linked-repo updated_at >60d
  + zero open tasks + zero open issues. Sorted longest-stale first, ≤20.
  Multi-repo items need ALL repos quiet to count as stale. Reuses the
  4-worker pool + the already-aggregated task/issue counts from the
  Tasks / Issues cards (no extra DAV/Gitea fetches).
- dashboardCache.invalidate(key) busts a single filter's cache entry;
  ?refresh=1 routes through it so ↻ button gets fresh data.
- "updated Nm ago · cached/fresh" label + ↻ refresh link in dashboard
  chrome.
- Empty-card collapse: with no filter + zero rows the card renders as
  a one-line muted note instead of full chrome. Filter-active cards
  keep chrome so m can tell "filter hid it" from "nothing there".
- design.md §"Dashboard / daily-driver view" extended with the 4 new
  surfaces; the 3e "stale (3f)" out-of-scope line dropped.
- 5 new tests: stale-surface, stale-skip-recent, refresh-busts-cache,
  empty-collapse, filter-keeps-chrome. 2 unit tests for gitea.GetRepo.
2026-05-15 19:13:43 +02:00
mAi
3901a1888e feat(phase 3f graph): visual /graph view, server-rendered SVG, layered DAG
- internal/graph package: pure-Go layered top-down DAG layout
  - LayerByLongestPath (multi-parent sits at max(parent-layer)+1)
  - OrderInLayer (slug-sort, deterministic)
  - Compute returns positions + edges + canvas size
  - cycle-safe (depth-cap)
- web/graph.go handler: filter chips reused from tree_filter
  - dim mode default (opacity 0.15 on non-matches)
  - ?isolate=1 hides non-matches + prunes orphaned edges
  - ?download=svg serves raw SVG attachment
- graph_svg.tmpl renders inline SVG: border colour by management
  (mai blue / self green / external orange / mixed dashed purple),
  opacity by status, tag pills, ×N multi-parent badge, click-navigate
- nav adds "graph" link; design.md §"Graph view" documents the surface
- 4 integration tests cover render, dim, isolate, SVG download
- 6 layout unit tests cover layering, ordering, cycle-guard
2026-05-15 19:06:57 +02:00
mAi
f3e5adf358 feat(phase 3e dashboard): cross-project /dashboard with tasks, issues, recent docs
- store.RecentDocuments(since, limit) returns dated item_links + parent item
- web/dashboard.go handler aggregates VTODOs + Gitea issues + dated links
  across every linked item, fanout via 4-worker goroutine pool, 60s TTL
  cache keyed by encoded TreeFilter
- Tasks card: bucketed Overdue/Today/Tomorrow/Week/NoDue, sort by bucket
  then due asc; ✓ button completes via existing PutTodo path + busts cache
- Issues card: read-only, reuses GiteaDeps.Cache
- Recent docs card: last-30d event_date links, canonical PER rendered
- Filter chips on top reuse tree_filter URL params (tag/mgmt/has)
- nav adds "dashboard" link; design.md §"Dashboard" documents the surface
- 4 integration tests (empty render, dated-link surfacing, tag filter,
  cache hit)
2026-05-15 18:59:52 +02:00
mAi
0e490bb600 feat(phase 3d auto-tag): backfill area tags, bulk-edit UI, soft-delete cleanup
- migration 0012: one-shot populate empty tags from each item's area-roots
  (so chips on /?tag=work etc. actually filter the 40+ mai-backfilled rows)
- migration 0013: cleanup 12 orphan item_links + BEFORE-UPDATE trigger that
  cascades soft-delete to item_links going forward — closes the data drift
  that made TestItemsUnifiedSurfacesMaiPointer fail since 3c
- /admin/bulk page: flat filter+checkbox list with one-tx Apply for add/
  remove tag, set management, set status. Per-row inline chip add/remove
  via /admin/bulk/chip. Reuses tree_filter URL params 1:1.
- design.md §3.2 + §4.1 updated; tag+management section notes 0012
- bulk + tag-backfill + soft-delete-cascade tests cover the new surface
2026-05-15 18:49:58 +02:00
mAi
e055e4607e feat(phase 3c per-events): event_date on item_links, Documents UI, PER URL resolver, MCP date-aware add_link
migration 0011_item_links_event_date.sql: ADD event_date date + partial
index (idempotent). Day granularity by design per the PER spec; the
column lands NULL on every existing row, no backfill.

store:
- ItemLink gains an EventDate *time.Time (every read path scans it).
- AddLinkDated(ctx, item, refType, refID, rel, note, date, metadata)
  upserts with COALESCE(new, old) for note + event_date so partial
  callers don't clobber prior state.
- DatedLinks(item) returns event_date IS NOT NULL ordered DESC.

web:
- per.go: parsePER strips a trailing .YYMMDD (rejects invalid dates like
  Feb 30); collisionTag yields a/b/.../z/aa/ab/...; computePERs walks
  DatedLinks output and assigns render-time collision tags inside each
  date group. Tags are never stored.
- handleDetail: 404 retry with PER stripped — /i/mfin.house1.260515
  resolves to the house1 item with HighlightDate=2026-05-15.
- documents_section.tmpl: add-form (ref_type/date/ref_id/note),
  date-sorted rows with computed PER, ref-type badge, remove × with
  anti-forgery item-id check, highlight row when HighlightDate matches.
- POST /i/{path}/links/add and /links/remove handlers; HTMX swap on the
  fragment, redirect for non-HTMX callers.

mcp:
- add_link accepts event_date: "YYYY-MM-DD" (parsed strict, hands back
  fmt.Errorf on bad form). linkView.event_date surfaces it on responses.
- Existing add_link callers without event_date keep working unchanged.

docs:
- docs/standards/per.md gains an Implementation section pointing at
  item_links.event_date + ref_types + render-time collision policy.
- docs/design.md adds a Documents/dated artifacts section with the
  schema delta, conflict policy, and URL routing rules.

tests:
- per_test.go: parsePER (valid/invalid dates, non-numeric, wrong
  length); collisionTag (1..53); computePERs (bare-then-.a, skips
  undated, multi-date grouping).
2026-05-15 18:35:21 +02:00
mAi
d5e7796cf6 feat(phase 3b filtering): full tree-page filter bar (search + chips + counts + HTMX swap)
Tree page (/) gains every navigation dimension m asked for:

- Debounced search input matching title/slug/aliases/content_md/paths
  case-insensitively (?q=…)
- Tag chip row (?tag=a,b — AND within tags, as before)
- Management chip row with ?mgmt=mai,self,external,unmanaged
  (OR within management; "unmanaged" is the synthetic empty-array case)
- Status chip row with ?status=active,done,archived (default = active;
  archived rows only surface when the separate show-archived toggle is on)
- Has-link chip row ?has=caldav-list,gitea-repo
- Each chip carries the count it would yield if toggled — honest user
  cue, computed via per-dimension recomputation in pure Go (cheap at
  m's scale)
- URL is the source of truth — every filter goes through the query
  string, so any view is bookmarkable; HTMX swaps the tree-section in
  place with hx-push-url=true on every chip click and on search keyup
- Empty-state copy with a clear-all link

Implementation:

- web/tree_filter.go new: TreeFilter struct + ParseTreeFilter +
  QueryString/URL + Toggle* helpers + Matches + applyTreeFilter
  (replacement for buildForest) + computeChipCounts.
- web/tree_filter_test.go: parse defaults + every dimension's match +
  URL round-trip + ancestor-keep semantics + chip counting.
- Linkages: linkKindsByItem on Server fans across the two has-link
  ref_types in one pass and feeds the filter.
- tree.tmpl reduced to a one-liner that calls tree-section; new
  tree_section partial powers both the initial page render and HTMX
  fragment swaps (matches the pattern from phases 2.a/b/d).

docs/design.md §4: tree-filter contract — URL keys, AND/OR rules,
count semantics, archived ergonomics.
2026-05-15 18:21:26 +02:00
mAi
1ffbfc6e69 feat(phase 2.d gitea): read-only issue ingest on items with gitea-repo links
gitea package (new): minimal client mirroring caldav's structure
- client.go: token auth, 5s timeout, ErrNotFound
- issues.go: ListIssues(owner, repo, opts) hitting
  /repos/{o}/{r}/issues?type=issues&state=…&since=…, ParseRepoRef,
  RepoHTMLURL. PullRequest-flagged rows dropped server- and client-side.
- httptest stubs covering parse, 404, ParseRepoRef variants.

web wiring:
- Server.Gitea optional GiteaDeps (Client + in-memory 3-min TTL cache
  keyed by owner/repo|state).
- detailIssues iterates every gitea-repo link, sums open issues, captures
  last-30d closed (≤20) into a disclosure. Per-repo failures surface as
  banner; one missing repo never blanks the section.
- relativeTime renders "Nm/h/d ago" / "yesterday" / fallback date.

Templates:
- issues_section.tmpl: per-repo block, header "Issues (n) + ↗ Gitea repo",
  rows with #N · title · labels · milestone · assignees · updated.
  Titles open in new tab.
- detail.tmpl: include the partial when Gitea is on and issues != nil.
- CSS: matches the Tasks section visual language.

main.go: GITEA_URL gates the integration (off when unset). GITEA_URL set
but GITEA_TOKEN missing → refuse to start.

deploy/dokploy.yaml: GITEA_URL env + GITEA_TOKEN secret added.

docs/design.md: new §6 mirroring §5's structure (link model, listing
semantics, caching, env contract, parked items).
2026-05-15 17:27:01 +02:00
mAi
83c965f111 feat(phase 2.b caldav): full read/write VTODO writeback from projax
caldav package:
- Todo carries URL, ETag, Raw so ListTodos rows can be PUT/DELETEd in place
- BuildVTodoICS for new VTODOs, ApplyVTodoEdit for in-place edits that
  preserve unknown properties (DESCRIPTION, CATEGORIES, X-*)
- PutTodo/DeleteTodo with If-Match optimistic concurrency
- ErrPreconditionFailed/ErrNotFound for 412/404
- RFC 5545 fold-at-75 + CRLF + text escape, hand-rolled UUID v4
- httptest round-trip (create -> list -> complete -> delete) plus 412 path

web:
- POST /i/{path}/caldav/todo/{complete,reopen,edit,delete,todo-create}
- Re-fetches the live ETag before each PUT/DELETE so ordinary use never
  trips 412; on actual 412 the section reloads with a banner
- Calendar URL must already be linked to the item (anti-forgery guard)
- tasks_section partial drives both the initial page render and HTMX
  swaps; detail.tmpl reduces to a one-liner template call

docs/design.md §5: rewrite for full read/write semantics + ETag concurrency.
2026-05-15 17:16:38 +02:00
mAi
41c1eaadaa feat(phase 1.5): tags + management + DAG + mai.projects sync
Big task. Five migrations, full store + web rewrite, and a model upgrade
that turns the parent_id tree into a parent_ids[] DAG.

Schema (db/migrations)
- 0006_tags_management_unify: adds tags + management text[] (GIN-indexed),
  collapses the area/project distinction (kind keeps the slot but 'area'
  is no longer a special value), drops the structural rules from the
  path trigger so root projects + non-root projects are both legal.
- 0007_backfill_mai_projects: one-shot, idempotent — for every row in
  mai.projects without a 'mai-project' item_link, create a projax.items
  row under a heuristic-chosen area (mhealth→health, msports/manjin→
  sports, kanzlai/hlckm/work/mworkrepo/paliad or HL/* repo→work,
  mhome→home, default→dev), insert the item_link, and tag the row
  management=['mai']. Also flips management='mai' on any already-linked
  pre-Phase-1.5 promotions.
- 0008_mai_projects_sync: bidirectional triggers. sync_to_mai runs as
  projax_admin and writes mai.projects directly (after the operator-run
  grant + RLS policy widening — documented in the migration header).
  sync_from_mai is SECURITY DEFINER so writes by the mai role fan out
  into projax.items. pg_trigger_depth() + projax.in_sync GUC keep the
  cycle suppressed. Slug stays the join key for new rows; the
  item_link pointer survives renames.
- 0009_items_unified_simplify: view collapses to a thin projection over
  projax.items now that mai.projects is a derived projection.
- 0010_multi_parent: parent_id → parent_ids uuid[], path → paths text[].
  compute_item_paths walks via parents' precomputed paths (no recursive
  CTE in the hot path; cycle detection uses one). New triggers:
  items_check_slug_collision (multi-parent uniqueness),
  items_after_delete (manual cascade since arrays don't carry FK).
  Trigger refresh_item_paths_recursive does parent-first DFS over
  descendants, guarded by projax.refreshing_paths GUC.

Go store + handlers
- Item gains ParentIDs []string + Paths []string. PrimaryPath /
  OtherPaths helpers feed the detail breadcrumb. Source always
  'projax' now; SourceRefDeref still surfaces the mai-id pointer.
- Update / Reparent / Create take ParentIDs []string. AddParent helper
  for the multi-parent UI's "also list under" action.
- GetByPath uses '$1 = any(paths)' so /i/work.paliad and /i/dev.paliad
  resolve to the same row.
- buildForest renders a multi-parent item under each of its parents
  (duplicated nodes in distinct branches). Tag-filter prune is
  branch-preserving.

Templates
- detail.tmpl: multi-select parents, tags + management chip inputs,
  "Also at: …" breadcrumb for multi-parent items.
- new.tmpl: same multi-select + chip inputs.
- tree.tmpl: tag-filter chip bar, "×N" badge on multi-parent rows,
  management chips visible on every row.
- classify.tmpl: re-parent workflow (no more promote-to-projax — the
  bidirectional sync removed the dichotomy).

Tests (DB + HTTP, all skip without env)
- TestMultiParentResolvesBothPaths   inserts an item with two parents,
                                     asserts both inherited paths.
- TestSlugCollisionUnderCommonParent  refuses a sibling clash.
- TestMultiParentBothPathsRouteToSameRow  HTTP-level: /i/dev.X and
                                          /i/work.X both 200, same row.
- TestReparentRoundTrip rewritten for parent_ids[] semantics.
- TestPathTriggerNestAndRename / Reparent rewritten to query paths[].

Docs (docs/design.md)
- §2 rewritten: items in a DAG, no area/project distinction.
- §3 schema: parent_ids + paths + tags + management + indices.
- §3.1 path-trigger overhaul incl. cycle detection via recursive CTE
  and slug-collision-under-common-parent guard.
- §3.2 view simplified.
- §3.4 NEW: mai.projects bidirectional sync, including the manual
  prereq.
- §4.1 + §4.2: classify becomes re-parent, tags+management UI section.

mai head start / mai hire / mai status / mai instruct keep working
because mai.projects retains its FK-target shape; the projax sync just
mirrors the row in lock-step.
2026-05-15 16:33:52 +02:00
mAi
360060b152 feat(auth): rip federation, give projax its own /login
mgmt.msbls.de is being retired; depending on it for auth was the wrong
direction. Match the mBrian / flexsiebels pattern instead — same
Supabase backend, but every tool runs its own login page and scopes
cookies to its own host.

Routes
- GET  /login   render a sign-in form (mBrian dark visual). If the
                request already has a valid session, jump to a safe
                redirectTo (or /).
- POST /login   exchange email+password at /auth/v1/token?grant_type=
                password, set cookies, 302 → redirectTo or /. On
                Supabase 4xx, re-render the form with the error.
- POST /logout  clear both cookies (Max-Age=-1) + 302 → /login.

Cookies
- access_token + refresh_token only. No Domain attribute → scope is
  projax.msbls.de exclusively. HttpOnly, Secure, SameSite=Lax, Path=/,
  Max-Age=1y. Matches mBrian + flexsiebels per-host pattern.

Middleware
- /healthz, /login, /logout always pass through (otherwise infinite
  redirect on the probe / login page).
- On invalid/expired session → 302 /login?redirectTo=<safe-path>,
  RELATIVE to projax. No more cross-host bounce.
- Cookie refresh on expiry still rotates both cookies in place.
- Bearer header path kept for scripted clients.

safeRedirect
- Path-only. Rejects "", "//*", "https://*", "\*", control-char
  injection. Cross-host or scheme bounces fall back to "/". Tested
  against the obvious bypasses.

Cleanup
- Drop PROJAX_LOGIN_URL + PROJAX_COOKIE_DOMAIN env vars (unused now).
- main.go: log "auth: own-login enabled" with the supabase URL on
  startup; warn loudly when SUPABASE_URL is unset.
- README trust-model section rewritten: own login, per-host cookies,
  same backend.
- layout.tmpl gains a "sign out" form-button in the nav so the tree /
  detail / classify pages can log out without curl.

Tests (14, no DB needed): stub Supabase via httptest covers
healthz/login/logout exemption, anonymous→/login redirect, valid
cookie + Bearer pass-through, stale-refresh rotation with NO Domain
attribute, hard-fail redirect, GET form render with redirectTo carry,
already-signed-in short-circuit, POST success with correct cookies,
POST bad-creds error surface, redirectTo safety (path-only, no //,
no absolute URLs), logout cookie clearance.

Full suite (incl. DB-backed): 27/27 green with PROJAX_SKIP_MIGRATE=1.
2026-05-15 15:16:55 +02:00
mAi
9f905de461 feat: Go HTTP server with tree / detail / new / classify
cmd/projax/main.go boots a pgxpool against PROJAX_DB_URL (falls back to
SUPABASE_DATABASE_URL), auto-applies embedded migrations on start
(disable with PROJAX_AUTO_MIGRATE=off), and serves on PROJAX_LISTEN_ADDR
(default :8080).

store package wraps the unified view + projax.items writes. Item has
helper methods for templates: IsArea, Editable, SourceRefDeref. The
Promote() flow runs the insert + item_links link inside a single
transaction so the source row drops out of items_unified atomically.

web package: per-page html/template instances parsed against a shared
layout.tmpl, embedded static/style.css, HTMX from CDN. Pages:
  GET  /                   tree of items_unified
  GET  /i/{path}           detail (editable for projax, read-only +
                           promote form for mai.projects)
  POST /i/{path}           update projax-native item
  POST /i/{path}/promote   one-page promote (HTMX-aware fragment for
                           inline classify)
  GET  /new?parent={path}  create form
  POST /new                create projax-native item
  GET  /admin/classify     orphan list with inline HTMX promote
  GET  /healthz            DB ping
  GET  /static/*           embedded assets

Auth is intentionally out of scope for v1 — service binds to whatever
PROJAX_LISTEN_ADDR points at, deploy guidance pins it to the Tailscale
interface (covered in 1d README).

Tests (skip when DB env is unset):
  TestTreeRenders, TestHealthz,
  TestDetailProjaxNativeEditable, TestDetailMaiProjectsReadOnly,
  TestClassifyListsOrphans, TestPromoteRoundTrip.
2026-05-15 13:24:44 +02:00