feat: implement ScanTerminalDashboard with AI-driven city forecast streaming and opportunity tracking
This commit is contained in:
@@ -1491,9 +1491,20 @@ export function ForecastTable() {
|
|||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { data } = useCityData();
|
const { data } = useCityData();
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
|
const daily = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const rawDaily = Array.isArray(data.forecast?.daily)
|
||||||
|
? data.forecast?.daily || []
|
||||||
|
: [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return rawDaily.filter((day) => {
|
||||||
|
const date = String(day?.date || "").trim();
|
||||||
|
if (!date || seen.has(date)) return false;
|
||||||
|
seen.add(date);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const daily = data.forecast?.daily || [];
|
|
||||||
const isSparseDaily = daily.length <= 1;
|
const isSparseDaily = daily.length <= 1;
|
||||||
const isForecastCompleting =
|
const isForecastCompleting =
|
||||||
store.loadingState.cityDetail &&
|
store.loadingState.cityDetail &&
|
||||||
@@ -1525,7 +1536,9 @@ export function ForecastTable() {
|
|||||||
) : (
|
) : (
|
||||||
daily
|
daily
|
||||||
.map((day, index) => {
|
.map((day, index) => {
|
||||||
const isToday = day.date === data.local_date || index === 0;
|
const isToday = data.local_date
|
||||||
|
? day.date === data.local_date
|
||||||
|
: index === 0;
|
||||||
const isSelected =
|
const isSelected =
|
||||||
(isToday &&
|
(isToday &&
|
||||||
store.forecastModalMode === "today" &&
|
store.forecastModalMode === "today" &&
|
||||||
|
|||||||
@@ -1635,7 +1635,7 @@ function ScanTerminalScreen() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeDetailRow) return;
|
if (!activeDetailRow) return;
|
||||||
if (!store.cityDetailsByName[activeDetailRow.city]) {
|
if (!findDetailForCity(store.cityDetailsByName, activeDetailRow.city)) {
|
||||||
void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {});
|
void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {});
|
||||||
}
|
}
|
||||||
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
|
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
|
||||||
@@ -1765,7 +1765,7 @@ function ScanTerminalScreen() {
|
|||||||
const selectedCityKey = normalizeCityKey(store.selectedCity);
|
const selectedCityKey = normalizeCityKey(store.selectedCity);
|
||||||
const rowCityKey = normalizeCityKey(cityName);
|
const rowCityKey = normalizeCityKey(cityName);
|
||||||
const hasCachedDetail =
|
const hasCachedDetail =
|
||||||
Boolean(store.cityDetailsByName[cityName]) ||
|
Boolean(findDetailForCity(store.cityDetailsByName, cityName)) ||
|
||||||
Object.values(store.cityDetailsByName).some((detail) =>
|
Object.values(store.cityDetailsByName).some((detail) =>
|
||||||
rowMatchesCity(row, detail?.name || detail?.display_name || ""),
|
rowMatchesCity(row, detail?.name || detail?.display_name || ""),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -285,7 +285,33 @@ function countAvailableModels(
|
|||||||
|
|
||||||
function countForecastDays(detail?: CityDetail | null): number {
|
function countForecastDays(detail?: CityDetail | null): number {
|
||||||
const daily = detail?.forecast?.daily;
|
const daily = detail?.forecast?.daily;
|
||||||
return Array.isArray(daily) ? daily.length : 0;
|
if (!Array.isArray(daily)) return 0;
|
||||||
|
return new Set(
|
||||||
|
daily
|
||||||
|
.map((day) => String(day?.date || "").trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCityLookupKey(value?: string | null): string {
|
||||||
|
return String(value || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCachedCityDetail(
|
||||||
|
detailsByName: Record<string, CityDetail>,
|
||||||
|
cityName?: string | null,
|
||||||
|
) {
|
||||||
|
const key = normalizeCityLookupKey(cityName);
|
||||||
|
if (!key) return null;
|
||||||
|
return (
|
||||||
|
detailsByName[cityName || ""] ||
|
||||||
|
Object.entries(detailsByName).find(([storedName, detail]) =>
|
||||||
|
[storedName, detail?.name, detail?.display_name].some(
|
||||||
|
(value) => normalizeCityLookupKey(value) === key,
|
||||||
|
),
|
||||||
|
)?.[1] ||
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasSparseModelCoverage(
|
function hasSparseModelCoverage(
|
||||||
@@ -402,10 +428,21 @@ function pickRicherForecast(
|
|||||||
currentValue: CityDetail["forecast"] | undefined,
|
currentValue: CityDetail["forecast"] | undefined,
|
||||||
incomingValue: CityDetail["forecast"] | undefined,
|
incomingValue: CityDetail["forecast"] | undefined,
|
||||||
) {
|
) {
|
||||||
return countForecastDays({ forecast: incomingValue } as CityDetail) >=
|
const picked = countForecastDays({ forecast: incomingValue } as CityDetail) >=
|
||||||
countForecastDays({ forecast: currentValue } as CityDetail)
|
countForecastDays({ forecast: currentValue } as CityDetail)
|
||||||
? incomingValue || currentValue
|
? incomingValue || currentValue
|
||||||
: currentValue;
|
: currentValue;
|
||||||
|
if (!picked?.daily || !Array.isArray(picked.daily)) return picked;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return {
|
||||||
|
...picked,
|
||||||
|
daily: picked.daily.filter((day) => {
|
||||||
|
const date = String(day?.date || "").trim();
|
||||||
|
if (!date || seen.has(date)) return false;
|
||||||
|
seen.add(date);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickPreferredNearbyStations(
|
function pickPreferredNearbyStations(
|
||||||
@@ -562,7 +599,9 @@ export function DashboardStoreProvider({
|
|||||||
const citiesRef = useRef<CityListItem[]>([]);
|
const citiesRef = useRef<CityListItem[]>([]);
|
||||||
const citySummariesRef = useRef<Record<string, CitySummary>>({});
|
const citySummariesRef = useRef<Record<string, CitySummary>>({});
|
||||||
const selectedCityRef = useRef<string | null>(null);
|
const selectedCityRef = useRef<string | null>(null);
|
||||||
const selectedDetail = selectedCity ? cityDetailsByName[selectedCity] || null : null;
|
const selectedDetail = selectedCity
|
||||||
|
? findCachedCityDetail(cityDetailsByName, selectedCity)
|
||||||
|
: null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (proAccess.loading) return;
|
if (proAccess.loading) return;
|
||||||
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
|
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
|
||||||
@@ -635,7 +674,7 @@ export function DashboardStoreProvider({
|
|||||||
force = false,
|
force = false,
|
||||||
depth: CityDetailDepth = "panel",
|
depth: CityDetailDepth = "panel",
|
||||||
) => {
|
) => {
|
||||||
const cached = cityDetailsByName[cityName];
|
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||||
const cachedMeta = cityDetailMetaByName[cityName];
|
const cachedMeta = cityDetailMetaByName[cityName];
|
||||||
const marketTargetDate =
|
const marketTargetDate =
|
||||||
depth === "market" ? selectedForecastDate || cached?.local_date : null;
|
depth === "market" ? selectedForecastDate || cached?.local_date : null;
|
||||||
@@ -713,7 +752,7 @@ export function DashboardStoreProvider({
|
|||||||
targetDate?: string | null;
|
targetDate?: string | null;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
let cached = cityDetailsByName[cityName];
|
let cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||||
try {
|
try {
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
cached = await ensureCityDetail(cityName, false, "panel");
|
cached = await ensureCityDetail(cityName, false, "panel");
|
||||||
@@ -748,7 +787,7 @@ export function DashboardStoreProvider({
|
|||||||
if (proAccess.loading) return;
|
if (proAccess.loading) return;
|
||||||
if (!selectedCity) return;
|
if (!selectedCity) return;
|
||||||
if (!isPanelOpen) return;
|
if (!isPanelOpen) return;
|
||||||
if (cityDetailsByName[selectedCity]) return;
|
if (findCachedCityDetail(cityDetailsByName, selectedCity)) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||||
@@ -1026,7 +1065,7 @@ export function DashboardStoreProvider({
|
|||||||
|
|
||||||
const selectCity = async (cityName: string) => {
|
const selectCity = async (cityName: string) => {
|
||||||
const wasSelectedCity = selectedCityRef.current === cityName;
|
const wasSelectedCity = selectedCityRef.current === cityName;
|
||||||
const cached = cityDetailsByName[cityName];
|
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||||
selectedCityRef.current = cityName;
|
selectedCityRef.current = cityName;
|
||||||
setSelectedCity(cityName);
|
setSelectedCity(cityName);
|
||||||
setIsPanelOpen(true);
|
setIsPanelOpen(true);
|
||||||
@@ -1073,7 +1112,7 @@ export function DashboardStoreProvider({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const focusCity = async (cityName: string) => {
|
const focusCity = async (cityName: string) => {
|
||||||
const cached = cityDetailsByName[cityName];
|
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||||
selectedCityRef.current = cityName;
|
selectedCityRef.current = cityName;
|
||||||
setSelectedCity(cityName);
|
setSelectedCity(cityName);
|
||||||
setIsPanelOpen(false);
|
setIsPanelOpen(false);
|
||||||
@@ -1328,7 +1367,7 @@ export function DashboardStoreProvider({
|
|||||||
const isLatestModalRequest = () =>
|
const isLatestModalRequest = () =>
|
||||||
modalOpenSeqRef.current === modalSeq &&
|
modalOpenSeqRef.current === modalSeq &&
|
||||||
selectedCityRef.current === cityName;
|
selectedCityRef.current === cityName;
|
||||||
let cachedDetail = cityDetailsByName[selectedCity];
|
let cachedDetail = findCachedCityDetail(cityDetailsByName, selectedCity);
|
||||||
if (!cachedDetail) {
|
if (!cachedDetail) {
|
||||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||||
try {
|
try {
|
||||||
@@ -1382,7 +1421,7 @@ export function DashboardStoreProvider({
|
|||||||
const isLatestModalRequest = () =>
|
const isLatestModalRequest = () =>
|
||||||
modalOpenSeqRef.current === modalSeq &&
|
modalOpenSeqRef.current === modalSeq &&
|
||||||
selectedCityRef.current === cityName;
|
selectedCityRef.current === cityName;
|
||||||
let cachedDetail = cityDetailsByName[cityName];
|
let cachedDetail = findCachedCityDetail(cityDetailsByName, cityName);
|
||||||
if (!cachedDetail) {
|
if (!cachedDetail) {
|
||||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||||
try {
|
try {
|
||||||
@@ -1494,7 +1533,7 @@ export function useCityData(name?: string | null) {
|
|||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const key = name || store.selectedCity;
|
const key = name || store.selectedCity;
|
||||||
return {
|
return {
|
||||||
data: key ? store.cityDetailsByName[key] || null : null,
|
data: key ? findCachedCityDetail(store.cityDetailsByName, key) : null,
|
||||||
isLoading:
|
isLoading:
|
||||||
store.loadingState.cityDetail &&
|
store.loadingState.cityDetail &&
|
||||||
Boolean(key) &&
|
Boolean(key) &&
|
||||||
|
|||||||
@@ -765,8 +765,15 @@ export function getTemperatureChartData(
|
|||||||
locale: Locale = "zh-CN",
|
locale: Locale = "zh-CN",
|
||||||
) {
|
) {
|
||||||
const hourly = detail.hourly || {};
|
const hourly = detail.hourly || {};
|
||||||
const times = hourly.times || [];
|
const rawTimes = Array.isArray(hourly.times) ? hourly.times : [];
|
||||||
const temps = hourly.temps || [];
|
const rawTemps = Array.isArray(hourly.temps) ? hourly.temps : [];
|
||||||
|
const times = rawTimes
|
||||||
|
.map((time) => String(time || "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const temps = times.map((_, index) => {
|
||||||
|
const value = Number(rawTemps[index]);
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
});
|
||||||
const suppressAnkaraMgmObservation = isTurkishMgmCity(detail);
|
const suppressAnkaraMgmObservation = isTurkishMgmCity(detail);
|
||||||
|
|
||||||
if (!times.length) return null;
|
if (!times.length) return null;
|
||||||
@@ -777,7 +784,9 @@ export function getTemperatureChartData(
|
|||||||
const offset =
|
const offset =
|
||||||
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
|
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
|
||||||
const debTemps = temps.map((temp) =>
|
const debTemps = temps.map((temp) =>
|
||||||
temp != null ? Number((temp + offset).toFixed(1)) : null,
|
temp != null && Number.isFinite(temp)
|
||||||
|
? Number((temp + offset).toFixed(1))
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
const debPast = debTemps.map((temp, index) =>
|
const debPast = debTemps.map((temp, index) =>
|
||||||
currentIndex >= 0 && index <= currentIndex ? temp : null,
|
currentIndex >= 0 && index <= currentIndex ? temp : null,
|
||||||
@@ -889,23 +898,23 @@ export function getTemperatureChartData(
|
|||||||
const metarPoints = new Array(times.length).fill(null);
|
const metarPoints = new Array(times.length).fill(null);
|
||||||
observationSource.forEach((item) => {
|
observationSource.forEach((item) => {
|
||||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||||
const temp = item.temp ?? null;
|
const temp = Number(item.temp);
|
||||||
if (index >= 0 && temp != null) {
|
if (index >= 0 && Number.isFinite(temp)) {
|
||||||
const existing = metarPoints[index];
|
const existing = metarPoints[index];
|
||||||
// Multiple reports can land in the same hour bucket. Keep the peak
|
// Multiple reports can land in the same hour bucket. Keep the peak
|
||||||
// value so an intrahour high is not hidden by a later weaker report.
|
// value so an intrahour high is not hidden by a later weaker report.
|
||||||
metarPoints[index] =
|
metarPoints[index] =
|
||||||
existing == null ? temp : Math.max(Number(existing), Number(temp));
|
existing == null ? temp : Math.max(Number(existing), temp);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const airportMetarPoints = new Array(times.length).fill(null);
|
const airportMetarPoints = new Array(times.length).fill(null);
|
||||||
airportMetarSource.forEach((item) => {
|
airportMetarSource.forEach((item) => {
|
||||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||||
const temp = item.temp ?? null;
|
const temp = Number(item.temp);
|
||||||
if (index >= 0 && temp != null) {
|
if (index >= 0 && Number.isFinite(temp)) {
|
||||||
const existing = airportMetarPoints[index];
|
const existing = airportMetarPoints[index];
|
||||||
airportMetarPoints[index] =
|
airportMetarPoints[index] =
|
||||||
existing == null ? temp : Math.max(Number(existing), Number(temp));
|
existing == null ? temp : Math.max(Number(existing), temp);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -916,17 +925,22 @@ export function getTemperatureChartData(
|
|||||||
detail.mgm?.time
|
detail.mgm?.time
|
||||||
) {
|
) {
|
||||||
const index = findNearestTimeIndex(times, detail.mgm.time);
|
const index = findNearestTimeIndex(times, detail.mgm.time);
|
||||||
if (index >= 0) {
|
const temp = Number(detail.mgm.temp);
|
||||||
mgmPoints[index] = detail.mgm.temp;
|
if (index >= 0 && Number.isFinite(temp)) {
|
||||||
|
mgmPoints[index] = temp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mgmHourlyPoints = new Array(times.length).fill(null);
|
const mgmHourlyPoints = new Array(times.length).fill(null);
|
||||||
let hasMgmHourly = false;
|
let hasMgmHourly = false;
|
||||||
detail.mgm?.hourly?.forEach((item) => {
|
const mgmHourlyRows = Array.isArray(detail.mgm?.hourly)
|
||||||
|
? detail.mgm?.hourly || []
|
||||||
|
: [];
|
||||||
|
mgmHourlyRows.forEach((item) => {
|
||||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||||
if (index >= 0) {
|
const temp = Number(item.temp);
|
||||||
mgmHourlyPoints[index] = item.temp ?? null;
|
if (index >= 0 && Number.isFinite(temp)) {
|
||||||
|
mgmHourlyPoints[index] = temp;
|
||||||
hasMgmHourly = true;
|
hasMgmHourly = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-1
@@ -54,6 +54,22 @@ _GROQ_COMMENTARY_CACHE_TTL_SEC = int(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_forecast_daily(rows: Any) -> list[Dict[str, Any]]:
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return []
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
date = str(row.get("date") or "").strip()
|
||||||
|
if not date or date in seen:
|
||||||
|
continue
|
||||||
|
seen.add(date)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _format_observation_time_local(value: Any, utc_offset: int) -> str:
|
def _format_observation_time_local(value: Any, utc_offset: int) -> str:
|
||||||
raw = str(value or "").strip()
|
raw = str(value or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -1810,7 +1826,9 @@ def _analyze(
|
|||||||
sunshine = daily.get("sunshine_duration", [])
|
sunshine = daily.get("sunshine_duration", [])
|
||||||
om_today = _sf(maxtemps[0]) if maxtemps else None
|
om_today = _sf(maxtemps[0]) if maxtemps else None
|
||||||
|
|
||||||
forecast_daily = [{"date": d, "max_temp": t} for d, t in zip(dates, maxtemps)]
|
forecast_daily = _dedupe_forecast_daily(
|
||||||
|
[{"date": d, "max_temp": t} for d, t in zip(dates, maxtemps)]
|
||||||
|
)
|
||||||
if om_today is None:
|
if om_today is None:
|
||||||
nws_high = _sf(raw.get("nws", {}).get("today_high"))
|
nws_high = _sf(raw.get("nws", {}).get("today_high"))
|
||||||
mgm_high = _sf(mgm.get("today_high")) if mgm else None
|
mgm_high = _sf(mgm.get("today_high")) if mgm else None
|
||||||
|
|||||||
Reference in New Issue
Block a user