feat(phase 4c-B slice 1): MCP timeline tool wrapping the chronological view

Exposes projax's /timeline aggregation (Phase 4a) over MCP-RPC so the
PWA (mAi#228) can fetch it without a session cookie against
projax.msbls.de. Same tool surface m's other agents already use.

## Changes

- web/timeline.go: export TimelineQuery, TimelinePayload, add typed
  TimelineArgs + BuildTimelinePayloadFromArgs entrypoint. The web cache
  stays scoped to the HTTP handler; MCP path re-aggregates per call.
- mcp/tools.go: register `timeline` tool when a TimelineBuilder is
  passed. Output mirrors the web template's shape but stringifies
  timestamps to YYYY-MM-DD or ISO-8601 UTC so JSON-RPC consumers don't
  need Go time semantics.
- mcp/tools_test.go: existing tests pass nil builder (no behaviour
  change to the rest of the tool surface).
- mcp/timeline_test.go: 7 unit tests covering registration, arg
  forwarding, error propagation, empty payload, and view serialisation.
- cmd/projax/main.go: pass the running *web.Server as the third arg so
  the timeline tool registers on the live server (CalDAV-aware).
- docs/design.md §14: documents the tool, schema, output shape, cache
  semantics.

## Out of scope

- Caching the MCP path (rejected — re-aggregation per call is cheap;
  divergent cache keys aren't worth invalidation complexity).
- Wrapping CalDAV writes (S2 — separate slice once m greenlights).
- PWA backend bridge + frontend (S2/S3 — m/mAi side, after this deploys).
This commit is contained in:
mAi
2026-05-17 18:42:48 +02:00
parent 2694623da1
commit 8b51746183
6 changed files with 540 additions and 19 deletions

View File

@@ -115,7 +115,11 @@ func main() {
if mcpToken := os.Getenv("PROJAX_MCP_TOKEN"); mcpToken != "" {
mcpSrv := mcp.New("projax", "0.1.0", mcpToken, logger)
mcp.RegisterProjaxTools(mcpSrv, store.New(pool))
// 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)
mcpMux := http.NewServeMux()
mcpSrv.Routes(mcpMux)
srv.MCP = mcpMux

View File

@@ -573,6 +573,63 @@ projax ships with an explicit dark / light toggle and a 1-year cookie that remem
- Per-page theme overrides — one global theme is enough.
- CSS transitions on the swap. The flip is instant; that's intentional.
## 14. Timeline MCP tool (Phase 4c-B Slice 1)
The chronological view (§12) is now reachable from MCP. The PWA's Otto-projax surface (mAi#228) consumes it to render `/projax/timeline` on m's phone without needing a session cookie against `projax.msbls.de`.
**Tool name:** `timeline`. Registered in `mcp/tools.go` when `RegisterProjaxTools` receives a non-nil `TimelineBuilder` (the running `*web.Server` satisfies it via `BuildTimelinePayloadFromArgs`). When the third arg is nil — e.g. in package tests that don't need timeline — the tool is silently omitted; the rest of the surface stays usable.
**Input schema** mirrors the URL query string of the web `/timeline` route, lifted to a typed JSON object:
```json
{
"from": "YYYY-MM-DD (optional, default now-30d)",
"to": "YYYY-MM-DD (optional, default now+90d)",
"order": "asc|desc (optional, default desc)",
"kinds": ["todo","event","doc","creation"],
"tags": ["work","dev"],
"mgmt": ["mai"],
"has": ["caldav-list"],
"status": ["active"],
"q": "paliad"
}
```
**Output shape** (see `mcp.timelineView` in `tools.go`):
```json
{
"days": [{
"date": "2026-05-17",
"label": "Today",
"sticky": "today",
"rows": [{
"kind": "todo|event|doc|creation",
"item_path": "work.paliad",
"item_title": "paliad",
"far_future": false,
"todo": {"uid","calendar_url","summary","status","due","priority"},
"event": {"uid","summary","start","start_label","end","all_day","location","recurring","duration_hint"},
"link": {linkView},
"per": "work.paliad.260517"
}]
}],
"from": "2026-04-17",
"to": "2026-08-16",
"to_inclusive": "2026-08-15",
"order": "desc",
"kinds": ["todo","event","doc","creation"],
"total_rows": 42,
"built_at": "2026-05-17T16:30:00Z"
}
```
Times stringify into either YYYY-MM-DD (date-only) or full RFC 3339 UTC (timed), matching the convention the existing `list_items` / `get_item` tools use. Polymorphic — JSON consumers can treat the timestamp strings as opaque or parse them per-locale.
**Cache:** the MCP path bypasses the web-side 90 s in-memory cache. Re-aggregation per RPC call is cheap (single DB pass + the same 4-worker CalDAV fan-out), and the two cache keying schemes diverge (URL filter-state vs. JSON args). Skipping the cache here keeps invalidation simple and the data fresher.
**Auth:** same `Authorization: Bearer ${PROJAX_MCP_TOKEN}` as the rest of `/mcp/rpc`. No CORS allowlist needed — consumers (PWA backend, future agents) call projax server-to-server.
## 9. Phase-1 deliverable checklist
- [ ] `projax.items` + `projax.item_links` migrations in `db/migrations/`

200
mcp/timeline_test.go Normal file
View File

@@ -0,0 +1,200 @@
package mcp
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"time"
"github.com/m/projax/web"
)
func mustTime(t *testing.T, s string) time.Time {
t.Helper()
tt, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("mustTime: %v", err)
}
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
}
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 {
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)
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) {
srv := newToolServer(t, nil)
if _, ok := srv.tools["timeline"]; ok {
t.Fatalf("expected timeline tool to be absent when builder 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{}})
tool, ok := srv.tools["timeline"]
if !ok {
t.Fatalf("expected timeline tool to be registered")
}
if tool.Description == "" {
t.Errorf("timeline tool should carry a Description")
}
if len(tool.InputSchema) == 0 {
t.Errorf("timeline tool should carry an InputSchema")
}
}
// 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(`{}`))
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)
}
}
// 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{}})
_, 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.
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,
})
if v.From != "2026-05-14" {
t.Errorf("From should be YYYY-MM-DD, got %q", v.From)
}
if v.BuiltAt != "2026-05-17T10:30:00Z" {
t.Errorf("BuiltAt should be ISO-8601 UTC, got %q", v.BuiltAt)
}
}

