Files
projax/web/timeline.go
mAi b22f50ca7b feat(adapter): Phase 6 Slice B — mBrian-backed read path live
Per t-projax-6-sliceB-readpath. mBrian migration (m/mBrian#73) is live
on msupabase with 65 nodes + 78 child_of + 81 projax-* edges. This
commit makes the projax read path source from there behind an env
switch.

CLIENT ARCH: direct pgxpool against mbrian.* schema (same
SUPABASE_DATABASE_URL the projax binary already uses for projax.*) —
matches flexsiebels/head's cross-coupling pattern. No MCP token
plumbing.

CONTRACT (all three honoured)
- External links are SELF-EDGES (source=target=item, rel='projax-*',
  payload in edges.metadata). linkFromEdge reads the node's outbound
  projax-* edges; ref_id derived per ref_type from metadata (caldav
  url, gitea owner/repo, mai-project mai_project_id).
- Slugs finalised: 'work'/'dania' resolve to mBrian's canonical nodes;
  projax-side squatters (renamed-aside, not deleted) are documented in
  the parity test as legacy-only and skipped from field comparison.
- created_at/updated_at NOT preserved — ItemsCreatedInRange orders off
  metadata.projax.start_time when present, fall back to mBrian
  created_at. Aggregator surfaces (timeline / dashboard) read off
  caldav DTSTART + gitea updated_at, so they're unaffected.

NEW FILES
- store/mbrian.go: MBrianReader concrete impl. Bulk-loads projax-
  managed nodes + child_of edges in one pair of queries per call,
  builds a graphContext in memory, derives Paths via ancestor walk
  (depth-capped at 64 like projax's trigger). Implements every
  ItemReader method.
- store/mbrian_parity_test.go: 5 parity tests against the live db —
  ListAll field equality (skipping the renamed squatter slugs),
  spot-check resolves, caldav-list link round-trip, gitea-repo link
  round-trip, AllTags union, NotFound consistency. All 5 GREEN.
- cmd/projax-remap-views/main.go: one-shot tool to rewrite
  projax.views.filter_json.project_id from old projax uuids to new
  mBrian uuids using the audit map mBrian dropped (head will relay
  the path). Dry-run default; --apply commits. Idempotent.
- docs/plans/slice-b-views-projectid-gap.md: surfaces the gap + the
  remediation path. Must run remap BEFORE slice E drops projax.items.

CHANGES
- store/adapter.go: kept the ItemReader interface + *Store assertion;
  removed the prep stub (replaced by mbrian.go).
- web/server.go: Server.Items store.ItemReader field. web.New defaults
  Items to the concrete *Store (legacy path). main.go overrides to
  MBrianReader when PROJAX_BACKEND=mbrian.
- All read-path call sites in web/ swapped from s.Store.<readMethod>(
  to s.Items.<readMethod>( for the 15 ItemReader methods. MCP tools
  unchanged (separate scope; can pivot in a follow-up). Writes still
  flow through s.Store.
- cmd/projax/main.go: PROJAX_BACKEND env switch with "store" (default)
  and "mbrian" values. Logs the choice at startup. Unknown value
  refuses to start.

SMOKE
- go build ./... green; go vet green.
- go test ./store/ -count=1 — all parity tests pass against live data.
- Local server boot with PROJAX_BACKEND=mbrian — backs binding logs
  "backend=mbrian (read path via store.MBrianReader)" and serves
  /views/tree (auth wall protects deeper smoke; parity tests cover
  that surface).

PRE-EXISTING failure NOT addressed in this commit: 3 timeline_filter
tests in web/ already failed on main (legacy /timeline URL hits the
Phase 5j 301 redirect to /views/timeline). No diff vs main in those
test files; out of scope for slice B.

OUT OF SCOPE FOR SLICE B (deferred):
- MCP read tools migration to ItemReader (separate diff, low risk).
- Aggregator's LinkLister wired to ItemReader (currently consumes
  *Store directly through Server.Aggregator()).
- views.filter_json.project_id remap RUN — tool ships here, run waits
  on the head's relay of the audit-map path.
- Slice C write-path. Slice D mai-bridge worker. Slice E drop.
2026-05-31 22:20:38 +02:00

376 lines
11 KiB
Go

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 <select multiple> submitted — same pre-5d
// shape calendar's parser carried before commit 6f0a318. parseValues
// (web/server.go) merges both URL styles into a single slice.
for _, k := range parseValues(r.URL.Query(), "kind") {
switch k {
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
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
projects, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
data := map[string]any{
"Title": "timeline",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
"Projects": projects,
"BasePath": "/views/timeline",
"ProjectChipTarget": "#timeline-section",
}
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.Items.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
}