Initial implementation of Gitea Wrapped TUI

- Gitea API client with repository and commit fetching
- Interactive credential input screen with masked token input
- Statistics analyzer for commits, languages, and activity patterns
- Multi-page report screen with ASCII charts and visualizations
- Integration of all components in main app coordinator
- Comprehensive README with usage instructions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-01 09:43:09 -06:00
commit fec14022cd
12 changed files with 1152 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
package gitea
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type Client struct {
BaseURL string
Token string
Client *http.Client
}
func NewClient(baseURL, token string) *Client {
baseURL = strings.TrimRight(baseURL, "/")
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
baseURL = "https://" + baseURL
}
if strings.HasSuffix(baseURL, "/api/v1") {
baseURL = strings.TrimSuffix(baseURL, "/api/v1")
}
if strings.HasSuffix(baseURL, "/api") {
baseURL = strings.TrimSuffix(baseURL, "/api")
}
return &Client{
BaseURL: baseURL,
Token: token,
Client: &http.Client{Timeout: 60 * time.Second},
}
}
type GiteaRepo struct {
ID int64 `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
Language string `json:"language"`
Topics []string `json:"topics"`
}
type GiteaCommit struct {
SHA string `json:"sha"`
Commit struct {
Author struct {
Name string `json:"name"`
Email string `json:"email"`
Date time.Time `json:"date"`
} `json:"author"`
Message string `json:"message"`
} `json:"commit"`
}
// do makes an authenticated HTTP request to Gitea API
func (c *Client) do(method, path string) ([]byte, error) {
url := fmt.Sprintf("%s/api/v1%s", c.BaseURL, path)
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request for %s: %w", url, err)
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", c.Token))
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "gitea-wrapped/1.0")
resp, err := c.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("API request failed (%s %s): %w", method, url, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errMsg := string(body)
if len(errMsg) > 500 {
errMsg = errMsg[:500]
}
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, errMsg)
}
return body, nil
}
func (c *Client) GetUserRepos() ([]GiteaRepo, error) {
var repos []GiteaRepo
page := 1
pageSize := 50
maxPages := 100 // Safety limit to prevent infinite loops
for page <= maxPages {
path := fmt.Sprintf("/user/repos?page=%d&limit=%d", page, pageSize)
body, err := c.do("GET", path)
if err != nil {
if len(repos) > 0 {
return repos, nil
}
return nil, err
}
var pageRepos []GiteaRepo
if err := json.Unmarshal(body, &pageRepos); err != nil {
return nil, err
}
if len(pageRepos) == 0 {
break
}
repos = append(repos, pageRepos...)
if len(pageRepos) < pageSize {
break
}
page++
}
return repos, nil
}
func (c *Client) GetRepoCommits(owner, repo string) ([]GiteaCommit, error) {
var commits []GiteaCommit
page := 1
pageSize := 50
maxPages := 1000 // Safety limit to prevent infinite loops
for page <= maxPages {
path := fmt.Sprintf("/repos/%s/%s/commits?page=%d&limit=%d", owner, repo, page, pageSize)
body, err := c.do("GET", path)
if err != nil {
if len(commits) > 0 {
return commits, nil
}
return nil, err
}
var pageCommits []GiteaCommit
if err := json.Unmarshal(body, &pageCommits); err != nil {
return nil, err
}
if len(pageCommits) == 0 {
break
}
commits = append(commits, pageCommits...)
if len(pageCommits) < pageSize {
break
}
page++
}
return commits, nil
}
func (c *Client) TestConnection() error {
_, err := c.do("GET", "/user")
if err != nil {
return err
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package models
import "time"
type Repository struct {
ID int64
Name string
FullName string
HTMLURL string
Language string
Topics []string
}
type Commit struct {
SHA string
Message string
Author string
Timestamp time.Time
RepoName string
}
type LanguageStats struct {
Language string
Count int
Percent float64
}
type DateStats struct {
Date time.Time
Count int
Day string
}
type TagStats struct {
Tag string
Count int
}
type UserStats struct {
Username string
TotalCommits int
TotalRepositories int
Languages []LanguageStats
Tags []TagStats
CommitsByWeekday []DateStats
CommitsByMonth []DateStats
TopRepositories []Repository
MostActiveMonth string
MostActiveDay string
AverageCommitsPerDay float64
}
+212
View File
@@ -0,0 +1,212 @@
package stats
import (
"sort"
"time"
"github.com/atridad/gitea-wrapped/pkg/gitea"
"github.com/atridad/gitea-wrapped/pkg/models"
)
type Analyzer struct {
repos []gitea.GiteaRepo
commits []models.Commit
}
func NewAnalyzer() *Analyzer {
return &Analyzer{
repos: []gitea.GiteaRepo{},
commits: []models.Commit{},
}
}
func (a *Analyzer) AddRepos(repos []gitea.GiteaRepo) {
a.repos = repos
}
func (a *Analyzer) AddCommits(commits []models.Commit) {
a.commits = append(a.commits, commits...)
}
func (a *Analyzer) Generate(username string) *models.UserStats {
stats := &models.UserStats{
Username: username,
TotalCommits: len(a.commits),
TotalRepositories: len(a.repos),
}
stats.Languages = a.generateLanguageStats()
stats.CommitsByWeekday = a.generateWeekdayStats()
stats.CommitsByMonth = a.generateMonthStats()
stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay()
if len(stats.CommitsByMonth) > 0 {
stats.MostActiveMonth = stats.CommitsByMonth[0].Date.Format("January")
}
if len(stats.CommitsByWeekday) > 0 {
stats.MostActiveDay = stats.CommitsByWeekday[0].Day
}
stats.TopRepositories = a.getTopRepositories(5)
return stats
}
// generateLanguageStats creates language statistics
func (a *Analyzer) generateLanguageStats() []models.LanguageStats {
langCount := make(map[string]int)
for _, repo := range a.repos {
if repo.Language != "" {
langCount[repo.Language]++
}
}
var stats []models.LanguageStats
for lang, count := range langCount {
stats = append(stats, models.LanguageStats{
Language: lang,
Count: count,
})
}
sort.Slice(stats, func(i, j int) bool {
return stats[i].Count > stats[j].Count
})
total := len(a.repos)
for i := range stats {
stats[i].Percent = float64(stats[i].Count) / float64(total) * 100
}
return stats
}
// generateWeekdayStats creates weekday statistics for commits
func (a *Analyzer) generateWeekdayStats() []models.DateStats {
dayCount := make(map[time.Weekday]int)
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
for _, commit := range a.commits {
day := commit.Timestamp.Weekday()
dayCount[day]++
}
var stats []models.DateStats
for i := 0; i < 7; i++ {
day := time.Weekday(i)
stats = append(stats, models.DateStats{
Day: dayNames[i],
Count: dayCount[day],
})
}
sort.Slice(stats, func(i, j int) bool {
return stats[i].Count > stats[j].Count
})
return stats
}
// generateMonthStats creates monthly statistics for commits
func (a *Analyzer) generateMonthStats() []models.DateStats {
monthCount := make(map[string]int)
monthKeys := []string{}
monthMap := make(map[string]time.Time)
for _, commit := range a.commits {
monthStr := commit.Timestamp.Format("2006-01")
monthCount[monthStr]++
if _, exists := monthMap[monthStr]; !exists {
monthKeys = append(monthKeys, monthStr)
monthMap[monthStr] = commit.Timestamp
}
}
var stats []models.DateStats
for _, monthStr := range monthKeys {
t, _ := time.Parse("2006-01", monthStr)
stats = append(stats, models.DateStats{
Date: t,
Count: monthCount[monthStr],
})
}
sort.Slice(stats, func(i, j int) bool {
return stats[i].Count > stats[j].Count
})
return stats
}
// calculateAverageCommitsPerDay calculates average commits per calendar day
func (a *Analyzer) calculateAverageCommitsPerDay() float64 {
if len(a.commits) == 0 {
return 0
}
if len(a.commits) < 2 {
return float64(len(a.commits))
}
sort.Slice(a.commits, func(i, j int) bool {
return a.commits[i].Timestamp.Before(a.commits[j].Timestamp)
})
firstDay := a.commits[0].Timestamp
lastDay := a.commits[len(a.commits)-1].Timestamp
daysDiff := lastDay.Sub(firstDay).Hours() / 24
if daysDiff == 0 {
daysDiff = 1
}
return float64(len(a.commits)) / daysDiff
}
// getTopRepositories returns the top N repositories by commit count
func (a *Analyzer) getTopRepositories(n int) []models.Repository {
repoCommitCount := make(map[string]int)
repoMap := make(map[string]models.Repository)
for _, commit := range a.commits {
repoCommitCount[commit.RepoName]++
}
for _, repo := range a.repos {
repoMap[repo.Name] = models.Repository{
ID: repo.ID,
Name: repo.Name,
FullName: repo.FullName,
HTMLURL: repo.HTMLURL,
Language: repo.Language,
Topics: repo.Topics,
}
}
type repoWithCount struct {
repo models.Repository
count int
}
var repos []repoWithCount
for name, count := range repoCommitCount {
if repo, exists := repoMap[name]; exists {
repos = append(repos, repoWithCount{repo, count})
}
}
sort.Slice(repos, func(i, j int) bool {
return repos[i].count > repos[j].count
})
if len(repos) > n {
repos = repos[:n]
}
result := make([]models.Repository, len(repos))
for i, rc := range repos {
result[i] = rc.repo
}
return result
}
+240
View File
@@ -0,0 +1,240 @@
package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/atridad/gitea-wrapped/pkg/gitea"
"github.com/atridad/gitea-wrapped/pkg/models"
"github.com/atridad/gitea-wrapped/pkg/stats"
)
type screenType int
const (
inputScreenType screenType = iota
loadingScreenType
reportScreenType
)
type tickMsg time.Time
type progressMsg string
type errorMsg string
type doneMsg struct {
repos []gitea.GiteaRepo
commits []models.Commit
username string
}
type App struct {
screen screenType
inputScreen *InputScreen
reportScreen *ReportScreen
loadingMsg string
err error
giteaClient *gitea.Client
analyzer *stats.Analyzer
spinnerFrame int
repos []gitea.GiteaRepo
username string
}
func NewApp() *App {
return &App{
screen: inputScreenType,
inputScreen: NewInputScreen(),
analyzer: stats.NewAnalyzer(),
}
}
func (a *App) Init() tea.Cmd {
return a.inputScreen.Init()
}
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
return a, tea.Quit
}
case tickMsg:
if a.screen == loadingScreenType {
a.spinnerFrame = (a.spinnerFrame + 1) % 4
return a, tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
}
switch a.screen {
case inputScreenType:
_, cmd := a.inputScreen.Update(msg)
if a.inputScreen.IsDone() {
url, username, token := a.inputScreen.GetCredentials()
if url == "" {
a.inputScreen.err = fmt.Errorf("URL is required")
a.inputScreen.done = false
return a, cmd
}
if username == "" {
a.inputScreen.err = fmt.Errorf("username is required")
a.inputScreen.done = false
return a, cmd
}
if token == "" {
a.inputScreen.err = fmt.Errorf("token is required")
a.inputScreen.done = false
return a, cmd
}
a.giteaClient = gitea.NewClient(url, token)
a.username = username
a.screen = loadingScreenType
a.loadingMsg = "Connecting to Gitea..."
a.spinnerFrame = 0
return a, tea.Batch(cmd,
tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg(t)
}),
a.testConnection())
}
return a, cmd
case loadingScreenType:
switch msg := msg.(type) {
case progressMsg:
a.loadingMsg = string(msg)
switch string(msg) {
case "Fetching repositories...":
return a, a.fetchRepos()
case "Fetching commits...":
return a, a.fetchCommits()
}
return a, nil
case errorMsg:
a.err = fmt.Errorf("%s", msg)
a.loadingMsg = "Error"
return a, nil
case doneMsg:
a.analyzer.AddRepos(msg.repos)
a.analyzer.AddCommits(msg.commits)
userStats := a.analyzer.Generate(msg.username)
a.reportScreen = NewReportScreen(userStats)
a.screen = reportScreenType
return a, nil
}
case reportScreenType:
if a.reportScreen != nil {
_, cmd := a.reportScreen.Update(msg)
return a, cmd
}
}
return a, nil
}
func (a *App) View() string {
switch a.screen {
case inputScreenType:
return a.inputScreen.View()
case loadingScreenType:
return a.renderLoadingScreen()
case reportScreenType:
if a.reportScreen != nil {
return a.reportScreen.View()
}
}
return ""
}
func (a *App) renderLoadingScreen() string {
var s string
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("212")).
Bold(true).
Render("🎵 Gitea Wrapped") + "\n\n"
spinners := []string{"⠋", "⠙", "⠹", "⠸"}
spinner := spinners[a.spinnerFrame%4]
s += fmt.Sprintf("%s %s\n", spinner, a.loadingMsg)
if a.err != nil {
s += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("1")).
Render(fmt.Sprintf("Error: %v", a.err)) + "\n"
}
return s
}
// testConnection tests API connection
func (a *App) testConnection() tea.Cmd {
return func() tea.Msg {
if err := a.giteaClient.TestConnection(); err != nil {
return errorMsg(fmt.Sprintf("failed to connect: %v", err))
}
return progressMsg("Fetching repositories...")
}
}
// fetchRepos fetches all repositories
func (a *App) fetchRepos() tea.Cmd {
return func() tea.Msg {
repos, err := a.giteaClient.GetUserRepos()
if err != nil {
return errorMsg(fmt.Sprintf("failed to fetch repos: %v", err))
}
a.repos = repos
return progressMsg("Fetching commits...")
}
}
// fetchCommits fetches commits from all repos
func (a *App) fetchCommits() tea.Cmd {
return func() tea.Msg {
var commits []models.Commit
for i, repo := range a.repos {
parts := strings.Split(repo.FullName, "/")
if len(parts) < 2 {
continue
}
owner := parts[0]
giteaCommits, err := a.giteaClient.GetRepoCommits(owner, repo.Name)
if err != nil {
continue
}
for _, gc := range giteaCommits {
commits = append(commits, models.Commit{
SHA: gc.SHA,
Message: gc.Commit.Message,
Author: gc.Commit.Author.Name,
Timestamp: gc.Commit.Author.Date,
RepoName: repo.Name,
})
}
}
return doneMsg{a.repos, commits, a.username}
}
}
+167
View File
@@ -0,0 +1,167 @@
package ui
import (
"fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type inputScreenState int
const (
urlInput inputScreenState = iota
usernameInput
tokenInput
)
type InputScreen struct {
urlField textinput.Model
usernameField textinput.Model
tokenField textinput.Model
focusedField inputScreenState
err error
url string
username string
token string
done bool
}
func NewInputScreen() *InputScreen {
urlField := textinput.New()
urlField.Placeholder = "https://gitea.example.com"
urlField.Focus()
usernameField := textinput.New()
usernameField.Placeholder = "your_username"
tokenField := textinput.New()
tokenField.Placeholder = "your_access_token"
tokenField.EchoMode = textinput.EchoPassword
return &InputScreen{
urlField: urlField,
usernameField: usernameField,
tokenField: tokenField,
focusedField: urlInput,
}
}
func (is *InputScreen) Init() tea.Cmd {
return textinput.Blink
}
func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC:
return is, tea.Quit
case tea.KeyEnter:
switch is.focusedField {
case urlInput:
is.url = is.urlField.Value()
is.focusedField = usernameInput
is.usernameField.Focus()
return is, nil
case usernameInput:
is.username = is.usernameField.Value()
is.focusedField = tokenInput
is.tokenField.Focus()
return is, nil
case tokenInput:
is.token = is.tokenField.Value()
is.done = true
return is, nil
}
case tea.KeyTab, tea.KeyShiftTab:
switch is.focusedField {
case urlInput:
is.url = is.urlField.Value()
case usernameInput:
is.username = is.usernameField.Value()
case tokenInput:
is.token = is.tokenField.Value()
}
if msg.Type == tea.KeyTab {
is.focusedField = (is.focusedField + 1) % 3
} else {
is.focusedField = (is.focusedField - 1 + 3) % 3
}
is.urlField.Blur()
is.usernameField.Blur()
is.tokenField.Blur()
switch is.focusedField {
case urlInput:
is.urlField.Focus()
case usernameInput:
is.usernameField.Focus()
case tokenInput:
is.tokenField.Focus()
}
return is, nil
}
case errMsg:
is.err = msg
return is, nil
}
var cmd tea.Cmd
switch is.focusedField {
case urlInput:
is.urlField, cmd = is.urlField.Update(msg)
case usernameInput:
is.usernameField, cmd = is.usernameField.Update(msg)
case tokenInput:
is.tokenField, cmd = is.tokenField.Update(msg)
}
return is, cmd
}
func (is *InputScreen) View() string {
var s string
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("212")).
Bold(true).
Render("Gitea Wrapped") + "\n\n"
s += "Enter your Gitea credentials to get started:\n\n"
s += "Gitea URL\n"
s += is.urlField.View() + "\n\n"
s += "Username\n"
s += is.usernameField.View() + "\n\n"
s += "Access Token\n"
s += is.tokenField.View() + "\n\n"
if is.err != nil {
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("1")).
Render(fmt.Sprintf("Error: %v", is.err)) + "\n"
}
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Render("(press Tab to navigate, Enter to continue, Ctrl+C to quit)") + "\n"
return s
}
func (is *InputScreen) IsDone() bool {
return is.done
}
func (is *InputScreen) GetCredentials() (string, string, string) {
return is.url, is.username, is.token
}
type errMsg error
+173
View File
@@ -0,0 +1,173 @@
package ui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/atridad/gitea-wrapped/pkg/models"
)
type ReportScreen struct {
stats *models.UserStats
page int
maxPages int
}
func NewReportScreen(stats *models.UserStats) *ReportScreen {
return &ReportScreen{
stats: stats,
page: 0,
maxPages: 3,
}
}
func (rs *ReportScreen) Init() tea.Cmd {
return nil
}
func (rs *ReportScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return rs, tea.Quit
case "right", "l", "n":
if rs.page < rs.maxPages-1 {
rs.page++
}
case "left", "h", "p":
if rs.page > 0 {
rs.page--
}
}
}
return rs, nil
}
func (rs *ReportScreen) View() string {
header := lipgloss.NewStyle().
Foreground(lipgloss.Color("212")).
Bold(true).
Render(fmt.Sprintf("Gitea Wrapped %d", 2026)) + "\n"
var content string
switch rs.page {
case 0:
content = rs.renderOverviewPage()
case 1:
content = rs.renderLanguagesPage()
case 2:
content = rs.renderActivityPage()
}
footer := lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Render(fmt.Sprintf("Page %d/%d | ← → to navigate | q to quit", rs.page+1, rs.maxPages))
return header + "\n" + content + "\n\n" + footer
}
func (rs *ReportScreen) renderOverviewPage() string {
var s strings.Builder
s.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color("14")).
Bold(true).
Render("Your 2026 Overview") + "\n\n")
s.WriteString(fmt.Sprintf("Username: %s\n", rs.stats.Username))
s.WriteString(fmt.Sprintf("Total Commits: %d\n", rs.stats.TotalCommits))
s.WriteString(fmt.Sprintf("Total Repositories: %d\n", rs.stats.TotalRepositories))
s.WriteString(fmt.Sprintf("Average Commits/Day: %.2f\n", rs.stats.AverageCommitsPerDay))
s.WriteString(fmt.Sprintf("Most Active Day: %s\n", rs.stats.MostActiveDay))
s.WriteString(fmt.Sprintf("Most Active Month: %s\n", rs.stats.MostActiveMonth))
s.WriteString("\nTop Repositories:\n")
for i, repo := range rs.stats.TopRepositories {
if i >= 5 {
break
}
s.WriteString(fmt.Sprintf(" %d. %s\n", i+1, repo.Name))
}
return s.String()
}
func (rs *ReportScreen) renderLanguagesPage() string {
var s strings.Builder
s.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color("14")).
Bold(true).
Render("Languages You Love") + "\n\n")
if len(rs.stats.Languages) == 0 {
s.WriteString("No language data available.\n")
return s.String()
}
maxLength := 30
maxCount := 0
for _, lang := range rs.stats.Languages {
if lang.Count > maxCount {
maxCount = lang.Count
}
}
for i, lang := range rs.stats.Languages {
if i >= 10 {
break
}
barLength := int(float64(lang.Count) / float64(maxCount) * float64(maxLength))
bar := strings.Repeat("█", barLength) + strings.Repeat("░", maxLength-barLength)
s.WriteString(fmt.Sprintf("%-12s %s %d (%.1f%%)\n", lang.Language, bar, lang.Count, lang.Percent))
}
return s.String()
}
func (rs *ReportScreen) renderActivityPage() string {
var s strings.Builder
s.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color("14")).
Bold(true).
Render("Your Activity Patterns") + "\n\n")
s.WriteString("Most Active Days of the Week:\n")
if len(rs.stats.CommitsByWeekday) > 0 {
maxCount := rs.stats.CommitsByWeekday[0].Count
for i, day := range rs.stats.CommitsByWeekday {
if i >= 7 {
break
}
barLength := int(float64(day.Count) / float64(maxCount) * 20)
bar := strings.Repeat("█", barLength) + strings.Repeat("░", 20-barLength)
s.WriteString(fmt.Sprintf("%-12s %s %d\n", day.Day, bar, day.Count))
}
} else {
s.WriteString("No weekday data available.\n")
}
s.WriteString("\nMost Active Months:\n")
if len(rs.stats.CommitsByMonth) > 0 {
maxCount := rs.stats.CommitsByMonth[0].Count
for i, month := range rs.stats.CommitsByMonth {
if i >= 12 {
break
}
barLength := int(float64(month.Count) / float64(maxCount) * 20)
bar := strings.Repeat("█", barLength) + strings.Repeat("░", 20-barLength)
monthStr := month.Date.Format("Jan 2006")
s.WriteString(fmt.Sprintf("%-12s %s %d\n", monthStr, bar, month.Count))
}
} else {
s.WriteString("No monthly data available.\n")
}
return s.String()
}