View File

@@ -9,12 +9,24 @@ import (
"time"
"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)
}
// 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.
func RegisterProjaxTools(s *Server, st *store.Store) {
// 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) {
s.Register(Tool{
Name: "list_items",
Description: "List projax items with optional filters (parent_path, tags, management, kind, status, q, has_repo, has_caldav).",
@@ -168,6 +180,178 @@ func RegisterProjaxTools(s *Server, st *store.Store) {
}`),
Handler: treeTool(st),
})
if tl != 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).",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"from": {"type": "string", "description": "YYYY-MM-DD inclusive lower bound; default now-30d"},
"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']"},
"q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"}
}
}`),
Handler: timelineTool(tl),
})
}
}
// --- 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).
func timelineTool(tl TimelineBuilder) ToolHandler {
return func(ctx context.Context, raw json.RawMessage) (any, error) {
var args web.TimelineArgs
if err := parseInput(raw, &args); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
payload, err := tl.BuildTimelinePayloadFromArgs(ctx, args)
if err != nil {
return nil, err
}
return toTimelineView(payload), nil
}
}
// 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.
type timelineView struct {
Days []timelineDayView `json:"days"`
From string `json:"from"` // YYYY-MM-DD inclusive
To string `json:"to"` // YYYY-MM-DD exclusive (matches internal bound)
ToInclusive string `json:"to_inclusive"` // YYYY-MM-DD inclusive (for display)
Order string `json:"order"`
Kinds []string `json:"kinds"`
TotalRows int `json:"total_rows"`
BuiltAt string `json:"built_at"`
}
type timelineDayView struct {
Date string `json:"date"` // YYYY-MM-DD
Label string `json:"label"`
Sticky string `json:"sticky"`
Rows []timelineRowView `json:"rows"`
}
type timelineRowView struct {
Kind string `json:"kind"`
ItemPath string `json:"item_path"`
ItemTitle string `json:"item_title"`
FarFuture bool `json:"far_future,omitempty"`
Todo *timelineTodoView `json:"todo,omitempty"`
Event *timelineEventView `json:"event,omitempty"`
Link *linkView `json:"link,omitempty"`
PER string `json:"per,omitempty"`
}
type timelineTodoView struct {
UID string `json:"uid"`
CalendarURL string `json:"calendar_url"`
Summary string `json:"summary"`
Status string `json:"status"`
Due string `json:"due,omitempty"` // YYYY-MM-DD or ISO when timed
Priority int `json:"priority,omitempty"`
}
type timelineEventView struct {
UID string `json:"uid"`
Summary string `json:"summary"`
Start string `json:"start"` // ISO-8601 local
StartLabel string `json:"start_label"`
End string `json:"end,omitempty"`
AllDay bool `json:"all_day,omitempty"`
Location string `json:"location,omitempty"`
Recurring bool `json:"recurring,omitempty"`
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"),
}
out.Days = make([]timelineDayView, 0, len(p.Days))
for _, d := range p.Days {
dv := timelineDayView{
Date: d.DateKey,
Label: d.Label,
Sticky: d.Sticky,
}
dv.Rows = make([]timelineRowView, 0, len(d.Rows))
for _, r := range d.Rows {
rv := timelineRowView{
Kind: r.Kind,
ItemPath: r.ItemPath,
FarFuture: r.FarFuture,
}
if r.Item != nil {
rv.ItemTitle = r.Item.Title
}
switch r.Kind {
case "todo":
tv := timelineTodoView{
UID: r.Todo.UID,
CalendarURL: r.CalendarURL,
Summary: r.Todo.Summary,
Status: r.Todo.Status,
Priority: r.Todo.Priority,
}
if r.Todo.Due != nil {
if r.Todo.Due.Hour() == 0 && r.Todo.Due.Minute() == 0 && r.Todo.Due.Second() == 0 {
tv.Due = r.Todo.Due.Format("2006-01-02")
} else {
tv.Due = r.Todo.Due.UTC().Format("2006-01-02T15:04:05Z")
}
}
rv.Todo = &tv
case "event":
ev := timelineEventView{
UID: r.Event.UID,
Summary: r.Event.Summary,
Start: r.Event.Start.UTC().Format("2006-01-02T15:04:05Z"),
StartLabel: r.StartLabel,
AllDay: r.Event.AllDay,
Location: r.Event.Location,
Recurring: r.Event.Recurring,
DurationHint: r.DurationHint,
}
if !r.Event.End.IsZero() {
ev.End = r.Event.End.UTC().Format("2006-01-02T15:04:05Z")
}
rv.Event = &ev
case "doc":
if r.Link != nil {
lv := toLinkView(r.Link)
rv.Link = &lv
}
rv.PER = r.PER
case "creation":
// no extra payload — ItemPath + ItemTitle cover the row
}
dv.Rows = append(dv.Rows, rv)
}
out.Days = append(out.Days, dv)
}
return out
}
// itemView is the JSON shape returned to MCP clients. We hand-roll it so the

