Skip to content
quarry Artifact [c498f3f778]
Login

Artifact [c498f3f778]

Artifact c498f3f7789ca40340339a1fda6f006ff72f517f462abd8989470fb92981ee17:


package web

// The activity profile (RFC 0013): the core sample — a window of
// check-ins as dots, hour of day or path on Y, one colour per person —
// and the core slice, the whole history folded to one cell per day and
// one row per year. Both are drawn here as inline SVG from the two ops'
// output, so a reader without JavaScript gets the whole picture and
// the controls are links. The canvas that replaces the sample when a
// script runs is a later landing; the SVG stays as its first paint.

import (
	"craftdesign.group/quarry/internal/idn"
	"fmt"
	"html/template"
	"math"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/ops"
)

// The op rows, as the registry marshals them.
type activityAuthor struct {
	Name     string `json:"name"`
	Checkins int    `json:"checkins"`
	Files    int    `json:"files"`
	Hue      int    `json:"hue"`
	Chroma   int    `json:"chroma"`
	Shade    int    `json:"shade"`
}

type activityRow struct {
	Day    int    `json:"day"`
	Hour   int    `json:"hour"`
	Author int    `json:"author"`
	Merge  int    `json:"merge"`
	Files  int    `json:"files"`
	Hash   string `json:"hash"`
	Branch string `json:"branch"`
}

type activityTouch struct {
	Day       int `json:"day"`
	Path      int `json:"path"`
	Author    int `json:"author"`
	Inherited int `json:"inherited"`
}

type activityOut struct {
	Since   string           `json:"since"`
	Until   string           `json:"until"`
	Authors []activityAuthor `json:"authors"`
	Paths   []string         `json:"paths"`
	Rows    []activityRow    `json:"rows"`
	Touches []activityTouch  `json:"touches"`
	Window  string           `json:"window"`
}

type activityDay struct {
	N       int     `json:"n"`
	Merges  int     `json:"merges"`
	Top     [][]int `json:"top"`
	Authors int     `json:"authors"`
	Path    int     `json:"path"`
}

type activityDaysOut struct {
	First    string                 `json:"first"`
	Last     string                 `json:"last"`
	Authors  []activityAuthor       `json:"authors"`
	Days     map[string]activityDay `json:"days"`
	Paths    []string               `json:"paths"`
	Releases map[string]string      `json:"releases"`
}

// activityView is what the activity page template draws from.
type activityView struct {
	Axis string // "clock" or "tree"
	// User is the person singled out, and UserIdx their place in the
	// op's author list (-1 when nobody is chosen or the name is not in
	// the window). The filter never subtracts: everyone's rows come
	// back and the rest are drawn grey (§7).
	User    string
	UserIdx int
	Win     string // the window control: "recent" or "all"
	Since   string
	Until   string
	Window  string // the op's own word: "picked" or "given"
	// Bucket is the day width the SVG drew at. Over the byte budget
	// the picture is drawn coarser, not shorter: the bucket doubles
	// and marks collapse into one per (bucket, row, person) cell (§7).
	// One is the normal case.
	Bucket  int
	Rows    int
	Touches int // authored touches — the dots on the tree axis
	Sample  template.HTML
	Slice   template.HTML
	// The legend under the picture is the chooser (§7): everyone in
	// the window, the chosen one marked, and a way back to everyone.
	Legend   []activityLegend
	ClearURL string
	// Summary is the marginal the sighted reader takes from the shape,
	// as rows for a visually hidden table (§10).
	Summary      []activitySummary
	SummaryLabel string
	Years        []activityYear // the slice's numbers, for its hidden table
	YearsTotal   int            // years in the repository's history
	AllYears     bool           // ?years=all: every year, not the last sliceYears
}

type activityYear struct {
	Label    string
	Checkins int
	Days     int
	Merges   int
	Lead     string // who led the most days
	LeadPct  int
}

// activityLegend is one entry of the legend: the person's identicon, a
// name and their check-ins in the window, the whole entry a link.
type activityLegend struct {
	Index    int
	Name     string
	Checkins int
	URL      string
	Current  bool
}

type activitySummary struct {
	Label string
	N     int
}

// activityByteBudget is the size a sample SVG must stay under; past it
// the picture is drawn at a coarser day resolution and says so (§7). A
// variable so a test can force the coarsening without a 25,000-touch
// fixture.
var activityByteBudget = 1 << 20

const (
	// activityBucketMax bounds the coarsening: past it the page ships
	// over budget, which is still bounded by days/32 x rows x people.
	activityBucketMax = 32
	// activityLegendMax is how many people the legend names.
	activityLegendMax = 24
)

// activityColour is a person's colour as a CSS value: the identicon's
// hue and chroma, lightness from the theme token, shade stepping the
// lightness so neighbours on the wheel stay apart (§6).
func activityColour(a activityAuthor) string {
	if a.Shade == 0 {
		return idn.Fill(a.Name, "var(--idn-l)")
	}
	return idn.Fill(a.Name, fmt.Sprintf("calc(var(--idn-l) + %d%%)", a.Shade*8))
}

// activityURL is a link to the activity page. There are three states
// and no more: which axis, who is singled out, and which window. A link
// carrying none of them is the bare page.
func activityURL(repo, axis, user, window string) string {
	q := url.Values{}
	if axis != "" && axis != "clock" {
		q.Set("axis", axis)
	}
	if user != "" {
		q.Set("user", user)
	}
	if window == "all" {
		q.Set("window", "all")
	}
	u := "/r/" + url.PathEscape(repo) + "/activity"
	if len(q) > 0 {
		u += "?" + q.Encode()
	}
	return u
}

func dayAfter(iso string, n int) string {
	t, err := time.Parse("2006-01-02", iso)
	if err != nil {
		return iso
	}
	return t.AddDate(0, 0, n).Format("2006-01-02")
}

func daysSpan(since, until string) int {
	a, err1 := time.Parse("2006-01-02", since)
	b, err2 := time.Parse("2006-01-02", until)
	if err1 != nil || err2 != nil || b.Before(a) {
		return 1
	}
	return int(b.Sub(a).Hours()/24) + 1
}

func esc(s string) string { return template.HTMLEscapeString(s) }

