Added bento :)

This commit is contained in:
2026-05-01 14:10:22 -06:00
parent fec14022cd
commit 9ea2cf8c34
17 changed files with 1304 additions and 253 deletions
+2
View File
@@ -14,4 +14,6 @@ vendor/
*.test
*.out
# Executable and runtime files
wrapped
.wrapped/
+2 -2
View File
@@ -1,10 +1,10 @@
.PHONY: build run clean
build:
go build -o wrapped ./cmd/gitea-wrapped/
go build -o wrapped ./cmd/wrapped-cli/
run: build
./wrapped
clean:
rm -f wrapped
rm -rf ./wrapped ./.wrapped
+3 -1
View File
@@ -1,3 +1,5 @@
# Gitea Wrapped
This was me testing what all this vibe coding nonsense was like. IDK man...
**NOTE: This was initially vibe-coded to see why people do this. My stance is still strictly anti-AI, and do not intend to continue to use it. Results were disappointing enough that I had to make significant changes before pushing anything. This is just a disclaimer for those who care about transparency.**
This is a Go based TUI that builds a "Spotify Wrapped" style recap of your gitea/forgejo activity. All login info is stored locally so I see nothing. Literally just sits in a JSON file on your machine. I hope you like this dumb project.
@@ -6,7 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/atridad/gitea-wrapped/pkg/ui"
"github.com/atridad/wrapped-cli/pkg/ui"
)
func main() {
+6 -2
View File
@@ -1,4 +1,4 @@
module github.com/atridad/gitea-wrapped
module github.com/atridad/wrapped-cli
go 1.26.2
@@ -19,6 +19,8 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -28,6 +30,8 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/image v0.39.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+11
View File
@@ -24,6 +24,10 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -44,9 +48,16 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+94
View File
@@ -0,0 +1,94 @@
package config
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Connection struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Username string `yaml:"username"`
Token string `yaml:"token"`
}
type Config struct {
Connections []Connection `yaml:"connections"`
}
func GetConfigPath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
dir := filepath.Dir(exe)
wrappedDir := filepath.Join(dir, ".wrapped")
if err := os.MkdirAll(wrappedDir, 0o755); err != nil {
return "", err
}
return filepath.Join(wrappedDir, "config.yaml"), nil
}
func GetResultsDir() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
dir := filepath.Dir(exe)
resultsDir := filepath.Join(dir, ".wrapped", "results")
if err := os.MkdirAll(resultsDir, 0o755); err != nil {
return "", err
}
return resultsDir, nil
}
func LoadConfig() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return &Config{Connections: []Connection{}}, nil
}
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func (c *Config) Save() error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0o600)
}
func (c *Config) GetConnection(name string) *Connection {
for i := range c.Connections {
if c.Connections[i].Name == name {
return &c.Connections[i]
}
}
return nil
}
func (c *Config) AddConnection(conn Connection) {
c.Connections = append(c.Connections, conn)
}
+44 -4
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"strings"
"sync"
"time"
)
@@ -47,6 +48,7 @@ type GiteaRepo struct {
type GiteaCommit struct {
SHA string `json:"sha"`
RepoName string `json:"-"`
Commit struct {
Author struct {
Name string `json:"name"`
@@ -57,7 +59,11 @@ type GiteaCommit struct {
} `json:"commit"`
}
// do makes an authenticated HTTP request to Gitea API
type RepoRef struct {
Owner string
Name string
}
func (c *Client) do(method, path string) ([]byte, error) {
url := fmt.Sprintf("%s/api/v1%s", c.BaseURL, path)
@@ -68,7 +74,7 @@ func (c *Client) do(method, path string) ([]byte, error) {
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")
req.Header.Set("User-Agent", "wrapped-cli/1.0")
resp, err := c.Client.Do(req)
if err != nil {
@@ -96,7 +102,7 @@ func (c *Client) GetUserRepos() ([]GiteaRepo, error) {
var repos []GiteaRepo
page := 1
pageSize := 50
maxPages := 100 // Safety limit to prevent infinite loops
maxPages := 100
for page <= maxPages {
path := fmt.Sprintf("/user/repos?page=%d&limit=%d", page, pageSize)
@@ -133,7 +139,7 @@ func (c *Client) GetRepoCommits(owner, repo string) ([]GiteaCommit, error) {
var commits []GiteaCommit
page := 1
pageSize := 50
maxPages := 1000 // Safety limit to prevent infinite loops
maxPages := 1000
for page <= maxPages {
path := fmt.Sprintf("/repos/%s/%s/commits?page=%d&limit=%d", owner, repo, page, pageSize)
@@ -166,6 +172,40 @@ func (c *Client) GetRepoCommits(owner, repo string) ([]GiteaCommit, error) {
return commits, nil
}
func (c *Client) GetReposCommitsParallel(repos []RepoRef) ([]GiteaCommit, error) {
var allCommits []GiteaCommit
var mu sync.Mutex
var wg sync.WaitGroup
semaphore := make(chan struct{}, 5)
for _, repo := range repos {
wg.Add(1)
go func(owner, repoName string) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
commits, err := c.GetRepoCommits(owner, repoName)
if err != nil {
return
}
for i := range commits {
commits[i].RepoName = repoName
}
mu.Lock()
allCommits = append(allCommits, commits...)
mu.Unlock()
}(repo.Owner, repo.Name)
}
wg.Wait()
return allCommits, nil
}
func (c *Client) TestConnection() error {
_, err := c.do("GET", "/user")
if err != nil {
+1
View File
@@ -15,6 +15,7 @@ type Commit struct {
SHA string
Message string
Author string
AuthorEmail string
Timestamp time.Time
RepoName string
}
+458
View File
@@ -0,0 +1,458 @@
package reports
import (
"fmt"
"image/color"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"time"
"github.com/atridad/wrapped-cli/pkg/config"
"github.com/atridad/wrapped-cli/pkg/models"
"github.com/fogleman/gg"
)
const (
canvasW = 1400
canvasH = 1160
gridGap = 20
radius = 24
unitCol = 325
twoCol = 670
fourCol = 1360
)
var (
bgColor = color.RGBA{9, 9, 11, 255}
cardBg = color.RGBA{24, 24, 27, 255}
cardBgPurple = color.RGBA{30, 20, 36, 255}
cardBgBlue = color.RGBA{15, 23, 42, 255}
accentMagenta = color.RGBA{192, 132, 252, 255}
accentCyan = color.RGBA{45, 212, 191, 255}
accentOrange = color.RGBA{251, 146, 60, 255}
accentGreen = color.RGBA{74, 222, 128, 255}
accentBlue = color.RGBA{96, 165, 250, 255}
textPrimary = color.RGBA{244, 244, 245, 255}
textSecondary = color.RGBA{161, 161, 170, 255}
textMuted = color.RGBA{113, 113, 122, 255}
borderColor = color.RGBA{39, 39, 42, 255}
barTrack = color.RGBA{39, 39, 42, 255}
)
type bentoCard struct {
x, y, w, h float64
bg color.RGBA
accent color.RGBA
}
func col(n int) float64 {
return float64(gridGap + n*(unitCol+gridGap))
}
func cardLayout() []bentoCard {
r1y := float64(gridGap)
r1h := float64(240)
r2y := r1y + r1h + gridGap
r2h := float64(240)
r3y := r2y + r2h + gridGap
r3h := float64(300)
r4y := r3y + r3h + gridGap
r4h := float64(280)
return []bentoCard{
{col(0), r1y, twoCol, r1h, cardBgPurple, accentMagenta},
{col(2), r1y, unitCol, r1h, cardBg, accentOrange},
{col(3), r1y, unitCol, r1h, cardBg, accentCyan},
{col(0), r2y, unitCol, r2h, cardBg, accentGreen},
{col(1), r2y, unitCol, r2h, cardBg, accentOrange},
{col(2), r2y, twoCol, r2h, cardBgBlue, accentBlue},
{col(0), r3y, fourCol, r3h, cardBg, accentMagenta},
{col(0), r4y, fourCol, r4h, cardBg, accentCyan},
}
}
func loadFont(dc *gg.Context, size float64) {
paths := []string{
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/SFNS.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
}
for _, p := range paths {
if err := dc.LoadFontFace(p, size); err == nil {
return
}
}
}
func drawCard(dc *gg.Context, c bentoCard) {
dc.DrawRoundedRectangle(c.x, c.y, c.w, c.h, radius)
dc.SetColor(c.bg)
dc.Fill()
dc.DrawRoundedRectangle(c.x, c.y, c.w, c.h, radius)
dc.SetColor(borderColor)
dc.SetLineWidth(1)
dc.Stroke()
}
func label(dc *gg.Context, c bentoCard, text string) {
loadFont(dc, 12)
dc.SetColor(textSecondary)
dc.DrawString(text, c.x+24, c.y+32)
}
func bigNum(dc *gg.Context, c bentoCard, value, sub string) {
loadFont(dc, 64)
dc.SetColor(c.accent)
dc.DrawString(value, c.x+24, c.y+110)
if sub != "" {
loadFont(dc, 14)
dc.SetColor(textSecondary)
dc.DrawString(sub, c.x+24, c.y+136)
}
}
func bigText(dc *gg.Context, c bentoCard, value string, size float64) {
loadFont(dc, size)
dc.SetColor(c.accent)
dc.DrawString(value, c.x+24, c.y+110)
}
func filledBar(dc *gg.Context, x, y, totalW, h, pct float64, fill color.RGBA) {
dc.DrawRoundedRectangle(x, y, totalW, h, 6)
dc.SetColor(barTrack)
dc.Fill()
fillW := totalW * pct
if fillW < 8 {
fillW = 8
}
dc.DrawRoundedRectangle(x, y, fillW, h, 6)
dc.SetColor(fill)
dc.Fill()
}
func renderCommitsCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "TOTAL COMMITS")
bigNum(dc, c, formatNumber(stats.TotalCommits),
fmt.Sprintf("%.1f commits per day on average", stats.AverageCommitsPerDay))
dc.DrawRoundedRectangle(c.x+24, c.y+c.h-40, c.w-48, 10, 5)
dc.SetColor(color.RGBA{50, 40, 60, 255})
dc.Fill()
dc.DrawRoundedRectangle(c.x+24, c.y+c.h-40, (c.w-48)*0.72, 10, 5)
dc.SetColor(c.accent)
dc.Fill()
}
func renderReposCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "REPOSITORIES")
bigNum(dc, c, formatNumber(stats.TotalRepositories), "worked on this year")
y := c.y + 160
maxW := c.w - 48
for i, repo := range stats.TopRepositories {
if i >= 3 {
break
}
loadFont(dc, 12)
dc.SetColor(textMuted)
dc.DrawString(truncate(repo.Name, maxW, dc), c.x+24, y)
y += 24
}
}
func renderActiveDayCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "MOST ACTIVE DAY")
bigText(dc, c, stats.MostActiveDay, 48)
if len(stats.CommitsByWeekday) > 0 {
loadFont(dc, 14)
dc.SetColor(textSecondary)
dc.DrawString(
fmt.Sprintf("%d commits that day", stats.CommitsByWeekday[0].Count),
c.x+24, c.y+136,
)
}
}
func renderAvgCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "AVG COMMITS / DAY")
bigNum(dc, c, fmt.Sprintf("%.1f", stats.AverageCommitsPerDay), "daily average")
}
func renderActiveMonthCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "MOST ACTIVE MONTH")
bigText(dc, c, stats.MostActiveMonth, 44)
}
func renderTopRepoCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "TOP REPOSITORY")
if len(stats.TopRepositories) == 0 {
return
}
top := stats.TopRepositories[0]
maxW := c.w - 48
loadFont(dc, 32)
dc.SetColor(c.accent)
dc.DrawString(truncate(top.Name, maxW, dc), c.x+24, c.y+100)
loadFont(dc, 13)
dc.SetColor(textSecondary)
dc.DrawString("your #1 repository this year", c.x+24, c.y+126)
y := c.y + 164
for i := 1; i < len(stats.TopRepositories) && i < 4; i++ {
loadFont(dc, 12)
dc.SetColor(textMuted)
dc.DrawString(
fmt.Sprintf("#%d %s", i+1, truncate(stats.TopRepositories[i].Name, maxW-30, dc)),
c.x+24, y,
)
y += 24
}
}
func renderLanguagesCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "LANGUAGES")
if len(stats.Languages) == 0 {
loadFont(dc, 15)
dc.SetColor(textSecondary)
dc.DrawString("No language data available.", c.x+24, c.y+100)
return
}
const (
numCols = 2
rowsPerCol = 5
rowH = 48.0
startY = 60.0
labelW = 110.0
countW = 90.0
barH = 14.0
)
maxCount := stats.Languages[0].Count
halfW := (c.w - 48 - gridGap) / 2
for i, lang := range stats.Languages {
if i >= numCols*rowsPerCol {
break
}
colIdx := i / rowsPerCol
rowIdx := i % rowsPerCol
baseX := c.x + 24 + float64(colIdx)*(halfW+float64(gridGap))
baseY := c.y + startY + float64(rowIdx)*rowH
pct := float64(lang.Count) / float64(maxCount)
barW := halfW - labelW - countW - 10
loadFont(dc, 13)
dc.SetColor(textPrimary)
dc.DrawString(truncatePx(lang.Language, labelW-8, dc), baseX, baseY+12)
filledBar(dc, baseX+labelW, baseY+2, barW, barH, pct, c.accent)
loadFont(dc, 12)
dc.SetColor(textSecondary)
dc.DrawString(
fmt.Sprintf("%d %.1f%%", lang.Count, lang.Percent),
baseX+labelW+barW+10, baseY+13,
)
}
}
func renderActivityCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "ACTIVITY PATTERNS")
const (
startY = 54.0
rowH = 28.0
labelW = 44.0
countW = 50.0
barH = 14.0
)
halfW := (c.w - 48 - float64(gridGap)) / 2
lx := c.x + 24
loadFont(dc, 12)
dc.SetColor(textSecondary)
dc.DrawString("By Weekday", lx, c.y+startY)
if len(stats.CommitsByWeekday) > 0 {
maxC := stats.CommitsByWeekday[0].Count
barW := halfW - labelW - countW - 10
for i, day := range stats.CommitsByWeekday {
if i >= 7 {
break
}
by := c.y + startY + 24 + float64(i)*rowH
loadFont(dc, 12)
dc.SetColor(textPrimary)
dc.DrawString(day.Day[:3], lx, by+12)
pct := float64(day.Count) / float64(maxC)
filledBar(dc, lx+labelW, by+2, barW, barH, pct, accentCyan)
loadFont(dc, 11)
dc.SetColor(textMuted)
dc.DrawString(formatNumber(day.Count), lx+labelW+barW+8, by+12)
}
}
rx := c.x + 24 + halfW + float64(gridGap)
loadFont(dc, 12)
dc.SetColor(textSecondary)
dc.DrawString("By Month", rx, c.y+startY)
if len(stats.CommitsByMonth) > 0 {
maxC := stats.CommitsByMonth[0].Count
barW := halfW - labelW - countW - 10
for i, month := range stats.CommitsByMonth {
if i >= 7 {
break
}
by := c.y + startY + 24 + float64(i)*rowH
loadFont(dc, 12)
dc.SetColor(textPrimary)
dc.DrawString(month.Date.Format("Jan"), rx, by+12)
pct := float64(month.Count) / float64(maxC)
filledBar(dc, rx+labelW, by+2, barW, barH, pct, accentOrange)
loadFont(dc, 11)
dc.SetColor(textMuted)
dc.DrawString(formatNumber(month.Count), rx+labelW+barW+8, by+12)
}
}
}
func formatNumber(n int) string {
switch {
case n >= 1_000_000:
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
case n >= 1_000:
return fmt.Sprintf("%.1fK", float64(n)/1_000)
default:
return fmt.Sprintf("%d", n)
}
}
func truncate(s string, maxPx float64, dc *gg.Context) string {
return truncatePx(s, maxPx, dc)
}
func truncatePx(s string, maxPx float64, dc *gg.Context) string {
w, _ := dc.MeasureString(s)
if w <= maxPx {
return s
}
for len(s) > 1 {
s = s[:len(s)-1]
w, _ = dc.MeasureString(s + "…")
if w <= maxPx {
return s + "…"
}
}
return s
}
func ExportReportAsImage(stats *models.UserStats, format string) (string, error) {
resultsDir, err := config.GetResultsDir()
if err != nil {
return "", err
}
dc := gg.NewContext(canvasW, canvasH)
dc.SetColor(bgColor)
dc.Clear()
cards := cardLayout()
for _, c := range cards {
drawCard(dc, c)
}
renderCommitsCard(dc, cards[0], stats)
renderReposCard(dc, cards[1], stats)
renderActiveDayCard(dc, cards[2], stats)
renderAvgCard(dc, cards[3], stats)
renderActiveMonthCard(dc, cards[4], stats)
renderTopRepoCard(dc, cards[5], stats)
renderLanguagesCard(dc, cards[6], stats)
renderActivityCard(dc, cards[7], stats)
now := time.Now()
timestamp := now.Format("2006-01-02 15-04-05")
filenameWithExt := fmt.Sprintf("%s - Gitea Wrapped.%s", timestamp, format)
filename := filepath.Join(resultsDir, filenameWithExt)
switch format {
case "png":
f, err := os.Create(filename)
if err != nil {
return "", err
}
defer f.Close()
if err := png.Encode(f, dc.Image()); err != nil {
return "", err
}
case "jpg", "jpeg":
f, err := os.Create(filename)
if err != nil {
return "", err
}
defer f.Close()
if err := jpeg.Encode(f, dc.Image(), &jpeg.Options{Quality: 92}); err != nil {
return "", err
}
default:
return "", fmt.Errorf("unsupported format: %s", format)
}
return filenameWithExt, nil
}
func GenerateReportImage(stats *models.UserStats, format string) (string, error) {
return ExportReportAsImage(stats, format)
}
func stripANSI(s string) string {
b := make([]byte, 0, len(s))
inEscape := false
for i := 0; i < len(s); i++ {
if s[i] == '\x1b' {
inEscape = true
} else if inEscape && s[i] == 'm' {
inEscape = false
} else if !inEscape {
b = append(b, s[i])
}
}
return string(b)
}
func getTextColor(line string) color.Color {
if strings.Contains(line, "Overview") ||
strings.Contains(line, "Languages") ||
strings.Contains(line, "Activity") {
return color.RGBA{0, 255, 255, 255}
}
return color.RGBA{200, 200, 200, 255}
}
+179
View File
@@ -0,0 +1,179 @@
package reports
import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
"gopkg.in/yaml.v3"
"github.com/atridad/wrapped-cli/pkg/config"
"github.com/atridad/wrapped-cli/pkg/models"
)
type ReportMetadata struct {
Timestamp time.Time `yaml:"timestamp"`
Username string `yaml:"username"`
TotalCommits int `yaml:"total_commits"`
Repositories int `yaml:"repositories"`
Stats *models.UserStats `yaml:"stats"`
}
type Report struct {
Metadata *ReportMetadata
MarkdownID string
MetadataID string
Timestamp time.Time
}
func SaveReport(stats *models.UserStats) (string, error) {
resultsDir, err := config.GetResultsDir()
if err != nil {
return "", err
}
now := time.Now()
timestamp := now.Format("2006-01-02 15-04-05")
metadataID := fmt.Sprintf("%s.yaml", now.Format("2006-01-02-15-04-05"))
imageID := fmt.Sprintf("%s - Gitea Wrapped.png", timestamp)
metadata := &ReportMetadata{
Timestamp: now,
Username: stats.Username,
TotalCommits: stats.TotalCommits,
Repositories: stats.TotalRepositories,
Stats: stats,
}
metadataPath := filepath.Join(resultsDir, metadataID)
data, err := yaml.Marshal(metadata)
if err != nil {
return "", err
}
if err := os.WriteFile(metadataPath, data, 0o644); err != nil {
return "", err
}
if _, err := ExportReportAsImage(stats, "png"); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to generate image: %v\n", err)
}
return imageID, nil
}
func ListReports() ([]Report, error) {
resultsDir, err := config.GetResultsDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(resultsDir)
if err != nil {
if os.IsNotExist(err) {
return []Report{}, nil
}
return nil, err
}
var reports []Report
for _, entry := range entries {
if entry.IsDir() {
continue
}
if filepath.Ext(entry.Name()) == ".yaml" {
metadataPath := filepath.Join(resultsDir, entry.Name())
data, err := os.ReadFile(metadataPath)
if err != nil {
continue
}
var metadata ReportMetadata
if err := yaml.Unmarshal(data, &metadata); err != nil {
continue
}
reports = append(reports, Report{
Metadata: &metadata,
MetadataID: entry.Name(),
Timestamp: metadata.Timestamp,
})
}
}
sort.Slice(reports, func(i, j int) bool {
return reports[i].Timestamp.After(reports[j].Timestamp)
})
return reports, nil
}
func GetReport(metadataID string) (*ReportMetadata, error) {
resultsDir, err := config.GetResultsDir()
if err != nil {
return nil, err
}
metadataPath := filepath.Join(resultsDir, metadataID)
data, err := os.ReadFile(metadataPath)
if err != nil {
return nil, err
}
var metadata ReportMetadata
if err := yaml.Unmarshal(data, &metadata); err != nil {
return nil, err
}
return &metadata, nil
}
func generateMarkdown(stats *models.UserStats) string {
md := fmt.Sprintf("# Gitea Wrapped — %s\n\n", time.Now().Format("January 2, 2006"))
md += fmt.Sprintf("**User**: %s\n\n", stats.Username)
md += "## Overview\n\n"
md += fmt.Sprintf("- **Total Commits**: %d\n", stats.TotalCommits)
md += fmt.Sprintf("- **Total Repositories**: %d\n", stats.TotalRepositories)
md += fmt.Sprintf("- **Average Commits/Day**: %.2f\n", stats.AverageCommitsPerDay)
md += fmt.Sprintf("- **Most Active Day**: %s\n", stats.MostActiveDay)
md += fmt.Sprintf("- **Most Active Month**: %s\n\n", stats.MostActiveMonth)
md += "## Languages\n\n"
for i, lang := range stats.Languages {
if i >= 10 {
break
}
md += fmt.Sprintf("- %s: %d repos (%.1f%%)\n", lang.Language, lang.Count, lang.Percent)
}
md += "\n"
md += "## Top Repositories\n\n"
for i, repo := range stats.TopRepositories {
md += fmt.Sprintf("%d. [%s](%s)\n", i+1, repo.Name, repo.HTMLURL)
}
md += "\n"
md += "## Most Active Days\n\n"
for i, day := range stats.CommitsByWeekday {
if i >= 7 {
break
}
md += fmt.Sprintf("- %s: %d commits\n", day.Day, day.Count)
}
md += "\n"
md += "## Most Active Months\n\n"
for i, month := range stats.CommitsByMonth {
if i >= 12 {
break
}
md += fmt.Sprintf("- %s: %d commits\n", month.Date.Format("January 2006"), month.Count)
}
return md
}
+57 -31
View File
@@ -4,8 +4,8 @@ import (
"sort"
"time"
"github.com/atridad/gitea-wrapped/pkg/gitea"
"github.com/atridad/gitea-wrapped/pkg/models"
"github.com/atridad/wrapped-cli/pkg/gitea"
"github.com/atridad/wrapped-cli/pkg/models"
)
type Analyzer struct {
@@ -28,17 +28,48 @@ func (a *Analyzer) AddCommits(commits []models.Commit) {
a.commits = append(a.commits, commits...)
}
func (a *Analyzer) inferPrimaryEmail(username string) string {
emailCount := make(map[string]int)
for _, c := range a.commits {
if c.AuthorEmail != "" {
emailCount[c.AuthorEmail]++
}
}
var bestEmail string
max := 0
for email, count := range emailCount {
if count > max {
max = count
bestEmail = email
}
}
return bestEmail
}
func (a *Analyzer) Generate(username string) *models.UserStats {
primaryEmail := a.inferPrimaryEmail(username)
var filteredCommits []models.Commit
for _, commit := range a.commits {
if primaryEmail != "" && commit.AuthorEmail == primaryEmail {
filteredCommits = append(filteredCommits, commit)
}
}
stats := &models.UserStats{
Username: username,
TotalCommits: len(a.commits),
TotalCommits: len(filteredCommits),
TotalRepositories: len(a.repos),
}
stats.Languages = a.generateLanguageStats()
stats.CommitsByWeekday = a.generateWeekdayStats()
stats.CommitsByMonth = a.generateMonthStats()
stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay()
stats.CommitsByWeekday = a.generateWeekdayStats(filteredCommits)
stats.CommitsByMonth = a.generateMonthStats(filteredCommits)
stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay(filteredCommits)
if len(stats.CommitsByMonth) > 0 {
stats.MostActiveMonth = stats.CommitsByMonth[0].Date.Format("January")
@@ -47,12 +78,11 @@ func (a *Analyzer) Generate(username string) *models.UserStats {
stats.MostActiveDay = stats.CommitsByWeekday[0].Day
}
stats.TopRepositories = a.getTopRepositories(5)
stats.TopRepositories = a.getTopRepositories(filteredCommits, 5)
return stats
}
// generateLanguageStats creates language statistics
func (a *Analyzer) generateLanguageStats() []models.LanguageStats {
langCount := make(map[string]int)
@@ -82,12 +112,11 @@ func (a *Analyzer) generateLanguageStats() []models.LanguageStats {
return stats
}
// generateWeekdayStats creates weekday statistics for commits
func (a *Analyzer) generateWeekdayStats() []models.DateStats {
func (a *Analyzer) generateWeekdayStats(commits []models.Commit) []models.DateStats {
dayCount := make(map[time.Weekday]int)
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
for _, commit := range a.commits {
for _, commit := range commits {
day := commit.Timestamp.Weekday()
dayCount[day]++
}
@@ -108,19 +137,17 @@ func (a *Analyzer) generateWeekdayStats() []models.DateStats {
return stats
}
// generateMonthStats creates monthly statistics for commits
func (a *Analyzer) generateMonthStats() []models.DateStats {
func (a *Analyzer) generateMonthStats(commits []models.Commit) []models.DateStats {
monthCount := make(map[string]int)
monthKeys := []string{}
monthMap := make(map[string]time.Time)
for _, commit := range a.commits {
for _, commit := range commits {
monthStr := commit.Timestamp.Format("2006-01")
monthCount[monthStr]++
if _, exists := monthMap[monthStr]; !exists {
monthKeys = append(monthKeys, monthStr)
monthMap[monthStr] = commit.Timestamp
}
for k := range monthCount {
monthKeys = append(monthKeys, k)
}
var stats []models.DateStats
@@ -139,37 +166,35 @@ func (a *Analyzer) generateMonthStats() []models.DateStats {
return stats
}
// calculateAverageCommitsPerDay calculates average commits per calendar day
func (a *Analyzer) calculateAverageCommitsPerDay() float64 {
if len(a.commits) == 0 {
func (a *Analyzer) calculateAverageCommitsPerDay(commits []models.Commit) float64 {
if len(commits) == 0 {
return 0
}
if len(a.commits) < 2 {
return float64(len(a.commits))
if len(commits) < 2 {
return float64(len(commits))
}
sort.Slice(a.commits, func(i, j int) bool {
return a.commits[i].Timestamp.Before(a.commits[j].Timestamp)
sort.Slice(commits, func(i, j int) bool {
return commits[i].Timestamp.Before(commits[j].Timestamp)
})
firstDay := a.commits[0].Timestamp
lastDay := a.commits[len(a.commits)-1].Timestamp
firstDay := commits[0].Timestamp
lastDay := commits[len(commits)-1].Timestamp
daysDiff := lastDay.Sub(firstDay).Hours() / 24
if daysDiff == 0 {
daysDiff = 1
}
return float64(len(a.commits)) / daysDiff
return float64(len(commits)) / daysDiff
}
// getTopRepositories returns the top N repositories by commit count
func (a *Analyzer) getTopRepositories(n int) []models.Repository {
func (a *Analyzer) getTopRepositories(commits []models.Commit, n int) []models.Repository {
repoCommitCount := make(map[string]int)
repoMap := make(map[string]models.Repository)
for _, commit := range a.commits {
for _, commit := range commits {
repoCommitCount[commit.RepoName]++
}
@@ -188,6 +213,7 @@ func (a *Analyzer) getTopRepositories(n int) []models.Repository {
repo models.Repository
count int
}
var repos []repoWithCount
for name, count := range repoCommitCount {
if repo, exists := repoMap[name]; exists {
+94 -24
View File
@@ -8,15 +8,17 @@ import (
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"
"github.com/atridad/wrapped-cli/pkg/config"
"github.com/atridad/wrapped-cli/pkg/gitea"
"github.com/atridad/wrapped-cli/pkg/models"
"github.com/atridad/wrapped-cli/pkg/stats"
)
type screenType int
const (
inputScreenType screenType = iota
menuScreenType screenType = iota
inputScreenType
loadingScreenType
reportScreenType
)
@@ -33,6 +35,7 @@ username string
type App struct {
screen screenType
menuScreen *MenuScreen
inputScreen *InputScreen
reportScreen *ReportScreen
loadingMsg string
@@ -42,19 +45,37 @@ analyzer *stats.Analyzer
spinnerFrame int
repos []gitea.GiteaRepo
username string
config *config.Config
}
func NewApp() *App {
cfg, err := config.LoadConfig()
if err != nil || cfg == nil {
cfg = &config.Config{Connections: []config.Connection{}}
}
screen := menuScreenType
var inputScreen *InputScreen
if len(cfg.Connections) == 0 {
screen = inputScreenType
inputScreen = NewInputScreen()
}
return &App{
screen: inputScreenType,
inputScreen: NewInputScreen(),
screen: screen,
menuScreen: NewMenuScreen(cfg),
inputScreen: inputScreen,
config: cfg,
analyzer: stats.NewAnalyzer(),
}
}
func (a *App) Init() tea.Cmd {
if a.screen == inputScreenType && a.inputScreen != nil {
return a.inputScreen.Init()
}
return nil
}
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
@@ -72,11 +93,41 @@ return tickMsg(t)
}
switch a.screen {
case menuScreenType:
_, cmd := a.menuScreen.Update(msg)
if a.menuScreen.IsNewConnection() {
a.screen = inputScreenType
a.menuScreen.ResetSelection()
a.inputScreen = NewInputScreen()
return a, a.inputScreen.Init()
}
if selected := a.menuScreen.GetSelectedName(); selected != "" {
conn := a.config.GetConnection(selected)
if conn != nil {
a.menuScreen.ResetSelection()
a.giteaClient = gitea.NewClient(conn.URL, conn.Token)
a.username = conn.Username
a.screen = loadingScreenType
a.loadingMsg = "Connecting to Gitea..."
a.spinnerFrame = 0
return a, tea.Batch(
tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg(t)
}),
a.testConnection())
}
}
return a, cmd
case inputScreenType:
_, cmd := a.inputScreen.Update(msg)
if a.inputScreen.IsDone() {
url, username, token := a.inputScreen.GetCredentials()
url, username, token, connName := a.inputScreen.GetCredentials()
if url == "" {
a.inputScreen.err = fmt.Errorf("URL is required")
@@ -94,6 +145,16 @@ a.inputScreen.done = false
return a, cmd
}
if connName != "" {
a.config.AddConnection(config.Connection{
Name: connName,
URL: url,
Username: username,
Token: token,
})
a.config.Save()
}
a.giteaClient = gitea.NewClient(url, token)
a.username = username
a.screen = loadingScreenType
@@ -137,6 +198,16 @@ return a, nil
}
case reportScreenType:
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
a.screen = menuScreenType
a.menuScreen = NewMenuScreen(a.config)
a.reportScreen = nil
a.analyzer = stats.NewAnalyzer()
return a, nil
}
}
if a.reportScreen != nil {
_, cmd := a.reportScreen.Update(msg)
return a, cmd
@@ -148,6 +219,8 @@ return a, nil
func (a *App) View() string {
switch a.screen {
case menuScreenType:
return a.menuScreen.View()
case inputScreenType:
return a.inputScreen.View()
case loadingScreenType:
@@ -165,7 +238,7 @@ var s string
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("212")).
Bold(true).
Render("🎵 Gitea Wrapped") + "\n\n"
Render("Gitea Wrapped") + "\n\n"
spinners := []string{"⠋", "⠙", "⠹", "⠸"}
spinner := spinners[a.spinnerFrame%4]
@@ -181,7 +254,6 @@ 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 {
@@ -191,7 +263,6 @@ return progressMsg("Fetching repositories...")
}
}
// fetchRepos fetches all repositories
func (a *App) fetchRepos() tea.Cmd {
return func() tea.Msg {
repos, err := a.giteaClient.GetUserRepos()
@@ -204,37 +275,36 @@ 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 {
var repoRefs []gitea.RepoRef
for _, 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
repoRefs = append(repoRefs, gitea.RepoRef{
Owner: parts[0],
Name: repo.Name,
})
}
giteaCommits, err := a.giteaClient.GetReposCommitsParallel(repoRefs)
if err != nil {
return errorMsg(fmt.Sprintf("failed to fetch commits: %v", err))
}
var commits []models.Commit
for _, gc := range giteaCommits {
commits = append(commits, models.Commit{
SHA: gc.SHA,
Message: gc.Commit.Message,
Author: gc.Commit.Author.Name,
AuthorEmail: gc.Commit.Author.Email,
Timestamp: gc.Commit.Author.Date,
RepoName: repo.Name,
RepoName: gc.RepoName,
})
}
}
return doneMsg{a.repos, commits, a.username}
}
}
+10
View File
@@ -0,0 +1,10 @@
package ui
import (
"github.com/atridad/wrapped-cli/pkg/models"
"github.com/atridad/wrapped-cli/pkg/reports"
)
func ExportReport(stats *models.UserStats) (string, error) {
return reports.SaveReport(stats)
}
+28 -6
View File
@@ -14,17 +14,20 @@ const (
urlInput inputScreenState = iota
usernameInput
tokenInput
connNameInput
)
type InputScreen struct {
urlField textinput.Model
usernameField textinput.Model
tokenField textinput.Model
connNameField textinput.Model
focusedField inputScreenState
err error
url string
username string
token string
connName string
done bool
}
@@ -40,10 +43,14 @@ func NewInputScreen() *InputScreen {
tokenField.Placeholder = "your_access_token"
tokenField.EchoMode = textinput.EchoPassword
connNameField := textinput.New()
connNameField.Placeholder = "(optional) Connection name"
return &InputScreen{
urlField: urlField,
usernameField: usernameField,
tokenField: tokenField,
connNameField: connNameField,
focusedField: urlInput,
}
}
@@ -72,6 +79,11 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return is, nil
case tokenInput:
is.token = is.tokenField.Value()
is.focusedField = connNameInput
is.connNameField.Focus()
return is, nil
case connNameInput:
is.connName = is.connNameField.Value()
is.done = true
return is, nil
}
@@ -83,17 +95,20 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
is.username = is.usernameField.Value()
case tokenInput:
is.token = is.tokenField.Value()
case connNameInput:
is.connName = is.connNameField.Value()
}
if msg.Type == tea.KeyTab {
is.focusedField = (is.focusedField + 1) % 3
is.focusedField = (is.focusedField + 1) % 4
} else {
is.focusedField = (is.focusedField - 1 + 3) % 3
is.focusedField = (is.focusedField - 1 + 4) % 4
}
is.urlField.Blur()
is.usernameField.Blur()
is.tokenField.Blur()
is.connNameField.Blur()
switch is.focusedField {
case urlInput:
@@ -102,6 +117,8 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
is.usernameField.Focus()
case tokenInput:
is.tokenField.Focus()
case connNameInput:
is.connNameField.Focus()
}
return is, nil
}
@@ -119,6 +136,8 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
is.usernameField, cmd = is.usernameField.Update(msg)
case tokenInput:
is.tokenField, cmd = is.tokenField.Update(msg)
case connNameInput:
is.connNameField, cmd = is.connNameField.Update(msg)
}
return is, cmd
@@ -132,7 +151,7 @@ func (is *InputScreen) View() string {
Bold(true).
Render("Gitea Wrapped") + "\n\n"
s += "Enter your Gitea credentials to get started:\n\n"
s += "Enter your Gitea credentials:\n\n"
s += "Gitea URL\n"
s += is.urlField.View() + "\n\n"
@@ -143,6 +162,9 @@ func (is *InputScreen) View() string {
s += "Access Token\n"
s += is.tokenField.View() + "\n\n"
s += "Connection Name (optional - to save for later)\n"
s += is.connNameField.View() + "\n\n"
if is.err != nil {
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("1")).
@@ -151,7 +173,7 @@ func (is *InputScreen) View() string {
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Render("(press Tab to navigate, Enter to continue, Ctrl+C to quit)") + "\n"
Render("(Tab to navigate, Enter to continue, Ctrl+C to quit)") + "\n"
return s
}
@@ -160,8 +182,8 @@ func (is *InputScreen) IsDone() bool {
return is.done
}
func (is *InputScreen) GetCredentials() (string, string, string) {
return is.url, is.username, is.token
func (is *InputScreen) GetCredentials() (url, username, token, connName string) {
return is.url, is.username, is.token, is.connName
}
type errMsg error
+122
View File
@@ -0,0 +1,122 @@
package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/atridad/wrapped-cli/pkg/config"
)
type MenuScreen struct {
connections []config.Connection
selected int
newSelected bool
confirmed bool
}
func NewMenuScreen(cfg *config.Config) *MenuScreen {
return &MenuScreen{
connections: cfg.Connections,
selected: 0,
confirmed: false,
}
}
func (m *MenuScreen) Init() tea.Cmd {
return nil
}
func (m *MenuScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.selected > 0 {
m.selected--
}
case "down", "j":
menuItems := len(m.connections) + 1
if m.selected < menuItems-1 {
m.selected++
}
case "enter":
if m.selected == len(m.connections) {
m.newSelected = true
} else if m.selected < len(m.connections) {
m.confirmed = true
}
}
}
return m, nil
}
func (m *MenuScreen) View() string {
var s string
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("212")).
Bold(true).
Render("Gitea Wrapped") + "\n\n"
if len(m.connections) == 0 {
s += "No saved connections yet.\n\n"
newPrefix := " "
if m.selected == 0 {
newPrefix = "→ "
}
if m.selected == 1 {
}
s += fmt.Sprintf("%s+ New Connection\n", newPrefix)
s += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Render("↑/↓ navigate | Enter to select | Ctrl+C to quit")
} else {
s += "Select a connection:\n\n"
for i, conn := range m.connections {
prefix := " "
if i == m.selected {
prefix = "→ "
}
s += fmt.Sprintf("%s%s (%s)\n", prefix, conn.Name, conn.URL)
}
newPrefix := " "
if m.selected == len(m.connections) {
newPrefix = "→ "
}
if m.selected == len(m.connections)+1 {
}
s += fmt.Sprintf("%s+ New Connection\n", newPrefix)
s += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Render("↑/↓ navigate | Enter to select | Ctrl+C to quit")
}
return s
}
func (m *MenuScreen) IsNewConnection() bool {
return m.newSelected
}
func (m *MenuScreen) GetSelectedName() string {
if m.confirmed && m.selected < len(m.connections) {
return m.connections[m.selected].Name
}
return ""
}
func (m *MenuScreen) ResetSelection() {
m.newSelected = false
m.confirmed = false
m.selected = 0
}
+12 -2
View File
@@ -7,20 +7,23 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/atridad/gitea-wrapped/pkg/models"
"github.com/atridad/wrapped-cli/pkg/models"
)
type ReportScreen struct {
stats *models.UserStats
page int
maxPages int
exportFile string
}
func NewReportScreen(stats *models.UserStats) *ReportScreen {
exportFile, _ := ExportReport(stats)
return &ReportScreen{
stats: stats,
page: 0,
maxPages: 3,
exportFile: exportFile,
}
}
@@ -67,7 +70,14 @@ func (rs *ReportScreen) View() string {
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
report := header + "\n" + content + "\n\n" + footer
if rs.exportFile != "" {
report += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("10")).
Render(fmt.Sprintf("✓ Report saved: %s", rs.exportFile))
}
return report
}
func (rs *ReportScreen) renderOverviewPage() string {