Artifact c88e75bc0bb37edc83e54e6b3988addbe0042e60dbc663844d31ceefaba5c9ef:
- File
internal/web/timeline.go
— part of check-in
[f50a282ab1]
at
2026-08-17 14:47:41
on branch trunk
— ci: verdicts on the timeline and check-in, live run status
Two follow-ups to the CI slice.
A check-in now carries its verdict wherever it appears — a pass/fail badge on timeline cards and the check-in page, and a ci field on the API. It is read from the store's tag artifacts, not from dagnats, so it answers with the engine down and travels with a clone. A check-in with no verdict shows nothing rather than a neutral badge that would read as a result.
The CI page follows runs live. dagnats' /runs/{id}/events is a JSON snapshot rather than a stream, so this polls and sends only when the rendered fragment actually differs, reusing the SSE shape the timeline and fleet already use. data-init sits on a wrapper OUTSIDE the patched element — the mistake that silently killed both other streams this morning and only showed up in a browser.
Verified in a browser rather than by asserting the server is right: a commit to synthetic made the table go from one row to two with no reload. (user: dmestas, size: 11375) [annotate] [blame] [check-ins using]
// timeline.go — the fossil-shaped timeline view model: day dividers, // per-branch colors, and the DAG rail graph rendered as server-side // inline SVG (no JavaScript; 0001 Principle 3). Rail assignment follows // the shape of fossil's graph.c — computed here, drawn here — walking // rows newest-first: a node lands on the rail that expects its hash, // rails continue toward primary parents, merges curve out to the rail // carrying the secondary parent. // // Rows have content-dependent heights, so each row's rails render as a // stretchable SVG (preserveAspectRatio="none": verticals stay straight, // curves stretch) filling the cell, with the node drawn as a separate // fixed-size dot overlay that cannot distort — that is what keeps the // rail visually continuous through tall cards, like fossil's graph. package web import ( "fmt" "hash/fnv" "html/template" neturl "net/url" "strings" ) const ( railW = 14 // px between rails headH = 36 // fixed head band: node + curves live here nodeCY = 24 // node center inside the head: card padding + half line maxRail = 14 // clamp; beyond this rails overlap rather than widen the page ) type tlDay struct { Date string Carry template.HTML // rails passing through the date-divider row Width int Rows []tlRow } type tlRow struct { Kind string // ci | w | t Time string Hash string Short string URL string // the event's page User string Title string // comment first line Paras []string // remaining comment paragraphs, newlines collapsed Branch string Tint template.CSS // per-branch card tint (empty for trunk) IsLeaf bool IsMerge bool CI template.HTML // CI verdict badge, empty when none was recorded Head template.HTML // fixed-height top layer: curves + node Tail template.HTML // stretching lower layer: rails only Width int // graph cell width } // branchHue is the cleanroom analogue of fossil's hash_color: a stable // hue from the branch name, so every page renders a branch identically. func branchHue(branch string) int { h := fnv.New32a() h.Write([]byte(branch)) return int(h.Sum32() % 360) } func branchStroke(branch string) string { switch branch { case "trunk": return "#7086a8" case "": // A rail whose branch is unknowable (its commit is beyond this // page) renders neutral — never a hashed hue, so no arbitrary // intermediate colors appear mid-line. return "#8a8f98" } return fmt.Sprintf("hsl(%d, 55%%, 45%%)", branchHue(branch)) } func branchTint(branch string) template.CSS { if branch == "trunk" || branch == "" { return "" } return template.CSS(fmt.Sprintf("background: hsla(%d, 55%%, 50%%, 0.14)", branchHue(branch))) } // rail tracks one vertical line of the graph: the hash it expects to // reach next (a parent of something above) and the branch coloring it. type rail struct { want string branch string active bool } // buildTimeline groups entries under day dividers and assigns graph // rails. Entries must be newest-first (the timeline order). Non-checkin // events (wiki, ticket) take no rail: the active rails pass straight // through their rows, like the date dividers. func buildTimeline(repo string, entries []timelineEntry) []tlDay { rails := []rail{} find := func(hash string) []int { var idxs []int for i := range rails { if rails[i].active && rails[i].want == hash { idxs = append(idxs, i) } } return idxs } alloc := func(want, branch string) int { for i := range rails { if !rails[i].active { rails[i] = rail{want: want, branch: branch, active: true} return i } } rails = append(rails, rail{want: want, branch: branch, active: true}) return len(rails) - 1 } // carry renders the rails active at a row boundary — the stretchable // layer a date-divider row shows so rails run unbroken through it. carry := func() (template.HTML, int) { width := (min(len(rails), maxRail))*railW + railW var b strings.Builder any := false fmt.Fprintf(&b, `<svg class="tl-tail" width="%d" preserveAspectRatio="none" viewBox="0 0 %d 1" aria-hidden="true">`, width, width) for i := range rails { if rails[i].active { any = true x := min(i, maxRail-1)*railW + railW/2 fmt.Fprintf(&b, `<line x1="%d" y1="0" x2="%d" y2="1" stroke="%s" stroke-width="2.5"/>`, x, x, branchStroke(rails[i].branch)) } } b.WriteString(`</svg>`) if !any { return "", width } return template.HTML(b.String()), width } // Look-ahead branch map: a rail heading toward a commit carries that // commit's branch color for its whole run — the branch line reads as // one color from fork to merge (fossil's rule). branchOf := map[string]string{} for _, e := range entries { branchOf[e.Hash] = e.Branch } var days []tlDay for _, e := range entries { date, clock, _ := strings.Cut(e.Date, "T") if len(clock) > 5 { clock = clock[:5] } if len(days) == 0 || days[len(days)-1].Date != date { c, w := carry() days = append(days, tlDay{Date: date, Carry: c, Width: w}) } if e.Kind == "w" || e.Kind == "t" || e.Kind == "e" || e.Kind == "f" { // A flat event row: rails pass through, no node. c, w := carry() var url string switch e.Kind { case "w": // a wiki row is one specific edit — link that revision url = "/r/" + repo + "/wiki/" + neturl.PathEscape(e.Ref) + "?rev=" + e.Hash[:min(12, len(e.Hash))] case "t": url = "/r/" + repo + "/ticket/" + e.Ref case "e": url = "/r/" + repo + "/technote/" + e.Ref[:min(16, len(e.Ref))] case "f": url = "/r/" + repo + "/forum/" + e.Ref[:min(16, len(e.Ref))] } day := &days[len(days)-1] day.Rows = append(day.Rows, tlRow{ Kind: e.Kind, Time: clock, Hash: e.Hash, URL: url, Short: e.Hash[:min(12, len(e.Hash))], User: e.User, Title: e.Comment, Tail: c, Width: w, }) continue } // Which rails were expecting this check-in? joins := find(e.Hash) var nodeRail int if len(joins) > 0 { nodeRail = joins[0] } else { nodeRail = alloc(e.Hash, e.Branch) // a tip: nothing above points here } // The segment ABOVE this node belongs to the line that was // heading here (e.g. a branch line ending at its fork commit); // capture its color before the rail is rebranded. aboveBranch := rails[nodeRail].branch joinBranch := map[int]string{} for _, j := range joins { joinBranch[j] = rails[j].branch } rails[nodeRail].branch = e.Branch // Snapshot the rails passing through this row (before rewiring), // for the vertical strokes. type seg struct { idx int branch string } var through []seg for i := range rails { if rails[i].active && i != nodeRail && !contains(joins, i) { through = append(through, seg{idx: i, branch: rails[i].branch}) } } // Rewire: converging rails close; the node rail continues to the // primary parent; secondary parents curve out. extraJoins := []int{} if len(joins) > 1 { extraJoins = joins[1:] } for _, j := range extraJoins { rails[j].active = false } hasAbove := len(joins) > 0 hasBelow := false var mergeOut []seg if len(e.Parents) > 0 { rails[nodeRail].want = e.Parents[0] hasBelow = true for _, p := range e.Parents[1:] { var target int if t := find(p); len(t) > 0 { target = t[0] } else { // The merged branch's line: colored by the commit // it descends to (look-ahead), neutral if off-page. target = alloc(p, branchOf[p]) } mergeOut = append(mergeOut, seg{idx: target, branch: rails[target].branch}) } } else { rails[nodeRail].active = false // root: nothing below } // Render the two graph layers. The head is a fixed-height band // holding the node and every curve (top-anchored, like fossil — // the node sits beside the card's first line); the tail is a // stretching layer of plain verticals that fills whatever height // the card takes, keeping rails continuous. width := (min(len(rails), maxRail))*railW + railW x := func(i int) int { return min(i, maxRail-1)*railW + railW/2 } cy := nodeCY nx := x(nodeRail) stroke := branchStroke(e.Branch) var hd strings.Builder fmt.Fprintf(&hd, `<svg class="tl-head" width="%d" height="%d" viewBox="0 0 %d %d" aria-hidden="true">`, width, headH, width, headH) for _, s := range through { fmt.Fprintf(&hd, `<line x1="%d" y1="0" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"/>`, x(s.idx), x(s.idx), headH, branchStroke(s.branch)) } if hasAbove { fmt.Fprintf(&hd, `<line x1="%d" y1="0" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"/>`, nx, nx, cy, branchStroke(aboveBranch)) } if hasBelow { fmt.Fprintf(&hd, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"/>`, nx, cy, nx, headH, stroke) } for _, j := range extraJoins { // A converging line ends here; it keeps ITS branch color to // the last pixel (a branch line meeting its fork point). fmt.Fprintf(&hd, `<path d="M %d 0 Q %d %d %d %d" fill="none" stroke="%s" stroke-width="2.5"/>`, x(j), x(j), cy, nx, cy, branchStroke(joinBranch[j])) } for _, m := range mergeOut { fmt.Fprintf(&hd, `<path d="M %d %d Q %d %d %d %d" fill="none" stroke="%s" stroke-width="2.5"/>`, nx, cy, x(m.idx), cy, x(m.idx), headH, branchStroke(m.branch)) } if e.IsLeaf { fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="4" style="fill:var(--bg)" stroke="%s" stroke-width="2.5"/>`, nx, cy, stroke) } else { fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="4.5" fill="%s"/>`, nx, cy, stroke) } hd.WriteString(`</svg>`) var tl strings.Builder fmt.Fprintf(&tl, `<svg class="tl-tail" width="%d" preserveAspectRatio="none" viewBox="0 0 %d 1" aria-hidden="true">`, width, width) for _, s := range through { fmt.Fprintf(&tl, `<line x1="%d" y1="0" x2="%d" y2="1" stroke="%s" stroke-width="2.5"/>`, x(s.idx), x(s.idx), branchStroke(s.branch)) } if hasBelow { fmt.Fprintf(&tl, `<line x1="%d" y1="0" x2="%d" y2="1" stroke="%s" stroke-width="2.5"/>`, nx, nx, stroke) } for _, m := range mergeOut { fmt.Fprintf(&tl, `<line x1="%d" y1="0" x2="%d" y2="1" stroke="%s" stroke-width="2.5"/>`, x(m.idx), x(m.idx), branchStroke(m.branch)) } tl.WriteString(`</svg>`) title, body, _ := strings.Cut(strings.TrimSpace(e.Comment), "\n") day := &days[len(days)-1] day.Rows = append(day.Rows, tlRow{ Kind: "ci", Time: clock, Hash: e.Hash, Short: e.Hash[:min(12, len(e.Hash))], URL: "/r/" + repo + "/checkin/" + e.Hash, User: e.User, Title: title, Paras: paragraphs(body), Branch: e.Branch, Tint: branchTint(e.Branch), IsLeaf: e.IsLeaf, IsMerge: e.IsMerge, CI: ciBadge(e.CI), Head: template.HTML(hd.String()), Tail: template.HTML(tl.String()), Width: width, }) } return days } // paragraphs splits a comment body on blank lines and collapses the // hard newlines within each paragraph — check-in comments carry 72-col // wraps that must reflow to the card width (fossil renders comments the // same way: newlines are not line breaks). func paragraphs(body string) []string { var out []string for _, p := range strings.Split(body, "\n\n") { p = strings.Join(strings.Fields(p), " ") if p != "" { out = append(out, p) } } return out } func contains(xs []int, v int) bool { for _, x := range xs { if x == v { return true } } return false }