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:
@@ -48,6 +48,7 @@ export async function GET(
|
|||||||
const depth = req.nextUrl.searchParams.get("depth");
|
const depth = req.nextUrl.searchParams.get("depth");
|
||||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||||
|
const resolution = req.nextUrl.searchParams.get("resolution");
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
force_refresh: forceRefresh,
|
force_refresh: forceRefresh,
|
||||||
});
|
});
|
||||||
@@ -60,6 +61,9 @@ export async function GET(
|
|||||||
if (targetDate) {
|
if (targetDate) {
|
||||||
searchParams.set("target_date", targetDate);
|
searchParams.set("target_date", targetDate);
|
||||||
}
|
}
|
||||||
|
if (resolution) {
|
||||||
|
searchParams.set("resolution", resolution);
|
||||||
|
}
|
||||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
|
|||||||
import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
|
import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
import { getInitialLocaleFromNavigator } from "@/lib/i18n";
|
import { getInitialLocaleFromNavigator } from "@/lib/i18n";
|
||||||
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
|
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
|
||||||
|
import { getSupabaseBrowserClient, hasSupabasePublicEnv } from "@/lib/supabase/client";
|
||||||
import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils";
|
import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||||
import { ProductAccessRequired } from "@/components/dashboard/scan-terminal/ProductAccessRequired";
|
import { ProductAccessRequired } from "@/components/dashboard/scan-terminal/ProductAccessRequired";
|
||||||
import {
|
import {
|
||||||
@@ -915,6 +916,65 @@ function ScanTerminalScreen() {
|
|||||||
const [proAccess, setProAccess] = useState<ProAccessState>(() =>
|
const [proAccess, setProAccess] = useState<ProAccessState>(() =>
|
||||||
createEmptyAccess(true),
|
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 [locale, setLocale] = useState<"zh-CN" | "en-US">("zh-CN");
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
const toggleLocale = () =>
|
const toggleLocale = () =>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Line,
|
Line,
|
||||||
LineChart as ReLineChart,
|
Area,
|
||||||
|
ComposedChart as ReComposedChart,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
|
ReferenceArea,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -454,6 +456,7 @@ type HourlyForecast = {
|
|||||||
temps: Array<number | null>;
|
temps: Array<number | null>;
|
||||||
modelCurves?: Record<string, Array<number | null>>;
|
modelCurves?: Record<string, Array<number | null>>;
|
||||||
runwayPlateHistory?: Record<string, Array<Record<string, unknown>>>;
|
runwayPlateHistory?: Record<string, Array<Record<string, unknown>>>;
|
||||||
|
runwayBandHistory?: Array<{ time: string; high_temp: number; low_temp: number; avg_temp: number }>;
|
||||||
amos?: AmosData | null;
|
amos?: AmosData | null;
|
||||||
airportCurrent?: AirportCurrentConditions | null;
|
airportCurrent?: AirportCurrentConditions | null;
|
||||||
airportPrimary?: AirportCurrentConditions | null;
|
airportPrimary?: AirportCurrentConditions | null;
|
||||||
@@ -474,6 +477,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
|
|||||||
temps: [],
|
temps: [],
|
||||||
modelCurves: undefined,
|
modelCurves: undefined,
|
||||||
runwayPlateHistory: (row as any)?.runway_plate_history || undefined,
|
runwayPlateHistory: (row as any)?.runway_plate_history || undefined,
|
||||||
|
runwayBandHistory: undefined,
|
||||||
amos: null,
|
amos: null,
|
||||||
airportCurrent: null,
|
airportCurrent: null,
|
||||||
airportPrimary: null,
|
airportPrimary: null,
|
||||||
@@ -487,6 +491,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
|
|||||||
|
|
||||||
type HourlyForecastFetchOptions = {
|
type HourlyForecastFetchOptions = {
|
||||||
ignoreCache?: boolean;
|
ignoreCache?: boolean;
|
||||||
|
resolution?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast {
|
function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast {
|
||||||
@@ -500,6 +505,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
|
|||||||
temps: hourlySource.temps || [],
|
temps: hourlySource.temps || [],
|
||||||
modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined,
|
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,
|
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,
|
amos: json.amos || null,
|
||||||
airportCurrent: json.airport_current || null,
|
airportCurrent: json.airport_current || null,
|
||||||
airportPrimary: json.airport_primary || null,
|
airportPrimary: json.airport_primary || null,
|
||||||
@@ -515,24 +521,27 @@ async function fetchHourlyForecastForCity(
|
|||||||
city: string,
|
city: string,
|
||||||
options: HourlyForecastFetchOptions = {},
|
options: HourlyForecastFetchOptions = {},
|
||||||
): Promise<HourlyForecast> {
|
): Promise<HourlyForecast> {
|
||||||
|
const resParam = options.resolution || "10m";
|
||||||
|
const cacheKey = `${city}:${resParam}`;
|
||||||
|
|
||||||
if (!options.ignoreCache) {
|
if (!options.ignoreCache) {
|
||||||
const cached = _hourlyCache.get(city);
|
const cached = _hourlyCache.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionCached = readSessionCache(city);
|
const sessionCached = readSessionCache(cacheKey);
|
||||||
if (sessionCached) {
|
if (sessionCached) {
|
||||||
_hourlyCache.set(city, sessionCached);
|
_hourlyCache.set(cacheKey, sessionCached);
|
||||||
return sessionCached.data;
|
return sessionCached.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestKey = options.ignoreCache ? `${city}:live` : city;
|
const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`;
|
||||||
const pending = _hourlyRequestCache.get(requestKey);
|
const pending = _hourlyRequestCache.get(requestKey);
|
||||||
if (pending) return pending;
|
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" },
|
headers: { Accept: "application/json" },
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
@@ -542,8 +551,8 @@ async function fetchHourlyForecastForCity(
|
|||||||
.then((json) => {
|
.then((json) => {
|
||||||
const data = parseHourlyForecastFromCityDetail(json);
|
const data = parseHourlyForecastFromCityDetail(json);
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
_hourlyCache.set(city, { ts: Date.now(), data });
|
_hourlyCache.set(cacheKey, { ts: Date.now(), data });
|
||||||
writeSessionCache(city, data);
|
writeSessionCache(cacheKey, data);
|
||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -816,7 +825,8 @@ function format3DayTimestamp(ts: number): string {
|
|||||||
function build3DayChartData(
|
function build3DayChartData(
|
||||||
row: ScanOpportunityRow | null,
|
row: ScanOpportunityRow | null,
|
||||||
hourly: HourlyForecast,
|
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 tzOffset = row?.tz_offset_seconds ?? 0;
|
||||||
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
|
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
@@ -827,6 +837,52 @@ function build3DayChartData(
|
|||||||
const series: EvidenceSeries[] = [];
|
const series: EvidenceSeries[] = [];
|
||||||
const na = (): Array<number | null> => new Array(n).fill(null);
|
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)
|
// DEB forecast curve (from hourly.times & hourly.temps)
|
||||||
if (hourly?.times?.length && hourly?.temps?.length) {
|
if (hourly?.times?.length && hourly?.temps?.length) {
|
||||||
const debVals = na();
|
const debVals = na();
|
||||||
@@ -901,9 +957,10 @@ function build3DayChartData(
|
|||||||
|
|
||||||
// Build data rows
|
// Build data rows
|
||||||
const data = slots.map((ts, i) => {
|
const data = slots.map((ts, i) => {
|
||||||
const point: Record<string, string | number | null> = {
|
const point: Record<string, any> = {
|
||||||
label: format3DayTimestamp(ts),
|
label: format3DayTimestamp(ts),
|
||||||
ts,
|
ts,
|
||||||
|
runway_band: bandVals[i] ?? null,
|
||||||
};
|
};
|
||||||
series.forEach((s) => {
|
series.forEach((s) => {
|
||||||
point[s.key] = s.values[i] ?? null;
|
point[s.key] = s.values[i] ?? null;
|
||||||
@@ -1004,10 +1061,27 @@ function buildDailyChartData(
|
|||||||
return { data, series: activeSeries };
|
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(
|
function buildFullDayChartData(
|
||||||
row: ScanOpportunityRow | null,
|
row: ScanOpportunityRow | null,
|
||||||
hourly: HourlyForecast,
|
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 tzOffset = row?.tz_offset_seconds ?? 0;
|
||||||
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
|
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 ──
|
// ── Settlement observations ──
|
||||||
// Determine if this is an HKO-sourced city to force the label
|
// Determine if this is an HKO-sourced city to force the label
|
||||||
const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
|
const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
|
||||||
@@ -1180,9 +1284,10 @@ function buildFullDayChartData(
|
|||||||
|
|
||||||
// ── Build data rows ──
|
// ── Build data rows ──
|
||||||
const data = slots.map((ts, i) => {
|
const data = slots.map((ts, i) => {
|
||||||
const point: Record<string, string | number | null> = {
|
const point: Record<string, any> = {
|
||||||
label: formatTimestamp(ts),
|
label: formatTimestamp(ts),
|
||||||
ts,
|
ts,
|
||||||
|
runway_band: bandVals[i] ?? null,
|
||||||
};
|
};
|
||||||
series.forEach((s) => { point[s.key] = s.values[i] ?? null; });
|
series.forEach((s) => { point[s.key] = s.values[i] ?? null; });
|
||||||
return point;
|
return point;
|
||||||
@@ -1304,6 +1409,19 @@ export function LiveTemperatureThresholdChart({
|
|||||||
const lastPatchAtRef = useRef<number>(Date.now());
|
const lastPatchAtRef = useRef<number>(Date.now());
|
||||||
const lastAppliedPatchRevisionRef = useRef<number>(0);
|
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(() => {
|
useEffect(() => {
|
||||||
setUserToggledKeys({});
|
setUserToggledKeys({});
|
||||||
lastPatchAtRef.current = Date.now();
|
lastPatchAtRef.current = Date.now();
|
||||||
@@ -1313,14 +1431,15 @@ export function LiveTemperatureThresholdChart({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!city) return;
|
if (!city) return;
|
||||||
|
|
||||||
|
const cacheKey = `${city}:${targetResolution}`;
|
||||||
// Check in-memory cache first
|
// Check in-memory cache first
|
||||||
let cached = _hourlyCache.get(city);
|
let cached = _hourlyCache.get(cacheKey);
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
// Fallback to session cache
|
// Fallback to session cache
|
||||||
const sessionEntry = readSessionCache(city);
|
const sessionEntry = readSessionCache(cacheKey);
|
||||||
if (sessionEntry) {
|
if (sessionEntry) {
|
||||||
cached = 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 delay = isActive ? 0 : (slotIndex ? 300 + slotIndex * 250 : 350);
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fetchHourlyForecastForCity(city)
|
fetchHourlyForecastForCity(city, { resolution: targetResolution })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled || !data) return;
|
if (cancelled || !data) return;
|
||||||
setHourly(data);
|
setHourly(data);
|
||||||
@@ -1348,7 +1467,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [city, row, isActive, slotIndex]);
|
}, [city, row, isActive, slotIndex, targetResolution]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
|
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
|
||||||
@@ -1400,10 +1519,37 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
const { data, series } = useMemo(() => {
|
const { data, series } = useMemo(() => {
|
||||||
if (timeframe === "3D") {
|
if (timeframe === "3D") {
|
||||||
return build3DayChartData(row, hourly);
|
return build3DayChartData(row, hourly, isEn);
|
||||||
}
|
}
|
||||||
return buildFullDayChartData(row, hourly);
|
return buildFullDayChartData(row, hourly, isEn);
|
||||||
}, [row, hourly, timeframe]);
|
}, [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 tzOffset = row?.tz_offset_seconds ?? 0;
|
||||||
const settlementObs = useMemo(() => {
|
const settlementObs = useMemo(() => {
|
||||||
@@ -1433,8 +1579,16 @@ export function LiveTemperatureThresholdChart({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const activeSeries = useMemo(() => {
|
const activeSeries = useMemo(() => {
|
||||||
return getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
|
const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
|
||||||
}, [chartSeries, userToggledKeys, city]);
|
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 normalizedKey = normalizeCityKey(row?.city);
|
||||||
const runwaySensorCities = new Set([
|
const runwaySensorCities = new Set([
|
||||||
@@ -1540,10 +1694,10 @@ export function LiveTemperatureThresholdChart({
|
|||||||
return list.sort((a, b) => a.threshold - b.threshold);
|
return list.sort((a, b) => a.threshold - b.threshold);
|
||||||
}, [row, allRows]);
|
}, [row, allRows]);
|
||||||
|
|
||||||
const intDegreeTicks = useMemo(() => buildIntDegreeTicks(activeSeries, data), [activeSeries, data]);
|
const intDegreeTicks = useMemo(() => buildIntDegreeTicks(activeSeries, zoomedData), [activeSeries, zoomedData]);
|
||||||
const chartDomain = useMemo(
|
const chartDomain = useMemo(
|
||||||
() => buildChartDomain(activeSeries, data),
|
() => buildChartDomain(activeSeries, zoomedData),
|
||||||
[activeSeries, data],
|
[activeSeries, zoomedData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const subtitle = row
|
const subtitle = row
|
||||||
@@ -1580,6 +1734,15 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
const timeframeActions = (
|
const timeframeActions = (
|
||||||
<div className="flex items-center gap-1.5">
|
<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">
|
<div className="flex items-center gap-1 rounded bg-[#eef2f6] p-0.5 border border-slate-200">
|
||||||
{(["1D", "3D"] as const).map((tf) => (
|
{(["1D", "3D"] as const).map((tf) => (
|
||||||
<button
|
<button
|
||||||
@@ -1587,7 +1750,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTimeframe(tf)}
|
onClick={() => setTimeframe(tf)}
|
||||||
className={clsx(
|
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
|
timeframe === tf
|
||||||
? "bg-white text-blue-600 shadow-sm border border-slate-200/50"
|
? "bg-white text-blue-600 shadow-sm border border-slate-200/50"
|
||||||
: "text-slate-500 hover:text-slate-800"
|
: "text-slate-500 hover:text-slate-800"
|
||||||
@@ -1635,7 +1798,45 @@ export function LiveTemperatureThresholdChart({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 (
|
return (
|
||||||
<Panel title={panelTitle} actions={timeframeActions}>
|
<Panel title={panelTitle} actions={timeframeActions}>
|
||||||
@@ -1852,36 +2053,66 @@ export function LiveTemperatureThresholdChart({
|
|||||||
{/* Chart */}
|
{/* Chart */}
|
||||||
<div className="relative min-h-0 flex-1 p-2">
|
<div className="relative min-h-0 flex-1 p-2">
|
||||||
{/* Interactive legend */}
|
{/* 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">
|
<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.map((s) => (
|
{chartSeries.length > 1 &&
|
||||||
<button
|
chartSeries
|
||||||
key={s.key}
|
.filter((s) => {
|
||||||
type="button"
|
const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max";
|
||||||
onClick={() => {
|
if (showRunwayDetails) {
|
||||||
setUserToggledKeys((prev) => ({
|
return s.key !== "runway_max";
|
||||||
...prev,
|
} else {
|
||||||
[s.key]: !isSeriesVisible(s.key),
|
return !isIndividualRunway;
|
||||||
}));
|
}
|
||||||
}}
|
})
|
||||||
className={clsx(
|
.map((s) => (
|
||||||
"inline-flex items-center gap-1.5 font-mono cursor-pointer transition-opacity hover:opacity-80",
|
<button
|
||||||
!isSeriesVisible(s.key) && "opacity-40 line-through"
|
key={s.key}
|
||||||
)}
|
type="button"
|
||||||
>
|
onClick={() => {
|
||||||
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
|
setUserToggledKeys((prev) => ({
|
||||||
<span className="text-slate-700 font-bold">{s.label}</span>
|
...prev,
|
||||||
</button>
|
[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>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<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" />
|
<CartesianGrid stroke="#dbe6ef" strokeDasharray="2 2" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: 9, fill: "#64748b" }}
|
tick={{ fontSize: 9, fill: "#64748b" }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: "#cbd5e1" }}
|
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
|
<YAxis
|
||||||
orientation="right"
|
orientation="right"
|
||||||
@@ -1921,8 +2152,38 @@ export function LiveTemperatureThresholdChart({
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
boxShadow: "0 8px 24px rgba(15,23,42,.12)",
|
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) => (
|
{activeSeries.map((item) => (
|
||||||
<Line
|
<Line
|
||||||
key={item.key}
|
key={item.key}
|
||||||
@@ -1938,7 +2199,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ReLineChart>
|
</ReComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1950,8 +2211,9 @@ export function __buildTemperatureChartDataForTest(
|
|||||||
row: ScanOpportunityRow | null,
|
row: ScanOpportunityRow | null,
|
||||||
hourly: HourlyForecast,
|
hourly: HourlyForecast,
|
||||||
timeframe: "1D" | "3D" = "1D",
|
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;
|
export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault;
|
||||||
|
|||||||
+31
@@ -310,4 +310,35 @@ export function runTests() {
|
|||||||
Math.min(...shanghaiDebValues) > 20,
|
Math.min(...shanghaiDebValues) > 20,
|
||||||
"DEB curve should not be pulled into an impossible negative range by stale row deb_prediction=0",
|
"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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,19 +41,14 @@ export async function buildBackendRequestHeaders(
|
|||||||
const incomingAuth = extractBearerToken(request.headers.get("authorization"));
|
const incomingAuth = extractBearerToken(request.headers.get("authorization"));
|
||||||
const includeSupabaseIdentity = options?.includeSupabaseIdentity !== false;
|
const includeSupabaseIdentity = options?.includeSupabaseIdentity !== false;
|
||||||
if (hasSupabaseServerEnv() && includeSupabaseIdentity) {
|
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 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 forwardedUserId = String(user?.id || "").trim();
|
||||||
const forwardedEmail = String(user?.email || "").trim();
|
const forwardedEmail = String(user?.email || "").trim();
|
||||||
if (forwardedUserId) {
|
if (forwardedUserId) {
|
||||||
@@ -67,6 +62,11 @@ export async function buildBackendRequestHeaders(
|
|||||||
headers.set("Authorization", `Bearer ${incomingAuth}`);
|
headers.set("Authorization", `Bearer ${incomingAuth}`);
|
||||||
return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null };
|
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 || "";
|
const accessToken = session?.access_token || "";
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
// Fallback to cookie-backed session when request does not carry bearer.
|
// Fallback to cookie-backed session when request does not carry bearer.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
||||||
import type { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
type CookieAdapter = {
|
type CookieAdapter = {
|
||||||
getAll: () => { name: string; value: string }[];
|
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
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
createSupabaseMiddlewareClient,
|
|
||||||
hasSupabaseServerEnv,
|
hasSupabaseServerEnv,
|
||||||
|
refreshMiddlewareSession,
|
||||||
} from "@/lib/supabase/server";
|
} from "@/lib/supabase/server";
|
||||||
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
|
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
|
||||||
|
|
||||||
@@ -69,13 +69,7 @@ async function handleTerminalGate(request: NextRequest): Promise<NextResponse> {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.next({
|
const { response, user } = await refreshMiddlewareSession(request);
|
||||||
request: { headers: request.headers },
|
|
||||||
});
|
|
||||||
const supabase = createSupabaseMiddlewareClient(request, response);
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
// Authenticated — pass through. Terminal client handles subscription gate.
|
// Authenticated — pass through. Terminal client handles subscription gate.
|
||||||
@@ -96,15 +90,7 @@ async function handleSupabaseAuthGate(request: NextRequest) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.next({
|
const { response, user } = await refreshMiddlewareSession(request);
|
||||||
request: {
|
|
||||||
headers: request.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const supabase = createSupabaseMiddlewareClient(request, response);
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return response;
|
return response;
|
||||||
@@ -134,13 +120,7 @@ async function handleSupabaseOptionalSession(request: NextRequest) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.next({
|
const { response } = await refreshMiddlewareSession(request);
|
||||||
request: {
|
|
||||||
headers: request.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const supabase = createSupabaseMiddlewareClient(request, response);
|
|
||||||
await supabase.auth.getUser();
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2079,11 +2079,13 @@ def _build_city_detail_payload(
|
|||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
|
resolution: Optional[str] = "10m",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
return _city_payload_detail(
|
return _city_payload_detail(
|
||||||
data,
|
data,
|
||||||
market_slug=market_slug,
|
market_slug=market_slug,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
resolution=resolution,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ async def city_detail_aggregate(
|
|||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
|
resolution: Optional[str] = "10m",
|
||||||
):
|
):
|
||||||
return await get_city_detail_aggregate_payload(
|
return await get_city_detail_aggregate_payload(
|
||||||
request,
|
request,
|
||||||
@@ -148,6 +149,7 @@ async def city_detail_aggregate(
|
|||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
market_slug=market_slug,
|
market_slug=market_slug,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
resolution=resolution,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ async def get_city_detail_aggregate_payload(
|
|||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
|
resolution: Optional[str] = "10m",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
legacy_routes._assert_entitlement(request)
|
legacy_routes._assert_entitlement(request)
|
||||||
city = legacy_routes._normalize_city_or_404(name)
|
city = legacy_routes._normalize_city_or_404(name)
|
||||||
@@ -162,6 +163,7 @@ async def get_city_detail_aggregate_payload(
|
|||||||
data,
|
data,
|
||||||
market_slug,
|
market_slug,
|
||||||
target_date,
|
target_date,
|
||||||
|
resolution,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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
|
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(
|
def build_city_detail_payload(
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
|
resolution: Optional[str] = "10m",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"city": data.get("name"),
|
"city": data.get("name"),
|
||||||
@@ -125,7 +251,8 @@ def build_city_detail_payload(
|
|||||||
or _build_intraday_meteorology(data),
|
or _build_intraday_meteorology(data),
|
||||||
"vertical_profile_signal": data.get("vertical_profile_signal") or {},
|
"vertical_profile_signal": data.get("vertical_profile_signal") or {},
|
||||||
"taf": data.get("taf") 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"),
|
"risk": data.get("risk"),
|
||||||
"settlement_station": data.get("settlement_station") or {},
|
"settlement_station": data.get("settlement_station") or {},
|
||||||
|
|||||||
Reference in New Issue
Block a user