Adding Cache

This commit is contained in:
2026-03-03 01:16:04 -05:00
parent f0641262bd
commit cb77df0eb6
7 changed files with 160 additions and 19 deletions

52
internal/api/cache.go Normal file
View File

@@ -0,0 +1,52 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
)
type FileCache struct {
dir string
}
func NewFileCache() *FileCache {
var cacheDir string
userCacheDir, err := os.UserCacheDir()
if err == nil {
cacheDir = filepath.Join(userCacheDir, "box-box", "openf1")
} else {
// Fallback to a local .cache directory
cacheDir = ".cache/box-box/openf1"
}
// Ensure the cache directory exists
_ = os.MkdirAll(cacheDir, 0755)
return &FileCache{
dir: cacheDir,
}
}
func (c *FileCache) getCachePath(key string) string {
hash := sha256.Sum256([]byte(key))
filename := hex.EncodeToString(hash[:]) + ".json"
return filepath.Join(c.dir, filename)
}
// Get retrieves data from the cache. Returns nil, false if not found.
func (c *FileCache) Get(key string) ([]byte, bool) {
path := c.getCachePath(key)
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
return data, true
}
// Set saves data to the cache.
func (c *FileCache) Set(key string, data []byte) error {
path := c.getCachePath(key)
return os.WriteFile(path, data, 0644)
}

View File

