Files
projax/internal/aggregate/timeline_days.go
mAi 825894f511 refactor(mcp): wire aggregator directly, drop TimelineBuilder seam
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
2026-05-22 00:15:07 +02:00

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)"
}