package web import ( "context" "net/http" "sort" "strings" "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. Phase 5b unified // onto internal/cache.TTLCache[V]. 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 ) // 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) } } // Accept both `?kind=event,doc` (comma-joined) and // `?kind=event&kind=doc` (HTMX multi-select submission). The earlier // q.Get + comma-split flavour dropped everything past the first value // when the chip strip's