package ui import ( "fmt" "strconv" "strings" "github.com/charmbracelet/lipgloss" "github.com/humdrum-tiv/sportsball/internal/model" ) // detailView renders a single game as a full-page live screen. It re-reads // the latest game data by ID every frame so scores/clock update live while // open. This is the "follow a game" surface (World Cup focus). When the game // has deeper data (soccer key events, box scores), it renders below the score // box and the page becomes vertically scrollable. func (a App) detailView() string { g := a.currentDetail() l, _ := model.LeagueByID(g.League) inner := a.detailInner() box := a.detailBox(g, l, inner) help := styleHelp.Render(keys.Back.Help().Key + " back " + "tab/enter switch " + keys.Up.Help().Key + " scroll " + "f/F fav " + "g/G sched " + keys.Theme.Help().Key + " theme " + keys.Refresh.Help().Key + " refresh " + keys.Quit.Help().Key + " quit") extra := a.detailExtra(g, l, inner) // Ticker strip on top, fixed score box below it, a height-clamped scrolling // body for the events / box score, then a fixed help footer. boxBlock := a.detailBoxBlock(box) header := lipgloss.PlaceHorizontal(a.width, lipgloss.Center, boxBlock, lipgloss.WithWhitespaceChars(" ")) helpLine := lipgloss.PlaceHorizontal(a.width, lipgloss.Center, help, lipgloss.WithWhitespaceChars(" ")) if extra == "" { full := lipgloss.JoinVertical(lipgloss.Center, boxBlock, "", help) return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, full, lipgloss.WithWhitespaceChars(" ")) } avail := a.detailBodyAvail(boxBlock) bodyLines := detailScrollWindow(strings.Split(extra, "\n"), a.detailScroll, avail) body := lipgloss.PlaceHorizontal(a.width, lipgloss.Center, strings.Join(bodyLines, "\n"), lipgloss.WithWhitespaceChars(" ")) out := []string{header, "", body} // Pad so the help footer sits at the bottom edge. used := lipgloss.Height(header) + 1 + len(bodyLines) + 1 for ; used < a.height-1; used++ { out = append(out, "") } out = append(out, helpLine) return strings.Join(out, "\n") } // detailInner is the width of the score box. func (a App) detailInner() int { inner := min(a.width-6, 72) if inner < 30 { inner = 30 } return inner } // detailBoxBlock stacks the ticker strip (if any) above the watched game's // score box. This whole block is fixed above the scrolling body, so both // detailView and detailScrollMax measure its height the same way. func (a App) detailBoxBlock(box string) string { ticker := a.tickerStrip() if ticker == "" { return box } return lipgloss.JoinVertical(lipgloss.Center, ticker, "", box) } // detailBox renders the fixed score box (stage, status, matchup, big block // score, meta). func (a App) detailBox(g model.Game, l model.League, inner int) string { stage := lipgloss.NewStyle().Foreground(teamColor(l.Color)).Bold(true). Render(l.Icon + " " + l.Name) rows := []string{ stage, "", a.bigStatus(g), "", a.matchupLine(g, inner), "", scoreSection(g), } if sit := situationBlock(g, inner); sit != "" { rows = append(rows, "", sit) } if meta := metaBlock(g, inner); meta != "" { rows = append(rows, "", meta) } body := lipgloss.JoinVertical(lipgloss.Center, rows...) return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colAccent). Padding(1, 3). Width(inner). Render(body) } // matchupLine is the centered "AWAY vs HOME" header, each side team-colored, // a ★ on favorites and the winner brightened. func (a App) matchupLine(g model.Game, w int) string { side := func(t model.Team, fav bool) string { name := t.FullName if name == "" { name = t.Name } c := teamColor(t.Color) st := lipgloss.NewStyle().Foreground(c).Bold(true) if g.Started() && t.Winner { st = st.Foreground(colWin) } s := st.Render(name) if fav { s = lipgloss.NewStyle().Foreground(colWarn).Render("★ ") + s } return s } away := side(g.Away, a.isFav(g.League, g.Away)) home := side(g.Home, a.isFav(g.League, g.Home)) vs := styleFaint.Render(" vs ") return lipgloss.NewStyle().Width(w - 6).Align(lipgloss.Center). Render(away + vs + home) } // scoreSection renders the oversized block scoreline (away–home), team-colored. // Pre-game shows the start time in place of a score. func scoreSection(g model.Game) string { if !g.Started() { return lipgloss.NewStyle().Foreground(colAccent2).Bold(true). Render("○ " + startLabel(g)) } awayCol := teamColor(g.Away.Color) homeCol := teamColor(g.Home.Color) if g.State == model.StateLive { // Live: pop both scores on the goal-orange accent. awayCol, homeCol = colGoal, colGoal } if g.Away.Winner { awayCol = colWin } if g.Home.Winner { homeCol = colWin } return blockScore(fmt.Sprintf("%d", g.Away.Score), fmt.Sprintf("%d", g.Home.Score), awayCol, homeCol) } // ruleHeader is a Golazo-style section header: a colored title followed by a // "////" rule filling the width, with a ▓▒░ fade tail for texture. func ruleHeader(title string, w int) string { t := lipgloss.NewStyle().Foreground(colAccent2).Bold(true).Render(strings.ToUpper(title)) fade := styleFaint.Render("▓▒░") n := w - lipgloss.Width(t) - 1 - 4 if n < 0 { n = 0 } rule := lipgloss.NewStyle().Foreground(colBorder).Render(strings.Repeat("/", n)) return t + " " + rule + " " + fade } // detailBodyAvail is the number of body lines visible between the fixed score // box and the footer. Mirrors the layout math in detailView. func (a App) detailBodyAvail(box string) int { chrome := lipgloss.Height(box) + 1 /*blank under box*/ + 2 /*blank + help*/ avail := a.height - chrome if avail < 3 { avail = 3 } return avail } // detailScrollMax is the largest useful scroll offset for the open game's // body, so key handling can clamp instead of letting the offset run away. func (a App) detailScrollMax() int { g := a.currentDetail() l, _ := model.LeagueByID(g.League) inner := a.detailInner() extra := a.detailExtra(g, l, inner) if extra == "" { return 0 } m := strings.Count(extra, "\n") + 1 - a.detailBodyAvail(a.detailBoxBlock(a.detailBox(g, l, inner))) if m < 0 { m = 0 } return m } // detailExtra renders the deeper, fetched detail below the score box: soccer // key events, or per-team box-score tables for the other sports. Returns "" if // the summary has not arrived (or carries nothing). func (a App) detailExtra(g model.Game, l model.League, w int) string { if a.detailErr != nil && len(a.detailData[g.ID].Events) == 0 && len(a.detailData[g.ID].BoxScore) == 0 { return styleErr.Render("summary unavailable: " + a.detailErr.Error()) } d, ok := a.detailData[g.ID] if !ok { return styleSubtle.Render("loading game detail…") } if l.Sport == "soccer" { events := eventsBlock(g, d.Events, "Key Events", w) if stats := teamStatsBlock(g, d.TeamStats, w); stats != "" { return stats + "\n\n" + events } return events } box := boxScoreBlock(d.BoxScore, max(w, min(a.width-6, 108))) // Baseball: a scoring-plays timeline sits above the box score. if l.Sport == "baseball" && len(d.Events) > 0 { plays := eventsBlock(g, d.Events, "Scoring Plays", w) return plays + "\n\n" + box } return box } // scrollWindow is shared with the dashboard; for detail we pass detailScroll as // the desired top line. Re-clamp here since detail has no card semantics. func detailScrollWindow(lines []string, offset, avail int) []string { if offset > len(lines)-avail { offset = len(lines) - avail } if offset < 0 { offset = 0 } if len(lines) <= avail { return lines } return lines[offset : offset+avail] } // currentDetail returns the freshest copy of the open game from polled data, // falling back to the snapshot captured when it was opened. func (a App) currentDetail() model.Game { for _, g := range a.games[a.detail.League] { if g.ID == a.detail.ID { return g } } return a.detail } // bigStatus is the prominent live/final/scheduled line with the match clock. func (a App) bigStatus(g model.Game) string { switch g.State { case model.StateLive: clock := g.Clock if clock == "" || clock == "0:00" { clock = g.Detail } out := liveDot(a.pulse()) + " " + lipgloss.NewStyle().Foreground(colLive).Bold(true).Render("LIVE") + " " + lipgloss.NewStyle().Foreground(colGoal).Bold(true).Render(clock) // Detail often repeats the clock for baseball ("Top 7th"); only append // when it adds something. if g.Detail != "" && g.Detail != clock { out += " " + styleSubtle.Render(g.Detail) } return out case model.StateFinal: return lipgloss.NewStyle().Foreground(colMuted).Bold(true). Render("◼ FINAL " + g.Detail) default: return styleSubtle.Render("○ " + startLabel(g) + " " + g.Detail) } } // eventsBlock renders the soccer key-event timeline mirrored by team: away // events align left, home events align right, around a center gutter — matching // the away-left/home-right layout of the header and score box, so the scoring // side reads at a glance (Golazo-style). Minute-stamped, team-colored. func eventsBlock(g model.Game, events []model.MatchEvent, title string, w int) string { if len(events) == 0 { return ruleHeader(title, w) + "\n\n" + styleSubtle.Render("nothing yet") } lines := []string{ruleHeader(title, w), ""} // While live, stream newest-first so the latest moment sits on top; a // finished game reads chronologically as a recap. ordered := events if g.State == model.StateLive { ordered = make([]model.MatchEvent, len(events)) for i, e := range events { ordered[len(events)-1-i] = e } } half := (w - 3) / 2 for _, e := range ordered { side := eventSide(g, e) cell := eventCell(g, e, half) var line string switch side { case sideAway: line = lipgloss.NewStyle().Width(half).Align(lipgloss.Left).Render(cell) + styleFaint.Render(" │ ") case sideHome: line = lipgloss.NewStyle().Width(half).Render("") + styleFaint.Render(" │ ") + lipgloss.NewStyle().Width(half).Align(lipgloss.Right).Render(cell) default: // neutral (kickoff, half-time) — centered line = lipgloss.NewStyle().Width(w).Align(lipgloss.Center).Render(cell) } lines = append(lines, line) } return lipgloss.NewStyle().Width(w).Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) } type eventSideT int const ( sideNeutral eventSideT = iota sideHome sideAway ) // eventSide classifies which team an event belongs to, preferring the team ID // (baseball plays carry only an id) and falling back to the display name. func eventSide(g model.Game, e model.MatchEvent) eventSideT { if e.TeamID != "" { switch e.TeamID { case g.Home.ID: return sideHome case g.Away.ID: return sideAway } } switch e.Team { case g.Home.FullName, g.Home.Name: return sideHome case g.Away.FullName, g.Away.Name: return sideAway } return sideNeutral } // eventCell renders one event's "min icon label", team-colored, clamped to w. func eventCell(g model.Game, e model.MatchEvent, w int) string { clock := e.Clock if clock == "" { clock = "·" } mn := lipgloss.NewStyle().Foreground(colGoal).Bold(true).Render(clock) label := e.ShortText if label == "" { label = e.Text } txt := lipgloss.NewStyle().Foreground(eventColor(g, e)).Render(truncate(label, w-10)) cell := mn + " " + eventIcon(e) + " " + txt if e.Scoring { cell = lipgloss.NewStyle().Bold(true).Render(cell) } return cell } // eventColor picks the colour of the team the event belongs to (matched by // display name), falling back to muted text. func eventColor(g model.Game, e model.MatchEvent) lipgloss.TerminalColor { switch eventSide(g, e) { case sideHome: return teamColor(g.Home.Color) case sideAway: return teamColor(g.Away.Color) } return colText } // eventIcon maps an event type to a glyph. func eventIcon(e model.MatchEvent) string { t := strings.ToLower(e.Type) switch { case strings.Contains(t, "home run"): return "💣" case strings.Contains(t, "goal"): return "⚽" case strings.Contains(t, "yellow"): return lipgloss.NewStyle().Foreground(colWarn).Render("▌") case strings.Contains(t, "red"): return lipgloss.NewStyle().Foreground(colLive).Render("▌") case strings.Contains(t, "substitut"): return lipgloss.NewStyle().Foreground(colMuted).Render("⇄") case strings.Contains(t, "penalty"): return "◎" case e.Scoring: // generic run-scoring play (single, double, sac fly…) return "⚾" default: return styleFaint.Render("·") } } // teamStatsBlock renders the soccer team comparison stats as split bars: each // row is the away value, a proportional away|home shaded bar in team colors, // and the home value, under a centered label. Returns "" when there are none. func teamStatsBlock(g model.Game, stats []model.TeamStat, w int) string { if len(stats) == 0 { return "" } awayC := teamColor(g.Away.Color) homeC := teamColor(g.Home.Color) barW := w - 16 // leave room for the two value columns + spacing if barW < 10 { barW = 10 } lines := []string{ruleHeader("Team Stats", w), ""} for _, s := range stats { av, hv := parseStat(s.Away), parseStat(s.Home) awayLen := 0 if total := av + hv; total > 0 { awayLen = int(float64(barW)*av/total + 0.5) } homeLen := barW - awayLen var bar string if av+hv == 0 { bar = styleFaint.Render(strings.Repeat("░", barW)) } else { bar = lipgloss.NewStyle().Foreground(awayC).Render(strings.Repeat("▓", awayLen)) + lipgloss.NewStyle().Foreground(homeC).Render(strings.Repeat("▓", homeLen)) } left := lipgloss.NewStyle().Foreground(awayC).Bold(true).Render(fmt.Sprintf("%5s", s.Away)) right := lipgloss.NewStyle().Foreground(homeC).Bold(true).Render(fmt.Sprintf("%-5s", s.Home)) row := left + " " + bar + " " + right label := styleSubtle.Render(s.Label) labelLine := lipgloss.PlaceHorizontal(lipgloss.Width(row), lipgloss.Center, label, lipgloss.WithWhitespaceChars(" ")) lines = append(lines, labelLine, row) } return strings.Join(lines, "\n") } // parseStat reads an ESPN stat display value as a float, tolerating a trailing // percent sign; non-numeric values become 0. func parseStat(s string) float64 { s = strings.TrimSuffix(strings.TrimSpace(s), "%") f, _ := strconv.ParseFloat(s, 64) return f } // boxScoreBlock renders each team's player stat tables for the stick-and-ball // sports. Columns that overflow the width are dropped from the right. func boxScoreBlock(teams []model.TeamBox, w int) string { if len(teams) == 0 { return styleSubtle.Render("no box score available") } var blocks []string for _, t := range teams { parts := []string{teamRuleHeader(t.Abbr, t.Name, w)} for _, grp := range t.Groups { if len(grp.Rows) == 0 { continue } parts = append(parts, "", statTable(grp, w)) } blocks = append(blocks, lipgloss.NewStyle().Width(w).Render( lipgloss.JoinVertical(lipgloss.Left, parts...))) } return lipgloss.JoinVertical(lipgloss.Left, blocks...) } // teamRuleHeader is a ruled section header for a box-score team block: the // abbreviation accented, the name in text, then a "////" rule to the width. func teamRuleHeader(abbr, name string, w int) string { title := lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render(abbr) + " " + lipgloss.NewStyle().Foreground(colText).Bold(true).Render(name) fade := styleFaint.Render("▓▒░") n := w - lipgloss.Width(title) - 1 - 4 if n < 0 { n = 0 } rule := lipgloss.NewStyle().Foreground(colBorder).Render(strings.Repeat("/", n)) return title + " " + rule + " " + fade } // statTable lays out one stat group as an aligned table: a left name column // plus right-aligned numeric columns. Trailing columns are dropped if they // don't fit in w. func statTable(grp model.StatGroup, w int) string { const nameW = 18 // Column widths from header + values. colW := make([]int, len(grp.Labels)) for i, l := range grp.Labels { colW[i] = lipgloss.Width(l) } for _, r := range grp.Rows { for i, s := range r.Stats { if i < len(colW) && lipgloss.Width(s) > colW[i] { colW[i] = lipgloss.Width(s) } } } // How many columns fit after the name column? fit := nameW keep := 0 for _, cw := range colW { if fit+cw+1 > w { break } fit += cw + 1 keep++ } groupName := strings.ToUpper(grp.Name) hdr := lipgloss.NewStyle().Width(nameW).Render( lipgloss.NewStyle().Foreground(colAccent2).Bold(true).Render(truncate(groupName, nameW-1))) for i := 0; i < keep; i++ { hdr += " " + styleFaint.Width(colW[i]).Align(lipgloss.Right).Render(grp.Labels[i]) } lines := []string{hdr} for _, r := range grp.Rows { row := lipgloss.NewStyle().Foreground(colText).Bold(true).Width(nameW). Render(truncate(r.Athlete, nameW-1)) for i := 0; i < keep; i++ { v := "" if i < len(r.Stats) { v = r.Stats[i] } row += " " + lipgloss.NewStyle().Foreground(colMuted). Width(colW[i]).Align(lipgloss.Right).Render(v) } lines = append(lines, row) } return lipgloss.JoinVertical(lipgloss.Left, lines...) } // situationBlock renders the live baseball play state inside the score box: the // count and outs, a bases diamond, and the current pitcher / batter with their // stat lines. Returns "" when there's no situation (not live baseball). func situationBlock(g model.Game, w int) string { s := g.Situation if s == nil { return "" } count := lipgloss.NewStyle().Foreground(colAccent2).Bold(true). Render(fmt.Sprintf("%d-%d", s.Balls, s.Strikes)) top := count + styleFaint.Render(" ") + outsDots(s.Outs) diamond := basesDiamond(s) pb := []string{} if s.Pitcher != "" { pb = append(pb, playerLine("P", s.Pitcher, s.PitcherLine, w)) } if s.Batter != "" { pb = append(pb, playerLine("AB", s.Batter, s.BatterLine, w)) } rows := []string{top, "", diamond} if len(pb) > 0 { rows = append(rows, "") rows = append(rows, pb...) } block := lipgloss.JoinVertical(lipgloss.Center, rows...) return lipgloss.NewStyle().Width(w - 6).Align(lipgloss.Center).Render(block) } // outsDots renders outs as filled/empty pips, e.g. "● ● ○ 2 outs". func outsDots(outs int) string { if outs < 0 { outs = 0 } if outs > 3 { outs = 3 } dots := "" for i := 0; i < 3; i++ { if i < outs { dots += lipgloss.NewStyle().Foreground(colGoal).Render("●") } else { dots += styleFaint.Render("○") } if i < 2 { dots += " " } } label := "outs" if outs == 1 { label = "out" } return dots + styleSubtle.Render(fmt.Sprintf(" %d %s", outs, label)) } // basesDiamond draws the three bases as a small diamond (second on top, third // left, first right), occupied bases popped on the goal accent. Both rows are // the same width so second base centers over the gap between third and first. func basesDiamond(s *model.Situation) string { base := func(on bool) string { if on { return lipgloss.NewStyle().Foreground(colGoal).Bold(true).Render("◆") } return styleFaint.Render("◇") } top := " " + base(s.OnSecond) + " " // 2nd at center (col 2) bottom := base(s.OnThird) + " " + base(s.OnFirst) // 3rd col 0, 1st col 4 return lipgloss.JoinVertical(lipgloss.Center, top, bottom) } // playerLine renders one "P / AB" situation line: a role tag, the name, and the // ESPN stat summary, clamped to width. Truncates the raw text (not the styled // string) so ANSI codes stay intact. func playerLine(role, name, stat string, w int) string { avail := max(w-6-len(role)-1, 1) if stat != "" { statPart := " " + stat if nameAvail := avail - lipgloss.Width(statPart); nameAvail >= 6 { name = truncate(name, nameAvail) } else { stat = "" // no room — drop the stat line, keep the name name = truncate(name, avail) } } else { name = truncate(name, avail) } out := lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render(role) + " " + lipgloss.NewStyle().Foreground(colText).Render(name) if stat != "" { out += styleFaint.Render(" " + stat) } return out } func metaBlock(g model.Game, w int) string { var lines []string if g.Venue != "" { lines = append(lines, styleSubtle.Render("📍 "+g.Venue)) } if g.Headline != "" { lines = append(lines, lipgloss.NewStyle().Foreground(colMuted). Width(w-6).Align(lipgloss.Center).Render(g.Headline)) } if len(lines) == 0 { return "" } return lipgloss.JoinVertical(lipgloss.Center, lines...) }