Commit Graph

158 Commits

Author SHA1 Message Date
mAi
a9f062a67e Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice A: paliad-shape schema redesign) 2026-05-29 11:41:34 +02:00
mAi
173d7ddbb2 feat(views): Phase 5j slice A — paliad-shape schema redesign
Hard-replaces the 5i projax.views table per m's Q10 pick (2026-05-29):
no real data to preserve after a few hours, and the shape changes are
big enough that a clean recreate beats a 6-step ALTER.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Root causes

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

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

## Fix

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

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

## Tests

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

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

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

## Surfaces double-checked (not broken)

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

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

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

Three layers, one branch:

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

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

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

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

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

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

## Helpers (caldav package, exported)

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

## Tests

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

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

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

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

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

Regression test (web/new_form_test.go):

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

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

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

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

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

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

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

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

Two distinct gaps, both surgically fixed.

## Gap 1 — edit UI missing

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

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

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

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

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

## Tests

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reordered top-to-bottom flow:

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

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

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

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

Net: +298 / -92.
2026-05-26 13:15:39 +02:00
mAi
084fd7973b Merge commit '63f5ed1' (phase 5h slice 8: design.md addendum — Dashboard overhaul §19) 2026-05-26 12:38:01 +02:00
mAi
63f5ed115c docs(design): Phase 5h Dashboard overhaul addendum
Final slice of Phase 5h — documents the Tiles + view switcher
contract in design.md as the source of truth so future workers
understand what Phase 5h shipped without archaeology.

New section §19 covers:
- URL contract (view, scope, refresh, filter param interaction)
- dashboardProject rollup fields + LastActivity max-across-sources rule
- IsCurrent predicate (the 14d window)
- Tiles layout + the minmax(0,1fr) + min-width:0 + overflow-wrap
  containment recipe (explaining the mid-rollout horizontal-scroll fix)
- Quiet (N) ▾ fold replacing the standalone Stale card
- Scope chip mechanics (Tiles-only)
- Tasks tab (today minus Stale) and Events tab (promoted with summary
  header + bigger day headings)
- Cache key composition + pin-flip InvalidateAll
- Mobile breakpoints + touch targets
- Explicit non-goals for Phase 5h (Activity tab, sortable rows, project
  filter dim, saved views — those are 5i with kahn)

References block updated to point at docs/plans/dashboard-overhaul.md
for the design rationale + m's chip picks.
2026-05-26 12:37:56 +02:00
mAi
bad877ae69 Merge commit 'a46f73f' (phase 5h slice 7: mobile polish — Tiles tab strip + touch targets) 2026-05-26 12:36:31 +02:00
mAi
a46f73f568 feat(dashboard): mobile polish — Tiles tab strip wraps, touch-target sizing
Phase 5h slice 7 — adds mobile-tier rules to the Phase 5h dashboard
chrome (Tiles tab strip + scope chip + tiles + Events day heading)
that match the Phase 3i mobile conventions (44px touch targets,
horizontal-scroll-able strips, wrap-friendly layouts).

≤ 768px tier:
- .dash-tabs wraps so 3 tabs + the scope chip never push past viewport.
- .dash-tab grows to 8x12 padding for thumb taps.
- .dash-scope-chip drops to its own row (flex-basis:100%) with
  centered alignment — auto-margin right-align is meaningless when
  wrapped.
