3 Commits
Author SHA1 Message Date
tiennm99 3dd5e85806 fix(card): show 7 *named* languages + Other, not 6 + Other (#19)
With topN=7 the previous collapseOther kept only the first 6 entries
and added "Other" as the 7th row. A user expecting to see 7 actual
languages in the legend saw six named languages plus "Other" — the
exact complaint just raised about the profile repo's donut.

Flip the semantic: the "top N" slots are reserved for real languages,
and "Other" is an extra row when (and only when) there's a non-zero
tail past the Nth entry. Topologically that means up to 8 legend
rows — still fits the card frame (row 8 text baseline at y=195, card
height 200).

- TestDonutTopSevenPlusOther pins the new contract with a 9-language
  input.
- adversarialProfile in TestCardsFitFrame bumped to 9 languages so
  the stress test exercises the 8-row legend geometry.
- design-guidelines: the donut row re-reads "Up to 7 named languages,
  plus an 'Other' row when the tail is non-zero (8 rows max)".
2026-04-19 10:46:10 +07:00
tiennm99 059c8b11ad docs: focus each file on users or coworkers, drop unrelated content (#18)
project-roadmap.md went from a 146-line phase-by-phase history to a
48-line focused view: what's planned, what's out of scope. Completed
work is already in git log + GitHub Releases — the doc re-telling it
was the thing most likely to rot and least likely to be read.

project-overview-pdr.md: "Open questions" section dropped its stale
bullet list and now just points at project-roadmap.md (single source of
truth for planned work).

code-standards.md: drop the ".claude/ directory" commit rule — that's
a per-user workflow detail, not a project-level standard. Docs are for
users of the CLI/Action and coworkers of this repo, nothing else.
2026-04-19 10:34:02 +07:00
tiennm99 65f17af2bc docs: collapse marketplace-publish step — already published (#17)
The repo is already listed on the Marketplace as ghstats-cards, so the
multi-paragraph "open the release page, tick the checkbox, re-publish"
instruction was stale. Replace with a one-line note pointing at the
existing listing and stating that new releases inherit visibility
automatically.
2026-04-19 10:26:57 +07:00
7 changed files with 85 additions and 149 deletions

No files matched your search

-1
View File
@@ -73,7 +73,6 @@ Never write comments that describe what well-named code does (`// increment coun
- Conventional commit prefixes: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `build:`, `ci:`, `chore:`.
- Scope is optional: `refactor(card): …`.
- Never use `chore:` or `docs:` for `.claude/` directory changes (project rule).
- No AI references. No "generated by" footers. Keep the subject ≤ 72 chars.
## Pre-commit checklist
+4 -8
View File
@@ -120,14 +120,10 @@ runs:
next Action run without a workflow edit.
5. Docker base images and third-party actions are SHA-pinned (with version
comments) so mutable-tag changes upstream can't rewrite a released image.
6. **Marketplace publishing (one-time per repo):** GitHub only exposes the
"Publish this Action to the GitHub Marketplace" toggle on the Release
web UI — there is no CLI flag. Open the newly created release at
`https://github.com/tiennm99/ghstats/releases/tag/vX.Y.Z/edit`, tick the
marketplace checkbox, accept the terms, and re-publish. Subsequent
releases inherit marketplace visibility automatically. The Marketplace
listing name is `ghstats-cards` (set in `action.yml`) because the bare
`ghstats` is already taken on the Marketplace.
6. **Marketplace:** the repo is already published on the GitHub Marketplace
as [`ghstats-cards`](https://github.com/marketplace/actions/ghstats-cards)
(the bare `ghstats` listing was taken). New releases inherit marketplace
visibility automatically — no manual step per release.
## Rollback
+1 -1
View File
@@ -50,7 +50,7 @@ Cap rows at what fits: up to 7 rows per card. Stats splits commits into lifetime
| Donut centre | `(250, 110)` |
| Outer radius | 55 |
| Inner radius | 30 |
| Top-N entries shown | 7 (overflow collapses into "Other") |
| Top-N entries shown | Up to 7 named languages, plus an "Other" row when the tail is non-zero (8 rows max) |
| Slice stroke | `theme.Background`, 1.5 px (gap between slices) |
| Legend origin | `(20, 55)` |
| Legend row height | 20 px |
+2 -5
View File
@@ -60,9 +60,6 @@ Distinguishing traits:
- Test suite covers rendering, XML escaping, number formatting, language sort.
- `go vet ./...` and `go test ./...` clean on every commit.
## Open questions / tracked roadmap items
## Open questions
- Per-commit REST classification (`-accurate-languages`)
- Partial bare clone mode for lifetime all-repo language stats
- `-exclude-repo` flag to drop known noise repos
- Expose `ownerAffiliations` beyond OWNER (COLLABORATOR, ORGANIZATION_MEMBER)
Planned follow-on work lives in [`project-roadmap.md`](./project-roadmap.md).
+30 -128
View File
@@ -1,146 +1,48 @@
# Project Roadmap
# Roadmap
## Phase 0 — Skeleton (✅ done)
What's planned next and what's intentionally out of scope. Completed work lives in the git log and GitHub Releases; this file doesn't rehash it.
- Module layout, flag parsing, placeholder SVG renderers.
## Planned
## Phase 1 — Five core cards (✅ done)
### Per-commit file classification (`-accurate-languages`)
- Profile details, repos-per-language, most-commit-language, stats, productive-time.
- GraphQL profile query + per-repo commit history.
- Docker-based Action wrapper, release workflow, 65-theme palette.
Fix the Markdown-blog misattribution case (and any repo where linguist's byte view disagrees with the files the user actually edited).
## Phase 2 — Chart quality (✅ done)
- **Approach**: `GET /repos/{owner}/{repo}/commits/{sha}` per commit → classify each file with `go-enry`. Weight by `additions + deletions`.
- **Cost**: ~1 REST call per commit. At current defaults (30 seed repos × 500 commits = 15 000 commits worst case) this is heavy; opt-in flag, schedule weekly not daily.
- **Research**: `plans/reports/researcher-260418-2001-accurate-language-stats.md`.
- **Status**: designed, not implemented.
- Match github-profile-summary-cards visual style (donuts, 24h bar chart, proper axes).
- Octicon labels on profile + stats cards.
- Smooth area chart for contributions (Catmull-Rom → cubic Bezier).
### Partial bare clone for lifetime language stats (`-deep`)
## Phase 3 — All-time variants (✅ done)
Lifetime language stats across every repo a user has committed in, without the 500-commits-per-repo cap.
- Unified commit-history fetch splits into last-year and all-time buckets.
- Per-year `contributionsCollection` loop yields `DailyContributionsAllTime` + `TotalCommitsAllTime`.
- Three new cards: most-commit-language-all-time, productive-time-all-time, contributions-all-time.
- Stats card gains a lifetime commits row.
- **Approach**: `git clone --filter=blob:none --bare` per seed repo + `git log --author --numstat` → go-enry.
- **Cost**: ~5 % of full-clone disk (trees only); 3–5 min runtime for 100 repos; zero REST calls.
- **Trade-off**: needs disk + git binary on runner.
- **Status**: researched only; would land behind `-deep`.
## Phase 4 — Accurate repo sampling (✅ done)
### User-configurable repo exclusion (`-exclude-repo`)
- Seed list built from `commitContributionsByRepository` across every active year.
- `-include-forks` / `-include-private` visibility flags (defaults later flipped on in Phase 7).
- `-top-repos` demoted to an optional cap (default 0 = unlimited).
- Commit-history query takes `$owner` so forks and non-owned repos are probeable.
Drop throwaway repos (experiments, stashed forks) from stats without turning off `include_forks` globally.
## Phase 5 — Byte-weighted attribution (✅ done)
- **Approach**: `-exclude-repo owner1/name1,owner2/name2` flag. Client-side filter on the seed list before probing.
- **Status**: pending user demand.
- Each commit distributes fractionally across repo's language bytes, not just primary.
- Improves mixed-code repo accuracy; still inaccurate for Markdown-heavy repos (linguist prose-exclusion).
### Expanded `ownerAffiliations`
## Phase 6 — Code-review remediation (✅ done)
Catch work in org repos where the user is a collaborator rather than owner.
Follow-up after the full-project review (`plans/reports/code-review-260418-2223-full-project.md`):
- **Approach**: expose `-affiliations OWNER,COLLABORATOR,ORGANIZATION_MEMBER`.
- **Blocker**: decide whether to display private org work on a public profile card by default.
- **Status**: blocked on that privacy call.
- Donut chart's single-slice (100%) rendering no longer produces an empty arc.
- `FetchContributionsAllTime` warns on stderr when a year returns nil user data.
- `attributeCommit` receives a precomputed per-repo byte total instead of re-summing every commit.
- `Profile.TotalContributions` → `TotalContributionsLastYear` (accurate semantics).
- `context.Context` threaded through all fetchers; `-timeout` flag (default 30m); Ctrl-C cancels in-flight requests.
- Rate-limit awareness: on 429 or exhausted primary limit, honor `Retry-After` / `X-RateLimit-Reset` up to 5 min and retry once.
- Release workflow gates docker + binaries on a test job; no more shipping broken tags.
- Docker base images and third-party GitHub Actions pinned to SHA with version comments.
- Stats card label "Contributed to (non-fork)" corrected to "Contributed to" (the query doesn't filter forks).
- Tests: fixed stale XML-escape assertion, added `TestDonutSingleSlice`, added `TestUTCOffsetLabel` for half-hour zones.
## Phase 7 — Release polish & Marketplace publish (✅ done)
- Visibility defaults flipped on: `-include-forks`, `-include-private` now default `true` (private silently no-ops if token lacks scope).
- Output filenames dropped the numeric prefix: `0-profile-details.svg` → `profile-details.svg` etc. Embedders reference by name.
- Card dimensions shrunk `500×220` → `340×200` to match github-profile-summary-cards so two cards fit per row in a README.
- Action `action.yml` name set to `ghstats-cards` for Marketplace (the bare `ghstats` is taken); repo stays `tiennm99/ghstats`.
- `v1.0.0`, `v1.1.0`, `v1.1.1` tagged and released. Prebuilt binaries (linux/darwin/windows × amd64/arm64) ship with each; Docker image pushed to `ghcr.io/tiennm99/ghstats`.
- Floating `v1` major tag created; `release.yml` has an `update-major-tag` job that force-moves `v1` to the latest patch after test+docker+binaries pass, so consumers pinned to `tiennm99/ghstats@v1` auto-pick new releases.
- README badges (Marketplace / Release / License) + direct Marketplace link for cross-navigation.
- Repo topics expanded for Marketplace discoverability (`ghstats-cards`, `profile-readme`, `stats-cards`, etc.).
- An attempted repo rename to `tiennm99/ghstats-cards` was committed and reverted (commits `399a3dc` + `8bd2128` on record) — GHCR path immutability and the cost of breaking pinned consumers outweighed the Marketplace-name cosmetic benefit.
## Phase 7.6 — S-tier breadth cards (✅ done)
Five new cards that ride on data already fetched — zero extra API calls:
- `contributions-heatmap` — canonical 7×53 calendar grid with a theme-derived 5-bucket intensity ramp.
- `contributions-by-year` — one bar per active year, peak year highlighted.
- `productive-weekday` + `productive-weekday-all-time` — mirror the hour-of-day pair; `FetchProductive` now also fills `Weekday` / `WeekdayAllTime` histograms.
- `top-starred-repos` — top 5 owned non-fork repos by ⭐; required threading `Stars` through `RepoInfo`.
- `streak` — current + longest streak + active days/total. Pure post-processing of `DailyContributionsAllTime`.
Card count: 9 → 15 (weekday adds LY + AT variants). `FetchProductive` still pays for commit-history pagination once; the new cards are pure renderers.
## Phase 7.5 — Demo gallery for theme discovery (✅ done)
- New `.github/workflows/demo.yml` renders every card for every theme against the repo owner's profile on each push to `main`.
- Output lands in `demo/<theme>/` (SVGs + a `README.md` for that theme pairing last-year / all-time variants side-by-side); top-level `demo/README.md` is a zero-image index linking to each theme page, so opening the gallery doesn't force a reader to fetch 975 SVGs at once.
- Loop prevention: workflow skips pushes that only touch `demo/**`, `**.md`, or `LICENSE`; `GITHUB_TOKEN`-driven pushes don't retrigger workflows by design.
- Consumer impact: none — this is a repo-internal discovery aid, not a shipped feature.
---
## Phase 8 — Per-commit file classification (planned)
**Goal**: fix the Markdown-blog misattribution case (and any repo where linguist's byte view disagrees with what files user actually edited).
**Approach**: `GET /repos/{owner}/{repo}/commits/{sha}` per commit → classify each file with `go-enry`. Weight by `additions + deletions`.
**Cost**: ~1 REST call per commit. At current defaults (30 seed repos × 500 commits = 15,000 commits worst case) this is heavy — needs `-accurate-languages` opt-in flag, schedule weekly not daily.
**Research**: see `plans/reports/researcher-260418-2001-accurate-language-stats.md`.
**Status**: designed, not implemented.
## Phase 9 — Partial bare clone for lifetime all-repo stats (planned)
**Goal**: lifetime language stats across **every** repo a user has committed in, without the 500-commits-per-repo cap.
**Approach**: `git clone --filter=blob:none --bare` per seed repo + `git log --author --numstat` → go-enry.
**Cost**: ~5% of full-clone disk (trees only, no blobs); 3–5 minutes runtime for 100 repos; zero REST calls.
**Trade-off**: needs disk + git binary on runner. Lowlighter/metrics' indepth mode does similar but clones full blobs; we'd skip those.
**Status**: researched only; behind `-deep` flag when landed.
## Phase 10 — User-configurable repo exclusion (planned)
**Goal**: let users drop throwaway repos (experiments, forks they stashed) from stats without disabling forks globally.
**Approach**: `-exclude-repo owner1/name1,owner2/name2` flag. Filter seed list before probing.
**Cost**: negligible (client-side filter).
**Status**: pending user demand.
## Phase 11 — Expand ownerAffiliations (planned)
**Goal**: catch work done in org repos where user is a collaborator, not owner (e.g., company monorepos).
**Approach**: expose `-affiliations OWNER,COLLABORATOR,ORGANIZATION_MEMBER` flag. Requires thinking about whether to *display* private org work on a public profile card.
**Status**: blocked on deciding the privacy default.
---
## Known limitations (not roadmap items — by design)
## Out of scope (by design)
| Limitation | Reason |
| --- | --- |
| Markdown/prose excluded from byte counts | Linguist's default; we defer to linguist |
| No real-time API | Scope: scheduled batch renderer, not a server |
| No WakaTime integration | Out of scope — WakaTime cards already exist (athul/waka-readme, anmol098/waka-readme-stats) |
| No heatmap (7×24) variant of productive time | Simplified to 24-hour bar chart to match reference project |
| Hard width of 340 px per card | Matches github-profile-summary-cards; customising would cascade through every chart's geometry. |
## Tracked research reports
All in `plans/reports/`:
- `researcher-260418-2001-accurate-language-stats.md` — metrics vs GRS vs go-enry feasibility
- `researcher-260418-2012-profile-stats-survey.md` — follow-up survey across 6 more tools
- `analysis-260418-2140-most-commit-language-all-time.md` — hand-reconstruction of tiennm99's card output, showing exactly why each language lands where
- `code-review-260418-2223-full-project.md` — adversarial review of the whole codebase; findings all closed in Phase 6
| Markdown/prose excluded from byte counts | Linguist's default — we defer to it |
| No real-time API / server mode | Scheduled batch renderer, not a service |
| No WakaTime integration | Other tools already cover this (`athul/waka-readme`, `anmol098/waka-readme-stats`) |
| No 7×24 heatmap variant of productive time | 24-hour bar chart matches the reference project |
| Hard 340 px card width | Matches github-profile-summary-cards; customising would cascade through every chart's geometry |
+38
View File
@@ -141,6 +141,37 @@ func TestDonutEmpty(t *testing.T) {
}
}
// TestDonutTopSevenPlusOther confirms the "7 named + Other" contract: when
// the input has more than 7 languages, the legend shows all 7 real entries
// followed by a single "Other" bucket. Regression guard against anyone
// sliding topN back to "N-1 real + Other" packing.
func TestDonutTopSevenPlusOther(t *testing.T) {
th, _ := theme.Lookup("dracula")
stats := []github.LangStat{
{Name: "Go", Color: "#00ADD8", Value: 100},
{Name: "TypeScript", Color: "#3178c6", Value: 90},
{Name: "Python", Color: "#3572A5", Value: 80},
{Name: "Rust", Color: "#dea584", Value: 70},
{Name: "JavaScript", Color: "#f1e05a", Value: 60},
{Name: "HTML", Color: "#e34c26", Value: 50},
{Name: "Shell", Color: "#89e051", Value: 40},
{Name: "Kotlin", Color: "#A97BFF", Value: 30},
{Name: "Java", Color: "#b07219", Value: 20},
}
svg := string(renderDonutCard("Test", stats, th))
for _, want := range []string{"Go", "TypeScript", "Python", "Rust", "JavaScript", "HTML", "Shell", "Other"} {
if !strings.Contains(svg, ">"+want+" ") {
t.Errorf("expected legend row for %q; not found in:\n%s", want, svg)
}
}
// Kotlin + Java spill into Other (they must NOT appear as named rows).
for _, dropped := range []string{">Kotlin ", ">Java "} {
if strings.Contains(svg, dropped) {
t.Errorf("expected %q to be collapsed into Other, but it renders:\n%s", dropped, svg)
}
}
}
func TestFormatInt(t *testing.T) {
cases := map[int]string{
0: "0",
@@ -356,12 +387,19 @@ func adversarialProfile() *github.Profile {
TotalContributedTo: 777,
TotalContributionsLastYear: 200_000,
CreatedAt: time.Date(2008, 1, 1, 0, 0, 0, 0, time.UTC),
// Nine languages so the donut's "top 7 + Other" layout renders all
// eight legend rows — catches any regression that breaks the tall
// legend geometry at y≈195.
ReposByLanguage: []github.LangStat{
{Name: "JavaScript", Color: "#f1e05a", Value: 1234},
{Name: "TypeScript", Color: "#3178c6", Value: 999},
{Name: "Go", Color: "#00ADD8", Value: 500},
{Name: "Rust", Color: "#dea584", Value: 321},
{Name: "Python", Color: "#3572A5", Value: 200},
{Name: "HTML", Color: "#e34c26", Value: 180},
{Name: "Shell", Color: "#89e051", Value: 120},
{Name: "Kotlin", Color: "#A97BFF", Value: 60},
{Name: "Java", Color: "#b07219", Value: 30},
},
CommitsByLanguage: []github.LangStat{
{Name: "JavaScript", Color: "#f1e05a", Value: 1_000_000},
+10 -6
View File
@@ -99,19 +99,23 @@ func polar(cx, cy float64, r, angle float64) (float64, float64) {
return cx + r*math.Cos(angle), cy + r*math.Sin(angle)
}
// collapseOther returns the top (n-1) slices plus an "Other" row summing the
// rest. When the slice fits, it's returned as-is.
// collapseOther returns the top n named entries, optionally followed by an
// "Other" row summing everything past that. "Top N" means N actual languages
// — the Other row is a bonus when there's a non-zero tail, not one of the N.
// When the input fits in N entries the caller gets the slice back as-is.
func collapseOther(in []github.LangStat, n int) []github.LangStat {
if len(in) <= n {
return in
}
out := make([]github.LangStat, 0, n)
out = append(out, in[:n-1]...)
out := make([]github.LangStat, 0, n+1)
out = append(out, in[:n]...)
var rest int64
for _, s := range in[n-1:] {
for _, s := range in[n:] {
rest += s.Value
}
out = append(out, github.LangStat{Name: "Other", Value: rest})
if rest > 0 {
out = append(out, github.LangStat{Name: "Other", Value: rest})
}
return out
}