2026-03-07 23:53:16 -05:00
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/AmanTahiliani/box-box/internal/api"
|
|
|
|
|
"github.com/AmanTahiliani/box-box/internal/models"
|
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type DashboardModel struct {
|
|
|
|
|
client *api.OpenF1Client
|
|
|
|
|
year int
|
|
|
|
|
width int
|
|
|
|
|
height int
|
|
|
|
|
loading bool
|
2026-03-27 19:27:38 -04:00
|
|
|
stale bool
|
2026-03-07 23:53:16 -05:00
|
|
|
spinner spinner.Model
|
|
|
|
|
meetings []models.Meeting
|
|
|
|
|
next *models.Meeting
|
|
|
|
|
sessions []models.Session
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewDashboardModel(client *api.OpenF1Client, year int) DashboardModel {
|
|
|
|
|
sp := spinner.New()
|
|
|
|
|
sp.Spinner = spinner.Points
|
|
|
|
|
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
|
|
|
|
|
|
|
|
|
return DashboardModel{
|
|
|
|
|
client: client,
|
|
|
|
|
year: year,
|
|
|
|
|
loading: true,
|
|
|
|
|
spinner: sp,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DashboardModel) Init() tea.Cmd {
|
|
|
|
|
return tea.Batch(
|
|
|
|
|
m.spinner.Tick,
|
|
|
|
|
fetchDashboardMeetings(m.client, m.year),
|
|
|
|
|
tickCountdown(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fetchDashboardMeetings(client *api.OpenF1Client, year int) tea.Cmd {
|
|
|
|
|
return func() tea.Msg {
|
|
|
|
|
meetings, err := client.GetMeetingsForYear(year)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return dashboardMeetingsLoadedMsg{err: err}
|
|
|
|
|
}
|
|
|
|
|
return dashboardMeetingsLoadedMsg{meetings: meetings}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fetchDashboardSessions(client *api.OpenF1Client, meetingKey int) tea.Cmd {
|
|
|
|
|
return func() tea.Msg {
|
|
|
|
|
sessions, err := client.GetSessionsForMeeting(meetingKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return dashboardSessionsLoadedMsg{err: err}
|
|
|
|
|
}
|
2026-03-27 20:30:07 -04:00
|
|
|
sortSessionsByStart(sessions)
|
2026-03-07 23:53:16 -05:00
|
|
|
return dashboardSessionsLoadedMsg{sessions: sessions}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type dashboardMeetingsLoadedMsg struct {
|
|
|
|
|
meetings []models.Meeting
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type dashboardSessionsLoadedMsg struct {
|
|
|
|
|
sessions []models.Session
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type tickCountdownMsg time.Time
|
|
|
|
|
|
|
|
|
|
func tickCountdown() tea.Cmd {
|
|
|
|
|
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
|
|
|
|
return tickCountdownMsg(t)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) {
|
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
|
case tea.WindowSizeMsg:
|
|
|
|
|
m.width = msg.Width
|
|
|
|
|
m.height = msg.Height
|
|
|
|
|
return m, nil
|
|
|
|
|
case spinner.TickMsg:
|
|
|
|
|
if m.loading {
|
|
|
|
|
var cmd tea.Cmd
|
|
|
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
|
|
|
return m, cmd
|
|
|
|
|
}
|
|
|
|
|
case dashboardMeetingsLoadedMsg:
|
|
|
|
|
if msg.err != nil {
|
|
|
|
|
m.err = msg.err
|
|
|
|
|
m.loading = false
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
m.meetings = msg.meetings
|
2026-03-27 19:27:38 -04:00
|
|
|
if m.client.LastResponseWasStale() {
|
|
|
|
|
m.stale = true
|
|
|
|
|
}
|
2026-03-07 23:53:16 -05:00
|
|
|
now := time.Now()
|
2026-03-27 20:30:07 -04:00
|
|
|
m.next = currentMeeting(m.meetings, now)
|
2026-03-27 00:44:00 -04:00
|
|
|
if m.next == nil {
|
2026-03-27 20:30:07 -04:00
|
|
|
m.next = nextUpcomingMeeting(m.meetings, now)
|
2026-03-27 00:44:00 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-07 23:53:16 -05:00
|
|
|
if m.next != nil {
|
|
|
|
|
return m, fetchDashboardSessions(m.client, int(m.next.MeetingKey))
|
|
|
|
|
}
|
|
|
|
|
m.loading = false
|
|
|
|
|
return m, nil
|
2026-03-27 00:30:53 -04:00
|
|
|
|
2026-03-07 23:53:16 -05:00
|
|
|
case dashboardSessionsLoadedMsg:
|
|
|
|
|
m.loading = false
|
|
|
|
|
if msg.err == nil {
|
|
|
|
|
m.sessions = msg.sessions
|
2026-03-27 19:27:38 -04:00
|
|
|
if m.client.LastResponseWasStale() {
|
|
|
|
|
m.stale = true
|
|
|
|
|
}
|
2026-03-07 23:53:16 -05:00
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
case tickCountdownMsg:
|
|
|
|
|
return m, tickCountdown()
|
2026-03-27 00:30:53 -04:00
|
|
|
case tea.KeyMsg:
|
|
|
|
|
if matchKey(msg, GlobalKeys.Retry) && m.err != nil {
|
|
|
|
|
m.err = nil
|
2026-03-27 19:27:38 -04:00
|
|
|
m.stale = false
|
2026-03-27 00:30:53 -04:00
|
|
|
m.loading = true
|
|
|
|
|
return m, m.Init()
|
|
|
|
|
}
|
2026-03-07 23:53:16 -05:00
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DashboardModel) View() string {
|
|
|
|
|
if m.loading {
|
|
|
|
|
return fmt.Sprintf("\n %s Loading dashboard...", m.spinner.View())
|
|
|
|
|
}
|
|
|
|
|
if m.err != nil {
|
2026-03-27 00:30:53 -04:00
|
|
|
return renderErrorView(m.err)
|
2026-03-07 23:53:16 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if m.next == nil {
|
|
|
|
|
return styleMuted.Render(fmt.Sprintf("\n No upcoming races found for %d.\n", m.year))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
w := m.width
|
|
|
|
|
if w < 40 {
|
|
|
|
|
w = 40
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 19:27:38 -04:00
|
|
|
if m.stale {
|
|
|
|
|
sb.WriteString(renderStaleBanner())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 23:53:16 -05:00
|
|
|
titleStyle := lipgloss.NewStyle().
|
|
|
|
|
Foreground(lipgloss.Color(colorF1Red)).
|
2026-03-27 20:53:06 -04:00
|
|
|
Bold(true).
|
|
|
|
|
MarginBottom(1)
|
2026-03-07 23:53:16 -05:00
|
|
|
|
2026-03-27 00:44:00 -04:00
|
|
|
now := time.Now()
|
2026-03-27 20:30:07 -04:00
|
|
|
weekendUnderway := meetingHasStarted(*m.next, now)
|
2026-03-27 00:44:00 -04:00
|
|
|
|
2026-03-27 20:53:06 -04:00
|
|
|
// Left panel: Meeting Info & Countdown
|
|
|
|
|
var leftSide strings.Builder
|
|
|
|
|
leftSide.WriteString("\n")
|
2026-03-27 00:44:00 -04:00
|
|
|
if weekendUnderway {
|
2026-03-27 20:53:06 -04:00
|
|
|
leftSide.WriteString(titleStyle.Render(fmt.Sprintf("CURRENT RACE: %s", m.next.MeetingOfficialName)) + "\n")
|
2026-03-27 00:44:00 -04:00
|
|
|
} else {
|
2026-03-27 20:53:06 -04:00
|
|
|
leftSide.WriteString(titleStyle.Render(fmt.Sprintf("NEXT RACE: %s", m.next.MeetingOfficialName)) + "\n")
|
2026-03-27 00:44:00 -04:00
|
|
|
}
|
2026-03-27 20:53:06 -04:00
|
|
|
leftSide.WriteString(fmt.Sprintf(" %s • %s\n", countryFlag(m.next.CountryCode), m.next.Location))
|
2026-03-27 00:30:53 -04:00
|
|
|
|
2026-03-27 20:30:07 -04:00
|
|
|
currentSession, nextSession := currentAndNextSession(m.sessions, now)
|
2026-03-07 23:53:16 -05:00
|
|
|
|
2026-03-27 20:53:06 -04:00
|
|
|
leftSide.WriteString("\n")
|
2026-03-27 20:30:07 -04:00
|
|
|
if currentSession != nil {
|
2026-03-27 20:53:06 -04:00
|
|
|
liveBadge := lipgloss.NewStyle().Background(lipgloss.Color(colorF1Red)).Foreground(lipgloss.Color(colorWhite)).Bold(true).Padding(0, 1).Render("LIVE")
|
|
|
|
|
leftSide.WriteString(fmt.Sprintf(" %s %s\n", liveBadge, currentSession.SessionName))
|
2026-03-27 20:30:07 -04:00
|
|
|
} else if nextSession != nil {
|
|
|
|
|
nextStart, err := sessionStartTime(*nextSession)
|
|
|
|
|
if err != nil {
|
2026-03-27 20:53:06 -04:00
|
|
|
leftSide.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Render("Schedule unavailable") + "\n")
|
2026-03-27 00:44:00 -04:00
|
|
|
} else {
|
2026-03-27 20:30:07 -04:00
|
|
|
diff := nextStart.Sub(now)
|
|
|
|
|
days := int(diff.Hours() / 24)
|
|
|
|
|
hours := int(diff.Hours()) % 24
|
|
|
|
|
mins := int(diff.Minutes()) % 60
|
|
|
|
|
secs := int(diff.Seconds()) % 60
|
2026-03-27 20:53:06 -04:00
|
|
|
|
|
|
|
|
countdown := fmt.Sprintf("%dd %02dh %02dm %02ds", days, hours, mins, secs)
|
|
|
|
|
leftSide.WriteString(fmt.Sprintf(" Next: %s\n", styleBold.Render(nextSession.SessionName)))
|
|
|
|
|
leftSide.WriteString(fmt.Sprintf(" Starts in: %s\n", styleCountdown.Render(countdown)))
|
2026-03-27 00:44:00 -04:00
|
|
|
}
|
2026-03-07 23:53:16 -05:00
|
|
|
} else {
|
2026-03-27 20:53:06 -04:00
|
|
|
leftSide.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Render("Weekend Finished") + "\n")
|
2026-03-07 23:53:16 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-27 20:53:06 -04:00
|
|
|
leftPanel := stylePanelBorder.Width(w/2 - 4).Render(leftSide.String())
|
2026-03-27 00:30:53 -04:00
|
|
|
|
2026-03-27 20:53:06 -04:00
|
|
|
// Right panel: Weekend Schedule
|
|
|
|
|
var rightSide strings.Builder
|
2026-03-07 23:53:16 -05:00
|
|
|
if len(m.sessions) > 0 {
|
2026-03-27 20:53:06 -04:00
|
|
|
rightSide.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true).Render("WEEKEND SCHEDULE (Local Time)") + "\n\n")
|
2026-03-07 23:53:16 -05:00
|
|
|
for _, s := range m.sessions {
|
2026-03-27 20:30:07 -04:00
|
|
|
stLocal, err := sessionStartTime(s)
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-07 23:53:16 -05:00
|
|
|
day := stLocal.Format("Mon 02 Jan")
|
|
|
|
|
tStr := stLocal.Format("15:04")
|
2026-03-27 00:30:53 -04:00
|
|
|
|
2026-03-07 23:53:16 -05:00
|
|
|
rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
|
2026-03-27 20:30:07 -04:00
|
|
|
if end, err := sessionEndTime(s); err == nil && !now.Before(end) {
|
2026-03-07 23:53:16 -05:00
|
|
|
rowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
|
2026-03-27 20:30:07 -04:00
|
|
|
} else if currentSession != nil && currentSession.SessionKey == s.SessionKey {
|
2026-03-27 20:53:06 -04:00
|
|
|
rowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Bold(true)
|
2026-03-07 23:53:16 -05:00
|
|
|
}
|
2026-03-27 00:30:53 -04:00
|
|
|
|
2026-03-27 20:53:06 -04:00
|
|
|
rightSide.WriteString(rowStyle.Render(fmt.Sprintf("%-15s %-12s %s", s.SessionName, day, tStr)) + "\n")
|
2026-03-07 23:53:16 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-27 20:53:06 -04:00
|
|
|
rightPanel := stylePanelBorder.Width(w/2 - 4).Render(rightSide.String())
|
|
|
|
|
|
|
|
|
|
if m.width > 80 {
|
|
|
|
|
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", rightPanel))
|
|
|
|
|
} else {
|
|
|
|
|
sb.WriteString(leftPanel + "\n" + rightPanel)
|
|
|
|
|
}
|
2026-03-07 23:53:16 -05:00
|
|
|
|
2026-03-27 20:53:06 -04:00
|
|
|
sb.WriteString("\n\n" + helpBar("1-7 tabs", "q quit"))
|
2026-03-07 23:53:16 -05:00
|
|
|
return sb.String()
|
|
|
|
|
}
|