feat(card): configurable start of week (#25)

* 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
This commit is contained in:
tiennm99 authored and GitHub committed 2026-04-21 20:57:21 +07:00
1 parent e8d9cc5900
commit 92387eddf5
13 files changed
+308 -24

No files matched your search

+2
View File
@@ -141,6 +141,7 @@ Then embed the cards in your `README.md`:
| `out` | `output` | Output directory |
| `themes` | `dracula` | Comma-separated theme ids, or `all` |
| `tz` | `UTC` | IANA tz for the productive-time card (e.g. `Asia/Saigon`) |
| `start_of_week` | `sunday` | First day of week for heatmap rows and weekday bars (`sunday`…`saturday`) |
| `top_repos` | `0` | Optional cap on seed repos probed for commit history (`0` = unlimited) |
| `commits_per_repo` | `500` | Max commits sampled per repo (covers last-year and all-time aggregates) |
| `include_forks` | `true` | Include forked repos in stats and commit probing |
@@ -179,6 +180,7 @@ ghstats -user tiennm99 -themes dracula,github_dark -tz Asia/Saigon -out output
| `-out` | `output` | Output directory (`<out>/<theme>/…svg`) |
| `-themes` | `dracula` | Comma-separated theme ids, or `all` |
| `-tz` | `Local` | IANA timezone for productive-time cards |
| `-start-of-week` | `sunday` | First day of week for heatmap rows and weekday bars (`sunday`…`saturday`) |
| `-top-repos` | `0` | Optional cap on seed repos probed (`0` = unlimited) |
| `-commits-per-repo` | `500` | Max commits sampled per repo |
| `-include-forks` | `true` | Include forked repos in the stats |
+4
View File
@@ -27,6 +27,10 @@ inputs:
description: IANA timezone for the productive-time heatmap (e.g. Asia/Saigon)
required: false
default: UTC
start_of_week:
description: First day of week for heatmap rows and weekday bars (sunday|monday|tuesday|…)
required: false
default: sunday
top_repos:
description: Optional cap on seed repos probed for commit history (0 = unlimited)
required: false
+3 -2
View File
@@ -31,7 +31,7 @@ ghstats/
│ │ ├── productive.go # productive-time (+ all-time)
│ │ ├── productive_weekday.go # productive-weekday (+ all-time)
│ │ ├── contributions.go # contributions (+ all-time)
│ │ ├── contributions_heatmap.go # contributions-heatmap (7×53 calendar grid)
│ │ ├── contributions_heatmap.go # contributions-heatmap (7×53 calendar grid; row 0 = Profile.WeekStart)
│ │ ├── contributions_by_year.go # contributions-by-year bar chart
│ │ ├── streak.go # streak (current/longest/active days)
│ │ ├── top_starred_repos.go # top-starred-repos bar list
@@ -110,8 +110,9 @@ commitHistoryQuery ──► Productive + Weekday + CommitsByLanguage (+ AllTime
## Test coverage
- `internal/card/card_test.go` — `RenderAll` produces 15 valid SVGs; XML escape through real render pipeline; `formatInt` cases; `TestDonutSingleSlice` / `TestDonutEmpty` (donut edge cases); `TestCardsFitFrame` (renders every card against an adversarial profile and asserts text + coordinates stay in the 340×200 frame); `TestFitTitleFontSize` (pins the auto-shrink table for every real title); `TestNiceTicksCoversMax` (guards the `yMax ≥ dataMax` invariant so bars can't overflow chartH).
- `internal/card/weekday_start_test.go` — `TestPadToWeekGridRotatesByWeekStart` (leading pad matches configured start day), `TestRenderWeekdayRespectsWeekStart` (bar order rotates with `WeekStart`), `TestRenderHeatmapLabelsRespectWeekStart` (heatmap row labels rotate accordingly).
- `internal/github/profile_test.go` — `sortLangStats` ordering and tiebreak.
- `main_test.go` — `TestUTCOffsetLabel` covers UTC, Asia/Saigon, half-hour (Kolkata), quarter-hour (Kathmandu) zones.
- `main_test.go` — `TestUTCOffsetLabel` covers UTC, Asia/Saigon, half-hour (Kolkata), quarter-hour (Kathmandu) zones. `TestParseWeekday` covers the `-start-of-week` input parsing.
No network-touching tests; real runs verified via `-token` + local build.
+1
View File
@@ -30,6 +30,7 @@ jobs:
token: ${{ secrets.GHSTATS_TOKEN }}
themes: dracula,github_dark,tokyonight
tz: Asia/Saigon
# start_of_week: monday # optional; default sunday — rotates heatmap rows + weekday bars
include_forks: "false"
include_private: "false"
commit_changes: "true"
+3 -1
View File
@@ -72,6 +72,7 @@ When there's **exactly one slice** (one language at 100%), the renderer emits tw
| Axis caption | "hour of day" bottom-center on productive-time; weekday / by-year omit the caption since the x labels are self-describing |
| Title format | `Commits by Hour (<window>, UTC±H[:MM])` / `Commits by Weekday (<window>)` — weekday drops the UTC so the title always fits at 15 px / `Contributions by Year` |
| Hover | `<title>HH:00 — N commits</title>` / `<title>Mon — N commits</title>` / `<title>YYYY — N commits</title>` |
| Bar order | `productive-weekday`: position 0 = `Profile.WeekStart` (set via `-start-of-week`, default Sunday); remaining bars rotate forward `(WeekStart+i) % 7`. Peak highlight tracks the drawn position so it stays aligned with the visible tallest bar. |
## Heatmap card (contributions-heatmap)
@@ -81,7 +82,8 @@ When there's **exactly one slice** (one language at 100%), the renderer emits tw
| Cell size | 8 × 8 px square, 1 px gap |
| Grid geometry | `leftPad 30`, `topPadA 45` (top half), `halfGap 13`, `topPadB 120`. Each half is 7 × 9 − 1 = 62 px tall. Grid bottom at y=182 leaves 18 px for the frame border. |
| Cell colour | 5-bucket ramp `mixHex(Background, Accent, k/4)` for `k ∈ 0..4` — no dedicated ramp field on the theme schema |
| Weekday labels | Mon / Wed / Fri only, right-anchored in the `leftPad` gutter |
| Weekday labels | Every other row (positions 1, 3, 5), right-anchored in the `leftPad` gutter. Label text is `weekdayShort[(int(WeekStart)+i) % 7]`, so Sunday-start renders Mon/Wed/Fri; Monday-start renders Tue/Thu/Sat. |
| Row order | Row 0 = `Profile.WeekStart` (`-start-of-week` flag; default `time.Sunday` matches GitHub's own calendar). `padToWeekGrid` rotates the leading blank pad to match. |
| Month labels | Printed above the first week where a 1st-of-month day falls; skipped when `x > width − 20` so `Dec` / `Apr` can't spill past the frame |
| Legend | "Less ▢▢▢▢▢ More" bottom-right |
| Hover | `<title>YYYY-MM-DD — N</title>` per cell |
+2
View File
@@ -11,6 +11,7 @@ token="${INPUT_TOKEN:-${GITHUB_TOKEN:-}}"
out="${INPUT_OUT:-output}"
themes="${INPUT_THEMES:-dracula}"
tz="${INPUT_TZ:-UTC}"
start_of_week="${INPUT_START_OF_WEEK:-sunday}"
top_repos="${INPUT_TOP_REPOS:-0}"
commits_per_repo="${INPUT_COMMITS_PER_REPO:-500}"
include_forks="${INPUT_INCLUDE_FORKS:-true}"
@@ -35,6 +36,7 @@ ghstats \
-out "$out" \
-themes "$themes" \
-tz "$tz" \
-start-of-week "$start_of_week" \
-top-repos "$top_repos" \
-commits-per-repo "$commits_per_repo" \
-include-forks="$include_forks" \
+21 -11
View File
@@ -14,7 +14,7 @@ type contributionsHeatmapCard struct{}
func (contributionsHeatmapCard) Filename() string { return "contributions-heatmap.svg" }
func (contributionsHeatmapCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
return renderHeatmap("Contributions (last year)", p.DailyContributions, t), nil
return renderHeatmap("Contributions (last year)", p.DailyContributions, p.WeekStart, t), nil
}
// renderHeatmap draws the 53-week contribution calendar as two stacked
@@ -24,7 +24,8 @@ func (contributionsHeatmapCard) SVG(p *github.Profile, t theme.Theme) ([]byte, e
// and distinctly more readable, while the year still reads top-to-bottom
// left-to-right. Cell color mixes theme.Background with theme.Accent in
// four intensity buckets so every palette inherits a usable heatmap.
func renderHeatmap(title string, days []github.DailyContribution, t theme.Theme) []byte {
// weekStart controls which weekday sits on row 0 (default time.Sunday).
func renderHeatmap(title string, days []github.DailyContribution, weekStart time.Weekday, t theme.Theme) []byte {
const (
width = 340
height = 200
@@ -47,7 +48,7 @@ func renderHeatmap(title string, days []github.DailyContribution, t theme.Theme)
return []byte(b.String())
}
cells := padToWeekGrid(days)
cells := padToWeekGrid(days, weekStart)
weeks := len(cells) / 7
buckets := intensityThresholds(cells)
@@ -72,7 +73,7 @@ func renderHeatmap(title string, days []github.DailyContribution, t theme.Theme)
}
for _, h := range halves {
renderHeatmapHalf(&b, cells, h.startWeek, h.endWeek, h.topPad, leftPad, cellSize, cellGap, ramp, buckets, t)
renderHeatmapHalf(&b, cells, h.startWeek, h.endWeek, h.topPad, leftPad, cellSize, cellGap, ramp, buckets, weekStart, t)
}
b.WriteString(footer)
@@ -81,12 +82,14 @@ func renderHeatmap(title string, days []github.DailyContribution, t theme.Theme)
// renderHeatmapHalf draws one half of the heatmap: weekday labels on the
// left, month labels above, and the 7×(endWeek-startWeek) grid itself.
func renderHeatmapHalf(b *strings.Builder, cells []github.DailyContribution, startWeek, endWeek, topPad, leftPad, cellSize, cellGap int, ramp [5]string, buckets [4]int, t theme.Theme) {
// Weekday labels (Mon/Wed/Fri) anchored to the right of the gutter.
for i, label := range [7]string{"", "Mon", "", "Wed", "", "Fri", ""} {
if label == "" {
// Labels are printed on odd rows (1, 3, 5) so the 3-per-column cadence
// matches GitHub's own calendar regardless of which weekday starts the week.
func renderHeatmapHalf(b *strings.Builder, cells []github.DailyContribution, startWeek, endWeek, topPad, leftPad, cellSize, cellGap int, ramp [5]string, buckets [4]int, weekStart time.Weekday, t theme.Theme) {
for i := 0; i < 7; i++ {
if i%2 == 0 {
continue
}
label := weekdayShort[(int(weekStart)+i)%7]
y := topPad + i*(cellSize+cellGap) + cellSize - 1
fmt.Fprintf(b, `
<text x="%d" y="%d" font-size="%d" fill="%s" text-anchor="end">%s</text>`,
@@ -132,12 +135,14 @@ func renderHeatmapHalf(b *strings.Builder, cells []github.DailyContribution, sta
}
// padToWeekGrid prepends zero-date slots so the returned slice is a clean
// weeks×7 grid starting on Sunday (index 0 = Sun, 6 = Sat).
func padToWeekGrid(days []github.DailyContribution) []github.DailyContribution {
// weeks×7 grid where row 0 corresponds to the configured weekStart. For
// example, with weekStart = time.Monday, a series beginning on a Thursday
// gets 3 leading blanks so row 0 stays Monday.
func padToWeekGrid(days []github.DailyContribution, weekStart time.Weekday) []github.DailyContribution {
if len(days) == 0 {
return nil
}
offset := int(days[0].Date.Weekday())
offset := (int(days[0].Date.Weekday()) - int(weekStart) + 7) % 7
grid := make([]github.DailyContribution, offset+len(days))
copy(grid[offset:], days)
// Round trailing remainder up to a full week so the grid is rectangular.
@@ -147,6 +152,11 @@ func padToWeekGrid(days []github.DailyContribution) []github.DailyContribution {
return grid
}
// weekdayShort is the 3-letter weekday name at index = int(time.Weekday).
// Shared by the heatmap row labels and the productive-weekday bars so the
// two cards agree on spelling.
var weekdayShort = [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
// intensityThresholds picks four cutoffs from the non-zero counts so cells
// distribute across the 5-bucket ramp. Quartile-ish without a sort cost.
func intensityThresholds(cells []github.DailyContribution) [4]int {
+20 -10
View File
@@ -3,6 +3,7 @@ package card
import (
"fmt"
"strings"
"time"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
@@ -13,7 +14,7 @@ type productiveWeekdayCard struct{}
func (productiveWeekdayCard) Filename() string { return "productive-weekday.svg" }
func (productiveWeekdayCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
return renderWeekday(weekdayTitle("last year"), p.Weekday, t), nil
return renderWeekday(weekdayTitle("last year"), p.Weekday, p.WeekStart, t), nil
}
type productiveWeekdayAllTimeCard struct{}
@@ -21,7 +22,7 @@ type productiveWeekdayAllTimeCard struct{}
func (productiveWeekdayAllTimeCard) Filename() string { return "productive-weekday-all-time.svg" }
func (productiveWeekdayAllTimeCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
return renderWeekday(weekdayTitle("all time"), p.WeekdayAllTime, t), nil
return renderWeekday(weekdayTitle("all time"), p.WeekdayAllTime, p.WeekStart, t), nil
}
// weekdayTitle skips the UTC offset — the data aggregates into day-of-week
@@ -31,12 +32,11 @@ func weekdayTitle(window string) string {
return "Commits by Weekday (" + window + ")"
}
// Index 0 = Sunday to match time.Weekday (which is what FetchProductive stores).
var weekdayLabels = [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
// renderWeekday draws a 7-bar chart: one bar per weekday. Reuses the same
// axis math as the hour-of-day card so the two feel like a matched pair.
func renderWeekday(title string, data [7]int, t theme.Theme) []byte {
// data is always indexed 0=Sun..6=Sat (raw time.Weekday); bar order is
// rotated so position 0 corresponds to weekStart.
func renderWeekday(title string, data [7]int, weekStart time.Weekday, t theme.Theme) []byte {
const (
width = 340
height = 200
@@ -52,9 +52,17 @@ func renderWeekday(title string, data [7]int, t theme.Theme) []byte {
var b strings.Builder
b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, title))
// rotated[i] is the weekday index (0..6 time.Weekday) rendered at
// position i along the x-axis. Peak is tracked in position space so the
// highlight lines up with the actual drawn bar.
var rotated [7]int
for i := 0; i < 7; i++ {
rotated[i] = (int(weekStart) + i) % 7
}
max := 0
peak := 0
for i, v := range data {
for i := 0; i < 7; i++ {
v := data[rotated[i]]
if v > max {
max = v
peak = i
@@ -91,7 +99,9 @@ func renderWeekday(title string, data [7]int, t theme.Theme) []byte {
// busiest day reads at a glance.
dim := mixHex(t.Background, t.Accent, 0.55)
for i := 0; i < 7; i++ {
count := data[i]
wd := rotated[i]
count := data[wd]
label := weekdayShort[wd]
barH := float64(chartH) * float64(count) / yMax
x := float64(leftAxis) + (barW+float64(barGap))*float64(i)
y := float64(topPad+chartH) - barH
@@ -102,8 +112,8 @@ func renderWeekday(title string, data [7]int, t theme.Theme) []byte {
fmt.Fprintf(&b, `
<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="2" fill="%s"><title>%s — %d commits</title></rect>
<text x="%.2f" y="%d" font-size="10" fill="%s" text-anchor="middle">%s</text>`,
x, y, barW, barH, fill, weekdayLabels[i], count,
x+barW/2, topPad+chartH+14, t.Muted, weekdayLabels[i])
x, y, barW, barH, fill, label, count,
x+barW/2, topPad+chartH+14, t.Muted, label)
}
b.WriteString(footer)
+148
View File
@@ -0,0 +1,148 @@
package card
import (
"strings"
"testing"
"time"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
// TestPadToWeekGridRotatesByWeekStart checks that the leading blank pad
// matches the configured start day — with weekStart=Monday, a Thursday-first
// series needs 3 leading zero-date slots so row 0 stays Monday.
func TestPadToWeekGridRotatesByWeekStart(t *testing.T) {
// Thursday 2025-01-02 — int(Weekday)=4.
thu := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)
days := []github.DailyContribution{{Date: thu, Count: 1}}
cases := []struct {
name string
weekStart time.Weekday
wantOffset int
}{
{"Sunday start", time.Sunday, 4}, // Thu is row 4 of Sun..Sat
{"Monday start", time.Monday, 3}, // Thu is row 3 of Mon..Sun
{"Thursday start", time.Thursday, 0},
{"Friday start", time.Friday, 6},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
grid := padToWeekGrid(days, c.weekStart)
// The first real day should land at wantOffset; slots before it
// carry a zero Date.
if grid[c.wantOffset].Date != thu {
t.Fatalf("weekStart=%v: expected Thu at offset %d, got %v", c.weekStart, c.wantOffset, grid[c.wantOffset].Date)
}
for i := 0; i < c.wantOffset; i++ {
if !grid[i].Date.IsZero() {
t.Errorf("weekStart=%v: slot %d should be blank, got %v", c.weekStart, i, grid[i].Date)
}
}
if len(grid)%7 != 0 {
t.Errorf("weekStart=%v: grid len %d not a multiple of 7", c.weekStart, len(grid))
}
})
}
}
// TestRenderWeekdayRespectsWeekStart asserts bar order follows weekStart.
// Saturday (6) has the peak — with Monday start, Saturday lands at position 5,
// which should carry the accent fill.
func TestRenderWeekdayRespectsWeekStart(t *testing.T) {
th, _ := theme.Lookup("dracula")
var data [7]int
data[time.Saturday] = 99 // peak
sunSVG := string(renderWeekday("t", data, time.Sunday, th))
monSVG := string(renderWeekday("t", data, time.Monday, th))
// Label positions: with Sunday start, "Sun" is the first label; with
// Monday start, "Mon" is the first label. Check the order of labels
// by scanning the <text …>LABEL</text> occurrences in the SVG.
sunOrder := extractWeekdayLabels(sunSVG)
wantSun := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
if !equalStrings(sunOrder, wantSun) {
t.Errorf("Sunday-start label order = %v, want %v", sunOrder, wantSun)
}
monOrder := extractWeekdayLabels(monSVG)
wantMon := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
if !equalStrings(monOrder, wantMon) {
t.Errorf("Monday-start label order = %v, want %v", monOrder, wantMon)
}
}
// TestRenderHeatmapLabelsRespectWeekStart asserts the left-gutter weekday
// labels rotate with weekStart (rows 1,3,5 get labels).
func TestRenderHeatmapLabelsRespectWeekStart(t *testing.T) {
th, _ := theme.Lookup("dracula")
base := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
var days []github.DailyContribution
for i := 0; i < 30; i++ {
days = append(days, github.DailyContribution{Date: base.AddDate(0, 0, i), Count: i % 3})
}
cases := []struct {
name string
weekStart time.Weekday
want []string // labels expected at rows 1, 3, 5
}{
{"Sunday start", time.Sunday, []string{"Mon", "Wed", "Fri"}},
{"Monday start", time.Monday, []string{"Tue", "Thu", "Sat"}},
{"Saturday start", time.Saturday, []string{"Sun", "Tue", "Thu"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
svg := string(renderHeatmap("t", days, c.weekStart, th))
for _, label := range c.want {
// Row labels use text-anchor="end"; pick that form so we
// don't collide with month labels along the top.
needle := `text-anchor="end">` + label
if !strings.Contains(svg, needle) {
t.Errorf("weekStart=%v: missing row label %q", c.weekStart, label)
}
}
})
}
}
// extractWeekdayLabels pulls weekday 3-letter names out of <text …>…</text>
// blocks in the order they appear. Good enough for bar-chart cards that emit
// exactly 7 weekday labels and no colliding strings.
func extractWeekdayLabels(svg string) []string {
names := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
var out []string
rest := svg
for {
idx := -1
var pick string
for _, n := range names {
// Match the `>Label</text>` trailer so we catch rendered labels
// and not the `<title>` tooltip text (which uses "—").
needle := ">" + n + "</text>"
i := strings.Index(rest, needle)
if i >= 0 && (idx == -1 || i < idx) {
idx = i
pick = n
}
}
if idx == -1 {
return out
}
out = append(out, pick)
rest = rest[idx+len(pick)+len("</text>")+1:]
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+7
View File
@@ -83,6 +83,13 @@ type Profile struct {
// display on time-based cards (e.g. "UTC+7.00" for Asia/Saigon). Filled
// by the CLI after loading -tz.
UTCOffsetLabel string
// WeekStart is the weekday used as row 0 on the heatmap and the first
// bar on the productive-weekday chart. Defaults to time.Sunday to match
// GitHub's own contribution calendar; -start-of-week on the CLI can flip
// it to time.Monday (or any other weekday) without touching the data —
// only the presentation order changes.
WeekStart time.Weekday
}
// DailyContribution is a single day in the contributions calendar.
+31
View File
@@ -28,6 +28,7 @@ func main() {
includeForks = flag.Bool("include-forks", true, "include forked repos in stats and commit probing")
includePrivate = flag.Bool("include-private", true, "include private repos (requires PAT with repo scope; silently no-op otherwise)")
timeout = flag.Duration("timeout", 30*time.Minute, "overall deadline for fetch phase (0 = no limit)")
startOfWeek = flag.String("start-of-week", "sunday", "first day of week for heatmap rows and weekday bars (sunday|monday|tuesday|…)")
listThemes = flag.Bool("list-themes", false, "print available theme ids and exit")
)
flag.Parse()
@@ -57,6 +58,12 @@ func main() {
loc = time.UTC
}
weekStart, err := parseWeekday(*startOfWeek)
if err != nil {
fmt.Fprintf(os.Stderr, "warn: %v, falling back to Sunday\n", err)
weekStart = time.Sunday
}
opts := github.FetchOptions{
IncludeForks: *includeForks,
IncludePrivate: *includePrivate,
@@ -84,6 +91,7 @@ func main() {
os.Exit(1)
}
profile.UTCOffsetLabel = utcOffsetLabel(loc)
profile.WeekStart = weekStart
// Year-loop fetch populates SeedRepos from commitContributionsByRepository
// plus the all-time contribution calendar; must precede FetchProductive so
@@ -136,6 +144,29 @@ func utcOffsetLabel(loc *time.Location) string {
return fmt.Sprintf("UTC%s%d:%02d", sign, hours, minutes)
}
// parseWeekday maps a case-insensitive English weekday name (full or 3-letter)
// to time.Weekday. Empty input → Sunday so a blank action input still works.
func parseWeekday(s string) (time.Weekday, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "sun", "sunday":
return time.Sunday, nil
case "mon", "monday":
return time.Monday, nil
case "tue", "tuesday":
return time.Tuesday, nil
case "wed", "wednesday":
return time.Wednesday, nil
case "thu", "thursday":
return time.Thursday, nil
case "fri", "friday":
return time.Friday, nil
case "sat", "saturday":
return time.Saturday, nil
default:
return time.Sunday, fmt.Errorf("unknown start-of-week %q", s)
}
}
func resolveThemes(spec string) ([]theme.Theme, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
+29
View File
@@ -6,6 +6,35 @@ import (
"time"
)
// TestParseWeekday covers the common case-insensitive inputs and confirms
// an unknown value errors (main.go then falls back to Sunday with a warn).
func TestParseWeekday(t *testing.T) {
ok := []struct {
in string
want time.Weekday
}{
{"", time.Sunday},
{"sunday", time.Sunday},
{"SUNDAY", time.Sunday},
{"Sun", time.Sunday},
{" monday ", time.Monday},
{"Mon", time.Monday},
{"saturday", time.Saturday},
}
for _, c := range ok {
got, err := parseWeekday(c.in)
if err != nil {
t.Errorf("parseWeekday(%q) err=%v", c.in, err)
}
if got != c.want {
t.Errorf("parseWeekday(%q)=%v want %v", c.in, got, c.want)
}
}
if _, err := parseWeekday("moonday"); err == nil {
t.Error("parseWeekday(moonday): want error, got nil")
}
}
// TestUTCOffsetLabel checks the compact format: integer hours drop the
// minutes suffix, non-zero offsets render as `UTC±H:MM`.
func TestUTCOffsetLabel(t *testing.T) {
+37
View File
@@ -0,0 +1,37 @@
# Configurable start-of-week
## Goal
Let users pick which weekday the contribution heatmap rows and productive-weekday bars start on. Default stays Sunday for back-compat with GitHub's own calendar.
## Surface
- CLI: `-start-of-week sunday|monday` (default `sunday`)
- Action input: `start_of_week` (default `sunday`)
- Accept case-insensitive full name; anything else → warn and fall back to Sunday (mirrors existing `-tz` behaviour)
Scope intentionally limited to Sun/Mon — the two real-world cases. Re-open if someone asks for Tue/Wed/etc.
## Code touchpoints
| File | Change |
|------|--------|
| `main.go` | parse `-start-of-week`, set `Profile.WeekStart` |
| `internal/github/model.go` | add `WeekStart time.Weekday` |
| `internal/card/contributions_heatmap.go` | rotate `padToWeekGrid` offset + weekday labels using `WeekStart` |
| `internal/card/productive_weekday.go` | reorder bars and labels using `WeekStart` |
| `action.yml` + `entrypoint.sh` | new input plumbed through |
| `README.md` | document |
| tests | weekday parse + rotation grid |
Streak card is count-based — untouched.
## Phases
1. Wire flag + profile field + action plumbing
2. Heatmap rotation
3. Weekday rotation
4. Tests
5. README
## Success
- `go build ./...` clean
- `go test ./...` passes (existing + new)
- Running with `-start-of-week monday` visibly rotates row 0 of the heatmap to Mon and moves Sun to row 6
- Default behaviour (no flag) identical to current output