mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-13 16:14:56 +00:00
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:
1 parent
e8d9cc5900
commit
92387eddf5
13 files changed
+308
-24
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user