Commit Graph

44 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
252b424d2c Merge commit '2925c43' (phase 5h slice 4: pin toggle on tiles + handleDashboardPin) 2026-05-26 12:33:26 +02:00
mAi
2925c43a1e feat(dashboard): pin toggle on tiles + handleDashboardPin handler
Phase 5h slice 4 — adds the star button on each tile that flips
Pinned on the projax item via POST /dashboard/pin.

Backend:
- store.SetPinned(ids, pinned bool) — minimal-write helper that
  mirrors SetPublic, only touching the pinned column.
- web/dashboard_pin.go — handleDashboardPin parses id + pin from
  form, calls SetPinned, invalidates the entire dashboard cache (pin
  affects sort order across every view/scope/filter combo), then
  re-renders by delegating to handleDashboard so HTMX receives the
  updated #dashboard-section HTML.
- Route: POST /dashboard/pin (sibling of /dashboard/task/*).

Frontend:
- Tile template now leads with a <form class="tile-pin-form"> that
  POSTs id + the inverted pin state. Button glyph is ☆ when unpinned,
  ★ when pinned; aria-label flips accordingly.
- HTMX swaps the entire #dashboard-section so the tile moves to the
  pinned-first position (or back to alphabetical) without a full reload.
- CSS: .tile-pin (transparent button, muted color, accent on hover);
  .tile-pin.pinned for the filled-star state.

Test helper: server_test.go gains a post() helper paired with the
existing get() — form-encoded POSTs for writeback tests.

Tests (dashboard_pin_test.go):
- TestDashboardPinTogglesItem — POST pin=true flips the row, and the
  re-render shows the .tile-pinned class on the tile <article>.
- TestDashboardPinUnpinsItem — POST pin=false on a pinned row unpins.
- TestDashboardPinRequiresID — missing id returns 400.
- TestDashboardPinInvalidatesCache — primes with unpinned cache,
  POSTs pin, asserts the next GET reflects the pinned class (proving
  the prior cache entry was busted).
2026-05-26 12:31:24 +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
316b4e408a feat(dashboard): tab strip + Tiles view + view-switcher URL routing
Phase 5h slice 2 — adds the three-tab dashboard chrome (Tiles / Tasks /
Events) and lands the Tiles view as the default landing surface per
m's §7 pick.

URL contract:
  /dashboard                — Tiles (default, elided)
  /dashboard?view=tasks     — today's 5-card layout
  /dashboard?view=events    — Events card promoted to a full-tab view
  Unknown ?view= falls back to Tiles.

Refactor: aggregator calls (Todos / Events / Issues) hoisted up into
buildDashboard so the rollup can consume the same uncapped rows without
a second DAV/Gitea round-trip. The legacy collect* helpers split into
pure projectTasks / projectEvents / projectIssues / projectDocs that
take pre-fetched rows. collectStale extended to return its per-item
repo-activity map alongside the trimmed stale list — the rollup uses
the map as a LastActivity signal.

Cache: key now composes (filter | view=X) so each tab has its own 60s
TTL slot. Tab switches don't poison the cache for siblings.

Tiles render with: pin star (when pinned), title + path + live badge,
counts row (open / overdue! / issues / quiet), NextSignal one-liner
(task wins over issue), and a tile-foot LastActivity stamp.

CSS:
- .dash-tabs strip with active-state border bridge.
- .dash-tiles grid: 1/2/3 cols at 600/900px breakpoints.
- .dash-events-view scaffolding for the promoted Events surface.

Templates: dashboard_section.tmpl restructured to dispatch by .View.
The cards layout is now {{define "dashboard-cards"}} and the
events-only surface is {{define "dashboard-events-view"}}. New
dashboard_tiles.tmpl defines {{define "dashboard-tiles"}}. Both
templates registered in the dashboard + dashboard_section bundles.

Tests:
- Existing dashboard tests retargeted at ?view=tasks for the legacy
  Tasks-tab expectations (5-card layout, inline writeback, stale card).
- New dashboard_view_test.go covers: default view = Tiles, three-tab
  strip rendering + active marker, view=tasks fallback, view=events
  promotion, unknown view fallback, tile rendering for seeded item,
  cache-key separation between views.
- TestLayoutNoTopHeader scoped to the body chrome before <main> so it
  no longer trips on legitimate <header> elements inside cards/tiles.

Out of scope (later slices): scope chip + Quiet fold (slice 3), pin
toggle handler (slice 4), Events tab dedicated polish (slice 5),
mobile polish (slice 7), design.md addendum (slice 8).
2026-05-26 12:22:32 +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
mAi
9d0dd74695 feat(layout): desktop sidebar replaces top-nav
Phase 5g slice A. m wants projax aligned with mBrian's nav layout: fixed-
left sidebar on desktop, bottom-nav on mobile (slice B). This slice drops
the top-nav <header> and ships the desktop sidebar; the ≤767px viewport
temporarily renders nav-less until slice B lands the bottom-nav.

web/templates/layout.tmpl:
- Delete the old <header><nav>...</nav></header>. Replace with
  <aside class="projax-sidebar"> carrying:
    * .sidebar-top: brand (▦ + "projax")
    * .sidebar-nav: 6 items (Tree → Dashboard → Calendar → Timeline →
      Graph → Admin) with inline SVG icons. Active class set server-side
      via `{{if eq $path "/dashboard"}}active{{end}}`.
    * .sidebar-bottom: theme toggle + sign-out form + collapse toggle.
- Content wrapped in <main class="projax-main">.
- New pre-paint <script> in <head> reads
  localStorage["projax.sidebar.collapsed"] and sets
  data-sidebar-collapsed="true" on <html> BEFORE first paint so the
  main-content margin doesn't flash 220px→56px on every navigation.
- Existing theme-toggle JS unchanged (the button is just relocated). New
  body-end <script> wires the #sidebar-collapse button: toggle the
  attribute, persist to localStorage, sync aria-expanded + title.
- DO NOT port mBrian's resize handle — that's the $effect-feedback bug
  mBrian debugged at length. Static 220/56px is fine for v1.

web/static/style.css:
- Strip the pre-5g `header { ... }`, `header nav { ... }`,
  `header .logout-form { ... }`, `header .brand { ... }`,
  `header .theme-toggle { ... }` rules and the matching @media
  overrides (320×, 480× targeted `header`).
- New `main.projax-main` rule: `margin-left: var(--projax-sidebar-width,
  220px)` on desktop, transitions on collapse. The
  `html[data-sidebar-collapsed="true"]` selector flips the var to 56px.
  Mobile (≤767px) zeros the margin.
- New `.projax-sidebar` block: fixed-left, z-index 50, .nav-item /
  .nav-icon / .nav-label rules, .active border-left accent (matches
  mBrian's `border-left: 2px solid #8cf` pattern but uses var(--accent)
  so it round-trips dark/light theme).
- @media (max-width: 767px) hides the sidebar so the phone isn't stuck
  with a 220px-wide hole until slice B.

web/server.go:
- render() injects `Path: r.URL.Path` into the template data map (unless
  caller pre-set it for tests) so the layout can mark the active nav
  item without any per-handler boilerplate.

Tests (web/layout_test.go):
- TestLayoutSidebarOnDesktop: aside present, all six href + label pairs
  rendered.
- TestLayoutActiveClass: /dashboard render has the Dashboard item with
  .active and Tree without.
- TestLayoutCollapseScript: pre-paint localStorage restore + the
  collapse-toggle handler both present.
- TestLayoutNoTopHeader: belt-and-braces — the pre-5g <header> and
  .logout-btn classes are gone.

All existing tests stay green (TestLayoutHasAdminNavLink,
TestLayoutHasManifestAndAppleTouchIcon, TestLayoutHasViewportMeta,
TestCalendar*, TestTreeRenders, etc.). No test source edits required —
existing assertions look at page CONTENT, not chrome.
2026-05-25 16:36:10 +02:00
mAi
28ac919e01 feat(calendar): polish grid styling + mobile breakpoint + design doc
Phase 5e slice B. Polish pass on the month grid: HTMX-swappable filter
chip strip, mobile breakpoint that collapses the 7-column table into a
vertical list of days, refined CSS for hover/today/adjacent-month, and
the docs/design.md §17 entry that pins the contract.

Templates:
- web/templates/calendar_section.tmpl (new) — extracted #calendar-section
  partial. Houses the filter chip strip (form with hx-get=/calendar
  hx-target=#calendar-section), counts line, and the grid <table>.
- web/templates/calendar.tmpl trimmed to the page chrome (h1, prev/next
  nav, today link) + {{template "calendar-section" .}}. Chrome stays
  outside the HTMX swap because chip filtering preserves the month
  context.

web/calendar.go:
- handleCalendar now branches on HX-Request: HTMX → calendar_section
  fragment, full GET → calendar (chrome + section). Same pattern as
  /timeline and /dashboard.
- calendarDay gains LongLabel ("Mi., 14. Mai") — populated by new
  formatCalendarLongLabel helper. Hidden on desktop via CSS; revealed at
  the ≤480px breakpoint where the column header drops out.

web/server.go:
- Calendar template now bundles the section partial. New calendar_section
  template registered as a standalone fragment for HTMX swaps. New
  render() entry case "calendar_section" → "calendar-section".

web/static/style.css:
- Refined .calendar-nav (tabular numerals, transition, no surface-alt
  fallback fighting the theme).
- New #calendar-filterbar layout (flex, gap, counts pushed right).
- .calendar-cell hover background, adjacent-month opacity bump (0.4→0.45
  + 0.7 on hover so it doesn't disappear when reading lead-in days).
- .today-pill line-height fix so it sits flush in the cell header.
- .cell-row min-width on .time slot, tighter line-height, 0.82em font.
- @media (max-width: 480px) breakpoint: grid + thead + tbody + tr + th +
  td all → display:block. Thead hidden; .day-label revealed. Adjacent-
  month cells DISPLAY:NONE on mobile (their value on desktop is grid
  rectangularity; on a vertical list they're just confusing). Cell rows
  bump to 0.95em for readability.

docs/design.md:
- New §17 Calendar view (Phase 5e). Documents sources (VEVENT/VTODO/
  dated item_links), what's excluded (creation markers + Gitea + untimed),
  the layout calculation, filter integration via TreeFilter, cache key,
  the mobile breakpoint, and the German register choice.

Tests (additive, all passing):
- TestFormatCalendarLongLabel — pins the German weekday + day + month
  abbreviation (Mo./Di./.../So., 1.–31., Jan/Feb/März/.../Dez).
- TestCalendarFilterChipStripRenders — chip strip present + hx-target +
  hx-get + hidden month input + tag/mgmt/kind multi-selects.
- TestCalendarHTMXReturnsSectionOnly — HX-Request returns #calendar-
  section only (no <body>, no .calendar-nav chrome).
- TestCalendarCellCarriesLongLabel — May 4 cell ("Mo., 4. Mai") present
  in HTML so the mobile breakpoint CSS reveal works.

Net: +315 / -61.
2026-05-22 12:07:25 +02:00
mAi
e5dd31144a feat(calendar): /calendar month-grid view with VEVENT/VTODO/DOC sources
Phase 5e slice A. New surface alongside /timeline (chronological spine) and
/dashboard (today/week buckets) — a 7×N month grid that answers "show me my
month at a glance." Monday-leading weeks per the German convention, with
adjacent-month lead-in/trail-out cells greyed to keep the grid rectangular.

web/calendar.go (new):
- calendarPayload / calendarWeek / calendarDay / calendarRow types.
- parseCalendarQuery: reads ?month=YYYY-MM (defaults to current month),
  ?kind=event,todo,doc (defaults to all three; creation excluded by design),
  inherits the full TreeFilter via ParseTreeFilter so ?tag=work / ?mgmt=mai
  scope identically to /timeline.
- handleCalendar: TTL-cached at 60s per (filter, month, kinds).
- buildCalendar: items → TreeFilter narrow → aggregate.{Todos,Events,Docs}
  for the grid window → bin by YYYY-MM-DD → stable per-cell sort (timed
  first, then by kind rank, then summary).
- layoutCalendarWeeks: pure function building the rectangular grid; lead
  days computed from mondayWeekday(monthStart), trailing pad from
  (totalCells % 7). Each cell caps visible rows at 3 and surfaces the
  remainder via ExtraCount so the template emits a "+N more" drill-down
  link to /timeline scoped to that single day.
- formatMonthLabel: German month names (Mai, März, Juni, Dezember).
- docSummary: prefers item_link.note, falls back to last path segment of
  ref_id, then ref_id verbatim.

web/templates/calendar.tmpl (new):
- Grid markup as a <table role="grid"> — semantically a calendar grid,
  works without JS, and the layout calc already pre-chunks weeks.
- Header carries h1 (German month label), prev/next/today nav, and the
  cached/fresh + total-rows counts line.
- Each cell: .calendar-cell, .is-today, .adjacent-month conditional
  classes; .today-pill rendered when IsToday.
- Rows: .row-event / .row-todo (+ .overdue) / .row-doc with a leading
  time slot and an <a> to /i/<itemPath>.
- "+N more" link drills into /timeline?from=YYYY-MM-DD&to=YYYY-MM-DD.

web/static/style.css:
- ~95 lines of minimal grid styling: 7-column table-fixed, 110px cell
  height, today border accent, adjacent-month opacity 0.4, per-kind row
  border-left colour. Slice B will refine cell sizing + add the mobile
  breakpoint + chip strip.

web/server.go:
- New calendar template parse (layout.tmpl + calendar.tmpl), calendar
  field on Server (cache.TTLCache[*calendarPayload]), route registration
  GET /calendar.

web/templates/layout.tmpl:
- Nav anchor added between timeline and graph.

web/server_test.go:
- TestLayoutHasViewportMeta now probes /calendar too.

Tests (web/calendar_test.go — pure unit):
- TestCalendarLayoutMondayLead, TestCalendarLayoutTrailingPad: grid math
  for Friday-leading (May 2026) and Monday-trailing (June 2026) months.
- TestCalendarTodayCell: IsToday flag lands on the right cell only.
- TestCalendarCellRowOverflow: >3 seeded rows → 3 visible + ExtraCount=2.
- TestMondayWeekday: Sunday→6, Monday→0 conversion.
- TestFormatMonthLabel: German month strings.
- TestParseCalendarQuery{Defaults,MonthParam,KindFilter}: URL parsing.

Tests (web/calendar_integration_test.go — DB integration):
- TestCalendarRendersMonthGrid: empty-data smoke through srv.Routes().
- TestCalendarSurfacesDatedLink: seeds an item_link on today, asserts
  the rendered cell carries the note text + .is-today class.
- TestCalendarFilterScopeByTag: seeds two tagged items, confirms
  ?tag=<work-tag> only renders the work-item rows.
- TestCalendarAdjacentMonthDays: May 2026 (Fri-leading) renders the
  Apr 27 lead-in cell with .adjacent-month.
- TestCalendarNavPrevNextLinks: prev → 2026-04, next → 2026-06 links
  present.

Slice B follows: refined CSS, mobile breakpoint (≤480px → vertical list
of days), HTMX filter chip strip, docs/design.md §17.
2026-05-22 12:01:03 +02:00
mAi
9ee26002f8 refactor(web): validate item writes via internal/itemwrite/
Phase 5c slice B. Three web write paths now pre-validate via the
itemwrite package before calling store.Create / Update / Reparent.

- handleDetailWrite: ValidateFormat + ValidateAgainstStore on (title,
  slug, status, parent_ids) before the store.Update call.
- handleNewSubmit: same pair, scoped to a new item (no ID yet).
- handleReparent: format + DB-aware checks; validator catches
  self-parent, unknown-parent, cycle. The existing
  "parent_ids required" guard stays as a separate fast-fail.
- handleBulkApply: set_status pre-flight against the validator. Other
  bulk actions (add_tag / set_mgmt / set_public / timeline_todos)
  don't mutate validated fields so they pass through unchanged.

On ValidationError the handler responds 400 + a human banner keyed on
err.Kind via the new s.itemWriteFailure helper. itemWriteBannerCopy
centralises the Kind→copy mapping so web/server.go and web/bulk.go
share one phrasing.

No web test source touched — all web/*_test.go assert on observable
behaviour (HTTP status, response body) and the new validator path
preserves both for valid AND invalid inputs the SQL trigger would
have rejected anyway. Tests stay green unmodified.

Task: t-projax-5c-itemwrite
2026-05-22 00:36:14 +02:00
mAi
d518978edb refactor(timeline): cache via internal/cache.TTLCache
Phase 5b slice C. Mirror of slice B for the timeline cache:
timelineCache + cachedTimeline + newTimelineCache deleted. The Server's
timeline field is now `*cache.TTLCache[*TimelinePayload]` constructed
via `cache.NewTTL[*TimelinePayload](timelineCacheTTL)`. Call sites
across web/{timeline,caldav,dashboard,links}.go renamed:

- s.timeline.get(k)        → s.timeline.Get(k)
- s.timeline.set(k, p)     → s.timeline.Set(k, p)
- s.timeline.invalidateAll → s.timeline.InvalidateAll
- (timeline never used keyed invalidate, so no .Invalidate rename)

Removes the unused `sync` import from web/timeline.go. The 50-line
timelineCache struct + four methods are gone; the file shrinks by
~50 lines.

All web/timeline_*test.go pass unmodified.

Task: t-projax-5b-cache
2026-05-22 00:27:08 +02:00
mAi
085e672dd5 refactor(dashboard): cache via internal/cache.TTLCache
Phase 5b slice B. dashboardCache deleted. The Server's dashboard field
is now `*cache.TTLCache[*dashboardPayload]` constructed via
`cache.NewTTL[*dashboardPayload](dashboardCacheTTL)`. All call sites
renamed:

- s.dashboard.get(k)         → s.dashboard.Get(k)
- s.dashboard.set(k, p)      → s.dashboard.Set(k, p)
- s.dashboard.invalidate(k)  → s.dashboard.Invalidate(k)
- s.dashboard.invalidateAll  → s.dashboard.InvalidateAll
  (across web/dashboard.go, web/server.go, web/caldav.go,
   web/links.go, web/gitea_writeback.go)

The 64-line dashboardCache struct + methods are gone; the dashboard
file shrinks by ~63 lines. TTL constant lifted out to
`dashboardCacheTTL = 60 * time.Second` so the const lives next to its
semantics rather than a magic-number literal in New().

All web/dashboard_*test.go pass unmodified.

Task: t-projax-5b-cache
2026-05-22 00:25:13 +02:00
mAi
4e919babed refactor(timeline): consume internal/aggregate/
Phase 5a slice B. Replace web/timeline.go's hand-rolled fan-out + day
grouping with calls into the aggregator package.

- web/timeline.go: collectTimelineTodos + collectTimelineEvents +
  in-line day grouping deleted. buildTimeline now calls
  aggregator.Todos/Events/Docs/Creations, decorates each typed row
  with the template-friendly TimelineRow shape (PER, StartLabel,
  DurationHint), then hands rows to aggregate.BuildTimelineDays for
  sorting + sticky-pill markers + far-future fade.
- web/timeline.go: TimelineRow / TimelineDay are now type aliases for
  the aggregate package's versions (Phase 5a slice A introduced them
  with the same flat-field layout the templates already address).
- web/server.go: new Server.Aggregator() factory builds a fresh
  *aggregate.Aggregator wired to the server's current CalDAV/Gitea
  deps (so main.go can install those after web.New without a re-init
  hook).
- web/{gitea,dashboard,gitea_writeback,gitea_test}.go: issueCache
  methods capitalised (Get/Set/Invalidate) so the aggregator's
  IssueCache interface accepts *web.issueCache directly. No behaviour
  change.

All web/timeline_*test.go pass unmodified — the refactor preserves
output shape and template field paths.

Task: t-projax-5a-aggregator
2026-05-22 00:05:14 +02:00
mAi
0bea9c1ba4 feat(phase 4f): per-item timeline_exclude flag (hide noise from /timeline)
m's stated use case: home VTODOs (shopping list) shouldn't pollute the
chronological /timeline by default, but they should stay visible on the
home detail page itself. This adds an item-level switch with four kinds
and a URL override to peek at everything when wanted.

## Schema (migration 0015)
- timeline_exclude text[] NOT NULL DEFAULT '{}'
- items_timeline_exclude_idx GIN
- items_unified view rebuilt to surface the new column
- Behaviour-neutral: empty array = unchanged from today. m flips the
  toggle himself via /admin/bulk or the detail-page form.

## Aggregation
- web/timeline.go: pre-compute the per-kind keep-list via keepFor(kind)
  before fanning out — items with the kind in their exclude array are
  dropped entirely (no CalDAV call wasted on excluded sources). Doc and
  creation rows check the per-item flag inline. `?include_excluded=1`
  (URL) and `include_excluded:true` (MCP arg) override the filter.
- store.Item.ExcludesTimelineKind(kind) helper accepts either singular
  ("todo") or plural ("todos") to bridge the kind-constant / persisted-
  value naming choice — see comment for the why.

## UI
- /i/{path} grows a "Timeline behaviour" collapsible section with four
  checkboxes (todos / events / docs / creation) and helper text. Open by
  default when any kind is excluded, so m can see at a glance what's
  hidden for this item.
- /admin/bulk gains a "timeline todos" select with "Exclude from timeline"
  and "Re-include on timeline" — the other three kinds stay editable
  per-item only per the task brief (most common use case is just todos).

## MCP
- update_item accepts timeline_exclude as a partial-update field with an
  enum-restricted whitelist; unknown values dropped silently.
- itemView always emits timeline_exclude (defaults to []) so consumers
  can render the toggle state without a second round-trip.

## Tests
- Migration + GIN index landed
- Item with timeline_exclude=['todos'] hides the VTODO from /timeline
- ?include_excluded=1 brings it back
- Bulk action toggles the array idempotently in both directions
- Detail page renders all 4 checkbox affordances

## docs/design.md
§12 gains a "Per-item exclusion" subsection documenting semantics, the
URL override, the bulk action, and the "detail page still shows everything"
invariant.

## Out of scope (per task brief)
- Per-tag exclusion (per-item is clearer)
- Per-day exclusion (overkill)
- Dashboard exclusion (m only flagged timeline; dashboard's "today" view
  should still show shopping today if it's due today)
- Auto-seeding home with timeline_exclude=['todos'] (m runs once himself
  via /admin/bulk after the deploy — schema change stays behaviour-neutral)
2026-05-17 19:28:49 +02:00
mAi
f6cf050c3f feat(phase 4d): public-listing fields so projax becomes the portfolio source of truth
Adds five additive columns on projax.items and propagates them through
every read/write path. flexsiebels.de (and any future portfolio renderer)
can now pull the public set via the MCP `list_items(public=true)` filter
and stop hard-coding project lists.

## Schema (migration 0014)
- public               boolean       default false (partial index when true)
- public_description   text          default ''
- public_live_url      text          default ''
- public_source_url    text          default ''
- public_screenshots   text[]        default '{}'
- items_unified view rebuilt to include the five new columns
- items_public_idx     PARTIAL INDEX WHERE public = true (5% of rows)

## Store
- Item struct + scan/scanItems extended (5 cols)
- UpdateInput accepts the new fields with full-replace semantics
- new SetPublic(ids, bool) for bulk write
- SearchFilters gains Public *bool — nil = no filter

## MCP
- list_items: new `public` boolean filter (input schema + handler)
- update_item: 5 new partial-update fields (nil pointer = leave alone)
- itemView always emits the 5 fields (even when public=false) so consumers
  can preview "what would publish" without a second round-trip
- 2 new integration tests against the DB

## Web
- /i/{path} grows a "Public listing" fieldset: toggle + textarea + 2 URL
  inputs + screenshot list editor with add/remove rows + inline JS for
  the editor. Values persist when public is off so toggling never
  destroys typed-in content.
- /admin/bulk action bar gains "Make public" / "Make private" via a new
  select; SQL update is a single statement per action.
- /?public=1 and /?public=0 chip parameters narrow the tree page.
  Active() + QueryString() + TogglePublic() round-trip the state.
- parseScreenshotList helper trims + drops empties + preserves order
- 5 integration tests: migration landed, form round-trip, bulk action
  round-trip, detail-page affordances, tree-filter narrowing

## docs/design.md §15
Documents the schema, MCP contract, UI surfaces, flexsiebels consumption
pattern, and what's NOT in scope (flexsiebels-side render, asset hosting,
approval workflows).

## Out of scope (per task brief)
- Flexsiebels rendering — separate task in m/flexsiebels.de after this ships
- Asset hosting (projax stores URLs, never bytes — same PER discipline)
- Multi-stage publish workflow (boolean is enough)
2026-05-17 19:11:26 +02:00
mAi
5dcacff520 feat(phase 4b): dark/light theme toggle + file-upload permanently out-of-scope
## Slice A — explicit dark/light toggle

projax now ships with two palettes and a 1y cookie to remember the choice.
Dark is the new default; ☀ button in the header nav flips to light and
writes projax_theme=light. Server reads the cookie via themeFromRequest(r)
and injects Theme + ThemeColor into every template via the centralised
render(w, r, …) path, so first paint never flashes the wrong theme. Inline
JS in layout.tmpl handles the toggle without a server roundtrip.

Every panel colour now lives in a CSS variable under
:root[data-theme=dark|light]; the only hardcoded hex values left are
inside those two :root blocks. A future palette tweak is one edit, not
30 selectors. Graph node colours, kind-badges, highlights and warn/ok/bad
all have parallel dark/light values picked for contrast.

Standalone SVG download bakes the light palette inline because the
downloaded asset has no parent :root providing vars — m's existing
snapshots stay print-friendly regardless of his current cookie.

Login page keeps its embedded dark CSS — it's the gateway, intentionally
always dark.

Tests: TestThemeDefaultIsDark, TestThemeCookieRoundTrips,
TestThemeCookieUnknownFallsBackToDark, TestThemeTogglePagesShareSameTheme,
TestThemeToggleScriptPresent, TestThemeColorMetaHelper. Full suite green.

## Slice B — file-upload permanently out of scope (m, 2026-05-17)

docs/design.md moves "File uploads / in-projax storage" from the §3c
parked list to a permanent "Out of scope (decided 2026-05-17)" clause
with the rationale: PER is the cross-reference index, not the file
system. docs/standards/per.md gains the same explicit clause so future
shifts working from the PER standard see the constraint where they
look. Memory note filed so future workers don't re-propose multipart
uploads, attachments tables, or documents buckets.

## docs/design.md §13 Theming

Documents the toggle approach, cookie semantics, palette table, the
standalone-SVG carve-out, the login-page exception, and the 4b
out-of-scope (prefers-color-scheme detection, per-page overrides,
transitions on swap).
2026-05-17 18:14:08 +02:00
mAi
7ed0a4d46c feat(phase 4a): chronological timeline at /timeline + dashboard VTODO edit/delete
/timeline braids every dated thing in projax into a single chronological spine:
CalDAV VTODOs (DUE anchor), VEVENTs (DTSTART), dated item_links (event_date),
and item-creation markers. Default window past-30d to future-90d; ?order=
toggles asc/desc; ?kind= narrows by row type; tree filter (?tag/?mgmt/?has)
applies across kinds. Today / Tomorrow get sticky pills; rows > today+30d
fade. 90s in-memory TTL cache keyed by (filter, window, order, kinds);
busted on any VTODO writeback or dated-link change.

Scope expansion (per head message during 4a): the dashboard Tasks card now
has edit + delete affordances on every row, matching the detail page. New
/dashboard/task/{edit,delete} endpoints share a writeback path with /done.
Timeline VTODO rows reuse the same handlers; HX-Target=timeline-section
selects the re-render surface. Timeline item_link rows reuse the existing
/i/{path}/links/remove handler with the same surface-switch.

VEVENT rows on the timeline remain read-only at v1 (3l decision stands).
Item-creation events render as muted "added X to projax" markers.

Tests cover empty state, dated-doc surfacing, kind-filter narrowing, order
toggle, mixed CalDAV todos + all-day events (with the (2 days) duration
hint), and tag-filter cross-kind. New dashboard test asserts the edit/
delete affordances are wired up.

docs/design.md gains §12 with the full source list, layout rules, time
window, filter integration, cache TTL, and deferred items.
2026-05-16 15:52:32 +02:00
mAi
dfa81fd58e feat(phase 3p): bake git SHA into binary + surface on /healthz
Closes the silent-deploy-rot gap caught by Phase 3n's triage. The
problem: a missing Gitea webhook left 11 commits stuck on an old
container while /healthz kept reporting 200 from the stale binary. With
no commit-level evidence on the wire, "deploy rolled" was unverifiable.

Mechanism:
- Dockerfile installs git, reads `git rev-parse --short HEAD` at build
  time, injects via `-ldflags="-X main.gitCommit=<sha>"`. Works under
  Dokploy's `git clone --depth 1` flow (the .git/ folder is in the
  build context) and under plain `docker build .` (same). Local
  `go run` falls back to "unknown".
- main.gitCommit assigns to web.Server.Version in main().
- /healthz now emits two lines: "ok" and "version: <sha>". Endpoint
  remains unauthenticated so any worker / monitor can verify "deploy
  rolled" without a session.

CLAUDE.md gets a mandatory "Post-deploy verification" section: after
every push, compare `git rev-parse --short HEAD` against
`curl /healthz | tail -1`. Mismatch = webhook broken; inspect Gitea
hook 172 (URL pattern `http://mlake.horse-ayu.ts.net:3000/api/deploy/
<refreshToken>` per the working webhooks on m/msbls.de + m/flexsiebels.de).

TestHealthzSurfacesVersion regression-guards the new line. Existing
TestHealthz updated to accept the multi-line body.
2026-05-16 15:35:28 +02:00
mAi
c486a8b028 feat(phase 3o admin-index): /admin landing + system panel + nav consolidation
The three admin pages (classify, caldav, bulk) had no shared entry point —
m navigated around and couldn't find them. /admin is now their index:

- 3 cards, each linking to the underlying tool, with live counts
  (orphan count via projax.items_unified predicate; calendar count via
  ListCalendars; item count via projax.items where deleted_at IS NULL
  AND archived = false)
- CalDAV card auto-disables when DAV_URL isn't configured
- System panel: version (build-time ldflags hook), last migration
  (projax.schema_migrations top row), MCP status (token present
  yes/no — token itself never displayed), upstream health (DAV +
  Gitea + Supabase, parallel-probed with 1s HTTP timeout each,
  cached 30s)

web/admin.go houses the handler + cache + probeURL helper + count
queries. Templates/admin.tmpl renders the cards + system grid.
admin_test.go covers /admin render + nav-link presence on every
chrome-bearing route.

Nav consolidation: the three separate admin links in layout.tmpl
collapse to one /admin entry. Pre-existing TestTreeRenders updated
to assert the new shape.

Probe-URL caveat: probeURL counts any HTTP response as "alive" (incl.
4xx) — the admin panel measures reachability, not authorisation. CalDAV
returns 401 on bare GET; Gitea returns 200 at the root; Supabase same.
All show green when alive.
2026-05-16 02:26:07 +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
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
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
4c8488cdbb fix(mcp): mount /mcp/rpc with explicit method patterns
Go 1.22 ServeMux treats prefix patterns as conflicting with the root
catch-all when one matches more methods than the other. Switch from
prefix mounting (`/mcp/`) to two explicit `POST/GET /mcp/rpc` handlers
so the projax server boots cleanly. /healthz crash loop on first deploy
of phase 3a was caused by this.
2026-05-15 18:11:49 +02:00
mAi
dc50823860 feat(phase 3a mcp): MCP surface so mai/otto/Claude can read+write projax
mcp package (new): minimal JSON-RPC 2.0 + MCP-protocol server, tools
delegate to *store.Store (no business-logic duplication).

- handler.go: handleRPC routes initialize / tools/list / tools/call /
  ping / notifications/initialized; Bearer-token middleware; results
  flow through the standard MCP content[].text envelope; tool errors
  surface as isError: true (transport errors stay JSON-RPC errors).
- tools.go: 10 tools — list_items / get_item / create_item /
  update_item / delete_item / list_links / add_link / remove_link /
  search / tree. Multi-parent in/out — parent_paths[] string array,
  resolved per call. itemView/linkView keep the wire shape snake_case
  and stable.
- mcp_test.go + tools_test.go: protocol primitives (no DB) plus a
  full create → get → search → delete round-trip skipping cleanly
  when the DB env is absent. Multi-parent assertion discovers the
  test pair from the live DB rather than hard-coding a row.

store extensions:
- ListByFilters(SearchFilters) with parent_path/tags/management/kind/
  status/q/has_repo/has_caldav predicates.
- Search(q, limit) ranked across title/slug/aliases/content_md.
- GetByPathOrSlug for callers that don't know the full path.
- SoftDeleteCascade refuses on live descendants unless cascade=true.

web:
- New optional Server.MCP http.Handler. main.go mounts an mcp.Server
  when PROJAX_MCP_TOKEN is set; /mcp/* gets a StripPrefix and bypasses
  the Supabase-cookie auth middleware (its own Bearer auth applies).
- Off cleanly when the token is unset.

ops:
- ~/.claude/mcp/projax.sh stdio→HTTP bridge (NDJSON in, NDJSON out,
  Bearer header).
- .mcp.json adds an http-transport entry for clients that speak
  HTTP+MCP natively.
- deploy/dokploy.yaml advertises PROJAX_MCP_TOKEN as a secret.
- docs/design.md §7 added: tool list, multi-parent semantics, env
  contract, transport + bridge.
2026-05-15 17:59:03 +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
96b61f7ed4 feat(phase 2 caldav): list + link + create CalDAV calendars
m's CalDAV server (dav.msbls.de, SabreDAV) now feeds projax via a thin
read-only-plus-create-on-demand integration. No background sync; tasks
fetched live on detail-page render.

New caldav/ package
- ListCalendars (PROPFIND Depth: 1, filters non-calendar collections)
- ListTodos (REPORT calendar-query for VTODO; hand-rolled iCalendar
  parser for UID/SUMMARY/STATUS/DUE/PRIORITY/LAST-MODIFIED — RFC 5545
  line-folding aware)
- CreateCalendar (MKCALENDAR, 405 → ErrCalendarExists for the "link
  instead" branch)
- httptest-stubbed tests cover all four paths.

Store
- ItemLink shape + LinksByType / LinksByRefType / AddLink / DeleteLink.
  AddLink upserts on (item_id, ref_type, ref_id, rel) so re-linking the
  same calendar is idempotent.

Web
- GET /admin/caldav — discovery + auto-suggested matches + manual
  linker. Suggestion = lowercased displayname == projax slug or title.
- POST /admin/caldav/link — insert item_links row.
- POST /admin/caldav/unlink — delete by link id.
- POST /i/{path}/caldav/create — MKCALENDAR at <base>/<slug>/, then
  AddLink. On 405 (already exists), fall back to link-only.
- Detail page Tasks section: per-calendar block with open VTODOs +
  collapsed completed (30d window). Errors per calendar logged and
  skipped, so one bad calendar does not blank the page.
- nav adds /admin/caldav link.

main.go
- DAV_URL + DAV_USER + DAV_PASSWORD optional. Missing DAV_URL → CalDAV
  off (admin page renders "not configured" notice). DAV_URL set but
  user/pass missing → fail fast at boot.

docs/design.md gains §5 documenting the integration shape.
deploy/dokploy.yaml lists the two new secrets + the env var.

Phase 2.b (writeback / two-way / background sync) is parked.
2026-05-15 16:57:43 +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
840c1760c9 feat(auth): federate with mgmt.msbls.de via Supabase cookies
projax was deployed publicly through Dokploy/Traefik with a Let's
Encrypt cert; the earlier "Tailscale-only" claim was never true. Gate
every request at the application layer using the same Supabase JWT
cookie pair that mgmt.msbls.de issues, so projax inherits SSO without
running its own login.

Middleware (web/auth.go):
- GET <SUPABASE_URL>/auth/v1/user with the access_token cookie or a
  Bearer header. On 2xx → pass through.
- On expiry, swap the refresh_token via /auth/v1/token?grant_type=
  refresh_token and rotate both cookies (Domain=msbls.de, HttpOnly,
  Secure, SameSite=Lax, Path=/, Max-Age=1y). Cookie attributes match
  mgmt/auth.ts verbatim — refreshed sessions stay drop-in compatible
  with the rest of the .msbls.de fleet.
- Anything still invalid → 302 to <PROJAX_LOGIN_URL>?redirectTo=
  <original-absolute-url>. mgmt's safeRedirect() rejects absolute URLs
  and falls back to /, so after login the user lands on mgmt; manual
  click back to projax then succeeds with the fresh cookie. UX is
  rough but functional; broadening mgmt's safeRedirect is parked for a
  separate PR.
- /healthz remains ungated so Dokploy/Traefik probes don't hit the
  redirect.

main.go: enable the middleware only when SUPABASE_URL is set; require
SUPABASE_ANON_KEY when it is (refuse to start otherwise). New env
overrides: PROJAX_LOGIN_URL (default https://mgmt.msbls.de/login),
PROJAX_COOKIE_DOMAIN (default msbls.de). Local dev with no env stays
fully anonymous.

Tests (7 cases, no DB needed): stub Supabase via httptest covers
healthz-open, anonymous-redirect, bad-cookie-redirect, good-cookie
pass-through, Bearer-pass-through, stale-but-refreshable rotation
(verifies cookie Domain/HttpOnly/Secure/SameSite), final fail
redirect.

DB-backed integration tests now honour PROJAX_SKIP_MIGRATE=1 so they
don't deadlock against the live container's auto-migrate during a
deploy window.

README + dokploy.yaml: kill the Tailscale-only claim, document the
federated-auth trust model and the new SUPABASE_* env contract.
2026-05-15 14:58:43 +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