2026-03-03 00:48:05 -05:00
package main
import (
2026-05-25 12:30:21 -04:00
"context"
2026-03-27 20:12:57 -04:00
"flag"
2026-03-03 00:48:05 -05:00
"fmt"
2026-03-27 02:54:09 -04:00
"log"
2026-05-25 12:30:21 -04:00
"net/http"
2026-03-03 00:48:05 -05:00
"os"
2026-05-25 13:52:43 -04:00
"strings"
2026-03-03 00:48:05 -05:00
"time"
"github.com/AmanTahiliani/box-box/internal/api"
2026-05-25 00:45:46 -04:00
"github.com/AmanTahiliani/box-box/internal/ingest"
2026-05-25 12:30:21 -04:00
"github.com/AmanTahiliani/box-box/internal/news"
2026-05-25 00:45:46 -04:00
"github.com/AmanTahiliani/box-box/internal/store"
2026-03-03 00:48:05 -05:00
"github.com/AmanTahiliani/box-box/internal/ui"
2026-03-27 20:12:57 -04:00
"github.com/AmanTahiliani/box-box/internal/web"
2026-03-03 00:48:05 -05:00
tea "github.com/charmbracelet/bubbletea"
)
func main ( ) {
2026-03-27 20:12:57 -04:00
webMode := flag . Bool ( "web" , false , "Start web companion server instead of TUI" )
port := flag . Int ( "port" , 8080 , "Port for web server (used with --web)" )
2026-05-25 00:45:46 -04:00
ingestYear := flag . Int ( "ingest-year" , 0 , "Ingest OpenF1 meetings for a season year" )
2026-05-25 13:52:43 -04:00
backfillSeason := flag . Int ( "backfill-season" , 0 , "Trigger full-season backfill/deep-ingestion for the given year" )
2026-05-25 02:38:27 -04:00
ingestMeeting := flag . Int ( "ingest-meeting" , 0 , "Ingest meeting metadata and Race Hub datasets for all sessions" )
2026-05-25 00:45:46 -04:00
ingestSession := flag . Int ( "ingest-session" , 0 , "Ingest Race Hub datasets for a session key" )
2026-05-25 12:30:21 -04:00
ingestNews := flag . Bool ( "ingest-news" , false , "Refresh RSS/Atom paddock briefing feeds" )
2026-07-04 01:06:19 -04:00
prefetchTrackOutlines := flag . Int ( "prefetch-track-outlines" , 0 , "Warm the web track-outline cache for a season year (for web-only hosts, run before --web so /api/v1/track-outline can serve live maps)" )
2026-05-25 00:45:46 -04:00
dryRun := flag . Bool ( "dry-run" , false , "Preview ingestion without writing domain rows" )
2026-05-25 13:52:43 -04:00
force := flag . Bool ( "force" , false , "Re-ingest datasets even if already tracked in the session_coverage table as completed" )
coverageYear := flag . Int ( "coverage" , 0 , "Show season coverage report for the given year" )
2026-05-25 00:45:46 -04:00
dbPath := flag . String ( "db" , "" , "Domain database path (default: ~/.local/share/box-box/boxbox.db)" )
2026-03-27 20:12:57 -04:00
flag . Parse ( )
2026-03-27 02:54:09 -04:00
2026-05-25 13:52:43 -04:00
if * coverageYear != 0 {
if err := runCoverageReport ( * coverageYear , * dbPath ) ; err != nil {
fmt . Fprintf ( os . Stderr , "coverage report error: %v\n" , err )
os . Exit ( 1 )
}
return
}
2026-07-03 00:48:07 -04:00
// BOXBOX_OPENF1_BASE_URL overrides the upstream OpenF1 API root. E2E runs
// point it at an unreachable address so tests stay hermetic and
// deterministic regardless of wall-clock date or network state.
baseURL := os . Getenv ( "BOXBOX_OPENF1_BASE_URL" )
if baseURL == "" {
baseURL = "https://api.openf1.org"
}
2026-03-27 00:30:53 -04:00
var client * api . OpenF1Client
if apiKey := os . Getenv ( "OPENF1_API_KEY" ) ; apiKey != "" {
2026-07-03 00:48:07 -04:00
client = api . NewOpenF1ClientWithKey ( baseURL , 15 * time . Second , apiKey )
2026-03-27 00:30:53 -04:00
} else {
2026-07-03 00:48:07 -04:00
client = api . NewOpenF1Client ( baseURL , 15 * time . Second )
2026-03-27 00:30:53 -04:00
}
defer client . Close ( )
// Clean up old file-based cache (one-time migration).
go api . CleanupOldFileCache ( )
2026-05-25 00:45:46 -04:00
ingestFlags := 0
if * ingestYear != 0 {
ingestFlags ++
}
2026-05-25 13:52:43 -04:00
if * backfillSeason != 0 {
ingestFlags ++
}
2026-05-25 00:45:46 -04:00
if * ingestMeeting != 0 {
ingestFlags ++
}
if * ingestSession != 0 {
ingestFlags ++
}
2026-05-25 12:30:21 -04:00
if * ingestNews {
ingestFlags ++
}
2026-07-04 01:06:19 -04:00
if * prefetchTrackOutlines != 0 {
ingestFlags ++
}
2026-05-25 00:45:46 -04:00
if ingestFlags > 0 {
if ingestFlags > 1 {
2026-07-04 01:06:19 -04:00
fmt . Fprintln ( os . Stderr , "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, --ingest-news, or --prefetch-track-outlines may be set" )
2026-05-25 00:45:46 -04:00
os . Exit ( 1 )
}
2026-07-04 01:06:19 -04:00
if * prefetchTrackOutlines != 0 {
if err := runTrackOutlinePrefetch ( client , * prefetchTrackOutlines ) ; err != nil {
fmt . Fprintf ( os . Stderr , "box-box track outline prefetch error: %v\n" , err )
os . Exit ( 1 )
}
return
}
2026-05-25 12:30:21 -04:00
if * ingestNews {
if err := runNewsIngestion ( * dryRun , * dbPath ) ; err != nil {
fmt . Fprintf ( os . Stderr , "box-box ingest error: %v\n" , err )
os . Exit ( 1 )
}
return
}
2026-07-04 01:06:19 -04:00
2026-05-25 13:52:43 -04:00
yearVal := * ingestYear
if * backfillSeason != 0 {
yearVal = * backfillSeason
}
if err := runIngestion ( client , yearVal , * ingestMeeting , * ingestSession , * force , * dryRun , * dbPath ) ; err != nil {
2026-05-25 00:45:46 -04:00
fmt . Fprintf ( os . Stderr , "box-box ingest error: %v\n" , err )
os . Exit ( 1 )
}
return
}
2026-03-27 20:12:57 -04:00
if * webMode {
log . SetOutput ( os . Stderr ) // web mode logs to stderr, not file
fmt . Printf ( "box-box web → http://localhost:%d\n" , * port )
2026-05-25 00:49:13 -04:00
var domainStore * store . Store
db := * dbPath
if db == "" {
db = store . DefaultDBPath ( )
}
if st , err := store . Open ( db ) ; err != nil {
log . Printf ( "web: domain database unavailable (%s): %v" , db , err )
} else {
domainStore = st
defer domainStore . Close ( )
}
srv := web . NewServer ( client , * port , domainStore )
2026-03-27 20:12:57 -04:00
log . Fatal ( srv . Start ( ) )
return
}
// TUI mode: redirect logs to file so they don't pollute the terminal.
if f , err := os . OpenFile ( "box-box.log" , os . O_CREATE | os . O_WRONLY | os . O_APPEND , 0 o644 ) ; err == nil {
log . SetOutput ( f )
defer f . Close ( )
}
2026-03-03 00:48:05 -05:00
model := ui . NewAppModel ( client )
p := tea . NewProgram (
model ,
tea . WithAltScreen ( ) ,
tea . WithMouseCellMotion ( ) ,
)
if _ , err := p . Run ( ) ; err != nil {
fmt . Fprintf ( os . Stderr , "box-box error: %v\n" , err )
os . Exit ( 1 )
}
}
2026-05-25 00:45:46 -04:00
2026-05-25 13:52:43 -04:00
func runIngestion ( client * api . OpenF1Client , year , meetingKey , sessionKey int , force , dryRun bool , dbPath string ) error {
2026-05-25 00:45:46 -04:00
log . SetOutput ( os . Stderr )
path := dbPath
if path == "" {
path = store . DefaultDBPath ( )
}
st , err := store . Open ( path )
if err != nil {
return fmt . Errorf ( "open domain database: %w" , err )
}
defer st . Close ( )
opts := ingest . DefaultOptions ( )
opts . DryRun = dryRun
2026-05-25 13:52:43 -04:00
opts . Force = force
2026-05-25 00:45:46 -04:00
opts . Progress = ingest . NewProgress ( os . Stderr )
svc := ingest . NewService ( st , ingest . NewOpenF1Source ( client ) , opts )
switch {
case year != 0 :
_ , err = svc . IngestYear ( year )
case meetingKey != 0 :
_ , err = svc . IngestMeeting ( meetingKey )
case sessionKey != 0 :
_ , err = svc . IngestSession ( sessionKey )
}
return err
}
2026-05-25 12:30:21 -04:00
2026-07-04 01:06:19 -04:00
func runTrackOutlinePrefetch ( client * api . OpenF1Client , year int ) error {
log . SetOutput ( os . Stderr )
fmt . Fprintf ( os . Stderr , "track outlines: warming HTTP cache %s for %d\n" , api . DefaultCacheDBPath ( ) , year )
meetings , err := client . GetMeetingsForYear ( year )
if err != nil {
return fmt . Errorf ( "fetch meetings for %d: %w" , year , err )
}
result := client . PrefetchTrackOutlinesForYear ( year , meetings )
fmt . Printf (
"track outlines %d: cached %d/%d unique circuit(s) before, %d/%d after; %d skipped, %d fetched, %d failed\n" ,
result . Year ,
result . CachedBefore ,
result . UniqueCircuits ,
result . CachedAfter ,
result . UniqueCircuits ,
result . Skipped ,
result . Fetched ,
result . Failed ,
)
if result . CachedAfter == 0 {
return fmt . Errorf ( "cached zero track outlines for %d" , year )
}
return nil
}
2026-05-25 12:30:21 -04:00
func runNewsIngestion ( dryRun bool , dbPath string ) error {
log . SetOutput ( os . Stderr )
path := dbPath
if path == "" {
path = store . DefaultDBPath ( )
}
var st * store . Store
if ! dryRun {
var err error
st , err = store . Open ( path )
if err != nil {
return fmt . Errorf ( "open domain database: %w" , err )
}
defer st . Close ( )
}
if dryRun {
fmt . Fprintf ( os . Stderr , "news: dry run, not writing to %s\n" , path )
} else {
fmt . Fprintf ( os . Stderr , "news: refreshing feeds into %s\n" , path )
}
2026-05-25 15:16:00 -04:00
ctx , cancel := context . WithTimeout ( context . Background ( ) , 5 * time . Minute )
2026-05-25 12:30:21 -04:00
defer cancel ( )
result , err := news . Refresh ( ctx , st , news . RefreshOptions {
Client : & http . Client { Timeout : 10 * time . Second } ,
DryRun : dryRun ,
Progress : os . Stderr ,
2026-05-25 15:16:00 -04:00
EnrichOG : ! dryRun ,
2026-05-25 12:30:21 -04:00
} )
fmt . Fprintf (
os . Stderr ,
"news: %d source(s) fetched, %d failed, %d item(s) fetched, %d upserted\n" ,
result . SourcesFetched ,
result . SourcesFailed ,
result . ItemsFetched ,
result . ItemsUpserted ,
)
return err
}
2026-05-25 13:52:43 -04:00
func runCoverageReport ( year int , dbPath string ) error {
path := dbPath
if path == "" {
path = store . DefaultDBPath ( )
}
st , err := store . Open ( path )
if err != nil {
return fmt . Errorf ( "open domain database: %w" , err )
}
defer st . Close ( )
rows , err := st . GetSeasonCoverage ( year )
if err != nil {
return fmt . Errorf ( "get season coverage: %w" , err )
}
if len ( rows ) == 0 {
fmt . Printf ( "No session coverage data found for year %d.\n" , year )
return nil
}
type datasetStatus struct {
Status string
Count int
}
type sessionInfo struct {
MeetingName string
SessionName string
SessionKey int
Datasets map [ string ] datasetStatus
}
var sessions [ ] sessionInfo
sessionMap := make ( map [ int ] int )
for _ , row := range rows {
idx , exists := sessionMap [ row . SessionKey ]
if ! exists {
idx = len ( sessions )
sessions = append ( sessions , sessionInfo {
MeetingName : row . MeetingName ,
SessionName : row . SessionName ,
SessionKey : row . SessionKey ,
Datasets : make ( map [ string ] datasetStatus ) ,
} )
sessionMap [ row . SessionKey ] = idx
}
if row . Dataset != "" {
sessions [ idx ] . Datasets [ row . Dataset ] = datasetStatus {
Status : row . Status ,
Count : row . RowCount ,
}
}
}
fmt . Printf ( "\n--- Season %d Coverage Report ---\n\n" , year )
2026-07-04 01:06:19 -04:00
fmt . Printf ( "%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n" ,
2026-05-25 13:52:43 -04:00
"Meeting / Session (Key)" , "ID" , "DR" , "SR" , "SG" , "ST" , "PS" , "PO" , "RC" , "WE" , "LA" )
fmt . Println ( strings . Repeat ( "-" , 82 ) )
for _ , sess := range sessions {
statusChar := func ( ds string ) string {
dsStatus , ok := sess . Datasets [ ds ]
if ! ok {
return "."
}
switch dsStatus . Status {
case "complete" :
return "✓"
case "failed" :
return "✗"
default :
return "."
}
}
nameCol := fmt . Sprintf ( "%s - %s (%d)" , sess . MeetingName , sess . SessionName , sess . SessionKey )
if len ( nameCol ) > 35 {
nameCol = nameCol [ : 32 ] + "..."
}
fmt . Printf ( "%-35s | %-5d | %s | %s | %s | %s | %s | %s | %s | %s | %s\n" ,
nameCol ,
sess . SessionKey ,
statusChar ( "drivers" ) ,
statusChar ( "session_result" ) ,
statusChar ( "starting_grid" ) ,
statusChar ( "stints" ) ,
statusChar ( "pit_stops" ) ,
statusChar ( "positions" ) ,
statusChar ( "race_control" ) ,
statusChar ( "weather" ) ,
statusChar ( "laps" ) ,
)
}
fmt . Println ( strings . Repeat ( "-" , 82 ) )
fmt . Println ( "\nLegend:" )
fmt . Println ( " [✓] Complete [✗] Failed [.] Pending/Unattempted" )
fmt . Println ( "Datasets:" )
fmt . Println ( " DR: drivers SR: session_result SG: starting_grid" )
fmt . Println ( " ST: stints PS: pit_stops PO: positions" )
fmt . Println ( " RC: race_control WE: weather LA: laps" )
fmt . Println ( )
return nil
}