- .tile padding tightens to 10x12; .tile-head gap narrows.
- .tile-pin button gets a 36px min target (we don't push to 44 here
  because it'd unbalance the header row; star is still tappable).
- .tile-live live-link gets 32px min target.
- .dash-quiet-summary fold-toggle has 12px vertical padding so the
  hit area is bigger than the text glyph.
- Events tab .event-day-heading wraps; event count drops below the
  label + date for legibility.

No HTML / template changes — pure CSS slice. All web/ tests stay green.
2026-05-26 12:36:22 +02:00
mAi
9692b86a4b Merge commit 'fee3251' (phase 5h slice 5: polish Events tab — summary header, fuller day labels) 2026-05-26 12:35:15 +02:00
mAi
fee3251946 feat(dashboard): polish Events tab — summary header, fuller day labels
Phase 5h slice 5 — the Events tab's routing landed in slice 2; this
slice adds the dedicated-surface polish that distinguishes it from
the Events card on the Tasks tab.

Changes:
- Top header summary: 'N events · next 7 days' so m sees the window
  shape at a glance without scanning rows.
- Day headings now carry three columns: the relative label
  ('Today' / 'Tomorrow' / weekday), the ISO date (mono font), and a
  right-aligned event count. Bigger visual hierarchy than the cards-tab
  flavour to justify the dedicated tab's existence.
- Empty-state copy invites linking a CalDAV calendar from a project's
  detail page so a never-seen-events tab doesn't feel broken.
- StartLabel fallback to '—' when an event has no parseable start
  time so the row doesn't collapse weirdly.

CSS adds .dash-events-summary, .event-day-heading flex layout,
.event-day-label / .event-day-date / .event-day-count spans, and a
constrained .dash-events-empty for the empty-state width.

Test: TestDashboardEventsViewRenders now also asserts the empty-state
copy ships, so a future refactor that drops the invite-to-link prose
gets caught.
2026-05-26 12:35:10 +02:00
mAi
ccaae32f39 Merge commit 'c4a4ba0' (phase 5h hotfix: contain Tiles grid to prevent horizontal scroll) 2026-05-26 12:33:26 +02:00
mAi
252b424d2c Merge commit '2925c43' (phase 5h slice 4: pin toggle on tiles + handleDashboardPin) 2026-05-26 12:33:26 +02:00
mAi
c4a4ba0687 fix(dashboard): contain Tiles grid to prevent horizontal scroll
m reported /dashboard horizontally scrolls because Tiles widen past
the viewport. Root cause: '1fr' grid columns have an implicit
'min-content' minimum, so any tile with a long unbreakable string
(dot-separated slug path like dev.youpc.kommentar.knowledge-tools, or
a long task summary in NextSignal) pushes its column beyond
viewport-fraction → grid overflows main → horizontal scroll.

Fix triangle (the canonical CSS-grid containment recipe):
1. grid-template-columns: minmax(0, 1fr) instead of 1fr — overrides
   the implicit min-content floor.
2. min-width: 0 on the tile <article> — lets the grid item shrink
   below its content's min-content width.
3. overflow-wrap: anywhere on .tile-path — long slug-paths wrap at
   any character instead of forcing the tile wider.

Also: overflow: hidden on .tile as a belt-and-braces guard; the
ellipsis on .tile-signal-text was already there (slice 2) so it
already handled long task summaries.

Applied across all three breakpoint rules (1-col base, 2-col 600px,
3-col 900px) so the grid containment holds at every width.
2026-05-26 12:33:18 +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
d75d9a10ce Merge branch 'mai/fuller/phase-5h-phase-a-design' (phase 5h slice 3: scope chip + Quiet fold + Stale folded into Tiles) 2026-05-26 12:27:37 +02:00
mAi
87132ee166 feat(dashboard): scope chip + Quiet (N) ▾ fold + Stale folded into Tiles
Phase 5h slice 3 — splits the Tiles rollup into ProjectsCurrent (primary
grid) and ProjectsQuiet (collapsible fold) per m's §7 'pinned ∪
recently-active ∪ open-work' rule.

URL contract extended:
  /dashboard                      — Tiles, scope=current (defaults elided)
  /dashboard?scope=all            — every active project in the grid
  /dashboard?scope=current        — same as default (chip allows explicit)

Scope chip lives next to the tab strip on Tiles only; Tasks + Events
tabs hide it (no scope concept there). Default chip label: '◇ current',
flips to '○ all' when scope=all. Chip href toggles to the alternate
state preserving filter + view.

Quiet fold:
- <details> element opened on click — projects with IsCurrent=false land
  here, including all stale candidates.
- Fold summary: 'Quiet (N) — older than 14d · M stale' (M omitted when 0).
- Quiet tiles render with the same shape as primary tiles, slightly
  faded; stale tiles also carry a 'tile-stale' class (dashed border) and
  a 'stale' flag in the header.

Stale card on the Tasks tab retires entirely — m's pick. The
LastActivity stamp on each tile carries the staleness signal; the
'consider archiving?' nudge migrates to the Quiet fold framing. Stale
data still computes (collectStale runs in buildDashboard) because the
rollup needs the per-item stale flag and the repo-activity map for
LastActivity.

Cache key extends: (filter | view=X | scope=Y) so toggling scope from
the chip lands in a separate cache slot (no stale render).

Tests:
- TestDashboardStaleCardSurfacesDormantMaiProject retargeted at the new
  Quiet fold + tile-stale class on Tiles.
- TestDashboardStaleCardSkipsRecentRepo asserts the inverse via class
  inspection on the tile <article>.
- 4 new tests cover the scope chip: renders on Tiles only, label flips
  on scope=all, scope=all hides the Quiet fold, chip URL flips correctly.

Empty state: scope=current with no current projects shows a
'Nothing current. Pin a project, or show all active.' note with a
direct link to scope=all.
2026-05-26 12:27:13 +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
1a508332b3 feat(dashboard): per-project rollup data model + IsCurrent predicate
Phase 5h slice 1 — adds dashboardProject struct that groups per-row
signals (TodoRow / IssueRow / EventRow / dated docs / optional repo
updated_at) by item.ID into one rollup per project. IsCurrent(now)
implements the §7 contract: pinned OR open-tasks>0 OR open-issues>0 OR
LastActivity within 14d.

No UI change — slice 2 wires the rollup into buildDashboard and the
Tiles template. activityRel produces tight tile-friendly labels
(now / Nm / Nh / Nd) distinct from relativeTime (used on rows).

Test coverage:
- task counts + overdue + soonest-due NextSignal
- COMPLETED VTODOs skipped but their LastModified feeds activity
- issues fill OpenIssues + LastActivity, task beats issue for NextSignal
- repoActivity map feeds LastActivity
- LastActivity = max across todo/event/doc/repo sources
- IsCurrent four branches + the no-signal false case
- pinned-first then path-sorted output order
- Stale flag passes through staleByItem map
- activityRel label shapes + future-flip

Refs §7 of docs/plans/dashboard-overhaul.md (commit 3647472).
2026-05-26 12:10:12 +02:00
mAi
c6a350f6a0 docs: Phase 5i Views-system design plan
Phase A (design) of Phase 5i — project filter dim, view-type as a
parameter, saved views, and per-page bindings. Five-slice implementation
plan (A: project filter → B: view-type URL → D: saved-views schema → C:
kanban → E: defaults). Nine open questions for m batched in §9 ready
for head delegation.

No code changes; this branch ships docs only. Coder shifts wait on m's
sign-off via head.
2026-05-26 12:10:08 +02:00
mAi
3647472ce8 docs(plans): dashboard overhaul Phase 5h design
Phase A inventor deliverable: 3 candidate shapes (Tiles + view switcher,
Project rows table, 3-pane workspace), recommendation = Tiles, and m's
chip-picker decisions captured in §7.

Scope locked for coder gate: Tiles default, Pinned ∪ active ∪ open-work
as 'current', 3 tabs (Tiles/Tasks/Events) — Activity deferred, Stale
folded into Quiet under Tiles.

No code touched. Coder shift only after head's go/no-go.
2026-05-26 12:02:29 +02:00
mAi
88fd77b439 Merge branch 'mai/knuth/fix-calendar-filters' (fix: <select multiple> filter strips drop values past first) 2026-05-26 11:56:46 +02:00
mAi
6f0a318979 fix(filters): preserve every value from <select multiple> filter strips
Symptom (m-reported): /calendar filters don't work.

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

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

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

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

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

Regression test:

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

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

Same fix transparently repairs /timeline, /dashboard, /graph, /bulk —
they all consume ParseTreeFilter and shared the bug.
2026-05-26 11:56:42 +02:00
mAi
69d872f7d2 Merge branch 'mai/knuth/phase-5g-mbrian-nav' (phase 5g slice B: mobile bottom-nav + drawer) 2026-05-25 16:40:19 +02:00
mAi
bd600633c9 feat(layout): mobile bottom-nav + drawer
Phase 5g slice B. Fills the ≤767px gap left by slice A (sidebar
display:none on mobile) with a fixed-bottom 5-slot nav + a drawer for
overflow items. iOS PWA install respects safe-area-inset-bottom so the
nav clears the home indicator.

web/templates/layout.tmpl:
- New <nav class="projax-bottom-nav"> with five slots:
    Tree (/) → Dashboard (/dashboard) → +New (/new, raised circle)
    → Calendar (/calendar) → Menu (drawer).
- Center "+ New" slot is a raised .capture-circle (margin-top: -10px,
  44×44px, accent background) — mBrian's capture-button pattern, but
  pointing at /new because projax has no separate capture flow.
- Menu slot is a <details class="projax-mobile-drawer"> whose <summary>
  IS the bottom-nav-item. Tapping pops a drawer-sheet absolutely
  positioned 8px above the bottom-nav with overflow items: Timeline,
  Graph, Admin, theme toggle, sign-out. Browser-default <details>
  handles open/close + tap-outside-dismiss — no JS, no gesture wiring.
- Active class on bottom-nav-item + drawer-item via same .Path-driven
  server-side pattern slice A introduced.
- Theme toggle handler now binds to BOTH #theme-toggle (sidebar) AND
  #theme-toggle-drawer (drawer). Flipping either updates the icon on
  both buttons, sets data-theme on <html>, writes the cookie.

web/static/style.css:
- .projax-bottom-nav: fixed bottom, height = calc(56px +
  env(safe-area-inset-bottom, 0)), flex justify-around, z-index 1021.
- .bottom-nav-item: 44×44px min, column-flex, touch-action: none for the
  capture-button so iOS doesn't intercept the tap.
- .capture-circle: 44×44px raised circle, accent background.
- .projax-mobile-drawer .drawer-sheet: fixed, bottom-right anchored
  above the nav, min(260px, calc(100vw - 16px)) wide, slide-up animation
  via @keyframes projax-drawer-up (translateY 8→0, 160ms ease-out).
- @media (min-width: 768px): bottom-nav hidden.
- @media (max-width: 767px): main.projax-main gets padding-bottom =
  calc(56px + 1rem + env(safe-area-inset-bottom)) so rows aren't hidden
  behind the nav.

docs/design.md:
- New §18 (Layout: sidebar + bottom-nav, Phase 5g). Documents both
  surfaces' breakpoints, the .Path-driven active marker, the pre-paint
  localStorage restore, the theme-toggle dual-binding, and the four
  features I deliberately did not port from mBrian (resize handle,
  capture modal, quick-switcher/saved-searches/Today/Work, slide-up
  gesture).

Tests (web/layout_test.go):
- TestLayoutBottomNavMarkup: 5 slots present in documented order, +New
  is .capture-btn with .capture-circle, Menu is <details>, drawer holds
  Timeline/Graph/Admin/theme/sign-out.
- TestLayoutBottomNavActiveClass: /calendar render highlights Calendar
  slot only.
- TestLayoutThemeToggleBoundToBothButtons: handler enumerates both
  button ids so flipping either flips the theme.

All 10 layout tests pass (7 from slice A + 3 from slice B). Full web
suite green. No test source edits to pre-existing tests — the bottom-
nav is additive markup.
2026-05-25 16:40:14 +02:00
mAi
c49ce45b2d Merge branch 'mai/knuth/phase-5g-mbrian-nav' (phase 5g slice A: desktop sidebar replaces top-nav) 2026-05-25 16:36:15 +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
07d88c14e5 Merge branch 'mai/knuth/phase-5e-calendar' (phase 5e slice B: polish + mobile + design doc) 2026-05-22 12:07:29 +02:00