5 Commits

Author SHA1 Message Date
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
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
mAi
825894f511 refactor(mcp): wire aggregator directly, drop TimelineBuilder seam
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
2026-05-22 00:15:07 +02:00
mAi
326f4c83b9 feat(aggregate): introduce internal/aggregate/ for fan-out + day-grouping
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
2026-05-21 23:57:54 +02:00
mAi
3901a1888e feat(phase 3f graph): visual /graph view, server-rendered SVG, layered DAG
- internal/graph package: pure-Go layered top-down DAG layout
  - LayerByLongestPath (multi-parent sits at max(parent-layer)+1)
  - OrderInLayer (slug-sort, deterministic)
  - Compute returns positions + edges + canvas size
  - cycle-safe (depth-cap)
- web/graph.go handler: filter chips reused from tree_filter
  - dim mode default (opacity 0.15 on non-matches)
  - ?isolate=1 hides non-matches + prunes orphaned edges
  - ?download=svg serves raw SVG attachment
- graph_svg.tmpl renders inline SVG: border colour by management
  (mai blue / self green / external orange / mixed dashed purple),
  opacity by status, tag pills, ×N multi-parent badge, click-navigate
- nav adds "graph" link; design.md §"Graph view" documents the surface
- 4 integration tests cover render, dim, isolate, SVG download
- 6 layout unit tests cover layering, ordering, cycle-guard
2026-05-15 19:06:57 +02:00