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).
776 lines
21 KiB
Go
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:])
|
|
}
|