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:
@@ -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
|
||||
|
||||
@@ -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
200
mcp/timeline_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
188
mcp/tools.go
188
mcp/tools.go
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
104
web/timeline.go
104
web/timeline.go
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user