commit 74d5904e55ac6fc49716383eca1b6f9f23136913
parent cfde566beecdf2785bc50a2df0c6cdd170cd767b
Author: Damian Myrda <damian@prime8.dev>
Date: Wed, 21 Oct 2026 18:25:21 +0000
feat(wakatime): use the public stats api, drop api key
Read the all-time total from the unauthenticated /users/@<user>/stats/all_time endpoint. WakaTimeUser replaces WakaTimeEnabled; empty disables the feature. Removes the token/API key handling and the auth-only weekly breakdown.
Diffstat:
17 files changed, 217 insertions(+), 650 deletions(-)
diff --git a/cmd/server/handlers/wakatime.go b/cmd/server/handlers/wakatime.go
@@ -3,16 +3,10 @@ package handlers
import (
"encoding/json"
"net/http"
-
- "git.prime8.dev/moncheeta/prime8dev/internal/config"
)
func (h *Handler) WakaTimeHandler(w http.ResponseWriter, r *http.Request) {
- fetch := h.wakaTimeService.FetchTotal
- if config.WakaTimeWeeklyEnabled {
- fetch = h.wakaTimeService.FetchStats
- }
- stats, err := fetch()
+ stats, err := h.wakaTimeService.FetchTotal()
if err != nil {
http.Error(w, "Failed to fetch stats", http.StatusInternalServerError)
return
diff --git a/cmd/server/main.go b/cmd/server/main.go
@@ -57,7 +57,7 @@ func run() error {
r.Get("/api/projects/{id}", h.ProjectDetailHandler)
r.Get("/api/fs", h.FSHandler)
- if config.WakaTimeEnabled {
+ if config.WakaTimeUser != "" {
r.Get("/api/wakatime", h.WakaTimeHandler)
}
diff --git a/env.example b/env.example
@@ -5,5 +5,3 @@ TERM=xterm-256color
COLORTERM=truecolor
GIT_REPOS_DIR=/srv/git/repos
GIT_CLONE_BASE=https://prime8.dev
-# OAuth token from https://wakatime.com/oauth (API keys are write-only since 2026-11-01)
-WAKATIME_TOKEN=waka_tok_your_token_here
diff --git a/internal/config/site.go b/internal/config/site.go
@@ -3,12 +3,9 @@ package config
const (
SiteDomain = "www.prime8.dev"
SiteURL = "https://" + SiteDomain
-)
-
-// WakaTimeEnabled toggles the all-time WakaTime total ("N+ hrs coded") in the
-// web status bar and the SSH TUI footer.
-const WakaTimeEnabled = true
-// WakaTimeWeeklyEnabled toggles the daily breakdown (wakatime --week) on the
-// about page. Off: only the all-time total is shown.
-const WakaTimeWeeklyEnabled = false
+ // WakaTimeUser is the WakaTime username whose public all-time total
+ // ("N+ hrs coded") shows in the web status bar and the SSH TUI footer.
+ // Empty turns the feature off: no network calls, no stat, no /api/wakatime.
+ WakaTimeUser = "moncheeta"
+)
diff --git a/internal/external/wakatime.go b/internal/external/wakatime.go
@@ -1,328 +1,131 @@
package external
import (
- "encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
- "os"
- "strings"
+ "net/url"
"time"
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
"github.com/patrickmn/go-cache"
)
+const (
+ wakaTimeTTL = 5 * time.Minute
+ wakaTimePendingTTL = time.Minute
+ wakaTimeKey = "wakatime:total"
+ wakaTimeStaleKey = "wakatime:total:stale"
+)
+
+// WakaTime reads a user's all-time total from WakaTime's public,
+// unauthenticated stats endpoint. The profile's stats must be public.
type WakaTime struct {
httpClient *http.Client
baseURL string
- apiKey string
+ user string
cache *cache.Cache
}
func NewWakaTime(c *cache.Cache) *WakaTime {
return &WakaTime{
- httpClient: &http.Client{
- Timeout: 10 * time.Second,
- },
- baseURL: "https://wakatime.com/api/v1",
- apiKey: wakaTimeCredential(),
- cache: c,
+ httpClient: &http.Client{Timeout: 10 * time.Second},
+ baseURL: "https://wakatime.com/api/v1",
+ user: config.WakaTimeUser,
+ cache: c,
}
}
type WakaTimeStats struct {
AllTime AllTimeStats
- Weekly WeeklyStats
}
-func (s *WakaTime) FetchStats() (*WakaTimeStats, error) {
- if s.apiKey == "" {
- return nil, nil
- }
-
- cacheKey := "wakatime:stats"
-
- if val, found := s.cache.Get(cacheKey); found {
- return val.(*WakaTimeStats), nil
- }
-
- allTime, err := s.fetchAllTimeStats()
- if err != nil {
- return nil, err
- }
-
- weekly, err := s.fetchWeeklyStats()
- if err != nil {
- return nil, err
- }
-
- stats := &WakaTimeStats{
- AllTime: *allTime,
- Weekly: *weekly,
- }
+type AllTimeStats struct {
+ TotalText string
+}
- s.cache.Set(cacheKey, stats, 5*time.Minute)
- return stats, nil
+type publicStatsResponse struct {
+ Data struct {
+ Status string `json:"status"`
+ TotalSecondsIncludingOtherLanguage *float64 `json:"total_seconds_including_other_language"`
+ } `json:"data"`
}
-// FetchTotal returns only the all-time total ("N+ hrs coded"), skipping the
-// daily/weekly endpoints. It returns nil when no API key is configured.
+// FetchTotal returns the all-time total ("N+ hrs coded"). It returns nil, nil
+// when no user is configured or while WakaTime is still computing the stats;
+// on a failed fetch it serves the last good value when there is one.
func (s *WakaTime) FetchTotal() (*WakaTimeStats, error) {
- if s.apiKey == "" {
+ if s.user == "" {
return nil, nil
}
- cacheKey := "wakatime:total"
- if val, found := s.cache.Get(cacheKey); found {
+ if val, found := s.cache.Get(wakaTimeKey); found {
return val.(*WakaTimeStats), nil
}
- resp, err := s.makeRequest("/users/current/all_time_since_today")
+ seconds, ok, err := s.fetchAllTimeSeconds()
if err != nil {
+ if stale := s.stale(); stale != nil {
+ return stale, nil
+ }
return nil, err
}
- var data allTimeResponse
- if err := json.Unmarshal(resp, &data); err != nil {
- return nil, err
+ if !ok {
+ // Still computing: keep showing the last good value (or nothing) and
+ // check again shortly.
+ stale := s.stale()
+ s.cache.Set(wakaTimeKey, stale, wakaTimePendingTTL)
+ return stale, nil
}
- stats := &WakaTimeStats{AllTime: AllTimeStats{TotalText: formatTotalHours(data.Data.TotalSeconds)}}
- s.cache.Set(cacheKey, stats, 5*time.Minute)
+ stats := &WakaTimeStats{AllTime: AllTimeStats{TotalText: formatTotalHours(seconds)}}
+ s.cache.Set(wakaTimeKey, stats, wakaTimeTTL)
+ s.cache.Set(wakaTimeStaleKey, stats, cache.NoExpiration)
return stats, nil
}
-func formatTotalHours(totalSeconds float64) string {
- return fmt.Sprintf("%d+ hrs coded", int(totalSeconds/3600))
-}
-
-type allTimeResponse struct {
- Data struct {
- TotalSeconds float64 `json:"total_seconds"`
- } `json:"data"`
-}
-
-type languagesResponse struct {
- Data struct {
- DailyAverageIncludingOtherLanguage float64 `json:"daily_average_including_other_language"`
- } `json:"data"`
-}
-
-type AllTimeStats struct {
- TotalText string
- DailyAverageText string
-}
-
-func (s *WakaTime) fetchAllTimeStats() (*AllTimeStats, error) {
- cacheKey := "wakatime:alltime"
-
- if val, found := s.cache.Get(cacheKey); found {
- return val.(*AllTimeStats), nil
- }
-
- statsCh := make(chan *allTimeResponse, 1)
- langCh := make(chan *languagesResponse, 1)
- errCh := make(chan error, 2)
-
- go func() {
- resp, err := s.makeRequest("/users/current/all_time_since_today")
- if err != nil {
- errCh <- err
- return
- }
- var data allTimeResponse
- if err := json.Unmarshal(resp, &data); err != nil {
- errCh <- err
- return
- }
- statsCh <- &data
- }()
-
- go func() {
- resp, err := s.makeRequest("/users/current/stats/last_year")
- if err != nil {
- errCh <- err
- return
- }
- var data languagesResponse
- if err := json.Unmarshal(resp, &data); err != nil {
- errCh <- err
- return
- }
- langCh <- &data
- }()
-
- var stats *allTimeResponse
- var langStats *languagesResponse
-
- for range 2 {
- select {
- case s := <-statsCh:
- stats = s
- case l := <-langCh:
- langStats = l
- case err := <-errCh:
- return nil, err
- }
- }
-
- totalSeconds := 0.0
- if stats != nil {
- totalSeconds = stats.Data.TotalSeconds
+func (s *WakaTime) stale() *WakaTimeStats {
+ if val, found := s.cache.Get(wakaTimeStaleKey); found {
+ return val.(*WakaTimeStats)
}
- totalText := formatTotalHours(totalSeconds)
-
- dailyAvgText := "0 mins"
- if langStats != nil && langStats.Data.DailyAverageIncludingOtherLanguage > 0 {
- dailyAvgText = formatDuration(langStats.Data.DailyAverageIncludingOtherLanguage)
- }
-
- result := &AllTimeStats{
- TotalText: totalText,
- DailyAverageText: dailyAvgText,
- }
-
- s.cache.Set(cacheKey, result, 5*time.Minute)
- return result, nil
-}
-
-func formatDuration(seconds float64) string {
- hours := int(seconds / 3600)
- minutes := int((seconds - float64(hours)*3600) / 60)
-
- if hours > 0 {
- return fmt.Sprintf("%d hrs %d mins", hours, minutes)
- }
- return fmt.Sprintf("%d mins", minutes)
+ return nil
}
-type summariesResponse struct {
- Data []struct {
- Range struct {
- Date string `json:"date"`
- } `json:"range"`
- GrandTotal struct {
- TotalSeconds float64 `json:"total_seconds"`
- } `json:"grand_total"`
- } `json:"data"`
-}
-
-type WeeklyStats struct {
- TotalText string
- Days []DailyEntry
-}
-
-type DailyEntry struct {
- DayText string
- DateText string
- TimeText string
- BarWidthPercent string
-}
-
-func (s *WakaTime) fetchWeeklyStats() (*WeeklyStats, error) {
- cacheKey := "wakatime:weekly"
-
- if val, found := s.cache.Get(cacheKey); found {
- return val.(*WeeklyStats), nil
- }
-
- end := time.Now()
- start := end.AddDate(0, 0, -6)
-
- url := fmt.Sprintf("/users/current/summaries?start=%s&end=%s",
- start.Format("2006-01-02"),
- end.Format("2006-01-02"))
-
- resp, err := s.makeRequest(url)
+// fetchAllTimeSeconds reports ok=false when the stats are not ready yet
+// (202 Accepted, a non-ok status, or a missing total).
+func (s *WakaTime) fetchAllTimeSeconds() (float64, bool, error) {
+ endpoint := fmt.Sprintf("%s/users/@%s/stats/all_time", s.baseURL, url.PathEscape(s.user))
+ resp, err := s.httpClient.Get(endpoint)
if err != nil {
- return nil, err
- }
-
- var data summariesResponse
- if err := json.Unmarshal(resp, &data); err != nil {
- return nil, err
- }
-
- days := make([]DailyEntry, 0, len(data.Data))
- var totalSeconds float64
- var maxMinutes = 1
- minutesPerDay := make([]int, len(data.Data))
-
- for i, day := range data.Data {
- date, _ := time.Parse("2006-01-02", day.Range.Date)
-
- dayText := date.Weekday().String()[:3]
- dateText := fmt.Sprintf("%s %d", date.Month().String()[:3], date.Day())
-
- seconds := day.GrandTotal.TotalSeconds
- totalSeconds += seconds
-
- minutes := int(seconds / 60)
- minutesPerDay[i] = minutes
- if minutes > maxMinutes {
- maxMinutes = minutes
- }
-
- timeText := formatDuration(seconds)
-
- days = append(days, DailyEntry{
- DayText: dayText,
- DateText: dateText,
- TimeText: timeText,
- })
- }
-
- for i := range days {
- days[i].BarWidthPercent = fmt.Sprintf("%d%%", (minutesPerDay[i]*100)/maxMinutes)
+ return 0, false, err
}
+ defer resp.Body.Close()
- result := &WeeklyStats{
- TotalText: formatDuration(totalSeconds),
- Days: days,
- }
-
- s.cache.Set(cacheKey, result, 5*time.Minute)
- return result, nil
-}
-
-func (s *WakaTime) makeRequest(path string) ([]byte, error) {
- url := s.baseURL + path
-
- req, err := http.NewRequest("GET", url, nil)
- if err != nil {
- return nil, err
+ switch resp.StatusCode {
+ case http.StatusOK:
+ case http.StatusAccepted:
+ return 0, false, nil
+ default:
+ return 0, false, fmt.Errorf("wakatime API returned %d", resp.StatusCode)
}
- req.Header.Set("Authorization", authHeader(s.apiKey))
-
- resp, err := s.httpClient.Do(req)
+ body, err := io.ReadAll(resp.Body)
if err != nil {
- return nil, err
+ return 0, false, err
}
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("wakatime API returned %d", resp.StatusCode)
+ var data publicStatsResponse
+ if err := json.Unmarshal(body, &data); err != nil {
+ return 0, false, err
}
- return io.ReadAll(resp.Body)
-}
-
-// wakaTimeCredential prefers WAKATIME_TOKEN (an OAuth access token from
-// wakatime.com/oauth) over WAKATIME_API_KEY: since 2026-11-01 API keys are
-// write-only and only work for plugins sending heartbeats.
-func wakaTimeCredential() string {
- if t := strings.TrimSpace(os.Getenv("WAKATIME_TOKEN")); t != "" {
- return t
+ total := data.Data.TotalSecondsIncludingOtherLanguage
+ if (data.Data.Status != "" && data.Data.Status != "ok") || total == nil || *total <= 0 {
+ return 0, false, nil
}
- return strings.TrimSpace(os.Getenv("WAKATIME_API_KEY"))
+ return *total, true, nil
}
-// authHeader sends OAuth tokens (waka_tok_...) as a bearer token and legacy
-// API keys as HTTP Basic.
-func authHeader(cred string) string {
- if strings.HasPrefix(cred, "waka_tok_") {
- return "Bearer " + cred
- }
- return "Basic " + base64.StdEncoding.EncodeToString([]byte(cred))
+func formatTotalHours(totalSeconds float64) string {
+ return fmt.Sprintf("%d+ hrs coded", int(totalSeconds/3600))
}
diff --git a/internal/external/wakatime_test.go b/internal/external/wakatime_test.go
@@ -3,60 +3,145 @@ package external
import (
"net/http"
"net/http/httptest"
+ "sync/atomic"
"testing"
"time"
"github.com/patrickmn/go-cache"
)
-func TestFetchTotalOnlyHitsAllTime(t *testing.T) {
- var paths []string
+func newTestWakaTime(srv *httptest.Server, user string) *WakaTime {
+ w := &WakaTime{user: user, cache: cache.New(time.Minute, time.Minute)}
+ if srv != nil {
+ w.httpClient = srv.Client()
+ w.baseURL = srv.URL
+ }
+ return w
+}
+
+func TestFetchTotalParsesPublicStats(t *testing.T) {
+ var gotPath, gotAuth string
+ var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- paths = append(paths, r.URL.Path)
- _, _ = w.Write([]byte(`{"data":{"total_seconds":3603600}}`))
+ hits.Add(1)
+ gotPath = r.URL.EscapedPath()
+ gotAuth = r.Header.Get("Authorization")
+ _, _ = w.Write([]byte(`{"data":{"status":"ok","total_seconds":6467373.8,"total_seconds_including_other_language":12283939.45}}`))
}))
defer srv.Close()
- w := &WakaTime{httpClient: srv.Client(), baseURL: srv.URL, apiKey: "k", cache: cache.New(time.Minute, time.Minute)}
+ w := newTestWakaTime(srv, "moncheeta")
stats, err := w.FetchTotal()
if err != nil {
t.Fatal(err)
}
- if stats.AllTime.TotalText != "1001+ hrs coded" {
- t.Fatalf("total = %q", stats.AllTime.TotalText)
+ if stats == nil || stats.AllTime.TotalText != "3412+ hrs coded" {
+ t.Fatalf("stats = %+v", stats)
}
- if stats.AllTime.DailyAverageText != "" || len(stats.Weekly.Days) != 0 {
- t.Fatalf("daily stats leaked: %+v", stats)
+ if gotPath != "/users/@moncheeta/stats/all_time" {
+ t.Fatalf("path = %q", gotPath)
}
- if len(paths) != 1 || paths[0] != "/users/current/all_time_since_today" {
- t.Fatalf("requests = %v", paths)
+ if gotAuth != "" {
+ t.Fatalf("unexpected Authorization header %q", gotAuth)
+ }
+
+ if _, err := w.FetchTotal(); err != nil {
+ t.Fatal(err)
+ }
+ if hits.Load() != 1 {
+ t.Fatalf("expected cached second call, got %d requests", hits.Load())
}
}
-func TestFetchTotalWithoutKey(t *testing.T) {
- w := &WakaTime{apiKey: "", cache: cache.New(time.Minute, time.Minute)}
- if stats, err := w.FetchTotal(); stats != nil || err != nil {
- t.Fatalf("got %v, %v", stats, err)
+func TestFetchTotalEscapesUser(t *testing.T) {
+ var gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.EscapedPath()
+ _, _ = w.Write([]byte(`{"data":{"status":"ok","total_seconds_including_other_language":7200}}`))
+ }))
+ defer srv.Close()
+
+ if _, err := newTestWakaTime(srv, "a b/c").FetchTotal(); err != nil {
+ t.Fatal(err)
+ }
+ if gotPath != "/users/@a%20b%2Fc/stats/all_time" {
+ t.Fatalf("path = %q", gotPath)
+ }
+}
+
+func TestFetchTotalPending(t *testing.T) {
+ cases := map[string]struct {
+ code int
+ body string
+ }{
+ "202": {http.StatusAccepted, `{"data":{"status":"pending_update"}}`},
+ "status": {http.StatusOK, `{"data":{"status":"pending_update","total_seconds_including_other_language":3600}}`},
+ "null total": {http.StatusOK, `{"data":{"status":"ok","total_seconds_including_other_language":null}}`},
+ "missing data": {http.StatusOK, `{}`},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tc.code)
+ _, _ = w.Write([]byte(tc.body))
+ }))
+ defer srv.Close()
+
+ stats, err := newTestWakaTime(srv, "moncheeta").FetchTotal()
+ if err != nil || stats != nil {
+ t.Fatalf("got %+v, %v; want nil, nil", stats, err)
+ }
+ })
}
}
-func TestAuthHeader(t *testing.T) {
- if got := authHeader("waka_tok_abc"); got != "Bearer waka_tok_abc" {
- t.Fatalf("token: got %q", got)
+func TestFetchTotalServesStaleWhilePendingOrFailing(t *testing.T) {
+ var mode atomic.Value
+ mode.Store("ok")
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch mode.Load().(string) {
+ case "pending":
+ w.WriteHeader(http.StatusAccepted)
+ _, _ = w.Write([]byte(`{"data":{"status":"pending_update"}}`))
+ case "error":
+ w.WriteHeader(http.StatusInternalServerError)
+ default:
+ _, _ = w.Write([]byte(`{"data":{"status":"ok","total_seconds_including_other_language":36000}}`))
+ }
+ }))
+ defer srv.Close()
+
+ w := newTestWakaTime(srv, "moncheeta")
+ if _, err := w.FetchTotal(); err != nil {
+ t.Fatal(err)
}
- if got := authHeader("waka_123"); got != "Basic d2FrYV8xMjM=" {
- t.Fatalf("key: got %q", got)
+
+ for _, m := range []string{"pending", "error"} {
+ mode.Store(m)
+ w.cache.Delete(wakaTimeKey)
+ stats, err := w.FetchTotal()
+ if err != nil || stats == nil || stats.AllTime.TotalText != "10+ hrs coded" {
+ t.Fatalf("%s: got %+v, %v; want stale value", m, stats, err)
+ }
}
}
-func TestWakaTimeCredentialPrefersToken(t *testing.T) {
- t.Setenv("WAKATIME_API_KEY", "waka_key")
- t.Setenv("WAKATIME_TOKEN", " waka_tok_x ")
- if got := wakaTimeCredential(); got != "waka_tok_x" {
- t.Fatalf("got %q", got)
+func TestFetchTotalErrorWithoutCache(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer srv.Close()
+
+ stats, err := newTestWakaTime(srv, "moncheeta").FetchTotal()
+ if err == nil || stats != nil {
+ t.Fatalf("got %+v, %v; want error", stats, err)
}
- t.Setenv("WAKATIME_TOKEN", "")
- if got := wakaTimeCredential(); got != "waka_key" {
- t.Fatalf("fallback: got %q", got)
+}
+
+func TestFetchTotalWithoutUser(t *testing.T) {
+ // No server: any network call would panic on the nil client.
+ w := newTestWakaTime(nil, "")
+ if stats, err := w.FetchTotal(); stats != nil || err != nil {
+ t.Fatalf("got %v, %v", stats, err)
}
}
diff --git a/internal/tui/content.go b/internal/tui/content.go
@@ -2,7 +2,6 @@ package tui
import (
"fmt"
- "strconv"
"strings"
"time"
@@ -30,9 +29,6 @@ func (m portfolioModel) aboutDetail() string {
"",
m.shellView(shellW),
}
- if appconfig.WakaTimeEnabled && appconfig.WakaTimeWeeklyEnabled {
- parts = append(parts, "", renderPrompt(m.user, "wakatime --week"), m.aboutWeeklyStats())
- }
return strings.Join(parts, "\n")
}
@@ -81,56 +77,6 @@ func (m portfolioModel) aboutFetchLines() string {
return strings.Join(lines, "\n")
}
-func (m portfolioModel) aboutWeeklyStats() string {
- if m.wakaWeekly == nil || len(m.wakaWeekly.Days) == 0 {
- if m.wakaLoading || !m.wakaLoaded {
- return dimStyle.Render("loading stats...")
- }
- return dimStyle.Render("stats unavailable")
- }
-
- barWidth := 24
- lines := make([]string, 0, len(m.wakaWeekly.Days)+2)
- lines = append(lines, rowStyle.Render("total: ")+infoStyle.Render(m.wakaWeekly.TotalText), "")
-
- for _, day := range m.wakaWeekly.Days {
- label := strings.ToLower(fitRunes(day.DayText, 3))
- label = padRunesRight(label, 3)
- percent := clampBarPercent(day.BarWidthPercent)
- bar := renderASCIIBar(percent, barWidth)
- line := dimStyle.Render(label+" ") + rowStyle.Render("|"+bar+"|") + dimStyle.Render(" "+day.TimeText)
- lines = append(lines, line)
- }
-
- return strings.Join(lines, "\n")
-}
-
-func clampBarPercent(percent string) int {
- v := strings.TrimSpace(strings.TrimSuffix(percent, "%"))
- n, err := strconv.Atoi(v)
- if err != nil {
- return 0
- }
- if n < 0 {
- return 0
- }
- if n > 100 {
- return 100
- }
- return n
-}
-
-func renderASCIIBar(percent int, width int) string {
- filled := (percent * width) / 100
- if filled < 0 {
- filled = 0
- }
- if filled > width {
- filled = width
- }
- return strings.Repeat("#", filled) + strings.Repeat("-", width-filled)
-}
-
func (m portfolioModel) projectDetail() string {
if len(m.projects) == 0 {
return "No projects available."
diff --git a/internal/tui/types.go b/internal/tui/types.go
@@ -76,7 +76,6 @@ type portfolioModel struct {
projectPrefetchDone bool
wakaHours string
- wakaWeekly *external.WeeklyStats
wakaLoaded bool
wakaLoading bool
wakaTime *external.WakaTime
diff --git a/internal/tui/update.go b/internal/tui/update.go
@@ -99,7 +99,6 @@ func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) {
case wakaFooterMsg:
m.wakaLoading = false
m.wakaLoaded = true
- m.wakaWeekly = msg.weekly
if msg.ok {
m.wakaHours = msg.hours
} else {
diff --git a/internal/tui/view.go b/internal/tui/view.go
@@ -215,7 +215,7 @@ func (m portfolioModel) renderFooter(width int) string {
right := ""
if m.wakaHours != "" {
right = footerWakaStyle.Render(m.wakaHours)
- } else if config.WakaTimeEnabled && !m.wakaLoaded {
+ } else if config.WakaTimeUser != "" && !m.wakaLoaded {
right = footerDimStyle.Render("loading...")
}
diff --git a/internal/tui/waka_footer.go b/internal/tui/waka_footer.go
@@ -7,9 +7,8 @@ import (
)
type wakaFooterMsg struct {
- hours string
- weekly *external.WeeklyStats
- ok bool
+ hours string
+ ok bool
}
type wakaFooterInitMsg struct{}
@@ -21,7 +20,7 @@ func initWakaFooterCmd() tea.Cmd {
}
func (m *portfolioModel) maybeLoadWakaFooterCmd() tea.Cmd {
- if !config.WakaTimeEnabled || m.wakaLoaded || m.wakaLoading || m.wakaTime == nil {
+ if config.WakaTimeUser == "" || m.wakaLoaded || m.wakaLoading || m.wakaTime == nil {
return nil
}
@@ -31,16 +30,11 @@ func (m *portfolioModel) maybeLoadWakaFooterCmd() tea.Cmd {
func fetchWakaFooterCmd(service *external.WakaTime) tea.Cmd {
return func() tea.Msg {
- fetch := service.FetchTotal
- if config.WakaTimeWeeklyEnabled {
- fetch = service.FetchStats
- }
- stats, err := fetch()
+ stats, err := service.FetchTotal()
if err != nil || stats == nil {
return wakaFooterMsg{ok: false}
}
- hasData := stats.AllTime.TotalText != "" || len(stats.Weekly.Days) > 0
- return wakaFooterMsg{hours: stats.AllTime.TotalText, weekly: &stats.Weekly, ok: hasData}
+ return wakaFooterMsg{hours: stats.AllTime.TotalText, ok: stats.AllTime.TotalText != ""}
}
}
diff --git a/internal/web/renderer.go b/internal/web/renderer.go
@@ -10,7 +10,6 @@ import (
"time"
"git.prime8.dev/moncheeta/prime8dev/internal/config"
- "git.prime8.dev/moncheeta/prime8dev/internal/external"
"git.prime8.dev/moncheeta/prime8dev/internal/utils"
"git.prime8.dev/moncheeta/prime8dev/internal/vfs"
)
@@ -33,7 +32,6 @@ type Data struct {
SelectedProjectID string
Project *config.Project
ProjectReadme template.HTML
- WakaStats *external.WakaTimeStats
Tabs []Tab
SiteURL string
}
@@ -55,26 +53,25 @@ func New() (*Renderer, error) {
}
funcs := template.FuncMap{
- "tabPath": utils.TabPath,
- "tabURL": utils.TabURL,
- "tagClass": utils.TagClass,
- "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) },
- "shellPath": ShellPath,
- "windowTitle": func(p string) string { return utils.WindowTitle(ShellPath(p)) },
- "shellUser": func() string { return vfs.FallbackUser },
- "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) },
- "inc": func(i int) int { return i + 1 },
- "techMeta": utils.TechMeta,
- "formatMonthYear": utils.FormatMonthYear,
- "formatEndDate": utils.FormatEndDate,
- "formatDuration": utils.FormatDuration,
- "durationHasYear": utils.DurationHasYear,
- "timelineYear": utils.TimelineYear,
- "toHTML": toHTML,
- "gitCloneURL": config.GitCloneURL,
- "gitSSHCloneURL": config.GitSSHCloneURL,
- "wakaTimeEnabled": func() bool { return config.WakaTimeEnabled },
- "wakaTimeWeeklyEnabled": func() bool { return config.WakaTimeEnabled && config.WakaTimeWeeklyEnabled },
+ "tabPath": utils.TabPath,
+ "tabURL": utils.TabURL,
+ "tagClass": utils.TagClass,
+ "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) },
+ "shellPath": ShellPath,
+ "windowTitle": func(p string) string { return utils.WindowTitle(ShellPath(p)) },
+ "shellUser": func() string { return vfs.FallbackUser },
+ "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) },
+ "inc": func(i int) int { return i + 1 },
+ "techMeta": utils.TechMeta,
+ "formatMonthYear": utils.FormatMonthYear,
+ "formatEndDate": utils.FormatEndDate,
+ "formatDuration": utils.FormatDuration,
+ "durationHasYear": utils.DurationHasYear,
+ "timelineYear": utils.TimelineYear,
+ "toHTML": toHTML,
+ "gitCloneURL": config.GitCloneURL,
+ "gitSSHCloneURL": config.GitSSHCloneURL,
+ "wakaTimeUser": func() string { return config.WakaTimeUser },
"inlineCSS": func(name string) template.CSS {
if css, ok := inlineStyles[name]; ok {
return css
diff --git a/internal/web/scripts/app.js b/internal/web/scripts/app.js
@@ -1,4 +1,3 @@
-import { initAbout } from "./modules/about.js";
import { initExperience } from "./modules/experience.js";
import { initHeader, updateAppTitle } from "./modules/header.js";
import { initHelp } from "./modules/help.js";
@@ -15,7 +14,6 @@ function initApp() {
initLayout();
const initDynamicContent = () => {
- initAbout();
initShell();
initProjects();
initExperience();
diff --git a/internal/web/scripts/modules/about.js b/internal/web/scripts/modules/about.js
@@ -1,138 +0,0 @@
-import { getWakaTimeData, subscribeWakaTimeRefresh } from "./wakatime.js";
-
-let unsubscribeAboutRefresh = null;
-
-export function initAbout() {
- const statsContainer = document.getElementById("activity-stats");
- const section = document.getElementById("activity-section");
- if (!statsContainer || !section) return;
-
- function hideActivity() {
- section.style.display = "none";
- }
-
- async function loadActivityData(data = null) {
- if (!section.isConnected) {
- if (unsubscribeAboutRefresh) {
- unsubscribeAboutRefresh();
- unsubscribeAboutRefresh = null;
- }
- return;
- }
-
- try {
- const stats = data || (await getWakaTimeData());
-
- if (!stats || !stats.Weekly || !Array.isArray(stats.Weekly.Days)) {
- hideActivity();
- return;
- }
-
- const totalText = stats.Weekly.TotalText;
- const dayBars = stats.Weekly.Days;
- if (!totalText || dayBars.length === 0) {
- hideActivity();
- return;
- }
-
- const days = dayBars.map((bar) => ({
- dayText: (bar.DayText || "").toLowerCase(),
- timeText: bar.TimeText || "",
- barWidthPercent:
- bar.BarWidthPercent && typeof bar.BarWidthPercent === "string"
- ? bar.BarWidthPercent.trim()
- : "0%",
- }));
-
- renderWeeklyStats(statsContainer, totalText, days);
- } catch {
- hideActivity();
- }
- }
-
- loadActivityData();
-
- if (unsubscribeAboutRefresh) {
- unsubscribeAboutRefresh();
- }
- unsubscribeAboutRefresh = subscribeWakaTimeRefresh((data) => {
- void loadActivityData(data);
- });
-}
-
-function renderWeeklyStats(statsContainer, totalText, days) {
- statsContainer.innerHTML = "";
-
- const width = 24;
- const pre = document.createElement("pre");
- pre.className = "weekly-ascii";
-
- const totalLabel = document.createElement("span");
- totalLabel.className = "weekly-total-label";
- totalLabel.textContent = "total: ";
-
- const totalValue = document.createElement("span");
- totalValue.className = "weekly-total-value";
- totalValue.textContent = totalText;
-
- pre.appendChild(totalLabel);
- pre.appendChild(totalValue);
- pre.appendChild(document.createTextNode("\n\n"));
-
- days.forEach((day, index) => {
- const label = (day.dayText || "").slice(0, 3).padEnd(3, " ");
- const percent = clampPercent(day.barWidthPercent);
- const { filled, empty } = asciiBarSegments(percent, width);
-
- const dayLabel = document.createElement("span");
- dayLabel.className = "weekly-day-label";
- dayLabel.textContent = `${label} `;
-
- const barFrame = document.createElement("span");
- barFrame.className = "weekly-day-bar-frame";
- barFrame.appendChild(document.createTextNode("|"));
-
- const barFilled = document.createElement("span");
- barFilled.className = "weekly-day-bar-filled";
- barFilled.textContent = filled;
-
- const barEmpty = document.createElement("span");
- barEmpty.className = "weekly-day-bar-empty";
- barEmpty.textContent = empty;
-
- const dayTime = document.createElement("span");
- dayTime.className = "weekly-day-time";
- dayTime.textContent = ` ${day.timeText}`;
-
- barFrame.appendChild(barFilled);
- barFrame.appendChild(barEmpty);
- barFrame.appendChild(document.createTextNode("|"));
-
- pre.appendChild(dayLabel);
- pre.appendChild(barFrame);
- pre.appendChild(dayTime);
-
- if (index < days.length - 1) {
- pre.appendChild(document.createTextNode("\n"));
- }
- });
-
- statsContainer.appendChild(pre);
-}
-
-function clampPercent(value) {
- const parsed = Number.parseFloat((value || "0").replace("%", ""));
- if (!Number.isFinite(parsed)) return 0;
- if (parsed < 0) return 0;
- if (parsed > 100) return 100;
- return parsed;
-}
-
-function asciiBarSegments(percent, width) {
- const filled = Math.round((percent / 100) * width);
- return {
- filled: "#".repeat(filled),
- empty: "-".repeat(width - filled),
- };
-}
-
diff --git a/internal/web/styles/about.css b/internal/web/styles/about.css
@@ -159,102 +159,6 @@
}
}
-.weekly-stats {
- background: var(--bg-alt);
- border: 1px solid var(--border);
- padding: 12px;
-}
-
-.weekly-ascii {
- margin: 0;
- color: var(--fg);
- font-size: var(--fs-12);
- line-height: 1.4;
- white-space: pre;
- overflow-x: auto;
- font-variant-ligatures: none;
-}
-
-.weekly-ascii .weekly-total-label {
- color: var(--fg);
-}
-
-.weekly-ascii .weekly-total-value {
- color: var(--cyan);
-}
-
-.weekly-ascii .weekly-day-label,
-.weekly-ascii .weekly-day-time {
- color: var(--fg-dim);
-}
-
-.weekly-ascii .weekly-day-bar-frame {
- color: var(--fg);
-}
-
-.weekly-ascii .weekly-day-bar-filled {
- color: var(--fg);
-}
-
-.weekly-ascii .weekly-day-bar-empty {
- color: var(--border);
-}
-
-.weekly-stats .loading-text {
- color: var(--fg-dim);
- font-style: italic;
- text-align: center;
- padding: 20px 0;
-}
-
-.weekly-total {
- font-size: var(--fs-16);
- font-weight: 500;
- color: var(--accent);
- margin-bottom: 12px;
-}
-
-.weekly-days {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.weekly-day-row {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: var(--fs-12);
-}
-
-.weekly-day-name {
- width: 32px;
- flex-shrink: 0;
- color: var(--fg-dim);
-}
-
-.weekly-day-bar-container {
- flex: 1;
- height: 8px;
- background: var(--bg);
- border-radius: 2px;
- overflow: hidden;
-}
-
-.weekly-day-bar {
- height: 100%;
- background: var(--accent);
- border-radius: 2px;
-}
-
-.weekly-day-time {
- min-width: 50px;
- flex-shrink: 0;
- text-align: right;
- color: var(--fg-dim);
- white-space: nowrap;
-}
-
@media (max-width: 700px) {
.about-layout {
padding: 16px;
diff --git a/internal/web/templates/partials/about.gohtml b/internal/web/templates/partials/about.gohtml
@@ -46,15 +46,6 @@
<div class="shell-history" aria-live="polite"></div>
<label class="shell-prompt shell-live"><span class="shell-user">{{shellUser}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-line"><span class="shell-cmd" data-before></span><span class="shell-cursor" aria-hidden="true"> </span><span class="shell-cmd" data-after></span></span><input class="shell-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go" aria-label="shell: type help for commands"></label>
</div>
-
- {{if wakaTimeWeeklyEnabled}}
- <div class="section" id="activity-section">
- <div class="shell-prompt"><span class="shell-user">{{shellUser}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">wakatime --week</span></div>
- <div class="weekly-stats" id="activity-stats">
- <div class="loading-text">loading stats...</div>
- </div>
- </div>
- {{end}}
</div>
</section>
diff --git a/internal/web/templates/partials/footer.gohtml b/internal/web/templates/partials/footer.gohtml
@@ -3,8 +3,8 @@
<span class="time" id="status-time"></span>
<span class="spacer"></span>
<button type="button" class="ssh" data-ssh-copy title="copy to clipboard"><span class="ssh-dollar">$</span> <span data-ssh-command>ssh prime8.dev</span><span class="ssh-hint">[copy]</span></button>
- {{if wakaTimeEnabled}}
- <a class="wakatime" id="wakatime-link" href="https://wakatime.com/@{{.About.Handle}}" target="_blank" rel="noopener noreferrer">Loading...</a>
+ {{with wakaTimeUser}}
+ <a class="wakatime" id="wakatime-link" href="https://wakatime.com/@{{.}}" target="_blank" rel="noopener noreferrer">Loading...</a>
{{end}}
</footer>