// 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 ""
}
// Same weight as --surface (10% alpha), so a branch card and a trunk
// card read as one material that differs only in hue. At 14% and 55%
// saturation the tint read as a different kind of card entirely.
return template.CSS(fmt.Sprintf("background: hsla(%d, 45%%, 50%%, 0.10)", 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
}