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
210 lines
5.3 KiB
Go
210 lines
5.3 KiB
Go
package aggregate
|
|
|
|
import (
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/m/projax/caldav"
|
|
)
|
|
|
|
// 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" / "Mon 16 May 2026"
|
|
Sticky string // "today" / "tomorrow" / ""
|
|
Rows []TimelineRow
|
|
}
|
|
|
|
// BuildOpts shapes BuildTimelineDays output. Now is the only required
|
|
// field; the rest provide deterministic overrides used by tests.
|
|
type BuildOpts struct {
|
|
Now time.Time
|
|
Order string // "asc" | "desc"; default "desc"
|
|
// TodayKey / TomorrowKey override the sticky-pill anchor dates. Empty
|
|
// values fall back to startOfDay(Now) and startOfDay(Now)+1d.
|
|
TodayKey string
|
|
TomorrowKey string
|
|
// FadeAfter, when > 0, sets rows whose Date > Now + FadeAfter to
|
|
// FarFuture = true. Zero leaves rows untouched (so callers that have
|
|
// already populated FarFuture themselves don't get double-flagged).
|
|
FadeAfter time.Duration
|
|
}
|
|
|
|
// BuildTimelineDays groups rows by day key, applies sticky-pill markers,
|
|
// optionally marks far-future rows, and sorts within each day using the
|
|
// canonical ordering (events → todos → docs → creations).
|
|
func BuildTimelineDays(rows []TimelineRow, opts BuildOpts) []TimelineDay {
|
|
if opts.Order == "" {
|
|
opts.Order = "desc"
|
|
}
|
|
todayKey := opts.TodayKey
|
|
if todayKey == "" {
|
|
todayKey = startOfDay(opts.Now).Format("2006-01-02")
|
|
}
|
|
tomorrowKey := opts.TomorrowKey
|
|
if tomorrowKey == "" {
|
|
tomorrowKey = startOfDay(opts.Now).AddDate(0, 0, 1).Format("2006-01-02")
|
|
}
|
|
|
|
if opts.FadeAfter > 0 {
|
|
cutoff := startOfDay(opts.Now).Add(opts.FadeAfter)
|
|
for i := range rows {
|
|
if rows[i].Date.After(cutoff) {
|
|
rows[i].FarFuture = true
|
|
}
|
|
}
|
|
}
|
|
|
|
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.Slice(keys, func(i, j int) bool {
|
|
if opts.Order == "asc" {
|
|
return keys[i] < keys[j]
|
|
}
|
|
return keys[i] > keys[j]
|
|
})
|
|
days := make([]TimelineDay, 0, len(keys))
|
|
for _, k := range keys {
|
|
d := byKey[k]
|
|
SortTimelineRows(d.Rows)
|
|
days = append(days, *d)
|
|
}
|
|
return days
|
|
}
|
|
|
|
// SortTimelineRows orders rows within a single day:
|
|
// 1. timed events (asc by start time)
|
|
// 2. all-day events
|
|
// 3. VTODOs
|
|
// 4. dated docs (alpha by PER)
|
|
// 5. creation markers (last)
|
|
//
|
|
// Within a kind, ties broken by Summary / PER / Item.Slug.
|
|
func SortTimelineRows(rows []TimelineRow) {
|
|
kindOrder := map[string]int{
|
|
KindEvent: 0,
|
|
KindTodo: 1,
|
|
KindDoc: 2,
|
|
KindCreation: 3,
|
|
}
|
|
sort.SliceStable(rows, func(i, j int) bool {
|
|
a, b := rows[i], rows[j]
|
|
if a.Kind == KindEvent && b.Kind == KindEvent {
|
|
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 KindTodo:
|
|
return a.Todo.Summary < b.Todo.Summary
|
|
case KindDoc:
|
|
return a.PER < b.PER
|
|
case KindCreation:
|
|
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.
|
|
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")
|
|
}
|
|
|
|
// EventStartLabel renders an event's start time as "10:00", "ganztägig"
|
|
// for all-day events, or "" if Start is the zero value.
|
|
func EventStartLabel(ev caldav.Event) string {
|
|
if ev.AllDay {
|
|
return "ganztägig"
|
|
}
|
|
if ev.Start.IsZero() {
|
|
return ""
|
|
}
|
|
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.
|
|
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 "(" + strconv.Itoa(hours) + "h)"
|
|
}
|
|
mins := int(dur.Minutes())
|
|
if mins > 0 {
|
|
return "(" + strconv.Itoa(mins) + "min)"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func formatDaysHint(n int) string {
|
|
if n == 1 {
|
|
return "(1 day)"
|
|
}
|
|
return "(" + strconv.Itoa(n) + " days)"
|
|
}
|