summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-22 20:41:23 +0200
committerPaul Buetow <paul@buetow.org>2026-02-22 20:41:23 +0200
commit64a22d411a6096e8e7e14af7151e530f0ee358ae (patch)
treeabbf44e3aa1f06cb9cd178ab7cce65ebf011d6d1
parent98001853687e85d8ec22ccfbed72804c3ec4ccd7 (diff)
feat(showcase): add weekly rank history and header movement
-rw-r--r--README.md5
-rw-r--r--internal/showcase/rank_history.go201
-rw-r--r--internal/showcase/rank_history_test.go153
-rw-r--r--internal/showcase/showcase.go33
-rw-r--r--internal/showcase/showcase_test.go24
m---------test/work2/test-repo0
m---------test/work3/test-repo0
7 files changed, 412 insertions, 4 deletions
diff --git a/README.md b/README.md
index 13175d1..6529d13 100644
--- a/README.md
+++ b/README.md
@@ -404,6 +404,7 @@ GitSyncer can generate a comprehensive showcase of all your projects using AI (a
- Extracts README images (including SVG support)
- Selects representative code snippets
- Orders projects by recent activity
+ - Tracks weekly rank history snapshots
- Generates overall portfolio statistics
- Caches summaries to avoid redundant AI calls
@@ -427,6 +428,8 @@ The showcase is generated in Gemini Gemtext format and includes:
- Release status breakdown (released vs experimental projects)
- AI-assistance statistics
- Individual project sections with:
+ - Rank history in the title for `now`, `1w`, `2w`, `3w`, and `4w`
+ - UTF-8 arrows (`↑`, `→`, `↓`) to show movement from older to newer positions
- Language breakdown
- Development metrics
- Latest release information or experimental status
@@ -434,6 +437,8 @@ The showcase is generated in Gemini Gemtext format and includes:
- Code snippet example
- Links to repositories
+Weekly rank snapshots are written on full showcase runs (all repositories), including `manage batch-run`. Single-repository showcase updates (`--repo`) read existing history but do not write snapshots.
+
### Configuration
The showcase output is written to `~/git/foo.zone-content/gemtext/about/showcase.gmi.tpl` by default (currently hardcoded).
diff --git a/internal/showcase/rank_history.go b/internal/showcase/rank_history.go
new file mode 100644
index 0000000..92c0c33
--- /dev/null
+++ b/internal/showcase/rank_history.go
@@ -0,0 +1,201 @@
+package showcase
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+ "time"
+)
+
+const (
+ rankHistoryFilename = ".gitsyncer-showcase-rank-history.json"
+ rankHistoryPoints = 5
+ rankHistoryVersion = 1
+)
+
+// RankSnapshot stores ranking spots for all repositories on one day.
+type RankSnapshot struct {
+ Date string `json:"date"`
+ Ranks map[string]int `json:"ranks"`
+}
+
+// RankHistoryStore stores all ranking snapshots used for weekly history.
+type RankHistoryStore struct {
+ Version int `json:"version"`
+ Snapshots []RankSnapshot `json:"snapshots"`
+}
+
+// RepoRankHistory represents one history point for a repository.
+type RepoRankHistory struct {
+ Spot int `json:"spot"`
+ Anchor string `json:"anchor"`
+ SnapshotDate string `json:"snapshotDate,omitempty"`
+ Arrow string `json:"arrow"`
+}
+
+func newRankHistoryStore() *RankHistoryStore {
+ return &RankHistoryStore{
+ Version: rankHistoryVersion,
+ Snapshots: []RankSnapshot{},
+ }
+}
+
+func loadRankHistory(path string) (*RankHistoryStore, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return newRankHistoryStore(), nil
+ }
+ return nil, fmt.Errorf("read rank history file: %w", err)
+ }
+
+ var store RankHistoryStore
+ if err := json.Unmarshal(data, &store); err != nil {
+ return nil, fmt.Errorf("parse rank history file: %w", err)
+ }
+
+ if store.Version == 0 {
+ store.Version = rankHistoryVersion
+ }
+ if store.Snapshots == nil {
+ store.Snapshots = []RankSnapshot{}
+ }
+
+ sort.Slice(store.Snapshots, func(i, j int) bool {
+ return store.Snapshots[i].Date < store.Snapshots[j].Date
+ })
+
+ return &store, nil
+}
+
+func saveRankHistory(path string, store *RankHistoryStore) error {
+ data, err := json.MarshalIndent(store, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal rank history: %w", err)
+ }
+ if err := os.WriteFile(path, data, 0644); err != nil {
+ return fmt.Errorf("write rank history file: %w", err)
+ }
+ return nil
+}
+
+func buildCurrentRanks(sorted []ProjectSummary) map[string]int {
+ ranks := make(map[string]int, len(sorted))
+ for i, summary := range sorted {
+ ranks[summary.Name] = i + 1
+ }
+ return ranks
+}
+
+func upsertSnapshotForDate(store *RankHistoryStore, anchorDate time.Time, ranks map[string]int) {
+ day := anchorDate.Format("2006-01-02")
+ clonedRanks := make(map[string]int, len(ranks))
+ for repo, rank := range ranks {
+ clonedRanks[repo] = rank
+ }
+
+ for i := range store.Snapshots {
+ if store.Snapshots[i].Date == day {
+ store.Snapshots[i].Ranks = clonedRanks
+ return
+ }
+ }
+
+ store.Snapshots = append(store.Snapshots, RankSnapshot{
+ Date: day,
+ Ranks: clonedRanks,
+ })
+ sort.Slice(store.Snapshots, func(i, j int) bool {
+ return store.Snapshots[i].Date < store.Snapshots[j].Date
+ })
+}
+
+func applyRankHistoryToSummaries(summaries []ProjectSummary, store *RankHistoryStore, anchorDate time.Time, points int) {
+ for i := range summaries {
+ summaries[i].RankHistory = computeRepoHistory(store, summaries[i].Name, anchorDate, points)
+ }
+}
+
+func computeRepoHistory(store *RankHistoryStore, repo string, anchorDate time.Time, points int) []RepoRankHistory {
+ history := make([]RepoRankHistory, 0, points)
+ for i := 0; i < points; i++ {
+ target := anchorDate.AddDate(0, 0, -7*i)
+ targetDate := target.Format("2006-01-02")
+ snapshot := latestSnapshotAtOrBefore(store, targetDate)
+
+ item := RepoRankHistory{
+ Anchor: anchorLabel(i),
+ Arrow: "·",
+ }
+ if snapshot != nil {
+ item.SnapshotDate = snapshot.Date
+ if spot, ok := snapshot.Ranks[repo]; ok {
+ item.Spot = spot
+ }
+ }
+ if i > 0 {
+ item.Arrow = movementArrow(history[i-1].Spot, item.Spot)
+ }
+
+ history = append(history, item)
+ }
+
+ return history
+}
+
+func latestSnapshotAtOrBefore(store *RankHistoryStore, targetDate string) *RankSnapshot {
+ var latest *RankSnapshot
+ for i := range store.Snapshots {
+ snapshot := &store.Snapshots[i]
+ if snapshot.Date <= targetDate {
+ if latest == nil || snapshot.Date > latest.Date {
+ latest = snapshot
+ }
+ }
+ }
+ return latest
+}
+
+func movementArrow(currentSpot, olderSpot int) string {
+ if currentSpot <= 0 || olderSpot <= 0 {
+ return "·"
+ }
+ if currentSpot == olderSpot {
+ return "→"
+ }
+ if currentSpot < olderSpot {
+ return "↑"
+ }
+ return "↓"
+}
+
+func formatRankHistoryForHeader(history []RepoRankHistory) string {
+ if len(history) == 0 {
+ return ""
+ }
+
+ tokens := make([]string, 0, len(history))
+ for i, point := range history {
+ spot := fmt.Sprintf("#%d", point.Spot)
+ if point.Spot <= 0 {
+ spot = "n/a"
+ }
+
+ if i == 0 {
+ tokens = append(tokens, fmt.Sprintf("%s(%s)", spot, point.Anchor))
+ continue
+ }
+ tokens = append(tokens, fmt.Sprintf("%s%s(%s)", point.Arrow, spot, point.Anchor))
+ }
+
+ return " [" + strings.Join(tokens, " ") + "]"
+}
+
+func anchorLabel(i int) string {
+ if i == 0 {
+ return "now"
+ }
+ return fmt.Sprintf("%dw", i)
+}
diff --git a/internal/showcase/rank_history_test.go b/internal/showcase/rank_history_test.go
new file mode 100644
index 0000000..108ed68
--- /dev/null
+++ b/internal/showcase/rank_history_test.go
@@ -0,0 +1,153 @@
+package showcase
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestMovementArrow(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ current int
+ older int
+ want string
+ }{
+ {name: "same spot", current: 3, older: 3, want: "→"},
+ {name: "improved", current: 2, older: 5, want: "↑"},
+ {name: "worse", current: 6, older: 4, want: "↓"},
+ {name: "missing older", current: 2, older: 0, want: "·"},
+ {name: "missing current", current: 0, older: 2, want: "·"},
+ }
+
+ for _, tc := range tests {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := movementArrow(tc.current, tc.older)
+ if got != tc.want {
+ t.Fatalf("movementArrow(%d,%d) = %q, want %q", tc.current, tc.older, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestComputeRepoHistory_LatestSnapshotAtOrBeforeAnchor(t *testing.T) {
+ t.Parallel()
+
+ store := &RankHistoryStore{
+ Version: rankHistoryVersion,
+ Snapshots: []RankSnapshot{
+ {Date: "2026-01-18", Ranks: map[string]int{"alpha": 4}},
+ {Date: "2026-02-01", Ranks: map[string]int{"alpha": 3}},
+ {Date: "2026-02-15", Ranks: map[string]int{"alpha": 2}},
+ {Date: "2026-02-22", Ranks: map[string]int{"alpha": 1}},
+ },
+ }
+
+ anchor, _ := time.Parse("2006-01-02", "2026-02-22")
+ history := computeRepoHistory(store, "alpha", anchor, 5)
+
+ wantSpots := []int{1, 2, 3, 3, 4}
+ if len(history) != len(wantSpots) {
+ t.Fatalf("history len = %d, want %d", len(history), len(wantSpots))
+ }
+ for i, want := range wantSpots {
+ if history[i].Spot != want {
+ t.Fatalf("history[%d].Spot = %d, want %d", i, history[i].Spot, want)
+ }
+ }
+}
+
+func TestComputeRepoHistory_UsesNAWhenNoRepoSpotAtAnchor(t *testing.T) {
+ t.Parallel()
+
+ store := &RankHistoryStore{
+ Version: rankHistoryVersion,
+ Snapshots: []RankSnapshot{
+ {Date: "2026-02-22", Ranks: map[string]int{"alpha": 1}},
+ {Date: "2026-02-15", Ranks: map[string]int{"beta": 3}},
+ },
+ }
+
+ anchor, _ := time.Parse("2006-01-02", "2026-02-22")
+ history := computeRepoHistory(store, "alpha", anchor, 2)
+
+ if history[0].Spot != 1 {
+ t.Fatalf("history[0].Spot = %d, want 1", history[0].Spot)
+ }
+ if history[1].Spot != 0 {
+ t.Fatalf("history[1].Spot = %d, want 0", history[1].Spot)
+ }
+ if history[1].Arrow != "·" {
+ t.Fatalf("history[1].Arrow = %q, want %q", history[1].Arrow, "·")
+ }
+}
+
+func TestUpsertSnapshotForDate_Idempotent(t *testing.T) {
+ t.Parallel()
+
+ store := newRankHistoryStore()
+ anchor, _ := time.Parse("2006-01-02", "2026-02-22")
+
+ upsertSnapshotForDate(store, anchor, map[string]int{"a": 1})
+ upsertSnapshotForDate(store, anchor, map[string]int{"a": 2, "b": 1})
+
+ if len(store.Snapshots) != 1 {
+ t.Fatalf("len(store.Snapshots) = %d, want 1", len(store.Snapshots))
+ }
+ if got := store.Snapshots[0].Ranks["a"]; got != 2 {
+ t.Fatalf("store.Snapshots[0].Ranks[\"a\"] = %d, want 2", got)
+ }
+}
+
+func TestRankHistoryReadWriteRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, rankHistoryFilename)
+ store := &RankHistoryStore{
+ Version: rankHistoryVersion,
+ Snapshots: []RankSnapshot{
+ {Date: "2026-02-22", Ranks: map[string]int{"alpha": 1}},
+ },
+ }
+ if err := saveRankHistory(path, store); err != nil {
+ t.Fatalf("saveRankHistory() error = %v", err)
+ }
+
+ got, err := loadRankHistory(path)
+ if err != nil {
+ t.Fatalf("loadRankHistory() error = %v", err)
+ }
+ if got.Version != rankHistoryVersion {
+ t.Fatalf("got.Version = %d, want %d", got.Version, rankHistoryVersion)
+ }
+ if len(got.Snapshots) != 1 {
+ t.Fatalf("len(got.Snapshots) = %d, want 1", len(got.Snapshots))
+ }
+}
+
+func TestFormatRankHistoryForHeader(t *testing.T) {
+ t.Parallel()
+
+ header := formatRankHistoryForHeader([]RepoRankHistory{
+ {Spot: 3, Anchor: "now"},
+ {Spot: 2, Anchor: "1w", Arrow: "↓"},
+ {Spot: 2, Anchor: "2w", Arrow: "→"},
+ {Spot: 0, Anchor: "3w", Arrow: "·"},
+ })
+
+ if !strings.Contains(header, "#3(now)") {
+ t.Fatalf("header missing current spot: %s", header)
+ }
+ if !strings.Contains(header, "↓#2(1w)") {
+ t.Fatalf("header missing down movement: %s", header)
+ }
+ if !strings.Contains(header, "·n/a(3w)") {
+ t.Fatalf("header missing n/a placeholder: %s", header)
+ }
+}
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go
index 19a9a89..5ec43d3 100644
--- a/internal/showcase/showcase.go
+++ b/internal/showcase/showcase.go
@@ -28,9 +28,10 @@ type ProjectSummary struct {
CodebergURL string
GitHubURL string
Metadata *RepoMetadata
- Images []string // Relative paths to images in showcase directory
- CodeSnippet string // Code snippet to show when no images
- CodeLanguage string // Language and file info for the snippet
+ RankHistory []RepoRankHistory // Latest 5 weekly rank points, newest first
+ Images []string // Relative paths to images in showcase directory
+ CodeSnippet string // Code snippet to show when no images
+ CodeLanguage string // Language and file info for the snippet
}
// LegacyRepoMetadata for backwards compatibility with old cache files
@@ -136,6 +137,23 @@ func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool)
return summaries[i].Metadata.Score > summaries[j].Metadata.Score
})
+ anchorDate := time.Now()
+ rankHistoryFile := filepath.Join(g.workDir, rankHistoryFilename)
+ rankHistoryStore, err := loadRankHistory(rankHistoryFile)
+ if err != nil {
+ return fmt.Errorf("failed to load rank history: %w", err)
+ }
+
+ // Only full showcase runs should update ranking snapshots.
+ if len(repoFilter) == 0 {
+ upsertSnapshotForDate(rankHistoryStore, anchorDate, buildCurrentRanks(summaries))
+ if err := saveRankHistory(rankHistoryFile, rankHistoryStore); err != nil {
+ return fmt.Errorf("failed to save rank history: %w", err)
+ }
+ }
+
+ applyRankHistoryToSummaries(summaries, rankHistoryStore, anchorDate, rankHistoryPoints)
+
// When filtering (single repo), we need to update existing showcase
if len(repoFilter) > 0 {
if err := g.updateShowcaseFile(summaries); err != nil {
@@ -591,7 +609,7 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string {
builder.WriteString("\n---\n\n")
}
- builder.WriteString(fmt.Sprintf("### %d. %s\n\n", i+1, summary.Name))
+ builder.WriteString(fmt.Sprintf("### %d. %s%s\n\n", i+1, summary.Name, formatRankHistoryForHeader(summary.RankHistory)))
// Add metadata if available
if summary.Metadata != nil {
@@ -748,6 +766,13 @@ func (g *Generator) updateShowcaseFile(newSummaries []ProjectSummary) error {
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)
+ }
+ applyRankHistoryToSummaries(allSummaries, rankHistoryStore, time.Now(), rankHistoryPoints)
+
// Format and write
content := g.formatGemtext(allSummaries)
if err := g.writeShowcaseFile(content); err != nil {
diff --git a/internal/showcase/showcase_test.go b/internal/showcase/showcase_test.go
index 284a6bc..3fce064 100644
--- a/internal/showcase/showcase_test.go
+++ b/internal/showcase/showcase_test.go
@@ -2,6 +2,7 @@ package showcase
import (
"reflect"
+ "strings"
"testing"
"codeberg.org/snonux/gitsyncer/internal/config"
@@ -98,3 +99,26 @@ func TestFilterExcludedRepos_EmptyConfigStillRemovesBackupRepos(t *testing.T) {
t.Fatalf("filterExcludedRepos() = %#v, want %#v", got, want)
}
}
+
+func TestFormatGemtext_IncludesRankHistoryInHeader(t *testing.T) {
+ t.Parallel()
+
+ g := &Generator{config: &config.Config{}}
+ content := g.formatGemtext([]ProjectSummary{
+ {
+ Name: "alpha",
+ Summary: "alpha summary",
+ RankHistory: []RepoRankHistory{
+ {Spot: 1, Anchor: "now"},
+ {Spot: 2, Anchor: "1w", Arrow: "↑"},
+ {Spot: 2, Anchor: "2w", Arrow: "→"},
+ {Spot: 0, Anchor: "3w", Arrow: "·"},
+ {Spot: 4, Anchor: "4w", Arrow: "↓"},
+ },
+ },
+ })
+
+ if !strings.Contains(content, "### 1. alpha [#1(now) ↑#2(1w) →#2(2w) ·n/a(3w) ↓#4(4w)]") {
+ t.Fatalf("rank history was not rendered in header: %s", content)
+ }
+}
diff --git a/test/work2/test-repo b/test/work2/test-repo
deleted file mode 160000
-Subproject 3404c05717de3426030b65999d8e659494c51bb
diff --git a/test/work3/test-repo b/test/work3/test-repo
deleted file mode 160000
-Subproject e5f2ab3c76156de1636fd7f766af505825cea00