From cb77df0eb62b0370a6f40607db41daa6bc9f2160 Mon Sep 17 00:00:00 2001 From: amantahiliani Date: Tue, 3 Mar 2026 01:16:04 -0500 Subject: [PATCH] Adding Cache --- internal/api/cache.go | 52 ++++++++++++++++++++++++++++++++++ internal/api/client.go | 2 ++ internal/api/openf1.go | 60 ++++++++++++++++++++++++++++++++++++++-- internal/ui/app.go | 24 ++++++++++++++-- internal/ui/calendar.go | 13 +++++---- internal/ui/keys.go | 5 ++++ internal/ui/standings.go | 23 +++++++++------ 7 files changed, 160 insertions(+), 19 deletions(-) create mode 100644 internal/api/cache.go diff --git a/internal/api/cache.go b/internal/api/cache.go new file mode 100644 index 0000000..27047bb --- /dev/null +++ b/internal/api/cache.go @@ -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) +} diff --git a/internal/api/client.go b/internal/api/client.go index 96ab0ff..3d5d8d1 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -8,11 +8,13 @@ import ( type OpenF1Client struct { url string httpClient *http.Client + cache *FileCache } func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client { return &OpenF1Client{ url: url, httpClient: &http.Client{Timeout: timeout}, + cache: NewFileCache(), } } diff --git a/internal/api/openf1.go b/internal/api/openf1.go index 126f3fe..f40ca1e 100644 --- a/internal/api/openf1.go +++ b/internal/api/openf1.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "encoding/json" "errors" "fmt" @@ -12,17 +13,31 @@ import ( ) // 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) { + if cachedData, ok := c.cache.Get(url); ok { + return io.NopCloser(bytes.NewReader(cachedData)), nil + } + resp, err := c.httpClient.Get(url) if err != nil { return nil, err } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { - resp.Body.Close() 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) { @@ -134,6 +149,29 @@ func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) { 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 // Race session. It resolves the latest session key automatically. func (c *OpenF1Client) GetLatestDriverChampionship() ([]models.ChampionshipDriver, error) { @@ -144,6 +182,14 @@ func (c *OpenF1Client) GetLatestDriverChampionship() ([]models.ChampionshipDrive 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) { sessionKey, err := c.getLatestRaceSessionKey() if err != nil { @@ -152,6 +198,14 @@ func (c *OpenF1Client) GetLatestTeamChampionship() ([]models.ChampionshipTeam, e 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) { body, err := c.get(fmt.Sprintf("%s/v1/session_result?session_key=%d", c.url, sessionKey)) if err != nil { diff --git a/internal/ui/app.go b/internal/ui/app.go index e001ba4..1d46a87 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -26,6 +26,7 @@ type AppModel struct { client *api.OpenF1Client activeTab tabIndex + year int width int height int @@ -37,11 +38,13 @@ type AppModel struct { // NewAppModel creates the root model and wires sub-models. func NewAppModel(client *api.OpenF1Client) AppModel { + year := 2025 return AppModel{ client: client, activeTab: tabStandings, - standings: NewStandingsModel(client), - calendar: NewCalendarModel(client), + year: year, + standings: NewStandingsModel(client, year), + calendar: NewCalendarModel(client, year), raceDetail: NewRaceDetailModel(client), driver: NewDriverModel(client), } @@ -95,6 +98,23 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.activeTab = tabCalendar 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: diff --git a/internal/ui/calendar.go b/internal/ui/calendar.go index 1dbfede..5215d97 100644 --- a/internal/ui/calendar.go +++ b/internal/ui/calendar.go @@ -19,11 +19,12 @@ type CalendarModel struct { err error spinner spinner.Model cursor int + year int width int height int } -func NewCalendarModel(client *api.OpenF1Client) CalendarModel { +func NewCalendarModel(client *api.OpenF1Client, year int) CalendarModel { s := spinner.New() s.Spinner = spinner.MiniDot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) @@ -31,6 +32,7 @@ func NewCalendarModel(client *api.OpenF1Client) CalendarModel { client: client, loading: true, spinner: s, + year: year, } } @@ -44,7 +46,7 @@ func fetchMeetings(client *api.OpenF1Client, year int) tea.Cmd { func (m CalendarModel) Init() tea.Cmd { return tea.Batch( 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 { 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 { return styleError.Render(fmt.Sprintf("\n Error: %v", m.err)) } 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 ( @@ -179,9 +181,10 @@ func (m CalendarModel) View() string { } var sb strings.Builder + sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n") sb.WriteString(strings.Join(rows, "\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() } diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 6e22fee..c134471 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -13,6 +13,7 @@ type GlobalKeyMap struct { Down key.Binding Enter key.Binding Back key.Binding + Year key.Binding } // GlobalKeys is the singleton global key map. @@ -53,6 +54,10 @@ var GlobalKeys = GlobalKeyMap{ key.WithKeys("b"), key.WithHelp("b", "back"), ), + Year: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "switch year"), + ), } // StandingsKeyMap holds standing-specific keybindings. diff --git a/internal/ui/standings.go b/internal/ui/standings.go index e576531..5871c03 100644 --- a/internal/ui/standings.go +++ b/internal/ui/standings.go @@ -30,12 +30,13 @@ type StandingsModel struct { err error spinner spinner.Model + year int cursor int width int height int } -func NewStandingsModel(client *api.OpenF1Client) StandingsModel { +func NewStandingsModel(client *api.OpenF1Client, year int) StandingsModel { s := spinner.New() s.Spinner = spinner.MiniDot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) @@ -45,27 +46,28 @@ func NewStandingsModel(client *api.OpenF1Client) StandingsModel { loading: true, spinner: s, drivers: make(map[int]models.Driver), + year: year, } } func (m StandingsModel) Init() tea.Cmd { return tea.Batch( m.spinner.Tick, - fetchDriverChampionship(m.client), - fetchTeamChampionship(m.client), + fetchDriverChampionship(m.client, m.year), + 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 { - standings, err := client.GetLatestDriverChampionship() + standings, err := client.GetDriverChampionshipForYear(year) 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 { - standings, err := client.GetLatestTeamChampionship() + standings, err := client.GetTeamChampionshipForYear(year) 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 { 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 { return styleError.Render(fmt.Sprintf("\n Error: %v", m.err)) @@ -146,6 +148,9 @@ func (m StandingsModel) View() string { var sb strings.Builder + // Year indicator + sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n") + // Toggle bar dStyle, cStyle := styleInactiveTab, styleInactiveTab if m.view == standingsViewDriver { @@ -166,7 +171,7 @@ func (m StandingsModel) View() string { } 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() }