diff --git a/box-box b/box-box index 14bf3bd..732686d 100755 Binary files a/box-box and b/box-box differ diff --git a/internal/ui/app.go b/internal/ui/app.go index 4910d49..b5437a2 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -55,12 +55,22 @@ type AppModel struct { trackMap TrackMapModel meetings []models.Meeting + nextInfo *statusBarTarget // Splash screen state showSplash bool splashSpinner spinner.Model } +type statusBarTarget struct { + meeting *models.Meeting + current *models.Session + next *models.Session + loading bool + stale bool + err error +} + func NewAppModel(client *api.OpenF1Client) AppModel { year := time.Now().Year() @@ -105,6 +115,43 @@ func prefetchTrackOutlines(client *api.OpenF1Client, year int) tea.Cmd { } } +type statusBarScheduleLoadedMsg struct { + target *statusBarTarget +} + +func fetchStatusBarSchedule(client *api.OpenF1Client, meetings []models.Meeting) tea.Cmd { + meetingsCopy := append([]models.Meeting(nil), meetings...) + return func() tea.Msg { + now := time.Now() + target := &statusBarTarget{} + + meeting := currentMeeting(meetingsCopy, now) + if meeting == nil { + meeting = nextUpcomingMeeting(meetingsCopy, now) + } + if meeting == nil { + return statusBarScheduleLoadedMsg{target: target} + } + + target.meeting = meeting + target.loading = true + + sessions, err := client.GetSessionsForMeeting(int(meeting.MeetingKey)) + if err != nil { + target.loading = false + target.err = err + target.stale = client.LastResponseWasStale() + return statusBarScheduleLoadedMsg{target: target} + } + + sortSessionsByStart(sessions) + target.current, target.next = currentAndNextSession(sessions, now) + target.loading = false + target.stale = client.LastResponseWasStale() + return statusBarScheduleLoadedMsg{target: target} + } +} + func (m AppModel) Init() tea.Cmd { return tea.Batch( m.dashboard.Init(), @@ -268,12 +315,18 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case meetingsLoadedMsg: if msg.err == nil { m.meetings = msg.meetings + m.nextInfo = &statusBarTarget{loading: true} + cmds = append(cmds, fetchStatusBarSchedule(m.client, msg.meetings)) } var cmd tea.Cmd m.calendar, cmd = m.calendar.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) + case statusBarScheduleLoadedMsg: + m.nextInfo = msg.target + return m, nil + case sessionResultsLoadedMsg: var cmd tea.Cmd m.raceDetail, cmd = m.raceDetail.Update(msg) @@ -521,37 +574,51 @@ func (m AppModel) renderStatusBar(width int) string { Render("BOX-BOX"), } - // Next race countdown - var nextMeeting *models.Meeting - for i := range m.meetings { - start, err := time.Parse(time.RFC3339, m.meetings[i].DateStart) - if err != nil { - start, _ = time.Parse("2006-01-02", m.meetings[i].DateStart[:min(len(m.meetings[i].DateStart), 10)]) - } - if start.After(now) { - nextMeeting = &m.meetings[i] - break - } - } + if m.nextInfo != nil && m.nextInfo.meeting != nil { + flag := countryFlag(m.nextInfo.meeting.CountryCode) + raceName := styleStatusValue.Render(m.nextInfo.meeting.MeetingName) - if nextMeeting != nil { - start, err := time.Parse(time.RFC3339, nextMeeting.DateStart) - if err == nil { - diff := start.Sub(now) - days := int(diff.Hours() / 24) - hours := int(diff.Hours()) % 24 - - flag := countryFlag(nextMeeting.CountryCode) - raceName := styleStatusValue.Render(nextMeeting.MeetingName) - countdown := styleCountdown.Render(fmt.Sprintf("%dd %dh", days, hours)) + leftParts = append(leftParts, styleStatusLabel.Render("│")) + switch { + case m.nextInfo.current != nil: leftParts = append(leftParts, - styleStatusLabel.Render("│"), - styleStatusLabel.Render("NEXT"), + styleStatusLabel.Render("LIVE"), flag+" "+raceName, - styleStatusLabel.Render("in"), - countdown, + styleStatusLabel.Render("·"), + styleStatusValue.Render(m.nextInfo.current.SessionName), ) + case m.nextInfo.next != nil: + if start, err := sessionStartTime(*m.nextInfo.next); err == nil { + diff := start.Sub(now) + days := int(diff.Hours() / 24) + hours := int(diff.Hours()) % 24 + leftParts = append(leftParts, + styleStatusLabel.Render("NEXT"), + flag+" "+raceName, + styleStatusLabel.Render("·"), + styleStatusValue.Render(m.nextInfo.next.SessionName), + styleStatusLabel.Render("in"), + styleCountdown.Render(fmt.Sprintf("%dd %dh", days, hours)), + ) + } + case meetingHasStarted(*m.nextInfo.meeting, now): + leftParts = append(leftParts, + styleStatusLabel.Render("DONE"), + flag+" "+raceName, + ) + default: + if start, err := meetingStartTime(*m.nextInfo.meeting); err == nil { + diff := start.Sub(now) + days := int(diff.Hours() / 24) + hours := int(diff.Hours()) % 24 + leftParts = append(leftParts, + styleStatusLabel.Render("NEXT"), + flag+" "+raceName, + styleStatusLabel.Render("in"), + styleCountdown.Render(fmt.Sprintf("%dd %dh", days, hours)), + ) + } } } diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index df28579..6a49670 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -2,7 +2,6 @@ package ui import ( "fmt" - "sort" "strings" "time" @@ -64,10 +63,7 @@ func fetchDashboardSessions(client *api.OpenF1Client, meetingKey int) tea.Cmd { if err != nil { return dashboardSessionsLoadedMsg{err: err} } - // Sort by DateStart - sort.Slice(sessions, func(i, j int) bool { - return sessions[i].DateStart < sessions[j].DateStart - }) + sortSessionsByStart(sessions) return dashboardSessionsLoadedMsg{sessions: sessions} } } @@ -113,29 +109,9 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { m.stale = true } now := time.Now() - - // First priority: find a meeting that has already started but not yet ended - // (i.e. we're currently in a GP weekend). - for i := range m.meetings { - mtg := m.meetings[i] - start, _ := time.Parse(time.RFC3339, mtg.DateStart) - end, _ := time.Parse(time.RFC3339, mtg.DateEnd) - if now.After(start.Local()) && now.Before(end.Local().Add(24*time.Hour)) { - m.next = &mtg - break - } - } - - // Second priority: if no ongoing weekend, find the next upcoming meeting. + m.next = currentMeeting(m.meetings, now) if m.next == nil { - for i := range m.meetings { - mtg := m.meetings[i] - start, _ := time.Parse(time.RFC3339, mtg.DateStart) - if now.Before(start.Local()) { - m.next = &mtg - break - } - } + m.next = nextUpcomingMeeting(m.meetings, now) } if m.next != nil { @@ -193,11 +169,8 @@ func (m DashboardModel) View() string { Foreground(lipgloss.Color(colorF1Red)). Bold(true) - // Determine if the weekend has started now := time.Now() - meetingStart, _ := time.Parse(time.RFC3339, m.next.DateStart) - meetingStart = meetingStart.Local() - weekendUnderway := now.After(meetingStart) + weekendUnderway := meetingHasStarted(*m.next, now) sb.WriteString("\n") if weekendUnderway { @@ -207,42 +180,23 @@ func (m DashboardModel) View() string { } sb.WriteString(fmt.Sprintf(" %s • %s\n", countryFlag(m.next.CountryCode), m.next.Location)) - end, _ := time.Parse(time.RFC3339, m.next.DateEnd) - endLocal := end.Local() - - // Show countdown to next session if available, else use start date - var nextStart time.Time - var nextSession *models.Session - startFound := false - for i := range m.sessions { - st, _ := time.Parse(time.RFC3339, m.sessions[i].DateStart) - if now.Before(st.Local()) { - nextStart = st.Local() - nextSession = &m.sessions[i] - startFound = true - break - } - } - - if !startFound { - nextStart, _ = time.Parse(time.RFC3339, m.next.DateStart) - nextStart = nextStart.Local() - } + currentSession, nextSession := currentAndNextSession(m.sessions, now) sb.WriteString("\n") - if now.After(nextStart) && now.Before(endLocal) { + if currentSession != nil { liveBadge := lipgloss.NewStyle().Background(lipgloss.Color(colorSoft)).Foreground(lipgloss.Color(colorSurface0)).Bold(true).Padding(0, 1).Render("LIVE") - sb.WriteString(" " + liveBadge + "\n") - } else if now.Before(nextStart) { - diff := nextStart.Sub(now) - days := int(diff.Hours() / 24) - hours := int(diff.Hours()) % 24 - mins := int(diff.Minutes()) % 60 - secs := int(diff.Seconds()) % 60 - if nextSession != nil { - sb.WriteString(fmt.Sprintf(" Next: %s in %dd %02dh %02dm %02ds\n", nextSession.SessionName, days, hours, mins, secs)) + sb.WriteString(fmt.Sprintf(" %s %s\n", liveBadge, currentSession.SessionName)) + } else if nextSession != nil { + nextStart, err := sessionStartTime(*nextSession) + if err != nil { + sb.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Render("Schedule unavailable") + "\n") } else { - sb.WriteString(fmt.Sprintf(" Starts in: %dd %02dh %02dm %02ds\n", days, hours, mins, secs)) + diff := nextStart.Sub(now) + days := int(diff.Hours() / 24) + hours := int(diff.Hours()) % 24 + mins := int(diff.Minutes()) % 60 + secs := int(diff.Seconds()) % 60 + sb.WriteString(fmt.Sprintf(" Next: %s in %dd %02dh %02dm %02ds\n", nextSession.SessionName, days, hours, mins, secs)) } } else { sb.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Render("Weekend Finished") + "\n") @@ -254,14 +208,18 @@ func (m DashboardModel) View() string { if len(m.sessions) > 0 { sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true).Render(" WEEKEND SCHEDULE (Local Time)") + "\n") for _, s := range m.sessions { - st, _ := time.Parse(time.RFC3339, s.DateStart) - stLocal := st.Local() + stLocal, err := sessionStartTime(s) + if err != nil { + continue + } day := stLocal.Format("Mon 02 Jan") tStr := stLocal.Format("15:04") rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)) - if now.After(stLocal) { + if end, err := sessionEndTime(s); err == nil && !now.Before(end) { rowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)) + } else if currentSession != nil && currentSession.SessionKey == s.SessionKey { + rowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSoft)).Bold(true) } sb.WriteString(rowStyle.Render(fmt.Sprintf(" %-15s %-12s %s", s.SessionName, day, tStr)) + "\n") diff --git a/internal/ui/schedule.go b/internal/ui/schedule.go new file mode 100644 index 0000000..6b14e87 --- /dev/null +++ b/internal/ui/schedule.go @@ -0,0 +1,140 @@ +package ui + +import ( + "fmt" + "sort" + "time" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +const defaultSessionDuration = 3 * time.Hour + +func parseScheduleTime(value string) (time.Time, error) { + if value == "" { + return time.Time{}, fmt.Errorf("empty time value") + } + + t, err := time.Parse(time.RFC3339, value) + if err == nil { + return t.Local(), nil + } + + t, err = time.Parse("2006-01-02", value[:min(len(value), 10)]) + if err != nil { + return time.Time{}, err + } + return t.Local(), nil +} + +func meetingStartTime(meeting models.Meeting) (time.Time, error) { + return parseScheduleTime(meeting.DateStart) +} + +func meetingEndTime(meeting models.Meeting) (time.Time, error) { + if meeting.DateEnd != "" { + if end, err := parseScheduleTime(meeting.DateEnd); err == nil { + return end, nil + } + } + + start, err := meetingStartTime(meeting) + if err != nil { + return time.Time{}, err + } + return start.Add(72 * time.Hour), nil +} + +func sessionStartTime(session models.Session) (time.Time, error) { + return parseScheduleTime(session.DateStart) +} + +func sessionEndTime(session models.Session) (time.Time, error) { + if session.DateEnd != "" { + if end, err := parseScheduleTime(session.DateEnd); err == nil { + return end, nil + } + } + + start, err := sessionStartTime(session) + if err != nil { + return time.Time{}, err + } + return start.Add(defaultSessionDuration), nil +} + +func sortSessionsByStart(sessions []models.Session) { + sort.Slice(sessions, func(i, j int) bool { + left, errLeft := sessionStartTime(sessions[i]) + right, errRight := sessionStartTime(sessions[j]) + if errLeft != nil || errRight != nil { + return sessions[i].DateStart < sessions[j].DateStart + } + return left.Before(right) + }) +} + +func currentMeeting(meetings []models.Meeting, now time.Time) *models.Meeting { + var selected *models.Meeting + var latest time.Time + + for i := range meetings { + start, err := meetingStartTime(meetings[i]) + if err != nil || start.After(now) { + continue + } + end, err := meetingEndTime(meetings[i]) + if err != nil || now.After(end.Add(24*time.Hour)) { + continue + } + if selected == nil || start.After(latest) { + selected = &meetings[i] + latest = start + } + } + + return selected +} + +func nextUpcomingMeeting(meetings []models.Meeting, now time.Time) *models.Meeting { + for i := range meetings { + start, err := meetingStartTime(meetings[i]) + if err != nil { + continue + } + if start.After(now) { + return &meetings[i] + } + } + return nil +} + +func currentAndNextSession(sessions []models.Session, now time.Time) (*models.Session, *models.Session) { + for i := range sessions { + start, err := sessionStartTime(sessions[i]) + if err != nil { + continue + } + end, err := sessionEndTime(sessions[i]) + if err != nil { + continue + } + + if (now.Equal(start) || now.After(start)) && now.Before(end) { + return &sessions[i], nil + } + if now.Before(start) { + return nil, &sessions[i] + } + } + + return nil, nil +} + +func meetingHasStarted(meeting models.Meeting, now time.Time) bool { + start, err := meetingStartTime(meeting) + if err != nil { + return false + } + return !now.Before(start) +} diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 543bedb..6e2da5a 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -19,6 +19,53 @@ function currentSeason() { return new Date().getFullYear(); } +function parseScheduleTime(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function sortSessionsByStart(sessions) { + return [...sessions].sort((a, b) => { + const left = parseScheduleTime(a.date_start); + const right = parseScheduleTime(b.date_start); + return (left?.getTime() || 0) - (right?.getTime() || 0); + }); +} + +function pickRelevantMeeting(meetings, now = new Date()) { + const started = meetings + .map(meeting => ({ + meeting, + start: parseScheduleTime(meeting.date_start), + end: parseScheduleTime(meeting.date_end) || null, + })) + .filter(entry => entry.start && entry.start.getTime() <= now.getTime()) + .filter(entry => !entry.end || now.getTime() <= entry.end.getTime() + 24 * 60 * 60 * 1000) + .sort((a, b) => b.start.getTime() - a.start.getTime()); + if (started.length) return started[0].meeting; + + return meetings.find(meeting => { + const start = parseScheduleTime(meeting.date_start); + return start && start.getTime() > now.getTime(); + }) || null; +} + +function pickCurrentAndNextSession(sessions, now = new Date()) { + for (const session of sessions) { + const start = parseScheduleTime(session.date_start); + const end = parseScheduleTime(session.date_end) || (start ? new Date(start.getTime() + 3 * 60 * 60 * 1000) : null); + if (!start || !end) continue; + + if (now.getTime() >= start.getTime() && now.getTime() < end.getTime()) { + return { current: session, next: null }; + } + if (now.getTime() < start.getTime()) { + return { current: null, next: session }; + } + } + return { current: null, next: null }; +} + // ---- Root state: routing + live check ---- function appState() { @@ -69,6 +116,10 @@ function homePage() { meetings: [], champDrivers: [], nextRace: null, + nextSession: null, + currentSession: null, + heroLabel: 'NEXT RACE', + heroMeta: '', countdown: '', season: currentSeason(), _countdownTimer: null, @@ -88,9 +139,33 @@ function homePage() { const r = await fetch(`/api/v1/meetings?year=${this.season}`); const data = await r.json(); this.meetings = Array.isArray(data) ? data : []; - // Find next race - const now = Date.now(); - this.nextRace = this.meetings.find(m => new Date(m.date_start).getTime() > now) || null; + + const meeting = pickRelevantMeeting(this.meetings); + this.nextRace = meeting; + this.nextSession = null; + this.currentSession = null; + this.heroLabel = meeting && parseScheduleTime(meeting.date_start)?.getTime() <= Date.now() + ? 'CURRENT WEEKEND' + : 'NEXT RACE'; + this.heroMeta = ''; + + if (!meeting) return; + + const sessionsResp = await fetch(`/api/v1/sessions?meeting_key=${meeting.meeting_key}`); + const sessionsData = await sessionsResp.json(); + const sessions = Array.isArray(sessionsData) ? sortSessionsByStart(sessionsData) : []; + const { current, next } = pickCurrentAndNextSession(sessions); + + this.currentSession = current; + this.nextSession = next; + + if (current) { + this.heroLabel = 'LIVE NOW'; + this.heroMeta = current.session_name; + } else if (next) { + this.heroLabel = 'NEXT SESSION'; + this.heroMeta = next.session_name; + } } catch (_) {} }, @@ -106,20 +181,46 @@ function homePage() { startCountdown() { if (!this.nextRace) return; - const target = new Date(this.nextRace.date_start).getTime(); const update = () => { - const diff = target - Date.now(); - if (diff <= 0) { this.countdown = 'Race day!'; return; } + const targetSource = this.currentSession || this.nextSession || this.nextRace; + const targetTime = this.currentSession + ? parseScheduleTime(this.currentSession.date_end)?.getTime() + : parseScheduleTime(targetSource?.date_start)?.getTime(); + if (!targetTime) { + this.countdown = ''; + return; + } + + const diff = targetTime - Date.now(); + if (this.currentSession) { + if (diff <= 0) { + this.countdown = 'Session complete'; + return; + } + this.countdown = `Ends in ${this.formatDuration(diff)}`; + return; + } + + if (diff <= 0) { + this.countdown = this.nextSession ? 'Session starting' : 'Race day!'; + return; + } + + const prefix = this.nextSession ? 'Starts in ' : ''; + this.countdown = prefix + this.formatDuration(diff); + }; + update(); + this._countdownTimer = setInterval(update, 1000); + }, + + formatDuration(diff) { const d = Math.floor(diff / 86400000); const h = Math.floor((diff % 86400000) / 3600000); const m = Math.floor((diff % 3600000) / 60000); const s = Math.floor((diff % 60000) / 1000); - this.countdown = d > 0 + return d > 0 ? `${d}d ${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(h)}:${pad(m)}:${pad(s)}`; - }; - update(); - this._countdownTimer = setInterval(update, 1000); }, barWidth(pts) { diff --git a/internal/web/assets/index.html b/internal/web/assets/index.html index e5c6866..b55a8e5 100644 --- a/internal/web/assets/index.html +++ b/internal/web/assets/index.html @@ -40,10 +40,11 @@
-
NEXT RACE
+

+
diff --git a/internal/web/assets/style.css b/internal/web/assets/style.css index a44c84c..fcd4188 100644 --- a/internal/web/assets/style.css +++ b/internal/web/assets/style.css @@ -150,6 +150,13 @@ a { color: inherit; text-decoration: none; } .next-race-flag { font-size: 3rem; margin: 0.25rem 0; } .next-race-name { font-size: 1.4rem; font-weight: 700; margin: 0.25rem 0; } .next-race-circuit { color: var(--text-2); font-size: 0.85rem; margin-bottom: 0.5rem; } +.next-race-session { + color: var(--text-1); + font-size: 0.9rem; + font-weight: 600; + letter-spacing: 0.04em; + margin-bottom: 0.45rem; +} .next-race-countdown { font-size: 1.6rem; font-weight: 800;