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
186 lines
5.3 KiB
Go
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
|
|
}
|