@@ -8,11 +8,13 @@ import (
type OpenF1Client struct { type OpenF1Client struct {
url string url string
httpClient *http.Client httpClient *http.Client
cache *FileCache
} }
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client { func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
return &OpenF1Client{ return &OpenF1Client{
url: url, url: url,
httpClient: &http.Client{Timeout: timeout}, httpClient: &http.Client{Timeout: timeout},
cache: NewFileCache(),
} }
} }

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -12,17 +13,31 @@ import (
) )
// get performs a GET request and returns the response body, or an error if the // get performs a GET request and returns the response body, or an error if the
// status code is not 200 OK. // status code is not 200 OK. It checks a local file cache before making the request.
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) { func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
if cachedData, ok := c.cache.Get(url); ok {
return io.NopCloser(bytes.NewReader(cachedData)), nil
}
resp, err := c.httpClient.Get(url) resp, err := c.httpClient.Get(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url) return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url)
} }
return resp.Body, nil
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Save to cache (ignoring errors as cache is not critical)
_ = c.cache.Set(url, data)
return io.NopCloser(bytes.NewReader(data)), nil
} }
func (c *OpenF1Client) GetMeetingsForYear(year int) ([]models.Meeting, error) { func (c *OpenF1Client) GetMeetingsForYear(year int) ([]models.Meeting, error) {
@@ -134,6 +149,29 @@ func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) {
return sessions[len(sessions)-1].SessionKey, nil return sessions[len(sessions)-1].SessionKey, nil
} }
// getLatestRaceSessionKeyForYear returns the session_key of the most recent Race session
// for a specific year.
func (c *OpenF1Client) getLatestRaceSessionKeyForYear(year int) (int, error) {
// The OpenF1 API doesn't support a direct year filter on sessions yet (verified by docs/common patterns)
// so we'll fetch meetings for that year first, then find the latest session.
// Actually, session endpoint does support year filter according to some versions of docs.
// Let's try year filter first as it's more efficient.
body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race&year=%d", c.url, year))
if err != nil {
return 0, err
}
defer body.Close()
var sessions []models.Session
if err := json.NewDecoder(body).Decode(&sessions); err != nil {
return 0, err
}
if len(sessions) == 0 {
return 0, fmt.Errorf("no Race sessions found for year %d", year)
}
return sessions[len(sessions)-1].SessionKey, nil
}
// GetLatestDriverChampionship returns championship standings for the most recent // GetLatestDriverChampionship returns championship standings for the most recent
// Race session. It resolves the latest session key automatically. // Race session. It resolves the latest session key automatically.
func (c *OpenF1Client) GetLatestDriverChampionship() ([]models.ChampionshipDriver, error) { func (c *OpenF1Client) GetLatestDriverChampionship() ([]models.ChampionshipDriver, error) {
@@ -144,6 +182,14 @@ func (c *OpenF1Client) GetLatestDriverChampionship() ([]models.ChampionshipDrive
return c.GetDriverChampionship(sessionKey) return c.GetDriverChampionship(sessionKey)
} }
func (c *OpenF1Client) GetDriverChampionshipForYear(year int) ([]models.ChampionshipDriver, error) {
sessionKey, err := c.getLatestRaceSessionKeyForYear(year)
if err != nil {
return nil, fmt.Errorf("could not resolve latest race session for year %d: %w", year, err)
}
return c.GetDriverChampionship(sessionKey)
}
func (c *OpenF1Client) GetLatestTeamChampionship() ([]models.ChampionshipTeam, error) { func (c *OpenF1Client) GetLatestTeamChampionship() ([]models.ChampionshipTeam, error) {
sessionKey, err := c.getLatestRaceSessionKey() sessionKey, err := c.getLatestRaceSessionKey()
if err != nil { if err != nil {
@@ -152,6 +198,14 @@ func (c *OpenF1Client) GetLatestTeamChampionship() ([]models.ChampionshipTeam, e
return c.GetTeamChampionship(sessionKey) return c.GetTeamChampionship(sessionKey)
} }
func (c *OpenF1Client) GetTeamChampionshipForYear(year int) ([]models.ChampionshipTeam, error) {
sessionKey, err := c.getLatestRaceSessionKeyForYear(year)
if err != nil {
return nil, fmt.Errorf("could not resolve latest race session for year %d: %w", year, err)
}
return c.GetTeamChampionship(sessionKey)
}
func (c *OpenF1Client) GetSessionResult(sessionKey int) ([]models.SessionResult, error) { func (c *OpenF1Client) GetSessionResult(sessionKey int) ([]models.SessionResult, error) {
body, err := c.get(fmt.Sprintf("%s/v1/session_result?session_key=%d", c.url, sessionKey)) body, err := c.get(fmt.Sprintf("%s/v1/session_result?session_key=%d", c.url, sessionKey))
if err != nil { if err != nil {

View File

@@ -26,6 +26,7 @@ type AppModel struct {
client *api.OpenF1Client client *api.OpenF1Client
activeTab tabIndex activeTab tabIndex
year int
width int width int
height int height int
@@ -37,11 +38,13 @@ type AppModel struct {
// NewAppModel creates the root model and wires sub-models. // NewAppModel creates the root model and wires sub-models.
func NewAppModel(client *api.OpenF1Client) AppModel { func NewAppModel(client *api.OpenF1Client) AppModel {
year := 2025
return AppModel{ return AppModel{
client: client, client: client,
activeTab: tabStandings, activeTab: tabStandings,
standings: NewStandingsModel(client), year: year,
calendar: NewCalendarModel(client), standings: NewStandingsModel(client, year),
calendar: NewCalendarModel(client, year),
raceDetail: NewRaceDetailModel(client), raceDetail: NewRaceDetailModel(client),
driver: NewDriverModel(client), driver: NewDriverModel(client),
} }
@@ -95,6 +98,23 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.activeTab = tabCalendar m.activeTab = tabCalendar
return m, nil return m, nil
} }
case "y":
// Cycle years: 2025 -> 2023 -> 2024 -> 2025
if m.year == 2025 {
m.year = 2023
} else {
m.year++
}
// Update sub-models and re-trigger fetches
m.calendar.year = m.year
m.calendar.loading = true
m.standings.year = m.year
m.standings.loading = true
return m, tea.Batch(
m.calendar.Init(),
m.standings.Init(),
)
} }
case meetingSelectedMsg: case meetingSelectedMsg:

View File

@@ -19,11 +19,12 @@ type CalendarModel struct {
err error err error
spinner spinner.Model spinner spinner.Model
cursor int cursor int
year int
width int width int
height int height int
} }
func NewCalendarModel(client *api.OpenF1Client) CalendarModel { func NewCalendarModel(client *api.OpenF1Client, year int) CalendarModel {
s := spinner.New() s := spinner.New()
s.Spinner = spinner.MiniDot s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
@@ -31,6 +32,7 @@ func NewCalendarModel(client *api.OpenF1Client) CalendarModel {
client: client, client: client,
loading: true, loading: true,
spinner: s, spinner: s,
year: year,
} }
} }
@@ -44,7 +46,7 @@ func fetchMeetings(client *api.OpenF1Client, year int) tea.Cmd {
func (m CalendarModel) Init() tea.Cmd { func (m CalendarModel) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
m.spinner.Tick, m.spinner.Tick,
fetchMeetings(m.client, 2025), fetchMeetings(m.client, m.year),
) )
} }
@@ -108,13 +110,13 @@ func (m CalendarModel) findNextRaceIndex() int {
func (m CalendarModel) View() string { func (m CalendarModel) View() string {
if m.loading { if m.loading {
return fmt.Sprintf("\n %s Loading 2025 calendar…", m.spinner.View()) return fmt.Sprintf("\n %s Loading %d calendar…", m.spinner.View(), m.year)
} }
if m.err != nil { if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err)) return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
} }
if len(m.meetings) == 0 { if len(m.meetings) == 0 {
return styleMuted.Render("\n No meetings found for 2025.") return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.", m.year))
} }
const ( const (
@@ -179,9 +181,10 @@ func (m CalendarModel) View() string {
} }
var sb strings.Builder var sb strings.Builder
sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n")
sb.WriteString(strings.Join(rows, "\n")) sb.WriteString(strings.Join(rows, "\n"))
sb.WriteString("\n\n") sb.WriteString("\n\n")
sb.WriteString(helpBar("j/k navigate", "enter select race", "q quit")) sb.WriteString(helpBar("y season", "j/k navigate", "enter select race", "q quit"))
return sb.String() return sb.String()
} }

View File

@@ -13,6 +13,7 @@ type GlobalKeyMap struct {
Down key.Binding Down key.Binding
Enter key.Binding Enter key.Binding
Back key.Binding Back key.Binding
Year key.Binding
} }
// GlobalKeys is the singleton global key map. // GlobalKeys is the singleton global key map.
@@ -53,6 +54,10 @@ var GlobalKeys = GlobalKeyMap{
key.WithKeys("b"), key.WithKeys("b"),
key.WithHelp("b", "back"), key.WithHelp("b", "back"),
), ),
Year: key.NewBinding(
key.WithKeys("y"),
key.WithHelp("y", "switch year"),
),
} }
// StandingsKeyMap holds standing-specific keybindings. // StandingsKeyMap holds standing-specific keybindings.

