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 embeddedFontData []byte var embeddedFontPath string func init() { tempDir := os.TempDir() embeddedFontPath = filepath.Join(tempDir, "BitCount.ttf") if _, err := os.Stat(embeddedFontPath); os.IsNotExist(err) { if err := os.WriteFile(embeddedFontPath, embeddedFontData, 0644); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to write embedded font: %v\n", err) } } } const ( canvasWidth = 2100 canvasHeight = 1740 gridGap = 30 gridColumnW = 487 cardRadius = 36 cardPadding = 36 cardBorderW = 1 defaultBarH = 21 defaultBarRad = 6 ) type cardKey string const ( cardTotalCommits cardKey = "total_commits" cardRepos cardKey = "repositories" cardActiveDay cardKey = "active_day" cardAvgCommits cardKey = "avg_commits" cardActiveMonth cardKey = "active_month" cardTopRepo cardKey = "top_repo" cardLanguages cardKey = "languages" cardActivity cardKey = "activity" ) type gridSize struct { Cols int Rows int } type gridPosition struct { Col int Row int ColSpan int RowSpan int } type cardStyle struct { Background color.RGBA Accent color.RGBA } type cardSpec struct { Key cardKey Layout gridPosition Style cardStyle Renderer func(*gg.Context, bentoCard, *models.UserStats) } type bentoCard struct { Key cardKey X float64 Y float64 W float64 H float64 Background color.RGBA Accent color.RGBA } // Bento card rows var bentoRowHeights = []int{ 360, 360, 450, 420, } // Bento card styling var bentoCardSpecs = []cardSpec{ { Key: cardTotalCommits, Layout: gridPosition{ Col: 0, Row: 0, ColSpan: 2, RowSpan: 1, }, Style: cardStyle{Background: cardBgPurple, Accent: accentMagenta}, Renderer: renderCommitsCard, }, { Key: cardRepos, Layout: gridPosition{ Col: 2, Row: 0, ColSpan: 1, RowSpan: 1, }, Style: cardStyle{Background: cardBg, Accent: accentOrange}, Renderer: renderReposCard, }, { Key: cardActiveDay, Layout: gridPosition{ Col: 3, Row: 0, ColSpan: 1, RowSpan: 1, }, Style: cardStyle{Background: cardBg, Accent: accentCyan}, Renderer: renderActiveDayCard, }, { Key: cardAvgCommits, Layout: gridPosition{ Col: 0, Row: 1, ColSpan: 1, RowSpan: 1, }, Style: cardStyle{Background: cardBg, Accent: accentGreen}, Renderer: renderAvgCard, }, { Key: cardActiveMonth, Layout: gridPosition{ Col: 1, Row: 1, ColSpan: 2, RowSpan: 1, }, Style: cardStyle{Background: cardBg, Accent: accentOrange}, Renderer: renderActiveMonthCard, }, { Key: cardTopRepo, Layout: gridPosition{ Col: 3, Row: 1, ColSpan: 1, RowSpan: 1, }, Style: cardStyle{Background: cardBgBlue, Accent: accentBlue}, Renderer: renderTopRepoCard, }, { Key: cardLanguages, Layout: gridPosition{ Col: 0, Row: 2, ColSpan: 4, RowSpan: 1, }, Style: cardStyle{Background: cardBg, Accent: accentMagenta}, Renderer: renderLanguagesCard, }, { Key: cardActivity, Layout: gridPosition{ Col: 0, Row: 3, ColSpan: 4, RowSpan: 1, }, Style: cardStyle{Background: cardBg, Accent: accentCyan}, Renderer: renderActivityCard, }, } 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} ) func buildBentoCards(specs []cardSpec) []bentoCard { cards := make([]bentoCard, 0, len(specs)) for _, spec := range specs { x := gridX(spec.Layout.Col) y := gridY(spec.Layout.Row) w := gridWidth(spec.Layout.ColSpan) h := gridHeight(spec.Layout.Row, spec.Layout.RowSpan) cards = append(cards, bentoCard{ Key: spec.Key, X: x, Y: y, W: w, H: h, Background: spec.Style.Background, Accent: spec.Style.Accent, }) } return cards } func gridX(col int) float64 { return float64(gridGap + col*(gridColumnW+gridGap)) } func gridY(row int) float64 { y := gridGap for _, h := range bentoRowHeights[:row] { y += h + gridGap } return float64(y) } func gridWidth(colSpan int) float64 { return float64(colSpan*gridColumnW + (colSpan-1)*gridGap) } func gridHeight(startRow int, rowSpan int) float64 { height := 0 for row := startRow; row < startRow+rowSpan; row++ { height += bentoRowHeights[row] } height += (rowSpan - 1) * gridGap return float64(height) } func cardInnerW(card bentoCard) float64 { return card.W - cardPadding*2 } func cardLeft(card bentoCard) float64 { return card.X + cardPadding } func cardTop(card bentoCard) float64 { return card.Y + cardPadding } func loadFont(ctx *gg.Context, size float64) { if err := ctx.LoadFontFace(embeddedFontPath, size); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to load embedded font: %v\n", err) } } func drawCard(ctx *gg.Context, card bentoCard) { ctx.DrawRoundedRectangle(card.X, card.Y, card.W, card.H, cardRadius) ctx.SetColor(card.Background) ctx.Fill() ctx.DrawRoundedRectangle(card.X, card.Y, card.W, card.H, cardRadius) ctx.SetColor(borderColor) ctx.SetLineWidth(cardBorderW) ctx.Stroke() } func drawCardLabel(ctx *gg.Context, card bentoCard, text string) { loadFont(ctx, 18) ctx.SetColor(textSecondary) lines := wrapText(text, cardInnerW(card), ctx) for i, line := range lines { ctx.DrawString(line, cardLeft(card), card.Y+48+float64(i)*24) } } func drawMetric(ctx *gg.Context, card bentoCard, value string, subtitle string) { loadFont(ctx, 96) ctx.SetColor(card.Accent) valueLines := wrapText(value, cardInnerW(card), ctx) for i, line := range valueLines { ctx.DrawString(line, cardLeft(card), card.Y+165+float64(i)*110) } if subtitle == "" { return } loadFont(ctx, 21) ctx.SetColor(textSecondary) subtitleLines := wrapText(subtitle, cardInnerW(card), ctx) for i, line := range subtitleLines { ctx.DrawString(line, cardLeft(card), card.Y+204+float64(i)*28) } } func drawLargeText(ctx *gg.Context, card bentoCard, text string, fontSize float64) { loadFont(ctx, fontSize*1.5) ctx.SetColor(card.Accent) lines := wrapText(text, cardInnerW(card), ctx) lineHeight := fontSize * 1.5 * 1.2 for i, line := range lines { ctx.DrawString(line, cardLeft(card), card.Y+165+float64(i)*lineHeight) } } func drawFilledBar( ctx *gg.Context, x float64, y float64, width float64, height float64, percent float64, fill color.RGBA, ) { ctx.DrawRoundedRectangle(x, y, width, height, defaultBarRad) ctx.SetColor(barTrack) ctx.Fill() fillWidth := width * percent if fillWidth < 8 { fillWidth = 8 } ctx.DrawRoundedRectangle(x, y, fillWidth, height, defaultBarRad) ctx.SetColor(fill) ctx.Fill() } func renderCommitsCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { drawCardLabel(ctx, card, "TOTAL COMMITS") drawMetric( ctx, card, formatNumber(stats.TotalCommits), fmt.Sprintf("%.1f commits per day on average", stats.AverageCommitsPerDay), ) barX := cardLeft(card) barY := card.Y + card.H - 60 barW := cardInnerW(card) ctx.DrawRoundedRectangle(barX, barY, barW, 15, 7) ctx.SetColor(color.RGBA{70, 50, 90, 255}) ctx.Fill() ctx.DrawRoundedRectangle(barX, barY, barW*0.72, 15, 7) ctx.SetColor(card.Accent) ctx.Fill() } func renderReposCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { drawCardLabel(ctx, card, "REPOSITORIES") drawMetric(ctx, card, formatNumber(stats.TotalRepositories), "worked on this year") y := card.Y + 240 maxTextWidth := cardInnerW(card) for index, repo := range stats.TopRepositories { if index >= 3 { break } loadFont(ctx, 18) ctx.SetColor(textMuted) ctx.DrawString(truncatePx(repo.Name, maxTextWidth, ctx), cardLeft(card), y) y += 36 } } func renderActiveDayCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { drawCardLabel(ctx, card, "MOST ACTIVE DAY") drawLargeText(ctx, card, stats.MostActiveDay, 72) if len(stats.CommitsByWeekday) == 0 { return } loadFont(ctx, 21) ctx.SetColor(textSecondary) ctx.DrawString( fmt.Sprintf("%d commits that day", stats.CommitsByWeekday[0].Count), cardLeft(card), card.Y+204, ) } func renderAvgCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { drawCardLabel(ctx, card, "AVG COMMITS / DAY") drawMetric(ctx, card, fmt.Sprintf("%.1f", stats.AverageCommitsPerDay), "daily average") } func renderActiveMonthCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { drawCardLabel(ctx, card, "MOST ACTIVE MONTH") drawLargeText(ctx, card, stats.MostActiveMonth, 66) } func renderTopRepoCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { drawCardLabel(ctx, card, "TOP REPOSITORY") if len(stats.TopRepositories) == 0 { return } topRepo := stats.TopRepositories[0] maxTextWidth := cardInnerW(card) loadFont(ctx, 48) ctx.SetColor(card.Accent) lines := wrapText(topRepo.Name, maxTextWidth, ctx) y := card.Y + 150 for index, line := range lines { if index > 0 { y -= 12 } ctx.DrawString(line, cardLeft(card), y) y += 60 } loadFont(ctx, 19) ctx.SetColor(textSecondary) ctx.DrawString("your #1 repository this year", cardLeft(card), y+10) y += 60 for index := 1; index < len(stats.TopRepositories) && index < 4; index++ { loadFont(ctx, 18) ctx.SetColor(textMuted) repoLabel := fmt.Sprintf("#%d %s", index+1, stats.TopRepositories[index].Name) repoLines := wrapText(repoLabel, maxTextWidth-10, ctx) for lineIndex, line := range repoLines { ctx.DrawString(line, cardLeft(card), y) y += 24 if lineIndex > 0 { y -= 2 } } y += 6 } } func renderLanguagesCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { drawCardLabel(ctx, card, "LANGUAGES") if len(stats.Languages) == 0 { loadFont(ctx, 22) ctx.SetColor(textSecondary) ctx.DrawString("No language data available.", cardLeft(card), card.Y+150) return } const ( columnCount = 2 rowsPerColumn = 5 rowHeight = 72.0 startYOffset = 90.0 labelWidth = 165.0 countWidth = 135.0 ) maxCount := stats.Languages[0].Count columnWidth := (cardInnerW(card) - gridGap) / columnCount for index, language := range stats.Languages { if index >= columnCount*rowsPerColumn { break } columnIndex := index / rowsPerColumn rowIndex := index % rowsPerColumn baseX := cardLeft(card) + float64(columnIndex)*(columnWidth+gridGap) baseY := card.Y + startYOffset + float64(rowIndex)*rowHeight percentOfMax := float64(language.Count) / float64(maxCount) barWidth := columnWidth - labelWidth - countWidth - 15 loadFont(ctx, 19) ctx.SetColor(textPrimary) ctx.DrawString( truncatePx(language.Language, labelWidth-12, ctx), baseX, baseY+18, ) drawFilledBar( ctx, baseX+labelWidth, baseY+3, barWidth, defaultBarH, percentOfMax, card.Accent, ) loadFont(ctx, 18) ctx.SetColor(textSecondary) ctx.DrawString( fmt.Sprintf("%d %.1f%%", language.Count, language.Percent), baseX+labelWidth+barWidth+15, baseY+19, ) } } func renderActivityCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { drawCardLabel(ctx, card, "ACTIVITY PATTERNS") const ( startYOffset = 81.0 rowHeight = 42.0 labelWidth = 66.0 countWidth = 75.0 ) columnWidth := (cardInnerW(card) - gridGap) / 2 renderWeekdayActivity( ctx, stats, cardLeft(card), card.Y+startYOffset, columnWidth, labelWidth, countWidth, rowHeight, ) renderMonthActivity( ctx, stats, cardLeft(card)+columnWidth+gridGap, card.Y+startYOffset, columnWidth, labelWidth, countWidth, rowHeight, ) } func renderWeekdayActivity( ctx *gg.Context, stats *models.UserStats, x float64, y float64, columnWidth float64, labelWidth float64, countWidth float64, rowHeight float64, ) { loadFont(ctx, 18) ctx.SetColor(textSecondary) ctx.DrawString("By Weekday", x, y) if len(stats.CommitsByWeekday) == 0 { return } maxCount := stats.CommitsByWeekday[0].Count barWidth := columnWidth - labelWidth - countWidth - 15 for index, day := range stats.CommitsByWeekday { if index >= 7 { break } rowY := y + 36 + float64(index)*rowHeight loadFont(ctx, 18) ctx.SetColor(textPrimary) ctx.DrawString(day.Day[:3], x, rowY+18) percentOfMax := float64(day.Count) / float64(maxCount) drawFilledBar( ctx, x+labelWidth, rowY+3, barWidth, defaultBarH, percentOfMax, accentCyan, ) loadFont(ctx, 16) ctx.SetColor(textMuted) ctx.DrawString(formatNumber(day.Count), x+labelWidth+barWidth+12, rowY+18) } } func renderMonthActivity( ctx *gg.Context, stats *models.UserStats, x float64, y float64, columnWidth float64, labelWidth float64, countWidth float64, rowHeight float64, ) { loadFont(ctx, 18) ctx.SetColor(textSecondary) ctx.DrawString("By Month", x, y) if len(stats.CommitsByMonth) == 0 { return } maxCount := stats.CommitsByMonth[0].Count barWidth := columnWidth - labelWidth - countWidth - 15 for index, month := range stats.CommitsByMonth { if index >= 7 { break } rowY := y + 36 + float64(index)*rowHeight loadFont(ctx, 18) ctx.SetColor(textPrimary) ctx.DrawString(month.Date.Format("Jan"), x, rowY+18) percentOfMax := float64(month.Count) / float64(maxCount) drawFilledBar( ctx, x+labelWidth, rowY+3, barWidth, defaultBarH, percentOfMax, accentOrange, ) loadFont(ctx, 16) ctx.SetColor(textMuted) ctx.DrawString(formatNumber(month.Count), x+labelWidth+barWidth+12, rowY+18) } } func ExportReportAsImage(stats *models.UserStats, format string) (string, error) { resultsDir, err := config.GetResultsDir() if err != nil { return "", err } ctx := gg.NewContext(canvasWidth, canvasHeight) ctx.SetColor(bgColor) ctx.Clear() cards := buildBentoCards(bentoCardSpecs) for _, card := range cards { drawCard(ctx, card) } for index, spec := range bentoCardSpecs { spec.Renderer(ctx, cards[index], stats) } filenameWithExt := buildReportFilename(format) filename := filepath.Join(resultsDir, filenameWithExt) if err := writeImage(ctx, filename, format); err != nil { return "", err } return filenameWithExt, nil } func GenerateReportImage(stats *models.UserStats, format string) (string, error) { return ExportReportAsImage(stats, format) } func buildReportFilename(format string) string { timestamp := time.Now().Format("2006-01-02 15-04-05") return fmt.Sprintf("%s - Gitea Wrapped.%s", timestamp, format) } func writeImage(ctx *gg.Context, filename string, format string) error { file, err := os.Create(filename) if err != nil { return err } defer file.Close() switch format { case "png": return png.Encode(file, ctx.Image()) case "jpg", "jpeg": return jpeg.Encode(file, ctx.Image(), &jpeg.Options{Quality: 92}) default: return fmt.Errorf("unsupported format: %s", format) } } 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 truncatePx(text string, maxWidth float64, ctx *gg.Context) string { width, _ := ctx.MeasureString(text) if width <= maxWidth { return text } for len(text) > 1 { text = text[:len(text)-1] width, _ = ctx.MeasureString(text + "…") if width <= maxWidth { return text + "…" } } return text } func wrapText(text string, maxWidth float64, ctx *gg.Context) []string { words := strings.Fields(text) if len(words) == 0 { return nil } var lines []string currentLine := "" for _, word := range words { testLine := word if currentLine != "" { testLine = currentLine + " " + word } width, _ := ctx.MeasureString(testLine) if width <= maxWidth { currentLine = testLine continue } if currentLine != "" { lines = append(lines, currentLine) } currentLine = word } if currentLine != "" { lines = append(lines, currentLine) } return lines }