diff --git a/README.md b/README.md index 87f64e0..778ef67 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Automatically update your GitHub profile README with beautiful metrics about you - šŸ“… **Commit Patterns** - Visualize when you code most (time of day, day of week) - šŸ’» **Language Statistics** - Track programming languages across your repositories - ā±ļø **WakaTime Integration** - Display coding time, editors, projects and OS usage +- šŸ“ˆ **Coding Streak Tracker** - Track your coding consistency and streaks with WakaTime - šŸŽØ **Customizable** - Choose metrics and customize appearance - šŸ”„ **Auto-Updating** - Runs on schedule to keep your profile fresh - šŸš€ **Easy Setup** - Get started in 5 minutes @@ -83,7 +84,8 @@ jobs: uses: thanhhaudev/github-stats@master env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - SHOW_METRICS: "COMMIT_TIMES_OF_DAY,COMMIT_DAYS_OF_WEEK,LANGUAGE_PER_REPO" + WAKATIME_API_KEY: ${{ secrets.WAKATIME_API_KEY }} # Optional: for WakaTime metrics + SHOW_METRICS: "COMMIT_TIMES_OF_DAY,COMMIT_DAYS_OF_WEEK,LANGUAGE_PER_REPO,CODING_STREAK" ``` ### Step 6: Trigger the Action @@ -100,8 +102,36 @@ Choose which metrics to display by setting the `SHOW_METRICS` environment variab **Example:** ```yaml -SHOW_METRICS: "COMMIT_TIMES_OF_DAY,COMMIT_DAYS_OF_WEEK,LANGUAGE_PER_REPO" +SHOW_METRICS: "COMMIT_TIMES_OF_DAY,COMMIT_DAYS_OF_WEEK,LANGUAGE_PER_REPO,CODING_STREAK" ``` +### šŸ“ˆ `CODING_STREAK` + +Shows your coding streak and consistency metrics combining GitHub commit data with WakaTime statistics. + +**Requirements:** +- GitHub commit data (automatically collected) - **Required** +- `WAKATIME_API_KEY` (optional) - Adds coding time statistics + +**Example output (with WakaTime):** + +**šŸ“ˆ Coding Streak** +``` +šŸ”„ Current Streak: 14 days +šŸ† Longest Streak: 45 days +šŸ“Š Daily Average: 3 hrs 44 mins +šŸ’Ŗ Total Coding Time: 1,383 hrs 16 mins +šŸŽÆ Coding Consistency: 87.5% +šŸ“… Active Days: 128 days +``` + +**Example output (without WakaTime):** +``` +šŸ”„ Current Streak: 14 days +šŸ† Longest Streak: 45 days +``` + +> šŸ’” **Note:** Streaks are calculated from your GitHub commit history (consecutive days with at least one commit). The metric respects your `TIME_ZONE` setting for accurate day boundaries. Coding time and consistency metrics are only shown when WakaTime is configured. + ### šŸ•’ `COMMIT_TIMES_OF_DAY` @@ -270,7 +300,7 @@ env: WAKATIME_API_KEY: ${{ secrets.WAKATIME_API_KEY }} WAKATIME_DATA: "EDITORS,LANGUAGES,PROJECTS,OPERATING_SYSTEMS" WAKATIME_RANGE: "last_30_days" - SHOW_METRICS: "COMMIT_TIMES_OF_DAY,COMMIT_DAYS_OF_WEEK,LANGUAGE_PER_REPO,LANGUAGES_AND_TOOLS,WAKATIME_SPENT_TIME" + SHOW_METRICS: "COMMIT_TIMES_OF_DAY,COMMIT_DAYS_OF_WEEK,LANGUAGE_PER_REPO,LANGUAGES_AND_TOOLS,WAKATIME_SPENT_TIME,CODING_STREAK" SHOW_LAST_UPDATE: "true" ONLY_MAIN_BRANCH: "true" PROGRESS_BAR_VERSION: "2" diff --git a/go.mod b/go.mod index bcdb1ed..2b836b2 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/thanhhaudev/github-stats -go 1.22.6 +go 1.22 require github.com/joho/godotenv v1.5.1 // indirect diff --git a/pkg/container/calculator.go b/pkg/container/calculator.go index 61fc88c..c101209 100644 --- a/pkg/container/calculator.go +++ b/pkg/container/calculator.go @@ -2,7 +2,10 @@ package container import ( "fmt" + "sort" "time" + + "github.com/thanhhaudev/github-stats/pkg/github" ) // CommitStats stores the calculated commit data @@ -11,6 +14,8 @@ type CommitStats struct { YearlyCommits map[int]int DailyCommits map[time.Weekday]int QuarterlyCommits map[string]int + CurrentStreak int + LongestStreak int } // LanguageStats stores the calculated language data @@ -42,11 +47,16 @@ func (d *DataContainer) CalculateCommits() *CommitStats { totalCommits++ } + // Calculate streaks + currentStreak, longestStreak := calculateStreaks(d.Data.Commits) + return &CommitStats{ TotalCommits: totalCommits, YearlyCommits: yearlyCommits, DailyCommits: dailyCommits, QuarterlyCommits: quarterlyCommits, + CurrentStreak: currentStreak, + LongestStreak: longestStreak, } } @@ -79,3 +89,108 @@ func (d *DataContainer) CalculateLanguages() *LanguageStats { Languages: totalLanguages, } } + +// truncateToMidnight truncates a time to midnight in its local timezone +func truncateToMidnight(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +// calculateStreaks calculates the current and longest commit streaks +// A streak is defined as consecutive days with at least one commit +func calculateStreaks(commits []github.Commit) (currentStreak, longestStreak int) { + if len(commits) == 0 { + return 0, 0 + } + + // get timezone from the first commit + loc := commits[0].CommittedDate.Location() // use the timezone from TIME_ZONE env variable + + // create a map of unique commit dates + uniqueDates := make(map[string]time.Time) + for _, commit := range commits { + midnight := truncateToMidnight(commit.CommittedDate) + dateKey := midnight.Format("2006-01-02") + if _, exists := uniqueDates[dateKey]; !exists { + uniqueDates[dateKey] = midnight + } + } + + // convert map to sorted slice of dates + var dates []time.Time + for _, date := range uniqueDates { + dates = append(dates, date) + } + + // sort dates in descending order (most recent first) + sort.Slice(dates, func(i, j int) bool { + return dates[i].After(dates[j]) + }) + + if len(dates) == 0 { + return 0, 0 + } + + // calculate current streak from today backwards + now := time.Now().In(loc) + today := truncateToMidnight(now) + yesterday := today.AddDate(0, 0, -1) + + // Check if there's a commit today or yesterday to start the streak + currentStreak = 0 + + // Start from the most recent commit date (already at midnight) + mostRecentDate := dates[0] + if mostRecentDate.Equal(today) || mostRecentDate.Equal(yesterday) { + expectedDate := mostRecentDate + currentStreak = 1 // Count the first day + + for i := 1; i < len(dates); i++ { + currentDate := dates[i] // Already at midnight + + // move to next expected date + nextExpectedDate := expectedDate.AddDate(0, 0, -1) + + // check if this date is the next consecutive day + if currentDate.Equal(nextExpectedDate) { + currentStreak++ + expectedDate = nextExpectedDate + } else { + break // gap in streak + } + } + } + + // calculate longest streak + longestStreak = 0 + tempStreak := 1 + + for i := 0; i < len(dates)-1; i++ { + currentDate := dates[i] // Already at midnight + nextDate := dates[i+1] // Already at midnight + + // Check if dates are consecutive (1 day apart) + daysDiff := int(currentDate.Sub(nextDate).Hours() / 24) + + if daysDiff == 1 { + tempStreak++ + } else { + if tempStreak > longestStreak { + longestStreak = tempStreak + } + tempStreak = 1 + } + } + + // check the last streak + if tempStreak > longestStreak { + longestStreak = tempStreak + } + + // if current streak is longer than longest, update longest + if currentStreak > longestStreak { + longestStreak = currentStreak + } + + return currentStreak, longestStreak +} diff --git a/pkg/container/calculator_test.go b/pkg/container/calculator_test.go new file mode 100644 index 0000000..b61f747 --- /dev/null +++ b/pkg/container/calculator_test.go @@ -0,0 +1,102 @@ +package container + +import ( + "testing" + "time" + + "github.com/thanhhaudev/github-stats/pkg/github" +) + +func TestCalculateStreaks(t *testing.T) { + tests := []struct { + name string + commits []github.Commit + expectedCurrent int + expectedLongest int + description string + }{ + { + name: "empty commits", + commits: []github.Commit{}, + expectedCurrent: 0, + expectedLongest: 0, + description: "No commits should result in 0 streaks", + }, + { + name: "single commit today", + commits: []github.Commit{ + {CommittedDate: time.Now()}, + }, + expectedCurrent: 1, + expectedLongest: 1, + description: "Single commit today should have streak of 1", + }, + { + name: "consecutive days - current streak", + commits: []github.Commit{ + {CommittedDate: time.Now()}, + {CommittedDate: time.Now().AddDate(0, 0, -1)}, + {CommittedDate: time.Now().AddDate(0, 0, -2)}, + {CommittedDate: time.Now().AddDate(0, 0, -3)}, + }, + expectedCurrent: 4, + expectedLongest: 4, + description: "4 consecutive days should have current and longest streak of 4", + }, + { + name: "broken streak", + commits: []github.Commit{ + {CommittedDate: time.Now().AddDate(0, 0, -10)}, + {CommittedDate: time.Now().AddDate(0, 0, -11)}, + {CommittedDate: time.Now().AddDate(0, 0, -12)}, + }, + expectedCurrent: 0, + expectedLongest: 3, + description: "Old commits should have current streak 0 but longest streak 3", + }, + { + name: "multiple commits same day", + commits: []github.Commit{ + // Use noon to avoid crossing midnight when adding hours + {CommittedDate: time.Now().Truncate(24 * time.Hour).Add(12 * time.Hour)}, + {CommittedDate: time.Now().Truncate(24 * time.Hour).Add(13 * time.Hour)}, + {CommittedDate: time.Now().Truncate(24 * time.Hour).Add(14 * time.Hour)}, + {CommittedDate: time.Now().AddDate(0, 0, -1).Truncate(24 * time.Hour).Add(12 * time.Hour)}, + {CommittedDate: time.Now().AddDate(0, 0, -1).Truncate(24 * time.Hour).Add(15 * time.Hour)}, + }, + expectedCurrent: 2, + expectedLongest: 2, + description: "Multiple commits on same day should count as 1 day", + }, + { + name: "longest streak in the past", + commits: []github.Commit{ + {CommittedDate: time.Now()}, + {CommittedDate: time.Now().AddDate(0, 0, -1)}, + {CommittedDate: time.Now().AddDate(0, 0, -5)}, + {CommittedDate: time.Now().AddDate(0, 0, -6)}, + {CommittedDate: time.Now().AddDate(0, 0, -7)}, + {CommittedDate: time.Now().AddDate(0, 0, -8)}, + {CommittedDate: time.Now().AddDate(0, 0, -9)}, + }, + expectedCurrent: 2, + expectedLongest: 5, + description: "Current streak 2, but longest streak 5 in the past", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + current, longest := calculateStreaks(tt.commits) + + if current != tt.expectedCurrent { + t.Errorf("%s: Expected current streak %d, got %d", tt.description, tt.expectedCurrent, current) + } + + if longest != tt.expectedLongest { + t.Errorf("%s: Expected longest streak %d, got %d", tt.description, tt.expectedLongest, longest) + } + }) + } +} + diff --git a/pkg/container/container.go b/pkg/container/container.go index da26647..b146204 100644 --- a/pkg/container/container.go +++ b/pkg/container/container.go @@ -24,10 +24,11 @@ type DataContainer struct { ClientManager *ClientManager Logger *log.Logger Data struct { - Viewer *github.Viewer - Repositories []github.Repository - Commits []github.Commit - WakaTime *wakatime.Stats + Viewer *github.Viewer + Repositories []github.Repository + Commits []github.Commit + WakaTime *wakatime.Stats + WakaTimeAllTime *wakatime.AllTimeSinceTodayStats } } @@ -42,6 +43,7 @@ func (d *DataContainer) metrics(com *CommitStats, lang *LanguageStats) map[strin d.Data.WakaTime, strings.Split(os.Getenv("WAKATIME_DATA"), ","), ), + "CODING_STREAK": writer.MakeCodingStreakList(d.Data.WakaTimeAllTime, com.CurrentStreak, com.LongestStreak), } } @@ -308,6 +310,15 @@ func (d *DataContainer) InitWakaStats(ctx context.Context) error { d.Data.WakaTime = v + // fetch all-time stats for streak calculation + d.Logger.Println("Fetching WakaTime all-time statistics...") + allTimeStats, err := d.ClientManager.GetWakaTimeAllTimeSinceToday(ctx) + if err != nil { + d.Logger.Println("An error occurred while fetching WakaTime all-time data:", err) + } else { + d.Data.WakaTimeAllTime = allTimeStats + } + return nil } diff --git a/pkg/container/manager.go b/pkg/container/manager.go index 96a26d3..2a6e9b1 100644 --- a/pkg/container/manager.go +++ b/pkg/container/manager.go @@ -192,6 +192,16 @@ func (c *ClientManager) GetWakaTimeStats(ctx context.Context) (*wakatime.Stats, return stats, nil } +// GetWakaTimeAllTimeSinceToday returns the user's all-time coding statistics since today +func (c *ClientManager) GetWakaTimeAllTimeSinceToday(ctx context.Context) (*wakatime.AllTimeSinceTodayStats, error) { + stats, err := c.WakaTimeClient.Stats.GetAllTimeSinceToday(ctx) + if err != nil { + return nil, err + } + + return stats, nil +} + // NewClientManager creates a new ClientManager func NewClientManager(w *wakatime.WakaTime, g *github.GitHub) *ClientManager { return &ClientManager{w, g} diff --git a/pkg/wakatime/stats.go b/pkg/wakatime/stats.go index 59908c8..d9233dd 100644 --- a/pkg/wakatime/stats.go +++ b/pkg/wakatime/stats.go @@ -34,6 +34,28 @@ type Stats struct { } `json:"data"` } +type AllTimeSinceTodayStats struct { + Data struct { + TotalSeconds float64 `json:"total_seconds"` + Text string `json:"text"` + Decimal string `json:"decimal"` + Digital string `json:"digital"` + DailyAverage int `json:"daily_average"` + IsUpToDate bool `json:"is_up_to_date"` + PercentCalculated int `json:"percent_calculated"` + Range struct { + Start string `json:"start"` + StartDate string `json:"start_date"` + StartText string `json:"start_text"` + End string `json:"end"` + EndDate string `json:"end_date"` + EndText string `json:"end_text"` + Timezone string `json:"timezone"` + } `json:"range"` + Timeout int `json:"timeout"` + } `json:"data"` +} + type StatsRange string func (s StatsRange) IsValid() bool { @@ -71,3 +93,15 @@ func (s *StatsService) Get(ctx context.Context) (*Stats, error) { return &stats, nil } + +// GetAllTimeSinceToday retrieves the user's all-time coding statistics since today +func (s *StatsService) GetAllTimeSinceToday(ctx context.Context) (*AllTimeSinceTodayStats, error) { + var stats AllTimeSinceTodayStats + + err := s.Client.GetWithContext(ctx, "users/current/all_time_since_today", nil, &stats) + if err != nil { + return nil, err + } + + return &stats, nil +} diff --git a/pkg/writer/writer.go b/pkg/writer/writer.go index df2e5f8..61325e3 100644 --- a/pkg/writer/writer.go +++ b/pkg/writer/writer.go @@ -129,6 +129,53 @@ func MakeLastUpdatedOn(t string) string { return fmt.Sprintf("\n\nā³ *Last updated on %s*", t) } +// MakeCodingStreakList returns coding streak statistics from commit data and WakaTime all-time data +// Works with or without WakaTime data - shows commit streaks even if WakaTime is not configured +func MakeCodingStreakList(s *wakatime.AllTimeSinceTodayStats, currentStreak, longestStreak int) string { + if s == nil && currentStreak == 0 && longestStreak == 0 { + return "" // return empty when there's no streaks and no WakaTime data + } + + var b strings.Builder + b.WriteString("**šŸ“ˆ Coding Streak**\n\n") + b.WriteString("```text\n") + + // always show streak data from commits + b.WriteString(fmt.Sprintf("šŸ”„ Current Streak: %d days\n", currentStreak)) + b.WriteString(fmt.Sprintf("šŸ† Longest Streak: %d days\n", longestStreak)) + + // show WakaTime data if available + if s != nil { + // calculate daily average hours and minutes + dailyAvgHours := s.Data.DailyAverage / 3600 + dailyAvgMinutes := (s.Data.DailyAverage % 3600) / 60 + + // parse start and end dates to calculate total days + startDate, _ := time.Parse("2006-01-02", s.Data.Range.StartDate) + endDate, _ := time.Parse("2006-01-02", s.Data.Range.EndDate) + totalDays := int(endDate.Sub(startDate).Hours() / 24) + + var activeDays int + if s.Data.DailyAverage > 0 { + activeDays = int(s.Data.TotalSeconds / float64(s.Data.DailyAverage)) // calculate active days based on total seconds and daily average + } + + var consistencyPercent float64 + if totalDays > 0 { + consistencyPercent = (float64(activeDays) / float64(totalDays)) * 100 // calculate consistency percentage + } + + b.WriteString(fmt.Sprintf("šŸ“Š Daily Average: %d hrs %d mins\n", dailyAvgHours, dailyAvgMinutes)) + b.WriteString(fmt.Sprintf("šŸ’Ŗ Total Coding Time: %s\n", s.Data.Text)) + b.WriteString(fmt.Sprintf("šŸŽÆ Coding Consistency: %.1f%%\n", consistencyPercent)) + b.WriteString(fmt.Sprintf("šŸ“… Active Days: %s days\n", addCommas(activeDays))) + } + + b.WriteString("```\n\n") + + return b.String() +} + // MakeCommitTimesOfDayList returns a list of commits made during different times of the day func MakeCommitTimesOfDayList(commits []github.Commit) string { if len(commits) == 0 { diff --git a/pkg/writer/writer_test.go b/pkg/writer/writer_test.go new file mode 100644 index 0000000..f5a528f --- /dev/null +++ b/pkg/writer/writer_test.go @@ -0,0 +1,122 @@ +package writer + +import ( + "strings" + "testing" + + "github.com/thanhhaudev/github-stats/pkg/wakatime" +) + +func TestMakeCodingStreakList(t *testing.T) { + tests := []struct { + name string + stats *wakatime.AllTimeSinceTodayStats + currentStreak int + longestStreak int + expected []string // strings that should be present in output + shouldBeEmpty bool + }{ + { + name: "nil stats and no streaks returns empty string", + stats: nil, + currentStreak: 0, + longestStreak: 0, + expected: []string{}, + shouldBeEmpty: true, + }, + { + name: "works without WakaTime but with streaks", + stats: nil, + currentStreak: 7, + longestStreak: 30, + expected: []string{ + "šŸ“ˆ Coding Streak", + "šŸ”„ Current Streak:", + "šŸ† Longest Streak:", + "7 days", + "30 days", + }, + shouldBeEmpty: false, + }, + { + name: "valid stats with real streak data", + currentStreak: 14, + longestStreak: 45, + stats: &wakatime.AllTimeSinceTodayStats{ + Data: struct { + TotalSeconds float64 `json:"total_seconds"` + Text string `json:"text"` + Decimal string `json:"decimal"` + Digital string `json:"digital"` + DailyAverage int `json:"daily_average"` + IsUpToDate bool `json:"is_up_to_date"` + PercentCalculated int `json:"percent_calculated"` + Range struct { + Start string `json:"start"` + StartDate string `json:"start_date"` + StartText string `json:"start_text"` + End string `json:"end"` + EndDate string `json:"end_date"` + EndText string `json:"end_text"` + Timezone string `json:"timezone"` + } `json:"range"` + Timeout int `json:"timeout"` + }{ + TotalSeconds: 4979819.370848, + Text: "1,383 hrs 16 mins", + DailyAverage: 13437, // ~3.7 hours + Range: struct { + Start string `json:"start"` + StartDate string `json:"start_date"` + StartText string `json:"start_text"` + End string `json:"end"` + EndDate string `json:"end_date"` + EndText string `json:"end_text"` + Timezone string `json:"timezone"` + }{ + StartDate: "2024-08-01", + EndDate: "2025-12-21", + }, + }, + }, + expected: []string{ + "šŸ“ˆ Coding Streak", + "šŸ”„ Current Streak:", + "šŸ† Longest Streak:", + "šŸ“Š Daily Average:", + "šŸ’Ŗ Total Coding Time:", + "šŸŽÆ Coding Consistency:", + "šŸ“… Active Days:", + "1,383 hrs 16 mins", + "14 days", + "45 days", + }, + shouldBeEmpty: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MakeCodingStreakList(tt.stats, tt.currentStreak, tt.longestStreak) + + if tt.shouldBeEmpty { + if result != "" { + t.Errorf("Expected empty string, got: %s", result) + } + return + } + + for _, expected := range tt.expected { + if !strings.Contains(result, expected) { + t.Errorf("Expected output to contain '%s', but it didn't.\nGot: %s", expected, result) + } + } + + // Verify it contains the markdown code block + if !strings.Contains(result, "```text") { + t.Error("Expected output to contain markdown code block") + } + }) + } +} +