Commit Graph

137 Commits

Author SHA1 Message Date
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
mAi
28ac919e01 feat(calendar): polish grid styling + mobile breakpoint + design doc
Phase 5e slice B. Polish pass on the month grid: HTMX-swappable filter
chip strip, mobile breakpoint that collapses the 7-column table into a
vertical list of days, refined CSS for hover/today/adjacent-month, and
the docs/design.md §17 entry that pins the contract.

Templates:
- web/templates/calendar_section.tmpl (new) — extracted #calendar-section
  partial. Houses the filter chip strip (form with hx-get=/calendar
  hx-target=#calendar-section), counts line, and the grid <table>.
- web/templates/calendar.tmpl trimmed to the page chrome (h1, prev/next
  nav, today link) + {{template "calendar-section" .}}. Chrome stays
  outside the HTMX swap because chip filtering preserves the month
  context.

web/calendar.go:
- handleCalendar now branches on HX-Request: HTMX → calendar_section
  fragment, full GET → calendar (chrome + section). Same pattern as
  /timeline and /dashboard.
- calendarDay gains LongLabel ("Mi., 14. Mai") — populated by new
  formatCalendarLongLabel helper. Hidden on desktop via CSS; revealed at
  the ≤480px breakpoint where the column header drops out.

web/server.go:
- Calendar template now bundles the section partial. New calendar_section
  template registered as a standalone fragment for HTMX swaps. New
  render() entry case "calendar_section" → "calendar-section".

web/static/style.css:
- Refined .calendar-nav (tabular numerals, transition, no surface-alt
  fallback fighting the theme).
- New #calendar-filterbar layout (flex, gap, counts pushed right).
- .calendar-cell hover background, adjacent-month opacity bump (0.4→0.45
  + 0.7 on hover so it doesn't disappear when reading lead-in days).
- .today-pill line-height fix so it sits flush in the cell header.
- .cell-row min-width on .time slot, tighter line-height, 0.82em font.
- @media (max-width: 480px) breakpoint: grid + thead + tbody + tr + th +
  td all → display:block. Thead hidden; .day-label revealed. Adjacent-
  month cells DISPLAY:NONE on mobile (their value on desktop is grid
  rectangularity; on a vertical list they're just confusing). Cell rows
  bump to 0.95em for readability.

docs/design.md:
- New §17 Calendar view (Phase 5e). Documents sources (VEVENT/VTODO/
  dated item_links), what's excluded (creation markers + Gitea + untimed),
  the layout calculation, filter integration via TreeFilter, cache key,
  the mobile breakpoint, and the German register choice.

Tests (additive, all passing):
- TestFormatCalendarLongLabel — pins the German weekday + day + month
  abbreviation (Mo./Di./.../So., 1.–31., Jan/Feb/März/.../Dez).
- TestCalendarFilterChipStripRenders — chip strip present + hx-target +
  hx-get + hidden month input + tag/mgmt/kind multi-selects.
- TestCalendarHTMXReturnsSectionOnly — HX-Request returns #calendar-
  section only (no <body>, no .calendar-nav chrome).
- TestCalendarCellCarriesLongLabel — May 4 cell ("Mo., 4. Mai") present
  in HTML so the mobile breakpoint CSS reveal works.

Net: +315 / -61.
2026-05-22 12:07:25 +02:00
mAi
45e3e2a891 Merge branch 'mai/knuth/phase-5e-calendar' (phase 5e slice A: month-grid calendar view) 2026-05-22 12:01:10 +02:00
mAi
e5dd31144a feat(calendar): /calendar month-grid view with VEVENT/VTODO/DOC sources
Phase 5e slice A. New surface alongside /timeline (chronological spine) and
/dashboard (today/week buckets) — a 7×N month grid that answers "show me my
month at a glance." Monday-leading weeks per the German convention, with
adjacent-month lead-in/trail-out cells greyed to keep the grid rectangular.

web/calendar.go (new):
- calendarPayload / calendarWeek / calendarDay / calendarRow types.
- parseCalendarQuery: reads ?month=YYYY-MM (defaults to current month),
  ?kind=event,todo,doc (defaults to all three; creation excluded by design),
  inherits the full TreeFilter via ParseTreeFilter so ?tag=work / ?mgmt=mai
  scope identically to /timeline.
- handleCalendar: TTL-cached at 60s per (filter, month, kinds).
- buildCalendar: items → TreeFilter narrow → aggregate.{Todos,Events,Docs}
  for the grid window → bin by YYYY-MM-DD → stable per-cell sort (timed
  first, then by kind rank, then summary).
- layoutCalendarWeeks: pure function building the rectangular grid; lead
  days computed from mondayWeekday(monthStart), trailing pad from
  (totalCells % 7). Each cell caps visible rows at 3 and surfaces the
  remainder via ExtraCount so the template emits a "+N more" drill-down
  link to /timeline scoped to that single day.
- formatMonthLabel: German month names (Mai, März, Juni, Dezember).
- docSummary: prefers item_link.note, falls back to last path segment of
  ref_id, then ref_id verbatim.

web/templates/calendar.tmpl (new):
- Grid markup as a <table role="grid"> — semantically a calendar grid,
  works without JS, and the layout calc already pre-chunks weeks.
- Header carries h1 (German month label), prev/next/today nav, and the
  cached/fresh + total-rows counts line.
- Each cell: .calendar-cell, .is-today, .adjacent-month conditional
  classes; .today-pill rendered when IsToday.
- Rows: .row-event / .row-todo (+ .overdue) / .row-doc with a leading
  time slot and an <a> to /i/<itemPath>.
- "+N more" link drills into /timeline?from=YYYY-MM-DD&to=YYYY-MM-DD.

web/static/style.css:
- ~95 lines of minimal grid styling: 7-column table-fixed, 110px cell
  height, today border accent, adjacent-month opacity 0.4, per-kind row
  border-left colour. Slice B will refine cell sizing + add the mobile
  breakpoint + chip strip.

web/server.go:
- New calendar template parse (layout.tmpl + calendar.tmpl), calendar
  field on Server (cache.TTLCache[*calendarPayload]), route registration
  GET /calendar.

web/templates/layout.tmpl:
- Nav anchor added between timeline and graph.

web/server_test.go:
- TestLayoutHasViewportMeta now probes /calendar too.

Tests (web/calendar_test.go — pure unit):
- TestCalendarLayoutMondayLead, TestCalendarLayoutTrailingPad: grid math
  for Friday-leading (May 2026) and Monday-trailing (June 2026) months.
- TestCalendarTodayCell: IsToday flag lands on the right cell only.
- TestCalendarCellRowOverflow: >3 seeded rows → 3 visible + ExtraCount=2.
- TestMondayWeekday: Sunday→6, Monday→0 conversion.
- TestFormatMonthLabel: German month strings.
- TestParseCalendarQuery{Defaults,MonthParam,KindFilter}: URL parsing.

Tests (web/calendar_integration_test.go — DB integration):
- TestCalendarRendersMonthGrid: empty-data smoke through srv.Routes().
- TestCalendarSurfacesDatedLink: seeds an item_link on today, asserts
  the rendered cell carries the note text + .is-today class.
- TestCalendarFilterScopeByTag: seeds two tagged items, confirms
  ?tag=<work-tag> only renders the work-item rows.
- TestCalendarAdjacentMonthDays: May 2026 (Fri-leading) renders the
  Apr 27 lead-in cell with .adjacent-month.
- TestCalendarNavPrevNextLinks: prev → 2026-04, next → 2026-06 links
  present.

Slice B follows: refined CSS, mobile breakpoint (≤480px → vertical list
of days), HTMX filter chip strip, docs/design.md §17.
2026-05-22 12:01:03 +02:00
mAi
76efdbeb73 Merge branch 'mai/knuth/phase-5d-mcp-errors' (phase 5d slice B: ValidationError surfaces via .error.data) 2026-05-22 11:51:01 +02:00
mAi
8370454b66 refactor(mcp): typed ValidationError surfaces via .error.data
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"`.
2026-05-22 11:50:57 +02:00
mAi
de1140a0f0 Merge branch 'mai/knuth/phase-5d-mcp-errors' (phase 5d slice A: widen ToolHandler signature) 2026-05-22 11:47:35 +02:00
mAi
d7438ba89e refactor(mcp): widen ToolHandler signature to return *ToolError with .data support
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.
2026-05-22 11:46:19 +02:00
mAi
982481c023 Merge branch 'mai/knuth/phase-5f-fix-dockerignore' (phase 5f: .dockerignore fix for healthz SHA) 2026-05-22 11:36:59 +02:00
mAi
7ebd435044 fix(docker): include .git in build context so healthz reports real SHA
.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.
2026-05-22 11:36:44 +02:00
mAi
3fbf71f7b3 Merge branch 'mai/knuth/phase-5c-itemwrite' (phase 5c slice C: MCP write tools validate) 2026-05-22 00:37:42 +02:00
mAi
63efc23843 refactor(mcp): validate item writes via internal/itemwrite/
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
2026-05-22 00:37:24 +02:00
mAi
c84a1f9d4b Merge branch 'mai/knuth/phase-5c-itemwrite' (phase 5c slice B: web write paths validate) 2026-05-22 00:36:20 +02:00
mAi
9ee26002f8 refactor(web): validate item writes via internal/itemwrite/
Phase 5c slice B. Three web write paths now pre-validate via the
itemwrite package before calling store.Create / Update / Reparent.

- handleDetailWrite: ValidateFormat + ValidateAgainstStore on (title,
  slug, status, parent_ids) before the store.Update call.
- handleNewSubmit: same pair, scoped to a new item (no ID yet).
- handleReparent: format + DB-aware checks; validator catches
  self-parent, unknown-parent, cycle. The existing
  "parent_ids required" guard stays as a separate fast-fail.
- handleBulkApply: set_status pre-flight against the validator. Other
  bulk actions (add_tag / set_mgmt / set_public / timeline_todos)
  don't mutate validated fields so they pass through unchanged.

On ValidationError the handler responds 400 + a human banner keyed on
err.Kind via the new s.itemWriteFailure helper. itemWriteBannerCopy
centralises the Kind→copy mapping so web/server.go and web/bulk.go
share one phrasing.

No web test source touched — all web/*_test.go assert on observable
behaviour (HTTP status, response body) and the new validator path
preserves both for valid AND invalid inputs the SQL trigger would
have rejected anyway. Tests stay green unmodified.

Task: t-projax-5c-itemwrite
2026-05-22 00:36:14 +02:00
mAi
4cc5191eed Merge branch 'mai/knuth/phase-5c-itemwrite' (phase 5c slice A: internal/itemwrite/) 2026-05-22 00:34:00 +02:00
mAi
df65e4b586 feat(itemwrite): introduce internal/itemwrite/ validator
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
2026-05-22 00:33:54 +02:00
mAi
062feea96f Merge branch 'mai/knuth/phase-5b-cache' (phase 5b slice C: timelineCache → cache.TTLCache) 2026-05-22 00:27:14 +02:00
mAi
d518978edb refactor(timeline): cache via internal/cache.TTLCache
Phase 5b slice C. Mirror of slice B for the timeline cache:
timelineCache + cachedTimeline + newTimelineCache deleted. The Server's
timeline field is now `*cache.TTLCache[*TimelinePayload]` constructed
via `cache.NewTTL[*TimelinePayload](timelineCacheTTL)`. Call sites
across web/{timeline,caldav,dashboard,links}.go renamed:

- s.timeline.get(k)        → s.timeline.Get(k)
- s.timeline.set(k, p)     → s.timeline.Set(k, p)
- s.timeline.invalidateAll → s.timeline.InvalidateAll
- (timeline never used keyed invalidate, so no .Invalidate rename)

Removes the unused `sync` import from web/timeline.go. The 50-line
timelineCache struct + four methods are gone; the file shrinks by
~50 lines.

All web/timeline_*test.go pass unmodified.

Task: t-projax-5b-cache
2026-05-22 00:27:08 +02:00
mAi
66cd46220a Merge branch 'mai/knuth/phase-5b-cache' (phase 5b slice B: dashboardCache → cache.TTLCache) 2026-05-22 00:25:18 +02:00
mAi
085e672dd5 refactor(dashboard): cache via internal/cache.TTLCache
Phase 5b slice B. dashboardCache deleted. The Server's dashboard field
is now `*cache.TTLCache[*dashboardPayload]` constructed via
`cache.NewTTL[*dashboardPayload](dashboardCacheTTL)`. All call sites
renamed:

- s.dashboard.get(k)         → s.dashboard.Get(k)
- s.dashboard.set(k, p)      → s.dashboard.Set(k, p)
- s.dashboard.invalidate(k)  → s.dashboard.Invalidate(k)
- s.dashboard.invalidateAll  → s.dashboard.InvalidateAll
  (across web/dashboard.go, web/server.go, web/caldav.go,
   web/links.go, web/gitea_writeback.go)

The 64-line dashboardCache struct + methods are gone; the dashboard
file shrinks by ~63 lines. TTL constant lifted out to
`dashboardCacheTTL = 60 * time.Second` so the const lives next to its
semantics rather than a magic-number literal in New().

All web/dashboard_*test.go pass unmodified.

Task: t-projax-5b-cache
2026-05-22 00:25:13 +02:00
mAi
cda0f1b9c7 Merge branch 'mai/knuth/phase-5b-cache' (phase 5b slice A: internal/cache/) 2026-05-22 00:23:55 +02:00
mAi
599d9a5bb0 feat(cache): introduce internal/cache/ TTLCache[V]
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
2026-05-22 00:23:50 +02:00