diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-22 20:41:23 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-22 20:41:23 +0200 |
| commit | 64a22d411a6096e8e7e14af7151e530f0ee358ae (patch) | |
| tree | abbf44e3aa1f06cb9cd178ab7cce65ebf011d6d1 | |
| parent | 98001853687e85d8ec22ccfbed72804c3ec4ccd7 (diff) | |
feat(showcase): add weekly rank history and header movement
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | internal/showcase/rank_history.go | 201 | ||||
| -rw-r--r-- | internal/showcase/rank_history_test.go | 153 | ||||
| -rw-r--r-- | internal/showcase/showcase.go | 33 | ||||
| -rw-r--r-- | internal/showcase/showcase_test.go | 24 | ||||
| m--------- | test/work2/test-repo | 0 | ||||
| m--------- | test/work3/test-repo | 0 |
7 files changed, 412 insertions, 4 deletions
@@ -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 |
