▍ humdrum codex / sportsball v0.1.0
license AGPL-3.0
3.1 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
package ui

import (
	"fmt"
	"strings"

	"github.com/charmbracelet/lipgloss"

	"github.com/humdrum-tiv/sportsball/internal/model"
)

const cardWidth = 34

// renderCard draws one game as a bordered card. pulse in [0,1] drives the
// live indicator brightness; selected swaps in the accent border. favAway/
// favHome mark a team with a star.
func renderCard(g model.Game, selected bool, pulse float64, favAway, favHome bool) string {
	var b strings.Builder

	b.WriteString(statusLine(g, pulse, selected))
	b.WriteString("\n")
	b.WriteString(teamRow(g.Away, g, g.Away.Winner, favAway))
	b.WriteString("\n")
	b.WriteString(teamRow(g.Home, g, g.Home.Winner, favHome))

	style := styleCard
	if selected {
		style = styleCardSelected
	}
	return style.Width(cardWidth).Render(b.String())
}

// statusLine is the card header: state badge + clock/detail, right-aligned.
// When selected, a leading accent marker reinforces the highlight border.
func statusLine(g model.Game, pulse float64, selected bool) string {
	var left string
	switch g.State {
	case model.StateLive:
		left = liveDot(pulse) + " " + lipgloss.NewStyle().
			Foreground(colLive).Bold(true).Render("LIVE")
	case model.StateFinal:
		left = styleFinal.Render("◼ FINAL")
	default:
		left = styleSubtle.Render("○ " + startLabel(g))
	}

	marker := "  "
	if selected {
		marker = lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render("▸ ")
	}
	left = marker + left

	// Pre-game already shows the start time on the left; ESPN's verbose detail
	// ("Tue, June 16th at 3:00 PM EDT") would just duplicate it. Show clock
	// while live, status detail when final, nothing pre-game.
	detail := ""
	switch g.State {
	case model.StateLive:
		if g.Clock != "" && g.Clock != "0:00" {
			detail = g.Clock
		} else {
			detail = g.Detail
		}
	case model.StateFinal:
		detail = g.Detail
	}
	right := styleFaint.Render(truncate(detail, 14))

	gap := cardWidth - 2 - lipgloss.Width(left) - lipgloss.Width(right)
	if gap < 1 {
		gap = 1
	}
	return left + strings.Repeat(" ", gap) + right
}

// teamRow renders one team: abbr (in team color) + name, score on the right.
// fav prefixes a star.
func teamRow(t model.Team, g model.Game, leading, fav bool) string {
	abbr := lipgloss.NewStyle().
		Foreground(teamColor(t.Color)).
		Bold(true).
		Render(fmt.Sprintf("%-3s", t.Abbr))

	star := ""
	if fav {
		star = lipgloss.NewStyle().Foreground(colWarn).Render("★")
	} else {
		star = " "
	}
	name := star + styleSubtle.Render(truncate(t.Name, 13))
	left := abbr + " " + name

	score := "  -"
	if g.Started() {
		ss := styleScore
		if leading && g.Started() {
			ss = styleWin
		}
		score = ss.Render(fmt.Sprintf("%3d", t.Score))
	}

	gap := cardWidth - 2 - lipgloss.Width(left) - lipgloss.Width(score)
	if gap < 1 {
		gap = 1
	}
	return left + strings.Repeat(" ", gap) + score
}

// startLabel is the short local start time for pre-game cards.
func startLabel(g model.Game) string {
	if g.Start.IsZero() {
		return "TBD"
	}
	return g.Start.Format("Mon 3:04 PM")
}

func truncate(s string, n int) string {
	if lipgloss.Width(s) <= n {
		return s
	}
	if n <= 1 {
		return s[:n]
	}
	return s[:n-1] + "…"
}