View File

@@ -40,7 +40,9 @@ func mustDBServer(t *testing.T) (*Server, *pgxpool.Pool) {
}
st := store.New(pool)
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
RegisterProjaxTools(srv, st)
// The MCP tests don't need a real timeline builder — passing nil keeps
// the timeline tool unregistered without requiring a web.Server here.
RegisterProjaxTools(srv, st, nil)
t.Cleanup(func() { pool.Close() })
return srv, pool
}

View File

@@ -2,6 +2,7 @@ package web
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
@@ -41,14 +42,14 @@ type timelineCache struct {
type cachedTimeline struct {
at time.Time
payload *timelinePayload
payload *TimelinePayload
}
func newTimelineCache(ttl time.Duration) *timelineCache {
return &timelineCache{ttl: ttl, rows: map[string]cachedTimeline{}}
}
func (c *timelineCache) get(key string) (*timelinePayload, bool) {
func (c *timelineCache) get(key string) (*TimelinePayload, bool) {
if c == nil {
return nil, false
}
@@ -65,7 +66,7 @@ func (c *timelineCache) get(key string) (*timelinePayload, bool) {
return v.payload, true
}
func (c *timelineCache) set(key string, p *timelinePayload) {
func (c *timelineCache) set(key string, p *TimelinePayload) {
if c == nil {
return
}
@@ -120,8 +121,8 @@ type TimelineDay struct {
Rows []TimelineRow
}
// timelinePayload is the rendered shape for /timeline.
type timelinePayload struct {
// TimelinePayload is the rendered shape for /timeline.
type TimelinePayload struct {
Days []TimelineDay // outer order respects ?order=
From time.Time // window start (inclusive)
To time.Time // window end (exclusive)
@@ -133,9 +134,9 @@ type timelinePayload struct {
TotalRows int // count across all days
}
// timelineQuery is the parsed user input. Built from URL params; round-trips
// TimelineQuery is the parsed user input. Built from URL params; round-trips
// to QueryString for the cache key.
type timelineQuery struct {
type TimelineQuery struct {
Filter TreeFilter
From time.Time
To time.Time
@@ -145,14 +146,14 @@ type timelineQuery struct {
// activeKinds returns the effective kind set for filter math: returns the
// requested subset, or all four when the user did not narrow.
func (q timelineQuery) activeKinds() []string {
func (q TimelineQuery) activeKinds() []string {
if len(q.Kinds) == 0 {
return []string{timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation}
}
return q.Kinds
}
func (q timelineQuery) wantKind(k string) bool {
func (q TimelineQuery) wantKind(k string) bool {
for _, x := range q.activeKinds() {
if x == k {
return true
@@ -163,7 +164,7 @@ func (q timelineQuery) wantKind(k string) bool {
// cacheKey is filter + window + order + kinds → string. Used both for the
// in-process cache and as the canonical URL state.
func (q timelineQuery) cacheKey() string {
func (q TimelineQuery) cacheKey() string {
parts := []string{
"f=" + q.Filter.QueryString(),
"from=" + q.From.Format("2006-01-02"),
@@ -176,10 +177,10 @@ func (q timelineQuery) cacheKey() string {
return strings.Join(parts, "|")
}
// parseTimelineQuery folds URL params into a timelineQuery. Defaults: past 30
// parseTimelineQuery folds URL params into a TimelineQuery. Defaults: past 30
// days through future 90 days; order=desc; kinds=all.
func parseTimelineQuery(r *http.Request, now time.Time) timelineQuery {
q := timelineQuery{
func parseTimelineQuery(r *http.Request, now time.Time) TimelineQuery {
q := TimelineQuery{
Filter: ParseTreeFilter(r.URL.Query()),
From: startOfDay(now.AddDate(0, 0, -timelineDefaultPastDays)),
To: startOfDay(now.AddDate(0, 0, timelineDefaultFutureDays)),
@@ -234,6 +235,79 @@ 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
}
// 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"
}
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()
@@ -272,7 +346,7 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
// buildTimeline gathers every dated source, applies the kind/filter narrowing,
// and groups rows by day in the requested order.
func (s *Server) buildTimeline(ctx context.Context, q timelineQuery, now time.Time) (*timelinePayload, error) {
func (s *Server) buildTimeline(ctx context.Context, q TimelineQuery, now time.Time) (*TimelinePayload, error) {
items, err := s.Store.ListAll(ctx)
if err != nil {
return nil, err
@@ -411,7 +485,7 @@ func (s *Server) buildTimeline(ctx context.Context, q timelineQuery, now time.Ti
totalRows += len(d.Rows)
}
return &timelinePayload{
return &TimelinePayload{
Days: days,
From: q.From,
To: q.To,