From 88a40c1d98340af07c3aa0f205ebab4bec9a6049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81ris=20Pop=C4=93ns?= Date: Sat, 10 Oct 2026 22:21:34 +0300 Subject: [PATCH 1/2] fix: accept status text as an OpenF1 session result position OpenF1 reports position as a string (e.g. "RT") for a driver with no time. Decoding it into *int failed the whole session_result list, so the latest session fell back to the previous one with a misleading "results not available yet" note. Accept a number, null or a status string, and show the status text in the time column when there is no time. --- latest.go | 41 ++++++++++++++++++++++++++++++++++------- main_test.go | 14 ++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/latest.go b/latest.go index ee715f3..d256b93 100644 --- a/latest.go +++ b/latest.go @@ -125,7 +125,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 +151,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 +163,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..78bcc4e 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) From ede1b29657568cf2dc3214c47fa0a0b3d1bdc347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81ris=20Pop=C4=93ns?= Date: Sat, 10 Oct 2026 22:22:33 +0300 Subject: [PATCH 2/2] fix: keep upstream_error on the fallback latest-session response When the newest session's results can't be fetched, the response falls back to the previous session but dropped the error that explained why, so the widget just said results weren't available yet. Carry upstream_error over so the cause is visible. --- latest.go | 3 +++ main_test.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/latest.go b/latest.go index d256b93..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 } diff --git a/main_test.go b/main_test.go index 78bcc4e..e1d40b2 100644 --- a/main_test.go +++ b/main_test.go @@ -338,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) } }