Files
ghstats/docs/system-architecture.md
T
tiennm99andGitHub 88397e80a3 docs: resync after card fixes — title auto-fit, truncate, abbreviated ticks (#15)
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).
2026-04-19 10:22:03 +07:00

143 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | 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 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.