From adfa934f9d426878a54c79f947ae4f5775b33243 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sun, 12 Jul 2026 18:05:13 -0400 Subject: [PATCH] feat: add canonical weekend context API --- internal/query/context.go | 425 +++++++++++++++++++++++++++++++++ internal/query/context_test.go | 258 ++++++++++++++++++++ internal/query/racehub.go | 12 +- internal/web/context.go | 52 ++++ internal/web/context_test.go | 113 +++++++++ internal/web/server.go | 1 + 6 files changed, 860 insertions(+), 1 deletion(-) create mode 100644 internal/query/context.go create mode 100644 internal/query/context_test.go create mode 100644 internal/web/context.go create mode 100644 internal/web/context_test.go diff --git a/internal/query/context.go b/internal/query/context.go new file mode 100644 index 0000000..06f1980 --- /dev/null +++ b/internal/query/context.go @@ -0,0 +1,425 @@ +package query + +import ( + "sort" + "strings" + "time" + + "github.com/AmanTahiliani/box-box/internal/models" + "github.com/AmanTahiliani/box-box/internal/store" +) + +// TemporalState describes where the fan is in the current season/weekend. +type TemporalState string + +const ( + TemporalNoSeason TemporalState = "no_season" + TemporalBetweenWeekends TemporalState = "between_weekends" + TemporalPreSession TemporalState = "pre_session" + TemporalSessionLive TemporalState = "session_live" + TemporalSessionSettling TemporalState = "session_settling" + TemporalBetweenSessions TemporalState = "between_sessions" + TemporalPostWeekend TemporalState = "post_weekend" + TemporalSeasonComplete TemporalState = "season_complete" + + preSessionWindow = 48 * time.Hour + postWeekendWindow = 48 * time.Hour +) + +// LiveEvidence is the small, transport-independent subset of FIA state needed +// by the context resolver. Active identity is authoritative over the schedule. +type LiveEvidence struct { + Active bool + Final bool + MeetingName string + CircuitName string + SessionName string + SessionType string + ObservedAt time.Time +} + +// ContextAvailability is structured source state for a referenced session. +type ContextAvailability struct { + Schedule string `json:"schedule"` + LiveTransport string `json:"live_transport"` + LiveSession string `json:"live_session"` + Archive string `json:"archive"` + LocalAnalysis string `json:"local_analysis"` + Freshness string `json:"freshness"` + ObservedAt string `json:"observed_at,omitempty"` + Limitations []string `json:"limitations"` +} + +// ContextSession couples a session identity with its availability contract. +type ContextSession struct { + Session models.Session `json:"session"` + Meeting *models.Meeting `json:"meeting,omitempty"` + Availability ContextAvailability `json:"availability"` +} + +// WeekendContext is the canonical local-first previous/current/next model. +type WeekendContext struct { + Season int `json:"season,omitempty"` + TemporalState TemporalState `json:"temporal_state"` + PreviousMeeting *models.Meeting `json:"previous_meeting,omitempty"` + FocusMeeting *models.Meeting `json:"focus_meeting,omitempty"` + NextMeeting *models.Meeting `json:"next_meeting,omitempty"` + PreviousCompletedSession *ContextSession `json:"previous_completed_session,omitempty"` + ActiveSession *ContextSession `json:"active_session,omitempty"` + NextSession *ContextSession `json:"next_session,omitempty"` + DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"` + ChampionshipRound int `json:"championship_round"` + TotalChampionshipRounds int `json:"total_championship_rounds"` +} + +type contextCandidate struct { + meeting store.Meeting + session store.Session + start time.Time + end time.Time + counts store.SessionDatasetCounts + complete bool + archived bool +} + +// ResolveWeekendContext computes the canonical context using only the domain +// store, the service clock, and optional in-memory FIA evidence. +func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, error) { + now := s.now().UTC() + out := WeekendContext{TemporalState: TemporalNoSeason} + years, err := s.store.ListYears() + if err != nil || len(years) == 0 { + return out, err + } + out.Season = currentLocalSeason(years, now.Year()) + meetings, err := s.store.ListMeetingsByYear(out.Season) + if err != nil { + return WeekendContext{}, err + } + if len(meetings) == 0 { + return out, nil + } + + byMeeting := make(map[int][]store.Session, len(meetings)) + var candidates []contextCandidate + for i := range meetings { + m := meetings[i] + sessions, listErr := s.store.ListSessionsByMeeting(m.MeetingKey) + if listErr != nil { + return WeekendContext{}, listErr + } + byMeeting[m.MeetingKey] = sessions + applySessionDisplayRange(&meetings[i], sessions) + m = meetings[i] + for _, sess := range sessions { + if m.IsCancelled || sess.IsCancelled { + continue + } + start, _ := parseContextTime(sess.DateStart) + end, _ := parseContextTime(sess.DateEnd) + if end.IsZero() && !start.IsZero() { + end = start.Add(3 * time.Hour) + } + counts, countErr := s.store.CountSessionDatasets(sess.SessionKey) + if countErr != nil { + return WeekendContext{}, countErr + } + archived := evidence.Final && liveMatches(evidence, m, sess) + candidates = append(candidates, contextCandidate{ + meeting: m, session: sess, start: start, end: end, counts: counts, + complete: hasMeaningfulAnalysis(counts) || archived, archived: archived, + }) + } + } + sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].start.Before(candidates[j].start) }) + + champMeetings := championshipMeetings(meetings, byMeeting) + out.TotalChampionshipRounds = len(champMeetings) + + var previous, next, defaultAnalysis *contextCandidate + for i := range candidates { + c := &candidates[i] + completionEligible := c.start.IsZero() || !c.start.After(now) || c.archived + if c.complete && completionEligible && (previous == nil || candidateTime(*c).After(candidateTime(*previous))) { + previous = c + } + if c.complete && hasMeaningfulAnalysis(c.counts) && (c.start.IsZero() || !c.start.After(now)) && (defaultAnalysis == nil || candidateTime(*c).After(candidateTime(*defaultAnalysis))) { + defaultAnalysis = c + } + if !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) { + next = c + } + } + + var active *contextCandidate + if evidence.Active { + for i := range candidates { + if liveMatches(evidence, candidates[i].meeting, candidates[i].session) { + active = &candidates[i] + break + } + } + if active == nil { + active = syntheticLiveCandidate(evidence, now) + } + if next != nil && active.session.SessionKey != 0 && next.session.SessionKey == active.session.SessionKey { + next = nil + for i := range candidates { + c := &candidates[i] + if c.session.SessionKey != active.session.SessionKey && c.start.After(now) && (next == nil || c.start.Before(next.start)) { + next = c + } + } + } + } + + if previous != nil { + out.PreviousCompletedSession = sessionRef(*previous, evidence, now) + out.PreviousMeeting = meetingModelByKey(meetings, previous.meeting.MeetingKey) + } + if defaultAnalysis != nil { + out.DefaultAnalysisSession = sessionRef(*defaultAnalysis, evidence, now) + } + if next != nil { + out.NextSession = sessionRef(*next, evidence, now) + out.NextMeeting = meetingModelByKey(meetings, next.meeting.MeetingKey) + } + if active != nil { + out.ActiveSession = sessionRef(*active, evidence, now) + out.FocusMeeting = out.ActiveSession.Meeting + out.TemporalState = TemporalSessionLive + } else { + out.FocusMeeting = chooseFocusMeeting(meetings, previous, next) + out.TemporalState = classifyTemporalState(now, previous, next, candidates, champMeetings) + } + if out.FocusMeeting != nil { + out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey)) + } + return out, nil +} + +func currentLocalSeason(years []int, current int) int { + for _, year := range years { + if year == current { + return year + } + } + for _, year := range years { + if year < current { + return year + } + } + return years[len(years)-1] +} + +func parseContextTime(value string) (time.Time, bool) { + if value == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed.UTC(), true + } + } + return time.Time{}, false +} + +func hasMeaningfulAnalysis(c store.SessionDatasetCounts) bool { + return c.Results > 0 || c.Laps > 0 || c.Stints > 0 || c.Positions > 0 || c.RaceControl > 0 +} + +func candidateTime(c contextCandidate) time.Time { + if !c.end.IsZero() { + return c.end + } + return c.start +} + +func liveMatches(e LiveEvidence, m store.Meeting, s store.Session) bool { + meetingMatch := normalizedContains(e.MeetingName, m.MeetingName) || normalizedContains(e.CircuitName, m.CircuitShortName) + sessionMatch := normalizedEqual(e.SessionName, s.SessionName) + if normalizeIdentity(e.SessionName) == "" { + sessionMatch = normalizedEqual(e.SessionType, s.SessionType) + } + return meetingMatch && sessionMatch +} + +func normalizedEqual(a, b string) bool { + return normalizeIdentity(a) != "" && normalizeIdentity(a) == normalizeIdentity(b) +} +func normalizedContains(a, b string) bool { + a, b = normalizeIdentity(a), normalizeIdentity(b) + return a != "" && b != "" && (strings.Contains(a, b) || strings.Contains(b, a)) +} +func normalizeIdentity(v string) string { + return strings.Map(func(r rune) rune { + if r >= 'A' && r <= 'Z' { + return r + ('a' - 'A') + } + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + return r + } + return -1 + }, v) +} + +func syntheticLiveCandidate(e LiveEvidence, now time.Time) *contextCandidate { + return &contextCandidate{meeting: store.Meeting{MeetingName: e.MeetingName, CircuitShortName: e.CircuitName}, session: store.Session{SessionName: e.SessionName, SessionType: e.SessionType, DateStart: now.Format(time.RFC3339)}, start: now} +} + +func applySessionDisplayRange(m *store.Meeting, sessions []store.Session) { + var first, last time.Time + for _, sess := range sessions { + if sess.IsCancelled { + continue + } + start, ok := parseContextTime(sess.DateStart) + if ok && (first.IsZero() || start.Before(first)) { + first = start + } + end, ok := parseContextTime(sess.DateEnd) + if !ok { + end = start + } + if !end.IsZero() && (last.IsZero() || end.After(last)) { + last = end + } + } + if !first.IsZero() { + m.DateStart = first.Format(time.RFC3339) + } + if !last.IsZero() { + m.DateEnd = last.Format(time.RFC3339) + } +} + +func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting { + for _, meeting := range meetings { + if meeting.MeetingKey == key { + model := meetingToModel(meeting) + return &model + } + } + return nil +} + +func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession { + session := sessionToModel(c.session) + meeting := meetingToModel(c.meeting) + availability := ContextAvailability{Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "fresh", Limitations: []string{}} + availability.LiveTransport = "unknown" + if c.session.SessionKey == 0 { + availability.Schedule = "unavailable" + availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched") + } + if evidence.Active && liveMatches(evidence, c.meeting, c.session) { + availability.LiveTransport = "connected" + availability.LiveSession = "active" + if !evidence.ObservedAt.IsZero() { + availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339) + } + } + if c.archived { + availability.Archive = "available" + if !evidence.ObservedAt.IsZero() { + availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339) + } + } + if c.counts.Results > 0 && (c.counts.Laps+c.counts.Stints+c.counts.Positions+c.counts.RaceControl > 0) { + availability.LocalAnalysis = "complete" + } else if hasMeaningfulAnalysis(c.counts) || c.counts.Drivers+c.counts.StartingGrid+c.counts.Weather > 0 { + availability.LocalAnalysis = "partial" + } else if !c.start.IsZero() && c.start.After(now) { + availability.LocalAnalysis = "not_applicable" + } else { + availability.LocalAnalysis = "pending" + } + return &ContextSession{Session: session, Meeting: &meeting, Availability: availability} +} + +func chooseFocusMeeting(meetings []store.Meeting, previous, next *contextCandidate) *models.Meeting { + if next != nil { + return meetingModelByKey(meetings, next.meeting.MeetingKey) + } + if previous != nil { + return meetingModelByKey(meetings, previous.meeting.MeetingKey) + } + return nil +} + +func classifyTemporalState(now time.Time, previous, next *contextCandidate, candidates []contextCandidate, championship []store.Meeting) TemporalState { + var latestStarted *contextCandidate + for i := range candidates { + if !candidates[i].start.IsZero() && !candidates[i].start.After(now) && (latestStarted == nil || candidates[i].start.After(latestStarted.start)) { + latestStarted = &candidates[i] + } + } + if latestStarted != nil && !latestStarted.complete && !latestStarted.end.IsZero() { + if now.Before(latestStarted.end) { + return TemporalPreSession + } + return TemporalSessionSettling + } + if previous != nil && next != nil && previous.meeting.MeetingKey == next.meeting.MeetingKey { + return TemporalBetweenSessions + } + if previous != nil && meetingFinalSession(*previous, candidates) && !candidateTime(*previous).After(now) && now.Sub(candidateTime(*previous)) <= postWeekendWindow { + return TemporalPostWeekend + } + if next != nil && next.start.Sub(now) <= preSessionWindow { + return TemporalPreSession + } + if next == nil && len(championship) > 0 { + return TemporalSeasonComplete + } + return TemporalBetweenWeekends +} + +func meetingFinalSession(previous contextCandidate, candidates []contextCandidate) bool { + latest := previous.start + for _, c := range candidates { + if c.meeting.MeetingKey == previous.meeting.MeetingKey && c.start.After(latest) { + return false + } + } + return true +} + +func championshipMeetings(meetings []store.Meeting, sessions map[int][]store.Session) []store.Meeting { + var out []store.Meeting + for _, m := range meetings { + if m.IsCancelled || isTestMeeting(m) { + continue + } + for _, sess := range sessions[m.MeetingKey] { + if !sess.IsCancelled && isChampionshipRace(sess) { + out = append(out, m) + break + } + } + } + sort.SliceStable(out, func(i, j int) bool { + a, _ := parseContextTime(out[i].DateStart) + b, _ := parseContextTime(out[j].DateStart) + return a.Before(b) + }) + return out +} + +func isTestMeeting(m store.Meeting) bool { + n := strings.ToLower(m.MeetingName + " " + m.MeetingOfficialName) + return strings.Contains(n, "test") +} +func isChampionshipRace(s store.Session) bool { + n := strings.ToLower(s.SessionName) + t := strings.ToLower(s.SessionType) + return (n == "race" || t == "race") && !strings.Contains(n, "sprint") && !strings.Contains(t, "sprint") +} +func championshipRound(meetings []store.Meeting, key int) int { + for i, m := range meetings { + if m.MeetingKey == key { + return i + 1 + } + } + return 0 +} diff --git a/internal/query/context_test.go b/internal/query/context_test.go new file mode 100644 index 0000000..5c66d0b --- /dev/null +++ b/internal/query/context_test.go @@ -0,0 +1,258 @@ +package query + +import ( + "testing" + "time" + + "github.com/AmanTahiliani/box-box/internal/store" +) + +func contextService(t *testing.T, now time.Time) *Service { + t.Helper() + base := openTestService(t) + return NewServiceWithClock(base.store, func() time.Time { return now }) +} + +func addContextMeeting(t *testing.T, svc *Service, key int, name, start, end string, cancelled bool) { + t.Helper() + if err := svc.store.UpsertMeeting(store.Meeting{MeetingKey: key, MeetingName: name, MeetingOfficialName: name, CircuitShortName: name, Year: 2026, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil { + t.Fatal(err) + } +} + +func addContextSession(t *testing.T, svc *Service, key, meeting int, name, start, end string, cancelled bool) { + t.Helper() + if err := svc.store.UpsertSession(store.Session{SessionKey: key, MeetingKey: meeting, SessionName: name, SessionType: name, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil { + t.Fatal(err) + } +} + +func completeContextSession(t *testing.T, svc *Service, key, meeting int) { + t.Helper() + if err := svc.store.UpsertSessionResult(store.SessionResult{SessionKey: key, MeetingKey: meeting, DriverNumber: 1, Position: 1}); err != nil { + t.Fatal(err) + } +} + +func TestResolveWeekendContextTemporalStates(t *testing.T) { + tests := []struct { + name string + now string + seed func(*testing.T, *Service) + evidence LiveEvidence + want TemporalState + }{ + {name: "no season", now: "2026-06-01T12:00:00Z", want: TemporalNoSeason}, + {name: "between weekends", now: "2026-06-10T12:00:00Z", seed: func(t *testing.T, s *Service) { + addContextMeeting(t, s, 1, "Monaco Grand Prix", "2026-06-01T09:00:00Z", "2026-06-02T16:00:00Z", false) + addContextSession(t, s, 11, 1, "Race", "2026-06-02T14:00:00Z", "2026-06-02T16:00:00Z", false) + completeContextSession(t, s, 11, 1) + addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false) + addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false) + addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false) + }, want: TemporalBetweenWeekends}, + {name: "pre session", now: "2026-06-19T12:00:00Z", seed: func(t *testing.T, s *Service) { + addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false) + addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false) + addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false) + }, want: TemporalPreSession}, + {name: "session live overrides schedule", now: "2026-06-20T12:00:00Z", seed: func(t *testing.T, s *Service) { + addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false) + addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false) + addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false) + }, evidence: LiveEvidence{Active: true, MeetingName: "Canadian Grand Prix", CircuitName: "Canada Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"}, want: TemporalSessionLive}, + {name: "session settling", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) { + addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false) + addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false) + addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false) + }, want: TemporalSessionSettling}, + {name: "between sessions", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) { + addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false) + addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false) + completeContextSession(t, s, 21, 2) + addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false) + }, want: TemporalBetweenSessions}, + {name: "post weekend", now: "2026-06-22T18:00:00Z", seed: func(t *testing.T, s *Service) { + addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false) + addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false) + completeContextSession(t, s, 22, 2) + }, want: TemporalPostWeekend}, + {name: "season complete", now: "2026-06-30T12:00:00Z", seed: func(t *testing.T, s *Service) { + addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false) + addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false) + completeContextSession(t, s, 22, 2) + }, want: TemporalSeasonComplete}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now, _ := time.Parse(time.RFC3339, tt.now) + svc := contextService(t, now) + if tt.seed != nil { + tt.seed(t, svc) + } + got, err := svc.ResolveWeekendContext(tt.evidence) + if err != nil { + t.Fatal(err) + } + if got.TemporalState != tt.want { + t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, tt.want, got) + } + }) + } +} + +func TestResolveWeekendContextTruthRules(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-10T12:00:00Z") + svc := contextService(t, now) + addContextMeeting(t, svc, 1, "Pre-Season Testing", "2026-02-01T00:00:00Z", "2026-02-03T00:00:00Z", false) + addContextSession(t, svc, 10, 1, "Race", "2026-02-03T10:00:00Z", "2026-02-03T12:00:00Z", false) + addContextMeeting(t, svc, 2, "Cancelled Grand Prix", "2026-03-01T00:00:00Z", "2026-03-03T00:00:00Z", true) + addContextSession(t, svc, 20, 2, "Race", "2026-03-03T10:00:00Z", "2026-03-03T12:00:00Z", false) + addContextMeeting(t, svc, 3, "British Grand Prix", "2026-07-01T00:00:00Z", "2026-07-05T00:00:00Z", false) + addContextSession(t, svc, 30, 3, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false) + addContextSession(t, svc, 31, 3, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false) + addContextSession(t, svc, 32, 3, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false) + completeContextSession(t, svc, 32, 3) + addContextMeeting(t, svc, 4, "Belgian Grand Prix", "2026-07-16T00:00:00Z", "2026-07-18T00:00:00Z", false) + addContextSession(t, svc, 40, 4, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false) + addContextSession(t, svc, 41, 4, "Cancelled Practice", "2026-07-18T09:00:00Z", "2026-07-18T10:00:00Z", true) + addContextSession(t, svc, 42, 4, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false) + + got, err := svc.ResolveWeekendContext(LiveEvidence{}) + if err != nil { + t.Fatal(err) + } + if got.TotalChampionshipRounds != 2 || got.ChampionshipRound != 2 { + t.Fatalf("rounds = %d/%d, want 2/2", got.ChampionshipRound, got.TotalChampionshipRounds) + } + if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 32 { + t.Fatalf("default analysis = %+v, want completed race 32", got.DefaultAnalysisSession) + } + if got.NextSession == nil || got.NextSession.Session.SessionKey != 40 { + t.Fatalf("next = %+v, want 40", got.NextSession) + } + if got.NextMeeting.DateStart != "2026-07-17T09:00:00Z" || got.NextMeeting.DateEnd != "2026-07-19T16:00:00Z" { + t.Fatalf("display range = %s..%s", got.NextMeeting.DateStart, got.NextMeeting.DateEnd) + } +} + +func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z") + svc := contextService(t, now) + addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false) + addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false) + got, err := svc.ResolveWeekendContext(LiveEvidence{}) + if err != nil { + t.Fatal(err) + } + if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil { + t.Fatalf("passed schedule was treated complete: %+v", got) + } + archive, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now}) + if err != nil { + t.Fatal(err) + } + if archive.PreviousCompletedSession == nil || archive.PreviousCompletedSession.Availability.Archive != "available" { + t.Fatalf("final archive not used: %+v", archive) + } + if archive.DefaultAnalysisSession != nil { + t.Fatal("archive without local analysis must not become default analysis") + } +} + +func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z") + svc := contextService(t, now) + addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false) + addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false) + completeContextSession(t, svc, 11, 1) // bad/preloaded data must not make a future session canonical + got, err := svc.ResolveWeekendContext(LiveEvidence{}) + if err != nil { + t.Fatal(err) + } + if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil { + t.Fatalf("future analysis selected: %+v", got) + } +} + +func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z") + svc := contextService(t, now) + addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false) + addContextSession(t, svc, 11, 1, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false) + addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false) + completeContextSession(t, svc, 11, 1) + got, err := svc.ResolveWeekendContext(LiveEvidence{}) + if err != nil { + t.Fatal(err) + } + if got.TemporalState != TemporalBetweenSessions || got.PreviousCompletedSession.Session.SessionKey != 11 || got.NextSession.Session.SessionKey != 12 { + t.Fatalf("sprint handoff = %+v", got) + } + if got.TotalChampionshipRounds != 1 { + t.Fatalf("sprint created extra championship round: %d", got.TotalChampionshipRounds) + } +} + +func TestResolveWeekendContextBoundaryTimestamps(t *testing.T) { + t.Run("pre-session window is inclusive", func(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-01T09:00:00Z") + svc := contextService(t, now) + addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false) + addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false) + addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false) + got, err := svc.ResolveWeekendContext(LiveEvidence{}) + if err != nil { + t.Fatal(err) + } + if got.TemporalState != TemporalPreSession { + t.Fatalf("state = %s", got.TemporalState) + } + }) + t.Run("scheduled end enters settling", func(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-03T10:00:00Z") + svc := contextService(t, now) + addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false) + addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false) + addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false) + got, err := svc.ResolveWeekendContext(LiveEvidence{}) + if err != nil { + t.Fatal(err) + } + if got.TemporalState != TemporalSessionSettling { + t.Fatalf("state = %s", got.TemporalState) + } + }) + t.Run("post-weekend window is inclusive", func(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-07T16:00:00Z") + svc := contextService(t, now) + addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false) + addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false) + completeContextSession(t, svc, 12, 1) + got, err := svc.ResolveWeekendContext(LiveEvidence{}) + if err != nil { + t.Fatal(err) + } + if got.TemporalState != TemporalPostWeekend { + t.Fatalf("state = %s", got.TemporalState) + } + }) +} + +func TestResolveWeekendContextPartialFutureSchedule(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z") + svc := contextService(t, now) + addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "", false) + addContextSession(t, svc, 11, 1, "Practice 1", "", "", false) + addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "", false) + got, err := svc.ResolveWeekendContext(LiveEvidence{}) + if err != nil { + t.Fatal(err) + } + if got.NextSession == nil || got.NextSession.Session.SessionKey != 12 { + t.Fatalf("partial schedule next = %+v", got.NextSession) + } + if got.FocusMeeting == nil || got.FocusMeeting.DateStart != "2026-07-05T14:00:00Z" || got.FocusMeeting.DateEnd != "2026-07-05T14:00:00Z" { + t.Fatalf("partial display range = %+v", got.FocusMeeting) + } +} diff --git a/internal/query/racehub.go b/internal/query/racehub.go index c5b8f1e..e1731c8 100644 --- a/internal/query/racehub.go +++ b/internal/query/racehub.go @@ -3,6 +3,7 @@ package query import ( "database/sql" "errors" + "time" "github.com/AmanTahiliani/box-box/internal/chapters" "github.com/AmanTahiliani/box-box/internal/models" @@ -12,11 +13,20 @@ import ( // Service assembles store-backed read models. type Service struct { store *store.Store + now func() time.Time } // NewService creates a query service over a domain store. func NewService(st *store.Store) *Service { - return &Service{store: st} + return NewServiceWithClock(st, time.Now) +} + +// NewServiceWithClock creates a query service with an injected clock. +func NewServiceWithClock(st *store.Store, now func() time.Time) *Service { + if now == nil { + now = time.Now + } + return &Service{store: st, now: now} } // EnrichedResult is a session result with driver identity fields. diff --git a/internal/web/context.go b/internal/web/context.go new file mode 100644 index 0000000..ee63deb --- /dev/null +++ b/internal/web/context.go @@ -0,0 +1,52 @@ +package web + +import ( + "net/http" + "strings" + + "github.com/AmanTahiliani/box-box/internal/live" + "github.com/AmanTahiliani/box-box/internal/query" +) + +func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) { + if !s.hasLocalQuery() { + writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason}) + return + } + + state := s.hub.State() + evidence := query.LiveEvidence{} + if state.IsLive && state.Data != nil { + evidence = liveEvidence(state.Data, true, false) + } else if state.LastSnapshot != nil && terminalSessionStatus(state.LastSnapshot.SessionStatus) { + evidence = liveEvidence(state.LastSnapshot, false, true) + if state.LastSnapshotAt != nil { + evidence.ObservedAt = *state.LastSnapshotAt + } + } + context, err := s.query.ResolveWeekendContext(evidence) + if err != nil { + writeError(w, err, http.StatusInternalServerError, false) + return + } + writeJSON(w, context) +} + +func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence { + return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType} +} + +func terminalSessionStatus(status string) bool { + normalized := strings.ToLower(strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + return r + } + return -1 + }, status)) + switch normalized { + case "finished", "finalised", "finalized", "ended", "aborted": + return true + default: + return false + } +} diff --git a/internal/web/context_test.go b/internal/web/context_test.go new file mode 100644 index 0000000..f5afd0c --- /dev/null +++ b/internal/web/context_test.go @@ -0,0 +1,113 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/AmanTahiliani/box-box/internal/live" + "github.com/AmanTahiliani/box-box/internal/query" + "github.com/AmanTahiliani/box-box/internal/store" +) + +func openContextStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "context.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func seedContextHandler(t *testing.T, st *store.Store) { + t.Helper() + if err := st.UpsertMeeting(store.Meeting{MeetingKey: 1, MeetingName: "British Grand Prix", CircuitShortName: "Silverstone", Year: 2026, DateStart: "2026-07-03T09:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil { + t.Fatal(err) + } + if err := st.UpsertSession(store.Session{SessionKey: 11, MeetingKey: 1, SessionName: "Race", SessionType: "Race", DateStart: "2026-07-05T14:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil { + t.Fatal(err) + } +} + +func TestWeekendContextHandlerWithoutStoreReturnsNoSeason(t *testing.T) { + s := NewServer(nil, 0, nil) + rr := httptest.NewRecorder() + s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + var got query.WeekendContext + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.TemporalState != query.TemporalNoSeason { + t.Fatalf("state = %s", got.TemporalState) + } +} + +func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) { + st := openContextStore(t) + seedContextHandler(t, st) + now, _ := time.Parse(time.RFC3339, "2026-07-05T13:55:00Z") + s := NewServer(nil, 0, st) // a nil OpenF1 client makes any REST dependency panic + s.query = query.NewServiceWithClock(st, func() time.Time { return now }) + s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Started", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now) + + handler, err := s.routes() + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String()) + } + var got query.WeekendContext + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.TemporalState != query.TemporalSessionLive || got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 { + t.Fatalf("live context = %+v", got) + } + if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" { + t.Fatalf("availability = %+v", got.ActiveSession.Availability) + } +} + +func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) { + st := openContextStore(t) + seedContextHandler(t, st) + now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z") + s := NewServer(nil, 0, st) + s.query = query.NewServiceWithClock(st, func() time.Time { return now }) + s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now) + rr := httptest.NewRecorder() + s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil)) + var got query.WeekendContext + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" { + t.Fatalf("archive context = %+v", got) + } + if got.DefaultAnalysisSession != nil { + t.Fatal("archive-only session must not become local default analysis") + } +} + +func TestTerminalSessionStatus(t *testing.T) { + for _, status := range []string{"Finished", "Finalised", "ENDED", "Aborted"} { + if !terminalSessionStatus(status) { + t.Errorf("%q should be terminal", status) + } + } + for _, status := range []string{"Started", "Resumed", "Inactive", ""} { + if terminalSessionStatus(status) { + t.Errorf("%q should not be terminal", status) + } + } +} diff --git a/internal/web/server.go b/internal/web/server.go index f748c03..1612966 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -62,6 +62,7 @@ func (s *Server) routes() (http.Handler, error) { // REST API — /api/v1/laps/comparison must be registered before /api/v1/laps // because Go's ServeMux uses longest-prefix matching. + mux.HandleFunc("/api/v1/weekend-context", s.handleWeekendContext) mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub) mux.HandleFunc("/api/v1/seasons", s.handleSeasons) mux.HandleFunc("/api/v1/weekend", s.handleWeekend)