package reports import ( _ "embed" "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" ) //go:embed BitCount.ttf var fontData []byte var fontPath string func init() { tmpDir := os.TempDir() fontPath = filepath.Join(tmpDir, "BitCount.ttf") if _, err := os.Stat(fontPath); os.IsNotExist(err) { if err := os.WriteFile(fontPath, fontData, 0644); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to write embedded font: %v\n", err) } } } const ( canvasW = 2100 canvasH = 1740 gridGap = 30 radius = 36 unitCol = 487 twoCol = 1005 fourCol = 2040 ) 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{220, 90, 255, 255} accentCyan = color.RGBA{0, 244, 255, 255} accentOrange = color.RGBA{255, 150, 0, 255} accentGreen = color.RGBA{34, 255, 100, 255} accentBlue = color.RGBA{100, 180, 255, 255} textPrimary = color.RGBA{255, 255, 255, 255} textSecondary = color.RGBA{200, 200, 215, 255} textMuted = color.RGBA{150, 150, 170, 255} borderColor = color.RGBA{60, 60, 70, 255} barTrack = color.RGBA{50, 50, 65, 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(360) r2y := r1y + r1h + gridGap r2h := float64(360) r3y := r2y + r2h + gridGap r3h := float64(450) r4y := r3y + r3h + gridGap r4h := float64(420) 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) { if err := dc.LoadFontFace(fontPath, size); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to load embedded font: %v\n", err) } } 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, 18) dc.SetColor(textSecondary) dc.DrawString(text, c.x+36, c.y+48) } func bigNum(dc *gg.Context, c bentoCard, value, sub string) { loadFont(dc, 96) dc.SetColor(c.accent) dc.DrawString(value, c.x+36, c.y+165) if sub != "" { loadFont(dc, 21) dc.SetColor(textSecondary) dc.DrawString(sub, c.x+36, c.y+204) } } func bigText(dc *gg.Context, c bentoCard, value string, size float64) { loadFont(dc, size*1.5) dc.SetColor(c.accent) dc.DrawString(value, c.x+36, c.y+165) } 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+36, c.y+c.h-60, c.w-72, 15, 7) dc.SetColor(color.RGBA{70, 50, 90, 255}) dc.Fill() dc.DrawRoundedRectangle(c.x+36, c.y+c.h-60, (c.w-72)*0.72, 15, 7) 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 + 240 maxW := c.w - 72 for i, repo := range stats.TopRepositories { if i >= 3 { break } loadFont(dc, 18) dc.SetColor(textMuted) dc.DrawString(truncate(repo.Name, maxW, dc), c.x+36, y) y += 36 } } func renderActiveDayCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { label(dc, c, "MOST ACTIVE DAY") bigText(dc, c, stats.MostActiveDay, 72) if len(stats.CommitsByWeekday) > 0 { loadFont(dc, 21) dc.SetColor(textSecondary) dc.DrawString( fmt.Sprintf("%d commits that day", stats.CommitsByWeekday[0].Count), c.x+36, c.y+204, ) } } 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, 66) } 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 - 72 loadFont(dc, 48) dc.SetColor(c.accent) dc.DrawString(truncate(top.Name, maxW, dc), c.x+36, c.y+150) loadFont(dc, 19) dc.SetColor(textSecondary) dc.DrawString("your #1 repository this year", c.x+36, c.y+189) y := c.y + 246 for i := 1; i < len(stats.TopRepositories) && i < 4; i++ { loadFont(dc, 18) dc.SetColor(textMuted) dc.DrawString( fmt.Sprintf("#%d %s", i+1, truncate(stats.TopRepositories[i].Name, maxW-45, dc)), c.x+36, y, ) y += 36 } } func renderLanguagesCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { label(dc, c, "LANGUAGES") if len(stats.Languages) == 0 { loadFont(dc, 22) dc.SetColor(textSecondary) dc.DrawString("No language data available.", c.x+36, c.y+150) return } const ( numCols = 2 rowsPerCol = 5 rowH = 72.0 startY = 90.0 labelW = 165.0 countW = 135.0 barH = 21.0 ) maxCount := stats.Languages[0].Count halfW := (c.w - 72 - gridGap) / 2 for i, lang := range stats.Languages { if i >= numCols*rowsPerCol { break } colIdx := i / rowsPerCol rowIdx := i % rowsPerCol baseX := c.x + 36 + float64(colIdx)*(halfW+float64(gridGap)) baseY := c.y + startY + float64(rowIdx)*rowH pct := float64(lang.Count) / float64(maxCount) barW := halfW - labelW - countW - 15 loadFont(dc, 19) dc.SetColor(textPrimary) dc.DrawString(truncatePx(lang.Language, labelW-12, dc), baseX, baseY+18) filledBar(dc, baseX+labelW, baseY+3, barW, barH, pct, c.accent) loadFont(dc, 18) dc.SetColor(textSecondary) dc.DrawString( fmt.Sprintf("%d %.1f%%", lang.Count, lang.Percent), baseX+labelW+barW+15, baseY+19, ) } } func renderActivityCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { label(dc, c, "ACTIVITY PATTERNS") const ( startY = 81.0 rowH = 42.0 labelW = 66.0 countW = 75.0 barH = 21.0 ) halfW := (c.w - 72 - float64(gridGap)) / 2 lx := c.x + 36 loadFont(dc, 18) 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 - 15 for i, day := range stats.CommitsByWeekday { if i >= 7 { break } by := c.y + startY + 36 + float64(i)*rowH loadFont(dc, 18) dc.SetColor(textPrimary) dc.DrawString(day.Day[:3], lx, by+18) pct := float64(day.Count) / float64(maxC) filledBar(dc, lx+labelW, by+3, barW, barH, pct, accentCyan) loadFont(dc, 16) dc.SetColor(textMuted) dc.DrawString(formatNumber(day.Count), lx+labelW+barW+12, by+18) } } rx := c.x + 36 + halfW + float64(gridGap) loadFont(dc, 18) 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 - 15 for i, month := range stats.CommitsByMonth { if i >= 7 { break } by := c.y + startY + 36 + float64(i)*rowH loadFont(dc, 18) dc.SetColor(textPrimary) dc.DrawString(month.Date.Format("Jan"), rx, by+18) pct := float64(month.Count) / float64(maxC) filledBar(dc, rx+labelW, by+3, barW, barH, pct, accentOrange) loadFont(dc, 16) dc.SetColor(textMuted) dc.DrawString(formatNumber(month.Count), rx+labelW+barW+12, by+18) } } } 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} }