diff --git a/README.md b/README.md index fcc90580..68ce5a13 100644 --- a/README.md +++ b/README.md @@ -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 (`//…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 | diff --git a/action.yml b/action.yml index 83baa990..5e484fd6 100644 --- a/action.yml +++ b/action.yml @@ -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 diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index ccaf9dc3..0e6d15b1 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -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. diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 31fe9824..cc492c98 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -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" diff --git a/docs/design-guidelines.md b/docs/design-guidelines.md index 211484e3..2be18254 100644 --- a/docs/design-guidelines.md +++ b/docs/design-guidelines.md @@ -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 (, UTC±H[:MM])` / `Commits by Weekday ()` — weekday drops the UTC so the title always fits at 15 px / `Contributions by Year` | | Hover | `HH:00 — N commits` / `Mon — N commits` / `YYYY — N commits` | +| 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 | `YYYY-MM-DD — N` per cell | diff --git a/entrypoint.sh b/entrypoint.sh index d373ec1d..bf12acf4 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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" \ diff --git a/internal/card/contributions_heatmap.go b/internal/card/contributions_heatmap.go index ed8614ab..0bc6eae1 100644 --- a/internal/card/contributions_heatmap.go +++ b/internal/card/contributions_heatmap.go @@ -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, ` %s`, @@ -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 { diff --git a/internal/card/productive_weekday.go b/internal/card/productive_weekday.go index 516adb5c..e5a85d31 100644 --- a/internal/card/productive_weekday.go +++ b/internal/card/productive_weekday.go @@ -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, ` %s — %d commits %s`, - 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) diff --git a/internal/card/weekday_start_test.go b/internal/card/weekday_start_test.go new file mode 100644 index 00000000..8df9aba8 --- /dev/null +++ b/internal/card/weekday_start_test.go @@ -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 LABEL 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 +// 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` trailer so we catch rendered labels + // and not the `` 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 +} diff --git a/internal/github/model.go b/internal/github/model.go index 497902d5..b4de013a 100644 --- a/internal/github/model.go +++ b/internal/github/model.go @@ -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. diff --git a/main.go b/main.go index ccce1979..80fde102 100644 --- a/main.go +++ b/main.go @@ -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 == "" { diff --git a/main_test.go b/main_test.go index 50d94960..59b538f6 100644 --- a/main_test.go +++ b/main_test.go @@ -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) { diff --git a/plans/260421-2028-start-of-week/plan.md b/plans/260421-2028-start-of-week/plan.md new file mode 100644 index 00000000..902d188a --- /dev/null +++ b/plans/260421-2028-start-of-week/plan.md @@ -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