Files
projax/internal/aggregate/rows.go
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

186 lines
5.3 KiB
Go

// Package aggregate concentrates the "fan out across linked items" pattern
// that web/dashboard.go, web/timeline.go and mcp/tools.go all needed
// separate copies of before Phase 5a. The package takes a slice of
// *store.Item, walks each item's item_links of a given ref_type, and
// reduces CalDAV/Gitea fetches into typed rows. The result types here are
// the surface area; see aggregator.go for the methods and timeline_days.go
// for the day-grouping helpers.
package aggregate
import (
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/gitea"
"github.com/m/projax/store"
)
// Row-kind constants for TimelineRow.Kind. Mirror the values web/timeline.go
// used pre-Phase-5a so query strings, JSON payloads, and templates stay
// stable across the refactor.
const (
KindTodo = "todo"
KindEvent = "event"
KindDoc = "doc"
KindCreation = "creation"
)
// TodoRow is one VTODO fetched from a calendar that is linked to a projax
// item. The caldav.Todo is embedded so existing template field accesses
// (`.Todo.UID`, `.Todo.Summary`, …) keep resolving through Go's field
// promotion without a deeper dereference chain.
type TodoRow struct {
Item *store.Item
CalendarURL string
caldav.Todo
}
// EventRow is one VEVENT from a linked calendar within the caller's time
// window. caldav.Event is embedded for the same template-ergonomics
// reason as TodoRow.
type EventRow struct {
Item *store.Item
caldav.Event
}
// IssueRow is one open Gitea issue from a linked repo. The Repo string is
// the raw `owner/repo` ref as stored on the item_link (so dashboards can
// link out without re-parsing).
type IssueRow struct {
Item *store.Item
Repo string
gitea.Issue
}
// DocRow is a dated item_link surfaced from projax.item_links — the
// document/PER source on the timeline.
type DocRow struct {
Item *store.Item
Link *store.ItemLink
}
// CreationRow marks an item's creation timestamp. The anchor date is
// Item.CreatedAt; no extra payload is needed.
type CreationRow struct {
Item *store.Item
}
// TimelineRow is one entry on the chronological spine. Exactly one of the
// pointer slots (Todo/Event/Doc/Creation) is non-nil per row, chosen by
// Kind. The flat display fields (CalendarURL/StartLabel/DurationHint/Link/
// PER) are populated where the corresponding kind needs them — they are
// kept flat so the existing html/template syntax (`.PER`, `.CalendarURL`,
// `.Link.RefID`) keeps working unchanged.
type TimelineRow struct {
Date time.Time
Kind string
Item *store.Item
ItemPath string
Todo *TodoRow
Event *EventRow
Doc *DocRow
Creation *CreationRow
// Template-friendly flat slots. Only the slot for this row's Kind is
// populated; others stay zero.
CalendarURL string
StartLabel string
DurationHint string
Link *store.ItemLink
PER string
FarFuture bool
}
// Result bundles every typed row kind a caller might want from a single
// pass. The MCP timeline tool consumes it; web/timeline.go and
// web/dashboard.go call the individual methods instead and only fetch
// what they render.
type Result struct {
Todos []TodoRow
Events []EventRow
Issues []IssueRow
Docs []DocRow
Creations []CreationRow
}
// ToTimelineRows projects a Result into the flat TimelineRow sum-type the
// /timeline view and MCP timeline tool both consume. Rows are emitted in
// fetch order — caller hands them to BuildTimelineDays for grouping +
// sorting. Each row's Date is the kind-appropriate anchor (Due/LastModified
// for todos, Start for events, event_date for docs, CreatedAt for
// creations). Window narrowing already happened inside the Aggregator
// methods; this helper just projects shapes.
func (r Result) ToTimelineRows() []TimelineRow {
out := make([]TimelineRow, 0, len(r.Todos)+len(r.Events)+len(r.Docs)+len(r.Creations))
for _, tr := range r.Todos {
open := tr.Todo.Status != "COMPLETED" && tr.Todo.Status != "CANCELLED"
var anchor *time.Time
if open {
anchor = tr.Todo.Due
} else if tr.Todo.LastModified != nil {
anchor = tr.Todo.LastModified
} else {
anchor = tr.Todo.Due
}
if anchor == nil {
continue
}
row := tr
out = append(out, TimelineRow{
Date: startOfDay(anchor.Local()),
Kind: KindTodo,
Item: tr.Item,
ItemPath: tr.Item.PrimaryPath(),
Todo: &row,
CalendarURL: tr.CalendarURL,
})
}
for _, ev := range r.Events {
row := ev
out = append(out, TimelineRow{
Date: startOfDay(ev.Event.Start.Local()),
Kind: KindEvent,
Item: ev.Item,
ItemPath: ev.Item.PrimaryPath(),
Event: &row,
StartLabel: EventStartLabel(ev.Event),
DurationHint: EventDurationHint(ev.Event),
})
}
for _, d := range r.Docs {
if d.Link == nil || d.Link.EventDate == nil {
continue
}
base := d.Item.PrimaryPath()
per := base + "." + FormatPERDate(*d.Link.EventDate)
row := d
out = append(out, TimelineRow{
Date: startOfDay(*d.Link.EventDate),
Kind: KindDoc,
Item: d.Item,
ItemPath: base,
Doc: &row,
Link: d.Link,
PER: per,
})
}
for _, c := range r.Creations {
row := c
out = append(out, TimelineRow{
Date: startOfDay(c.Item.CreatedAt),
Kind: KindCreation,
Item: c.Item,
ItemPath: c.Item.PrimaryPath(),
Creation: &row,
})
}
return out
}