wakatime.go (5723B)
1 package external 2 3 import ( 4 "encoding/base64" 5 "encoding/json" 6 "fmt" 7 "io" 8 "net/http" 9 "os" 10 "strings" 11 "time" 12 13 "github.com/patrickmn/go-cache" 14 ) 15 16 type WakaTime struct { 17 httpClient *http.Client 18 baseURL string 19 apiKey string 20 cache *cache.Cache 21 } 22 23 func NewWakaTime(c *cache.Cache) *WakaTime { 24 return &WakaTime{ 25 httpClient: &http.Client{ 26 Timeout: 10 * time.Second, 27 }, 28 baseURL: "https://wakatime.com/api/v1", 29 apiKey: strings.TrimSpace(os.Getenv("WAKATIME_API_KEY")), 30 cache: c, 31 } 32 } 33 34 type WakaTimeStats struct { 35 AllTime AllTimeStats 36 Weekly WeeklyStats 37 } 38 39 func (s *WakaTime) FetchStats() (*WakaTimeStats, error) { 40 if s.apiKey == "" { 41 return nil, nil 42 } 43 44 cacheKey := "wakatime:stats" 45 46 if val, found := s.cache.Get(cacheKey); found { 47 return val.(*WakaTimeStats), nil 48 } 49 50 allTime, err := s.fetchAllTimeStats() 51 if err != nil { 52 return nil, err 53 } 54 55 weekly, err := s.fetchWeeklyStats() 56 if err != nil { 57 return nil, err 58 } 59 60 stats := &WakaTimeStats{ 61 AllTime: *allTime, 62 Weekly: *weekly, 63 } 64 65 s.cache.Set(cacheKey, stats, 5*time.Minute) 66 return stats, nil 67 } 68 69 type allTimeResponse struct { 70 Data struct { 71 TotalSeconds float64 `json:"total_seconds"` 72 } `json:"data"` 73 } 74 75 type languagesResponse struct { 76 Data struct { 77 DailyAverageIncludingOtherLanguage float64 `json:"daily_average_including_other_language"` 78 } `json:"data"` 79 } 80 81 type AllTimeStats struct { 82 TotalText string 83 DailyAverageText string 84 } 85 86 func (s *WakaTime) fetchAllTimeStats() (*AllTimeStats, error) { 87 cacheKey := "wakatime:alltime" 88 89 if val, found := s.cache.Get(cacheKey); found { 90 return val.(*AllTimeStats), nil 91 } 92 93 statsCh := make(chan *allTimeResponse, 1) 94 langCh := make(chan *languagesResponse, 1) 95 errCh := make(chan error, 2) 96 97 go func() { 98 resp, err := s.makeRequest("/users/current/all_time_since_today") 99 if err != nil { 100 errCh <- err 101 return 102 } 103 var data allTimeResponse 104 if err := json.Unmarshal(resp, &data); err != nil { 105 errCh <- err 106 return 107 } 108 statsCh <- &data 109 }() 110 111 go func() { 112 resp, err := s.makeRequest("/users/current/stats/last_year") 113 if err != nil { 114 errCh <- err 115 return 116 } 117 var data languagesResponse 118 if err := json.Unmarshal(resp, &data); err != nil { 119 errCh <- err 120 return 121 } 122 langCh <- &data 123 }() 124 125 var stats *allTimeResponse 126 var langStats *languagesResponse 127 128 for range 2 { 129 select { 130 case s := <-statsCh: 131 stats = s 132 case l := <-langCh: 133 langStats = l 134 case err := <-errCh: 135 return nil, err 136 } 137 } 138 139 totalSeconds := 0.0 140 if stats != nil { 141 totalSeconds = stats.Data.TotalSeconds 142 } 143 totalHours := int(totalSeconds / 3600) 144 totalText := fmt.Sprintf("%d+ hrs coded", totalHours) 145 146 dailyAvgText := "0 mins" 147 if langStats != nil && langStats.Data.DailyAverageIncludingOtherLanguage > 0 { 148 dailyAvgText = formatDuration(langStats.Data.DailyAverageIncludingOtherLanguage) 149 } 150 151 result := &AllTimeStats{ 152 TotalText: totalText, 153 DailyAverageText: dailyAvgText, 154 } 155 156 s.cache.Set(cacheKey, result, 5*time.Minute) 157 return result, nil 158 } 159 160 func formatDuration(seconds float64) string { 161 hours := int(seconds / 3600) 162 minutes := int((seconds - float64(hours)*3600) / 60) 163 164 if hours > 0 { 165 return fmt.Sprintf("%d hrs %d mins", hours, minutes) 166 } 167 return fmt.Sprintf("%d mins", minutes) 168 } 169 170 type summariesResponse struct { 171 Data []struct { 172 Range struct { 173 Date string `json:"date"` 174 } `json:"range"` 175 GrandTotal struct { 176 TotalSeconds float64 `json:"total_seconds"` 177 } `json:"grand_total"` 178 } `json:"data"` 179 } 180 181 type WeeklyStats struct { 182 TotalText string 183 Days []DailyEntry 184 } 185 186 type DailyEntry struct { 187 DayText string 188 DateText string 189 TimeText string 190 BarWidthPercent string 191 } 192 193 func (s *WakaTime) fetchWeeklyStats() (*WeeklyStats, error) { 194 cacheKey := "wakatime:weekly" 195 196 if val, found := s.cache.Get(cacheKey); found { 197 return val.(*WeeklyStats), nil 198 } 199 200 end := time.Now() 201 start := end.AddDate(0, 0, -6) 202 203 url := fmt.Sprintf("/users/current/summaries?start=%s&end=%s", 204 start.Format("2006-01-02"), 205 end.Format("2006-01-02")) 206 207 resp, err := s.makeRequest(url) 208 if err != nil { 209 return nil, err 210 } 211 212 var data summariesResponse 213 if err := json.Unmarshal(resp, &data); err != nil { 214 return nil, err 215 } 216 217 days := make([]DailyEntry, 0, len(data.Data)) 218 var totalSeconds float64 219 var maxMinutes = 1 220 minutesPerDay := make([]int, len(data.Data)) 221 222 for i, day := range data.Data { 223 date, _ := time.Parse("2006-01-02", day.Range.Date) 224 225 dayText := date.Weekday().String()[:3] 226 dateText := fmt.Sprintf("%s %d", date.Month().String()[:3], date.Day()) 227 228 seconds := day.GrandTotal.TotalSeconds 229 totalSeconds += seconds 230 231 minutes := int(seconds / 60) 232 minutesPerDay[i] = minutes 233 if minutes > maxMinutes { 234 maxMinutes = minutes 235 } 236 237 timeText := formatDuration(seconds) 238 239 days = append(days, DailyEntry{ 240 DayText: dayText, 241 DateText: dateText, 242 TimeText: timeText, 243 }) 244 } 245 246 for i := range days { 247 days[i].BarWidthPercent = fmt.Sprintf("%d%%", (minutesPerDay[i]*100)/maxMinutes) 248 } 249 250 result := &WeeklyStats{ 251 TotalText: formatDuration(totalSeconds), 252 Days: days, 253 } 254 255 s.cache.Set(cacheKey, result, 5*time.Minute) 256 return result, nil 257 } 258 259 func (s *WakaTime) makeRequest(path string) ([]byte, error) { 260 url := s.baseURL + path 261 262 req, err := http.NewRequest("GET", url, nil) 263 if err != nil { 264 return nil, err 265 } 266 267 auth := base64.StdEncoding.EncodeToString([]byte(s.apiKey)) 268 req.Header.Set("Authorization", fmt.Sprintf("Basic %s", auth)) 269 270 resp, err := s.httpClient.Do(req) 271 if err != nil { 272 return nil, err 273 } 274 defer resp.Body.Close() 275 276 if resp.StatusCode != http.StatusOK { 277 return nil, fmt.Errorf("wakatime API returned %d", resp.StatusCode) 278 } 279 280 return io.ReadAll(resp.Body) 281 }