2026-03-03 00:48:05 -05:00
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"math"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
"unicode/utf8"
|
|
|
|
|
|
2026-03-27 00:30:53 -04:00
|
|
|
"github.com/AmanTahiliani/box-box/internal/api"
|
2026-03-03 00:48:05 -05:00
|
|
|
"github.com/AmanTahiliani/box-box/internal/models"
|
2026-03-03 02:29:34 -05:00
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
2026-03-03 00:48:05 -05:00
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// formatSeconds converts a duration in seconds to a lap time string (e.g. "1:31.234").
|
|
|
|
|
func formatSeconds(s float64) string {
|
|
|
|
|
if s <= 0 {
|
|
|
|
|
return "--:--.---"
|
|
|
|
|
}
|
|
|
|
|
minutes := int(s) / 60
|
|
|
|
|
secs := s - float64(minutes*60)
|
|
|
|
|
return fmt.Sprintf("%d:%06.3f", minutes, secs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// formatGap formats the gap_to_leader field which can be float64, string, or []interface{}.
|
|
|
|
|
func formatGap(v interface{}) string {
|
|
|
|
|
if v == nil {
|
|
|
|
|
return "LEADER"
|
|
|
|
|
}
|
|
|
|
|
switch val := v.(type) {
|
|
|
|
|
case float64:
|
|
|
|
|
if val == 0 {
|
|
|
|
|
return "LEADER"
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("+%.3fs", val)
|
|
|
|
|
case string:
|
|
|
|
|
return val
|
|
|
|
|
case []interface{}:
|
|
|
|
|
if len(val) == 0 {
|
|
|
|
|
return "--"
|
|
|
|
|
}
|
|
|
|
|
if f, ok := val[len(val)-1].(float64); ok {
|
|
|
|
|
return formatSeconds(f)
|
|
|
|
|
}
|
|
|
|
|
return "--"
|
|
|
|
|
}
|
|
|
|
|
return "--"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// formatDuration formats a session result duration (float64 or []float64 for qualifying).
|
|
|
|
|
func formatDuration(v interface{}) string {
|
|
|
|
|
if v == nil {
|
|
|
|
|
return "--"
|
|
|
|
|
}
|
|
|
|
|
switch val := v.(type) {
|
|
|
|
|
case float64:
|
|
|
|
|
return formatSeconds(val)
|
|
|
|
|
case []interface{}:
|
|
|
|
|
if len(val) == 0 {
|
|
|
|
|
return "--"
|
|
|
|
|
}
|
|
|
|
|
if f, ok := val[len(val)-1].(float64); ok {
|
|
|
|
|
return formatSeconds(f)
|
|
|
|
|
}
|
|
|
|
|
return "--"
|
|
|
|
|
}
|
|
|
|
|
return "--"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// hexToStyle creates a lipgloss.Style with the given hex color as the foreground.
|
|
|
|
|
func hexToStyle(hex string) lipgloss.Style {
|
|
|
|
|
if hex == "" {
|
|
|
|
|
return lipgloss.NewStyle()
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasPrefix(hex, "#") {
|
|
|
|
|
hex = "#" + hex
|
|
|
|
|
}
|
|
|
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// hexToBgStyle creates a lipgloss.Style with the given hex color as the background.
|
|
|
|
|
func hexToBgStyle(hex string) lipgloss.Style {
|
|
|
|
|
if hex == "" {
|
|
|
|
|
return lipgloss.NewStyle()
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasPrefix(hex, "#") {
|
|
|
|
|
hex = "#" + hex
|
|
|
|
|
}
|
|
|
|
|
return lipgloss.NewStyle().Background(lipgloss.Color(hex)).Foreground(lipgloss.Color(colorWhite))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 00:48:05 -05:00
|
|
|
// sparkline generates a unicode block chart for lap times.
|
|
|
|
|
// Pit laps (nil duration) are rendered as spaces.
|
|
|
|
|
func sparkline(laps []models.Lap, width int) string {
|
|
|
|
|
const blocks = "▁▂▃▄▅▆▇█"
|
|
|
|
|
blockRunes := []rune(blocks)
|
|
|
|
|
|
|
|
|
|
if len(laps) == 0 {
|
2026-03-03 02:29:34 -05:00
|
|
|
return styleMuted.Render(strings.Repeat("·", width))
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect valid lap durations
|
|
|
|
|
var durations []float64
|
|
|
|
|
minDur, maxDur := math.MaxFloat64, 0.0
|
|
|
|
|
for _, lap := range laps {
|
|
|
|
|
if lap.LapDuration != nil && *lap.LapDuration > 0 {
|
|
|
|
|
d := *lap.LapDuration
|
|
|
|
|
durations = append(durations, d)
|
|
|
|
|
if d < minDur {
|
|
|
|
|
minDur = d
|
|
|
|
|
}
|
|
|
|
|
if d > maxDur {
|
|
|
|
|
maxDur = d
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
durations = append(durations, -1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rng := maxDur - minDur
|
|
|
|
|
if rng == 0 {
|
|
|
|
|
rng = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
count := 0
|
|
|
|
|
for _, d := range durations {
|
|
|
|
|
if count >= width {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if d < 0 {
|
2026-03-03 02:29:34 -05:00
|
|
|
sb.WriteString(styleMuted.Render("·"))
|
2026-03-03 00:48:05 -05:00
|
|
|
} else {
|
|
|
|
|
norm := (d - minDur) / rng
|
|
|
|
|
// Invert: fast laps = tall bar (higher index)
|
|
|
|
|
idx := int((1.0-norm)*float64(len(blockRunes)-1) + 0.5)
|
|
|
|
|
if idx < 0 {
|
|
|
|
|
idx = 0
|
|
|
|
|
}
|
|
|
|
|
if idx >= len(blockRunes) {
|
|
|
|
|
idx = len(blockRunes) - 1
|
|
|
|
|
}
|
2026-03-03 02:29:34 -05:00
|
|
|
// Color based on performance: fast=green, mid=yellow, slow=red
|
|
|
|
|
var blockStyle lipgloss.Style
|
|
|
|
|
switch {
|
|
|
|
|
case norm < 0.25:
|
|
|
|
|
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
|
|
|
|
|
case norm < 0.5:
|
|
|
|
|
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
|
|
|
|
|
case norm < 0.75:
|
|
|
|
|
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange))
|
|
|
|
|
default:
|
|
|
|
|
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString(blockStyle.Render(string(blockRunes[idx])))
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
count++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result := sb.String()
|
2026-03-03 02:29:34 -05:00
|
|
|
resultLen := utf8.RuneCountInString(lipgloss.NewStyle().Render(result))
|
|
|
|
|
_ = resultLen
|
2026-03-03 00:48:05 -05:00
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// windArrow maps a wind direction in degrees to a unicode arrow.
|
|
|
|
|
func windArrow(degrees int) string {
|
|
|
|
|
arrows := []string{"↑", "↗", "→", "↘", "↓", "↙", "←", "↖"}
|
|
|
|
|
idx := ((degrees + 22) / 45) % 8
|
|
|
|
|
return arrows[idx]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// tyreStyle returns a lipgloss.Style for a tyre compound.
|
|
|
|
|
func tyreStyle(c models.TyreCompound) lipgloss.Style {
|
|
|
|
|
switch c {
|
|
|
|
|
case models.CompoundSoft:
|
|
|
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorSoft)).Bold(true)
|
|
|
|
|
case models.CompoundMedium:
|
|
|
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)).Bold(true)
|
|
|
|
|
case models.CompoundHard:
|
|
|
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorHard)).Bold(true)
|
|
|
|
|
case models.CompoundIntermediate:
|
|
|
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorInter)).Bold(true)
|
|
|
|
|
case models.CompoundWet:
|
|
|
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet)).Bold(true)
|
|
|
|
|
default:
|
|
|
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// tyreBgStyle returns a lipgloss.Style with background for tyre compound.
|
|
|
|
|
func tyreBgStyle(c models.TyreCompound) lipgloss.Style {
|
|
|
|
|
switch c {
|
|
|
|
|
case models.CompoundSoft:
|
|
|
|
|
return lipgloss.NewStyle().Background(lipgloss.Color(colorSoft)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
|
|
|
|
case models.CompoundMedium:
|
|
|
|
|
return lipgloss.NewStyle().Background(lipgloss.Color(colorMedium)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
|
|
|
|
case models.CompoundHard:
|
|
|
|
|
return lipgloss.NewStyle().Background(lipgloss.Color(colorHard)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
|
|
|
|
case models.CompoundIntermediate:
|
|
|
|
|
return lipgloss.NewStyle().Background(lipgloss.Color(colorInter)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
|
|
|
|
case models.CompoundWet:
|
|
|
|
|
return lipgloss.NewStyle().Background(lipgloss.Color(colorWet)).Foreground(lipgloss.Color(colorWhite)).Bold(true)
|
|
|
|
|
default:
|
|
|
|
|
return lipgloss.NewStyle().Background(lipgloss.Color(colorSurface2)).Foreground(lipgloss.Color(colorMuted))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 00:48:05 -05:00
|
|
|
// tyreAbbrev returns a single-letter abbreviation for a tyre compound.
|
|
|
|
|
func tyreAbbrev(c models.TyreCompound) string {
|
|
|
|
|
switch c {
|
|
|
|
|
case models.CompoundSoft:
|
|
|
|
|
return "S"
|
|
|
|
|
case models.CompoundMedium:
|
|
|
|
|
return "M"
|
|
|
|
|
case models.CompoundHard:
|
|
|
|
|
return "H"
|
|
|
|
|
case models.CompoundIntermediate:
|
|
|
|
|
return "I"
|
|
|
|
|
case models.CompoundWet:
|
|
|
|
|
return "W"
|
|
|
|
|
default:
|
|
|
|
|
return "?"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// renderDelta returns a colored position change indicator, always 2 visible columns wide.
|
2026-03-03 00:48:05 -05:00
|
|
|
func renderDelta(current, start int) string {
|
|
|
|
|
diff := start - current // positive = gained positions
|
|
|
|
|
switch {
|
|
|
|
|
case diff > 0:
|
2026-03-03 02:29:34 -05:00
|
|
|
s := fmt.Sprintf("▲%d", diff)
|
|
|
|
|
return padRightVisible(styleDeltaUp.Render(s), 2)
|
2026-03-03 00:48:05 -05:00
|
|
|
case diff < 0:
|
2026-03-03 02:29:34 -05:00
|
|
|
s := fmt.Sprintf("▼%d", -diff)
|
|
|
|
|
return padRightVisible(styleDeltaDown.Render(s), 2)
|
2026-03-03 00:48:05 -05:00
|
|
|
default:
|
2026-03-03 02:29:34 -05:00
|
|
|
return padRightVisible(styleDeltaEqual.Render("─"), 2)
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// renderPosition formats a position with podium coloring.
|
|
|
|
|
func renderPosition(pos int) string {
|
|
|
|
|
s := fmt.Sprintf("%d", pos)
|
|
|
|
|
switch pos {
|
|
|
|
|
case 1:
|
|
|
|
|
return stylePositionFirst.Render(s)
|
|
|
|
|
case 2:
|
|
|
|
|
return stylePositionSecond.Render(s)
|
|
|
|
|
case 3:
|
|
|
|
|
return stylePositionThird.Render(s)
|
|
|
|
|
default:
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderPointsBar draws a horizontal progress bar for points.
|
|
|
|
|
func renderPointsBar(points, maxPoints float64, width int, color string) string {
|
|
|
|
|
if maxPoints == 0 || width <= 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
ratio := points / maxPoints
|
|
|
|
|
filled := int(ratio * float64(width))
|
|
|
|
|
if filled > width {
|
|
|
|
|
filled = width
|
|
|
|
|
}
|
|
|
|
|
if filled < 0 {
|
|
|
|
|
filled = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
barStyle := lipgloss.NewStyle().
|
|
|
|
|
Background(lipgloss.Color(color)).
|
|
|
|
|
Foreground(lipgloss.Color(color))
|
|
|
|
|
emptyStyle := lipgloss.NewStyle().
|
|
|
|
|
Background(lipgloss.Color(colorSurface2)).
|
|
|
|
|
Foreground(lipgloss.Color(colorSurface2))
|
|
|
|
|
|
|
|
|
|
bar := ""
|
|
|
|
|
if filled > 0 {
|
|
|
|
|
bar += barStyle.Render(strings.Repeat("━", filled))
|
|
|
|
|
}
|
|
|
|
|
remaining := width - filled
|
|
|
|
|
if remaining > 0 {
|
|
|
|
|
bar += emptyStyle.Render(strings.Repeat("━", remaining))
|
|
|
|
|
}
|
|
|
|
|
return bar
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 00:48:05 -05:00
|
|
|
// meetingStatus returns a status indicator for a meeting.
|
2026-03-27 00:44:00 -04:00
|
|
|
// isNext is true when this meeting is the "current or next" highlighted one.
|
2026-03-03 00:48:05 -05:00
|
|
|
func meetingStatus(m models.Meeting, now time.Time, isNext bool) string {
|
2026-03-27 00:44:00 -04:00
|
|
|
start, err := time.Parse(time.RFC3339, m.DateStart)
|
|
|
|
|
if err != nil {
|
|
|
|
|
start, _ = time.Parse("2006-01-02", m.DateStart[:min(len(m.DateStart), 10)])
|
|
|
|
|
} else {
|
|
|
|
|
start = start.Local()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 00:48:05 -05:00
|
|
|
end, err := time.Parse(time.RFC3339, m.DateEnd)
|
|
|
|
|
if err != nil {
|
|
|
|
|
end, err = time.Parse("2006-01-02", m.DateEnd[:min(len(m.DateEnd), 10)])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return " "
|
|
|
|
|
}
|
|
|
|
|
end = end.Add(24 * time.Hour)
|
2026-03-27 00:44:00 -04:00
|
|
|
} else {
|
|
|
|
|
end = end.Local()
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-27 00:44:00 -04:00
|
|
|
// Currently underway: started but not ended
|
|
|
|
|
if now.After(start) && now.Before(end) {
|
|
|
|
|
return styleNext.Render("▶")
|
|
|
|
|
}
|
2026-03-03 00:48:05 -05:00
|
|
|
if end.Before(now) {
|
|
|
|
|
return stylePast.Render("✓")
|
|
|
|
|
}
|
|
|
|
|
if isNext {
|
2026-03-03 02:29:34 -05:00
|
|
|
return styleNext.Render("●")
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
2026-03-03 02:29:34 -05:00
|
|
|
return styleFuture.Render("○")
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// truncate shortens a string to max runes, appending "…" if truncated.
|
|
|
|
|
func truncate(s string, max int) string {
|
|
|
|
|
runes := []rune(s)
|
|
|
|
|
if len(runes) <= max {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
if max <= 1 {
|
|
|
|
|
return "…"
|
|
|
|
|
}
|
|
|
|
|
return string(runes[:max-1]) + "…"
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// padRight pads or truncates a plain string to exactly width runes.
|
|
|
|
|
// Only use this for strings that contain no ANSI escape codes.
|
2026-03-03 00:48:05 -05:00
|
|
|
func padRight(s string, width int) string {
|
|
|
|
|
runes := []rune(s)
|
|
|
|
|
if len(runes) >= width {
|
|
|
|
|
return string(runes[:width])
|
|
|
|
|
}
|
|
|
|
|
return s + strings.Repeat(" ", width-len(runes))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// padLeft left-pads a plain string to exactly width runes.
|
|
|
|
|
// Only use this for strings that contain no ANSI escape codes.
|
2026-03-03 00:48:05 -05:00
|
|
|
func padLeft(s string, width int) string {
|
|
|
|
|
runes := []rune(s)
|
|
|
|
|
if len(runes) >= width {
|
|
|
|
|
return string(runes[:width])
|
|
|
|
|
}
|
|
|
|
|
return strings.Repeat(" ", width-len(runes)) + s
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// padRightVisible pads an ANSI-styled string to exactly width visible columns.
|
|
|
|
|
// Uses lipgloss.Width to measure the visible display width, ignoring escape codes.
|
|
|
|
|
func padRightVisible(s string, width int) string {
|
|
|
|
|
vis := lipgloss.Width(s)
|
|
|
|
|
if vis >= width {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
return s + strings.Repeat(" ", width-vis)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// padLeftVisible left-pads an ANSI-styled string to exactly width visible columns.
|
|
|
|
|
// Uses lipgloss.Width to measure the visible display width, ignoring escape codes.
|
|
|
|
|
func padLeftVisible(s string, width int) string {
|
|
|
|
|
vis := lipgloss.Width(s)
|
|
|
|
|
if vis >= width {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
return strings.Repeat(" ", width-vis) + s
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 00:48:05 -05:00
|
|
|
func min(a, b int) int {
|
|
|
|
|
if a < b {
|
|
|
|
|
return a
|
|
|
|
|
}
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func max(a, b int) int {
|
|
|
|
|
if a > b {
|
|
|
|
|
return a
|
|
|
|
|
}
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// countryFlag converts a country code to an emoji flag.
|
2026-03-03 00:48:05 -05:00
|
|
|
func countryFlag(countryCode string) string {
|
|
|
|
|
code := strings.ToUpper(countryCode)
|
|
|
|
|
if len(code) != 2 {
|
|
|
|
|
return " "
|
|
|
|
|
}
|
|
|
|
|
r1 := rune(0x1F1E6 + int(code[0]-'A'))
|
|
|
|
|
r2 := rune(0x1F1E6 + int(code[1]-'A'))
|
|
|
|
|
return string(r1) + string(r2)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// helpBar renders a styled help bar with key/description pairs.
|
|
|
|
|
// Format: "key:description" pairs separated by spaces in the output.
|
2026-03-03 00:48:05 -05:00
|
|
|
func helpBar(hints ...string) string {
|
2026-03-03 02:29:34 -05:00
|
|
|
var parts []string
|
|
|
|
|
for _, h := range hints {
|
|
|
|
|
// Split on first space: "key description"
|
|
|
|
|
idx := strings.Index(h, " ")
|
|
|
|
|
if idx > 0 {
|
|
|
|
|
key := h[:idx]
|
|
|
|
|
desc := h[idx+1:]
|
|
|
|
|
parts = append(parts, styleHelpKey.Render(key)+" "+styleHelpDesc.Render(desc))
|
|
|
|
|
} else {
|
|
|
|
|
parts = append(parts, styleHelpDesc.Render(h))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
bar := strings.Join(parts, styleMuted.Render(" │ "))
|
|
|
|
|
return styleHelp.Render(bar)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// divider renders a subtle horizontal divider line.
|
|
|
|
|
func divider(width int) string {
|
|
|
|
|
if width <= 0 {
|
|
|
|
|
width = 40
|
|
|
|
|
}
|
|
|
|
|
return styleMuted.Render(strings.Repeat("─", width))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// matchKey checks if a KeyMsg matches a binding.
|
|
|
|
|
func matchKey(msg tea.KeyMsg, binding interface{ Keys() []string }) bool {
|
|
|
|
|
for _, k := range binding.Keys() {
|
|
|
|
|
if msg.String() == k {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// teamColorFromName returns the known team color hex for a team name, or a fallback.
|
|
|
|
|
func teamColorFromName(teamName string) string {
|
|
|
|
|
name := strings.ToLower(teamName)
|
|
|
|
|
switch {
|
|
|
|
|
case strings.Contains(name, "red bull") && !strings.Contains(name, "racing bulls"):
|
|
|
|
|
return colorRedbull
|
|
|
|
|
case strings.Contains(name, "mercedes"):
|
|
|
|
|
return colorMercedes
|
|
|
|
|
case strings.Contains(name, "ferrari"):
|
|
|
|
|
return colorFerrari
|
|
|
|
|
case strings.Contains(name, "mclaren"):
|
|
|
|
|
return colorMclaren
|
|
|
|
|
case strings.Contains(name, "aston martin"):
|
|
|
|
|
return colorAstonMartin
|
|
|
|
|
case strings.Contains(name, "alpine"):
|
|
|
|
|
return colorAlpine
|
|
|
|
|
case strings.Contains(name, "williams"):
|
|
|
|
|
return colorWilliams
|
|
|
|
|
case strings.Contains(name, "haas"):
|
|
|
|
|
return colorHaas
|
|
|
|
|
case strings.Contains(name, "racing bulls"), strings.Contains(name, "alphatauri"), strings.Contains(name, "rb "):
|
|
|
|
|
return colorRB
|
|
|
|
|
case strings.Contains(name, "sauber"), strings.Contains(name, "alfa romeo"), strings.Contains(name, "stake"):
|
|
|
|
|
return colorSauber
|
|
|
|
|
default:
|
|
|
|
|
return colorMuted
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// teamColorBar renders a thin colored bar (e.g., "█") for team identity.
|
|
|
|
|
func teamColorBar(teamColor string) string {
|
|
|
|
|
if teamColor == "" {
|
|
|
|
|
teamColor = colorMuted
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasPrefix(teamColor, "#") {
|
|
|
|
|
teamColor = "#" + teamColor
|
|
|
|
|
}
|
|
|
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
2026-03-27 00:30:53 -04:00
|
|
|
|
|
|
|
|
// renderErrorView returns a formatted error view. If the error is the OpenF1
|
|
|
|
|
// live-session lockout, it shows a special informational banner instead of a
|
|
|
|
|
// raw error string.
|
|
|
|
|
func renderErrorView(err error) string {
|
|
|
|
|
if api.IsLiveSessionError(err) {
|
|
|
|
|
title := lipgloss.NewStyle().
|
|
|
|
|
Foreground(lipgloss.Color(colorF1Red)).
|
|
|
|
|
Bold(true).
|
|
|
|
|
Render(" LIVE SESSION IN PROGRESS")
|
|
|
|
|
|
|
|
|
|
body := lipgloss.NewStyle().
|
|
|
|
|
Foreground(lipgloss.Color(colorWhite)).
|
|
|
|
|
Render(" The OpenF1 API restricts all access (including historical data)\n during live F1 sessions. This applies to the free tier.")
|
|
|
|
|
|
|
|
|
|
hint := lipgloss.NewStyle().
|
|
|
|
|
Foreground(lipgloss.Color(colorMuted)).
|
|
|
|
|
Render(" Access will be restored ~30 minutes after the session ends.\n Set OPENF1_API_KEY to bypass this restriction (paid tier).")
|
|
|
|
|
|
|
|
|
|
return fmt.Sprintf("\n%s\n\n%s\n\n%s\n\n", title, body, hint) +
|
|
|
|
|
helpBar("r retry", "q quit")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", err)) +
|
|
|
|
|
helpBar("r retry", "q quit")
|
|
|
|
|
}
|