Files
projax/web/timeline.go
mAi 8b51746183 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).
2026-05-17 18:42:48 +02:00

776 lines
21 KiB
Go

package web
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/store"
)
// Timeline cache TTL — looser than the dashboard's 60s because /timeline is
// browse-y rather than action-y. Per filter+window key.
const timelineCacheTTL = 90 * time.Second
// timelineKindTodo / Event / Doc / Creation are the four filterable row
// kinds. They double as the `?kind=` query values.
const (
timelineKindTodo = "todo"
timelineKindEvent = "event"
timelineKindDoc = "doc"
timelineKindCreation = "creation"
)
// timelineDefaultPastDays / FutureDays bound the default window: 30 days back
// and 90 forward, per design.md §12.
const (
timelineDefaultPastDays = 30
timelineDefaultFutureDays = 90
)
// timelineCache holds aggregated payloads per (filter, window, order) key.
type timelineCache struct {
ttl time.Duration
mu sync.Mutex
rows map[string]cachedTimeline
}
type cachedTimeline struct {
at time.Time
payload *TimelinePayload
}
func newTimelineCache(ttl time.Duration) *timelineCache {
return &timelineCache{ttl: ttl, rows: map[string]cachedTimeline{}}
}
func (c *timelineCache) get(key string) (*TimelinePayload, bool) {
if c == nil {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.rows[key]
if !ok {
return nil, false
}
if time.Since(v.at) > c.ttl {
delete(c.rows, key)
return nil, false
}
return v.payload, true
}
func (c *timelineCache) set(key string, p *TimelinePayload) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.rows[key] = cachedTimeline{at: time.Now(), payload: p}
}
func (c *timelineCache) invalidateAll() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.rows = map[string]cachedTimeline{}
}
// TimelineRow is one entry on the chronological spine. A row exists per
// (date, source) tuple — no aggregation across rows.
type TimelineRow struct {
Date time.Time // day anchor used for grouping (date-only resolution)
Kind string // todo | event | doc | creation
Item *store.Item // owning projax item; never nil
ItemPath string // PrimaryPath of Item (snapshot for the template)
// VTODO rows populate Todo + CalendarURL. The detail-page handlers route
// off (calendar_url, uid).
Todo caldav.Todo
CalendarURL string
// VEVENT rows populate Event. Read-only at v1.
Event caldav.Event
StartLabel string // "10:00" / "" for all-day
DurationHint string // "(2 days)" / "(1h)" / ""
// Doc rows populate Link + PER for the row label.
Link *store.ItemLink
PER string
// Creation rows reuse Item; no extra fields needed.
// FarFuture is true when Date > today+30; the template applies a fade.
FarFuture bool
}
// TimelineDay groups rows that share a date for the spine header.
type TimelineDay struct {
Date time.Time
DateKey string // YYYY-MM-DD
Label string // "Today", "Tomorrow", or "Mon 16 May 2026"
Sticky string // "today" / "tomorrow" / "" — drives sticky pill rendering
Rows []TimelineRow
}
// 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)
ToInclusive time.Time // To - 1 day; used by templates for display
Order string // "desc" (default) or "asc"
Kinds []string // active row kinds (default: all four)
BuiltAt time.Time
Cached bool
TotalRows int // count across all days
}
// TimelineQuery is the parsed user input. Built from URL params; round-trips
// to QueryString for the cache key.
type TimelineQuery struct {
Filter TreeFilter
From time.Time
To time.Time
Order string // "asc" | "desc"
Kinds []string // sorted, lower-case; empty means "all four"
}
// 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 {
if len(q.Kinds) == 0 {
return []string{timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation}
}
return q.Kinds
}
func (q TimelineQuery) wantKind(k string) bool {
for _, x := range q.activeKinds() {
if x == k {
return true
}
}
return false
}
// 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 {
parts := []string{
"f=" + q.Filter.QueryString(),
"from=" + q.From.Format("2006-01-02"),
"to=" + q.To.Format("2006-01-02"),
"order=" + q.Order,
}
if len(q.Kinds) > 0 {
parts = append(parts, "kinds="+strings.Join(q.Kinds, ","))
}
return strings.Join(parts, "|")
}
// 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{
Filter: ParseTreeFilter(r.URL.Query()),
From: startOfDay(now.AddDate(0, 0, -timelineDefaultPastDays)),
To: startOfDay(now.AddDate(0, 0, timelineDefaultFutureDays)),
Order: "desc",
}
if v := strings.TrimSpace(r.URL.Query().Get("from")); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
q.From = startOfDay(t)
}
}
if v := strings.TrimSpace(r.URL.Query().Get("to")); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
// `to` is inclusive in URL terms; convert to exclusive bound by adding a day.
q.To = startOfDay(t).AddDate(0, 0, 1)
}
}
// `before` advances the window into the past for "older" pagination.
if v := strings.TrimSpace(r.URL.Query().Get("before")); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
q.To = startOfDay(t)
q.From = q.To.AddDate(0, 0, -timelineDefaultPastDays-timelineDefaultFutureDays)
}
}
if v := strings.TrimSpace(r.URL.Query().Get("order")); v == "asc" {
q.Order = "asc"
}
// Past-only / future-only narrowing.
switch strings.TrimSpace(r.URL.Query().Get("when")) {
case "past":
if q.To.After(startOfDay(now)) {
q.To = startOfDay(now)
}
case "future":
if q.From.Before(startOfDay(now)) {
q.From = startOfDay(now)
}
}
if v := strings.TrimSpace(r.URL.Query().Get("kind")); v != "" {
seen := map[string]bool{}
for _, k := range strings.Split(v, ",") {
k = strings.TrimSpace(strings.ToLower(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 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()
q := parseTimelineQuery(r, now)
key := q.cacheKey()
if r.URL.Query().Get("refresh") == "1" {
s.timeline.invalidateAll()
}
payload, hit := s.timeline.get(key)
if !hit {
built, err := s.buildTimeline(r.Context(), q, now)
if err != nil {
s.fail(w, r, err)
return
}
s.timeline.set(key, built)
payload = built
}
display := *payload
display.Cached = hit
data := map[string]any{
"Title": "timeline",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "timeline_section", data)
return
}
s.render(w, r, "timeline", data)
}
// 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) {
items, err := s.Store.ListAll(ctx)
if err != nil {
return nil, err
}
linkKinds, err := s.linkKindsByItem(ctx)
if err != nil {
return nil, err
}
byID := map[string]*store.Item{}
matched := []*store.Item{}
for _, it := range items {
// Always index every live item by ID so `event_date` rows can look up
// their owning item even if the filter would otherwise hide it. We do
// the filter check at row-emit time (per source).
byID[it.ID] = it
if !q.Filter.Active() || q.Filter.Matches(it, linkKinds[it.ID]) {
matched = append(matched, it)
}
}
matchedSet := map[string]struct{}{}
for _, it := range matched {
matchedSet[it.ID] = struct{}{}
}
rows := []TimelineRow{}
// --- VTODOs (DUE within window for open; LastModified within for done/cancelled). ---
if q.wantKind(timelineKindTodo) && s.CalDAV != nil {
todos := s.collectTimelineTodos(ctx, matched, q.From, q.To)
rows = append(rows, todos...)
}
// --- VEVENTs (DTSTART within window). ---
if q.wantKind(timelineKindEvent) && s.CalDAV != nil {
events := s.collectTimelineEvents(ctx, matched, q.From, q.To)
rows = append(rows, events...)
}
// --- Dated item_links (event_date within window). ---
if q.wantKind(timelineKindDoc) {
docs, err := s.Store.DatedLinksRange(ctx, q.From, q.To)
if err != nil {
s.Logger.Warn("timeline dated links", "err", err)
}
for _, d := range docs {
it, ok := byID[d.Link.ItemID]
if !ok {
continue
}
if _, in := matchedSet[it.ID]; q.Filter.Active() && !in {
continue
}
base := it.PrimaryPath()
per := base + "." + formatPERDate(*d.Link.EventDate)
rows = append(rows, TimelineRow{
Date: startOfDay(*d.Link.EventDate),
Kind: timelineKindDoc,
Item: it,
ItemPath: base,
Link: &d.Link,
PER: per,
})
}
}
// --- Item-creation events (created_at within window). ---
if q.wantKind(timelineKindCreation) {
created, err := s.Store.ItemsCreatedInRange(ctx, q.From, q.To)
if err != nil {
s.Logger.Warn("timeline created", "err", err)
}
for _, it := range created {
if _, in := matchedSet[it.ID]; q.Filter.Active() && !in {
continue
}
rows = append(rows, TimelineRow{
Date: startOfDay(it.CreatedAt),
Kind: timelineKindCreation,
Item: it,
ItemPath: it.PrimaryPath(),
})
}
}
// Fade-after-30d marker.
fadeCutoff := startOfDay(now).AddDate(0, 0, 30)
for i := range rows {
if rows[i].Date.After(fadeCutoff) {
rows[i].FarFuture = true
}
}
// Group by day key. Compare via the YYYY-MM-DD string rather than
// time.Time.Equal so the today/tomorrow sticky pills fire reliably even
// when sources put `Date` in different timezones (e.g. postgres DATE comes
// back UTC-midnight while time.Now is local).
todayKey := startOfDay(now).Format("2006-01-02")
tomorrowKey := startOfDay(now).AddDate(0, 0, 1).Format("2006-01-02")
byKey := map[string]*TimelineDay{}
keys := []string{}
for _, r := range rows {
k := r.Date.Format("2006-01-02")
d, ok := byKey[k]
if !ok {
d = &TimelineDay{
Date: r.Date,
DateKey: k,
Label: timelineDayLabelByKey(r.Date, k, todayKey, tomorrowKey),
}
switch k {
case todayKey:
d.Sticky = "today"
case tomorrowKey:
d.Sticky = "tomorrow"
}
byKey[k] = d
keys = append(keys, k)
}
d.Rows = append(d.Rows, r)
}
// Sort keys per requested order.
sort.Slice(keys, func(i, j int) bool {
if q.Order == "asc" {
return keys[i] < keys[j]
}
return keys[i] > keys[j]
})
days := make([]TimelineDay, 0, len(keys))
totalRows := 0
for _, k := range keys {
d := byKey[k]
sortTimelineRows(d.Rows)
days = append(days, *d)
totalRows += len(d.Rows)
}
return &TimelinePayload{
Days: days,
From: q.From,
To: q.To,
ToInclusive: q.To.AddDate(0, 0, -1),
Order: q.Order,
Kinds: q.activeKinds(),
BuiltAt: time.Now(),
TotalRows: totalRows,
}, nil
}
// collectTimelineTodos fans out across (item, calendar) pairs with the same
// 4-worker pool used by the dashboard, then narrows by DUE for open and
// LAST-MODIFIED for completed/cancelled.
func (s *Server) collectTimelineTodos(ctx context.Context, items []*store.Item, from, to time.Time) []TimelineRow {
type job struct {
item *store.Item
link *store.ItemLink
}
jobs := []job{}
for _, it := range items {
links, err := s.Store.LinksByType(ctx, it.ID, refTypeCalDAV)
if err != nil {
s.Logger.Warn("timeline caldav links", "item", it.PrimaryPath(), "err", err)
continue
}
for _, l := range links {
jobs = append(jobs, job{item: it, link: l})
}
}
type result struct {
item *store.Item
cal string
all []caldav.Todo
}
results := make(chan result, len(jobs))
in := make(chan job, len(jobs))
const workers = 4
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range in {
todos, err := s.CalDAV.Client.ListTodos(ctx, j.link.RefID)
if err != nil {
s.Logger.Warn("timeline list todos", "calendar", j.link.RefID, "err", err)
continue
}
results <- result{item: j.item, cal: j.link.RefID, all: todos}
}
}()
}
for _, j := range jobs {
in <- j
}
close(in)
wg.Wait()
close(results)
out := []TimelineRow{}
for r := range results {
for _, td := range r.all {
open := td.Status != "COMPLETED" && td.Status != "CANCELLED"
// Decide which timestamp anchors the row.
var anchor *time.Time
if open {
anchor = td.Due
} else {
// Completed/cancelled rows surface only in the recent past — use
// LAST-MODIFIED as the anchor when present, fall back to DUE.
if td.LastModified != nil {
anchor = td.LastModified
} else if td.Due != nil {
anchor = td.Due
}
}
if anchor == nil {
continue
}
day := startOfDay(anchor.Local())
if day.Before(from) || !day.Before(to) {
continue
}
out = append(out, TimelineRow{
Date: day,
Kind: timelineKindTodo,
Item: r.item,
ItemPath: r.item.PrimaryPath(),
Todo: td,
CalendarURL: r.cal,
})
}
}
return out
}
// collectTimelineEvents fans out across (item, calendar) pairs and reuses the
// caldav ListEvents server-side time-range filter for the cheap path.
func (s *Server) collectTimelineEvents(ctx context.Context, items []*store.Item, from, to time.Time) []TimelineRow {
type job struct {
item *store.Item
link *store.ItemLink
}
jobs := []job{}
for _, it := range items {
links, err := s.Store.LinksByType(ctx, it.ID, refTypeCalDAV)
if err != nil {
s.Logger.Warn("timeline caldav-events links", "item", it.PrimaryPath(), "err", err)
continue
}
for _, l := range links {
jobs = append(jobs, job{item: it, link: l})
}
}
type result struct {
item *store.Item
events []caldav.Event
}
results := make(chan result, len(jobs))
in := make(chan job, len(jobs))
const workers = 4
var wg sync.WaitGroup
opts := caldav.ListEventsOpts{TimeMin: from, TimeMax: to}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range in {
events, err := s.CalDAV.Client.ListEvents(ctx, j.link.RefID, opts)
if err != nil {
s.Logger.Warn("timeline list events", "calendar", j.link.RefID, "err", err)
continue
}
results <- result{item: j.item, events: events}
}
}()
}
for _, j := range jobs {
in <- j
}
close(in)
wg.Wait()
close(results)
out := []TimelineRow{}
for r := range results {
for _, ev := range r.events {
day := startOfDay(ev.Start.Local())
if day.Before(from) || !day.Before(to) {
continue
}
row := TimelineRow{
Date: day,
Kind: timelineKindEvent,
Item: r.item,
ItemPath: r.item.PrimaryPath(),
Event: ev,
StartLabel: eventStartLabel(ev),
}
row.DurationHint = eventDurationHint(ev)
out = append(out, row)
}
}
return out
}
// sortTimelineRows orders rows within a single day:
// 1. timed events (asc by start time)
// 2. all-day events
// 3. VTODOs (DUE rows sink to end-of-day)
// 4. dated docs (alpha by PER)
// 5. creation markers (last)
//
// Within a kind, ties broken by Summary / PER / Item.Slug for stability.
func sortTimelineRows(rows []TimelineRow) {
kindOrder := map[string]int{
timelineKindEvent: 0,
timelineKindTodo: 1,
timelineKindDoc: 2,
timelineKindCreation: 3,
}
sort.SliceStable(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
// Events: timed first then all-day, both sorted by start.
if a.Kind == timelineKindEvent && b.Kind == timelineKindEvent {
if a.Event.AllDay != b.Event.AllDay {
return !a.Event.AllDay
}
if !a.Event.Start.Equal(b.Event.Start) {
return a.Event.Start.Before(b.Event.Start)
}
return a.Event.Summary < b.Event.Summary
}
if kindOrder[a.Kind] != kindOrder[b.Kind] {
return kindOrder[a.Kind] < kindOrder[b.Kind]
}
switch a.Kind {
case timelineKindTodo:
return a.Todo.Summary < b.Todo.Summary
case timelineKindDoc:
return a.PER < b.PER
case timelineKindCreation:
return a.Item.Slug < b.Item.Slug
}
return false
})
}
// timelineDayLabelByKey renders the day header text using the precomputed
// YYYY-MM-DD key. Today / Tomorrow are special-cased; everything else gets
// the weekday + dd MMM yyyy form. The key-based comparison sidesteps the
// timezone trap that bit row grouping with time.Time.Equal.
func timelineDayLabelByKey(t time.Time, key, todayKey, tomorrowKey string) string {
switch key {
case todayKey:
return "Today"
case tomorrowKey:
return "Tomorrow"
}
return t.Format("Mon 02 Jan 2006")
}
// 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 (the all-day label already covers it) and for
// events with no DTEND.
func eventDurationHint(ev caldav.Event) string {
if ev.End.IsZero() {
return ""
}
if ev.AllDay {
days := int(startOfDay(ev.End).Sub(startOfDay(ev.Start)).Hours() / 24)
if days <= 1 {
return ""
}
return formatDaysHint(days)
}
dur := ev.End.Sub(ev.Start)
if dur <= 0 {
return ""
}
hours := int(dur.Hours())
if hours >= 24 {
days := int(dur.Hours()/24) + 1
return formatDaysHint(days)
}
if hours >= 1 {
return "(" + itoa(hours) + "h)"
}
mins := int(dur.Minutes())
if mins > 0 {
return "(" + itoa(mins) + "min)"
}
return ""
}
func formatDaysHint(n int) string {
if n == 1 {
return "(1 day)"
}
return "(" + itoa(n) + " days)"
}
func itoa(i int) string {
// inlined strconv.Itoa to keep this file dependency-light alongside the
// rest of web/.
if i == 0 {
return "0"
}
neg := i < 0
if neg {
i = -i
}
var buf [20]byte
pos := len(buf)
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
if neg {
pos--
buf[pos] = '-'
}
return string(buf[pos:])
}