From 00efebdf84e0529ca72f1c1db14c24392331cd9c Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Thu, 1 Oct 2026 02:06:50 +0200 Subject: [PATCH] Stabilize F1 Live scrolling and news reader, and speed up the dashboard fetch. --- apps/ios/VMC/Data/ApiClient.swift | 6 ++ apps/ios/VMC/UI/EventHubsScreens.swift | 33 +++++++-- apps/ios/VMC/UI/F1LiveScreen.swift | 99 +++++++++++++++++--------- apps/master-api/src/openf1/client.ts | 2 +- apps/master-api/src/openf1/live.ts | 74 ++++++++++++++----- 5 files changed, 155 insertions(+), 59 deletions(-) diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift index ec00d06..35fab4f 100644 --- a/apps/ios/VMC/Data/ApiClient.swift +++ b/apps/ios/VMC/Data/ApiClient.swift @@ -358,6 +358,12 @@ actor ApiClient { 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 { let enc = Self.encodePathSegment(id) return try await get("/api/v1/client/f1/news/\(enc)") diff --git a/apps/ios/VMC/UI/EventHubsScreens.swift b/apps/ios/VMC/UI/EventHubsScreens.swift index c1db9f0..fa1eb5d 100644 --- a/apps/ios/VMC/UI/EventHubsScreens.swift +++ b/apps/ios/VMC/UI/EventHubsScreens.swift @@ -250,8 +250,9 @@ struct F1HubScreen: View { .buttonStyle(HubPrimaryButtonStyle()) NavigationLink(value: EventHubRoute.f1Live) { Label("Timing", systemImage: "timer") + .modifier(HubNavCapsule(kind: .glass)) } - .buttonStyle(HubGlassButtonStyle()) + .buttonStyle(.plain) } else if let meeting { NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) { Label("Bekijk weekend", systemImage: "calendar") @@ -259,13 +260,15 @@ struct F1HubScreen: View { .buttonStyle(HubPrimaryButtonStyle()) NavigationLink(value: EventHubRoute.f1Live) { Label("Timing", systemImage: "timer") + .modifier(HubNavCapsule(kind: .glass)) } - .buttonStyle(HubGlassButtonStyle()) + .buttonStyle(.plain) } else { NavigationLink(value: EventHubRoute.f1Live) { Label("Live timing", systemImage: "timer") + .modifier(HubNavCapsule(kind: .primary)) } - .buttonStyle(HubPrimaryButtonStyle()) + .buttonStyle(.plain) } } } else if let meeting { @@ -286,8 +289,9 @@ struct F1HubScreen: View { .buttonStyle(HubPrimaryButtonStyle()) NavigationLink(value: EventHubRoute.f1Live) { Label("Timing", systemImage: "timer") + .modifier(HubNavCapsule(kind: .glass)) } - .buttonStyle(HubGlassButtonStyle()) + .buttonStyle(.plain) } } else { HubHero( @@ -301,8 +305,9 @@ struct F1HubScreen: View { ) { NavigationLink(value: EventHubRoute.f1Live) { Label("Live timing", systemImage: "timer") + .modifier(HubNavCapsule(kind: .primary)) } - .buttonStyle(HubPrimaryButtonStyle()) + .buttonStyle(.plain) } } } @@ -347,7 +352,7 @@ struct F1HubScreen: View { ) .padding(.horizontal, 20) } - .buttonStyle(HubPressStyle()) + .buttonStyle(.plain) } 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 { func makeBody(configuration: Configuration) -> some View { configuration.label diff --git a/apps/ios/VMC/UI/F1LiveScreen.swift b/apps/ios/VMC/UI/F1LiveScreen.swift index 2d4550f..a32f136 100644 --- a/apps/ios/VMC/UI/F1LiveScreen.swift +++ b/apps/ios/VMC/UI/F1LiveScreen.swift @@ -25,13 +25,17 @@ struct F1LiveScreen: View { @EnvironmentObject private var app: AppModel @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase @State private var tab: Tab = .overview @State private var payload: F1LiveDashboard? @State private var error: String? @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 { ZStack(alignment: .top) { @@ -63,8 +67,18 @@ struct F1LiveScreen: View { .environment(\.colorScheme, .dark) .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) - .task { await load() } - .onReceive(clock) { _ in Task { await load(silent: true) } } + .safeAreaPadding(.top) + .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 @@ -80,20 +94,21 @@ struct F1LiveScreen: View { ].compactMap { $0 }.joined(separator: " ยท ") ) tabBar - ScrollView { + ScrollView(.vertical, showsIndicators: false) { Group { switch tab { case .overview: overview(p) case .timing: timingTable(p.timing) case .map: mapBlock(p) case .telemetry: telemetryBlock(p) - case .news: newsList(p.news) + case .news: newsList(newsItems) } } .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 40) } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) } } @@ -157,6 +172,7 @@ struct F1LiveScreen: View { } .padding(.horizontal, 16) } + .frame(height: 38) .padding(.bottom, 4) } @@ -191,9 +207,9 @@ struct F1LiveScreen: View { if let t = p.telemetry { telemetryCard(t) } - if !p.news.isEmpty { + if !newsItems.isEmpty { sectionTitle("Nieuws") - ForEach(p.news.prefix(4)) { item in + ForEach(newsItems.prefix(4)) { item in newsCard(item) } } @@ -465,8 +481,8 @@ struct F1LiveScreen: View { } private func newsCard(_ item: F1NewsItem) -> some View { - NavigationLink { - F1NewsArticleScreen(preview: item) + Button { + selectedNews = item } label: { HStack(alignment: .top, spacing: 12) { RemoteImage(url: item.imageUrl, contentMode: .fill) @@ -574,15 +590,30 @@ struct F1LiveScreen: View { } private func load(silent: Bool = false) async { + if loading { return } + loading = true + defer { loading = false } do { let next = try await app.api.f1Live() + if Task.isCancelled { return } payload = next error = nil + if newsItems.isEmpty { newsItems = next.news } if let speed = next.telemetry?.speed { speedTrail.append(speed) 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 { + if Task.isCancelled { return } if payload == nil && !silent { self.error = error.localizedDescription } } } @@ -710,14 +741,14 @@ private struct F1NewsArticleScreen: View { @State private var error: String? var body: some View { - ZStack(alignment: .topLeading) { + ZStack(alignment: .top) { F1LiveLook.bg.ignoresSafeArea() - ScrollView(showsIndicators: false) { + ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { hero - VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 16) { Text(article?.title ?? preview.title) - .font(.system(size: 28, weight: .bold)) + .font(.system(size: 26, weight: .bold)) .foregroundStyle(.white) .fixedSize(horizontal: false, vertical: true) metaRow @@ -741,22 +772,34 @@ private struct F1NewsArticleScreen: View { } } .padding(.horizontal, 20) - .padding(.top, 22) - .padding(.bottom, 56) + .padding(.top, 18) + .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) - .navigationBarBackButtonHidden(true) - .toolbar(.hidden, for: .navigationBar) .task { await load() } } private var hero: some View { ZStack(alignment: .bottom) { RemoteImage(url: article?.imageUrl ?? preview.imageUrl, contentMode: .fill) - .frame(height: 280) + .frame(height: 220) .frame(maxWidth: .infinity) .clipped() LinearGradient( @@ -766,7 +809,7 @@ private struct F1NewsArticleScreen: View { ) .frame(height: 140) } - .frame(height: 280) + .frame(height: 220) .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 { do { article = try await app.api.f1NewsArticle(id: preview.id) error = nil + } catch is CancellationError { + return } catch { + if Task.isCancelled { return } self.error = "Artikel kon niet worden geladen." } } diff --git a/apps/master-api/src/openf1/client.ts b/apps/master-api/src/openf1/client.ts index b041c32..d8c6f48 100644 --- a/apps/master-api/src/openf1/client.ts +++ b/apps/master-api/src/openf1/client.ts @@ -22,7 +22,7 @@ function buildUrl(path: string, params: OpenF1Params): string { export async function openF1Get(path: string, params: OpenF1Params = {}, timeoutMs = 18_000): Promise { let lastErr: Error | null = null; - for (let attempt = 0; attempt < 3; attempt++) { + for (let attempt = 0; attempt < 2; attempt++) { const token = await getOpenF1AccessToken(); const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), timeoutMs); diff --git a/apps/master-api/src/openf1/live.ts b/apps/master-api/src/openf1/live.ts index f286d79..9da33ec 100644 --- a/apps/master-api/src/openf1/live.ts +++ b/apps/master-api/src/openf1/live.ts @@ -101,6 +101,8 @@ const TYRE: Record = { }; let snapCache: { at: number; data: F1LiveDashboard } | null = null; +let inflight: Promise | null = null; +const OF1_MS = 8_000; function formatLap(v: unknown): string { const n = num(v); @@ -151,9 +153,35 @@ export async function getF1LiveDashboard(): Promise { if (!(await isOpenF1Configured())) { 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 { + try { + return await buildDashboardInner(); + } catch (err) { + if (snapCache) return snapCache.data; + throw err; + } +} + +async function buildDashboardInner(): Promise { + const sessions = asRows(await openF1Get("sessions", { session_key: "latest" }, OF1_MS)); const session = rec(sessions[0]); const sessionKey = num(session.session_key); const meetingKey = num(session.meeting_key); @@ -171,9 +199,8 @@ export async function getF1LiveDashboard(): Promise { const year = num(session.year) ?? new Date().getUTCFullYear(); const circuitKey = num(session.circuit_key); - const driverRows = asRows(await openF1Get("drivers", { session_key: sessionKey })); - const [ + driverRows, meetingRows, positionRows, intervalRows, @@ -184,29 +211,38 @@ export async function getF1LiveDashboard(): Promise { locationRows, scheduleRows, circuitPack, - news, ] = await Promise.all([ - settled(openF1Get("meetings", { meeting_key: meetingKey ?? "latest" }).then(asRows), []), - settled(openF1Get("position", { session_key: sessionKey }).then(asRows), []), - settled(openF1Get("intervals", { session_key: sessionKey }).then(asRows), []), - settled(openF1Get("laps", { session_key: sessionKey }).then(asRows), []), - settled(openF1Get("stints", { session_key: sessionKey }).then(asRows), []), - settled(openF1Get("weather", { session_key: sessionKey }).then(asRows), []), - settled(openF1Get("race_control", { session_key: sessionKey }).then(asRows), []), + settled(openF1Get("drivers", { session_key: sessionKey }, OF1_MS).then(asRows), []), + settled(openF1Get("meetings", { meeting_key: meetingKey ?? "latest" }, OF1_MS).then(asRows), []), + settled(openF1Get("position", { session_key: sessionKey }, OF1_MS).then(asRows), []), + settled(openF1Get("intervals", { session_key: sessionKey }, OF1_MS).then(asRows), []), + settled(openF1Get("laps", { session_key: sessionKey }, OF1_MS).then(asRows), []), + settled(openF1Get("stints", { session_key: sessionKey }, OF1_MS).then(asRows), []), + settled(openF1Get("weather", { session_key: sessionKey }, OF1_MS).then(asRows), []), + settled(openF1Get("race_control", { session_key: sessionKey }, OF1_MS).then(asRows), []), settled( - openF1Get("location", { - session_key: sessionKey, - "date>=": locFrom, - ...(locTo ? { "date<": locTo } : {}), - }).then(asRows), + openF1Get( + "location", + { + session_key: sessionKey, + "date>=": locFrom, + ...(locTo ? { "date<": locTo } : {}), + }, + OF1_MS + ).then(asRows), [] ), meetingKey - ? settled(openF1Get("sessions", { meeting_key: meetingKey }).then(asRows), []) + ? settled(openF1Get("sessions", { meeting_key: meetingKey }, OF1_MS).then(asRows), []) : Promise.resolve([]), 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 drivers = driverRows.map(rec);