prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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 }