diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index af8fb315..684b1d12 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -55,6 +55,7 @@ import { mergeAccessStateWithAuthPayload, type AuthProfilePayload, } from "@/components/dashboard/scan-terminal/terminal-access-state"; +import { loadTerminalAuthProfile } from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap"; import { cityListItemsToScanRows, mergeScanRowsWithCityFallbackRows, @@ -1034,12 +1035,15 @@ function ScanTerminalScreen() { cancelled = true; }; } - const sessionPromise = hasSupabasePublicEnv() - ? getSupabaseBrowserClient().auth.getSession() - : Promise.resolve({ data: { session: null } }); - - sessionPromise - .then(({ data: { session } }) => loadAuthProfile(session?.access_token)) + const supabaseEnabled = hasSupabasePublicEnv(); + loadTerminalAuthProfile({ + getSession: () => + supabaseEnabled + ? getSupabaseBrowserClient().auth.getSession() + : Promise.resolve({ data: { session: null } }), + hasSupabasePublicEnv: supabaseEnabled, + loadAuthProfile, + }) .then((payload) => { if (cancelled) return; setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload)); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index 15a21820..0cfbdf88 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -1332,6 +1332,46 @@ export function runTests() { "MGM series should not mix METAR airportCurrent points into the MGM airport-station line", ); + const ankaraMgmWithMetarLabel = __buildTemperatureChartDataForTest( + { + city: "ankara", + local_date: "2026-05-29", + local_time: "18:48", + tz_offset_seconds: 3 * 60 * 60, + current_temp: 14, + airport: "LTAC", + } as any, + { + localTime: "18:48", + times: [], + temps: [], + airportPrimary: { + source_code: "mgm", + source_label: "METAR", + temp: 14, + obs_time: "2026-05-29T15:48:00Z", + }, + airportCurrent: { + source_code: "metar", + source_label: "METAR", + temp: 17, + obs_time: "18:20", + }, + airportPrimaryTodayObs: [ + { time: "2026-05-29T15:00:00Z", temp: 14 }, + { time: "2026-05-29T15:30:00Z", temp: 15 }, + ], + metarTodayObs: [ + { time: "2026-05-29T15:20:00Z", temp: 17 }, + ], + } as any, + "1D", + ); + const ankaraMgmSeries = seriesByKey(ankaraMgmWithMetarLabel.series as any, "madis") as any; + const ankaraMetarSeries = seriesByKey(ankaraMgmWithMetarLabel.series as any, "metar") as any; + assert(ankaraMgmSeries?.label === "MGM", "Ankara MGM airport-primary series should be labeled MGM even if payload source_label says METAR"); + assert(ankaraMetarSeries?.label === "METAR", "Ankara METAR backup series should keep the METAR label"); + const chengduMergedHourly = __mergePatchIntoHourlyForTest( { localTime: "05:25", diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts new file mode 100644 index 00000000..59e2dbf1 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts @@ -0,0 +1,82 @@ +import { + loadTerminalAuthProfile, + type TerminalAuthProfilePayload, +} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +export async function runTests() { + const slowCookieProfile = deferred(); + const fastSession = deferred<{ data: { session: { access_token: string } } }>(); + const calls: string[] = []; + + const resultPromise = loadTerminalAuthProfile({ + hasSupabasePublicEnv: true, + getSession: () => { + calls.push("getSession"); + return fastSession.promise; + }, + loadAuthProfile: (accessToken) => { + const token = String(accessToken || ""); + calls.push(token ? `profile:${token}` : "profile:cookie"); + if (!token) return slowCookieProfile.promise; + return Promise.resolve({ + authenticated: true, + user_id: "bearer-user", + subscription_active: true, + }); + }, + }); + + await flushMicrotasks(); + assert( + calls.includes("profile:cookie") && calls.includes("getSession"), + "terminal auth bootstrap should start cookie profile and Supabase session in parallel", + ); + + fastSession.resolve({ data: { session: { access_token: "fast-token" } } }); + const result = await resultPromise; + assert( + calls.includes("profile:fast-token"), + "terminal auth bootstrap should retry auth profile with the Supabase bearer token", + ); + assert( + result.authenticated === true && result.user_id === "bearer-user", + "terminal auth bootstrap should not wait for a slow cookie profile when bearer auth succeeds", + ); + + const slowSession = deferred<{ data: { session: { access_token: string } | null } }>(); + const cookieOnlyResult = await loadTerminalAuthProfile({ + hasSupabasePublicEnv: true, + getSession: () => slowSession.promise, + loadAuthProfile: (accessToken) => { + assert(!accessToken, "authenticated cookie profile should finish without requiring bearer profile"); + return Promise.resolve({ + authenticated: true, + user_id: "cookie-user", + subscription_active: true, + }); + }, + }); + assert( + cookieOnlyResult.user_id === "cookie-user", + "terminal auth bootstrap should accept an authenticated cookie profile immediately", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 92489bf6..ba5d5b32 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -627,6 +627,43 @@ function isMgmAirportPrimary(hourly: HourlyForecast) { return sourceTokens.some((value) => value === "mgm" || value.includes("turkey_mgm")); } +function canonicalAirportPrimarySourceLabel(hourly: HourlyForecast) { + const primary = hourly?.airportPrimary; + const tokens = [ + primary?.source_code, + (primary as any)?.source, + ].map((value) => String(value || "").trim().toLowerCase()); + if (tokens.some((value) => value === "mgm" || value.includes("turkey_mgm"))) return "MGM"; + if (tokens.some((value) => value.includes("jma"))) return "JMA"; + if (tokens.some((value) => value.includes("fmi"))) return "FMI"; + if (tokens.some((value) => value.includes("knmi"))) return "KNMI"; + if (tokens.some((value) => value.includes("ims"))) return "IMS"; + if (tokens.some((value) => value.includes("ncm"))) return "NCM"; + if (tokens.some((value) => value.includes("aeroweb"))) return "AeroWeb"; + if (tokens.some((value) => value.includes("singapore_mss") || value === "mss")) return "MSS"; + if (tokens.some((value) => value.includes("madis") || value.includes("noaa"))) return "NOAA MADIS"; + return ""; +} + +function isGenericAirportPrimaryLabel(label: string) { + const normalized = label.trim().toLowerCase(); + return ( + !normalized || + normalized === "metar" || + normalized === "madis" || + normalized === "noaa madis" + ); +} + +function airportPrimarySeriesLabel(hourly: HourlyForecast, isHKO: boolean) { + if (isHKO) return "HKO"; + const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly); + if (canonicalLabel === "MGM") return canonicalLabel; + const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim(); + if (payloadLabel && !isGenericAirportPrimaryLabel(payloadLabel)) return payloadLabel; + return canonicalLabel || payloadLabel || "NOAA MADIS"; +} + function airportPrimaryObservationPoints(hourly: HourlyForecast) { return appendLatestAirportObservation( hourly?.airportPrimaryTodayObs, @@ -1800,7 +1837,7 @@ function buildFullDayChartData( if (madisVals.some((v) => v !== null)) { series.push({ key: "madis", - label: isHKO ? "HKO" : (hourly?.airportPrimary?.source_label || "NOAA MADIS"), + label: airportPrimarySeriesLabel(hourly, isHKO), source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"), color: "#0284c7", dashed: isHKO ? true : false, diff --git a/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts b/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts new file mode 100644 index 00000000..c5832f74 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts @@ -0,0 +1,91 @@ +import type { AuthProfilePayload } from "@/components/dashboard/scan-terminal/terminal-access-state"; + +export type TerminalAuthProfilePayload = AuthProfilePayload; + +type SupabaseSessionResult = { + data?: { + session?: { + access_token?: string | null; + } | null; + } | null; +} | null | undefined; + +type LoadTerminalAuthProfileOptions = { + getSession: () => Promise; + hasSupabasePublicEnv: boolean; + loadAuthProfile: (accessToken?: string | null) => Promise; +}; + +type SettledProfile = + | { ok: true; payload: TerminalAuthProfilePayload | null } + | { ok: false; error: unknown }; + +function settleProfile( + promise: Promise, +): Promise { + return promise + .then((payload) => ({ ok: true as const, payload })) + .catch((error) => ({ ok: false as const, error })); +} + +function firstKnownProfile(cookieResult: SettledProfile, bearerResult: SettledProfile) { + if (bearerResult.ok && bearerResult.payload?.authenticated) return bearerResult.payload; + if (cookieResult.ok && cookieResult.payload) return cookieResult.payload; + if (bearerResult.ok && bearerResult.payload) return bearerResult.payload; + if (!cookieResult.ok) throw cookieResult.error; + if (!bearerResult.ok) throw bearerResult.error; + return { + authenticated: false, + subscription_active: false, + points: 0, + }; +} + +export async function loadTerminalAuthProfile({ + getSession, + hasSupabasePublicEnv, + loadAuthProfile, +}: LoadTerminalAuthProfileOptions) { + let resolvedAuthenticated = false; + let resolveAuthenticated: + | ((payload: TerminalAuthProfilePayload) => void) + | null = null; + + const authenticatedProfile = new Promise((resolve) => { + resolveAuthenticated = resolve; + }); + + const resolveIfAuthenticated = (payload: TerminalAuthProfilePayload | null) => { + if (!payload?.authenticated || resolvedAuthenticated) return; + resolvedAuthenticated = true; + resolveAuthenticated?.(payload); + }; + + const cookieProfile = settleProfile( + loadAuthProfile(null).then((payload) => { + resolveIfAuthenticated(payload); + return payload; + }), + ); + + const bearerProfile = settleProfile( + (async () => { + if (!hasSupabasePublicEnv) return null; + const sessionResult = await getSession(); + if (resolvedAuthenticated) return null; + const accessToken = String( + sessionResult?.data?.session?.access_token || "", + ).trim(); + if (!accessToken) return null; + const payload = await loadAuthProfile(accessToken); + resolveIfAuthenticated(payload); + return payload; + })(), + ); + + const fallbackProfile = Promise.all([cookieProfile, bearerProfile]).then( + ([cookieResult, bearerResult]) => firstKnownProfile(cookieResult, bearerResult), + ); + + return Promise.race([authenticatedProfile, fallbackProfile]); +}