package query import ( "database/sql" "errors" "time" "github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/store" ) // weekendNow is the clock used to decide whether a session has started. It is a // package var so tests can pin it deterministically. var weekendNow = time.Now // ErrMeetingNotFound is returned when a meeting is not in the local store. var ErrMeetingNotFound = errors.New("meeting not found") // WeekendSession is one session within a meeting with dataset coverage. type WeekendSession struct { Session models.Session `json:"session"` Source string `json:"source"` Datasets map[string]DatasetInfo `json:"datasets"` } // Weekend is the local-first read model for one race weekend. type Weekend struct { Source string `json:"source"` MeetingKey int `json:"meeting_key"` Meeting models.Meeting `json:"meeting"` Sessions []WeekendSession `json:"sessions"` DefaultSessionKey int `json:"default_session_key,omitempty"` // DefaultAnalysisSession is the session a fan-facing default landing should // open. Unlike DefaultSessionKey it never resolves to a future session, so // bare /race-hub never renders empty post-session analysis. DefaultAnalysisSession int `json:"default_analysis_session,omitempty"` } // ListSeasons returns years with ingested meetings, newest first. func (s *Service) ListSeasons() ([]int, error) { return s.store.ListYears() } // GetWeekend loads meeting metadata, sessions, and per-session dataset coverage. func (s *Service) GetWeekend(meetingKey int) (Weekend, error) { meeting, err := s.store.GetMeeting(meetingKey) if err != nil { if errors.Is(err, sql.ErrNoRows) { return Weekend{}, ErrMeetingNotFound } return Weekend{}, err } sessions, err := s.store.ListSessionsByMeeting(meetingKey) if err != nil { return Weekend{}, err } out := Weekend{ Source: ResponseSourceNone, MeetingKey: meetingKey, Meeting: meetingToModel(meeting), Sessions: []WeekendSession{}, } if len(sessions) == 0 { return out, nil } out.Sessions = make([]WeekendSession, 0, len(sessions)) for _, sess := range sessions { counts, err := s.store.CountSessionDatasets(sess.SessionKey) if err != nil { return Weekend{}, err } datasets := datasetsFromCounts(true, true, counts) sessionModel := sessionToModel(sess) if sess.IsCancelled { datasets = cancelledDatasets() } if datasets["starting_grid"].Status == DatasetStatusMissing && !isGridExpected(sessionModel.SessionType, sessionModel.SessionName) { datasets["starting_grid"] = skippedNA() } out.Sessions = append(out.Sessions, WeekendSession{ Session: sessionModel, Source: sessionSource(sess, datasets), Datasets: datasets, }) } if meeting.IsCancelled { out.Source = ResponseSourceCancelled } else { out.Source = weekendSource(out.Sessions) } out.DefaultSessionKey = pickDefaultSession(out.Sessions) out.DefaultAnalysisSession = pickDefaultAnalysisSession(out.Sessions, weekendNow()) return out, nil } func sessionSource(sess store.Session, datasets map[string]DatasetInfo) string { if sess.IsCancelled { return ResponseSourceCancelled } return responseSource(datasets) } func weekendSource(sessions []WeekendSession) string { if len(sessions) == 0 { return ResponseSourceNone } hasLocal := false allMissing := true for _, sess := range sessions { if sess.Source == ResponseSourceNone { continue } allMissing = false if sess.Source == ResponseSourceLocal || sess.Source == ResponseSourcePartial { hasLocal = true } } if allMissing { return ResponseSourceNone } for _, sess := range sessions { if sess.Source == ResponseSourcePartial || sess.Source == ResponseSourceNone { return ResponseSourcePartial } } if hasLocal { return ResponseSourceLocal } return ResponseSourceNone } func pickDefaultSession(sessions []WeekendSession) int { if len(sessions) == 0 { return 0 } bestIdx := 0 bestScore := datasetScore(sessions[0].Datasets) for i := 1; i < len(sessions); i++ { score := datasetScore(sessions[i].Datasets) if score >= bestScore { bestScore = score bestIdx = i } } return sessions[bestIdx].Session.SessionKey } // pickDefaultAnalysisSession chooses the session a fan should land on by default. // It never returns a future session: among sessions that have already started // (or whose start time is unknown) it prefers the one with the richest local // dataset coverage, breaking ties toward the later session. When every session // is still upcoming it returns 0 so callers render a pre-session view instead of // empty analysis. func pickDefaultAnalysisSession(sessions []WeekendSession, now time.Time) int { bestKey := 0 bestScore := -1 var bestStart time.Time for _, sess := range sessions { start, ok := parseSessionStart(sess.Session.DateStart) // Skip sessions that are clearly in the future; unknown start times are // treated as eligible so historical data without timestamps still works. if ok && start.After(now) { continue } score := datasetScore(sess.Datasets) if score > bestScore || (score == bestScore && ok && start.After(bestStart)) { bestScore = score bestKey = sess.Session.SessionKey if ok { bestStart = start } } } return bestKey } func parseSessionStart(value string) (time.Time, bool) { if value == "" { return time.Time{}, false } if t, err := time.Parse(time.RFC3339, value); err == nil { return t, true } if t, err := time.Parse("2006-01-02T15:04:05", value); err == nil { return t, true } if t, err := time.Parse("2006-01-02", value[:min(len(value), 10)]); err == nil { return t, true } return time.Time{}, false } func datasetScore(datasets map[string]DatasetInfo) int { score := 0 for _, info := range datasets { if info.Status == DatasetStatusAvailable { score++ } } return score }