Files
projax/web/graph.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

244 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"net/http"
"github.com/m/projax/internal/graph"
"github.com/m/projax/store"
)
// graphNode is the template-facing shape: position + style hints derived from
// the underlying item's management/status/tags.
type graphNodeView struct {
ID string
Slug string
Title string
Path string
Pos graph.Pos
Tags []string
TagsShown []string // capped to 3 with "+N" overflow logic via TagOverflow
TagOverflow int
Status string
Management []string
MgmtClass string // "mai" | "self" | "external" | "mixed" | "unmanaged"
StatusOp float64 // 1.0 active, 0.6 done, 0.3 archived
PathCount int // ×N badge when > 1
Matched bool // matches the filter (when filter is active)
}
// graphPayload is everything the SVG template needs.
type graphPayload struct {
Nodes []graphNodeView
Edges []graph.Edge
CanvasWidth float64
CanvasHeight float64
Isolate bool // when true, dim-only stays off — non-match nodes hidden entirely
NodeW, NodeH float64
// Standalone toggles the inline :root palette in graph_svg.tmpl. Set to
// true on ?download=svg responses (no outer HTML page providing vars);
// false when the SVG is embedded inside the layout chrome.
Standalone bool
}
func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
linkKinds, err := s.linkKindsByItem(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
allTags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
filter := ParseTreeFilter(r.URL.Query())
isolate := r.URL.Query().Get("isolate") == "1"
// Build layout-input nodes from every live item (the graph deliberately
// shows the full DAG; the filter dims non-matches via opacity unless
// isolate=1 hides them).
nodes := make([]graph.Node, 0, len(items))
for _, it := range items {
nodes = append(nodes, graph.Node{
ID: it.ID,
Slug: it.Slug,
ParentIDs: it.ParentIDs,
})
}
opts := graph.Opts{NodeWidth: 130, NodeHeight: 44, HGap: 28, VGap: 90, MarginX: 40, MarginY: 32}
layout, err := graph.Compute(nodes, opts)
if err != nil {
s.fail(w, r, err)
return
}
// Filter matching: every node carries its match state so the template can
// branch on the dim/isolate behaviour.
byID := make(map[string]*store.Item, len(items))
for _, it := range items {
byID[it.ID] = it
}
var views []graphNodeView
visibleEdges := layout.Edges
for _, it := range items {
pos, ok := layout.Positions[it.ID]
if !ok {
continue
}
matched := filter.Matches(it, linkKinds[it.ID])
if isolate && filter.Active() && !matched {
continue
}
v := graphNodeView{
ID: it.ID,
Slug: it.Slug,
Title: it.Title,
Path: it.PrimaryPath(),
Pos: pos,
Tags: it.Tags,
Status: it.Status,
Management: it.Management,
MgmtClass: managementClass(it.Management),
StatusOp: statusOpacity(it.Status, it.Archived),
PathCount: len(it.Paths),
Matched: matched,
}
if len(it.Tags) > 3 {
v.TagsShown = it.Tags[:3]
v.TagOverflow = len(it.Tags) - 3
} else {
v.TagsShown = it.Tags
}
views = append(views, v)
}
if isolate && filter.Active() {
// Drop edges referencing removed nodes.
visible := map[string]struct{}{}
for _, v := range views {
visible[v.ID] = struct{}{}
}
kept := visibleEdges[:0]
for _, e := range visibleEdges {
if _, ok := visible[e.ParentID]; !ok {
continue
}
if _, ok := visible[e.ChildID]; !ok {
continue
}
kept = append(kept, e)
}
visibleEdges = kept
}
payload := graphPayload{
Nodes: views,
Edges: visibleEdges,
CanvasWidth: layout.CanvasWidth,
CanvasHeight: layout.CanvasHeight,
Isolate: isolate,
NodeW: opts.NodeWidth,
NodeH: opts.NodeHeight,
}
// Download mode: serve raw SVG with attachment headers.
if r.URL.Query().Get("download") == "svg" {
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="projax-graph.svg"`)
payload.Standalone = true
s.renderRaw(w, "graph_svg", payload)
return
}
data := map[string]any{
"Title": "graph",
"P": payload,
"Filter": filter,
"Isolate": isolate,
"AllTags": allTags,
"Total": len(items),
"Matched": countMatches(items, filter, linkKinds),
}
s.render(w, r, "graph", data)
}
// renderRaw is like render but writes the body to w without the page-layout
// chrome — used for the SVG download path.
func (s *Server) renderRaw(w http.ResponseWriter, name string, data any) {
t, ok := s.pages[name]
if !ok {
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
return
}
entry := "graph-svg"
if err := t.ExecuteTemplate(w, entry, data); err != nil {
s.Logger.Error("render svg", "page", name, "err", err)
}
}
func managementClass(m []string) string {
hasMai, hasSelf, hasExt := false, false, false
for _, x := range m {
switch x {
case "mai":
hasMai = true
case "self":
hasSelf = true
case "external":
hasExt = true
}
}
count := 0
for _, b := range []bool{hasMai, hasSelf, hasExt} {
if b {
count++
}
}
if count == 0 {
return "unmanaged"
}
if count > 1 {
return "mixed"
}
switch {
case hasMai:
return "mai"
case hasSelf:
return "self"
case hasExt:
return "external"
}
return "unmanaged"
}
func statusOpacity(status string, archived bool) float64 {
if archived {
return 0.3
}
switch status {
case "done":
return 0.6
case "archived":
return 0.3
default:
return 1.0
}
}
func countMatches(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) int {
if !f.Active() {
return len(items)
}
n := 0
for _, it := range items {
if f.Matches(it, linkKinds[it.ID]) {
n++
}
}
return n
}