f820fa5830218aa43a12a16923bf95760b3f81a6
44 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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.
|
|||
| 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.
|
|||
| 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).
|
|||
| 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. |
|||
| 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).
|
|||
| 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.
|
|||
| 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).
|
|||
| 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.
|
|||
| 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 |
|||
| 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 /). |
|||
| 252b424d2c | Merge commit '2925c43' (phase 5h slice 4: pin toggle on tiles + handleDashboardPin) | |||
| 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). |
|||
| f234c72f50 | Merge branch 'mai/fuller/phase-5h-phase-a-design' (phase 5h slices 1-2: rollup model + Tiles tab) | |||
| 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).
|
|||
| 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.
|
|||
| 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.
|
|||
| 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.
|
|||
| 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.
|
|||
| 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 |
|||
| 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
|
|||
| 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 |
|||
| 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
|
|||
| 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)
|
|||
| 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)
|
|||
| 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). |
|||
| 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.
|
|||
| 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. |
|||
| 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. |
|||
| 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 |
|||
| 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
|
|||
| 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 |
|||
| 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) |
|||
| 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 |
|||
| 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).
|
|||
| 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. |
|||
| 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. |
|||
| 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. |
|||
| 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).
|
|||
| 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.
|
|||
| 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.
|
|||
| 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.
|
|||
| 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.
|
|||
| 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. |
|||
| 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.
|