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)
Each major section on /i/{path} is now wrapped in a native <details>
element with a smart-default `open` attribute. The inline JS overrides
the default from localStorage so m's per-item collapse state survives
reloads.
## Smart defaults (server-rendered open attr)
- Tasks: open if any linked calendar has >=1 open VTODO
- Issues: open if total open issues <= 10
- Documents: open if dated link count <= 5
- Public listing: closed by default
## Persistence
localStorage["projax.section." + item_id + "." + section] = "open" | "closed".
Inline JS reads on boot, writes on toggle. The "reset section state" link
in the form actions wipes every key for the current item and reloads —
smart defaults take over again.
## What's not collapsed
- Title + status/tags chip line (always visible breadcrumb)
- The inline edit form's standard fields (title/slug/parents/content)
Only the auxiliary sections — Tasks, Issues, Documents, Public listing —
collapse. m always sees what an item *is* without expanding anything.
## Tests
- TestDetailIncludesSectionToggleScript — script fragments ship
- TestDetailSectionsWrappedInDetails — every section has its wrapper
- TestDetailDocumentsClosedDefaultsWhenManyItems — 0-doc baseline is open
## docs/design.md
New section before §15 documents thresholds, persistence semantics, and
the non-collapsible carve-outs.
Adds five additive columns on projax.items and propagates them through
every read/write path. flexsiebels.de (and any future portfolio renderer)
can now pull the public set via the MCP `list_items(public=true)` filter
and stop hard-coding project lists.
## Schema (migration 0014)
- public boolean default false (partial index when true)
- public_description text default ''
- public_live_url text default ''
- public_source_url text default ''
- public_screenshots text[] default '{}'
- items_unified view rebuilt to include the five new columns
- items_public_idx PARTIAL INDEX WHERE public = true (5% of rows)
## Store
- Item struct + scan/scanItems extended (5 cols)
- UpdateInput accepts the new fields with full-replace semantics
- new SetPublic(ids, bool) for bulk write
- SearchFilters gains Public *bool — nil = no filter
## MCP
- list_items: new `public` boolean filter (input schema + handler)
- update_item: 5 new partial-update fields (nil pointer = leave alone)
- itemView always emits the 5 fields (even when public=false) so consumers
can preview "what would publish" without a second round-trip
- 2 new integration tests against the DB
## Web
- /i/{path} grows a "Public listing" fieldset: toggle + textarea + 2 URL
inputs + screenshot list editor with add/remove rows + inline JS for
the editor. Values persist when public is off so toggling never
destroys typed-in content.
- /admin/bulk action bar gains "Make public" / "Make private" via a new
select; SQL update is a single statement per action.
- /?public=1 and /?public=0 chip parameters narrow the tree page.
Active() + QueryString() + TogglePublic() round-trip the state.
- parseScreenshotList helper trims + drops empties + preserves order
- 5 integration tests: migration landed, form round-trip, bulk action
round-trip, detail-page affordances, tree-filter narrowing
## docs/design.md §15
Documents the schema, MCP contract, UI surfaces, flexsiebels consumption
pattern, and what's NOT in scope (flexsiebels-side render, asset hosting,
approval workflows).
## Out of scope (per task brief)
- Flexsiebels rendering — separate task in m/flexsiebels.de after this ships
- Asset hosting (projax stores URLs, never bytes — same PER discipline)
- Multi-stage publish workflow (boolean is enough)
Exposes projax's /timeline aggregation (Phase 4a) over MCP-RPC so the
PWA (mAi#228) can fetch it without a session cookie against
projax.msbls.de. Same tool surface m's other agents already use.
## Changes
- web/timeline.go: export TimelineQuery, TimelinePayload, add typed
TimelineArgs + BuildTimelinePayloadFromArgs entrypoint. The web cache
stays scoped to the HTTP handler; MCP path re-aggregates per call.
- mcp/tools.go: register `timeline` tool when a TimelineBuilder is
passed. Output mirrors the web template's shape but stringifies
timestamps to YYYY-MM-DD or ISO-8601 UTC so JSON-RPC consumers don't
need Go time semantics.
- mcp/tools_test.go: existing tests pass nil builder (no behaviour
change to the rest of the tool surface).
- mcp/timeline_test.go: 7 unit tests covering registration, arg
forwarding, error propagation, empty payload, and view serialisation.
- cmd/projax/main.go: pass the running *web.Server as the third arg so
the timeline tool registers on the live server (CalDAV-aware).
- docs/design.md §14: documents the tool, schema, output shape, cache
semantics.
## Out of scope
- Caching the MCP path (rejected — re-aggregation per call is cheap;
divergent cache keys aren't worth invalidation complexity).
- Wrapping CalDAV writes (S2 — separate slice once m greenlights).
- PWA backend bridge + frontend (S2/S3 — m/mAi side, after this deploys).
Phase A of the 4c task brief. Survey found the integration already
exists and ships (mAi#228, 2026-05-15) — the question is which slice
deepens it next.
Plan covers:
- Otto-PWA structural notes (Go backend + Bun/TS frontend in m/mAi, not
m/otto — ADR-006 moved PWA code into mAi)
- Existing MCP-consumption pattern (Bearer-token JSON-RPC bridge,
graceful 501 degradation, 4 endpoints registered, frontend shells +
client TS, live at https://otto.msbls.de/projax/)
- 3 deepening slices: (S1) timeline surface, (S2) CalDAV writeback,
(S3) dated docs quick-add
- Recommendation: ship (S1) first — Phase 4a just landed /timeline
in projax web, the data + aggregation logic exist, exposing via MCP
is a clean wrap with no schema or auth model change
- Impl plan if greenlit: 3 slices across projax + mAi with cross-repo
deploy verification
Out of scope until head greenlights: writing any code in m/mAi.
## Slice A — explicit dark/light toggle
projax now ships with two palettes and a 1y cookie to remember the choice.
Dark is the new default; ☀ button in the header nav flips to light and
writes projax_theme=light. Server reads the cookie via themeFromRequest(r)
and injects Theme + ThemeColor into every template via the centralised
render(w, r, …) path, so first paint never flashes the wrong theme. Inline
JS in layout.tmpl handles the toggle without a server roundtrip.
Every panel colour now lives in a CSS variable under
:root[data-theme=dark|light]; the only hardcoded hex values left are
inside those two :root blocks. A future palette tweak is one edit, not
30 selectors. Graph node colours, kind-badges, highlights and warn/ok/bad
all have parallel dark/light values picked for contrast.
Standalone SVG download bakes the light palette inline because the
downloaded asset has no parent :root providing vars — m's existing
snapshots stay print-friendly regardless of his current cookie.
Login page keeps its embedded dark CSS — it's the gateway, intentionally
always dark.
Tests: TestThemeDefaultIsDark, TestThemeCookieRoundTrips,
TestThemeCookieUnknownFallsBackToDark, TestThemeTogglePagesShareSameTheme,
TestThemeToggleScriptPresent, TestThemeColorMetaHelper. Full suite green.
## Slice B — file-upload permanently out of scope (m, 2026-05-17)
docs/design.md moves "File uploads / in-projax storage" from the §3c
parked list to a permanent "Out of scope (decided 2026-05-17)" clause
with the rationale: PER is the cross-reference index, not the file
system. docs/standards/per.md gains the same explicit clause so future
shifts working from the PER standard see the constraint where they
look. Memory note filed so future workers don't re-propose multipart
uploads, attachments tables, or documents buckets.
## docs/design.md §13 Theming
Documents the toggle approach, cookie semantics, palette table, the
standalone-SVG carve-out, the login-page exception, and the 4b
out-of-scope (prefers-color-scheme detection, per-page overrides,
transitions on swap).
/timeline braids every dated thing in projax into a single chronological spine:
CalDAV VTODOs (DUE anchor), VEVENTs (DTSTART), dated item_links (event_date),
and item-creation markers. Default window past-30d to future-90d; ?order=
toggles asc/desc; ?kind= narrows by row type; tree filter (?tag/?mgmt/?has)
applies across kinds. Today / Tomorrow get sticky pills; rows > today+30d
fade. 90s in-memory TTL cache keyed by (filter, window, order, kinds);
busted on any VTODO writeback or dated-link change.
Scope expansion (per head message during 4a): the dashboard Tasks card now
has edit + delete affordances on every row, matching the detail page. New
/dashboard/task/{edit,delete} endpoints share a writeback path with /done.
Timeline VTODO rows reuse the same handlers; HX-Target=timeline-section
selects the re-render surface. Timeline item_link rows reuse the existing
/i/{path}/links/remove handler with the same surface-switch.
VEVENT rows on the timeline remain read-only at v1 (3l decision stands).
Item-creation events render as muted "added X to projax" markers.
Tests cover empty state, dated-doc surfacing, kind-filter narrowing, order
toggle, mixed CalDAV todos + all-day events (with the (2 days) duration
hint), and tag-filter cross-kind. New dashboard test asserts the edit/
delete affordances are wired up.
docs/design.md gains §12 with the full source list, layout rules, time
window, filter integration, cache TTL, and deferred items.
Closes the silent-deploy-rot gap caught by Phase 3n's triage. The
problem: a missing Gitea webhook left 11 commits stuck on an old
container while /healthz kept reporting 200 from the stale binary. With
no commit-level evidence on the wire, "deploy rolled" was unverifiable.
Mechanism:
- Dockerfile installs git, reads `git rev-parse --short HEAD` at build
time, injects via `-ldflags="-X main.gitCommit=<sha>"`. Works under
Dokploy's `git clone --depth 1` flow (the .git/ folder is in the
build context) and under plain `docker build .` (same). Local
`go run` falls back to "unknown".
- main.gitCommit assigns to web.Server.Version in main().
- /healthz now emits two lines: "ok" and "version: <sha>". Endpoint
remains unauthenticated so any worker / monitor can verify "deploy
rolled" without a session.
CLAUDE.md gets a mandatory "Post-deploy verification" section: after
every push, compare `git rev-parse --short HEAD` against
`curl /healthz | tail -1`. Mismatch = webhook broken; inspect Gitea
hook 172 (URL pattern `http://mlake.horse-ayu.ts.net:3000/api/deploy/
<refreshToken>` per the working webhooks on m/msbls.de + m/flexsiebels.de).
TestHealthzSurfacesVersion regression-guards the new line. Existing
TestHealthz updated to accept the multi-line body.
The three admin pages (classify, caldav, bulk) had no shared entry point —
m navigated around and couldn't find them. /admin is now their index:
- 3 cards, each linking to the underlying tool, with live counts
(orphan count via projax.items_unified predicate; calendar count via
ListCalendars; item count via projax.items where deleted_at IS NULL
AND archived = false)
- CalDAV card auto-disables when DAV_URL isn't configured
- System panel: version (build-time ldflags hook), last migration
(projax.schema_migrations top row), MCP status (token present
yes/no — token itself never displayed), upstream health (DAV +
Gitea + Supabase, parallel-probed with 1s HTTP timeout each,
cached 30s)
web/admin.go houses the handler + cache + probeURL helper + count
queries. Templates/admin.tmpl renders the cards + system grid.
admin_test.go covers /admin render + nav-link presence on every
chrome-bearing route.
Nav consolidation: the three separate admin links in layout.tmpl
collapse to one /admin entry. Pre-existing TestTreeRenders updated
to assert the new shape.
Probe-URL caveat: probeURL counts any HTTP response as "alive" (incl.
4xx) — the admin panel measures reachability, not authorisation. CalDAV
returns 401 on bare GET; Gitea returns 200 at the root; Supabase same.
All show green when alive.
Three structural bugs from Phase 3d caught by m's "doesn't work" report:
1. The chip-add <form class="chip-add" ...> was rendered INSIDE the outer
<form id="bulk-actions" ...> in bulk_section.tmpl. HTML forbids
nested forms — browsers silently flatten them, so the chip-add's
hx-trigger="submit" never fired and pressing Enter in any chip-add
input dispatched the outer Apply form instead. Replaced the inner
<form> with a <span class="chip-add"> wrapping an input that fires
hx-post directly on Enter (hx-trigger="keyup[key=='Enter']") plus an
explicit + button. No more nested forms. New TestBulkPageHasNoNested
Forms regression-guards via a substring check on the rendered HTML.
2. handleBulkApply 400'd on empty ids OR empty action via http.Error,
which HTMX swapped into #bulk-section as a plain-text error page —
the page chrome vanished and the user saw "no action chosen". Now
the handler validates inputs, sets a banner string, and falls through
to renderBulkList (the section re-renders with the banner inline).
Banner copy is task-specific so m can tell what he missed.
3. renderBulkList read filter values with r.FormValue, which returns
ONLY the first value for multi-value names. Multi-select tag/mgmt/
status filters dropped their 2nd+ values on every Apply round-trip.
Switched to r.Form["..."] + a new normaliseFormStrings helper that
dedupes / lowercases / trims the slice. TestBulkApplyRendersWithFilter
Preserved regression-guards.
All 3 bugs caught by tests written first (TestBulkPageHasNoNestedForms,
TestBulkApplyEmpty{Action,Ids}RendersInlineBanner, TestBulkApplyRenders
WithFilterPreserved). Existing 4 bulk tests still green; full test suite
green.
Per docs/plans/mgmt-teardown.md §4 steps 5 + 6.
Step 5: deploy/dokploy.yaml — stale "federated with mgmt.msbls.de" line
in the header comment replaced with the current host-scoped /login cookie
model. The mgmt federation never happened in projax anyway (projax
cookies are host-scoped, no Domain attribute).
Step 6: append a "DONE 2026-05-16" section to docs/plans/mgmt-teardown.md
recording every step's commit hash across both repos, the head-approved
deviation from §4 step 1 (SvelteKit-side redirect instead of Dokploy
Traefik labels — Dokploy config is UI-only), verification curls, and the
post-teardown janitorial that's out of scope for the worker (env-var
cleanup in Dokploy, DNS at m's leisure).
m/msbls.de side merged separately (86bfa61) — three commits:
2941dc4 (redirect), <previous step's commit covers the rest>.
caldav package:
- Event struct: UID, Summary, Start, End, AllDay, Location, Description,
Recurring, URL — read-only, no writeback
- ListEvents(ctx, calendarURL, ListEventsOpts{TimeMin, TimeMax}) issues
REPORT calendar-query with server-side <c:time-range> filter
- parseVEvents handles DATE vs DATE-TIME (via hasDateOnlyParam since
splitLine strips ;VALUE=DATE), RRULE-present → Recurring=true with NO
expansion (literal DTSTART only)
- 2 unit tests: full parse (DATE-TIME, all-day, recurring), hasDateOnlyParam
web dashboard:
- dashboardEvent / dashboardEventGroup types
- collectEvents fans out 4-worker pool across every caldav-list link,
fixed 7-day window from now, sort start-asc, cap 50, group by day
- dayLabelFor: Today / Tomorrow / weekday-day-month
- Events card on /dashboard between Tasks and Issues, with empty-collapse
- 2 integration tests with stubbed CalDAV: surfaces upcoming + DATE/RRULE
rendering; empty-collapse with no links
design.md §5 (CalDAV) + §Dashboard updated; mgmt-teardown plan's one
blocking gap is now closed.
Research-only output: audit of every /mgmt/* route + auth shell + server
libs in m/msbls.de, mapping to projax equivalents, gap list, migration
sequence, risk register.
Headline:
- 4 mgmt routes audited (root, /login, /self redirect, /mgmt/* guard)
- 3 already at parity on projax (login, auth guard, CalDAV VTODOs)
- 1 small gap (VEVENTs on dashboard) is the only blocker — Phase 3l candidate
- 2 further "gaps" (mWorkRepo cards, mBrian topic cards) recommended
park-forever; mgmt never shipped them either
- Cross-repo grep confirms ZERO external dependencies on /mgmt/* — only
one stale comment in projax/deploy/dokploy.yaml
No code touched. m reads the plan + decides go/no-go on Gap 1 + migration
sequence (§4) before any teardown work.
The manifest + icons + sw.js need to be reachable pre-auth so the iOS
'Add to Home Screen' flow can fetch the manifest from the /login page
(the browser fetches install metadata BEFORE the user signs in). Static
assets are embedded, non-sensitive, no leakage risk.
- web/static/manifest.webmanifest: name/short_name/start_url=/dashboard/
display=standalone/theme_color/background_color + three icons (192, 512,
512-maskable with ~12% safe-zone padding)
- web/static/sw.js: minimal SW — install caches /static/* shell assets,
fetch is network-first with cache fallback on GETs only, skips /mcp/
and non-GETs entirely. CACHE_NAME versioned for clean activate-time
prune.
- cmd/icongen: stdlib-only generator that produces the three PNG icons
from a stylised "p" monogram. Run once at brand-change, commit output.
- web.init() registers .webmanifest → application/manifest+json with
mime.AddExtensionType so Chrome accepts the manifest at all
- layout.tmpl + login.tmpl: manifest link, apple-touch-icon, theme-color,
apple-mobile-web-app-* metas, inline SW-register on load (silent on
failure — older browsers still work)
- design.md gets §"PWA install (Phase 3j)"; CLAUDE.md "Out of scope"
drops the Phase-3j line and adds push/background-sync as the
remaining Otto-PWA territory
- 4 new tests cover manifest MIME, sw.js delivery, all 3 icons, layout
meta tags