mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Adding Cache
This commit is contained in:
52
internal/api/cache.go
Normal file
52
internal/api/cache.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user