// activityHandler serves /r/{repo}/activity.
func activityHandler(r *ops.Registry) http.HandlerFunc {
	return func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		q := req.URL.Query()
		axis := q.Get("axis")
		if axis == "" {
			axis = "clock"
		}
		if axis != "clock" && axis != "tree" {
			fail(r, w, req, fmt.Errorf("axis must be clock or tree: %w", ops.ErrInvalid))
			return
		}
		win := "recent"
		if q.Get("window") == "all" {
			win = "all"
		}
		v := activityView{Axis: axis, User: q.Get("user"), UserIdx: -1, Win: win, Bucket: 1}
		since, until := q.Get("since"), q.Get("until")

		// The whole history folded to days: the year slice draws from
		// it, and the "all" window takes its ends from it.
		var days activityDaysOut
		haveDays := false
		loadDays := func() {
			if haveDays {
				return
			}
			out, err := invoke(req.Context(), r, "repo.activity.days", url.Values{"repo": {repo}})
			if err != nil {
				return
			}
			remarshal(out, &days)
			haveDays = true
		}
		if win == "all" {
			loadDays()
			if days.First != "" {
				since, until = days.First, days.Last
			}
		}

		// No user, no branch: the person filter singles out, it does
		// not subtract, so everyone's rows have to come back (§7).
		var act activityOut
		out, err := invoke(req.Context(), r, "repo.activity", url.Values{
			"repo": {repo}, "since": {since}, "until": {until},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		remarshal(out, &act)
		for i, a := range act.Authors {
			if a.Name == v.User {
				v.UserIdx = i
				break
			}
		}
		// Over the budget the picture is coarsened, not shortened: the
		// day bucket doubles until the SVG fits (§7).
		var sample string
		for {
			sample = coreSampleSVG(repo, axis, act, v.UserIdx, v.Bucket)
			if len(sample) <= activityByteBudget || v.Bucket >= activityBucketMax {
				break
			}
			v.Bucket *= 2
		}
		v.Sample = template.HTML(sample)
		v.Since, v.Until, v.Window = act.Since, act.Until, act.Window
		v.Rows = len(act.Rows)
		for _, t := range act.Touches {
			if t.Inherited == 0 {
				v.Touches++
			}
		}
		v.Legend = activityLegendOf(repo, axis, win, v.User, act.Authors)
		v.ClearURL = activityURL(repo, axis, "", win)
		v.SummaryLabel, v.Summary = activitySummaryOf(axis, act)

		loadDays()
		if haveDays {
			// The days op has its own author list, so the chosen
			// person is resolved against it, not against the window's.
			sliceUser := -1
			for i, a := range days.Authors {
				if a.Name == v.User {
					sliceUser = i
					break
				}
			}
			spans := yearSpans(days)
			v.YearsTotal = len(spans)
			if q.Get("years") == "all" {
				v.AllYears = true
			} else if len(spans) > sliceYears {
				spans = spans[:sliceYears]
			}
			svg, years := coreSliceSVG(days, spans, false, sliceUser)
			v.Slice, v.Years = template.HTML(svg), years
		}
		// ?frag=1 is the same page asked for as a fragment: the view
		// region alone, as one Datastar patch into #act-view. The
		// slice lives in that region too, so a filter click re-cuts
		// the year-by-year with the rest (§7).
		if q.Get("frag") != "" {
			var buf strings.Builder
			if err := activityPage.ExecuteTemplate(&buf, "act-view", pageData{Repo: repo, Activity: &v}); err != nil {
				http.Error(w, "view render failed", http.StatusInternalServerError)
				return
			}
			w.Header().Set("Content-Type", "text/event-stream")
			w.Header().Set("Cache-Control", "no-store")
			fmt.Fprint(w, "event: datastar-patch-elements\ndata: mode inner\ndata: selector #act-view\n")
			for _, line := range strings.Split(buf.String(), "\n") {
				fmt.Fprintf(w, "data: elements %s\n", line)
			}
			fmt.Fprint(w, "\n")
			return
		}
		render(r, w, req, activityPage, pageData{
			Title: "Activity", Repo: repo, Activity: &v,
		})
	}
}

// activityLegendOf is the legend under the picture, which is also the
// chooser (§7): everyone in the window, most check-ins first. Everyone
// stays named whoever is chosen — the legend is the door, not a report
// of what survived a filter.
func activityLegendOf(repo, axis, win, user string, authors []activityAuthor) []activityLegend {
	n := len(authors)
	if n > activityLegendMax {
		n = activityLegendMax
	}
	out := make([]activityLegend, 0, n)
	for i, a := range authors[:n] {
		out = append(out, activityLegend{
			Index: i, Name: a.Name, Checkins: a.Checkins,
			URL:     activityURL(repo, axis, a.Name, win),
			Current: a.Name == user,
		})
	}
	return out
}

// activitySummaryOf is the right-hand marginal as numbers: per hour on
// the clock axis, the ten most-touched paths on the tree axis.
func activitySummaryOf(axis string, act activityOut) (string, []activitySummary) {
	if axis == "clock" {
		var hours [24]int
		for _, r := range act.Rows {
			if r.Hour >= 0 && r.Hour < 24 {
				hours[r.Hour]++
			}
		}
		out := make([]activitySummary, 0, 24)
		for h, n := range hours {
			out = append(out, activitySummary{Label: fmt.Sprintf("%02d:00", h), N: n})
		}
		return "check-ins by hour (UTC)", out
	}
	counts := make(map[int]int)
	for _, t := range act.Touches {
		if t.Inherited == 0 {
			counts[t.Path]++
		}
	}
	idx := make([]int, 0, len(counts))
	for p := range counts {
		idx = append(idx, p)
	}
	sort.Slice(idx, func(i, j int) bool {
		if counts[idx[i]] != counts[idx[j]] {
			return counts[idx[i]] > counts[idx[j]]
		}
		return idx[i] < idx[j]
	})
	if len(idx) > 10 {
		idx = idx[:10]
	}
	out := make([]activitySummary, 0, len(idx))
	for _, p := range idx {
		if p >= 0 && p < len(act.Paths) {
			out = append(out, activitySummary{Label: act.Paths[p], N: counts[p]})
		}
	}
	return "most-changed files", out
}

// Layout of the core sample, in viewBox units.
const (
	sampleW      = 960
	sampleLeft   = 12 // the hour ticks / date rail, rules only — no labels
	sampleRight  = 70 // the per-hour / per-day marginal
	sampleTop    = 48 // the per-day marginal
	sampleBottom = 22 // date labels
	sampleHour   = 12 // one hour's height on the clock axis
)

// Layout of the tree axis (§7): time runs down the page and files
// across, so the block is the same height whatever the repository —
// one narrow column per path, one row per day. When the window is
// longer than the plot the rows overlap, and that overlap is the
// texture, not a defect.
const (
	treeTop      = 56 // the per-path bars (48) and a little breath
	treeBottom   = 14
	treePlotH    = 520
	treeH        = treeTop + treePlotH + treeBottom
	treeBarH     = 48  // the per-path marginal's tallest bar
	treeBandMin  = 46  // a directory band narrower than this gets no label
	treeMonthMax = 900 // past this span the date rail steps by year
	treeDense    = 120 // past this many paths the dot shrinks with the column
)

// userAttr and colourAttr are the two optional attributes the filter
// adds: which person is singled out, and their colour on a group.
func userAttr(user int) string {
	if user < 0 {
		return ""
	}
	return fmt.Sprintf(` data-user="%d"`, user)
}

func colourAttr(act activityOut, i int) string {
	if i < 0 || i >= len(act.Authors) {
		return ""
	}
	return fmt.Sprintf(` style="--c:%s"`, activityColour(act.Authors[i]))
}

func maxOf(v []int) int {
	m := 0
	for _, n := range v {
		if n > m {
			m = n
		}
	}
	return m
}

// bucketMid is a day bucket's centre, in days from the window's start,
// clamped to the window's last day.
func bucketMid(span, bucket, i int) float64 {
	c := float64(i*bucket) + float64(bucket-1)/2
	if c > float64(span-1) {
		c = float64(span - 1)
	}
	return c
}

// bucketLabel names a bucket's days: one date, or the range.
func bucketLabel(since string, span, bucket, i int) string {
	from := dayAfter(since, i*bucket)
	last := i*bucket + bucket - 1
	if last > span-1 {
		last = span - 1
	}
	if last <= i*bucket {
		return from
	}
	return from + " to " + dayAfter(since, last)
}

// coreSampleSVG draws the window. Dots are grouped by author so the
// colour is set once per person, not once per dot: the group carries
// --c and the stylesheet does the rest. The chosen person's group is
// marked mine and the sheet greys everyone else — the filter singles
// out, it never subtracts (§7). Nothing is written on the plot; the
// hover says what and when (§3). bucket is the day width: past one, the
// marks in a bucket collapse to one per (bucket, row, person), which is
// how a picture over its byte budget is made smaller.
func coreSampleSVG(repo, axis string, act activityOut, user, bucket int) string {
	if bucket < 1 {
		bucket = 1
	}
	if axis == "tree" {
		return treeSampleSVG(act, user, bucket)
	}
	span := daysSpan(act.Since, act.Until)
	nb := (span + bucket - 1) / bucket
	pw := float64(sampleW - sampleLeft - sampleRight)
	dx := pw / float64(span)
	x := func(day float64) float64 { return sampleLeft + (day+0.5)*dx }

	ph := 24 * sampleHour
	h := sampleTop + ph + sampleBottom
	plotTop := float64(sampleTop)

	var b strings.Builder
	// Every dot is a link, and an image may not hold focusable things;
	// this chart is a group with a name.
	fmt.Fprintf(&b, `<svg class="act act-clock" viewBox="0 0 %d %d" role="group" aria-label="%s" data-axis="clock" data-rows="%d" data-bucket="%d"%s data-since="%s" data-until="%s" data-window="%s">`,
		sampleW, h, esc(activitySentence(act, user)), len(act.Rows), bucket, userAttr(user),
		esc(act.Since), esc(act.Until), esc(act.Window))
	fmt.Fprintf(&b, `<title>%s</title>`, esc(activitySentence(act, user)))

	// The top marginal: the project's days in grey, and under a filter
	// the person's own in their colour on top, on the project's scale
	// so the two read as a share, not as two unrelated charts.
	perDay, minePerDay := make([]int, nb), make([]int, nb)
	var perHour, minePerHour [24]int
	for _, r := range act.Rows {
		if r.Day >= 0 && r.Day < span {
			perDay[r.Day/bucket]++
			if r.Author == user {
				minePerDay[r.Day/bucket]++
			}
		}
		if r.Hour >= 0 && r.Hour < 24 {
			perHour[r.Hour]++
			if r.Author == user {
				minePerHour[r.Hour]++
			}
		}
	}
	maxDay := maxOf(perDay)
	dayBars := func(counts []int, cls, style string) {
		fmt.Fprintf(&b, `<g class="%s" data-marginal="days"%s>`, cls, style)
		if maxDay > 0 {
			scale := float64(sampleTop-10) / float64(maxDay)
			bw := math.Max(1, dx*float64(bucket))
			for i, n := range counts {
				if n == 0 {
					continue
				}
				bh := float64(n) * scale
				fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>`,
					sampleLeft+float64(i*bucket)*dx, float64(sampleTop-4)-bh, bw, bh)
			}
		}
		b.WriteString(`</g>`)
	}
	dayBars(perDay, "mx", "")
	if user >= 0 {
		dayBars(minePerDay, "mx mine", colourAttr(act, user))
	}

	// The hour ticks down the left edge: rules, not labels.
	b.WriteString(`<g class="ax">`)
	for hr := 0; hr < 24; hr += 3 {
		fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f"/>`,
			sampleLeft-5, plotTop+float64(hr)*sampleHour, sampleLeft, plotTop+float64(hr)*sampleHour)
	}
	b.WriteString(`</g>`)

	maxH := maxOf(perHour[:])
	hourBars := func(counts []int, cls, style string) {
		fmt.Fprintf(&b, `<g class="%s" data-marginal="hours"%s>`, cls, style)
		if maxH > 0 {
			scale := float64(sampleRight-10) / float64(maxH)
			for hr, n := range counts {
				if n == 0 {
					continue
				}
				fmt.Fprintf(&b, `<rect x="%d" y="%.1f" width="%.1f" height="%d"/>`,
					sampleW-sampleRight+4, plotTop+float64(hr)*sampleHour+1, float64(n)*scale, sampleHour-2)
			}
		}
		b.WriteString(`</g>`)
	}
	hourBars(perHour[:], "my", "")
	if user >= 0 {
		hourBars(minePerHour[:], "my mine", colourAttr(act, user))
	}

	// The dots, one group per author. The radius is an attribute, not
	// only a style: engines without CSS geometry would draw nothing.
	byAuthor := make([][]string, len(act.Authors))
	if bucket == 1 {
		for _, r := range act.Rows {
			if r.Author < 0 || r.Author >= len(byAuthor) {
				continue
			}
			cls := "dot"
			if r.Merge != 0 {
				cls = "dot merge"
			}
			title := fmt.Sprintf("%s %s %02d:00", act.Authors[r.Author].Name, dayAfter(act.Since, r.Day), r.Hour)
			if r.Merge != 0 {
				title += " · merge"
			} else if r.Files == 1 {
				title += " · 1 file"
			} else {
				title += fmt.Sprintf(" · %d files", r.Files)
			}
			if r.Branch != "" {
				title += " · " + r.Branch
			}
			byAuthor[r.Author] = append(byAuthor[r.Author], fmt.Sprintf(
				`<a href="/r/%s/checkin/%s"><circle class="%s" cx="%.1f" cy="%.1f" r="2.4"><title>%s</title></circle></a>`,
				url.PathEscape(repo), url.PathEscape(r.Hash), cls, x(float64(r.Day)), plotTop+float64(r.Hour)*sampleHour+sampleHour/2, esc(title)))
		}
	} else {
		// Coarsened: a cell is a bucket of days at one hour for one
		// person, so it is a count, not a check-in, and not a link.
		type cell struct{ b, hour, author int }
		seen := map[cell]int{}
		var order []cell
		for _, r := range act.Rows {
			if r.Author < 0 || r.Author >= len(byAuthor) || r.Day < 0 || r.Day >= span || r.Hour < 0 || r.Hour > 23 {
				continue
			}
			c := cell{r.Day / bucket, r.Hour, r.Author}
			if _, ok := seen[c]; !ok {
				order = append(order, c)
			}
			seen[c]++
		}
		for _, c := range order {
			title := fmt.Sprintf("%s %s %02d:00 · %d check-ins",
				act.Authors[c.author].Name, bucketLabel(act.Since, span, bucket, c.b), c.hour, seen[c])
			byAuthor[c.author] = append(byAuthor[c.author], fmt.Sprintf(
				`<circle class="dot" cx="%.1f" cy="%.1f" r="2.4"><title>%s</title></circle>`,
				x(bucketMid(span, bucket, c.b)), plotTop+float64(c.hour)*sampleHour+sampleHour/2, esc(title)))
		}
	}
	writeAuthorGroups(&b, act, byAuthor, user)
	b.WriteString(`</svg>`)
	return b.String()
}

// treeSampleSVG draws the tree axis: one column per path, one row per
// day with the newest at the top, so the picture's height is fixed and
// only the columns thin out as a repository grows. Only authored
// touches are dots — the inherited ones a merge carried in are shape,
// not anyone's work, and drawing them here would double the bytes for a
// picture the reader can only see with a pointer anyway.
func treeSampleSVG(act activityOut, user, bucket int) string {
	span := daysSpan(act.Since, act.Until)
	nb := (span + bucket - 1) / bucket
	paths := len(act.Paths)
	pw := float64(sampleW - sampleLeft - sampleRight)
	colW := pw
	if paths > 0 {
		colW = pw / float64(paths)
	}
	rowH := float64(treePlotH) / float64(span)
	x := func(p int) float64 { return sampleLeft + (float64(p)+0.5)*colW }
	y := func(d float64) float64 { return treeTop + (float64(span-1)-d+0.5)*rowH }

	// A dot wide enough to see, or as wide as its column allows. Below
	// 1.4 a circle is mush; a square of the same size reads crisper
	// and costs fewer bytes.
	r := 2.4
	if paths > treeDense {
		r = math.Max(0.9, colW*0.45)
	}
	mark := func(cx, cy float64, title string) string {
		if r < 1.4 {
			return fmt.Sprintf(`<rect class="dot" x="%.1f" y="%.1f" width="%.1f" height="%.1f"><title>%s</title></rect>`,
				cx-r, cy-r, 2*r, 2*r, title)
		}
		return fmt.Sprintf(`<circle class="dot" cx="%.1f" cy="%.1f" r="%.1f"><title>%s</title></circle>`,
			cx, cy, r, title)
	}

	var b strings.Builder
	fmt.Fprintf(&b, `<svg class="act act-tree" viewBox="0 0 %d %d" role="img" aria-label="%s" data-axis="tree" data-rows="%d" data-paths="%d" data-span="%d" data-bucket="%d"%s data-since="%s" data-until="%s" data-window="%s">`,
		sampleW, treeH, esc(activitySentence(act, user)), len(act.Rows), paths, span, bucket, userAttr(user),
		esc(act.Since), esc(act.Until), esc(act.Window))
	fmt.Fprintf(&b, `<title>%s</title>`, esc(activitySentence(act, user)))

	// Directory bands (§5): paths arrive sorted so a directory's files
	// are contiguous; a run of five or more is a column of the plot.
	// The band is the structure and keeps its name as a data hook; the
	// name is not written on the plot (§3).
	b.WriteString(`<g class="bands">`)
	start, shade := 0, false
	for i := 1; i <= paths; i++ {
		if i < paths && dirOf(act.Paths[i]) == dirOf(act.Paths[start]) {
			continue
		}
		if n := i - start; n >= 5 {
			label := dirOf(act.Paths[start])
			if label == "" {
				label = "/"
			}
			x0, w := sampleLeft+float64(start)*colW, float64(n)*colW
			fmt.Fprintf(&b, `<g data-dir="%s">`, esc(label))
			if shade {
				fmt.Fprintf(&b, `<rect class="band" x="%.1f" y="%d" width="%.1f" height="%d"/>`,
					x0, treeTop, w, treePlotH)
			}
			shade = !shade
			b.WriteString(`</g>`)
		}
		start = i
	}
	b.WriteString(`</g>`)

	// The date rail down the left: a rule wherever the month changes,
	// or the year on a window too long for months. The rule is the
	// structure; the date is in the hover.
	b.WriteString(`<g class="rail">`)
	if since, err := time.Parse("2006-01-02", act.Since); err == nil {
		prev := ""
		for d := span - 1; d >= 0; d-- {
			t := since.AddDate(0, 0, d)
			label := t.Format("2006-01")
			if span > treeMonthMax {
				label = t.Format("2006")
			}
			if label == prev {
				continue
			}
			prev = label
			yy := y(float64(d))
			fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f"><title>%s</title></line>`,
				sampleLeft, yy, sampleW-sampleRight, yy, label)
		}
	}
	b.WriteString(`</g>`)

	// Marginals: which files are touched most across the top, how busy
	// each day was down the right edge; under a filter the person's own
	// counts sit on top in their colour, on the project's scale.
	perPath, minePath := make([]int, paths), make([]int, paths)
	perDay, mineDay := make([]int, nb), make([]int, nb)
	for _, t := range act.Touches {
		if t.Inherited != 0 {
			continue
		}
		mine := t.Author == user
		if t.Path >= 0 && t.Path < paths {
			perPath[t.Path]++
			if mine {
				minePath[t.Path]++
			}
		}
		if t.Day >= 0 && t.Day < span {
			perDay[t.Day/bucket]++
			if mine {
				mineDay[t.Day/bucket]++
			}
		}
	}
	maxP, maxD := maxOf(perPath), maxOf(perDay)
	pathBars := func(counts []int, cls, style string) {
		fmt.Fprintf(&b, `<g class="%s" data-marginal="paths"%s>`, cls, style)
		if maxP > 0 {
			scale := float64(treeBarH) / float64(maxP)
			bw := math.Max(1, colW)
			for p, n := range counts {
				if n == 0 {
					continue
				}
				bh := float64(n) * scale
				fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>`,
					sampleLeft+float64(p)*colW, float64(treeBarH)-bh, bw, bh)
			}
		}
		b.WriteString(`</g>`)
	}
	dayBars := func(counts []int, cls, style string) {
		fmt.Fprintf(&b, `<g class="%s" data-marginal="days"%s>`, cls, style)
		if maxD > 0 {
			scale := float64(sampleRight-10) / float64(maxD)
			bh := math.Max(1, rowH*float64(bucket))
			for i, n := range counts {
				if n == 0 {
					continue
				}
				fmt.Fprintf(&b, `<rect x="%d" y="%.1f" width="%.1f" height="%.1f"/>`,
					sampleW-sampleRight+4, y(bucketMid(span, bucket, i))-bh/2, float64(n)*scale, bh)
			}
		}
		b.WriteString(`</g>`)
	}
	pathBars(perPath, "mx", "")
	if user >= 0 {
		pathBars(minePath, "mx mine", colourAttr(act, user))
	}
	dayBars(perDay, "my", "")
	if user >= 0 {
		dayBars(mineDay, "my mine", colourAttr(act, user))
	}

	byAuthor := make([][]string, len(act.Authors))
	if bucket == 1 {
		for _, t := range act.Touches {
			if t.Inherited != 0 || t.Author < 0 || t.Author >= len(byAuthor) || t.Path < 0 || t.Path >= paths {
				continue
			}
			byAuthor[t.Author] = append(byAuthor[t.Author], mark(x(t.Path), y(float64(t.Day)), fmt.Sprintf("%s %s %s",
				esc(act.Authors[t.Author].Name), dayAfter(act.Since, t.Day), esc(act.Paths[t.Path]))))
		}
	} else {
		type cell struct{ b, path, author int }
		seen := map[cell]int{}
		var order []cell
		for _, t := range act.Touches {
			if t.Inherited != 0 || t.Author < 0 || t.Author >= len(byAuthor) ||
				t.Path < 0 || t.Path >= paths || t.Day < 0 || t.Day >= span {
				continue
			}
			c := cell{t.Day / bucket, t.Path, t.Author}
			if _, ok := seen[c]; !ok {
				order = append(order, c)
			}
			seen[c]++
		}
		for _, c := range order {
			byAuthor[c.author] = append(byAuthor[c.author], mark(x(c.path), y(bucketMid(span, bucket, c.b)),
				fmt.Sprintf("%s %s %s · %d changes", esc(act.Authors[c.author].Name),
					bucketLabel(act.Since, span, bucket, c.b), esc(act.Paths[c.path]), seen[c])))
		}
	}
	writeAuthorGroups(&b, act, byAuthor, user)
	b.WriteString(`</svg>`)
	return b.String()
}

// writeAuthorGroups writes the marks one group per author: the colour
// is set once per person, on the group, not once per dot. The chosen
// person's group is marked mine, which is the hook the sheet greys the
// others by — a per-index rule cannot be written in a static sheet.
func writeAuthorGroups(b *strings.Builder, act activityOut, byAuthor [][]string, user int) {
	for i, dots := range byAuthor {
		if len(dots) == 0 {
			continue
		}
		cls := "au"
		if i == user {
			cls = "au mine"
		}
		fmt.Fprintf(b, `<g class="%s" data-author="%d" style="--c:%s">`, cls, i, activityColour(act.Authors[i]))
		for _, d := range dots {
			b.WriteString(d)
		}
		b.WriteString(`</g>`)
	}
}

func dirOf(path string) string {
	if i := strings.LastIndexByte(path, '/'); i >= 0 {
		return path[:i]
	}
	return ""
}

// activitySentence is the chart's aria-label: the numbers a sighted
// reader takes from the shape, in one sentence (§10). Under a filter it
// says who is in colour, because that is what the picture now shows.
func activitySentence(act activityOut, user int) string {
	lead := ""
	if len(act.Authors) > 0 && len(act.Rows) > 0 {
		a := act.Authors[0]
		lead = fmt.Sprintf("; %s led with %d%%", a.Name, a.Checkins*100/len(act.Rows))
	}
	who := ""
	if user >= 0 && user < len(act.Authors) {
		who = fmt.Sprintf("; %s is in colour, everyone else grey", act.Authors[user].Name)
	}
	return fmt.Sprintf("%d check-ins by %d people, %s to %s%s%s",
		len(act.Rows), len(act.Authors), act.Since, act.Until, lead, who)
}

// The slice: one block per span (a calendar year, or the home page's
// last year), laid out the way GitHub taught everyone to read a year —
// a column per week, a row per weekday — so eight years stack on a
// page without scrolling. A cell is the day's leading author's colour
// at the day's volume, with a second-author stripe at 30% share and a
// release notch; the block's right column carries the ownership bar
// and the year's numbers (§7).
const (
	sliceCell   = 11
	sliceStep   = 13 // cell plus gap
	sliceTop    = 16 // month labels above the grid
	sliceGap    = 14 // between blocks
	sliceLabelW = 46
	sliceBarW   = 150
	// Wide enough for the longest numbers line, which now opens with
	// the year: "2020 · 12,345 check-ins · 366 active days".
	sliceRightW = 262
	sliceStripH = 22 // the home page's one-row strip
)

// sliceYears is how many blocks the activity page shows unless asked
// for all of them.
const sliceYears = 8

type sliceSpan struct {
	Label string
	From  string // ISO, inclusive
	Days  int
}

// yearSpans is every calendar year from the first check-in to the
// last, newest first — the slice reads like a core, top is now.
func yearSpans(d activityDaysOut) []sliceSpan {
	first, err1 := time.Parse("2006-01-02", d.First)
	last, err2 := time.Parse("2006-01-02", d.Last)
	if err1 != nil || err2 != nil || last.Before(first) {
		return nil
	}
	var out []sliceSpan
	for y := last.Year(); y >= first.Year(); y-- {
		days := 365
		if y%4 == 0 && (y%100 != 0 || y%400 == 0) {
			days = 366
		}
		out = append(out, sliceSpan{Label: fmt.Sprint(y), From: fmt.Sprintf("%d-01-01", y), Days: days})
	}
	return out
}

// lastYearSpan is the home page's block: the 365 days ending at the
// last check-in.
func lastYearSpan(d activityDaysOut) []sliceSpan {
	last, err := time.Parse("2006-01-02", d.Last)
	if err != nil {
		return nil
	}
	return []sliceSpan{{Label: "", From: last.AddDate(0, 0, -364).Format("2006-01-02"), Days: 365}}
}

// coreSliceSVG draws the slice. With fit true it is the home page's
// strip instead: one row, a cell per day with no gaps, stretched to
// its box — the compact door, not the grid. In the grid every day of
// the span is drawn: a day with no work of its own is an empty cell,
// so the year reads as a whole shape and not as marks floating on the
// page. user is the person singled out, or -1: their days are their
// colour at their own volume, a day that was someone else's a grey
// cell at the day's weight, and an empty day stays empty (§7).
func coreSliceSVG(d activityDaysOut, spans []sliceSpan, fit bool, user int) (string, []activityYear) {
	if len(spans) == 0 {
		return "", nil
	}
	var years []activityYear
	// Columns are weeks; a span starting late in the week can need 54.
	cols := 0
	for _, s := range spans {
		from, err := time.Parse("2006-01-02", s.From)
		if err != nil {
			return "", nil
		}
		if c := (int(from.Weekday())+s.Days-1)/7 + 1; c > cols {
			cols = c
		}
	}
	cellsX := float64(sliceLabelW)
	if fit {
		cellsX = 0
	}
	gridW := cols*sliceStep - (sliceStep - sliceCell)
	gridH := 7*sliceStep - (sliceStep - sliceCell)
	blockH := sliceTop + gridH
	barX := cellsX + float64(gridW) + 18
	w := int(barX) + sliceRightW
	h := len(spans)*(blockH+sliceGap) - sliceGap + 2
	if fit {
		w, h = spans[0].Days*sliceCell, sliceStripH
	}

	colour := func(i int) string {
		if i < 0 || i >= len(d.Authors) {
			return "var(--muted)"
		}
		return activityColour(d.Authors[i])
	}
	name := func(i int) string {
		if i < 0 || i >= len(d.Authors) {
			return "?"
		}
		return d.Authors[i].Name
	}

	var b strings.Builder
	active := 0
	for _, s := range spans {
		for i := 0; i < s.Days; i++ {
			if _, ok := d.Days[dayAfter(s.From, i)]; ok {
				active++
			}
		}
	}
	// The strip's cells keep the old repository-wide scale — it is one
	// row of a door and its look is fixed.
	var counts []int
	for _, day := range d.Days {
		if day.N > 0 {
			counts = append(counts, day.N)
		}
	}
	sort.Ints(counts)
	p95 := 3
	if len(counts) > 0 {
		if n := counts[len(counts)*95/100]; n > p95 {
			p95 = n
		}
	}
	stripOpacity := func(n int) float64 {
		level := math.Log1p(float64(n)) / math.Log1p(float64(p95))
		if level > 1 {
			level = 1
		}
		return 0.3 + 0.7*level
	}
	// In the grid the weight is what the reader is meant to see, so it
	// runs the whole way: one check-in is faint at 0.25 and the span's
	// own busy days (its 95th percentile) are solid, on a log ramp so
	// the middle of a year is not all pinned near the top. Per span,
	// because a quiet year measured against a loud one is a grid of
	// near-blanks — the numbers beside it carry the comparison.
	weigh := func(peak int) func(int) float64 {
		if peak < 2 {
			peak = 2
		}
		base, top := math.Log1p(1), math.Log1p(float64(peak))
		return func(n int) float64 {
			level := (math.Log1p(float64(n)) - base) / (top - base)
			if level < 0 {
				level = 0
			}
			if level > 1 {
				level = 1
			}
			return 0.25 + 0.75*level
		}
	}
	plural := func(n int, one, many string) string {
		if n == 1 {
			return one
		}
		return many
	}
	label := fmt.Sprintf("%d active %s across %d %s, %s to %s", active, plural(active, "day", "days"),
		len(spans), plural(len(spans), "year", "years"), d.First, d.Last)
	size := ""
	if fit {
		size = ` preserveAspectRatio="none"`
	}
	fmt.Fprintf(&b, `<svg class="slice"%s viewBox="0 0 %d %d" role="img" aria-label="%s" data-days="%d" data-spans="%d" data-cols="%d">`,
		size, w, h, esc(label), active, len(spans), cols)
	fmt.Fprintf(&b, `<title>%s</title>`, esc(label))

	for si, s := range spans {
		top := float64(si*(blockH+sliceGap)) + 1
		gy := top + sliceTop
		from, _ := time.Parse("2006-01-02", s.From)
		dow0 := int(from.Weekday())
		// The span's own busy day sets the weight scale: the person's
		// high under a filter, the day's otherwise.
		var spanCounts []int
		for i := 0; i < s.Days; i++ {
			day, ok := d.Days[dayAfter(s.From, i)]
			if !ok || day.N == 0 {
				continue
			}
			n := day.N
			if user >= 0 {
				n = 0
				for _, t := range day.Top {
					if len(t) == 2 && t[0] == user {
						n = t[1]
						break
					}
				}
			}
			if n > 0 {
				spanCounts = append(spanCounts, n)
			}
		}
		sort.Ints(spanCounts)
		peak := 1
		if len(spanCounts) > 0 {
			peak = spanCounts[len(spanCounts)*95/100]
		}
		opacity := weigh(peak)
		if fit {
			opacity = stripOpacity
		}
		// Group cells by leading author so the colour is set once.
		cells := map[int][]string{}
		var notches, empties, others []string
		led := map[int]int{}
		checkins, merges, days := 0, 0, 0
		for i := 0; i < s.Days; i++ {
			date := dayAfter(s.From, i)
			day, ok := d.Days[date]
			cx := cellsX + float64((dow0+i)/7*sliceStep)
			cy := gy + float64((dow0+i)%7*sliceStep)
			cw, ch := sliceCell, sliceCell
			if fit {
				cx, cy, ch = float64(i*sliceCell), 0, sliceStripH
			}
			if tag, rel := d.Releases[date]; rel {
				// "release 2.14.2" when the day carries a version,
				// plain "release" when it carries only the tag.
				tag = strings.TrimPrefix(tag, "version-")
				if tag == "release" {
					tag = ""
				} else {
					tag = " " + tag
				}
				if fit {
					notches = append(notches, fmt.Sprintf(`<rect class="rel" x="%.0f" y="%.0f" width="%d" height="4" data-date="%s"><title>%s · release%s</title></rect>`,
						cx, cy, cw, esc(date), esc(date), esc(tag)))
				} else {
					// A marker in the gutter above the grid, over its
					// week's column: inside the cell it was a speck
					// competing with the weight it sits on.
					notches = append(notches, fmt.Sprintf(`<path class="rel" d="M%.0f %.0fL%.0f %.0fL%.0f %.0fZ" data-date="%s"><title>%s · release%s</title></path>`,
						cx+sliceCell/2, gy-2, cx+1, gy-7, cx+sliceCell-1, gy-7, esc(date), esc(date), esc(tag)))
				}
			}
			if !ok || day.N == 0 {
				// An empty cell, so the grid is a grid. The strip has
				// no room for them and shows the days it has.
				if !fit {
					empties = append(empties, fmt.Sprintf(`<rect class="c empty" x="%.0f" y="%.0f" width="%d" height="%d"/>`,
						cx, cy, cw, ch))
				}
				continue
			}
			checkins += day.N
			merges += day.Merges
			days++
			lead := -1
			if len(day.Top) > 0 && len(day.Top[0]) == 2 {
				lead = day.Top[0][0]
			}
			led[lead]++
			title := fmt.Sprintf("%s · %d check-in", date, day.N)
			if day.N != 1 {
				title += "s"
			}
			if day.Merges > 0 {
				title += fmt.Sprintf(" (%d merge", day.Merges)
				if day.Merges != 1 {
					title += "s"
				}
				title += ")"
			}
			for k, t := range day.Top {
				if len(t) != 2 {
					continue
				}
				if k == 0 {
					title += " · "
				} else {
					title += ", "
				}
				title += fmt.Sprintf("%s %d", name(t[0]), t[1])
			}
			if day.Path >= 0 && day.Path < len(d.Paths) {
				p := d.Paths[day.Path]
				if p == "" {
					p = "/"
				}
				title += " · " + p
			}
			if user >= 0 {
				// One person's year: their days in their colour at
				// their own weight; a day that was someone else's is
				// a grey cell at the day's weight, so the rest of the
				// project stays visible behind them and an empty day
				// is still the emptiest cell (§7).
				n := 0
				for _, t := range day.Top {
					if len(t) == 2 && t[0] == user {
						n = t[1]
						break
					}
				}
				if n == 0 {
					if !fit {
						others = append(others, fmt.Sprintf(`<rect class="c other" x="%.0f" y="%.0f" width="%d" height="%d" fill-opacity="%.2f" data-date="%s"><title>%s</title></rect>`,
							cx, cy, cw, ch, 0.6*opacity(day.N), esc(date), esc(title)))
					}
					continue
				}
				cells[user] = append(cells[user], fmt.Sprintf(`<rect class="c" x="%.0f" y="%.0f" width="%d" height="%d" fill-opacity="%.2f" data-date="%s"><title>%s</title></rect>`,
					cx, cy, cw, ch, opacity(n), esc(date), esc(title)))
				continue
			}
			// Nothing is drawn inside a cell: the whole cell is the
			// day's leading person and its weight is the day's volume.
			cells[lead] = append(cells[lead], fmt.Sprintf(`<rect class="c" x="%.0f" y="%.0f" width="%d" height="%d" fill-opacity="%.2f" data-date="%s"><title>%s</title></rect>`,
				cx, cy, cw, ch, opacity(day.N), esc(date), esc(title)))
		}
		fmt.Fprintf(&b, `<g class="row" data-span="%s" data-checkins="%d" data-days="%d" data-merges="%d">`, esc(s.Label), checkins, days, merges)
		yr := activityYear{Label: s.Label, Checkins: checkins, Days: days, Merges: merges}
		// The year says its name in the gutter left of its grid, on every
		// block: a filtered person's empty year still needs naming, or the
		// reader loses count of where they are in the core.
		if s.Label != "" && !fit {
			fmt.Fprintf(&b, `<text class="yl" x="0" y="%.0f">%s</text>`, gy+10, esc(s.Label))
		}
		b.WriteString(strings.Join(empties, ""))
		b.WriteString(strings.Join(others, ""))
		for _, i := range authorKeys(cells) {
			fmt.Fprintf(&b, `<g class="au" data-author="%d" style="--c:%s">%s</g>`, i, colour(i), strings.Join(cells[i], ""))
		}
		b.WriteString(strings.Join(notches, ""))
		// The right column: the year's numbers and who led how many of
		// its days.
		if days > 0 && !fit {
			fmt.Fprintf(&b, `<text class="n" x="%.0f" y="%.0f">%s check-%s · %d active %s</text>`,
				barX, gy+10, commas(checkins), plural(checkins, "in", "ins"), days, plural(days, "day", "days"))
			fmt.Fprintf(&b, `<g class="own" data-led="%d">`, len(led))
			x := barX
			for _, i := range sortedByCount(led) {
				bw := float64(sliceBarW) * float64(led[i]) / float64(days)
				seg, c := "au", colour(i)
				if user >= 0 && i != user {
					seg, c = "au ghost", "var(--slice-empty)"
				}
				fmt.Fprintf(&b, `<rect class="%s" data-author="%d" style="--c:%s" x="%.1f" y="%.0f" width="%.1f" height="12"><title>%s led %d of %d days</title></rect>`,
					seg, i, c, x, gy+17, bw, esc(name(i)), led[i], days)
				x += bw
			}
			b.WriteString(`</g>`)
			leads := sortedByCount(led)
			yr.Lead, yr.LeadPct = name(leads[0]), led[leads[0]]*100/days
			mine := ""
			if user >= 0 && user != leads[0] && led[user] > 0 {
				mine = fmt.Sprintf(" · %s %d%%", esc(name(user)), led[user]*100/days)
			}
			fmt.Fprintf(&b, `<text class="m" x="%.0f" y="%.0f">%s led %d%% of days%s</text>`, barX, gy+45, esc(yr.Lead), yr.LeadPct, mine)
			fmt.Fprintf(&b, `<text class="m" x="%.0f" y="%.0f">%s %s · %d%%</text>`, barX, gy+61, commas(merges), plural(merges, "merge", "merges"), merges*100/max(checkins, 1))
		}
		years = append(years, yr)
		b.WriteString(`</g>`)
	}
	b.WriteString(`</svg>`)
	return b.String(), years
}

// commas writes 12345 as 12,345.
func commas(n int) string {
	s := strconv.Itoa(n)
	for i := len(s) - 3; i > 0; i -= 3 {
		s = s[:i] + "," + s[i:]
	}
	return s
}

func authorKeys(m map[int][]string) []int {
	out := make([]int, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	sort.Ints(out)
	return out
}

func sortedByCount(m map[int]int) []int {
	out := make([]int, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	sort.Slice(out, func(i, j int) bool {
		if m[out[i]] != m[out[j]] {
			return m[out[i]] > m[out[j]]
		}
		return out[i] < out[j]
	})
	return out
}

// sliceHoverAttr is the slice's hover readout, as Datastar attributes
// on the container: pointerover reads the cell's (or release marker's)
// own <title> — the no-script hover and the screen reader's text — into
// a signal that the "slice-tip" card shows beside the pointer. Nothing
// is duplicated per cell, so the SVG costs the same bytes with or
// without it. The card is positioned in the container's own box, so the
// container must not be the strip's scroller — a card hanging out of a
// scroll box scrolls the strip away vertically.
//
// The first hover moves the <title> into data-tip and drops it: the
// browser's own tooltip only appears after a delay, so a title gone by
// then never shows, and the card is not doubled by it. Without script
// the title stays, and a morphed-in cell gets the same treatment when
// it is first hovered.
const sliceHoverAttr template.HTMLAttr = `data-signals="{sliceTip: '', sliceX: 0, sliceY: 0}" ` +
	`data-on:pointerover="var c = evt.target.closest('rect,path'); var t = c && c.firstElementChild; if (t && t.tagName === 'title') { c.dataset.tip = t.textContent; t.remove() } $sliceTip = c && c.dataset.tip || ''" ` +
	`data-on:pointermove="$sliceX = evt.clientX - el.getBoundingClientRect().left; $sliceY = evt.clientY - el.getBoundingClientRect().top" ` +
	`data-on:pointerout="$sliceTip = ''"`

// homeSlice is the repository home's door: the last year as one row.
func homeSlice(r *ops.Registry, req *http.Request, repo string) template.HTML {
	out, err := invoke(req.Context(), r, "repo.activity.days", url.Values{"repo": {repo}})
	if err != nil {
		return ""
	}
	var days activityDaysOut
	remarshal(out, &days)
	svg, _ := coreSliceSVG(days, lastYearSpan(days), true, -1)
	return template.HTML(svg)
}

var activityPage = template.Must(template.Must(base.Clone()).Parse(`{{define "act-view"}}{{with .Activity}}
{{- /* Three controls and no more (§7): which axis, which window, and
       the reset-zoom button the canvas adds inside .act-sample. There
       is no branch chooser (branch is always all) and no merges toggle
       (inherited touches never drew). The person is not a control here
       either — the panel beside the graph is the chooser.
       Every link morphs #act-view in place (morphAttr): the href is the
       cold-loadable page, pushState puts it in the address bar. */ -}}
<div class="act-controls">
<nav class="seg-box" aria-label="axis">
<a href="{{activityURL $.Repo "clock" .User .Win}}"{{if eq .Axis "clock"}} class="here" aria-current="true"{{end}} {{morph (activityURL $.Repo "clock" .User .Win)}}>clock</a>
<a href="{{activityURL $.Repo "tree" .User .Win}}"{{if eq .Axis "tree"}} class="here" aria-current="true"{{end}} {{morph (activityURL $.Repo "tree" .User .Win)}}>tree</a>
</nav>
<nav class="seg-box act-win" aria-label="window">
<a href="{{activityURL $.Repo .Axis .User ""}}"{{if eq .Win "recent"}} class="here" aria-current="true"{{end}} {{morph (activityURL $.Repo .Axis .User "")}}>recent</a>
<a href="{{activityURL $.Repo .Axis .User "all"}}"{{if eq .Win "all"}} class="here" aria-current="true"{{end}} {{morph (activityURL $.Repo .Axis .User "all")}}>all</a>
</nav>
</div>
<p class="act-window" data-window="{{.Window}}" data-bucket="{{.Bucket}}">{{.Since}} to {{.Until}} · {{count .Rows}} {{plural .Rows "check-in" "check-ins"}}{{if eq .Axis "tree"}} · {{count .Touches}} {{plural .Touches "file change" "file changes"}}{{end}}{{if gt .Bucket 1}} · drawn at {{.Bucket}}-day resolution{{end}}</p>
<div class="act-sample">{{.Sample}}</div>
{{- /* The legend is the chooser: everyone in the window, the chosen one
       marked and still in colour while the rest of the dots grey with
       the plot, and a way back to everyone. */ -}}
{{if .Legend}}<ol class="act-legend"{{if ge .UserIdx 0}} data-user="{{.UserIdx}}"{{end}}>
{{range .Legend}}<li><a href="{{.URL}}" data-author="{{.Index}}"{{if .Current}} aria-current="true"{{end}} {{morph .URL}}>{{identicon .Name}} {{.Name}} <span class="act-n" title="check-ins">{{count .Checkins}}</span></a></li>
{{end}}{{if .User}}<li><a class="act-clear" href="{{.ClearURL}}" {{morph .ClearURL}}>everyone</a></li>
{{end}}</ol>{{end}}
{{if .Summary}}<table class="sr-only" data-summary="{{.Axis}}"><caption>{{.SummaryLabel}}</caption>
{{range .Summary}}<tr><th scope="row">{{.Label}}</th><td>{{.N}}</td></tr>{{end}}</table>{{end}}
{{if .Slice}}<h2 class="act-h">Year by year</h2>
<div class="act-strata" {{sliceHover}}><div class="act-slice">{{.Slice}}</div>{{template "slice-tip"}}</div>
<p class="act-key">a column per week, a row per weekday; every day is a cell, the colour is the day's leading person and the weight is how much happened, the marker above a release{{if gt .YearsTotal (len .Years)}} · <a class="act-years" href="{{activityURL $.Repo .Axis .User .Win}}{{if or .User (ne .Axis "clock") (eq .Win "all")}}&amp;{{else}}?{{end}}years=all">all {{.YearsTotal}} years</a>{{else if .AllYears}} · <a class="act-years" href="{{activityURL $.Repo .Axis .User .Win}}">recent years</a>{{end}}</p>
<table class="sr-only" data-summary="years"><caption>per year</caption><tr><th>year</th><th>check-ins</th><th>active days</th><th>merges</th><th>led most days</th></tr>
{{range .Years}}<tr><th scope="row">{{.Label}}</th><td>{{.Checkins}}</td><td>{{.Days}}</td><td>{{.Merges}}</td><td>{{.Lead}}{{if .Lead}} {{.LeadPct}}%{{end}}</td></tr>{{end}}</table>{{end}}
{{end}}{{end}}
{{define "content"}}
{{with .Activity}}
<div id="act-view" data-on:popstate__window="@get(location.pathname + location.search + (location.search ? '&' : '?') + 'frag=1')">{{template "act-view" $}}</div>
{{end}}
{{end}}`))