diff --git a/frontend/app/api/city/[name]/detail/route.ts b/frontend/app/api/city/[name]/detail/route.ts index cb928cec..2f8c6afe 100644 --- a/frontend/app/api/city/[name]/detail/route.ts +++ b/frontend/app/api/city/[name]/detail/route.ts @@ -48,6 +48,7 @@ export async function GET( const depth = req.nextUrl.searchParams.get("depth"); const marketSlug = req.nextUrl.searchParams.get("market_slug"); const targetDate = req.nextUrl.searchParams.get("target_date"); + const resolution = req.nextUrl.searchParams.get("resolution"); const searchParams = new URLSearchParams({ force_refresh: forceRefresh, }); @@ -60,6 +61,9 @@ export async function GET( if (targetDate) { searchParams.set("target_date", targetDate); } + if (resolution) { + searchParams.set("resolution", resolution); + } const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`; try { diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 81087fc6..d6ef2095 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -18,6 +18,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; import { getInitialLocaleFromNavigator } from "@/lib/i18n"; import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; +import { getSupabaseBrowserClient, hasSupabasePublicEnv } from "@/lib/supabase/client"; import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils"; import { ProductAccessRequired } from "@/components/dashboard/scan-terminal/ProductAccessRequired"; import { @@ -915,6 +916,65 @@ function ScanTerminalScreen() { const [proAccess, setProAccess] = useState(() => createEmptyAccess(true), ); + + // Listen to Supabase auth events (e.g. token refreshed, signed out) + useEffect(() => { + if (!hasSupabasePublicEnv()) return; + try { + const supabase = getSupabaseBrowserClient(); + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange(async (event, session) => { + if (event === "SIGNED_OUT") { + setProAccess(createEmptyAccess(false)); + } else if (event === "TOKEN_REFRESHED" || event === "SIGNED_IN") { + try { + const response = await fetch("/api/auth/me", { + cache: "no-store", + headers: { Accept: "application/json" }, + }); + if (response.ok) { + const payload = await response.json(); + setProAccess({ + loading: false, + authenticated: Boolean(payload.authenticated), + userId: payload.user_id ?? null, + subscriptionActive: payload.subscription_active === true, + subscriptionPlanCode: payload.subscription_plan_code ?? null, + subscriptionExpiresAt: payload.subscription_expires_at ?? null, + subscriptionTotalExpiresAt: + payload.subscription_total_expires_at ?? + payload.subscription_expires_at ?? + null, + subscriptionQueuedDays: Math.max( + 0, + Number(payload.subscription_queued_days ?? 0), + ), + points: Number(payload.points ?? 0), + error: null, + }); + } + } catch {} + } + }); + return () => { + subscription.unsubscribe(); + }; + } catch {} + }, []); + + // Periodically touch the session to trigger background refresh if near expiry + useEffect(() => { + if (!hasSupabasePublicEnv()) return; + const supabase = getSupabaseBrowserClient(); + const interval = setInterval(async () => { + try { + await supabase.auth.getSession(); + } catch {} + }, 15 * 60 * 1000); // every 15 minutes + + return () => clearInterval(interval); + }, []); const [locale, setLocale] = useState<"zh-CN" | "en-US">("zh-CN"); const isEn = locale === "en-US"; const toggleLocale = () => diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 755b41fe..4d80e124 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -5,8 +5,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { CartesianGrid, Line, - LineChart as ReLineChart, + Area, + ComposedChart as ReComposedChart, ReferenceLine, + ReferenceArea, ResponsiveContainer, Tooltip, XAxis, @@ -454,6 +456,7 @@ type HourlyForecast = { temps: Array; modelCurves?: Record>; runwayPlateHistory?: Record>>; + runwayBandHistory?: Array<{ time: string; high_temp: number; low_temp: number; avg_temp: number }>; amos?: AmosData | null; airportCurrent?: AirportCurrentConditions | null; airportPrimary?: AirportCurrentConditions | null; @@ -474,6 +477,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca temps: [], modelCurves: undefined, runwayPlateHistory: (row as any)?.runway_plate_history || undefined, + runwayBandHistory: undefined, amos: null, airportCurrent: null, airportPrimary: null, @@ -487,6 +491,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca type HourlyForecastFetchOptions = { ignoreCache?: boolean; + resolution?: string; }; function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast { @@ -500,6 +505,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec temps: hourlySource.temps || [], modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined, runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined, + runwayBandHistory: (json as any)?.runway_band_history || undefined, amos: json.amos || null, airportCurrent: json.airport_current || null, airportPrimary: json.airport_primary || null, @@ -515,24 +521,27 @@ async function fetchHourlyForecastForCity( city: string, options: HourlyForecastFetchOptions = {}, ): Promise { + const resParam = options.resolution || "10m"; + const cacheKey = `${city}:${resParam}`; + if (!options.ignoreCache) { - const cached = _hourlyCache.get(city); + const cached = _hourlyCache.get(cacheKey); if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { return cached.data; } - const sessionCached = readSessionCache(city); + const sessionCached = readSessionCache(cacheKey); if (sessionCached) { - _hourlyCache.set(city, sessionCached); + _hourlyCache.set(cacheKey, sessionCached); return sessionCached.data; } } - const requestKey = options.ignoreCache ? `${city}:live` : city; + const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`; const pending = _hourlyRequestCache.get(requestKey); if (pending) return pending; - const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, { + const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, { headers: { Accept: "application/json" }, }) .then(async (res) => { @@ -542,8 +551,8 @@ async function fetchHourlyForecastForCity( .then((json) => { const data = parseHourlyForecastFromCityDetail(json); if (!data) return null; - _hourlyCache.set(city, { ts: Date.now(), data }); - writeSessionCache(city, data); + _hourlyCache.set(cacheKey, { ts: Date.now(), data }); + writeSessionCache(cacheKey, data); return data; }) .finally(() => { @@ -816,7 +825,8 @@ function format3DayTimestamp(ts: number): string { function build3DayChartData( row: ScanOpportunityRow | null, hourly: HourlyForecast, -): { data: Array>; series: EvidenceSeries[] } { + isEn: boolean, +): { data: Array>; series: EvidenceSeries[] } { const tzOffset = row?.tz_offset_seconds ?? 0; const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); @@ -827,6 +837,52 @@ function build3DayChartData( const series: EvidenceSeries[] = []; const na = (): Array => new Array(n).fill(null); + // ── Runway history series ── + const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr); + runwayHistorySeries.forEach((rhs) => { + const binned = binObservationsToSlots(slots, rhs.points); + if (!binned.some((v) => v !== null)) return; + series.push({ + key: rhs.key, + label: rhs.label, + source: "", + color: rhs.color, + featured: rhs.isSettlement, + dashed: !rhs.isSettlement, + values: binned, + }); + }); + + // ── Runway band & max series ── + const normBandObs = (hourly?.runwayBandHistory || []).map((pt) => { + try { + const ts = getCityLocalUtcTimestamp(pt.time, tzOffset, localDateStr); + if (ts === null) return null; + return { + ts, + high: pt.high_temp, + low: pt.low_temp, + avg: pt.avg_temp + }; + } catch { + return null; + } + }).filter((v): v is NonNullable => v !== null); + + const bandVals = binBandObservationsToSlots(slots, normBandObs); + const maxVals = bandVals.map((val) => val ? val[1] : null); + + if (maxVals.some((v) => v !== null)) { + series.push({ + key: "runway_max", + label: isEn ? "Runway Max" : "跑道最高温", + source: "Runway Max", + color: "#009688", + featured: true, + values: maxVals, + }); + } + // DEB forecast curve (from hourly.times & hourly.temps) if (hourly?.times?.length && hourly?.temps?.length) { const debVals = na(); @@ -901,9 +957,10 @@ function build3DayChartData( // Build data rows const data = slots.map((ts, i) => { - const point: Record = { + const point: Record = { label: format3DayTimestamp(ts), ts, + runway_band: bandVals[i] ?? null, }; series.forEach((s) => { point[s.key] = s.values[i] ?? null; @@ -1004,10 +1061,27 @@ function buildDailyChartData( return { data, series: activeSeries }; } +function binBandObservationsToSlots( + slots: number[], + obs: Array<{ ts: number; high: number; low: number; avg: number }>, +): Array<[number, number] | null> { + const result: Array<[number, number] | null> = new Array(slots.length).fill(null); + for (const point of obs) { + for (let i = slots.length - 1; i >= 0; i--) { + if (point.ts >= slots[i]) { + result[i] = [point.low, point.high]; + break; + } + } + } + return result; +} + function buildFullDayChartData( row: ScanOpportunityRow | null, hourly: HourlyForecast, -): { data: Array>; series: EvidenceSeries[] } { + isEn: boolean, +): { data: Array>; series: EvidenceSeries[] } { const tzOffset = row?.tz_offset_seconds ?? 0; const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); @@ -1052,6 +1126,36 @@ function buildFullDayChartData( }); }); + // ── Runway band & max series ── + const normBandObs = (hourly?.runwayBandHistory || []).map((pt) => { + try { + const ts = getCityLocalUtcTimestamp(pt.time, tzOffset, localDateStr); + if (ts === null) return null; + return { + ts, + high: pt.high_temp, + low: pt.low_temp, + avg: pt.avg_temp + }; + } catch { + return null; + } + }).filter((v): v is NonNullable => v !== null); + + const bandVals = binBandObservationsToSlots(slots, normBandObs); + const maxVals = bandVals.map((val) => val ? val[1] : null); + + if (maxVals.some((v) => v !== null)) { + series.push({ + key: "runway_max", + label: isEn ? "Runway Max" : "跑道最高温", + source: "Runway Max", + color: "#009688", + featured: true, + values: maxVals, + }); + } + // ── Settlement observations ── // Determine if this is an HKO-sourced city to force the label const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan' @@ -1180,9 +1284,10 @@ function buildFullDayChartData( // ── Build data rows ── const data = slots.map((ts, i) => { - const point: Record = { + const point: Record = { label: formatTimestamp(ts), ts, + runway_band: bandVals[i] ?? null, }; series.forEach((s) => { point[s.key] = s.values[i] ?? null; }); return point; @@ -1304,6 +1409,19 @@ export function LiveTemperatureThresholdChart({ const lastPatchAtRef = useRef(Date.now()); const lastAppliedPatchRevisionRef = useRef(0); + const [showRunwayDetails, setShowRunwayDetails] = useState(false); + const [refAreaLeft, setRefAreaLeft] = useState(null); + const [refAreaRight, setRefAreaRight] = useState(null); + const [zoomRange, setZoomRange] = useState<[number, number] | null>(null); + const [targetResolution, setTargetResolution] = useState("10m"); + + useEffect(() => { + setUserToggledKeys({}); + setZoomRange(null); + lastPatchAtRef.current = Date.now(); + lastAppliedPatchRevisionRef.current = 0; + }, [city, timeframe]); + useEffect(() => { setUserToggledKeys({}); lastPatchAtRef.current = Date.now(); @@ -1313,14 +1431,15 @@ export function LiveTemperatureThresholdChart({ useEffect(() => { if (!city) return; + const cacheKey = `${city}:${targetResolution}`; // Check in-memory cache first - let cached = _hourlyCache.get(city); + let cached = _hourlyCache.get(cacheKey); if (!cached) { // Fallback to session cache - const sessionEntry = readSessionCache(city); + const sessionEntry = readSessionCache(cacheKey); if (sessionEntry) { cached = sessionEntry; - _hourlyCache.set(city, sessionEntry); + _hourlyCache.set(cacheKey, sessionEntry); } } @@ -1336,7 +1455,7 @@ export function LiveTemperatureThresholdChart({ const delay = isActive ? 0 : (slotIndex ? 300 + slotIndex * 250 : 350); const timer = setTimeout(() => { - fetchHourlyForecastForCity(city) + fetchHourlyForecastForCity(city, { resolution: targetResolution }) .then((data) => { if (cancelled || !data) return; setHourly(data); @@ -1348,7 +1467,7 @@ export function LiveTemperatureThresholdChart({ cancelled = true; clearTimeout(timer); }; - }, [city, row, isActive, slotIndex]); + }, [city, row, isActive, slotIndex, targetResolution]); useEffect(() => { if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return; @@ -1400,10 +1519,37 @@ export function LiveTemperatureThresholdChart({ const { data, series } = useMemo(() => { if (timeframe === "3D") { - return build3DayChartData(row, hourly); + return build3DayChartData(row, hourly, isEn); } - return buildFullDayChartData(row, hourly); - }, [row, hourly, timeframe]); + return buildFullDayChartData(row, hourly, isEn); + }, [row, hourly, timeframe, isEn]); + + const zoomedData = useMemo(() => { + if (!zoomRange || data.length === 0) return data; + const [start, end] = zoomRange; + return data.slice(start, end + 1); + }, [data, zoomRange]); + + useEffect(() => { + if (timeframe === "3D") { + setTargetResolution("30m"); + } else if (zoomRange && data.length > 0) { + const zoomedData = data.slice(zoomRange[0], zoomRange[1] + 1); + if (zoomedData.length > 0) { + const startTs = zoomedData[0].ts; + const endTs = zoomedData[zoomedData.length - 1].ts; + if (endTs - startTs <= 2 * 60 * 60 * 1000) { + setTargetResolution("1m"); + } else { + setTargetResolution("10m"); + } + } else { + setTargetResolution("10m"); + } + } else { + setTargetResolution("10m"); + } + }, [timeframe, zoomRange, data]); const tzOffset = row?.tz_offset_seconds ?? 0; const settlementObs = useMemo(() => { @@ -1433,8 +1579,16 @@ export function LiveTemperatureThresholdChart({ }; const activeSeries = useMemo(() => { - return getVisibleTemperatureSeries(city, chartSeries, userToggledKeys); - }, [chartSeries, userToggledKeys, city]); + const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys); + return rawVisible.filter((s) => { + const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max"; + if (showRunwayDetails) { + return s.key !== "runway_max"; + } else { + return !isIndividualRunway; + } + }); + }, [chartSeries, userToggledKeys, city, showRunwayDetails]); const normalizedKey = normalizeCityKey(row?.city); const runwaySensorCities = new Set([ @@ -1540,10 +1694,10 @@ export function LiveTemperatureThresholdChart({ return list.sort((a, b) => a.threshold - b.threshold); }, [row, allRows]); - const intDegreeTicks = useMemo(() => buildIntDegreeTicks(activeSeries, data), [activeSeries, data]); + const intDegreeTicks = useMemo(() => buildIntDegreeTicks(activeSeries, zoomedData), [activeSeries, zoomedData]); const chartDomain = useMemo( - () => buildChartDomain(activeSeries, data), - [activeSeries, data], + () => buildChartDomain(activeSeries, zoomedData), + [activeSeries, zoomedData], ); const subtitle = row @@ -1580,6 +1734,15 @@ export function LiveTemperatureThresholdChart({ const timeframeActions = (
+ {zoomRange && ( + + )}
{(["1D", "3D"] as const).map((tf) => (
)}
- ); + ); const handleMouseDown = (e: any) => { + if (compact || !e) return; + if (typeof e.activeTooltipIndex === "number") { + setRefAreaLeft(e.activeTooltipIndex); + setRefAreaRight(e.activeTooltipIndex); + } + }; + + const handleMouseMove = (e: any) => { + if (compact || !e || refAreaLeft === null) return; + if (typeof e.activeTooltipIndex === "number") { + setRefAreaRight(e.activeTooltipIndex); + } + }; + + const handleMouseUp = () => { + if (refAreaLeft === null || refAreaRight === null) { + setRefAreaLeft(null); + setRefAreaRight(null); + return; + } + + let leftIdx = refAreaLeft; + let rightIdx = refAreaRight; + + if (leftIdx > rightIdx) { + [leftIdx, rightIdx] = [rightIdx, leftIdx]; + } + + if (rightIdx - leftIdx >= 1) { + const originalStartIndex = zoomRange ? zoomRange[0] : 0; + const newStart = originalStartIndex + leftIdx; + const newEnd = originalStartIndex + rightIdx; + setZoomRange([newStart, newEnd]); + } + + setRefAreaLeft(null); + setRefAreaRight(null); + }; return ( @@ -1852,36 +2053,66 @@ export function LiveTemperatureThresholdChart({ {/* Chart */}
{/* Interactive legend */} -
- {chartSeries.length > 1 && chartSeries.map((s) => ( - - ))} +
+ {chartSeries.length > 1 && + chartSeries + .filter((s) => { + const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max"; + if (showRunwayDetails) { + return s.key !== "runway_max"; + } else { + return !isIndividualRunway; + } + }) + .map((s) => ( + + ))} + + {/* "Show Runway Details" toggle switch */} + {hasRunwayData && ( + + )}
- + setZoomRange(null)} + > `${Number(value).toFixed(2)}°`} + formatter={(value: unknown) => { + if (Array.isArray(value)) { + const [low, high] = value; + if (typeof low === "number" && typeof high === "number") { + return `${low.toFixed(1)}° - ${high.toFixed(1)}°`; + } + } + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2)}°` : String(value); + }} /> + {/* Runway Temperature Band (low-high range area) */} + {hasRunwayData && ( + + )} + {refAreaLeft !== null && refAreaRight !== null && zoomedData[refAreaLeft] && zoomedData[refAreaRight] && ( + + )} {activeSeries.map((item) => ( ))} - +
@@ -1950,8 +2211,9 @@ export function __buildTemperatureChartDataForTest( row: ScanOpportunityRow | null, hourly: HourlyForecast, timeframe: "1D" | "3D" = "1D", + isEn = false, ) { - return timeframe === "3D" ? build3DayChartData(row, hourly) : buildFullDayChartData(row, hourly); + return timeframe === "3D" ? build3DayChartData(row, hourly, isEn) : buildFullDayChartData(row, hourly, isEn); } export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index ade5d163..1ab701a2 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -310,4 +310,35 @@ export function runTests() { Math.min(...shanghaiDebValues) > 20, "DEB curve should not be pulled into an impossible negative range by stale row deb_prediction=0", ); + + // ── Runway range band and runway_max test ── + const shanghaiWithBand = __buildTemperatureChartDataForTest( + { + city: "shanghai", + local_date: "2026-05-26", + local_time: "14:00", + tz_offset_seconds: 8 * 60 * 60, + } as any, + { + localTime: "14:00", + times: ["00:00", "12:00", "18:00"], + temps: [24.2, 31.5, 26.5], + runwayBandHistory: [ + { time: "2026-05-26T00:00:00+08:00", high_temp: 26.0, low_temp: 24.0, avg_temp: 25.0 }, + { time: "2026-05-26T12:00:00+08:00", high_temp: 32.0, low_temp: 29.0, avg_temp: 30.5 }, + ] + } as any, + "1D", + ); + + const runwayMaxSeries = seriesByKey(shanghaiWithBand.series, "runway_max") as any; + assert(runwayMaxSeries, "runway_max series should be present when runwayBandHistory is provided"); + assert(runwayMaxSeries.color === "#009688", "runway_max series should use the primary teal color"); + assert(runwayMaxSeries.featured === true, "runway_max series should be featured"); + + // Verify that runway_band exists on some data rows + const bandPoints = shanghaiWithBand.data.filter((d) => d.runway_band !== null); + assert(bandPoints.length >= 2, "runway_band tuples should be binned into data slots"); + const firstBand = bandPoints[0].runway_band; + assert(Array.isArray(firstBand) && firstBand[0] === 24.0 && firstBand[1] === 26.0, "runway_band tuple values should match input limits"); } diff --git a/frontend/lib/backend-auth.ts b/frontend/lib/backend-auth.ts index d2d0d066..da9278aa 100644 --- a/frontend/lib/backend-auth.ts +++ b/frontend/lib/backend-auth.ts @@ -41,19 +41,14 @@ export async function buildBackendRequestHeaders( const incomingAuth = extractBearerToken(request.headers.get("authorization")); const includeSupabaseIdentity = options?.includeSupabaseIdentity !== false; if (hasSupabaseServerEnv() && includeSupabaseIdentity) { - const supabase = createSupabaseRouteClient(request, new NextResponse(null, { status: 200 })); - const { - data: { session }, - } = await supabase.auth.getSession(); - let user = session?.user ?? null; - if (session) { - const { - data: { user: validated }, - } = await supabase.auth.getUser(); - user = validated ?? session.user; - } - const passthroughResponse = new NextResponse(null, { status: 200 }); + const supabase = createSupabaseRouteClient(request, passthroughResponse); + + // Always call getUser() to ensure token is validated and refreshed if expired + const { + data: { user }, + } = await supabase.auth.getUser(); + const forwardedUserId = String(user?.id || "").trim(); const forwardedEmail = String(user?.email || "").trim(); if (forwardedUserId) { @@ -67,6 +62,11 @@ export async function buildBackendRequestHeaders( headers.set("Authorization", `Bearer ${incomingAuth}`); return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null }; } + + // Call getSession() to get the updated access token (after getUser() has refreshed it if needed) + const { + data: { session }, + } = await supabase.auth.getSession(); const accessToken = session?.access_token || ""; if (accessToken) { // Fallback to cookie-backed session when request does not carry bearer. diff --git a/frontend/lib/supabase/server.ts b/frontend/lib/supabase/server.ts index 2acb8cc0..f5dc06e5 100644 --- a/frontend/lib/supabase/server.ts +++ b/frontend/lib/supabase/server.ts @@ -1,5 +1,5 @@ import { createServerClient, type CookieOptions } from "@supabase/ssr"; -import type { NextRequest, NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; type CookieAdapter = { getAll: () => { name: string; value: string }[]; @@ -68,3 +68,46 @@ export function createSupabaseRouteClient( }); } +export async function refreshMiddlewareSession(request: NextRequest) { + let response = NextResponse.next({ + request: { + headers: request.headers, + }, + }); + + if (!hasSupabaseServerEnv()) { + return { response, user: null }; + } + + const supabase = createSupabaseServerClient({ + getAll() { + return request.cookies.getAll().map((item) => ({ + name: item.name, + value: item.value, + })); + }, + setAll(cookiesToSet) { + for (const cookie of cookiesToSet) { + request.cookies.set(cookie.name, cookie.value); + } + response = NextResponse.next({ + request: { + headers: request.headers, + }, + }); + for (const cookie of cookiesToSet) { + response.cookies.set(cookie.name, cookie.value, cookie.options); + } + }, + }); + + try { + const { + data: { user }, + } = await supabase.auth.getUser(); + return { response, user }; + } catch { + return { response, user: null }; + } +} + diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 50eb02ed..2b127101 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { - createSupabaseMiddlewareClient, hasSupabaseServerEnv, + refreshMiddlewareSession, } from "@/lib/supabase/server"; import { isLocalFullAccessHost } from "@/lib/local-dev-access"; @@ -69,13 +69,7 @@ async function handleTerminalGate(request: NextRequest): Promise { return NextResponse.next(); } - const response = NextResponse.next({ - request: { headers: request.headers }, - }); - const supabase = createSupabaseMiddlewareClient(request, response); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { response, user } = await refreshMiddlewareSession(request); if (user) { // Authenticated — pass through. Terminal client handles subscription gate. @@ -96,15 +90,7 @@ async function handleSupabaseAuthGate(request: NextRequest) { return NextResponse.next(); } - const response = NextResponse.next({ - request: { - headers: request.headers, - }, - }); - const supabase = createSupabaseMiddlewareClient(request, response); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { response, user } = await refreshMiddlewareSession(request); if (user) { return response; @@ -134,13 +120,7 @@ async function handleSupabaseOptionalSession(request: NextRequest) { return NextResponse.next(); } - const response = NextResponse.next({ - request: { - headers: request.headers, - }, - }); - const supabase = createSupabaseMiddlewareClient(request, response); - await supabase.auth.getUser(); + const { response } = await refreshMiddlewareSession(request); return response; } diff --git a/web/analysis_service.py b/web/analysis_service.py index dd31b21d..1ab385bb 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -2079,11 +2079,13 @@ def _build_city_detail_payload( data: Dict[str, Any], market_slug: Optional[str] = None, target_date: Optional[str] = None, + resolution: Optional[str] = "10m", ) -> Dict[str, Any]: return _city_payload_detail( data, market_slug=market_slug, target_date=target_date, + resolution=resolution, ) diff --git a/web/routers/city.py b/web/routers/city.py index fc1d687b..6a61d57d 100644 --- a/web/routers/city.py +++ b/web/routers/city.py @@ -141,6 +141,7 @@ async def city_detail_aggregate( force_refresh: bool = False, market_slug: Optional[str] = None, target_date: Optional[str] = None, + resolution: Optional[str] = "10m", ): return await get_city_detail_aggregate_payload( request, @@ -148,6 +149,7 @@ async def city_detail_aggregate( force_refresh=force_refresh, market_slug=market_slug, target_date=target_date, + resolution=resolution, ) diff --git a/web/services/city_api.py b/web/services/city_api.py index ba41fe75..26d09a94 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -142,6 +142,7 @@ async def get_city_detail_aggregate_payload( force_refresh: bool = False, market_slug: Optional[str] = None, target_date: Optional[str] = None, + resolution: Optional[str] = "10m", ) -> Dict[str, Any]: legacy_routes._assert_entitlement(request) city = legacy_routes._normalize_city_or_404(name) @@ -162,6 +163,7 @@ async def get_city_detail_aggregate_payload( data, market_slug, target_date, + resolution, ) diff --git a/web/services/city_payloads.py b/web/services/city_payloads.py index 476a8aeb..44cabcf7 100644 --- a/web/services/city_payloads.py +++ b/web/services/city_payloads.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, List +from datetime import datetime, timezone +import re from web.core import _is_excluded_model_name @@ -34,10 +36,134 @@ def build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]: } +def _parse_time_val(val: str) -> Optional[datetime]: + if not val: + return None + try: + val = str(val).strip().replace("Z", "+00:00") + if "T" in val: + return datetime.fromisoformat(val) + else: + return datetime.fromisoformat(val) + except Exception: + try: + val_clean = re.sub(r'\.\d+', '', val) + return datetime.strptime(val_clean, "%Y-%m-%d %H:%M:%S") + except Exception: + return None + + +def aggregate_runway_history(raw_history: Dict[str, List[Dict[str, Any]]], resolution: str) -> Dict[str, List[Dict[str, Any]]]: + if not raw_history: + return {} + if not resolution or resolution == "1m": + return raw_history + + try: + if resolution.endswith("m"): + minutes = int(resolution[:-1]) + elif resolution.endswith("h"): + minutes = int(resolution[:-1]) * 60 + else: + minutes = 10 + except Exception: + minutes = 10 + + seconds = minutes * 60 + aggregated = {} + for rwy, points in raw_history.items(): + if not points: + continue + + buckets = {} + for pt in points: + t_str = pt.get("time") or pt.get("timestamp") + temp = pt.get("temp") or pt.get("temp_c") or pt.get("value") + if temp is None or not isinstance(t_str, str): + continue + dt = _parse_time_val(t_str) + if not dt: + continue + + ts = int(dt.timestamp()) + bucket_ts = (ts // seconds) * seconds + + if bucket_ts not in buckets: + buckets[bucket_ts] = [] + buckets[bucket_ts].append(temp) + + bucket_points = [] + for bucket_ts in sorted(buckets.keys()): + temps = buckets[bucket_ts] + close_temp = temps[-1] + bucket_dt = datetime.fromtimestamp(bucket_ts, tz=timezone.utc) + bucket_points.append({ + "time": bucket_dt.isoformat(), + "temp": round(close_temp, 1) + }) + aggregated[rwy] = bucket_points + + return aggregated + + +def build_runway_band_history(raw_history: Dict[str, List[Dict[str, Any]]], resolution: str) -> List[Dict[str, Any]]: + if not raw_history: + return [] + + try: + if resolution.endswith("m"): + minutes = int(resolution[:-1]) + elif resolution.endswith("h"): + minutes = int(resolution[:-1]) * 60 + else: + minutes = 10 + except Exception: + minutes = 10 + + seconds = minutes * 60 + buckets = {} + for rwy, points in raw_history.items(): + for pt in points: + t_str = pt.get("time") or pt.get("timestamp") + temp = pt.get("temp") or pt.get("temp_c") or pt.get("value") + if temp is None or not isinstance(t_str, str): + continue + dt = _parse_time_val(t_str) + if not dt: + continue + + ts = int(dt.timestamp()) + bucket_ts = (ts // seconds) * seconds + + if bucket_ts not in buckets: + buckets[bucket_ts] = [] + buckets[bucket_ts].append(temp) + + band_history = [] + for bucket_ts in sorted(buckets.keys()): + temps = buckets[bucket_ts] + if not temps: + continue + high_temp = max(temps) + low_temp = min(temps) + avg_temp = sum(temps) / len(temps) + + bucket_dt = datetime.fromtimestamp(bucket_ts, tz=timezone.utc) + band_history.append({ + "time": bucket_dt.isoformat(), + "high_temp": round(high_temp, 1), + "low_temp": round(low_temp, 1), + "avg_temp": round(avg_temp, 1), + }) + + return band_history + + def build_city_detail_payload( data: Dict[str, Any], market_slug: Optional[str] = None, target_date: Optional[str] = None, + resolution: Optional[str] = "10m", ) -> Dict[str, Any]: return { "city": data.get("name"), @@ -125,7 +251,8 @@ def build_city_detail_payload( or _build_intraday_meteorology(data), "vertical_profile_signal": data.get("vertical_profile_signal") or {}, "taf": data.get("taf") or {}, - "runway_plate_history": data.get("runway_plate_history") or {}, + "runway_plate_history": aggregate_runway_history(data.get("runway_plate_history") or {}, resolution), + "runway_band_history": build_runway_band_history(data.get("runway_plate_history") or {}, resolution), "risk": data.get("risk"), "settlement_station": data.get("settlement_station") or {},