wakatime.go (3521B)
1 package external 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "io" 7 "net/http" 8 "net/url" 9 "time" 10 11 "git.prime8.dev/moncheeta/prime8dev/internal/config" 12 "github.com/patrickmn/go-cache" 13 ) 14 15 const ( 16 wakaTimeTTL = time.Hour 17 wakaTimePendingTTL = time.Minute 18 wakaTimeKey = "wakatime:total" 19 wakaTimeStaleKey = "wakatime:total:stale" 20 ) 21 22 // WakaTime reads a user's all-time total from WakaTime's public, 23 // unauthenticated stats endpoint. The profile's stats must be public. 24 type WakaTime struct { 25 httpClient *http.Client 26 baseURL string 27 user string 28 cache *cache.Cache 29 } 30 31 func NewWakaTime(c *cache.Cache) *WakaTime { 32 return &WakaTime{ 33 httpClient: &http.Client{Timeout: 10 * time.Second}, 34 baseURL: "https://wakatime.com/api/v1", 35 user: config.WakaTimeUser, 36 cache: c, 37 } 38 } 39 40 type WakaTimeStats struct { 41 AllTime AllTimeStats 42 } 43 44 type AllTimeStats struct { 45 TotalText string 46 } 47 48 type publicStatsResponse struct { 49 Data struct { 50 Status string `json:"status"` 51 TotalSecondsIncludingOtherLanguage *float64 `json:"total_seconds_including_other_language"` 52 } `json:"data"` 53 } 54 55 // FetchTotal returns the all-time total ("3.4k hrs logged"). It returns nil, nil 56 // when no user is configured or while WakaTime is still computing the stats; 57 // on a failed fetch it serves the last good value when there is one. 58 func (s *WakaTime) FetchTotal() (*WakaTimeStats, error) { 59 if s.user == "" { 60 return nil, nil 61 } 62 63 if val, found := s.cache.Get(wakaTimeKey); found { 64 return val.(*WakaTimeStats), nil 65 } 66 67 seconds, ok, err := s.fetchAllTimeSeconds() 68 if err != nil { 69 if stale := s.stale(); stale != nil { 70 return stale, nil 71 } 72 return nil, err 73 } 74 if !ok { 75 // Still computing: keep showing the last good value (or nothing) and 76 // check again shortly. 77 stale := s.stale() 78 s.cache.Set(wakaTimeKey, stale, wakaTimePendingTTL) 79 return stale, nil 80 } 81 82 stats := &WakaTimeStats{AllTime: AllTimeStats{TotalText: formatTotalHours(seconds)}} 83 s.cache.Set(wakaTimeKey, stats, wakaTimeTTL) 84 s.cache.Set(wakaTimeStaleKey, stats, cache.NoExpiration) 85 return stats, nil 86 } 87 88 func (s *WakaTime) stale() *WakaTimeStats { 89 if val, found := s.cache.Get(wakaTimeStaleKey); found { 90 return val.(*WakaTimeStats) 91 } 92 return nil 93 } 94 95 // fetchAllTimeSeconds reports ok=false when the stats are not ready yet 96 // (202 Accepted, a non-ok status, or a missing total). 97 func (s *WakaTime) fetchAllTimeSeconds() (float64, bool, error) { 98 endpoint := fmt.Sprintf("%s/users/@%s/stats/all_time", s.baseURL, url.PathEscape(s.user)) 99 resp, err := s.httpClient.Get(endpoint) 100 if err != nil { 101 return 0, false, err 102 } 103 defer resp.Body.Close() 104 105 switch resp.StatusCode { 106 case http.StatusOK: 107 case http.StatusAccepted: 108 return 0, false, nil 109 default: 110 return 0, false, fmt.Errorf("wakatime API returned %d", resp.StatusCode) 111 } 112 113 body, err := io.ReadAll(resp.Body) 114 if err != nil { 115 return 0, false, err 116 } 117 var data publicStatsResponse 118 if err := json.Unmarshal(body, &data); err != nil { 119 return 0, false, err 120 } 121 122 total := data.Data.TotalSecondsIncludingOtherLanguage 123 if (data.Data.Status != "" && data.Data.Status != "ok") || total == nil || *total <= 0 { 124 return 0, false, nil 125 } 126 return *total, true, nil 127 } 128 129 func formatTotalHours(totalSeconds float64) string { 130 hours := int(totalSeconds / 3600) 131 if hours < 1000 { 132 return fmt.Sprintf("%d hrs logged", hours) 133 } 134 // Truncate, never round up: 3447 h reads 3.4k. 135 return fmt.Sprintf("%d.%dk hrs logged", hours/1000, hours%1000/100) 136 }