prime8dev

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

commit 4f95ba0df38a843bd7616661b689ee869481303a
parent 5ab2529c1c6fa6543ccc2dc70c63f2fca8f9503e
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:
Mcmd/server/handlers/wakatime.go | 8+-------
Mcmd/server/main.go | 2+-
Menv.example | 2--
Minternal/config/site.go | 13+++++--------
Minternal/external/wakatime.go | 337+++++++++++++++++--------------------------------------------------------------
Minternal/external/wakatime_test.go | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Minternal/tui/content.go | 54------------------------------------------------------
Minternal/tui/types.go | 1-
Minternal/tui/update.go | 1-
Minternal/tui/view.go | 2+-
Minternal/tui/waka_footer.go | 16+++++-----------
Minternal/web/renderer.go | 41+++++++++++++++++++----------------------
Minternal/web/scripts/app.js | 2--
Dinternal/web/scripts/modules/about.js | 138-------------------------------------------------------------------------------
Minternal/web/styles/about.css | 96-------------------------------------------------------------------------------
Minternal/web/templates/partials/about.gohtml | 9---------
Minternal/web/templates/partials/footer.gohtml | 4++--
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">&nbsp;</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>