diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-27 09:50:41 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-27 09:50:41 +0300 |
| commit | bb0446ab03b6e16a3082ef2c4a9222613ef8dc90 (patch) | |
| tree | ef7f1a4f4a2ff3662a9db24eb82b7bc08227437e /internal/showcase | |
| parent | e0648fdc2ed75a09f16e08626f122a5ed4582ad1 (diff) | |
feat(showcase): add interactive SVG rank history graph
Generate rank-history.svg alongside showcase.gmi.tpl on every showcase
run (full or single-repo update). The graph shows a Google-Trends-style
polyline per project from oldest to newest; hovering highlights the
selected project and shows a tooltip with rank and snapshot date for
each recorded week.
Key implementation details:
- new file internal/showcase/rank_history_svg.go with GenerateRankHistorySVG
- golden-ratio HSL colour spacing for visually distinct project lines
- M/L SVG path commands with gaps for weeks with no snapshot data
- PROJECTS JSON array embedded in CDATA script for JS tooltip rendering
- rank-history.svg linked from the gemtext showcase header
Changes to showcase.go:
- updateShowcaseFile returns ([]ProjectSummary, error) so the caller
can pass the full merged+sorted list to writeRankHistorySVGFile
- extract showcaseOutputDir() helper to deduplicate the hardcoded
~/git/foo.zone-content/gemtext/about path used in four places
- log a warning (instead of silently continuing) when getRepositories
fails inside updateShowcaseFile so data loss is visible to the operator
Bug fixes found during review:
- off-by-bounds panic: reversal loop used revIdx=numPoints-1-j as source
index but numPoints is a constant (5) not len(s.RankHistory); replaced
with j as source and numPoints-1-j as destination so partial slices
never panic
- redundant getElementById in JS onEnter replaced with allPG[idx] which
is already the correct element
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/showcase')
| -rw-r--r-- | internal/showcase/rank_history_svg.go | 412 | ||||
| -rw-r--r-- | internal/showcase/rank_history_svg_test.go | 266 | ||||
| -rw-r--r-- | internal/showcase/showcase.go | 108 |
3 files changed, 755 insertions, 31 deletions
diff --git a/internal/showcase/rank_history_svg.go b/internal/showcase/rank_history_svg.go new file mode 100644 index 0000000..43b2a66 --- /dev/null +++ b/internal/showcase/rank_history_svg.go @@ -0,0 +1,412 @@ +package showcase + +import ( + "encoding/json" + "fmt" + "math" + "strings" +) + +// SVG canvas and margin constants (pixels in viewBox coordinates). +const ( + svgViewWidth = 1000 + svgViewHeight = 560 + svgMarginLeft = 55 + svgMarginRight = 20 + svgMarginTop = 70 + svgMarginBottom = 50 +) + +// svgTimePoint is one weekly data snapshot for a project, embedded in the SVG +// for JavaScript tooltip rendering. +type svgTimePoint struct { + Label string `json:"label"` // "now", "1w", "2w", … + Spot int `json:"spot"` // 0 means no data for this week + Date string `json:"date,omitempty"` +} + +// svgProjectData carries per-project metadata used by the interactive JS layer. +type svgProjectData struct { + Name string `json:"name"` + Color string `json:"color"` + Points []svgTimePoint `json:"points"` +} + +// projectColor returns a visually distinct CSS hex color for project index i. +// It uses golden-ratio hue spacing so successive projects never look similar. +func projectColor(i int) string { + const golden = 0.618033988749895 + hue := math.Mod(float64(i)*golden*360, 360) + return hslToRGBHex(hue, 0.75, 0.62) +} + +// hslToRGBHex converts an HSL color (h in [0,360), s and l in [0,1]) to a +// CSS hex string like "#rrggbb". +func hslToRGBHex(h, s, l float64) string { + c := (1 - math.Abs(2*l-1)) * s + x := c * (1 - math.Abs(math.Mod(h/60, 2)-1)) + m := l - c/2 + + var r, g, b float64 + switch { + case h < 60: + r, g, b = c, x, 0 + case h < 120: + r, g, b = x, c, 0 + case h < 180: + r, g, b = 0, c, x + case h < 240: + r, g, b = 0, x, c + case h < 300: + r, g, b = x, 0, c + default: + r, g, b = c, 0, x + } + + ri := int(math.Round((r + m) * 255)) + gi := int(math.Round((g + m) * 255)) + bi := int(math.Round((b + m) * 255)) + return fmt.Sprintf("#%02x%02x%02x", ri, gi, bi) +} + +// GenerateRankHistorySVG creates an interactive inline SVG that shows a +// Google-Trends-style rank history graph for all projects. +// +// Layout: +// - X-axis: left = oldest snapshot, right = "now". +// - Y-axis: rank 1 at top (best), max rank at bottom. +// - Each project is one colored polyline; gaps occur when a week has no data. +// - Hovering a line highlights it, dims others, and shows a tooltip with the +// project name and rank at each recorded snapshot. +func GenerateRankHistorySVG(summaries []ProjectSummary) string { + numPoints := rankHistoryPoints // 5 weekly snapshots + + // Collect per-project data, reversing the history so oldest is on the left. + allProjects := make([]svgProjectData, 0, len(summaries)) + maxRank := 1 + colorIdx := 0 + + for _, s := range summaries { + if len(s.RankHistory) == 0 { + continue + } + + pts := make([]svgTimePoint, numPoints) + hasData := false + // RankHistory is newest-first (index 0 = "now", index len-1 = oldest). + // We want pts to be oldest-first so the left side of the graph is + // the most distant point. Place each source entry at its destination + // index (numPoints-1-j) so that "now" lands at pts[numPoints-1] and + // older entries land to the left. Entries for weeks with no snapshot + // stay as zero-value svgTimePoint (Spot=0 → no data for that column). + // Using j as the source index (not a reversed index based on numPoints) + // prevents an out-of-bounds panic when len(s.RankHistory) < numPoints. + for j := 0; j < len(s.RankHistory) && j < numPoints; j++ { + dstIdx := numPoints - 1 - j // "now" (j=0) maps to rightmost slot + h := s.RankHistory[j] + pts[dstIdx] = svgTimePoint{ + Label: h.Anchor, + Spot: h.Spot, + Date: h.SnapshotDate, + } + if h.Spot > maxRank { + maxRank = h.Spot + } + if h.Spot > 0 { + hasData = true + } + } + + if !hasData { + continue // skip projects that have never appeared in any snapshot + } + + allProjects = append(allProjects, svgProjectData{ + Name: s.Name, + Color: projectColor(colorIdx), + Points: pts, + }) + colorIdx++ + } + + // Human-readable X-axis labels (left = oldest, right = "now"). + // Position i is (numPoints-1-i) weeks ago; position numPoints-1 is "now". + xLabels := make([]string, numPoints) + for i := 0; i < numPoints; i++ { + weeksAgo := numPoints - 1 - i + if weeksAgo == 0 { + xLabels[i] = "now" + } else { + xLabels[i] = fmt.Sprintf("%dw ago", weeksAgo) + } + } + + // --- Layout helpers --- + plotW := svgViewWidth - svgMarginLeft - svgMarginRight + plotH := svgViewHeight - svgMarginTop - svgMarginBottom + + xPos := func(i int) float64 { + if numPoints <= 1 { + return float64(svgMarginLeft) + float64(plotW)/2 + } + return float64(svgMarginLeft) + float64(i)*float64(plotW)/float64(numPoints-1) + } + + // rank 1 → top of plot, maxRank → bottom of plot. + yPos := func(rank int) float64 { + if rank <= 0 { + return -999 // off-screen sentinel; caller should skip + } + if maxRank <= 1 { + return float64(svgMarginTop) + float64(plotH)/2 + } + ratio := float64(rank-1) / float64(maxRank-1) + return float64(svgMarginTop) + ratio*float64(plotH) + } + + // Embed project data as JSON for the JS tooltip layer. + projectsJSON, _ := json.Marshal(allProjects) + + // --- Build SVG sub-sections --- + + // Horizontal grid lines and Y-axis labels. + var gridBuf strings.Builder + yStep := gridStep(maxRank) + plotRight := float64(svgMarginLeft + plotW) + for r := 1; r <= maxRank; r += yStep { + y := yPos(r) + fmt.Fprintf(&gridBuf, + `<line class="gl" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>`, + float64(svgMarginLeft), y, plotRight, y) + fmt.Fprintf(&gridBuf, + `<text class="al" x="%.1f" y="%.1f" text-anchor="end">%d</text>`, + float64(svgMarginLeft)-6, y+4, r) + } + + // Vertical grid lines and X-axis labels. + var xAxisBuf strings.Builder + plotBottom := float64(svgMarginTop + plotH) + for i := 0; i < numPoints; i++ { + x := xPos(i) + fmt.Fprintf(&xAxisBuf, + `<line class="gl" x1="%.1f" y1="%d" x2="%.1f" y2="%.1f"/>`, + x, svgMarginTop, x, plotBottom) + fmt.Fprintf(&xAxisBuf, + `<text class="al" x="%.1f" y="%.1f" text-anchor="middle">%s</text>`, + x, plotBottom+16, xLabels[i]) + } + + // Project lines and dot groups. + var linesBuf strings.Builder + for i, proj := range allProjects { + pathD := buildSVGPath(proj.Points, xPos, yPos) + if pathD == "" { + continue + } + + // One circle per valid data point so the tooltip hit-area is larger. + var circleBuf strings.Builder + for j, pt := range proj.Points { + if pt.Spot <= 0 { + continue + } + x := xPos(j) + y := yPos(pt.Spot) + if y < 0 { + continue + } + fmt.Fprintf(&circleBuf, `<circle cx="%.1f" cy="%.1f" r="4"/>`, x, y) + } + + fmt.Fprintf(&linesBuf, + `<g class="pg" id="pg-%d" onmouseenter="onEnter(%d,event)" onmouseleave="onLeave()">`, + i, i) + fmt.Fprintf(&linesBuf, `<path d="%s" stroke="%s" class="pl"/>`, pathD, proj.Color) + fmt.Fprintf(&linesBuf, `<g class="pd" fill="%s">%s</g>`, proj.Color, circleBuf.String()) + linesBuf.WriteString(`</g>`) + } + + // --- Assemble the full SVG --- + var svg strings.Builder + + fmt.Fprintf(&svg, + `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`, + svgViewWidth, svgViewHeight, svgViewWidth, svgViewHeight) + + // Embedded CSS – kept compact but readable. + svg.WriteString(`<style> +svg{background:#1a1a2e;font-family:monospace} +.gl{stroke:#2a2a5a;stroke-width:.6;stroke-dasharray:4,3} +.al{fill:#888;font-size:11px} +.title{fill:#ddd;font-size:14px;font-weight:bold} +.sub{fill:#666;font-size:10px} +.pg{cursor:pointer;opacity:.55;transition:opacity .15s} +.pl{stroke-width:1.8;fill:none} +.pd circle{transition:r .15s} +#tt{pointer-events:none;display:none} +#ttbg{fill:#1a1a40;stroke:#4444aa;stroke-width:1} +#tttl{fill:#fff;font-size:12px;font-weight:bold;font-family:monospace} +.ttrow{fill:#bbb;font-size:10px;font-family:monospace} +</style>`) + + // Solid background rect (belt-and-suspenders for SVG viewers that ignore + // the CSS background property). + fmt.Fprintf(&svg, `<rect width="%d" height="%d" fill="#1a1a2e"/>`, svgViewWidth, svgViewHeight) + + // Title + subtitle. + fmt.Fprintf(&svg, `<text class="title" x="%d" y="28" text-anchor="middle">Project Rank History</text>`, svgViewWidth/2) + fmt.Fprintf(&svg, + `<text class="sub" x="%d" y="44" text-anchor="middle">rank 1 = highest score · hover a line to highlight · %d projects tracked</text>`, + svgViewWidth/2, len(allProjects)) + + // Rotated Y-axis label. + cx := float64(svgMarginLeft) - 40 + cy := float64(svgMarginTop) + float64(plotH)/2 + fmt.Fprintf(&svg, + `<text class="al" text-anchor="middle" transform="rotate(-90 %.1f %.1f)" x="%.1f" y="%.1f">Rank</text>`, + cx, cy, cx, cy) + + // Grid, axes, and project lines. + svg.WriteString(gridBuf.String()) + svg.WriteString(xAxisBuf.String()) + svg.WriteString(linesBuf.String()) + + // Tooltip overlay (hidden until a project is hovered). + svg.WriteString(`<g id="tt"> +<rect id="ttbg" x="0" y="0" width="220" height="100" rx="5" ry="5"/> +<text id="tttl" x="10" y="18"></text> +<g id="ttbd"></g> +</g>`) + + // Inline JavaScript for interactivity. + // PROJECTS is the JSON array; each entry has name, color, and points[]. + fmt.Fprintf(&svg, `<script><![CDATA[ +var PROJECTS=%s; +var svgEl=document.querySelector('svg'); +var tt=document.getElementById('tt'); +var ttbg=document.getElementById('ttbg'); +var tttl=document.getElementById('tttl'); +var ttbd=document.getElementById('ttbd'); +var allPG=svgEl.querySelectorAll('.pg'); +var activeIdx=-1; + +// onEnter is called when the cursor enters a project group. +// It dims all other groups, shows the tooltip, and marks the project active. +function onEnter(idx,evt){ + activeIdx=idx; + var p=PROJECTS[idx]; + tttl.textContent=p.name; + + // Clear old tooltip rows. + while(ttbd.firstChild)ttbd.removeChild(ttbd.firstChild); + + // Build one row per snapshot, newest first (points array is oldest-first). + var y=16; + for(var i=p.points.length-1;i>=0;i--){ + var pt=p.points[i]; + if(pt.spot<=0)continue; + var label=pt.label==='now'?'now':pt.label+' ago'; + var line='#'+pt.spot+' '+label+(pt.date?' ('+pt.date+')':''); + var t=document.createElementNS('http://www.w3.org/2000/svg','text'); + t.setAttribute('x','10'); + t.setAttribute('y',String(y)); + t.setAttribute('class','ttrow'); + t.textContent=line; + ttbd.appendChild(t); + y+=13; + } + + // Resize tooltip background to fit content. + var w=220, h=26+y; + ttbg.setAttribute('width',w); + ttbg.setAttribute('height',h); + + // Highlight hovered group; dim all others. + for(var i=0;i<allPG.length;i++){ + allPG[i].style.opacity=(i===idx)?'1':'0.08'; + } + // Bold the hovered line. allPG[idx] is already that element — no getElementById needed. + allPG[idx].querySelector('.pl').style.strokeWidth='3'; + + moveTT(evt); + tt.style.display='block'; +} + +// onLeave restores all groups to their default opacity and hides the tooltip. +function onLeave(){ + tt.style.display='none'; + for(var i=0;i<allPG.length;i++){ + allPG[i].style.opacity='0.55'; + allPG[i].querySelector('.pl').style.strokeWidth=''; + } + activeIdx=-1; +} + +// Follow the cursor while hovering. +svgEl.addEventListener('mousemove',function(evt){ + if(activeIdx>=0)moveTT(evt); +}); + +// moveTT repositions the tooltip near the cursor, keeping it inside the viewBox. +function moveTT(evt){ + var pt=svgEl.createSVGPoint(); + pt.x=evt.clientX; pt.y=evt.clientY; + var sp=pt.matrixTransform(svgEl.getScreenCTM().inverse()); + var w=parseFloat(ttbg.getAttribute('width')); + var h=parseFloat(ttbg.getAttribute('height')); + var tx=sp.x+14, ty=sp.y-10; + var vw=svgEl.viewBox.baseVal.width, vh=svgEl.viewBox.baseVal.height; + if(tx+w>vw-5)tx=sp.x-w-14; + if(ty+h>vh-5)ty=vh-h-5; + if(ty<5)ty=5; + tt.setAttribute('transform','translate('+tx+','+ty+')'); +} +]]></script>`, string(projectsJSON)) + + svg.WriteString(`</svg>`) + return svg.String() +} + +// buildSVGPath converts a slice of time points into an SVG path string using +// M (moveto) at the start of each run of valid points and L (lineto) within. +// Gaps (Spot == 0) cause the pen to be lifted so the line breaks cleanly. +func buildSVGPath(points []svgTimePoint, xPos func(int) float64, yPos func(int) float64) string { + var parts []string + prevValid := false + + for i, pt := range points { + if pt.Spot <= 0 { + prevValid = false + continue + } + x := xPos(i) + y := yPos(pt.Spot) + if y < 0 { + prevValid = false + continue + } + cmd := "L" + if !prevValid { + cmd = "M" + } + parts = append(parts, fmt.Sprintf("%s%.1f %.1f", cmd, x, y)) + prevValid = true + } + + return strings.Join(parts, " ") +} + +// gridStep returns how many rank positions to skip between Y-axis grid lines, +// keeping the graph legible when there are many projects. +func gridStep(maxRank int) int { + switch { + case maxRank > 30: + return 5 + case maxRank > 15: + return 3 + case maxRank > 8: + return 2 + default: + return 1 + } +} diff --git a/internal/showcase/rank_history_svg_test.go b/internal/showcase/rank_history_svg_test.go new file mode 100644 index 0000000..06aab4c --- /dev/null +++ b/internal/showcase/rank_history_svg_test.go @@ -0,0 +1,266 @@ +package showcase + +import ( + "strings" + "testing" +) + +// makeTestSummaries builds a small slice of ProjectSummary with rank history +// suitable for unit-testing the SVG generator without touching the filesystem. +func makeTestSummaries() []ProjectSummary { + return []ProjectSummary{ + { + Name: "alpha", + RankHistory: []RepoRankHistory{ + {Spot: 1, Anchor: "now", SnapshotDate: "2026-05-27"}, + {Spot: 2, Anchor: "1w", SnapshotDate: "2026-05-20"}, + {Spot: 2, Anchor: "2w", SnapshotDate: "2026-05-13"}, + {Spot: 3, Anchor: "3w", SnapshotDate: "2026-05-06"}, + {Spot: 0, Anchor: "4w"}, // missing snapshot week + }, + }, + { + Name: "beta", + RankHistory: []RepoRankHistory{ + {Spot: 2, Anchor: "now", SnapshotDate: "2026-05-27"}, + {Spot: 1, Anchor: "1w", SnapshotDate: "2026-05-20"}, + {Spot: 1, Anchor: "2w", SnapshotDate: "2026-05-13"}, + {Spot: 0, Anchor: "3w"}, + {Spot: 0, Anchor: "4w"}, + }, + }, + { + // Project with no rank data at all should be skipped. + Name: "ghost", + RankHistory: []RepoRankHistory{{Spot: 0}, {Spot: 0}, {Spot: 0}, {Spot: 0}, {Spot: 0}}, + }, + } +} + +func TestGenerateRankHistorySVG_IsValidSVG(t *testing.T) { + t.Parallel() + + svg := GenerateRankHistorySVG(makeTestSummaries()) + + if !strings.HasPrefix(svg, "<svg ") { + t.Fatalf("expected SVG to start with '<svg ', got prefix: %.40s", svg) + } + if !strings.HasSuffix(svg, "</svg>") { + t.Fatalf("expected SVG to end with '</svg>', got suffix: %.40s", svg[len(svg)-40:]) + } +} + +func TestGenerateRankHistorySVG_ContainsProjectNames(t *testing.T) { + t.Parallel() + + svg := GenerateRankHistorySVG(makeTestSummaries()) + + if !strings.Contains(svg, `"name":"alpha"`) { + t.Error("SVG JSON data missing project 'alpha'") + } + if !strings.Contains(svg, `"name":"beta"`) { + t.Error("SVG JSON data missing project 'beta'") + } + // 'ghost' has no valid data points and must be excluded. + if strings.Contains(svg, `"name":"ghost"`) { + t.Error("SVG JSON data should not contain 'ghost' (no rank data)") + } +} + +func TestGenerateRankHistorySVG_ContainsInteractiveJS(t *testing.T) { + t.Parallel() + + svg := GenerateRankHistorySVG(makeTestSummaries()) + + // Key JS functions must be present for interactivity. + for _, fn := range []string{"onEnter", "onLeave", "moveTT"} { + if !strings.Contains(svg, fn) { + t.Errorf("SVG JavaScript missing function %q", fn) + } + } + // Tooltip anchor elements must exist. + for _, id := range []string{"tt", "ttbg", "tttl", "ttbd"} { + if !strings.Contains(svg, `id="`+id+`"`) { + t.Errorf("SVG missing element id=%q", id) + } + } +} + +func TestGenerateRankHistorySVG_EmptySummaries(t *testing.T) { + t.Parallel() + + svg := GenerateRankHistorySVG([]ProjectSummary{}) + + // Should still produce a valid (empty-data) SVG with the subtitle showing 0 projects. + if !strings.Contains(svg, "<svg ") { + t.Fatal("expected valid SVG even with empty input") + } + if !strings.Contains(svg, "0 projects tracked") { + t.Error("expected subtitle to mention 0 projects") + } +} + +func TestGenerateRankHistorySVG_TimeAxisLabels(t *testing.T) { + t.Parallel() + + svg := GenerateRankHistorySVG(makeTestSummaries()) + + // All five time-axis labels must appear. + for _, label := range []string{"now", "1w ago", "2w ago", "3w ago", "4w ago"} { + if !strings.Contains(svg, label) { + t.Errorf("SVG missing time-axis label %q", label) + } + } +} + +// TestBuildSVGPath_SkipsZeroSpots verifies that missing data produces M commands +// (pen-up) rather than L commands (pen-down), keeping lines broken at gaps. +func TestBuildSVGPath_SkipsZeroSpots(t *testing.T) { + t.Parallel() + + points := []svgTimePoint{ + {Spot: 3, Label: "4w"}, + {Spot: 0, Label: "3w"}, // gap + {Spot: 2, Label: "2w"}, + {Spot: 1, Label: "1w"}, + {Spot: 1, Label: "now"}, + } + + xPos := func(i int) float64 { return float64(i) * 100 } + yPos := func(rank int) float64 { + if rank <= 0 { + return -999 + } + return float64(rank) * 10 + } + + path := buildSVGPath(points, xPos, yPos) + + // Should start at index 0 (rank=3) with M. + if !strings.HasPrefix(path, "M0.0 ") { + t.Fatalf("path should start with M0.0, got: %s", path) + } + // After the gap the next segment must begin with M (not L). + if !strings.Contains(path, "M200.0") { + t.Errorf("path should re-open with M after gap at index 2, got: %s", path) + } + // Consecutive valid points should be joined with L. + if !strings.Contains(path, "L300.0") { + t.Errorf("path should continue with L at index 3, got: %s", path) + } +} + +// TestProjectColor_GoldenRatioDistribution verifies that successive colors are +// distinct (no two consecutive projects share the same hex string within 10). +func TestProjectColor_GoldenRatioDistribution(t *testing.T) { + t.Parallel() + + seen := make(map[string]int, 30) + for i := 0; i < 30; i++ { + c := projectColor(i) + if !strings.HasPrefix(c, "#") || len(c) != 7 { + t.Errorf("projectColor(%d) = %q: expected #rrggbb format", i, c) + } + if prev, ok := seen[c]; ok { + t.Errorf("projectColor(%d) == projectColor(%d) == %q: colors should be distinct", i, prev, c) + } + seen[c] = i + } +} + +// TestHslToRGBHex_KnownValues checks a few well-known HSL → RGB conversions. +func TestHslToRGBHex_KnownValues(t *testing.T) { + t.Parallel() + + tests := []struct { + h, s, l float64 + want string + }{ + {0, 0, 0, "#000000"}, // black + {0, 0, 1, "#ffffff"}, // white + {0, 1, 0.5, "#ff0000"}, // pure red + } + + for _, tc := range tests { + got := hslToRGBHex(tc.h, tc.s, tc.l) + if got != tc.want { + t.Errorf("hslToRGBHex(%.0f, %.0f, %.1f) = %q, want %q", tc.h, tc.s, tc.l, got, tc.want) + } + } +} + +// TestGenerateRankHistorySVG_PartialRankHistory ensures that summaries with +// fewer than rankHistoryPoints history entries do not panic and produce valid +// SVG. This guards against the off-by-one reversal bug where revIdx was +// computed against numPoints (constant 5) instead of len(s.RankHistory), +// causing an index out-of-bounds when the slice was shorter. +func TestGenerateRankHistorySVG_PartialRankHistory(t *testing.T) { + t.Parallel() + + summaries := []ProjectSummary{ + { + Name: "partial", + // Only 3 history entries instead of the full 5. + RankHistory: []RepoRankHistory{ + {Spot: 1, Anchor: "now", SnapshotDate: "2026-05-27"}, + {Spot: 2, Anchor: "1w", SnapshotDate: "2026-05-20"}, + {Spot: 3, Anchor: "2w", SnapshotDate: "2026-05-13"}, + }, + }, + { + Name: "single-entry", + // Only 1 history entry. + RankHistory: []RepoRankHistory{ + {Spot: 5, Anchor: "now", SnapshotDate: "2026-05-27"}, + }, + }, + } + + // Must not panic despite len(RankHistory) < rankHistoryPoints. + var svg string + require := func(cond bool, msg string) { + t.Helper() + if !cond { + t.Fatal(msg) + } + } + require(func() (ok bool) { + defer func() { + if r := recover(); r != nil { + t.Errorf("GenerateRankHistorySVG panicked with partial history: %v", r) + ok = false + } + }() + svg = GenerateRankHistorySVG(summaries) + return true + }(), "GenerateRankHistorySVG must not panic with partial RankHistory") + + require(strings.Contains(svg, `"name":"partial"`), "SVG should include 'partial' project") + require(strings.Contains(svg, `"name":"single-entry"`), "SVG should include 'single-entry' project") +} + +// TestGridStep_ReturnsSensibleSteps ensures the Y-axis step function keeps +// the graph legible across various project counts. +func TestGridStep_ReturnsSensibleSteps(t *testing.T) { + t.Parallel() + + tests := []struct { + maxRank int + wantMax int // step must be ≤ wantMax + }{ + {5, 1}, + {10, 2}, + {20, 3}, + {40, 5}, + } + + for _, tc := range tests { + got := gridStep(tc.maxRank) + if got > tc.wantMax { + t.Errorf("gridStep(%d) = %d, want ≤ %d", tc.maxRank, got, tc.wantMax) + } + if got <= 0 { + t.Errorf("gridStep(%d) = %d, want > 0", tc.maxRank, got) + } + } +} diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go index ebd48a7..b7c1f5b 100644 --- a/internal/showcase/showcase.go +++ b/internal/showcase/showcase.go @@ -156,17 +156,28 @@ func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool) applyRankHistoryToSummaries(summaries, rankHistoryStore, anchorDate, rankHistoryPoints) - // When filtering (single repo), we need to update existing showcase + // When filtering (single repo), we need to update existing showcase. + // updateShowcaseFile merges the new summary with all cached ones and returns + // the complete, sorted list so we can regenerate the SVG consistently. + var allSummaries []ProjectSummary if len(repoFilter) > 0 { - if err := g.updateShowcaseFile(summaries); err != nil { + allSummaries, err = g.updateShowcaseFile(summaries) + if err != nil { return fmt.Errorf("failed to update showcase file: %w", err) } } else { - // Full regeneration - format as Gemtext and write + // Full regeneration - format as Gemtext and write. content := g.formatGemtext(summaries) if err := g.writeShowcaseFile(content); err != nil { return fmt.Errorf("failed to write showcase file: %w", err) } + allSummaries = summaries + } + + // Always regenerate the interactive SVG rank history alongside the text showcase. + if err := g.writeRankHistorySVGFile(allSummaries); err != nil { + // Non-fatal: log the warning but don't abort the showcase run. + fmt.Printf("Warning: failed to write rank history SVG: %v\n", err) } return nil @@ -840,11 +851,10 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool // Always extract images from README (not cached) fmt.Printf("Extracting images from README...\n") - home, err := os.UserHomeDir() + showcaseDir, err := showcaseOutputDir() if err != nil { - return nil, fmt.Errorf("failed to get home directory: %w", err) + return nil, err } - showcaseDir := filepath.Join(home, "git", "foo.zone-content", "gemtext", "about") images, err := extractImagesFromRepo(repoPath, repoName, showcaseDir) if err != nil { fmt.Printf("Warning: Failed to extract images: %v\n", err) @@ -898,6 +908,9 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string { // Introduction paragraph builder.WriteString("This page showcases my side projects, providing an overview of what each project does, its technical implementation, and key metrics. Each project summary includes information about the programming languages used, development activity, releases, and licensing. The projects are ranked by score, which combines recent activity, project size, tag history, and whether the project has shipped a release.\n\n") + // Link to the interactive SVG rank history graph generated alongside this file. + builder.WriteString("=> rank-history.svg Interactive Project Rank History Graph (SVG)\n\n") + // Template inline TOC builder.WriteString("<< template::inline::toc\n\n") @@ -1091,15 +1104,24 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string { return builder.String() } +// showcaseOutputDir returns the canonical directory where showcase output files +// (Gemtext, SVG, images) are written. Centralised here so all writers agree on +// the path and a future change only needs to touch one place. +func showcaseOutputDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, "git", "foo.zone-content", "gemtext", "about"), nil +} + // writeShowcaseFile writes the showcase content to the target file func (g *Generator) writeShowcaseFile(content string) error { - // Build target path - home, err := os.UserHomeDir() + targetDir, err := showcaseOutputDir() if err != nil { - return fmt.Errorf("failed to get home directory: %w", err) + return err } - targetDir := filepath.Join(home, "git", "foo.zone-content", "gemtext", "about") targetFile := filepath.Join(targetDir, "showcase.gmi.tpl") // Create directory if it doesn't exist @@ -1116,21 +1138,51 @@ func (g *Generator) writeShowcaseFile(content string) error { return nil } -// updateShowcaseFile updates specific entries in an existing showcase file -func (g *Generator) updateShowcaseFile(newSummaries []ProjectSummary) error { - // Load existing summaries from cache files instead of parsing Gemtext +// writeRankHistorySVGFile generates an interactive SVG rank history graph and +// writes it to the same directory as the showcase Gemtext file. +func (g *Generator) writeRankHistorySVGFile(summaries []ProjectSummary) error { + targetDir, err := showcaseOutputDir() + if err != nil { + return err + } + + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", targetDir, err) + } + + svgContent := GenerateRankHistorySVG(summaries) + + targetFile := filepath.Join(targetDir, "rank-history.svg") + if err := os.WriteFile(targetFile, []byte(svgContent), 0644); err != nil { + return fmt.Errorf("failed to write rank history SVG: %w", err) + } + + fmt.Printf("Rank history SVG written to: %s\n", targetFile) + return nil +} + +// updateShowcaseFile merges newSummaries with all existing cached project +// summaries, re-sorts by score, applies rank history, regenerates the Gemtext +// showcase file, and returns the complete sorted summary list so the caller +// can also regenerate the SVG graph consistently. +func (g *Generator) updateShowcaseFile(newSummaries []ProjectSummary) ([]ProjectSummary, error) { + // Load all cached summaries from disk so the full project list is available. existingSummaries := make(map[string]ProjectSummary) - // Get all repositories in work directory to load their cached summaries + // Load all existing cached summaries so the single-repo update does not + // accidentally truncate the full showcase to just the one regenerated project. + // If getRepositories fails (e.g. work directory temporarily unavailable), + // log a warning and continue — the overlay below will still write newSummaries, + // which is better than silently producing an empty/truncated output file. repos, err := g.getRepositories() - if err == nil { + if err != nil { + fmt.Printf("Warning: failed to list repositories for showcase update: %v (cached repos may be missing from output)\n", err) + } else { cacheDir := filepath.Join(g.workDir, ".gitsyncer-showcase-cache") for _, repo := range repos { - // Skip excluded repos if g.isExcluded(repo) { continue } - cacheFile := filepath.Join(cacheDir, repo+".json") if cached, err := g.loadFromCache(cacheFile); err == nil { existingSummaries[repo] = *cached @@ -1138,44 +1190,40 @@ func (g *Generator) updateShowcaseFile(newSummaries []ProjectSummary) error { } } - // Update with new summaries + // Overlay the freshly generated summaries onto the cached set. for _, summary := range newSummaries { existingSummaries[summary.Name] = summary } - // Convert map to slice - var allSummaries []ProjectSummary + // Flatten map to slice and sort by score (highest first). + allSummaries := make([]ProjectSummary, 0, len(existingSummaries)) for _, summary := range existingSummaries { allSummaries = append(allSummaries, summary) } - - // Sort by score (highest first) sort.Slice(allSummaries, func(i, j int) bool { - // If metadata is missing, put at the end if allSummaries[i].Metadata == nil { return false } if allSummaries[j].Metadata == nil { return true } - // Higher score is better (combines LOC and recent activity) return allSummaries[i].Metadata.Score > allSummaries[j].Metadata.Score }) rankHistoryFile := filepath.Join(g.workDir, rankHistoryFilename) rankHistoryStore, err := loadRankHistory(rankHistoryFile) if err != nil { - return fmt.Errorf("failed to load rank history: %w", err) + return nil, fmt.Errorf("failed to load rank history: %w", err) } applyRankHistoryToSummaries(allSummaries, rankHistoryStore, time.Now(), rankHistoryPoints) - // Format and write + // Format and write the Gemtext showcase. content := g.formatGemtext(allSummaries) if err := g.writeShowcaseFile(content); err != nil { - return err + return nil, err } - return nil + return allSummaries, nil } // loadFromCache loads a project summary from cache @@ -1217,13 +1265,11 @@ func (g *Generator) verifyImages(summary *ProjectSummary) error { return nil } - home, err := os.UserHomeDir() + showcaseDir, err := showcaseOutputDir() if err != nil { return err } - showcaseDir := filepath.Join(home, "git", "foo.zone-content", "gemtext", "about") - for _, imgPath := range summary.Images { fullPath := filepath.Join(showcaseDir, imgPath) if _, err := os.Stat(fullPath); err != nil { |
