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
420 lines
12 KiB
Go
420 lines
12 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/m/projax/internal/aggregate"
|
|
"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. Re-export the
|
|
// aggregate-package constants under the web names callers already use.
|
|
const (
|
|
timelineKindTodo = aggregate.KindTodo
|
|
timelineKindEvent = aggregate.KindEvent
|
|
timelineKindDoc = aggregate.KindDoc
|
|
timelineKindCreation = aggregate.KindCreation
|
|
)
|
|
|
|
// 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. Re-exported from
|
|
// internal/aggregate so MCP + web share one row shape. (Pre-Phase-5a
|
|
// web/timeline.go defined its own copy; the aggregator package now owns
|
|
// the canonical sum-type wrapper.)
|
|
type TimelineRow = aggregate.TimelineRow
|
|
|
|
// TimelineDay groups rows that share a date for the spine header.
|
|
type TimelineDay = aggregate.TimelineDay
|
|
|
|
// 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"
|
|
// Phase 4f: when true, per-item timeline_exclude arrays are ignored —
|
|
// every source surfaces regardless. Used for the "show me what I'm
|
|
// hiding" peek (URL: ?include_excluded=1, MCP arg: include_excluded).
|
|
IncludeExcluded bool
|
|
}
|
|
|
|
// 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, ","))
|
|
}
|
|
if q.IncludeExcluded {
|
|
parts = append(parts, "include_excluded=1")
|
|
}
|
|
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"
|
|
}
|
|
if r.URL.Query().Get("include_excluded") == "1" {
|
|
q.IncludeExcluded = true
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// 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{}{}
|
|
}
|
|
|
|
// Phase 4f: per-item exclude filter. Pre-compute the subset of `matched`
|
|
// items that retain each source kind. Skipped here = the aggregator never
|
|
// fans out to CalDAV for that item; saves a network call for each
|
|
// excluded link too.
|
|
keepFor := func(kind string) []*store.Item {
|
|
if q.IncludeExcluded {
|
|
return matched
|
|
}
|
|
out := matched[:0:0]
|
|
for _, it := range matched {
|
|
if it.ExcludesTimelineKind(kind) {
|
|
continue
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out
|
|
}
|
|
|
|
agg := s.Aggregator()
|
|
window := aggregate.Window{From: q.From, To: q.To}
|
|
rows := []TimelineRow{}
|
|
|
|
// --- VTODOs (DUE within window for open; LastModified within for done/cancelled). ---
|
|
if q.wantKind(timelineKindTodo) && s.CalDAV != nil {
|
|
for _, tr := range agg.Todos(ctx, keepFor(timelineKindTodo), window) {
|
|
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 if tr.Todo.Due != nil {
|
|
anchor = tr.Todo.Due
|
|
}
|
|
if anchor == nil {
|
|
continue
|
|
}
|
|
row := tr // copy so the &row below doesn't alias the loop var
|
|
rows = append(rows, TimelineRow{
|
|
Date: startOfDay(anchor.Local()),
|
|
Kind: timelineKindTodo,
|
|
Item: tr.Item,
|
|
ItemPath: tr.Item.PrimaryPath(),
|
|
Todo: &row,
|
|
CalendarURL: tr.CalendarURL,
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- VEVENTs (DTSTART within window). ---
|
|
if q.wantKind(timelineKindEvent) && s.CalDAV != nil {
|
|
for _, ev := range agg.Events(ctx, keepFor(timelineKindEvent), window) {
|
|
day := startOfDay(ev.Event.Start.Local())
|
|
if day.Before(q.From) || !day.Before(q.To) {
|
|
continue
|
|
}
|
|
row := ev
|
|
rows = append(rows, TimelineRow{
|
|
Date: day,
|
|
Kind: timelineKindEvent,
|
|
Item: ev.Item,
|
|
ItemPath: ev.Item.PrimaryPath(),
|
|
Event: &row,
|
|
StartLabel: aggregate.EventStartLabel(ev.Event),
|
|
DurationHint: aggregate.EventDurationHint(ev.Event),
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Dated item_links (event_date within window). ---
|
|
if q.wantKind(timelineKindDoc) {
|
|
for _, d := range agg.Docs(ctx, matched, window) {
|
|
it := d.Item
|
|
if _, in := matchedSet[it.ID]; q.Filter.Active() && !in {
|
|
continue
|
|
}
|
|
if !q.IncludeExcluded && it.ExcludesTimelineKind(timelineKindDoc) {
|
|
continue
|
|
}
|
|
base := it.PrimaryPath()
|
|
per := base + "." + formatPERDate(*d.Link.EventDate)
|
|
row := d
|
|
rows = append(rows, TimelineRow{
|
|
Date: startOfDay(*d.Link.EventDate),
|
|
Kind: timelineKindDoc,
|
|
Item: it,
|
|
ItemPath: base,
|
|
Doc: &row,
|
|
Link: d.Link,
|
|
PER: per,
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Item-creation events (created_at within window). ---
|
|
if q.wantKind(timelineKindCreation) {
|
|
for _, c := range agg.Creations(ctx, matched, window) {
|
|
it := c.Item
|
|
if _, in := matchedSet[it.ID]; q.Filter.Active() && !in {
|
|
continue
|
|
}
|
|
if !q.IncludeExcluded && it.ExcludesTimelineKind(timelineKindCreation) {
|
|
continue
|
|
}
|
|
row := c
|
|
rows = append(rows, TimelineRow{
|
|
Date: startOfDay(it.CreatedAt),
|
|
Kind: timelineKindCreation,
|
|
Item: it,
|
|
ItemPath: it.PrimaryPath(),
|
|
Creation: &row,
|
|
})
|
|
}
|
|
}
|
|
|
|
days := aggregate.BuildTimelineDays(rows, aggregate.BuildOpts{
|
|
Now: now,
|
|
Order: q.Order,
|
|
FadeAfter: 30 * 24 * time.Hour,
|
|
})
|
|
totalRows := 0
|
|
for _, d := range days {
|
|
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
|
|
}
|