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.
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).
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).
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.
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.
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.
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.
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.
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.
Phase 5d slice B. createItemTool / updateItemTool stop encoding rejections
as `validation <kind>: <detail> [{json-blob}]` glued into .error.message
and instead return ValidationToolError(ve), which the JSON-RPC envelope
marshals as:
{ code: -32602,
message: "<kind>: <detail>",
data: { kind, path, detail } }
Clients introspect `.error.data.kind` directly — no JSON suffix to parse
out of the message. -32602 is the JSON-RPC "Invalid params" code, the
right semantic level for an itemwrite rejection.
mcp/tools.go:
- Replace itemWriteError with ValidationToolError. The legacy helper is
gone; four call sites (create_item × 2, update_item × 2) switch over
one-for-one.
mcp/mcp_test.go:
- Add TestToolsCallValidationError. Pins the wire shape: code=-32602,
message=`<kind>: <detail>` with no JSON suffix, and data carrying
{kind, path, detail}. Also asserts the rejection does NOT route
through result.isError — the slice A guarantee remains intact for
validation errors specifically.
- Import internal/itemwrite for the ValidationError fixture.
No test source edits to existing assertions — the prior tests don't
inspect the legacy `validation X: Y [{...}]` Msg shape, so behaviour
preservation holds without touching them. The new test is additive.
Live probe (post-deploy): POST `create_item` against projax.msbls.de/mcp/rpc
with slug='BAD.SLUG' returns `error.data.kind = "invalid-slug-format"`.
Phase 5d slice A. ToolHandler was `func(ctx, params) (any, error)` and
errors surfaced through the MCP `result.isError=true` content envelope with
no place to put structured payloads. Widen to `(any, *ToolError)` so
handlers return a typed `{Code, Msg, Data}` that the server marshals into
the JSON-RPC `error` envelope (`{code, message, data}`) — `data` is omitted
when nil so today's untyped errors stay clean.
Handler.go:
- ToolError gains `Code int`; Msg+Data unchanged. Error() preserved.
- Drop `AsToolError` — `errors.As` indirection is no longer needed now that
handlers return *ToolError directly.
- Add `InternalError(err)` (-32603, wraps a plain error) and
`InvalidParamsError(msg)` (-32602, declared for slice B's validation
promotion — no callers in slice A).
- `handleToolsCall` switches from the `result.isError` envelope to the
JSON-RPC `error` envelope via new `writeToolErr` helper. Transport-level
errors (`writeErr`) are unchanged.
Tools.go:
- `itemWriteError` now returns `*ToolError` with the legacy
`validation <kind>: <detail> [{json-blob}]` Msg text and no Data. Slice B
replaces this with `ValidationToolError` (typed .data + -32602).
- All ten tool handlers migrated to the new signature. Non-validation
paths default to `Code: codeInternalError (-32603)` via `InternalError(err)`
for semantic preservation; "field is required" guards keep the same
message string under -32603.
- Helper functions (`resolveItem`, `resolveParentPaths`,
`resolveTimelineWindow`, `resolveTimelineItems`, `applyHasLinkFilters`,
`parseInput`) keep returning plain `error`; their tool-handler callers
wrap with `InternalError`.
Test source edits (per the 5c rule):
- `mcp_test.go` TestToolsCallSuccessAndError: error path now asserts on
the JSON-RPC `.error.code == -32603` and `.error.message == "kaboom"`
envelope instead of `result.isError=true` + content text. The success
path is unchanged (`isError:false` and content[].text stay). Also
refreshed two handler-literal signatures in the same test file from
`(any, error)` → `(any, *ToolError)` so the test compiles against the
widened signature.
All other MCP tests stay behaviour-preserving — they exercise success
paths through the unchanged result envelope, or hit error paths via
`Handler(...) (any, *ToolError)` directly (timeline_test.go) and still see
a non-nil error.
.dockerignore excluded .git/, so `git rev-parse --short HEAD` inside the
Dockerfile build silently fell back to "unknown" and /healthz reported
`version: unknown` on every deploy. Remove the .git entry; the Dockerfile
already runs git inside the build stage and Dokploy clones --depth 1.
After deploy, `curl https://projax.msbls.de/healthz | tail -1` returns
the short commit SHA matching `git rev-parse --short HEAD` on main —
deploy verification becomes a one-shot SHA match instead of inspecting
container task IDs on mlake.
Phase 5c slice C. createItemTool and updateItemTool now pre-validate
through internal/itemwrite/ before the store.Create / Update call.
- itemWriteError wraps a *ValidationError into an error whose
message embeds the JSON shape {kind, path, detail} — the JSON-RPC
envelope carries that as .error.message, and clients can parse the
bracketed JSON suffix to extract a typed object. (A future Slice D
could promote this to .error.data with native typed-error support
in the mcp server; out of scope here.)
- createItemTool: ValidateFormat + ValidateAgainstStore on
(title, slug, status, parent_ids) before store.Create. The old
"slug and title are required" inline check is removed — the
validator's missing-required kind covers it with a structured
reject.
- updateItemTool: same pair on the patched-item shape (the item's
existing fields plus whatever the input overrides). Catches
cycle / self-parent / slug-collision before the txn opens.
No mcp test source touched — assertions are on observable behaviour
(tool result shape, error presence) and the validator preserves both
for valid AND invalid inputs the SQL trigger would have rejected.
Task: t-projax-5c-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
Phase 5c slice A. Pulls the structural rules out of the Postgres
triggers into a Go-side validator. The trigger stays as defence in
depth; the validator is the human-facing error path.
- docs/plans/itemwrite-validation.md enumerates every rule the
triggers in 0001 + 0010 enforce, with the ValidationError.Kind
callers will see for each. Eleven rules total (two SQL-only safety
rails kept untranslated).
- internal/itemwrite/itemwrite.go: ValidationError + Input + Reader
interface + ValidateFormat (pure: missing fields, slug format,
status whitelist, self-parent) + ValidateAgainstStore (DB-aware:
unknown-parent, slug-collision under any common parent, cycle via
ancestor-closure DFS capped at 64 hops to mirror the trigger).
- Eight kind constants exported: missing-required, invalid-slug-format,
invalid-status, slug-collision, cycle, self-parent, unknown-parent,
unresolvable-path.
Tests cover every kind on both happy and reject paths: missing /
whitespace fields, slug containing dot / upper / whitespace, invalid
status enum, self-parent guard, unknown parent id, root slug collision,
sibling slug collision under common parent, cycle on ancestor closure,
and the "Reader returns ListAll error → validator returns nil" path
(callers see the infra error later, validator doesn't mask it).
No caller migrates yet. Same Go-linker DCE caveat as 5a/5b slice A:
`strings <binary> | grep internal/itemwrite` returns 0 until slice B
imports.
Task: t-projax-5c-itemwrite
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
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
Phase 5b slice A. Generic TTL cache that replaces the mechanically
identical dashboardCache + timelineCache in slices B/C.
- TTLCache[V] over map[string]entry[V] with sync.RWMutex.
- Get / Set / Invalidate(key) / InvalidateAll.
- Lazy expiry — a Get past the deadline removes the entry; no sweeper
goroutine (matches today's behaviour and stays simple at single-user
scale).
- Nil receiver is safe across all four methods — same defensive shape
the existing per-package caches use.
Tests cover empty Get, Set+Get, expiry on miss, overwrite,
keyed-Invalidate isolation, InvalidateAll, nil receiver, pointer
payload behaviour, and a -race-flag concurrent-access probe across
8 workers × 200 ops.
No web/mcp wiring yet — slices B/C migrate the callers. Same Go
linker DCE caveat as 5a slice A applies (strings | grep alone won't
fire on this slice).
Task: t-projax-5b-cache
Slice D regression: when args.Kinds was empty (the default 'all' case),
the in-memory timeline_exclude pass iterated zero kinds and dropped
every item. Expand to the full kind set before filtering, and use the
expanded set when reporting timelineView.kinds — matches the web view's
activeKinds() behaviour.
Probed live: timeline tool now returns days/rows for the default
window again.
Task: t-projax-5a-aggregator
Phase 5a slice D. The MCP timeline tool no longer depends on
*web.Server — it talks to *aggregate.Aggregator directly. The wrong-way
mcp → web layering that necessitated the TimelineBuilder interface is
gone.
- mcp/tools.go: TimelineBuilder interface deleted.
RegisterProjaxTools(s, st, agg *aggregate.Aggregator) now takes the
aggregator directly; passing nil keeps the timeline tool unregistered
(kill-switch contract unchanged).
- mcp/tools.go: TimelineArgs moved from web/ to mcp/ since it is the
MCP-facing input shape. The timeline tool runs the full pipeline:
store.ListByFilters → in-mem timeline-exclude + has-link narrowing →
agg.All(...) → Result.ToTimelineRows() → aggregate.BuildTimelineDays
→ timelineView. No web/ import in the timeline path.
- internal/aggregate/rows.go: new Result.ToTimelineRows() helper that
projects the typed rows into the flat TimelineRow sum-type both
web/timeline.go and mcp/tools.go consume. Single source of truth for
the Date-anchor choice across kinds.
- internal/aggregate/timeline_days.go: FormatPERDate lifted from web/
so timeline-row builders outside web/ can render PER strings without
re-importing web/.
- web/timeline.go: BuildTimelinePayloadFromArgs + TimelineArgs deleted
(no remaining callers — slice D inlined the MCP path).
- cmd/projax/main.go: pass srv.Aggregator() into RegisterProjaxTools.
MCP tree-filter parity note: the move to store.ListByFilters narrows
status to a single value (first of args.Status) and AND-matches
management (vs the web TreeFilter's OR). m's documented MCP uses
(tag + default status) round-trip identically. Logged as a footnote in
docs/plans/aggregator-refactor.md.
All mcp + web + aggregate tests green.
Task: t-projax-5a-aggregator
Phase 5a slice C. collectTasks / collectIssues / collectEvents each
become 10-15 line shims that ask the aggregator for typed rows and
project them into the dashboard-flavoured display types.
- collectTasks: aggregator.Todos → filter status → bucket by due
distance (overdue/today/tomorrow/week/no-due) → cap 30.
- collectIssues: aggregator.Issues → relativeTime label → sort by
updated desc → cap 30. The Gitea TTL cache is now shared with the
detail page through the aggregator.
- collectEvents: aggregator.Events with the [now, now+7d) window →
EventStartLabel + dayLabelFor projection → group by day. Sort + cap
semantics unchanged. CalendarRef field on dashboardEvent is no
longer surfaced (kept for backwards compat).
- Dead eventStartLabel helper removed; aggregate.EventStartLabel is
the canonical implementation now.
collectStale stays in dashboard.go — it has dashboard-specific
"is-this-item-quiet" reduce logic the aggregator doesn't model.
All dashboard tests (dashboard_test, dashboard_events_test,
dashboard_edit_test) pass unmodified.
Task: t-projax-5a-aggregator
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
Phase 5a slice A: a new package that concentrates the "fan out across
linked items" pattern web/dashboard.go, web/timeline.go and mcp/tools.go
each had separate copies of. No callers touch it yet — slices B/C/D
migrate them in turn.
- Aggregator with five methods (Todos/Events/Issues/Docs/Creations) plus
All convenience for the MCP timeline. Each method takes a *store.Item
slice and (optionally) a Window, returns typed Row slices.
- Row types embed the underlying caldav.Todo / caldav.Event / gitea.Issue
so existing html/template field accesses (.Todo.UID, .Event.Summary,
…) keep resolving via Go field promotion in slices B/C.
- TimelineRow sum-type wrapper (with pointer slots per Kind) plus the
flat template-friendly fields. Lifted-but-untouched from web/.
- BuildTimelineDays + SortTimelineRows + EventStartLabel +
EventDurationHint lifted near-verbatim from web/timeline.go.
- CalDAV/Gitea/Store interfaces in the aggregator so unit tests stub IO
cleanly. Real *caldav.Client / *gitea.Client / *store.Store satisfy
by method set.
- Per-source error handling preserved: log at WARN + skip the bad
fetch, return surviving rows.
Tests cover empty inputs, fan-out call counts, per-source error
recovery, window narrowing for todos, issue-cache hit path, doc/creation
allow-list filtering, BuildTimelineDays asc/desc order, sticky pills,
far-future fade, within-day sort.
Plan doc captures the slicing strategy + design decisions:
docs/plans/aggregator-refactor.md.
Task: t-projax-5a-aggregator
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)