View File

@@ -30,12 +30,13 @@ type StandingsModel struct {
err error err error
spinner spinner.Model spinner spinner.Model
year int
cursor int cursor int
width int width int
height int height int
} }
func NewStandingsModel(client *api.OpenF1Client) StandingsModel { func NewStandingsModel(client *api.OpenF1Client, year int) StandingsModel {
s := spinner.New() s := spinner.New()
s.Spinner = spinner.MiniDot s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
@@ -45,27 +46,28 @@ func NewStandingsModel(client *api.OpenF1Client) StandingsModel {
loading: true, loading: true,
spinner: s, spinner: s,
drivers: make(map[int]models.Driver), drivers: make(map[int]models.Driver),
year: year,
} }
} }
func (m StandingsModel) Init() tea.Cmd { func (m StandingsModel) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
m.spinner.Tick, m.spinner.Tick,
fetchDriverChampionship(m.client), fetchDriverChampionship(m.client, m.year),
fetchTeamChampionship(m.client), fetchTeamChampionship(m.client, m.year),
) )
} }
func fetchDriverChampionship(client *api.OpenF1Client) tea.Cmd { func fetchDriverChampionship(client *api.OpenF1Client, year int) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
standings, err := client.GetLatestDriverChampionship() standings, err := client.GetDriverChampionshipForYear(year)
return driverChampionshipLoadedMsg{standings: standings, err: err} return driverChampionshipLoadedMsg{standings: standings, err: err}
} }
} }
func fetchTeamChampionship(client *api.OpenF1Client) tea.Cmd { func fetchTeamChampionship(client *api.OpenF1Client, year int) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
standings, err := client.GetLatestTeamChampionship() standings, err := client.GetTeamChampionshipForYear(year)
return teamChampionshipLoadedMsg{standings: standings, err: err} return teamChampionshipLoadedMsg{standings: standings, err: err}
} }
} }
@@ -138,7 +140,7 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
func (m StandingsModel) View() string { func (m StandingsModel) View() string {
if m.loading { if m.loading {
return fmt.Sprintf("\n %s Loading championship standings…", m.spinner.View()) return fmt.Sprintf("\n %s Loading %d championship standings…", m.spinner.View(), m.year)
} }
if m.err != nil { if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err)) return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
@@ -146,6 +148,9 @@ func (m StandingsModel) View() string {
var sb strings.Builder var sb strings.Builder
// Year indicator
sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n")
// Toggle bar // Toggle bar
dStyle, cStyle := styleInactiveTab, styleInactiveTab dStyle, cStyle := styleInactiveTab, styleInactiveTab
if m.view == standingsViewDriver { if m.view == standingsViewDriver {
@@ -166,7 +171,7 @@ func (m StandingsModel) View() string {
} }
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(helpBar("d drivers", "c constructors", "j/k navigate", "q quit")) sb.WriteString(helpBar("y season", "d drivers", "c constructors", "j/k navigate", "q quit"))
return sb.String() return sb.String()
} }