docs: add project documentation set

Seven canonical docs under docs/ per the project structure convention:

- project-overview-pdr.md   users, non-goals, requirements
- codebase-summary.md       directory layout, module responsibilities
- system-architecture.md    runtime phases, GraphQL flow, SVG primitives
- code-standards.md         YAGNI/KISS/DRY, Go conventions, commit rules
- design-guidelines.md      frame dimensions, theme roles, per-card specs
- deployment-guide.md       Action/binary/Docker paths, release process
- project-roadmap.md        done phases (0-5), planned phases (6-9)

All files under the 800-line cap. Each leans on tables; grammar
sacrificed for concision per project rules.
This commit is contained in:
2026-04-18 22:10:09 +07:00
parent 0168fd1f56
commit 8b3a8e1932
7 changed files with 740 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
# Code Standards
## Principles
Applied in order: **YAGNI → KISS → DRY**.
- No feature flags, no plugin systems, no abstractions for hypothetical callers.
- One way to do a thing. A helper emerges only after the second call site, never before.
- Three similar lines beats a premature abstraction. Extract when a fourth arrives.
## Go conventions
### File naming
- Multi-word files use `snake_case` (Go ecosystem standard): `repos_per_language.go`, `contributions_all_time.go`.
- Single-word files stay single-word: `client.go`, `model.go`, `profile.go`.
- Test files adjacent to the unit under test with `_test.go` suffix.
### Package structure
- `main.go` at repo root — the CLI wrapper only.
- All reusable code under `internal/` so it can't accidentally become an API.
- Each package owns one concern: `github` = network, `card` = render, `theme` = palette data.
### Error handling
- Wrap errors with `fmt.Errorf("%w: …", err)` when adding context; bare return when the caller already has enough context.
- Sentinel errors (`errors.New`) only when callers need to type-check. We have none today — don't invent them.
- Network/API errors in fetchers bubble up; `main.go` decides whether to exit or warn.
### No hidden state
- Profile fetchers mutate the `*Profile` argument in-place (`FetchContributionsAllTime(p, opts)`) — explicit ownership, no package-level caches.
- Renderers are pure functions of `(*Profile, theme.Theme)`. No side effects, no goroutines.
### Comments
Default: **no comments**. Exceptions:
- **Why non-obvious code is non-obvious.** Example: the `scaleFactor = 10_000` constant — a comment explains why fixed-point and not float.
- **Hidden invariant or constraint.** Example: the comment on `contributionYearQuery` noting the `maxRepositories: 100` cap.
- **Workaround for an upstream behavior.** Example: clamping the current year's `to` to `now` so GitHub doesn't reject future timestamps.
Never write comments that describe what well-named code does (`// increment counter`). Never reference "the recent fix" or "for issue #42" — that belongs in git.
### Function length
- No hard limit. 200-line functions are fine when the logic is linear.
- Extract a helper only when (a) the same shape repeats twice, or (b) a block needs an independent name to be read at the call site.
### Exported vs unexported
- Start unexported. Export only when a test file or another package needs the symbol.
- Types on the public API: `Profile`, `RepoInfo`, `LangStat`, `LangEdge`, `DailyContribution`, `FetchOptions`, `Client`, `Theme`, `Card`.
- Everything else stays lowercase.
## SVG output standards
- Always XML-escape user-controlled strings through `escapeXML` (`&`, `<`, `>`, `"`, `'`).
- Numbers formatted via `formatInt` with thousands separators.
- Stable viewbox per card (`500×220` for most, `500×220` for profile too).
- No `<script>` tags, no event handlers. Cards are pure markup.
## Testing
- Unit tests in the same package, no mocking of http.Client — we test rendering and pure helpers.
- Network tests are omitted; integration verification is manual (`./ghstats -token ...`).
- `go vet ./...` clean before every commit.
## Commit conventions
- 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
```
go vet ./...
go test ./...
go build ./...
```
All three must pass. If a test is failing, fix the test before committing — don't skip.
## Dependency policy
- **Stdlib only.** `go.mod` lists no `require` entries.
- If a future feature needs a third-party module, add it in a dedicated commit with justification in the message.
- Keep binary size under 15 MB.
+109
View File
@@ -0,0 +1,109 @@
# Codebase Summary
## Layout
```
ghstats/
├── main.go # CLI entry point; wires flags → fetchers → renderers
├── action.yml # GitHub Action metadata
├── entrypoint.sh # Action runtime; maps INPUT_* env → CLI flags
├── Dockerfile # Multi-stage build for the Action image
├── go.mod # Module declaration; no external deps
├── internal/
│ ├── github/ # GraphQL client + fetchers + models
│ │ ├── client.go # HTTP POST to /graphql, error decoding
│ │ ├── queries.go # profileQuery, commitHistoryQuery, contributionYearQuery
│ │ ├── model.go # Profile, RepoInfo, LangStat, LangEdge, DailyContribution
│ │ ├── profile.go # FetchProfile — user + owned repos + stats + calendar
│ │ ├── productive.go # FetchProductive — commit history → hour histogram + lang buckets
│ │ ├── contributions_all_time.go # FetchContributionsAllTime — per-year loop → seed list + daily series
│ │ └── profile_test.go # sortLangStats tiebreak
│ ├── card/ # SVG renderers; one file per card
│ │ ├── card.go # Card interface, RenderAll, allCards slice
│ │ ├── svg.go # escapeXML, formatInt, header, footer
│ │ ├── axis.go # niceTicks (d3-style 1/2/5 × 10^k), formatTick
│ │ ├── icons.go # Octicon path strings
│ │ ├── profile.go # 0-profile-details
│ │ ├── repos_per_language.go # 1-repos-per-language
│ │ ├── most_commit_language.go # 2-most-commit-language
│ │ ├── most_commit_language_all_time.go # 6-most-commit-language-all-time
│ │ ├── stats.go # 3-stats
│ │ ├── productive.go # 4-productive-time + 7-*-all-time
│ │ ├── contributions.go # 5-contributions + 8-*-all-time
│ │ ├── donut_chart.go # renderDonutCard — shared by language cards
│ │ └── card_test.go # Rendering + escape + format tests
│ └── theme/
│ └── theme.go # 61-palette map ported from github-profile-summary-cards
├── .github/workflows/
│ ├── ci.yml # go vet + go test on push/PR
│ └── release.yml # GHCR image + cross-platform binaries on tag
├── docs/ # This directory
├── plans/ # Research reports + implementation plans
└── output/dracula/ # Sample committed; other themes gitignored
```
## Module responsibilities
### `internal/github`
All network I/O. Exposes a `*Client` with three fetchers:
| Fetcher | Input | Populates |
| --- | --- | --- |
| `FetchProfile(login, opts)` | username, visibility flags | Profile basics, totals, owned-repos aggregation, last-year daily calendar, `TopRepos` |
| `FetchContributionsAllTime(p, opts)` | Profile | `SeedRepos`, `DailyContributionsAllTime`, `TotalCommitsAllTime` |
| `FetchProductive(p, repos, loc, cap)` | Profile + seed + tz + cap | `Productive`, `CommitsByLanguage`, `ProductiveAllTime`, `CommitsByLanguageAllTime` |
Call order in `main.go`: Profile → AllTime → Productive.
### `internal/card`
Pure rendering. Every card implements the `Card` interface:
```go
type Card interface {
Filename() string
SVG(*github.Profile, theme.Theme) ([]byte, error)
}
```
`RenderAll` iterates `allCards`, writes each to `<outDir>/<themeID>/<Filename>`.
Shared helpers:
- `renderDonutCard` — language donut + legend (used by 3 language cards)
- `renderProductiveTime` — 24h bar chart (used by both productive cards)
- `renderContributions` — smooth area chart (used by both contributions cards)
- `header`, `footer` — SVG chrome
- `niceTicks`, `formatTick` — axis math
### `internal/theme`
Static map of 61 themes. Each theme specifies title/text/background/stroke/accent/muted plus `StrokeOpacity` for correct light-theme borders.
## Card ↔ data flow
```
profileQuery ─────► Profile.{identity, owned repos, totals, last-year calendar}
contributionYearQuery ─┬──► SeedRepos + DailyContributionsAllTime + TotalCommitsAllTime
└─ seed into ─►
commitHistoryQuery ──► Productive + CommitsByLanguage (+ AllTime variants)
9 SVG files per theme
```
## Test coverage
- `internal/card/card_test.go` — RenderAll produces 9 valid SVGs; escape + formatInt spot-checks.
- `internal/github/profile_test.go``sortLangStats` ordering and tiebreak.
No network-touching tests; real runs verified via `-token` + local build.
## Naming conventions
- Go files use snake_case for multi-word names (`repos_per_language.go`, `contributions_all_time.go`).
- Cards' `Filename()` returns the numbered SVG output name — consumers sort lexicographically.
- Themes in snake_case to match upstream (`github_dark`, `nord_bright`).
+137
View File
@@ -0,0 +1,137 @@
# Deployment Guide
Three consumption paths: **GitHub Action**, **prebuilt binaries**, **go install**.
## 1. GitHub Action (recommended for README auto-updates)
### Workflow template
File: `.github/workflows/ghstats.yml` in your profile repo.
```yaml
name: ghstats
on:
schedule:
- cron: "0 0 * * *" # daily at 00:00 UTC
workflow_dispatch:
permissions:
contents: write # needed for commit_changes
jobs:
cards:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: tiennm99/ghstats@v1
with:
user: ${{ github.repository_owner }}
token: ${{ secrets.GHSTATS_TOKEN }}
themes: dracula,github_dark,tokyonight
tz: Asia/Saigon
include_forks: "false"
include_private: "false"
commit_changes: "true"
```
### Required secrets
`GHSTATS_TOKEN`: a **classic** personal access token with at minimum:
| Scope | Needed for |
| --- | --- |
| `read:user` | Basic profile fields, contribution calendar |
| `repo` | Only if `include_private: "true"` |
Fine-grained PATs and the default `${{ github.token }}` lack the introspection scope for contribution calendars in many orgs, so a classic PAT is recommended.
Create one at <https://github.com/settings/tokens> → "Generate new token (classic)" → select `read:user` (+ `repo` if needed) → save as repo secret `GHSTATS_TOKEN`.
### Embedding in README
```md
![profile](./output/dracula/0-profile-details.svg)
![repos-per-language](./output/dracula/1-repos-per-language.svg)
![most-commit-language](./output/dracula/2-most-commit-language.svg)
![stats](./output/dracula/3-stats.svg)
![productive-time](./output/dracula/4-productive-time.svg)
![contributions](./output/dracula/5-contributions.svg)
![most-commit-language-all-time](./output/dracula/6-most-commit-language-all-time.svg)
![productive-time-all-time](./output/dracula/7-productive-time-all-time.svg)
![contributions-all-time](./output/dracula/8-contributions-all-time.svg)
```
The Action commits SVGs to `output/<theme>/` on the default branch. GitHub serves them from the raw URL the README references.
## 2. Prebuilt binaries
Each tag under `v*` publishes:
- Linux `amd64`, `arm64`
- macOS `amd64`, `arm64`
- Windows `amd64`
Released via `.github/workflows/release.yml` which matrixes `GOOS` × `GOARCH`, strips symbols (`-ldflags="-s -w"`), and uploads tar.gz / zip to the GitHub Release.
Install:
```sh
# Linux x86_64 example
curl -L https://github.com/tiennm99/ghstats/releases/latest/download/ghstats_linux_amd64.tar.gz \
| tar xz
./ghstats -user YOUR_USERNAME
```
## 3. go install
```sh
go install github.com/tiennm99/ghstats@latest
```
Requires Go 1.26+. Puts the binary in `$(go env GOPATH)/bin`.
## Docker image
Published to `ghcr.io/tiennm99/ghstats:<tag>` on each `v*` release via `.github/workflows/release.yml` (buildx, multi-tag: exact version, major.minor, major, latest).
The Action itself uses a runner-built image by default (`image: Dockerfile` in `action.yml`). To switch to the pre-built image for faster cold starts, edit `action.yml`:
```yaml
runs:
using: docker
image: docker://ghcr.io/tiennm99/ghstats:v1
```
## Release process
1. Ensure `go vet ./...` and `go test ./...` pass on `main`.
2. Tag: `git tag -a v1.2.0 -m "..." && git push origin v1.2.0`.
3. `release.yml` handles GHCR push + binary artifacts automatically.
4. Update any public Actions marketplace metadata if the major version changed.
## Rollback
- Revert the tag: `git push --delete origin v1.2.0`, delete GitHub release, delete GHCR tag.
- Users pinned to `@v1` keep working because the previous patch is still tagged.
## Rate limit considerations
| Scenario | GraphQL calls per run | Notes |
| --- | --- | --- |
| Typical user, defaults | 1540 | Well under 5000 pts/hr |
| Active user (8 years, 30+ seed repos) | 4080 | Still comfortable |
| `-include-private=true` with 100+ work repos | 80200 | Fine for daily cron |
| Adversarial user with 500+ committed repos/year | Capped by `maxRepositories: 100` per year query | Long tail drops silently |
No REST calls today. Future `-accurate-languages` mode will push toward 1000+ REST per run; schedule that mode less frequently (weekly, not daily).
## Troubleshooting
| Symptom | Check |
| --- | --- |
| "error: fetch profile: graphql: Could not resolve to a User" | Username typo |
| "http 401" | Token expired or lacks `read:user` |
| "http 403: rate limit exceeded" | PAT scope too narrow; token quota consumed by another workflow |
| Blank contribution chart | User has 0 contributions in their window; expected |
| Private repo data missing | `-include-private=true` not set, or PAT lacks `repo` |
| Nothing committed by the Action | Check `permissions: contents: write` in the workflow |
+108
View File
@@ -0,0 +1,108 @@
# Design Guidelines
Visual conventions for ghstats SVG cards. All cards share a single frame shape so they stack cleanly in a README.
## Card frame
| Property | Value |
| --- | --- |
| Width × Height | **500 × 220** |
| Corner radius | 8 px |
| Stroke | `theme.Stroke` at `theme.StrokeOpacity` |
| Fill | `theme.Background` |
| Font family | `'Segoe UI', Ubuntu, Sans-Serif` |
| Title | 18 px, weight 600, `theme.Title`, anchored at `(25, 35)` |
Generated by `header(width, height, bg, stroke, strokeOpacity, titleColor, title)` in `internal/card/svg.go`.
## Theme role mapping
| Theme field | Used for |
| --- | --- |
| `Title` | Card title text |
| `Text` | Primary content (values, names) |
| `Background` | Card fill + stroke around donut slices to separate colors |
| `Stroke` + `StrokeOpacity` | Card outline |
| `Muted` | Axis lines, axis labels, icons, legend metadata |
| `Accent` | Bars, area fills, stat values, fallback slice color |
Cards MUST NOT hardcode colors outside these fields. If a new visual needs a shade, pick the closest existing field — don't extend the schema without a strong reason.
## Row-based cards (profile, stats)
Single-column rows of `icon + label` or `icon + label + value`.
| Metric | Value |
| --- | --- |
| First row baseline (y) | 70 |
| Row spacing | 24 px |
| Icon scale | `14/16 = 0.875` from 16×16 Octicon viewBox |
| Icon color | `theme.Muted` |
| Right-aligned value anchor (stats) | `x = 475`, `text-anchor="end"` |
| Value font weight | 600 |
| Value color | `theme.Accent` |
Cap rows at what fits: 7 for profile, 7 for stats (commits row splits into lifetime + last-year).
## Donut cards (language breakdowns)
| Metric | Value |
| --- | --- |
| Donut centre | `(380, 120)` |
| Outer radius | 70 |
| Inner radius | 38 |
| Top-N entries shown | 6 (overflow collapses into "Other") |
| Slice stroke | `theme.Background`, 1.5 px (gap between slices) |
| Legend origin | `(30, 70)` |
| Legend row height | 22 px |
| Swatch size | 12 × 12 |
Language colors come from linguist via GraphQL (`repo.languages.edges[].node.color`). Missing colors fall back to `theme.Accent`.
## Bar-chart cards (productive time)
| Metric | Value |
| --- | --- |
| Chart area | `x ∈ [50, 475]`, `y ∈ [60, 170]` (110 tall) |
| Bars | 24 bars, 2 px gap |
| Bar fill | `theme.Accent` |
| Y-axis ticks | `niceTicks(max, 5)` — 1/2/5 × 10^k ladder |
| X-axis labels | Hours 0, 6, 12, 18, 23 |
| Axis caption | "hour of day" bottom-center |
| Title format | `Commits by Hour (<window>, UTC±N.NN)` |
| Hover | `<title>HH:00 — N commits</title>` inside each bar |
## Area-chart cards (contributions)
| Metric | Value |
| --- | --- |
| Chart area | `x ∈ [35, 465]`, `y ∈ [60, 180]` (120 tall) |
| Curve | Catmull-Rom → cubic Bezier (tension 0.5) |
| Fill | `theme.Accent` at 25% opacity |
| Stroke | `theme.Accent`, 2 px |
| Y-axis | **Both sides**, mirrored, same tick values |
| X-axis labels | `mm/yy`, stride-thinned to ~6 labels regardless of bucket count |
| Axis caption | "mm/yy" bottom-center |
| First/last labels | Always printed (pinned endpoints) |
Missing months in the `[first, last]` range are inserted as zero-count rows to keep the curve time-continuous.
## Icons
- Sourced from [Primer Octicons](https://primer.style/octicons/) 16×16 set.
- Stored as raw `<path d="…"/>` strings in `internal/card/icons.go`.
- Rendered inside `<g transform="translate(x,y) scale(0.875)" fill="muted">…</g>`.
- Used: `iconRepos`, `iconCompany`, `iconLocation`, `iconClock`, `iconLink`, `iconPeople`, `iconStar`, `iconCommit`, `iconPR`, `iconIssue`, `iconReview`.
Add new icons by copying the `<path>` from Octicons and appending to `icons.go`. Keep them to the same 16×16 viewBox so the existing scale math applies.
## Accessibility
- Contrast is the theme author's responsibility — we don't validate at runtime.
- Tooltips (`<title>`) on productive-time bars let screen readers announce counts.
- No motion, no `<animate>` elements — profile READMEs render statically.
## Text overflow
- Long strings (bio, repo names) are **not truncated**; they're XML-escaped and printed as-is.
- If a card looks crowded at 500 px width, that's a card design problem — fix the layout, not the data.
+68
View File
@@ -0,0 +1,68 @@
# ghstats — Product Development Requirements
## One-liner
Single-binary Go CLI + GitHub Action that renders 9 themed SVG cards summarising a GitHub user's public (and optionally private) profile, for embedding in a profile README.
## Users
- **Primary**: GitHub users maintaining a profile README who want auto-updating stat cards without a self-hosted service.
- **Secondary**: Tools integrating profile summaries (dashboards, portfolio sites).
## Non-goals
- WakaTime-style editor telemetry.
- Cloning repos or running linguist locally (lowlighter/metrics territory). A future `-accurate-languages` mode may add per-commit REST classification; clone-mode is out of scope for v1.
- Real-time / per-request API server. ghstats is a scheduled batch renderer.
## Value proposition vs alternatives
| Tool | Language | Runs as | Solves per-commit attribution? |
| --- | --- | --- | --- |
| anuraghazra/github-readme-stats | JS | hosted service | No (byte-size only) |
| vn7n24fzkq/github-profile-summary-cards | TS | Action + hosted | No (primary-language-per-repo) |
| lowlighter/metrics (indepth) | JS | Action | Yes (clones + linguist-js) |
| **ghstats** | Go | Action + CLI | Partial (byte-weighted today; REST-per-commit planned) |
Distinguishing traits:
- **Single binary**: no Node, no Ruby, no Docker needed for CLI usage.
- **Seed-list sampling**: commit-history probes land on repos the user actually committed in (via `contributionsCollection.commitContributionsByRepository`), not top-starred or owned-only.
- **All-time variants**: for every time-bounded card (most-commit-language, productive-time, contributions), there's a lifetime counterpart.
- **Public-safe defaults**: forks and private repos are **off** by default; users opt in.
## Functional requirements
| # | Requirement |
| --- | --- |
| F1 | Render 9 cards per selected theme (see `docs/system-architecture.md`) |
| F2 | Support 60+ themes ported from github-profile-summary-cards |
| F3 | Handle the full username→profile→cards flow in a single invocation |
| F4 | Package as GitHub Action with `commit_changes` auto-commit of output |
| F5 | Expose `-include-forks` / `-include-private` toggles |
| F6 | Apply byte-weighted commit-to-language attribution |
| F7 | Render smooth area charts for time-series (Catmull-Rom) |
| F8 | Support IANA timezones for productive-time (display `UTC±N.NN`) |
## Non-functional requirements
| Axis | Target |
| --- | --- |
| Runtime (scheduled Action) | < 60 s for typical user |
| GraphQL calls per run | < 100 |
| REST calls per run | 0 (may grow with future modes) |
| Dependencies | stdlib only (no Go module deps required) |
| Binary size | < 15 MB stripped |
| SVG output correctness | XML-escaped, no script injection from user data |
## Success metrics
- Cards render identically across `dracula`, `github`, `github_dark`, `nord_bright`, `tokyonight`.
- Test suite covers rendering, XML escaping, number formatting, language sort.
- `go vet ./...` and `go test ./...` clean on every commit.
## Open questions / tracked roadmap items
- 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)
+99
View File
@@ -0,0 +1,99 @@
# Project Roadmap
## Phase 0 — Skeleton (✅ done)
- Module layout, flag parsing, placeholder SVG renderers.
## Phase 1 — Five core cards (✅ done)
- Profile details, repos-per-language, most-commit-language, stats, productive-time.
- GraphQL profile query + per-repo commit history.
- Docker-based Action wrapper, release workflow, 61-theme palette.
## Phase 2 — Chart quality (✅ done)
- 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).
## Phase 3 — All-time variants (✅ done)
- Unified commit-history fetch splits into last-year and all-time buckets.
- Per-year `contributionsCollection` loop yields `DailyContributionsAllTime` + `TotalCommitsAllTime`.
- Three new cards: 6-most-commit-language-all-time, 7-productive-time-all-time, 8-contributions-all-time.
- Stats card gains a lifetime commits row.
## Phase 4 — Accurate repo sampling (✅ done)
- Seed list built from `commitContributionsByRepository` across every active year.
- `-include-forks` / `-include-private` visibility flags (default off).
- `-top-repos` demoted to an optional cap (default 0 = unlimited).
- Commit-history query takes `$owner` so forks and non-owned repos are probeable.
## Phase 5 — Byte-weighted attribution (✅ done)
- 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).
---
## Phase 6 — 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 7 — 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); 35 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 8 — 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 9 — 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)
| 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 500 px per card | Keeps README layout predictable; customizing width would cascade through every chart math |
## 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
+128
View File
@@ -0,0 +1,128 @@
# System Architecture
## Runtime shape
One process, three phases: **flag parsing → data fetch → SVG render**.
```
┌───────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌──────────────┐
│ flag / env│──►│ internal/github │──►│ internal/card │──►│ output/*.svg│
│ parsing │ │ (GraphQL only) │ │ (pure render) │ │ per theme │
└───────────┘ └──────────────────┘ └─────────────────┘ └──────────────┘
▲ ▲
│ │
api.github.com internal/theme
```
No database, no cache, no background workers. Stateless CLI; Action runtime just sets environment variables + runs the binary.
## Data-fetch sequence
```
main.go
FetchProfile(login, opts)
│ profileQuery × N pages (owned repos, STARGAZERS desc, 100/page)
│ yields: Profile.{identity, stars, forks, PRs, issues,
│ TopRepos, ReposByLanguage,
│ ContributionYears,
│ DailyContributions (last year),
│ TotalCommits (last year)}
FetchContributionsAllTime(profile, opts)
│ contributionYearQuery × len(ContributionYears)
│ per year: totalCommitContributions +
│ contributionCalendar.weeks +
│ commitContributionsByRepository(maxRepositories: 100)
│ yields: SeedRepos (deduped),
│ DailyContributionsAllTime,
│ TotalCommitsAllTime
FetchProductive(profile, profile.SeedRepos, loc, commitsPerRepo)
│ commitHistoryQuery × (#seeds × pages)
│ per commit: t = committedDate in loc
│ ProductiveAllTime[t.Hour]++ + language votes
│ if t.After(yearAgo): Productive[t.Hour]++ + language votes
│ yields: Productive, ProductiveAllTime,
│ CommitsByLanguage, CommitsByLanguageAllTime
card.RenderAll(profile, theme, outDir) × len(themes)
```
## GraphQL queries
All three queries live in `internal/github/queries.go`.
| Query | Purpose | Cost estimate |
| --- | --- | --- |
| `profileQuery` | Profile identity + totals + owned repos + last-year calendar | 110 calls (100 repos/page × ≤10 pages safety cap) |
| `contributionYearQuery` | Per-year calendar + seed list | 1 call per active year (typically 110) |
| `commitHistoryQuery` | Authored commits on default branch | 1 call per 100 commits per seed repo |
Typical run (8 active years, 30 seed repos, avg 50 commits each):
- profile: 1 call
- year loop: 8 calls
- commit history: 30 × 1 = 30 calls
- **≈ 39 GraphQL calls, 0 REST calls**
## Attribution model
Language attribution for the "most commit language" card is **byte-weighted**:
```
for each commit C in repo R:
total_bytes = Σ R.languages[*].bytes
for each (lang, bytes) in R.languages:
commits_by_lang[lang] += scaleFactor × bytes / total_bytes
```
Implementation in `internal/github/productive.go:attributeCommit`. `scaleFactor = 10_000` preserves fractional precision in int64 storage — percentages rendered in the card are unaffected by magnitude.
Known distortion: linguist excludes prose types (Markdown, AsciiDoc, reST) from byte counts. Blog-style repos with 95% Markdown and 5% JS still attribute all commits to JS. Future fix: per-commit REST file classification via `-accurate-languages` (see roadmap).
## SVG generation
Each card produces a self-contained SVG with:
- Card frame (rounded rect, theme background, theme stroke + opacity)
- Title (top-left, theme title color)
- Content layer (chart elements, text, legend)
Shared primitives:
- `renderDonutCard(title, stats, theme)` — pie slices via polar arc math + legend with color swatches
- `renderProductiveTime(title, hours, theme)` — 24 bars + both axes + tick math from `niceTicks`
- `renderContributions(title, days, theme)` — monthly aggregation, Catmull-Rom → cubic Bezier area path, two-sided Y axis
Catmull-Rom control-point math: for each segment `P_i → P_{i+1}`,
```
C1 = P_i + (P_{i+1} - P_{i-1}) / 6
C2 = P_{i+1} - (P_{i+2} - P_i) / 6
```
Tension = 0.5 (d3's default).
## Theme model
`theme.Theme` is a pure-data struct — no methods. Cards pull `t.Background`, `t.Text`, `t.Title`, `t.Accent`, `t.Muted`, `t.Stroke`, `t.StrokeOpacity`. The 61 palettes live in a map keyed by snake_case ID.
Light themes (`default`, `github`, `nord_bright`, etc.) use `StrokeOpacity: 1` with a visible stroke color; dark themes often use `StrokeOpacity: 0` or a stroke that blends into the background.
## Failure modes
| Fault | Behavior |
| --- | --- |
| Empty `-user` | Exit 2, usage printed |
| Unknown theme | Exit 2, suggests `-list-themes` |
| GraphQL 4xx/5xx | Error wrapped with HTTP status and truncated body |
| Rate limit | Bubbles up as a GraphQL error |
| `FetchProductive` network error | Warn to stderr; partial data rendered |
| Unknown timezone | Warn to stderr; fall back to UTC |
| User with 0 commits | Card renders "No data available" |
## Extension points
- **New card**: implement `Card` interface, add to `allCards` in `card.go`.
- **New theme**: add entry to `themes` map in `theme.go`.
- **New fetcher mode** (e.g., REST per-commit): add a new method on `*Client`, call from `main.go`, wire to new `Profile` fields.