diff --git a/latest.go b/latest.go index ee715f3..f9be533 100644 --- a/latest.go +++ b/latest.go @@ -106,6 +106,9 @@ func (a *app) latestSession(c echo.Context) error { if cached, ok := a.cache.getDurable(sessionResultsKey(year, s), now); ok { fallback := a.sessionResponse(year, s, cached) fallback["pending"] = result["session"] + if upstreamErr, ok := result["upstream_error"]; ok { + fallback["upstream_error"] = upstreamErr + } result, ttl = fallback, 2*time.Minute break } @@ -125,7 +128,7 @@ func (a *app) fetchSessionResults(key string, at, now time.Time) ([]map[string]a return nil, false, fmt.Errorf("session not on OpenF1 yet") } var results []struct { - Position *int `json:"position"` + Position sessionPosition `json:"position"` DriverNumber int `json:"driver_number"` Duration json.RawMessage `json:"duration"` Gap json.RawMessage `json:"gap_to_leader"` @@ -151,10 +154,10 @@ func (a *app) fetchSessionResults(key string, at, now time.Time) ([]map[string]a byNumber[d.Number] = i } sort.SliceStable(results, func(i, j int) bool { - if results[i].Position == nil || results[j].Position == nil { - return results[i].Position != nil + if results[i].Position.n == nil || results[j].Position.n == nil { + return results[i].Position.n != nil } - return *results[i].Position < *results[j].Position + return *results[i].Position.n < *results[j].Position.n }) rows := make([]map[string]any, 0, len(results)) for _, r := range results { @@ -163,16 +166,43 @@ func (a *app) fetchSessionResults(key string, at, now time.Time) ([]map[string]a if i, ok := byNumber[r.DriverNumber]; ok { row["driver"], row["surname"], row["team"] = drivers[i].Acronym, drivers[i].LastName, shortTeamName(drivers[i].Team) } - if r.Position != nil { - row["position"] = *r.Position + if r.Position.n != nil { + row["position"] = *r.Position.n + } + row["time"] = sessionTimeCell(r.Duration, r.Gap, r.DNF, r.DNS, r.DSQ, r.Position.n != nil && *r.Position.n == 1) + if row["time"] == "" && r.Position.text != "" { + row["time"] = r.Position.text } - row["time"] = sessionTimeCell(r.Duration, r.Gap, r.DNF, r.DNS, r.DSQ, r.Position != nil && *r.Position == 1) rows = append(rows, row) } end, err := time.Parse(time.RFC3339, match.End) return rows, err == nil && end.Before(now), nil } +// sessionPosition is OpenF1's `position`: an integer, null, or a status string +// like "RT" for a driver who didn't set a time. One string used to fail the +// whole result list. +type sessionPosition struct { + n *int + text string +} + +func (p *sessionPosition) UnmarshalJSON(raw []byte) error { + if string(raw) == "null" { + return nil + } + var n int + if json.Unmarshal(raw, &n) == nil { + p.n = &n + return nil + } + var text string + if json.Unmarshal(raw, &text) == nil { + p.text = text + } + return nil +} + func sessionTimeCell(duration, gap json.RawMessage, dnf, dns, dsq, leader bool) string { switch { case dsq: diff --git a/main_test.go b/main_test.go index 4dcadff..e1d40b2 100644 --- a/main_test.go +++ b/main_test.go @@ -238,6 +238,20 @@ func TestLatestSessionContract(t *testing.T) { } } +// OpenF1 sends "RT" as the position of a driver with no time; one string used to +// fail the whole qualifying result list and leave the fallback session showing. +func TestSessionPositionAcceptsStatusText(t *testing.T) { + var rows []struct { + Position sessionPosition `json:"position"` + } + if err := json.Unmarshal([]byte(`[{"position":1},{"position":null},{"position":"RT"}]`), &rows); err != nil { + t.Fatal(err) + } + if rows[0].Position.n == nil || *rows[0].Position.n != 1 || rows[1].Position.n != nil || rows[2].Position.n != nil || rows[2].Position.text != "RT" { + t.Fatalf("unexpected positions: %#v", rows) + } +} + func TestLatestSessionExcludesRaceAndSurvivesLockout(t *testing.T) { mock := newUpstreamMock(t) a := testApp(t, mock) @@ -324,7 +338,7 @@ func TestLatestSessionFallsBackToLastCompleteSessionDuringLockout(t *testing.T) // A later session has finished but OpenF1 is locked out: keep showing the cached one. a.now = func() time.Time { return time.Date(2026, 3, 7, 17, 0, 0, 0, time.UTC) } result := decode(t, request(t, server, "/f1/latest_session/")) - if result["pending"] == nil || result["session"] == result["pending"] || len(result["results"].([]any)) != 3 { + if result["pending"] == nil || result["session"] == result["pending"] || result["upstream_error"] == nil || len(result["results"].([]any)) != 3 { t.Fatalf("expected earlier cached session plus pending note (before=%v): %#v", before["session"], result) } }