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 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
## 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.
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.
- gitea pkg: CloseIssue, ReopenIssue, CreateIssue, AddComment + ErrForbidden
classification on 401/403. Client.do sets Content-Type on non-empty bodies.
- web handler: POST /i/{path}/issues/{close|reopen|comment|create}
- authorisation guard: repo form value must match a gitea-repo item_link
on the target item (rejects form-crafted writes to unrelated repos)
- HTMX re-renders issues_section partial after each action
- busts gitea per-repo cache (open + closed-recent) and dashboard 60s TTL
- templates: ✓ close button + reopen + collapsible comment box on every
issue row; "+ new issue" disclosure per repo
- design.md §6 retitled "Phase 2.d read; 3h writeback" with auth/perm
semantics + parked list
- 5 unit tests in gitea/, 5 integration tests in web/ covering happy paths
+ 403 → inline banner fallback
- gitea.GetRepo returns FullName + UpdatedAt for the stale-card probe
- dashboard collectStale: mai-managed items + linked-repo updated_at >60d
+ zero open tasks + zero open issues. Sorted longest-stale first, ≤20.
Multi-repo items need ALL repos quiet to count as stale. Reuses the
4-worker pool + the already-aggregated task/issue counts from the
Tasks / Issues cards (no extra DAV/Gitea fetches).
- dashboardCache.invalidate(key) busts a single filter's cache entry;
?refresh=1 routes through it so ↻ button gets fresh data.
- "updated Nm ago · cached/fresh" label + ↻ refresh link in dashboard
chrome.
- Empty-card collapse: with no filter + zero rows the card renders as
a one-line muted note instead of full chrome. Filter-active cards
keep chrome so m can tell "filter hid it" from "nothing there".
- design.md §"Dashboard / daily-driver view" extended with the 4 new
surfaces; the 3e "stale (3f)" out-of-scope line dropped.
- 5 new tests: stale-surface, stale-skip-recent, refresh-busts-cache,
empty-collapse, filter-keeps-chrome. 2 unit tests for gitea.GetRepo.