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
This commit is contained in:
mAi
2026-05-22 00:15:07 +02:00
parent ea0fb21069
commit 825894f511
6 changed files with 421 additions and 254 deletions

View File

@@ -115,11 +115,11 @@ func main() {
if mcpToken := os.Getenv("PROJAX_MCP_TOKEN"); mcpToken != "" {
mcpSrv := mcp.New("projax", "0.1.0", mcpToken, logger)
// srv carries the CalDAV client + store the timeline aggregation
// needs. Passing srv as the TimelineBuilder enables the `timeline`
// MCP tool; if CalDAV is disabled the tool still works (todo/event
// rows just don't surface, doc + creation rows do).
mcp.RegisterProjaxTools(mcpSrv, store.New(pool), srv)
// Phase 5a slice D wired the MCP timeline tool directly to the
// shared *aggregate.Aggregator instead of pointing back at
// *web.Server. Passing nil here disables the `timeline` tool
// cleanly; the rest of the projax MCP toolset stays usable.
mcp.RegisterProjaxTools(mcpSrv, store.New(pool), srv.Aggregator())
mcpMux := http.NewServeMux()
mcpSrv.Routes(mcpMux)
srv.MCP = mcpMux

View File

@@ -104,3 +104,82 @@ type Result struct {
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
}

View File

@@ -161,6 +161,13 @@ func EventStartLabel(ev caldav.Event) string {
return ev.Start.Local().Format("15:04")
}
// FormatPERDate is the inverse of parsePER's YYMMDD slice. Lives here
// (rather than in web/) so timeline-row builders outside web/ can render
// PER strings without dragging in the web package.
func FormatPERDate(t time.Time) string {
return t.UTC().Format("060102")
}
// EventDurationHint produces a "(N days)" badge for multi-day events and
// a "(Nh)" hint for timed events whose end is on the same day. Empty for
// all-day single-day events and events with no DTEND.

View File

@@ -2,13 +2,13 @@ package mcp
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"time"
"github.com/m/projax/web"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/store"
)
func mustTime(t *testing.T, s string) time.Time {
@@ -20,42 +20,50 @@ func mustTime(t *testing.T, s string) time.Time {
return tt
}
// fakeBuilder implements TimelineBuilder for unit tests. Each method records
// the last call so tests can assert the args passed through unchanged.
type fakeBuilder struct {
lastArgs web.TimelineArgs
payload *web.TimelinePayload
err error
// stubStore is a minimal LinkLister + ListByFilters-aware shim for unit
// tests. We embed the absence of *store.Store fields the timeline tool
// needs and surface only ListByFilters via a fake — the rest of mcp tests
// pass nil because they don't exercise this path.
type stubLinkLister struct{}
func (stubLinkLister) LinksByType(_ context.Context, _, _ string) ([]*store.ItemLink, error) {
return nil, nil
}
func (stubLinkLister) DatedLinksRange(_ context.Context, _, _ time.Time) ([]*store.ItemLinkWithItem, error) {
return nil, nil
}
func (stubLinkLister) ItemsCreatedInRange(_ context.Context, _, _ time.Time) ([]*store.Item, error) {
return nil, nil
}
func (f *fakeBuilder) BuildTimelinePayloadFromArgs(_ context.Context, args web.TimelineArgs) (*web.TimelinePayload, error) {
f.lastArgs = args
return f.payload, f.err
}
func newToolServer(t *testing.T, tl TimelineBuilder) *Server {
func newToolServer(t *testing.T, agg *aggregate.Aggregator) *Server {
t.Helper()
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
// Pass a nil *store.Store — none of the store-backed tools are exercised
// in this file; we only call the timeline tool.
RegisterProjaxTools(srv, nil, tl)
// Pass a nil *store.Store — the timeline tool's store-backed paths
// short-circuit cleanly on errors in this test surface (we just probe
// registration + arg parsing here).
RegisterProjaxTools(srv, nil, agg)
return srv
}
// TestTimelineToolUnregisteredWhenBuilderNil proves passing nil yields a
// missing "timeline" tool. This keeps existing MCP callers working without
// pulling in web/.
func TestTimelineToolUnregisteredWhenBuilderNil(t *testing.T) {
func newAggregator() *aggregate.Aggregator {
return aggregate.New(stubLinkLister{}, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
}
// TestTimelineToolUnregisteredWhenAggregatorNil proves passing nil yields a
// missing "timeline" tool — same kill-switch contract as the pre-Phase-5a
// TimelineBuilder=nil case.
func TestTimelineToolUnregisteredWhenAggregatorNil(t *testing.T) {
srv := newToolServer(t, nil)
if _, ok := srv.tools["timeline"]; ok {
t.Fatalf("expected timeline tool to be absent when builder is nil")
t.Fatalf("expected timeline tool to be absent when agg is nil")
}
}
// TestTimelineToolRegisteredWhenBuilderSet proves the tool shows up when
// a builder is plumbed through.
func TestTimelineToolRegisteredWhenBuilderSet(t *testing.T) {
srv := newToolServer(t, &fakeBuilder{payload: &web.TimelinePayload{}})
// TestTimelineToolRegisteredWhenAggregatorSet proves the tool shows up when
// an aggregator is plumbed through.
func TestTimelineToolRegisteredWhenAggregatorSet(t *testing.T) {
srv := newToolServer(t, newAggregator())
tool, ok := srv.tools["timeline"]
if !ok {
t.Fatalf("expected timeline tool to be registered")
@@ -68,133 +76,77 @@ func TestTimelineToolRegisteredWhenBuilderSet(t *testing.T) {
}
}
// TestTimelineToolForwardsArgs proves the JSON arguments flow through to
// BuildTimelinePayloadFromArgs unchanged. The fake records the last call.
func TestTimelineToolForwardsArgs(t *testing.T) {
now := mustTime(t, "2026-05-17T10:00:00Z")
fb := &fakeBuilder{
payload: &web.TimelinePayload{
From: now.AddDate(0, 0, -30),
To: now.AddDate(0, 0, 90),
ToInclusive: now.AddDate(0, 0, 89),
Order: "asc",
Kinds: []string{"doc", "event"},
BuiltAt: now,
},
}
srv := newToolServer(t, fb)
raw := []byte(`{
"from": "2026-04-01",
"to": "2026-08-01",
"order": "asc",
"kinds": ["doc","event"],
"tags": ["work"],
"mgmt": ["mai"],
"has": ["caldav-list"],
"status": ["active"],
"q": "paliad"
}`)
out, err := srv.tools["timeline"].Handler(context.Background(), raw)
if err != nil {
t.Fatalf("timeline tool call failed: %v", err)
}
if fb.lastArgs.From != "2026-04-01" || fb.lastArgs.To != "2026-08-01" {
t.Errorf("expected from/to to flow through, got %+v", fb.lastArgs)
}
if fb.lastArgs.Order != "asc" {
t.Errorf("expected order=asc, got %q", fb.lastArgs.Order)
}
if len(fb.lastArgs.Kinds) != 2 || fb.lastArgs.Kinds[0] != "doc" {
t.Errorf("expected kinds=[doc,event], got %v", fb.lastArgs.Kinds)
}
if fb.lastArgs.Q != "paliad" {
t.Errorf("expected q=paliad, got %q", fb.lastArgs.Q)
}
// And the result is the rendered timelineView, not the raw payload.
view, ok := out.(timelineView)
if !ok {
t.Fatalf("expected timelineView, got %T", out)
}
if view.Order != "asc" {
t.Errorf("view.Order = %q, want asc", view.Order)
}
if view.From != "2026-04-17" {
t.Errorf("view.From = %q, want 2026-04-17 (from payload not args)", view.From)
}
}
// TestTimelineToolPropagatesError proves builder errors bubble out as
// tool-handler errors (the JSON-RPC layer turns them into isError=true).
func TestTimelineToolPropagatesError(t *testing.T) {
fb := &fakeBuilder{err: errors.New("boom")}
srv := newToolServer(t, fb)
_, err := srv.tools["timeline"].Handler(context.Background(), []byte(`{}`))
// TestTimelineToolBadDateReturnsError proves a malformed `from` rejects with
// a tool-level error rather than panicking.
func TestTimelineToolBadDateReturnsError(t *testing.T) {
srv := newToolServer(t, newAggregator())
_, err := srv.tools["timeline"].Handler(context.Background(), []byte(`{"from":"not-a-date"}`))
if err == nil {
t.Fatalf("expected timeline tool to propagate builder error, got nil")
}
if err.Error() != "boom" {
t.Errorf("expected wrapped error to contain 'boom', got %q", err.Error())
}
}
// TestTimelineToolEmptyPayloadRendersEmptyDays exercises the empty-state
// path so we don't accidentally drop the Days slice when there's nothing.
func TestTimelineToolEmptyPayloadRendersEmptyDays(t *testing.T) {
now := mustTime(t, "2026-05-17T10:00:00Z")
fb := &fakeBuilder{
payload: &web.TimelinePayload{
From: now,
To: now.AddDate(0, 0, 1),
ToInclusive: now,
Order: "desc",
Kinds: []string{"todo", "event", "doc", "creation"},
BuiltAt: now,
},
}
srv := newToolServer(t, fb)
out, err := srv.tools["timeline"].Handler(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("timeline tool call failed: %v", err)
}
view, ok := out.(timelineView)
if !ok {
t.Fatalf("expected timelineView, got %T", out)
}
if view.Days == nil {
t.Errorf("Days slice should be initialised (empty array), not nil")
}
if view.TotalRows != 0 {
t.Errorf("empty payload should report TotalRows=0, got %d", view.TotalRows)
t.Fatalf("expected timeline tool to error on bad date")
}
}
// TestTimelineToolBadJSONReturnsError proves a malformed args body produces
// a tool-level error rather than panicking.
func TestTimelineToolBadJSONReturnsError(t *testing.T) {
srv := newToolServer(t, &fakeBuilder{payload: &web.TimelinePayload{}})
srv := newToolServer(t, newAggregator())
_, err := srv.tools["timeline"].Handler(context.Background(), []byte(`not-json`))
if err == nil {
t.Fatalf("expected timeline tool to error on bad JSON")
}
}
// TestTimelineViewSerializesTimes proves the timelineView shape uses string
// timestamps so the JSON-RPC envelope stays language-agnostic.
// TestTimelineViewSerializesTimes proves the buildTimelineView helper emits
// the expected stringified time shape so the JSON-RPC envelope stays
// language-agnostic.
func TestTimelineViewSerializesTimes(t *testing.T) {
now := mustTime(t, "2026-05-17T10:30:00Z")
v := toTimelineView(&web.TimelinePayload{
From: now.AddDate(0, 0, -3),
To: now.AddDate(0, 0, 4),
ToInclusive: now.AddDate(0, 0, 3),
Order: "desc",
Kinds: []string{"todo"},
BuiltAt: now,
TotalRows: 0,
})
v := buildTimelineView(nil, now.AddDate(0, 0, -3), now.AddDate(0, 0, 4), "desc", []string{aggregate.KindTodo}, 0, now)
if v.From != "2026-05-14" {
t.Errorf("From should be YYYY-MM-DD, got %q", v.From)
}
if v.To != "2026-05-21" {
t.Errorf("To should be YYYY-MM-DD, got %q", v.To)
}
if v.ToInclusive != "2026-05-20" {
t.Errorf("ToInclusive should be exclusive-To minus one day, got %q", v.ToInclusive)
}
if v.BuiltAt != "2026-05-17T10:30:00Z" {
t.Errorf("BuiltAt should be ISO-8601 UTC, got %q", v.BuiltAt)
}
if v.Days == nil {
t.Errorf("Days slice should be initialised (empty array), not nil")
}
}
// TestResolveTimelineKindsSanitises proves resolveTimelineKinds drops
// unknown values, dedupes, and sorts.
func TestResolveTimelineKindsSanitises(t *testing.T) {
got := resolveTimelineKinds([]string{"todo", "junk", "event", "todo", "DOC"})
want := []string{"doc", "event", "todo"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("pos %d: got %q, want %q", i, got[i], want[i])
}
}
}
// TestResolveTimelineWindowDefaults proves no args → default 30/+90 window.
func TestResolveTimelineWindowDefaults(t *testing.T) {
now := mustTime(t, "2026-05-21T12:00:00Z")
from, to, err := resolveTimelineWindow(TimelineArgs{}, now)
if err != nil {
t.Fatalf("default window: %v", err)
}
wantFrom := now.AddDate(0, 0, -timelineDefaultPastDays).Format("2006-01-02")
if from.Format("2006-01-02") != wantFrom {
t.Errorf("from = %s, want %s", from.Format("2006-01-02"), wantFrom)
}
wantTo := now.AddDate(0, 0, timelineDefaultFutureDays).Format("2006-01-02")
if to.Format("2006-01-02") != wantTo {
t.Errorf("to = %s, want %s", to.Format("2006-01-02"), wantTo)
}
}

View File

@@ -5,28 +5,35 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/store"
"github.com/m/projax/web"
)
// TimelineBuilder is the subset of *web.Server the MCP timeline tool needs.
// Kept as an interface so tests can drive the registration without spinning
// up a full web.Server (the existing mcp tests pass nil — the timeline tool
// is simply not registered in that case).
type TimelineBuilder interface {
BuildTimelinePayloadFromArgs(ctx context.Context, args web.TimelineArgs) (*web.TimelinePayload, error)
// TimelineArgs is the MCP-facing input shape for the `timeline` tool — a
// JSON-friendly equivalent of the URL query string web/timeline.go consumes.
type TimelineArgs struct {
From string `json:"from"` // YYYY-MM-DD, optional (default now-30d)
To string `json:"to"` // YYYY-MM-DD, optional (default now+90d)
Order string `json:"order"` // "asc" | "desc" (default desc)
Kinds []string `json:"kinds"` // subset of [todo,event,doc,creation]; empty = all
Tags []string `json:"tags"` // ALL must be present
Mgmt []string `json:"mgmt"` // ALL must be present
Has []string `json:"has"` // ALL ref-types present (caldav-list / gitea-repo)
Status []string `json:"status"` // ANY match (default ["active"]) — first value used for the store filter
Q string `json:"q"` // substring match against title/slug/aliases/content_md
IncludeExcluded bool `json:"include_excluded"` // ignore per-item timeline_exclude arrays
}
// RegisterProjaxTools wires every projax-flavoured tool onto an *mcp.Server.
// All tools delegate to *store.Store directly so business logic is shared
// with the web UI — no duplication. The optional tl argument adds the
// timeline tool when non-nil (it needs the web aggregation surface that
// fans out across CalDAV; passing nil keeps the rest of the toolset usable
// without web/ deps).
func RegisterProjaxTools(s *Server, st *store.Store, tl TimelineBuilder) {
// with the web UI — no duplication. The optional agg argument adds the
// timeline tool when non-nil (it needs the fan-out aggregator; passing nil
// keeps the rest of the toolset usable without aggregate deps).
func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) {
s.Register(Tool{
Name: "list_items",
Description: "List projax items with optional filters (parent_path, tags, management, kind, status, q, has_repo, has_caldav, public).",
@@ -187,7 +194,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, tl TimelineBuilder) {
}`),
Handler: treeTool(st),
})
if tl != nil {
if agg != nil {
s.Register(Tool{
Name: "timeline",
Description: "Chronological spine of dated content (VTODOs with DUE, VEVENTs, dated item_links, item-creation markers) braided into per-day groups. Same shape as projax's /timeline web view. All filters optional; defaults mirror the web page (past 30d through next 90d, desc order, all four kinds).",
@@ -198,41 +205,235 @@ func RegisterProjaxTools(s *Server, st *store.Store, tl TimelineBuilder) {
"to": {"type": "string", "description": "YYYY-MM-DD inclusive upper bound; default now+90d"},
"order": {"type": "string", "enum": ["asc","desc"], "description": "Day-group order; default desc"},
"kinds": {"type": "array", "items": {"type": "string", "enum": ["todo","event","doc","creation"]}, "description": "Narrow to a subset; empty = all four"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ALL tags must be present"},
"mgmt": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ANY of these management modes matches"},
"has": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ALL link kinds present (e.g. ['caldav-list'])"},
"status": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ANY status matches; default ['active']"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "ALL tags must be present"},
"mgmt": {"type": "array", "items": {"type": "string"}, "description": "ALL management modes must be present"},
"has": {"type": "array", "items": {"type": "string"}, "description": "ALL link kinds present (e.g. ['caldav-list'])"},
"status": {"type": "array", "items": {"type": "string"}, "description": "First value used for the store-level filter; default ['active']"},
"q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"}
}
}`),
Handler: timelineTool(tl),
Handler: timelineTool(st, agg),
})
}
}
// --- timeline ---
//
// The tool wraps web.*Server.BuildTimelinePayloadFromArgs. Output shape
// mirrors the web template's data structure with one small adaptation:
// time.Time values are serialised as ISO-8601 strings so the JSON-RPC
// envelope stays language-agnostic (the PWA's TypeScript client decodes
// these to Date via the same convention the existing list_items / get_item
// tools use).
// The tool runs the timeline aggregation end-to-end inside mcp/: store
// filters → fan-out via *aggregate.Aggregator → row projection
// BuildTimelineDays → serialised timelineView. Phase 5a slice D moved this
// off *web.Server.BuildTimelinePayloadFromArgs so mcp no longer points back
// at web/. Tree-filter dimensions (tags/mgmt/status/Q/has) are translated
// to store.SearchFilters; the MCP filter surface is slightly narrower than
// the web TreeFilter (single-value status, AND-management) — m's main MCP
// use cases (tag + status) round-trip identically.
func timelineTool(tl TimelineBuilder) ToolHandler {
const (
timelineDefaultPastDays = 30
timelineDefaultFutureDays = 90
)
func timelineTool(st *store.Store, agg *aggregate.Aggregator) ToolHandler {
return func(ctx context.Context, raw json.RawMessage) (any, error) {
var args web.TimelineArgs
var args TimelineArgs
if err := parseInput(raw, &args); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
payload, err := tl.BuildTimelinePayloadFromArgs(ctx, args)
now := time.Now()
from, to, err := resolveTimelineWindow(args, now)
if err != nil {
return nil, err
}
return toTimelineView(payload), nil
order := "desc"
if args.Order == "asc" {
order = "asc"
}
kinds := resolveTimelineKinds(args.Kinds)
items, err := resolveTimelineItems(ctx, st, args)
if err != nil {
return nil, err
}
if !args.IncludeExcluded {
items = filterByTimelineExclude(items, kinds)
}
hasCalDAVRefType := containsString(args.Has, aggregate.RefTypeCalDAV)
hasGitRefType := containsString(args.Has, aggregate.RefTypeGiteaRepo)
items, err = applyHasLinkFilters(ctx, st, items, hasCalDAVRefType, hasGitRefType)
if err != nil {
return nil, err
}
result := agg.All(ctx, items, aggregate.AllOpts{
Window: aggregate.Window{From: from, To: to},
Kinds: kinds,
})
rows := result.ToTimelineRows()
// Defensive narrowing for events: the aggregator passed `to` to
// CalDAV as TimeMax (exclusive) — but events occasionally arrive
// outside that bound when servers misinterpret RECURRENCE-ID. Strip
// them here so the MCP payload stays inside the documented window.
filtered := rows[:0]
for _, r := range rows {
if r.Date.Before(from) || !r.Date.Before(to) {
continue
}
filtered = append(filtered, r)
}
days := aggregate.BuildTimelineDays(filtered, aggregate.BuildOpts{
Now: now,
Order: order,
FadeAfter: 30 * 24 * time.Hour,
})
totalRows := 0
for _, d := range days {
totalRows += len(d.Rows)
}
return buildTimelineView(days, from, to, order, kinds, totalRows, now), nil
}
}
func resolveTimelineWindow(args TimelineArgs, now time.Time) (time.Time, time.Time, error) {
from := startOfDay(now.AddDate(0, 0, -timelineDefaultPastDays))
to := startOfDay(now.AddDate(0, 0, timelineDefaultFutureDays))
if v := strings.TrimSpace(args.From); v != "" {
t, err := time.Parse("2006-01-02", v)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("from must be YYYY-MM-DD: %w", err)
}
from = startOfDay(t)
}
if v := strings.TrimSpace(args.To); v != "" {
t, err := time.Parse("2006-01-02", v)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("to must be YYYY-MM-DD: %w", err)
}
to = startOfDay(t).AddDate(0, 0, 1)
}
return from, to, nil
}
func resolveTimelineKinds(in []string) []string {
seen := map[string]bool{}
out := []string{}
allowed := map[string]struct{}{
aggregate.KindTodo: {}, aggregate.KindEvent: {}, aggregate.KindDoc: {}, aggregate.KindCreation: {},
}
for _, k := range in {
k = strings.ToLower(strings.TrimSpace(k))
if _, ok := allowed[k]; !ok {
continue
}
if seen[k] {
continue
}
seen[k] = true
out = append(out, k)
}
sort.Strings(out)
return out
}
func resolveTimelineItems(ctx context.Context, st *store.Store, args TimelineArgs) ([]*store.Item, error) {
status := "active"
if len(args.Status) > 0 && strings.TrimSpace(args.Status[0]) != "" {
status = strings.TrimSpace(args.Status[0])
}
if status == "*" || status == "any" {
status = ""
}
return st.ListByFilters(ctx, store.SearchFilters{
Tags: trimList(args.Tags),
Management: trimList(args.Mgmt),
Status: status,
Q: strings.TrimSpace(args.Q),
})
}
func filterByTimelineExclude(items []*store.Item, kinds []string) []*store.Item {
if len(items) == 0 {
return items
}
out := items[:0:0]
for _, it := range items {
keep := false
for _, k := range kinds {
if !it.ExcludesTimelineKind(k) {
keep = true
break
}
}
if keep {
out = append(out, it)
}
}
return out
}
func applyHasLinkFilters(ctx context.Context, st *store.Store, items []*store.Item, needCalDAV, needGitea bool) ([]*store.Item, error) {
if !needCalDAV && !needGitea {
return items, nil
}
hasCalDAV := map[string]struct{}{}
hasGitea := map[string]struct{}{}
if needCalDAV {
links, err := st.LinksByRefType(ctx, aggregate.RefTypeCalDAV)
if err != nil {
return nil, err
}
for _, l := range links {
hasCalDAV[l.ItemID] = struct{}{}
}
}
if needGitea {
links, err := st.LinksByRefType(ctx, aggregate.RefTypeGiteaRepo)
if err != nil {
return nil, err
}
for _, l := range links {
hasGitea[l.ItemID] = struct{}{}
}
}
out := items[:0:0]
for _, it := range items {
if needCalDAV {
if _, ok := hasCalDAV[it.ID]; !ok {
continue
}
}
if needGitea {
if _, ok := hasGitea[it.ID]; !ok {
continue
}
}
out = append(out, it)
}
return out, nil
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func trimList(in []string) []string {
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
}
func startOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
// timelineView mirrors web.TimelinePayload but stringifies times so JSON
// consumers don't have to know about Go's RFC 3339 format vs the YYYY-MM-DD
// flavour the web template uses.
@@ -286,18 +487,21 @@ type timelineEventView struct {
DurationHint string `json:"duration_hint,omitempty"`
}
func toTimelineView(p *web.TimelinePayload) timelineView {
out := timelineView{
From: p.From.Format("2006-01-02"),
To: p.To.Format("2006-01-02"),
ToInclusive: p.ToInclusive.Format("2006-01-02"),
Order: p.Order,
Kinds: sliceOr(p.Kinds, []string{}),
TotalRows: p.TotalRows,
BuiltAt: p.BuiltAt.UTC().Format("2006-01-02T15:04:05Z"),
func buildTimelineView(days []aggregate.TimelineDay, from, to time.Time, order string, kinds []string, totalRows int, builtAt time.Time) timelineView {
if kinds == nil {
kinds = []string{}
}
out.Days = make([]timelineDayView, 0, len(p.Days))
for _, d := range p.Days {
out := timelineView{
From: from.Format("2006-01-02"),
To: to.Format("2006-01-02"),
ToInclusive: to.AddDate(0, 0, -1).Format("2006-01-02"),
Order: order,
Kinds: kinds,
TotalRows: totalRows,
BuiltAt: builtAt.UTC().Format("2006-01-02T15:04:05Z"),
}
out.Days = make([]timelineDayView, 0, len(days))
for _, d := range days {
dv := timelineDayView{
Date: d.DateKey,
Label: d.Label,
@@ -361,6 +565,7 @@ func toTimelineView(p *web.TimelinePayload) timelineView {
return out
}
// itemView is the JSON shape returned to MCP clients. We hand-roll it so the
// field names stay snake_case and the *time.Time / *string nullability
// renders as JSON null instead of being skipped (omitempty would hide them).

View File

@@ -2,7 +2,6 @@ package web
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
@@ -218,81 +217,6 @@ func parseTimelineQuery(r *http.Request, now time.Time) TimelineQuery {
return q
}
// TimelineArgs is the MCP-facing input shape — a struct equivalent of the
// URL query string consumed by parseTimelineQuery. JSON-tagged so callers
// can unmarshal a JSON object straight into it.
type TimelineArgs struct {
From string `json:"from"` // YYYY-MM-DD, optional (default now-30d)
To string `json:"to"` // YYYY-MM-DD, optional (default now+90d)
Order string `json:"order"` // "asc" | "desc" (default desc)
Kinds []string `json:"kinds"` // subset of [todo,event,doc,creation]; empty = all
Tags []string `json:"tags"` // tree-filter: ALL must be present
Mgmt []string `json:"mgmt"` // tree-filter: ANY match (incl. "unmanaged")
Has []string `json:"has"` // tree-filter: ALL ref-types present
Status []string `json:"status"` // tree-filter: ANY match (default ["active"])
Q string `json:"q"` // tree-filter: substring match
IncludeExcluded bool `json:"include_excluded"`// Phase 4f: ignore per-item timeline_exclude arrays
}
// BuildTimelinePayloadFromArgs is the MCP entrypoint to the timeline
// aggregation. It mirrors parseTimelineQuery but reads from a typed struct
// rather than an *http.Request. Returns the same TimelinePayload the web
// handler renders.
//
// Note: the in-memory cache is NOT consulted on the MCP path — the timeline
// data is small enough that re-aggregation per RPC call is cheaper than
// invalidating across two different keying schemes. The web cache stays
// scoped to the web handler.
func (s *Server) BuildTimelinePayloadFromArgs(ctx context.Context, args TimelineArgs) (*TimelinePayload, error) {
now := time.Now()
q := TimelineQuery{
Filter: TreeFilter{
Tags: args.Tags,
Management: args.Mgmt,
HasLinks: args.Has,
Status: args.Status,
Q: args.Q,
},
From: startOfDay(now.AddDate(0, 0, -timelineDefaultPastDays)),
To: startOfDay(now.AddDate(0, 0, timelineDefaultFutureDays)),
Order: "desc",
}
if len(q.Filter.Status) == 0 {
q.Filter.Status = []string{"active"}
}
if v := strings.TrimSpace(args.From); v != "" {
t, err := time.Parse("2006-01-02", v)
if err != nil {
return nil, fmt.Errorf("from must be YYYY-MM-DD: %w", err)
}
q.From = startOfDay(t)
}
if v := strings.TrimSpace(args.To); v != "" {
t, err := time.Parse("2006-01-02", v)
if err != nil {
return nil, fmt.Errorf("to must be YYYY-MM-DD: %w", err)
}
q.To = startOfDay(t).AddDate(0, 0, 1)
}
if args.Order == "asc" {
q.Order = "asc"
}
q.IncludeExcluded = args.IncludeExcluded
seen := map[string]bool{}
for _, k := range args.Kinds {
k = strings.ToLower(strings.TrimSpace(k))
switch k {
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
if !seen[k] {
seen[k] = true
q.Kinds = append(q.Kinds, k)
}
}
}
sort.Strings(q.Kinds)
return s.buildTimeline(ctx, q, now)
}
// handleTimeline renders the chronological spine at /timeline.
func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
now := time.Now()