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.
5.5 KiB
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 | 1–10 calls (100 repos/page × ≤10 pages safety cap) |
contributionYearQuery |
Per-year calendar + seed list | 1 call per active year (typically 1–10) |
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 swatchesrenderProductiveTime(title, hours, theme)— 24 bars + both axes + tick math fromniceTicksrenderContributions(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
Cardinterface, add toallCardsincard.go. - New theme: add entry to
themesmap intheme.go. - New fetcher mode (e.g., REST per-commit): add a new method on
*Client, call frommain.go, wire to newProfilefields.