* feat(card): configurable start of week for heatmap + weekday cards
New -start-of-week CLI flag (and start_of_week action input) rotates the
contribution-heatmap rows and the productive-weekday bars so users whose
calendars start on Monday (or any other day) get matching output. Default
stays Sunday to preserve existing renders.
* docs: note start-of-week in design-guidelines, codebase-summary, deployment-guide
- design-guidelines: heatmap row order + productive-weekday bar order now derive from Profile.WeekStart
- codebase-summary: list new weekday_start_test.go cases + TestParseWeekday
- deployment-guide: mention start_of_week as an optional action input in the workflow template
Two cuts together keep the productive-time and productive-weekday
titles at the same 15 px the rest of the gallery uses:
1. utcOffsetLabel is now compact. Integer offsets drop the '.00'
padding ("UTC+7" vs the old "UTC+7.00") and non-integer offsets use
the colon form ("UTC+5:30", "UTC+5:45"). Saves 3 chars for the
common integer case, so "Commits by Hour (last year, UTC+7)" lands
at 34 chars — inside the 15 px budget.
2. weekdayTitle no longer embeds the UTC label. Day-of-week aggregates
by whole days; clock precision isn't informative there, and
dropping it shortens the title to 30 chars so the full 15 px lands
for every timezone.
Quarter-hour-zone users (Kathmandu UTC+5:45) see the hour title drop
to 14 px — 37 chars still exceeds the budget — but that's a rare
case and only 1 px off.
TestUTCOffsetLabel updated to the new format. TestFitTitleFontSize
pinned the new titles.
## Heatmap — two halves, 8x8 cells
The single-row 53-week layout could never hold square cells larger than
4x4 inside a 340 px card — cramped. Split the year in half (ceil(weeks/2)
on top, floor on bottom) and each half is ~27 weeks wide, freeing the
cells to be 8x8 (4x the area) while keeping comfortable left (30 px)
and right (~67 px) gutters. Grid:
topPadA = 45, half = 7*9 - 1 = 62 tall
halfGap = 13
topPadB = 120, same 62 tall
grid bottom at y = 182, 18 px frame margin
Year still reads top-to-bottom left-to-right, just with one extra line
break at the midpoint. Dropped the separate Less/More legend — at 8x8
the intensity gradient is self-explanatory, and the removal buys the
vertical space the new layout needs.
Refactored into a helper `renderHeatmapHalf` so the two halves share a
single code path (labels + month markers + cells).
## Font-size vocabulary
Four named constants in svg.go: fontBody=12, fontLabel=11, fontAxis=10,
fontBigNum=28. Every card's existing literals already sit on this
ladder except the heatmap, which was using 9 for weekday/month labels.
Heatmap now routes through fontAxis so the whole gallery shares one
size scale.
Other cards' literals weren't rewritten to reference the constants
(pure churn for no behavior change); the constants give future cards
the vocabulary and the design-guidelines the schema.
The 4x12 rectangular stretch from v1.2.3 read as "weird". Requirement
is square cells + comfortable side padding. Given the 53-week hard
constraint, those two requirements together pick the cell size:
53 * (size + gap) + leftPad + rightPad = 340
Candidates audited:
6 x 6, gap 1 → 371 wide, overflows the frame
5 x 5, gap 1 → 318 wide, only 11 px of total side padding ("very close")
5 x 5, gap 0 → 265 wide, cells touch (need a stroke to fake a gap)
4 x 4, gap 1 → 265 wide, 30 + 45 px gutters, real 1 px gaps ✓
3 x 3, gap 2 → 265 wide, cells become pinhead-sized
4 x 4 with a 1 px gap is the largest square that keeps breathing room
on both sides without any rendering trickery. Grid is 35 px tall;
there's leftover vertical space on the card but short beats "stretched
horizontal bands" visually. Bump topPad from 62 → 70 to offset the
grid slightly down from the title and reduce that apparent emptiness.
Legend swatches also revert to the same cellSize so the Less/More
bar matches the grid visually again.
Cells were 4 × 4 with 1 px gap: the grid used 35 of 140 available
vertical pixels (25 %) and ~80 px of dead space below. Widening the
cells isn't an option — 53 weeks already consume every horizontal
pixel past the weekday-label gutter. So stretch vertically instead:
cellW = 4 (unchanged) cellH = 12 cellGap = 1
Grid footprint is now 265 × 91 px inside a 340 × 200 frame, with a 32
px gap below the grid for the legend. Each weekday reads as a distinct
horizontal band instead of a cramped postage-stamp row.
Legend keeps 8 × 8 SQUARE swatches so the "Less ▢▢▢▢▢ More" row is
still recognisable as an intensity legend rather than a stretched echo
of the data cells. Label baseline offset switches from `cellSize - 1`
to `cellH - 3` so "Mon"/"Wed"/"Fri" sit visually centred in the taller
rows.
Two follow-ups:
1. "Top 7 languages" counts rows the reader sees, not "7 named + Other".
Revert collapseOther to the original semantic: when the input has more
than n entries, keep in[:n-1] named and fold the tail into a single
"Other" row — 7 rows total with topN=7. Input with ≤7 real languages
still passes through unchanged. Test and adversarial-profile updated
to pin the cap (6 named + Other, Shell/Kotlin/Java collapse).
2. Contributions heatmap grid was touching the card right edge
(leftPad 22 + 53*6 = 340 exactly). Shrink cells from 5×5 to 4×4 and
bump leftPad to 30, giving 45 px of right gutter. Grid no longer
reads as bleeding into the frame border; weekday label column also
gets a few more pixels of breathing room on the left.
With topN=7 the previous collapseOther kept only the first 6 entries
and added "Other" as the 7th row. A user expecting to see 7 actual
languages in the legend saw six named languages plus "Other" — the
exact complaint just raised about the profile repo's donut.
Flip the semantic: the "top N" slots are reserved for real languages,
and "Other" is an extra row when (and only when) there's a non-zero
tail past the Nth entry. Topologically that means up to 8 legend
rows — still fits the card frame (row 8 text baseline at y=195, card
height 200).
- TestDonutTopSevenPlusOther pins the new contract with a 9-language
input.
- adversarialProfile in TestCardsFitFrame bumped to 9 languages so
the stress test exercises the 8-row legend geometry.
- design-guidelines: the donut row re-reads "Up to 7 named languages,
plus an 'Other' row when the tail is non-zero (8 rows max)".
Legend at x=20-155 fits 7 rows (y=55 to y=175); donut at cx=250, cy=110
is unaffected. Update the design-guidelines table to match. Stress test
still passes — the legend column never leaves its left-side gutter.
The title fitter was correct but the loop-down-from-maxFont form hid
its intent. Switch to the direct expression:
ideal = floor(budget / (chars * 0.6))
clamp to [11, 15]
Same answer for every title; easier to verify at a glance. Hoist the
four constants (leftInset=20, rightSafety=4, minFont=11, maxFont=15,
charRatio=0.6) to package scope so the new unit test can reference
them without re-declaring.
Utilization audit for realistic dracula titles (width=340, budget=316):
Stats (5) → 15 px (14 %)
Top Starred Repos (17) → 15 px (48 %)
Most Commit Language (all time) (31) → 15 px (91 %)
Commits by Hour (last year, UTC+7.00) (37) → 14 px (98 %)
Commits by Weekday (last year, UTC+7.00) (40) → 13 px (99 %)
Commits by Weekday (last year, UTC+12.75) (41) → 12 px (93 %)
200-char pathological → 11 px (floor)
TestFitTitleFontSize pins this table so a future charRatio tweak can't
silently regress any real title. TestCardsFitFrame (the end-to-end
check) plus this unit test now cover both the geometry and the picked-
font-size paths.
When niceTicks picks a step that doesn't divide the data max, the last
returned tick was the highest step multiple ≤ max. Callers used it as
yMax, so any data point > lastTick rendered a bar > chartH that poked
above the chart top into the title area.
Concrete case from the dracula demo: max=625 with step=100 → ticks
[0, 100, 200, 300, 400, 500, 600], yMax=600, bar height for 625 =
110*(625/600) = 114.58 — 4.58 px past the chart top and right against
the card title.
Fix in niceTicks itself: round the top tick UP to the next step multiple
(`last = ceil(max/step) * step`), so 625 yields [..., 600, 700] and the
same 625 bar lands at 110*(625/700) ≈ 98.2 px, with a clean 12 px gap
to the title.
This is the stable answer to title-vs-bar collision: regardless of
which weekday (or year, or month, or hour) holds the peak, the chart
headroom is built into the axis instead of leaned on per-card. The
title auto-shrink from the previous fix still applies — that's for
literal text width, an orthogonal problem.
Add TestNiceTicksCoversMax covering the cases (625, 99, 101, 7, 49,
999, 1001) that would have silently regressed before.
productive-weekday's "Commits by Weekday (last year, UTC+7.00)" is 40
chars — at font-size 15 that renders ≈360 px wide, past the 340 px
frame. Before the previous stress-test run missed it because the
fixture left UTCOffsetLabel blank, so the title was the short
"Commits by Weekday (last year)" form that happens to fit.
Fix at the render layer instead of rewriting every long title:
header() now picks the largest font between 11 and 15 px at which the
title still fits in 316 px (card width − 20 left inset − 4 right
safety). 40-char titles land at 13 px; ≤ 35-char titles still render at
15 px as before. Readability floor is 11 px — far from the point at
which text becomes illegible, but a hard floor nevertheless.
Stress-test fixture now populates UTCOffsetLabel with "UTC+12.75"
(half-hour / quarter-hour zones make the widest title) so this class
of bug surfaces automatically from here on.
The stress test caught real overflows from profiles the author doesn't
have:
- Y-axis tick labels were raw integers. A user with 10,000+ yearly
contributions or 1000+ monthly commits would render "10000" / "1000000"
at text-anchor="end" against a ~28 px gutter — the digits spilled
leftward past x=0. formatTick now abbreviates: 999→"999", 1500→"1.5k",
12345→"12k", 1234567→"1.2M". No label exceeds 4 chars, all fit the
gutter for every card that uses niceTicks.
- Profile details dumped Company / Location / Website / title verbatim,
which works for the author (VNG, Ho Chi Minh, miti99.com) but not for
40-char strings. Each row truncates at 40 runes; the title truncates at
34. Uses a rune-aware truncate() helper hoisted out of top-starred-repos
into svg.go so every list-style card can share it.
- Streak date range collapses to a single-year form ("Jan 2 — Dec 31")
when start.Year() == end.Year() and to "YYYY — YYYY" across years. The
previous "Jan 2 — Dec 31, 2025" format at 10 px × 21 chars pushed past
the ~113 px column width.
The TestCardsFitFrame stress test was reading text-anchor and font-size
with a non-greedy regex that missed attributes whose position varied. It
now parses the opening <text> tag as a block and extracts each attribute
with its own regex, so text-anchor="end" / "middle" elements are no
longer false negatives. The check also estimates rendered width
(0.6 × font-size × len) and asserts the implied left/right edges stay in
the frame — catching exactly the class of bug the axis-tick case
represents.
Three cards overflowed the 340×200 frame for realistic profile data:
- contributions-heatmap: the classic case — 53 weeks at 9px cellSize + 2px
gap pushed the grid out to x≈611. Shrink to cellSize=5, cellGap=1 so
leftPad(22) + 53*6 = 340 (exact fit). Drop month labels within 20 px of
the right edge so "Dec"/"Apr" can't stick past the frame.
- streak: the third column rendered "N / M" at font-size 28, centered at
x=282. For 4+ digit totals (e.g. 584 / 3031) the text extended to x≈347.
Refactor to show the active-days integer by itself in the big slot and
push "of N total (P%)" into the small detail line that the other two
columns already use.
- top-starred-repos: the per-row star icon sat at x=306 while the right-
anchored number ended at x=334, so 5+ digit star counts collided with
the icon. Drop the icon (card title already says "Top Starred Repos"),
emit the count as "N ★", right-anchor at x=334 with a 6 px safety gap.
Add a new TestCardsFitFrame stress test that renders every card against an
adversarial profile (10-digit counts, 40-char names, 20 active years,
53-week span) and asserts every positional attribute stays inside the
frame. This is the automated half of the new "fit-the-frame invariant"
added to docs/design-guidelines.md + a pre-release review checklist in
docs/code-standards.md.
Bug reports will still surface text-overflow cases that the coordinate
check can't see (a text-anchor="middle" element has a single x attribute
but renders outward), so the docs also spell out the human-review step:
render dracula against tiny/typical/adversarial fixtures before release.
Five new cards, all derived from data FetchProductive / FetchProfile already
pull, so zero additional API calls:
- contributions-heatmap: 7×53 calendar grid with a 5-bucket intensity ramp
mixed from each theme's Background→Accent so palettes with no dedicated
heat ramp still render sensibly.
- streak: current streak, longest streak with date ranges, active/total days.
- contributions-by-year: one bar per active year, peak year highlighted.
- productive-weekday + -all-time: 7-bar day-of-week mirror of the hour-of-day
cards; FetchProductive now also fills Weekday / WeekdayAllTime histograms
during the same commit-history pass.
- top-starred-repos: top 5 owned non-fork repos by stargazer count; threads
Stars through RepoInfo.
Card count: 9 → 14. Registered in allCards grouped by recency (last-year
block, then all-time block). Render test extended to cover all new files
and realistic daily-series inputs.
This reverts commit 399a3dc. Repo stays as tiennm99/ghstats; the
Marketplace display name ("ghstats-cards" in action.yml) is the only
place the new name remains, since that field requires uniqueness on
the Marketplace.
plans/reports/* were added alongside the rename in the same commit
and are preserved by not deleting them in this revert.
Matches the Marketplace name; repo is being renamed in lockstep.
- go.mod module path: github.com/tiennm99/ghstats →
github.com/tiennm99/ghstats-cards
- Import paths across every .go file updated.
- README badges, install snippets, and the 'go install' line point
to the new URL/path.
- docs/deployment-guide.md workflow template, Docker image path, and
release edit URL updated.
Breaking for consumers pinned to the old URL; they need to swap
tiennm99/ghstats → tiennm99/ghstats-cards in workflows and switch
Docker pulls to ghcr.io/tiennm99/ghstats-cards. GitHub's HTTP
redirect covers git clones but GHCR does NOT redirect — users must
update image URIs manually.
- I4 — TestRenderAll now seeds Name/Company with XML-significant chars
that actually hit the render pipeline via cardTitle(). Previous test
checked Bio which is no longer rendered, so the assertion was vacuous.
- New TestDonutSingleSlice guards against the I1 empty-arc regression:
asserts the single-slice path emits <circle> primitives and not the
degenerate A-command path.
- New TestDonutEmpty covers the zero-stats fallback.
- New TestUTCOffsetLabel pins the UTC±N.NN format across UTC,
Asia/Saigon, Asia/Kolkata (half-hour), Asia/Kathmandu (quarter-hour).
Catches %+.2f regressions.
N8 — the field is set from contributionsCollection.contributionCalendar,
a rolling last-year window, not a lifetime total. Rename + updated
comment makes the semantics explicit so future readers don't confuse
it with TotalCommitsAllTime.
- I1 — donut chart with a single slice (100%) now renders via two
concentric <circle> elements instead of a degenerate SVG arc that
drew nothing. Reproduced with a standalone probe; regression test
added separately.
- I2 — FetchContributionsAllTime logs a warn to stderr when a year's
query returns nil user data so callers notice partial results
instead of rendering an empty all-time card silently.
- I6 — attributeCommit() receives the repo's byte total precomputed
once per repo rather than re-summing language edges for every
commit in the inner loop.
- I3 — update FetchOptions doc to describe zero-value vs CLI-flag defaults.
- I5 — release workflow gates docker/binaries on a test job; tags no
longer ship broken artifacts.
- N1 — replace handwritten joinErrs with strings.Join.
- N3 — truncate() now backs up to a UTF-8 rune boundary so error
messages never end on a split codepoint.
- N4 — pin Docker base images (golang:1.26-alpine, alpine:3.21) to
SHA256 digests.
- N5 — pin third-party GitHub Actions to commit SHAs with version
comments for readability.
- N9 — drop the "(non-fork)" qualifier from the stats card label; the
underlying GraphQL doesn't actually filter forks, so the phrasing
was misleading.
Files now land at output/<theme>/profile-details.svg etc., without
leading 0-8 prefixes. README authors embed cards by name, so the
lexicographic-sort rationale for the prefix no longer applies.
- All Filename() methods + the allCards ordering comment updated.
- Tests updated to expect the 9 unnumbered filenames.
- README, deployment-guide, codebase-summary, roadmap references
refreshed.
- Dracula sample SVGs regenerated under new names.
Replace the top-10-starred sampling with a seed list built from
contributionsCollection.commitContributionsByRepository, unioned across
every active contributionYear. Commit-history probes now land only on
repos where the user actually committed, covering all owned repos plus
forks and repos owned by others when allowed.
New visibility knobs (default off — public-facing READMEs stay
safe):
- -include-forks / include_forks : include forked repos
- -include-private / include_private: include private repos (requires
PAT with repo scope)
Compatibility:
- -top-repos default changed 10 → 0 (unlimited); still usable as a cap
for fast local runs.
- commitHistoryQuery now takes $owner so probes can target forks or
repos outside the user's ownership.
- FetchProfile now accepts FetchOptions; PublicRepos counts only repos
that pass the visibility filter.
Seed-list approach mirrors github-profile-summary-cards' own repo
sourcing but keeps our byte-weighted commit attribution.
Four time-bounded cards ("last year") now have all-time counterparts, and
the stats card gains a lifetime commits row.
New cards:
- 6-most-commit-language-all-time.svg (byte-weighted, all lifetime commits)
- 7-productive-time-all-time.svg (hour histogram over all lifetime commits)
- 8-contributions-all-time.svg (area chart spanning every active year)
Data pipeline:
- Drop the "since" filter from commitHistoryQuery; FetchProductive now
paginates unbounded commits and splits each commit into last-year and
all-time buckets in a single pass — no extra API calls.
- New contributionYearQuery iterates user.contributionYears to
concatenate calendar data and accumulate TotalCommitsAllTime.
- -commits-per-repo default bumped 100 → 500 to give all-time depth.
Polish:
- Productive-time title embeds the configured tz as "UTC±N.NN" (e.g.
UTC+7.00) on both last-year and all-time cards.
- Contribution x-axis flipped to mm/yy with an "mm/yy" footer caption
paralleling productive-time's "hour of day".
- Contribution x-axis label stride now targets ~6 labels regardless of
bucket count so the all-time chart (~100 months) stays readable while
the underlying curve still samples every month.
New 5-contributions.svg renders the last year's contribution calendar
as a monthly smooth-filled area chart. Pure Go SVG; no extra API calls
— one additional contributionCalendar.weeks block in the existing
profile GraphQL query carries the data.
- Y-axis mirrored on both sides with nice ticks.
- X-axis labels in YY/MM format, every other month to avoid overlap.
- Smooth curve via Catmull-Rom interpolation converted to cubic Bezier
(d3.curveCatmullRom default tension 0.5).
- Missing months between first and last are inserted as zero-count so
the chart stays time-continuous.
Replace the 2x3 grid with one row per stat: icon + label + right-aligned
value. Icons (star, commit, PR, issue, code-review, repos) mirror the
Octicons used by github-profile-summary-cards' stats card.
Each commit now contributes a full "vote" partitioned across the repo's
languages proportional to linguist byte counts, instead of crediting only
the primary language. A 60% Go / 40% Python repo adds 0.6 to Go and 0.4
to Python per commit.
- RepoInfo gains []LangEdge capturing the full byte breakdown already
returned by profileQuery.
- FetchProductive distributes each commit via a fixed-point scaleFactor
(int64 preserved, percentages unchanged in the card).
- Fallback to primary language only when linguist reports zero bytes
(empty repo).
Caveat: linguist excludes prose languages (Markdown, AsciiDoc, reST) from
its byte output, so Markdown-heavy repos still skew toward the detected
code fraction. Fixing that case requires per-commit file classification
via REST /commits/{sha} + go-enry — tracked as future work.
- Title becomes "login (Name)" (or just "login") instead of
"Name's Profile Details".
- Row labels replaced with Octicon glyphs (repos, company, location,
link, clock, people) rendered in the theme's muted color.
- Joined and account-age rows merge into a single "YYYY-MM-DD (N years
ago)" line; years round down to whole years with month/day fallbacks.
- Drop profile-summary-for-github from README credits — no code from
that project is used. github-profile-summary-cards is the only direct
reference.
- Replace the previous 30-theme list with the complete 61-theme palette
from github-profile-summary-cards (snake_case ids, e.g. github_dark,
nord_bright, solarized_dark).
- Add Stroke and StrokeOpacity fields to Theme; card frame now uses the
theme's own stroke instead of a hardcoded white overlay so light themes
render a visible border.
- Drop the github-readme-stats credit from README; all palette data now
comes from one upstream source.
- Productive time is now a 24-hour bar chart with axes and nice tick labels
instead of a 7x24 heatmap. Model Productive field reshaped from
[7][24]int to [24]int.
- Language cards render as donut charts with a left-side legend instead of
a stacked bar. Slices beyond top-6 collapse into an "Other" row.
- Add niceTicks helper (1/2/5 * 10^k ladder, d3-style) for axis ticks.
- Legacy language_bar.go removed.
Align card set with github-profile-summary-cards' 5-card layout:
0-profile-details.svg (unchanged)
1-repos-per-language.svg (new) owned repos grouped by primary language
2-most-commit-language.svg (new) last-year commits attributed to each repo's primary language
3-stats.svg (renumbered)
4-productive-time.svg (renumbered)
- FetchProductive now fills p.CommitsByLanguage from the same commit history
it uses for the heatmap, so no extra API calls are introduced.
- TopRepos carries primary language so productive-time can aggregate by lang.
- LangStat.Bytes renamed to Value (repo count or commit count, context-dependent).
- Shared bar+legend renderer extracted to language_bar.go.
- Ignore generated output/ directory.
- Add GraphQL client fetching profile, stats, language aggregation, and
per-repo commit histograms for the productive-time heatmap.
- Render real SVG cards (profile details, top languages, stats grid,
weekday×hour heatmap) with XML escaping and thousands-formatted numbers.
- Expand theme palette to 30 built-ins ported from github-readme-stats;
add -list-themes, multi-theme rendering, and 'all' shortcut.
- Package as Docker-based GitHub Action (action.yml, Dockerfile,
entrypoint.sh) with optional auto-commit of generated cards.
- Release workflow publishes GHCR image and cross-platform binaries on
v* tags.
- Unit tests cover rendering, XML escape, number formatting, language sort.