feat: implement scan terminal dashboard with AI-driven city analysis and state management
This commit is contained in:
@@ -41,6 +41,7 @@ export async function POST(req: NextRequest) {
|
||||
console.info("[scan-ai-city] proxy request", {
|
||||
city: requestBody.city,
|
||||
force_refresh: requestBody.force_refresh === true,
|
||||
locale: requestBody.locale,
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -643,6 +643,7 @@ function AiPinnedCityCard({
|
||||
body: JSON.stringify({
|
||||
city: detailCityName,
|
||||
force_refresh: aiRefreshToken > 0,
|
||||
locale,
|
||||
}),
|
||||
})
|
||||
.then(async (response) => {
|
||||
@@ -664,7 +665,7 @@ function AiPinnedCityCard({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [aiForecastKey, aiRefreshToken, detailCityName]);
|
||||
}, [aiForecastKey, aiRefreshToken, detailCityName, locale]);
|
||||
|
||||
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
||||
const localizedFinalJudgment =
|
||||
|
||||
@@ -116,7 +116,6 @@ function getInitialProAccessState(): ProAccessState {
|
||||
}
|
||||
|
||||
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
|
||||
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
|
||||
const CITY_LOAD_RETRY_DELAYS_MS = [700, 1600];
|
||||
type CityDetailDepth = "panel" | "market" | "nearby" | "full";
|
||||
|
||||
@@ -228,10 +227,6 @@ function detailSatisfiesDepth(
|
||||
return normalizeDetailDepth(detail) === "full";
|
||||
}
|
||||
|
||||
function shouldCheckSparseCoverageForDepth(depth: CityDetailDepth) {
|
||||
return depth === "panel" || depth === "market" || depth === "full";
|
||||
}
|
||||
|
||||
function hasMeaningfulModelMap(
|
||||
value: Record<string, number | null> | undefined,
|
||||
): value is Record<string, number | null> {
|
||||
@@ -399,7 +394,6 @@ export function DashboardStoreProvider({
|
||||
const modalOpenSeqRef = useRef(0);
|
||||
const hydratedSelectionRef = useRef(false);
|
||||
const hydratedProCacheRef = useRef(false);
|
||||
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
|
||||
const summaryInflightByCityRef = useRef<Record<string, Promise<CitySummary>>>(
|
||||
{},
|
||||
);
|
||||
@@ -474,32 +468,36 @@ export function DashboardStoreProvider({
|
||||
dashboardClient.clearCityDetailCache();
|
||||
}, [proAccess]);
|
||||
|
||||
const scheduleBackgroundDetailRefresh = (
|
||||
const ensureCityDetail = async (
|
||||
cityName: string,
|
||||
cached: CityDetail,
|
||||
cachedMeta?: { cachedAt: number; revision: string },
|
||||
force = false,
|
||||
depth: CityDetailDepth = "panel",
|
||||
) => {
|
||||
const nowTs = Date.now();
|
||||
const lastTs = backgroundSummaryCheckAtRef.current[cityName] || 0;
|
||||
if (nowTs - lastTs < BACKGROUND_SUMMARY_REFRESH_MS) {
|
||||
return;
|
||||
const cached = cityDetailsByName[cityName];
|
||||
const cachedMeta = cityDetailMetaByName[cityName];
|
||||
const marketTargetDate =
|
||||
depth === "market" ? selectedForecastDate || cached?.local_date : null;
|
||||
const hasRequestedDepth = detailSatisfiesDepth(
|
||||
cached,
|
||||
depth,
|
||||
marketTargetDate,
|
||||
);
|
||||
if (
|
||||
!force &&
|
||||
cached &&
|
||||
hasRequestedDepth &&
|
||||
dashboardClient.isCityDetailFresh(cachedMeta)
|
||||
) {
|
||||
return cached;
|
||||
}
|
||||
backgroundSummaryCheckAtRef.current[cityName] = nowTs;
|
||||
|
||||
void dashboardClient
|
||||
.getCitySummary(cityName)
|
||||
.then(async (summary) => {
|
||||
const revision = getCityRevision(summary);
|
||||
if (!revision || revision === cachedMeta?.revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force && cached && hasRequestedDepth) {
|
||||
try {
|
||||
const latestDetail = await dashboardClient.getCityDetail(cityName, {
|
||||
force: false,
|
||||
depth: normalizeDetailDepth(cached),
|
||||
force: true,
|
||||
depth,
|
||||
});
|
||||
const detail = latestDetail;
|
||||
|
||||
setCityDetailsByName((current) => ({
|
||||
...current,
|
||||
[cityName]: mergeCityDetail(current[cityName], detail),
|
||||
@@ -515,77 +513,7 @@ export function DashboardStoreProvider({
|
||||
revision: getCityRevision(detail),
|
||||
},
|
||||
}));
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const ensureCityDetail = async (
|
||||
cityName: string,
|
||||
force = false,
|
||||
depth: CityDetailDepth = "panel",
|
||||
) => {
|
||||
const cached = cityDetailsByName[cityName];
|
||||
const cachedMeta = cityDetailMetaByName[cityName];
|
||||
const marketTargetDate =
|
||||
depth === "market" ? selectedForecastDate || cached?.local_date : null;
|
||||
const hasRequestedDepth = detailSatisfiesDepth(
|
||||
cached,
|
||||
depth,
|
||||
marketTargetDate,
|
||||
);
|
||||
const cachedIsSparse =
|
||||
shouldCheckSparseCoverageForDepth(depth) &&
|
||||
(depth === "market"
|
||||
? hasSparseModelCoverage(cached, marketTargetDate)
|
||||
: hasSparseDetailCoverage(cached, cached?.local_date));
|
||||
if (
|
||||
!force &&
|
||||
cached &&
|
||||
hasRequestedDepth &&
|
||||
!cachedIsSparse &&
|
||||
dashboardClient.isCityDetailFresh(cachedMeta)
|
||||
) {
|
||||
scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta);
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!force && cached && hasRequestedDepth) {
|
||||
try {
|
||||
const summary = await dashboardClient.getCitySummary(cityName);
|
||||
const revision = getCityRevision(summary);
|
||||
if (revision && revision === cachedMeta?.revision) {
|
||||
if (cachedIsSparse) {
|
||||
const latestDetail = await dashboardClient.getCityDetail(cityName, {
|
||||
force: true,
|
||||
depth,
|
||||
});
|
||||
const detail = latestDetail;
|
||||
setCityDetailsByName((current) => ({
|
||||
...current,
|
||||
[cityName]: mergeCityDetail(current[cityName], detail),
|
||||
}));
|
||||
setCitySummariesByName((current) => ({
|
||||
...current,
|
||||
[cityName]: toCitySummary(detail),
|
||||
}));
|
||||
setCityDetailMetaByName((current) => ({
|
||||
...current,
|
||||
[cityName]: {
|
||||
cachedAt: Date.now(),
|
||||
revision: getCityRevision(detail),
|
||||
},
|
||||
}));
|
||||
return detail;
|
||||
}
|
||||
setCityDetailMetaByName((current) => ({
|
||||
...current,
|
||||
[cityName]: {
|
||||
cachedAt: Date.now(),
|
||||
revision,
|
||||
},
|
||||
}));
|
||||
return cached;
|
||||
}
|
||||
return detail;
|
||||
} catch {
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export type AssistantChatResponse = {
|
||||
};
|
||||
|
||||
const CACHE_KEY = "polyWeather_v1";
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000;
|
||||
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
||||
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
|
||||
@@ -244,16 +244,18 @@ function readLegacyCache(raw: string): CityCacheBundle {
|
||||
};
|
||||
const details = parsed.data || {};
|
||||
const cachedAt = parsed.timestamp || 0;
|
||||
const meta = Object.fromEntries(
|
||||
Object.entries(details).map(([cityName, detail]) => [
|
||||
cityName,
|
||||
{
|
||||
cachedAt,
|
||||
revision: getCityRevision(detail),
|
||||
},
|
||||
]),
|
||||
);
|
||||
return { details, meta };
|
||||
const freshDetails: Record<string, CityDetail> = {};
|
||||
const meta: Record<string, CityCacheMeta> = {};
|
||||
Object.entries(details).forEach(([cityName, detail]) => {
|
||||
const nextMeta = {
|
||||
cachedAt,
|
||||
revision: getCityRevision(detail),
|
||||
};
|
||||
if (!isFresh(nextMeta)) return;
|
||||
freshDetails[cityName] = detail;
|
||||
meta[cityName] = nextMeta;
|
||||
});
|
||||
return { details: freshDetails, meta };
|
||||
}
|
||||
|
||||
export const dashboardClient = {
|
||||
@@ -606,11 +608,13 @@ export const dashboardClient = {
|
||||
const meta: Record<string, CityCacheMeta> = {};
|
||||
Object.entries(parsed.entries).forEach(([cityName, entry]) => {
|
||||
if (!entry?.detail) return;
|
||||
details[cityName] = entry.detail;
|
||||
meta[cityName] = {
|
||||
const nextMeta = {
|
||||
cachedAt: entry.cachedAt || 0,
|
||||
revision: entry.revision || getCityRevision(entry.detail),
|
||||
};
|
||||
if (!isFresh(nextMeta)) return;
|
||||
details[cityName] = entry.detail;
|
||||
meta[cityName] = nextMeta;
|
||||
});
|
||||
return { details, meta };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user