Stabilize F1 Live scrolling and news reader, and speed up the dashboard fetch.

This commit is contained in:
Jos Vooges | STH 2026-10-01 02:06:50 +02:00
parent b911a4a7a7
commit 00efebdf84
5 changed files with 155 additions and 59 deletions

View file

@ -358,6 +358,12 @@ actor ApiClient {
try await get("/api/v1/client/f1/live") try await get("/api/v1/client/f1/live")
} }
func f1News() async throws -> [F1NewsItem] {
struct Wrap: Decodable { let items: [F1NewsItem]? }
let wrap: Wrap = try await get("/api/v1/client/f1/news")
return wrap.items ?? []
}
func f1NewsArticle(id: String) async throws -> F1NewsArticle { func f1NewsArticle(id: String) async throws -> F1NewsArticle {
let enc = Self.encodePathSegment(id) let enc = Self.encodePathSegment(id)
return try await get("/api/v1/client/f1/news/\(enc)") return try await get("/api/v1/client/f1/news/\(enc)")

View file

@ -250,8 +250,9 @@ struct F1HubScreen: View {
.buttonStyle(HubPrimaryButtonStyle()) .buttonStyle(HubPrimaryButtonStyle())
NavigationLink(value: EventHubRoute.f1Live) { NavigationLink(value: EventHubRoute.f1Live) {
Label("Timing", systemImage: "timer") Label("Timing", systemImage: "timer")
.modifier(HubNavCapsule(kind: .glass))
} }
.buttonStyle(HubGlassButtonStyle()) .buttonStyle(.plain)
} else if let meeting { } else if let meeting {
NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) { NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) {
Label("Bekijk weekend", systemImage: "calendar") Label("Bekijk weekend", systemImage: "calendar")
@ -259,13 +260,15 @@ struct F1HubScreen: View {
.buttonStyle(HubPrimaryButtonStyle()) .buttonStyle(HubPrimaryButtonStyle())
NavigationLink(value: EventHubRoute.f1Live) { NavigationLink(value: EventHubRoute.f1Live) {
Label("Timing", systemImage: "timer") Label("Timing", systemImage: "timer")
.modifier(HubNavCapsule(kind: .glass))
} }
.buttonStyle(HubGlassButtonStyle()) .buttonStyle(.plain)
} else { } else {
NavigationLink(value: EventHubRoute.f1Live) { NavigationLink(value: EventHubRoute.f1Live) {
Label("Live timing", systemImage: "timer") Label("Live timing", systemImage: "timer")
.modifier(HubNavCapsule(kind: .primary))
} }
.buttonStyle(HubPrimaryButtonStyle()) .buttonStyle(.plain)
} }
} }
} else if let meeting { } else if let meeting {
@ -286,8 +289,9 @@ struct F1HubScreen: View {
.buttonStyle(HubPrimaryButtonStyle()) .buttonStyle(HubPrimaryButtonStyle())
NavigationLink(value: EventHubRoute.f1Live) { NavigationLink(value: EventHubRoute.f1Live) {
Label("Timing", systemImage: "timer") Label("Timing", systemImage: "timer")
.modifier(HubNavCapsule(kind: .glass))
} }
.buttonStyle(HubGlassButtonStyle()) .buttonStyle(.plain)
} }
} else { } else {
HubHero( HubHero(
@ -301,8 +305,9 @@ struct F1HubScreen: View {
) { ) {
NavigationLink(value: EventHubRoute.f1Live) { NavigationLink(value: EventHubRoute.f1Live) {
Label("Live timing", systemImage: "timer") Label("Live timing", systemImage: "timer")
.modifier(HubNavCapsule(kind: .primary))
} }
.buttonStyle(HubPrimaryButtonStyle()) .buttonStyle(.plain)
} }
} }
} }
@ -347,7 +352,7 @@ struct F1HubScreen: View {
) )
.padding(.horizontal, 20) .padding(.horizontal, 20)
} }
.buttonStyle(HubPressStyle()) .buttonStyle(.plain)
} }
private func seasonSection(_ p: F1HubPayload) -> some View { private func seasonSection(_ p: F1HubPayload) -> some View {
@ -1579,6 +1584,22 @@ struct HubPressStyle: ButtonStyle {
} }
} }
private struct HubNavCapsule: ViewModifier {
enum Kind { case glass, primary }
let kind: Kind
func body(content: Content) -> some View {
content
.font(.system(size: 16, weight: .semibold))
.lineLimit(1)
.foregroundStyle(kind == .primary ? Color.black : Color.white)
.padding(.horizontal, kind == .primary ? 22 : 20)
.frame(height: 46)
.background(kind == .primary ? Color.white : Color.white.opacity(0.16), in: Capsule())
.overlay(Capsule().strokeBorder(.white.opacity(kind == .primary ? 0 : 0.18), lineWidth: 1))
}
}
private struct HubPrimaryButtonStyle: ButtonStyle { private struct HubPrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label

View file

@ -25,13 +25,17 @@ struct F1LiveScreen: View {
@EnvironmentObject private var app: AppModel @EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@State private var tab: Tab = .overview @State private var tab: Tab = .overview
@State private var payload: F1LiveDashboard? @State private var payload: F1LiveDashboard?
@State private var error: String? @State private var error: String?
@State private var speedTrail: [Double] = [] @State private var speedTrail: [Double] = []
@State private var selectedNews: F1NewsItem?
@State private var loading = false
@State private var newsItems: [F1NewsItem] = []
private let clock = Timer.publish(every: 4, on: .main, in: .common).autoconnect() private let clock = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
@ -63,8 +67,18 @@ struct F1LiveScreen: View {
.environment(\.colorScheme, .dark) .environment(\.colorScheme, .dark)
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .navigationBar)
.task { await load() } .safeAreaPadding(.top)
.onReceive(clock) { _ in Task { await load(silent: true) } } .onAppear {
if payload == nil { Task { await load() } }
}
.onReceive(clock) { _ in
guard scenePhase == .active, selectedNews == nil, payload != nil else { return }
Task { await load(silent: true) }
}
.fullScreenCover(item: $selectedNews) { item in
F1NewsArticleScreen(preview: item)
.environmentObject(app)
}
} }
@ViewBuilder @ViewBuilder
@ -80,20 +94,21 @@ struct F1LiveScreen: View {
].compactMap { $0 }.joined(separator: " · ") ].compactMap { $0 }.joined(separator: " · ")
) )
tabBar tabBar
ScrollView { ScrollView(.vertical, showsIndicators: false) {
Group { Group {
switch tab { switch tab {
case .overview: overview(p) case .overview: overview(p)
case .timing: timingTable(p.timing) case .timing: timingTable(p.timing)
case .map: mapBlock(p) case .map: mapBlock(p)
case .telemetry: telemetryBlock(p) case .telemetry: telemetryBlock(p)
case .news: newsList(p.news) case .news: newsList(newsItems)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 14) .padding(.top, 14)
.padding(.bottom, 40) .padding(.bottom, 40)
} }
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
} }
} }
@ -157,6 +172,7 @@ struct F1LiveScreen: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
.frame(height: 38)
.padding(.bottom, 4) .padding(.bottom, 4)
} }
@ -191,9 +207,9 @@ struct F1LiveScreen: View {
if let t = p.telemetry { if let t = p.telemetry {
telemetryCard(t) telemetryCard(t)
} }
if !p.news.isEmpty { if !newsItems.isEmpty {
sectionTitle("Nieuws") sectionTitle("Nieuws")
ForEach(p.news.prefix(4)) { item in ForEach(newsItems.prefix(4)) { item in
newsCard(item) newsCard(item)
} }
} }
@ -465,8 +481,8 @@ struct F1LiveScreen: View {
} }
private func newsCard(_ item: F1NewsItem) -> some View { private func newsCard(_ item: F1NewsItem) -> some View {
NavigationLink { Button {
F1NewsArticleScreen(preview: item) selectedNews = item
} label: { } label: {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
RemoteImage(url: item.imageUrl, contentMode: .fill) RemoteImage(url: item.imageUrl, contentMode: .fill)
@ -574,15 +590,30 @@ struct F1LiveScreen: View {
} }
private func load(silent: Bool = false) async { private func load(silent: Bool = false) async {
if loading { return }
loading = true
defer { loading = false }
do { do {
let next = try await app.api.f1Live() let next = try await app.api.f1Live()
if Task.isCancelled { return }
payload = next payload = next
error = nil error = nil
if newsItems.isEmpty { newsItems = next.news }
if let speed = next.telemetry?.speed { if let speed = next.telemetry?.speed {
speedTrail.append(speed) speedTrail.append(speed)
if speedTrail.count > 40 { speedTrail.removeFirst(speedTrail.count - 40) } if speedTrail.count > 40 { speedTrail.removeFirst(speedTrail.count - 40) }
} }
if newsItems.isEmpty {
if let items = try? await app.api.f1News(), !items.isEmpty, !Task.isCancelled {
newsItems = items
}
}
} catch is CancellationError {
return
} catch let err as URLError where err.code == .cancelled {
return
} catch { } catch {
if Task.isCancelled { return }
if payload == nil && !silent { self.error = error.localizedDescription } if payload == nil && !silent { self.error = error.localizedDescription }
} }
} }
@ -710,14 +741,14 @@ private struct F1NewsArticleScreen: View {
@State private var error: String? @State private var error: String?
var body: some View { var body: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .top) {
F1LiveLook.bg.ignoresSafeArea() F1LiveLook.bg.ignoresSafeArea()
ScrollView(showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
hero hero
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 16) {
Text(article?.title ?? preview.title) Text(article?.title ?? preview.title)
.font(.system(size: 28, weight: .bold)) .font(.system(size: 26, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
metaRow metaRow
@ -741,22 +772,34 @@ private struct F1NewsArticleScreen: View {
} }
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 22) .padding(.top, 18)
.padding(.bottom, 56) .padding(.bottom, 40)
} }
} }
backButton }
.safeAreaInset(edge: .top, spacing: 0) {
HStack {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.white.opacity(0.14), in: Circle())
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(F1LiveLook.bg.opacity(0.92))
} }
.environment(\.colorScheme, .dark) .environment(\.colorScheme, .dark)
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.task { await load() } .task { await load() }
} }
private var hero: some View { private var hero: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
RemoteImage(url: article?.imageUrl ?? preview.imageUrl, contentMode: .fill) RemoteImage(url: article?.imageUrl ?? preview.imageUrl, contentMode: .fill)
.frame(height: 280) .frame(height: 220)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.clipped() .clipped()
LinearGradient( LinearGradient(
@ -766,7 +809,7 @@ private struct F1NewsArticleScreen: View {
) )
.frame(height: 140) .frame(height: 140)
} }
.frame(height: 280) .frame(height: 220)
.clipped() .clipped()
} }
@ -825,24 +868,14 @@ private struct F1NewsArticleScreen: View {
} }
} }
private var backButton: some View {
Button { dismiss() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.black.opacity(0.45), in: Circle())
.overlay(Circle().strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
}
.padding(.leading, 16)
.padding(.top, 6)
}
private func load() async { private func load() async {
do { do {
article = try await app.api.f1NewsArticle(id: preview.id) article = try await app.api.f1NewsArticle(id: preview.id)
error = nil error = nil
} catch is CancellationError {
return
} catch { } catch {
if Task.isCancelled { return }
self.error = "Artikel kon niet worden geladen." self.error = "Artikel kon niet worden geladen."
} }
} }

