package home
import (
"encoding/base64"
"strconv"
"strings"
)
// Two charts, merged pull requests and reviews per month, each stacked by
// repository. A realm cannot read GitHub, so each chart's numbers live in a
// section, "activity" and "reviewactivity", and scripts/activity.py prints the
// Set commands that refresh them. A section reads, lines split by ";":
//
// title merged pull requests
// subtitle per month, on public gno.land repositories
// series gnolang/gno,gnodaokit,Others,Peer Dev
// scale Peer Dev,10,commits
// outline gnolang/gno pull requests opened
// updated 2026-09-24
// 2025-07 4 0 1 48 | 3
//
// Each month row carries one count per series. A scaled series counts another
// unit: its bars are drawn at one step per 10, and it stays out of the totals.
// The count after "|" draws a striped bar in the first series' colour beside
// the stack.
const (
activitySlot = "activity"
reviewActivitySlot = "reviewactivity"
chartWidth = 720
chartX = 24
maxBar = 90
scaledFill = "#fbbf24"
othersFill = "#94a3b8"
)
var (
monthNames = []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
// A repository keeps its colour in both charts; one not listed here takes
// a spare colour picked from its name.
repoFills = map[string]string{
"gnolang/gno": "#3ddc97",
"gno-onboarding-bot": "#7aa2ff",
"gnodaokit": "#c084fc",
"awesome-gno": "#f472b6",
"gnolove": "#2dd4bf",
}
spareFills = []string{"#fb923c", "#a3e635", "#38bdf8", "#e879f9"}
)
type month struct {
label string // "Jul", or "Jan 26" where a year starts
counts []int
outline int
}
type activity struct {
noun string
subtitle string
series []string
scaled string // the series counted in another unit, or ""
scaleDiv int
scaleUnit string
outline string // the striped bar's legend, or ""
updated string // "24 Sep 2026", the day the numbers were counted
months []month
}
// parseActivity reads a chart section; a malformed line is skipped.
func parseActivity(spec string) activity {
var a activity
for _, line := range strings.Split(strings.ReplaceAll(spec, "\n", ";"), ";") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "title "):
a.noun = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "subtitle "):
a.subtitle = strings.TrimSpace(line[9:])
case strings.HasPrefix(line, "series "):
for _, s := range strings.Split(line[7:], ",") {
a.series = append(a.series, strings.TrimSpace(s))
}
case strings.HasPrefix(line, "scale "):
parts := strings.Split(line[6:], ",")
if len(parts) != 3 {
continue
}
div, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil || div < 1 {
continue
}
a.scaled, a.scaleDiv, a.scaleUnit = strings.TrimSpace(parts[0]), div, strings.TrimSpace(parts[2])
case strings.HasPrefix(line, "outline "):
a.outline = strings.TrimSpace(line[8:])
case strings.HasPrefix(line, "updated "):
a.updated = formatDay(strings.TrimSpace(line[8:]))
default:
if m, ok := parseMonth(line, len(a.series), len(a.months) == 0); ok {
a.months = append(a.months, m)
}
}
}
return a
}
func parseMonth(line string, width int, first bool) (month, bool) {
fields := strings.Fields(line)
if len(fields) < 1 || len(fields[0]) != 7 || fields[0][4] != '-' {
return month{}, false
}
m, err := strconv.Atoi(fields[0][5:])
if err != nil || m < 1 || m > 12 {
return month{}, false
}
out := month{label: monthNames[m-1]}
if m == 1 || first {
out.label += " " + fields[0][2:4]
}
counts := fields[1:]
if n := len(counts); n >= 2 && counts[n-2] == "|" {
v, err := strconv.Atoi(counts[n-1])
if err != nil || v < 0 {
return month{}, false
}
out.outline, counts = v, counts[:n-2]
}
if len(counts) != width || width == 0 {
return month{}, false
}
for _, f := range counts {
n, err := strconv.Atoi(f)
if err != nil || n < 0 {
return month{}, false
}
out.counts = append(out.counts, n)
}
return out, true
}
// formatDay turns 2026-09-24 into "24 Sep 2026", or "" when it is not a date.
func formatDay(s string) string {
if len(s) != 10 || s[4] != '-' || s[7] != '-' {
return ""
}
m, err1 := strconv.Atoi(s[5:7])
d, err2 := strconv.Atoi(s[8:])
if err1 != nil || err2 != nil || m < 1 || m > 12 || d < 1 || d > 31 {
return ""
}
return strconv.Itoa(d) + " " + monthNames[m-1] + " " + s[:4]
}
func (a activity) fill(i int) string {
name := a.series[i]
switch name {
case "Others":
return othersFill
case a.scaled:
return scaledFill
}
if f, ok := repoFills[name]; ok {
return f
}
sum := 0
for j := 0; j < len(name); j++ {
sum += int(name[j])
}
return spareFills[sum%len(spareFills)]
}
// drawn returns how many steps series i takes in the bar, rounding a scaled
// count up so a quiet month still shows.
func (a activity) drawn(i, n int) int {
if a.series[i] == a.scaled {
return (n + a.scaleDiv - 1) / a.scaleDiv
}
return n
}
// renderChart draws a chart section as a markdown image, or "" when it holds
// no month.
func renderChart(slot string) string {
a := parseActivity(Get(slot))
if len(a.months) == 0 || a.noun == "" {
return ""
}
totals := make([]int, len(a.series))
peak, sum, outlined := 1, 0, 0
for _, m := range a.months {
outlined += m.outline
if m.outline > peak {
peak = m.outline
}
steps := 0
for i, n := range m.counts {
totals[i] += n
steps += a.drawn(i, n)
if a.series[i] != a.scaled {
sum += n
}
}
if steps > peak {
peak = steps
}
}
// Legend entries flow left to right and wrap, so the chart sits below them.
var b, items strings.Builder
x, y := chartX, 96
for i, s := range a.series {
label := s + " " + strconv.Itoa(totals[i])
if s == a.scaled {
label += " " + a.scaleUnit + ", drawn 1 per " + strconv.Itoa(a.scaleDiv)
}
w := 24 + len(label)*7
if x+w > chartWidth-chartX {
x, y = chartX, y+22
}
rect(&items, x, y-10, 12, 12, 0, a.fill(i))
text(&items, x+18, y, "s", "#ffffff", label)
x += w + 12
}
if a.outline != "" {
label := a.outline + " " + strconv.Itoa(outlined)
w := 24 + len(label)*7
if x+w > chartWidth-chartX {
x, y = chartX, y+22
}
rect(&items, x, y-10, 12, 12, 0, "url(#o)")
text(&items, x+18, y, "s", "#ffffff", label)
}
baseline := y + 30 + maxBar
height := baseline + 34
sh, sw := strconv.Itoa(height), strconv.Itoa(chartWidth)
b.WriteString(`")
alt := strings.ToUpper(a.noun[:1]) + a.noun[1:] + " per month, by repository"
return ")) + ")\n"
}
// The chart is written straight into one builder: p/nt/svg formats each
// element with ufmt and appends it to a growing string, which cost this page
// close to a billion gas.
func rect(b *strings.Builder, x, y, w, h, r int, fill string) {
b.WriteString(``)
}
// text writes s as SVG text; callers pass repository names and numbers, so it
// escapes the three characters that could break the markup.
func text(b *strings.Builder, x, y int, class, fill, s string) {
s = strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s)
b.WriteString(`` + s + ``)
}
// The area chart is a ring of one number per area, from the "areas" section,
// which reads, lines split by ";":
//
// title pull requests by area
// subtitle merged into gnolang/gno
// updated 2026-09-24
// slice 26 Docs
// slice 17 GnoVM
const areasSlot = "areas"
var areaFills = []string{"#3ddc97", "#7aa2ff", "#c084fc", "#f472b6", "#2dd4bf", "#fb923c"}
type slice struct {
label string
count int
}
func parseAreas(spec string) (noun, subtitle, updated string, slices []slice) {
for _, line := range strings.Split(strings.ReplaceAll(spec, "\n", ";"), ";") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "title "):
noun = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "subtitle "):
subtitle = strings.TrimSpace(line[9:])
case strings.HasPrefix(line, "updated "):
updated = formatDay(strings.TrimSpace(line[8:]))
case strings.HasPrefix(line, "slice "):
n, label, ok := strings.Cut(strings.TrimSpace(line[6:]), " ")
c, err := strconv.Atoi(n)
if ok && err == nil && c > 0 && strings.TrimSpace(label) != "" {
slices = append(slices, slice{strings.TrimSpace(label), c})
}
}
}
return noun, subtitle, updated, slices
}
// renderAreas draws the "areas" section as a ring, or "" when it holds no slice.
func renderAreas() string {
noun, subtitle, updated, slices := parseAreas(Get(areasSlot))
if len(slices) == 0 || noun == "" {
return ""
}
total := 0
for _, s := range slices {
total += s.count
}
const height, cx, cy, r = 300, 140, 190, 80
var b strings.Builder
b.WriteString(`")
return "![" + mdText(strings.ToUpper(noun[:1])+noun[1:]+", "+subtitle) + "](data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(b.String())) + ")\n"
}