diff --git a/internal/query/context.go b/internal/query/context.go index 06f1980..7c61e51 100644 --- a/internal/query/context.go +++ b/internal/query/context.go @@ -136,21 +136,6 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, 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 { @@ -162,14 +147,21 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, 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 - } - } + } + + var previous, next, defaultAnalysis *contextCandidate + for i := range candidates { + c := &candidates[i] + isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey + completionEligible := c.start.IsZero() || !c.start.After(now) || c.archived + if !isActive && c.complete && completionEligible && (previous == nil || candidateTime(*c).After(candidateTime(*previous))) { + previous = c + } + if !isActive && c.complete && hasMeaningfulAnalysis(c.counts) && (c.start.IsZero() || !c.start.After(now)) && (defaultAnalysis == nil || candidateTime(*c).After(candidateTime(*defaultAnalysis))) { + defaultAnalysis = c + } + if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) { + next = c } } @@ -190,7 +182,7 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, out.TemporalState = TemporalSessionLive } else { out.FocusMeeting = chooseFocusMeeting(meetings, previous, next) - out.TemporalState = classifyTemporalState(now, previous, next, candidates, champMeetings) + out.TemporalState = classifyTemporalState(now, previous, next, candidates, champMeetings, championshipScheduleUnknown(champMeetings, byMeeting)) } if out.FocusMeeting != nil { out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey)) @@ -347,13 +339,18 @@ func chooseFocusMeeting(meetings []store.Meeting, previous, next *contextCandida return nil } -func classifyTemporalState(now time.Time, previous, next *contextCandidate, candidates []contextCandidate, championship []store.Meeting) TemporalState { +func classifyTemporalState(now time.Time, previous, next *contextCandidate, candidates []contextCandidate, championship []store.Meeting, scheduleUnknown bool) 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] } } + // Once a new meeting enters its preparation window, an ingest gap from an + // older meeting must not keep the product stuck in settling. + if next != nil && next.start.Sub(now) <= preSessionWindow && (latestStarted == nil || latestStarted.meeting.MeetingKey != next.meeting.MeetingKey) { + return TemporalPreSession + } if latestStarted != nil && !latestStarted.complete && !latestStarted.end.IsZero() { if now.Before(latestStarted.end) { return TemporalPreSession @@ -369,12 +366,25 @@ func classifyTemporalState(now time.Time, previous, next *contextCandidate, cand if next != nil && next.start.Sub(now) <= preSessionWindow { return TemporalPreSession } - if next == nil && len(championship) > 0 { + if next == nil && len(championship) > 0 && !scheduleUnknown { return TemporalSeasonComplete } return TemporalBetweenWeekends } +func championshipScheduleUnknown(meetings []store.Meeting, sessions map[int][]store.Session) bool { + for _, meeting := range meetings { + for _, session := range sessions[meeting.MeetingKey] { + if !session.IsCancelled && isChampionshipRace(session) { + if _, ok := parseContextTime(session.DateStart); !ok { + return true + } + } + } + } + return false +} + func meetingFinalSession(previous contextCandidate, candidates []contextCandidate) bool { latest := previous.start for _, c := range candidates { diff --git a/internal/query/context_test.go b/internal/query/context_test.go index 5c66d0b..8dac292 100644 --- a/internal/query/context_test.go +++ b/internal/query/context_test.go @@ -256,3 +256,66 @@ func TestResolveWeekendContextPartialFutureSchedule(t *testing.T) { t.Fatalf("partial display range = %+v", got.FocusMeeting) } } + +func TestResolveWeekendContextActiveSessionIsNotCompletedOrDefault(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-05T15: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, 10, 1, "Qualifying", "2026-07-04T14:00:00Z", "2026-07-04T15:00:00Z", false) + completeContextSession(t, svc, 10, 1) + addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false) + completeContextSession(t, svc, 11, 1) + + got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now}) + if err != nil { + t.Fatal(err) + } + if got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 { + t.Fatalf("active = %+v", got.ActiveSession) + } + if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 10 { + t.Fatalf("previous = %+v, want earlier completed session", got.PreviousCompletedSession) + } + if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 10 { + t.Fatalf("default = %+v, want earlier completed session", got.DefaultAnalysisSession) + } +} + +func TestResolveWeekendContextOldIncompleteSessionDoesNotSuppressNextWeekend(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-16T12: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, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false) + addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false) + addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false) + addContextSession(t, svc, 22, 2, "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.TemporalState != TemporalPreSession { + t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, TemporalPreSession, got) + } + if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 { + t.Fatalf("focus = %+v, want Belgian weekend", got.FocusMeeting) + } +} + +func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z") + svc := contextService(t, now) + addContextMeeting(t, svc, 1, "British Grand Prix", "", "", false) + addContextSession(t, svc, 11, 1, "Race", "", "", false) + + got, err := svc.ResolveWeekendContext(LiveEvidence{}) + if err != nil { + t.Fatal(err) + } + if got.TemporalState != TemporalBetweenWeekends { + t.Fatalf("state = %s, want limited %s context", got.TemporalState, TemporalBetweenWeekends) + } + if got.TotalChampionshipRounds != 1 { + t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds) + } +}