View file

@ -22,7 +22,7 @@ function buildUrl(path: string, params: OpenF1Params): string {
export async function openF1Get<T = unknown>(path: string, params: OpenF1Params = {}, timeoutMs = 18_000): Promise<T> { export async function openF1Get<T = unknown>(path: string, params: OpenF1Params = {}, timeoutMs = 18_000): Promise<T> {
let lastErr: Error | null = null; let lastErr: Error | null = null;
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 2; attempt++) {
const token = await getOpenF1AccessToken(); const token = await getOpenF1AccessToken();
const ac = new AbortController(); const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs); const timer = setTimeout(() => ac.abort(), timeoutMs);

View file

@ -101,6 +101,8 @@ const TYRE: Record<string, string> = {
}; };
let snapCache: { at: number; data: F1LiveDashboard } | null = null; let snapCache: { at: number; data: F1LiveDashboard } | null = null;
let inflight: Promise<F1LiveDashboard> | null = null;
const OF1_MS = 8_000;
function formatLap(v: unknown): string { function formatLap(v: unknown): string {
const n = num(v); const n = num(v);
@ -151,9 +153,35 @@ export async function getF1LiveDashboard(): Promise<F1LiveDashboard> {
if (!(await isOpenF1Configured())) { if (!(await isOpenF1Configured())) {
throw new AppError("NOT_CONFIGURED", "OpenF1 is niet geconfigureerd", 503); throw new AppError("NOT_CONFIGURED", "OpenF1 is niet geconfigureerd", 503);
} }
if (snapCache && Date.now() - snapCache.at < 2500) return snapCache.data; const now = Date.now();
const ttl = snapCache?.data.live ? 2_000 : 10_000;
if (snapCache && now - snapCache.at < ttl) return snapCache.data;
if (snapCache && now - snapCache.at < 45_000) {
if (!inflight) {
inflight = buildDashboard().finally(() => {
inflight = null;
});
}
return snapCache.data;
}
if (inflight) return inflight;
inflight = buildDashboard().finally(() => {
inflight = null;
});
return inflight;
}
const sessions = asRows(await openF1Get("sessions", { session_key: "latest" })); async function buildDashboard(): Promise<F1LiveDashboard> {
try {
return await buildDashboardInner();
} catch (err) {
if (snapCache) return snapCache.data;
throw err;
}
}
async function buildDashboardInner(): Promise<F1LiveDashboard> {
const sessions = asRows(await openF1Get("sessions", { session_key: "latest" }, OF1_MS));
const session = rec(sessions[0]); const session = rec(sessions[0]);
const sessionKey = num(session.session_key); const sessionKey = num(session.session_key);
const meetingKey = num(session.meeting_key); const meetingKey = num(session.meeting_key);
@ -171,9 +199,8 @@ export async function getF1LiveDashboard(): Promise<F1LiveDashboard> {
const year = num(session.year) ?? new Date().getUTCFullYear(); const year = num(session.year) ?? new Date().getUTCFullYear();
const circuitKey = num(session.circuit_key); const circuitKey = num(session.circuit_key);
const driverRows = asRows(await openF1Get("drivers", { session_key: sessionKey }));
const [ const [
driverRows,
meetingRows, meetingRows,
positionRows, positionRows,
intervalRows, intervalRows,
@ -184,29 +211,38 @@ export async function getF1LiveDashboard(): Promise<F1LiveDashboard> {
locationRows, locationRows,
scheduleRows, scheduleRows,
circuitPack, circuitPack,
news,
] = await Promise.all([ ] = await Promise.all([
settled(openF1Get("meetings", { meeting_key: meetingKey ?? "latest" }).then(asRows), []), settled(openF1Get("drivers", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("position", { session_key: sessionKey }).then(asRows), []), settled(openF1Get("meetings", { meeting_key: meetingKey ?? "latest" }, OF1_MS).then(asRows), []),
settled(openF1Get("intervals", { session_key: sessionKey }).then(asRows), []), settled(openF1Get("position", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("laps", { session_key: sessionKey }).then(asRows), []), settled(openF1Get("intervals", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("stints", { session_key: sessionKey }).then(asRows), []), settled(openF1Get("laps", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("weather", { session_key: sessionKey }).then(asRows), []), settled(openF1Get("stints", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("race_control", { session_key: sessionKey }).then(asRows), []), settled(openF1Get("weather", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("race_control", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled( settled(
openF1Get("location", { openF1Get(
session_key: sessionKey, "location",
"date>=": locFrom, {
...(locTo ? { "date<": locTo } : {}), session_key: sessionKey,
}).then(asRows), "date>=": locFrom,
...(locTo ? { "date<": locTo } : {}),
},
OF1_MS
).then(asRows),
[] []
), ),
meetingKey meetingKey
? settled(openF1Get("sessions", { meeting_key: meetingKey }).then(asRows), []) ? settled(openF1Get("sessions", { meeting_key: meetingKey }, OF1_MS).then(asRows), [])
: Promise.resolve([]), : Promise.resolve([]),
circuitKey ? loadCircuitMap(circuitKey, year) : Promise.resolve(null), circuitKey ? loadCircuitMap(circuitKey, year) : Promise.resolve(null),
settled(getF1News(12), []),
]); ]);
const news = snapCache?.data.news ?? [];
void getF1News(12)
.then((items) => {
if (snapCache) snapCache.data = { ...snapCache.data, news: items };
})
.catch(() => undefined);
const meeting = rec(meetingRows[0]); const meeting = rec(meetingRows[0]);
const drivers = driverRows.map(rec); const drivers = driverRows.map(rec);