mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-01 12:20:21 +00:00
Several recent code changes hadn't propagated to the docs:
- design-guidelines
* Card frame title row: document the 11–15 px auto-shrink (not a flat
15 px anymore).
* Donut Top-N: already 7 (updated earlier).
* Bar-chart section renamed to cover weekday + by-year too; document
the peak-vs-dim highlight convention and the niceTicks yMax ≥ max
invariant.
* Add Heatmap / Stat-column (streak) / List (top-starred) card
sections — they were missing entirely.
* Rewrite "Text overflow" from "we don't truncate" to the current
truth (truncate helper, formatTick abbreviations).
* Replace dangling `truncateName` reference with `truncate`.
- code-standards
* SVG output standards: call out truncate, formatTick abbreviation,
header auto-fit so the card-review gate reflects what the renderers
actually do.
- codebase-summary
* Layout tree: svg.go / axis.go comments list the helpers they now
contain; productive.go notes the weekday histogram.
* demo/ tree shows the index vs per-theme split.
* Data-flow diagram includes Weekday in the productive pass.
* Test coverage row lists TestCardsFitFrame / TestFitTitleFontSize /
TestNiceTicksCoversMax — the new invariant guards.
- system-architecture
* Shared primitives list adds renderWeekday and renderHeatmap; donut
blurb updated to "top 7".
* New "Chart-geometry invariants" block documents niceTicks ceiling,
formatTick abbreviation, header auto-fit.
- project-roadmap
* Phase 7.5 bullet updated to describe the index + per-theme demo
split (was single-README TOC).
143 lines
7.5 KiB
Markdown
143 lines
7.5 KiB
Markdown
# 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.
|
||
|
||
A root `context.Context` is built in `main.go` with an overall deadline (`-timeout`, default 30m) and cancelled on `SIGINT`/`SIGTERM`. Every fetcher and HTTP request inherits it so a slow run aborts cleanly instead of draining the 6h Action budget.
|
||
|
||
## Data-fetch sequence
|
||
|
||
```
|
||
main.go
|
||
│
|
||
▼
|
||
FetchProfile(ctx, 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(ctx, profile, opts)
|
||
│ contributionYearQuery × len(ContributionYears)
|
||
│ per year: totalCommitContributions +
|
||
│ contributionCalendar.weeks +
|
||
│ commitContributionsByRepository(maxRepositories: 100)
|
||
│ yields: SeedRepos (deduped),
|
||
│ DailyContributionsAllTime,
|
||
│ TotalCommitsAllTime
|
||
│
|
||
▼
|
||
FetchProductive(ctx, profile, profile.SeedRepos, loc, commitsPerRepo)
|
||
│ commitHistoryQuery × (#seeds × pages)
|
||
│ per commit: t = committedDate in loc
|
||
│ ProductiveAllTime[t.Hour]++
|
||
│ WeekdayAllTime[t.Weekday]++ + language votes
|
||
│ if t.After(yearAgo): Productive[t.Hour]++
|
||
│ Weekday[t.Weekday]++ + language votes
|
||
│ yields: Productive, Weekday, ProductiveAllTime, WeekdayAllTime,
|
||
│ 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 repo R:
|
||
total_bytes = Σ R.languages[*].bytes // precomputed once per repo
|
||
for each commit C in R:
|
||
for each (lang, bytes) in R.languages:
|
||
commits_by_lang[lang] += scaleFactor × bytes / total_bytes
|
||
```
|
||
|
||
Implementation in `internal/github/productive.go:attributeCommit`. The per-repo byte total is hoisted out of the commit loop so the hot path doesn't re-sum language edges for every commit. `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 (top 7 entries, rest collapse into "Other"). Single-slice case (one language at 100%) renders as two concentric `<circle>` elements instead of an arc, since SVG's `A` command from point P back to P draws nothing.
|
||
- `renderProductiveTime(title, hours, theme)` — 24 bars + both axes + tick math from `niceTicks`
|
||
- `renderWeekday(title, data, theme)` — 7-bar day-of-week chart mirroring the productive-time layout; peak bar uses `theme.Accent`, others `mixHex(Background, Accent, 0.55)`
|
||
- `renderHeatmap(title, days, theme)` — 7×53 calendar grid with a 5-bucket intensity ramp synthesised from `theme.Background → theme.Accent`
|
||
- `renderContributions(title, days, theme)` — monthly aggregation, Catmull-Rom → cubic Bezier area path, two-sided Y axis
|
||
|
||
Chart-geometry invariants:
|
||
- `niceTicks(max, 5)` rounds the top tick up to the next step (`last = ceil(max/step) × step`), guaranteeing `yMax ≥ dataMax` — bar heights can never exceed `chartH` and collide with the title row.
|
||
- `formatTick` abbreviates ≥ 1000 to `k` / `M` / `B` so y-axis labels never exceed 4 characters (`10000 → "10k"`, `1234567 → "1.2M"`); keeps the left gutter ≤ 28 px for every profile.
|
||
- `header()` picks the largest title font in [11, 15] px at which the string fits in `width − 24` at a 0.6 char-width estimate, so long titles like `Commits by Weekday (last year, UTC+7.00)` still fit the frame.
|
||
|
||
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 65 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 (UTF-8-safe) body |
|
||
| Primary rate limit (429 / 403 + remaining=0) | Sleep up to 5 min honoring `Retry-After` / `X-RateLimit-Reset`, retry once; longer windows surface as error |
|
||
| Per-year query returns nil user | Warn to stderr; other years still contribute |
|
||
| `FetchProductive` network error | Warn to stderr; partial data rendered |
|
||
| Unknown timezone | Warn to stderr; fall back to UTC |
|
||
| Overall timeout (`-timeout`) or Ctrl-C | `ctx` cancels in-flight requests; partial data may render |
|
||
| 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.
|