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 { data } = useCityData();
|
||||
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;
|
||||
|
||||
const daily = data.forecast?.daily || [];
|
||||
const isSparseDaily = daily.length <= 1;
|
||||
const isForecastCompleting =
|
||||
store.loadingState.cityDetail &&
|
||||
@@ -1525,7 +1536,9 @@ export function ForecastTable() {
|
||||
) : (
|
||||
daily
|
||||
.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 =
|
||||
(isToday &&
|
||||
store.forecastModalMode === "today" &&
|
||||
|
||||
@@ -1635,7 +1635,7 @@ function ScanTerminalScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeDetailRow) return;
|
||||
if (!store.cityDetailsByName[activeDetailRow.city]) {
|
||||
if (!findDetailForCity(store.cityDetailsByName, activeDetailRow.city)) {
|
||||
void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {});
|
||||
}
|
||||
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
|
||||
@@ -1765,7 +1765,7 @@ function ScanTerminalScreen() {
|
||||
const selectedCityKey = normalizeCityKey(store.selectedCity);
|
||||
const rowCityKey = normalizeCityKey(cityName);
|
||||
const hasCachedDetail =
|
||||
Boolean(store.cityDetailsByName[cityName]) ||
|
||||
Boolean(findDetailForCity(store.cityDetailsByName, cityName)) ||
|
||||
Object.values(store.cityDetailsByName).some((detail) =>
|
||||
rowMatchesCity(row, detail?.name || detail?.display_name || ""),
|
||||
);
|
||||
|
||||
@@ -285,7 +285,33 @@ function countAvailableModels(
|
||||
|
||||
function countForecastDays(detail?: CityDetail | null): number {
|
||||
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(
|
||||
@@ -402,10 +428,21 @@ function pickRicherForecast(
|
||||
currentValue: 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)
|
||||
? incomingValue || 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(
|
||||
@@ -562,7 +599,9 @@ export function DashboardStoreProvider({
|
||||
const citiesRef = useRef<CityListItem[]>([]);
|
||||
const citySummariesRef = useRef<Record<string, CitySummary>>({});
|
||||
const selectedCityRef = useRef<string | null>(null);
|
||||
const selectedDetail = selectedCity ? cityDetailsByName[selectedCity] || null : null;
|
||||
const selectedDetail = selectedCity
|
||||
? findCachedCityDetail(cityDetailsByName, selectedCity)
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (proAccess.loading) return;
|
||||
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
|
||||
@@ -635,7 +674,7 @@ export function DashboardStoreProvider({
|
||||
force = false,
|
||||
depth: CityDetailDepth = "panel",
|
||||
) => {
|
||||
const cached = cityDetailsByName[cityName];
|
||||
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||
const cachedMeta = cityDetailMetaByName[cityName];
|
||||
const marketTargetDate =
|
||||
depth === "market" ? selectedForecastDate || cached?.local_date : null;
|
||||
@@ -713,7 +752,7 @@ export function DashboardStoreProvider({
|
||||
targetDate?: string | null;
|
||||
},
|
||||
) => {
|
||||
let cached = cityDetailsByName[cityName];
|
||||
let cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||
try {
|
||||
if (!cached) {
|
||||
cached = await ensureCityDetail(cityName, false, "panel");
|
||||
@@ -748,7 +787,7 @@ export function DashboardStoreProvider({
|
||||
if (proAccess.loading) return;
|
||||
if (!selectedCity) return;
|
||||
if (!isPanelOpen) return;
|
||||
if (cityDetailsByName[selectedCity]) return;
|
||||
if (findCachedCityDetail(cityDetailsByName, selectedCity)) return;
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
@@ -1026,7 +1065,7 @@ export function DashboardStoreProvider({
|
||||
|
||||
const selectCity = async (cityName: string) => {
|
||||
const wasSelectedCity = selectedCityRef.current === cityName;
|
||||
const cached = cityDetailsByName[cityName];
|
||||
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||
selectedCityRef.current = cityName;
|
||||
setSelectedCity(cityName);
|
||||
setIsPanelOpen(true);
|
||||
@@ -1073,7 +1112,7 @@ export function DashboardStoreProvider({
|
||||
};
|
||||
|
||||
const focusCity = async (cityName: string) => {
|
||||
const cached = cityDetailsByName[cityName];
|
||||
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||
selectedCityRef.current = cityName;
|
||||
setSelectedCity(cityName);
|
||||
setIsPanelOpen(false);
|
||||
@@ -1328,7 +1367,7 @@ export function DashboardStoreProvider({
|
||||
const isLatestModalRequest = () =>
|
||||
modalOpenSeqRef.current === modalSeq &&
|
||||
selectedCityRef.current === cityName;
|
||||
let cachedDetail = cityDetailsByName[selectedCity];
|
||||
let cachedDetail = findCachedCityDetail(cityDetailsByName, selectedCity);
|
||||
if (!cachedDetail) {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
try {
|
||||
@@ -1382,7 +1421,7 @@ export function DashboardStoreProvider({
|
||||
const isLatestModalRequest = () =>
|
||||
modalOpenSeqRef.current === modalSeq &&
|
||||
selectedCityRef.current === cityName;
|
||||
let cachedDetail = cityDetailsByName[cityName];
|
||||
let cachedDetail = findCachedCityDetail(cityDetailsByName, cityName);
|
||||
if (!cachedDetail) {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
try {
|
||||
@@ -1494,7 +1533,7 @@ export function useCityData(name?: string | null) {
|
||||
const store = useDashboardStore();
|
||||
const key = name || store.selectedCity;
|
||||
return {
|
||||
data: key ? store.cityDetailsByName[key] || null : null,
|
||||
data: key ? findCachedCityDetail(store.cityDetailsByName, key) : null,
|
||||
isLoading:
|
||||
store.loadingState.cityDetail &&
|
||||
Boolean(key) &&
|
||||
|
||||
@@ -765,8 +765,15 @@ export function getTemperatureChartData(
|
||||
locale: Locale = "zh-CN",
|
||||
) {
|
||||
const hourly = detail.hourly || {};
|
||||
const times = hourly.times || [];
|
||||
const temps = hourly.temps || [];
|
||||
const rawTimes = Array.isArray(hourly.times) ? hourly.times : [];
|
||||
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);
|
||||
|
||||
if (!times.length) return null;
|
||||
@@ -777,7 +784,9 @@ export function getTemperatureChartData(
|
||||
const offset =
|
||||
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
|
||||
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) =>
|
||||
currentIndex >= 0 && index <= currentIndex ? temp : null,
|
||||
@@ -889,23 +898,23 @@ export function getTemperatureChartData(
|
||||
const metarPoints = new Array(times.length).fill(null);
|
||||
observationSource.forEach((item) => {
|
||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||
const temp = item.temp ?? null;
|
||||
if (index >= 0 && temp != null) {
|
||||
const temp = Number(item.temp);
|
||||
if (index >= 0 && Number.isFinite(temp)) {
|
||||
const existing = metarPoints[index];
|
||||
// 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.
|
||||
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);
|
||||
airportMetarSource.forEach((item) => {
|
||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||
const temp = item.temp ?? null;
|
||||
if (index >= 0 && temp != null) {
|
||||
const temp = Number(item.temp);
|
||||
if (index >= 0 && Number.isFinite(temp)) {
|
||||
const existing = 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
|
||||
) {
|
||||
const index = findNearestTimeIndex(times, detail.mgm.time);
|
||||
if (index >= 0) {
|
||||
mgmPoints[index] = detail.mgm.temp;
|
||||
const temp = Number(detail.mgm.temp);
|
||||
if (index >= 0 && Number.isFinite(temp)) {
|
||||
mgmPoints[index] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
const mgmHourlyPoints = new Array(times.length).fill(null);
|
||||
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 || ""));
|
||||
if (index >= 0) {
|
||||
mgmHourlyPoints[index] = item.temp ?? null;
|
||||
const temp = Number(item.temp);
|
||||
if (index >= 0 && Number.isFinite(temp)) {
|
||||
mgmHourlyPoints[index] = temp;
|
||||
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:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
@@ -1810,7 +1826,9 @@ def _analyze(
|
||||
sunshine = daily.get("sunshine_duration", [])
|
||||
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:
|
||||
nws_high = _sf(raw.get("nws", {}).get("today_high"))
|
||||
mgm_high = _sf(mgm.get("today_high")) if mgm else None
|
||||
|
||||
Reference in New Issue
Block a user