Auth session 持久化 + 多分辨率跑道图表 + 拖拽缩放

- middleware: refreshMiddlewareSession 前置刷新 Supabase token
- backend-auth: getUser() 优先触发 refresh,再 getSession 拿新 token
- Dashboard: onAuthStateChange 实时更新 proAccess + 15min getSession 心跳
- detail proxy: 新增 city/[name]/detail 代理,透传 resolution 参数
- city_payloads: aggregate_runway_history + build_runway_band_history 跑道聚合
- chart: 10m→1m 自适应分辨率 + zoom 拖拽 + 跑道温区 Area + Runway Details 开关
This commit is contained in:
2569718930@qq.com
2026-05-26 20:37:00 +08:00
parent 88b80d66e8
commit d029d5c4e7
11 changed files with 604 additions and 91 deletions
@@ -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 {
@@ -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<ProAccessState>(() =>
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 = () =>
@@ -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<number | null>;
modelCurves?: Record<string, Array<number | null>>;
runwayPlateHistory?: Record<string, Array<Record<string, unknown>>>;
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<HourlyForecast> {
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<Record<string, string | number | null>>; series: EvidenceSeries[] } {
isEn: boolean,
): { data: Array<Record<string, any>>; 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<number | null> => 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<typeof v> => 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<string, string | number | null> = {
const point: Record<string, any> = {
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<Record<string, string | number | null>>; series: EvidenceSeries[] } {
isEn: boolean,
): { data: Array<Record<string, any>>; 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<typeof v> => 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<string, string | number | null> = {
const point: Record<string, any> = {
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<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0);
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(false);
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
const [refAreaRight, setRefAreaRight] = useState<number | null>(null);
const [zoomRange, setZoomRange] = useState<[number, number] | null>(null);
const [targetResolution, setTargetResolution] = useState<string>("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 = (
<div className="flex items-center gap-1.5">
{zoomRange && (
<button
type="button"
onClick={() => setZoomRange(null)}
className="px-2 py-0.5 text-[9px] font-bold rounded bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-300 shadow-sm transition-all cursor-pointer"
>
{isEn ? "Reset Zoom" : "重置缩放"}
</button>
)}
<div className="flex items-center gap-1 rounded bg-[#eef2f6] p-0.5 border border-slate-200">
{(["1D", "3D"] as const).map((tf) => (
<button
@@ -1587,7 +1750,7 @@ export function LiveTemperatureThresholdChart({
type="button"
onClick={() => setTimeframe(tf)}
className={clsx(
"px-2 py-0.5 text-[9px] font-bold rounded transition-all",
"px-2 py-0.5 text-[9px] font-bold rounded transition-all cursor-pointer",
timeframe === tf
? "bg-white text-blue-600 shadow-sm border border-slate-200/50"
: "text-slate-500 hover:text-slate-800"
@@ -1635,7 +1798,45 @@ export function LiveTemperatureThresholdChart({
</div>
)}
</div>
);
); 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 (
<Panel title={panelTitle} actions={timeframeActions}>
@@ -1852,36 +2053,66 @@ export function LiveTemperatureThresholdChart({
{/* Chart */}
<div className="relative min-h-0 flex-1 p-2">
{/* Interactive legend */}
<div className="flex flex-wrap gap-x-4 gap-y-1 px-3 py-1.5 text-[11px] border-b border-[#e2e8f0] bg-white">
{chartSeries.length > 1 && chartSeries.map((s) => (
<button
key={s.key}
type="button"
onClick={() => {
setUserToggledKeys((prev) => ({
...prev,
[s.key]: !isSeriesVisible(s.key),
}));
}}
className={clsx(
"inline-flex items-center gap-1.5 font-mono cursor-pointer transition-opacity hover:opacity-80",
!isSeriesVisible(s.key) && "opacity-40 line-through"
)}
>
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<span className="text-slate-700 font-bold">{s.label}</span>
</button>
))}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-3 py-1.5 text-[11px] border-b border-[#e2e8f0] bg-white">
{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) => (
<button
key={s.key}
type="button"
onClick={() => {
setUserToggledKeys((prev) => ({
...prev,
[s.key]: !isSeriesVisible(s.key),
}));
}}
className={clsx(
"inline-flex items-center gap-1.5 font-mono cursor-pointer transition-opacity hover:opacity-80",
!isSeriesVisible(s.key) && "opacity-40 line-through"
)}
>
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<span className="text-slate-700 font-bold">{s.label}</span>
</button>
))}
{/* "Show Runway Details" toggle switch */}
{hasRunwayData && (
<label className="inline-flex items-center gap-1.5 ml-auto cursor-pointer text-slate-600 hover:text-slate-800 font-semibold select-none">
<input
type="checkbox"
checked={showRunwayDetails}
onChange={(e) => setShowRunwayDetails(e.target.checked)}
className="rounded border-slate-300 text-teal-600 focus:ring-teal-500 h-3.5 w-3.5 cursor-pointer"
/>
<span>{isEn ? "Show Runway Details" : "显示跑道明细"}</span>
</label>
)}
</div>
<ResponsiveContainer width="100%" height="100%">
<ReLineChart data={data} margin={{ top: 16, right: compact ? 20 : 44, left: 4, bottom: 8 }}>
<ReComposedChart
data={zoomedData}
margin={{ top: 16, right: compact ? 20 : 44, left: 4, bottom: 8 }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDoubleClick={() => setZoomRange(null)}
>
<CartesianGrid stroke="#dbe6ef" strokeDasharray="2 2" />
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: "#64748b" }}
tickLine={false}
axisLine={{ stroke: "#cbd5e1" }}
interval={Math.max(1, Math.floor(data.length / (compact ? 6 : 10)))}
interval={Math.max(1, Math.floor(zoomedData.length / (compact ? 6 : 10)))}
/>
<YAxis
orientation="right"
@@ -1921,8 +2152,38 @@ export function LiveTemperatureThresholdChart({
fontSize: 11,
boxShadow: "0 8px 24px rgba(15,23,42,.12)",
}}
formatter={(value: unknown) => `${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 && (
<Area
dataKey="runway_band"
name={isEn ? "Runway Range" : "跑道区间"}
stroke="none"
fill="#009688"
fillOpacity={showRunwayDetails ? 0.08 : 0.18}
connectNulls={true}
isAnimationActive={false}
/>
)}
{refAreaLeft !== null && refAreaRight !== null && zoomedData[refAreaLeft] && zoomedData[refAreaRight] && (
<ReferenceArea
x1={zoomedData[refAreaLeft].label}
x2={zoomedData[refAreaRight].label}
strokeOpacity={0.3}
fill="#3b82f6"
fillOpacity={0.15}
/>
)}
{activeSeries.map((item) => (
<Line
key={item.key}
@@ -1938,7 +2199,7 @@ export function LiveTemperatureThresholdChart({
isAnimationActive={false}
/>
))}
</ReLineChart>
</ReComposedChart>
</ResponsiveContainer>
</div>
</div>
@@ -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;
@@ -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");
}
+12 -12
View File
@@ -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.
+44 -1
View File
@@ -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 };
}
}
+4 -24
View File
@@ -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<NextResponse> {
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;
}
+2
View File
@@ -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,
)
+2
View File
@@ -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,
)
+2
View File
@@ -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,
)
+129 -2
View File
@@ -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 {},