Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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`

Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
115 changes: 115 additions & 0 deletions pkg/container/calculator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package container

import (
"fmt"
"sort"
"time"

"github.com/thanhhaudev/github-stats/pkg/github"
)

// CommitStats stores the calculated commit data
Expand All @@ -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
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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
}
102 changes: 102 additions & 0 deletions pkg/container/calculator_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}

19 changes: 15 additions & 4 deletions pkg/container/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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
}

Expand Down
10 changes: 10 additions & 0 deletions pkg/container/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading