Files
ghstats/docs/system-architecture.md
T
tiennm99 734ac21be9 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

7.5 KiB
Raw Blame History

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.