diff --git a/frontend/app/api/cities/route.ts b/frontend/app/api/cities/route.ts index ba7feb78..38ce29ba 100644 --- a/frontend/app/api/cities/route.ts +++ b/frontend/app/api/cities/route.ts @@ -1,10 +1,11 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { buildCachedJsonResponse } from "@/lib/http-cache"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; export const dynamic = "force-dynamic"; -export async function GET() { +export async function GET(req: NextRequest) { if (!API_BASE) { return NextResponse.json( { error: "POLYWEATHER_API_BASE_URL is not configured" }, @@ -25,7 +26,11 @@ export async function GET() { ); } const data = await res.json(); - return NextResponse.json(data); + return buildCachedJsonResponse( + req, + data, + "public, max-age=0, s-maxage=300, stale-while-revalidate=1800", + ); } catch (error) { return NextResponse.json( { error: "Failed to fetch cities", detail: String(error) }, diff --git a/frontend/app/api/city/[name]/summary/route.ts b/frontend/app/api/city/[name]/summary/route.ts index 20beb876..127a1bc5 100644 --- a/frontend/app/api/city/[name]/summary/route.ts +++ b/frontend/app/api/city/[name]/summary/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { buildCachedJsonResponse } from "@/lib/http-cache"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -16,6 +17,7 @@ export async function GET( const { name } = await context.params; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; + const bypassCache = forceRefresh === "true"; const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`; try { @@ -31,7 +33,18 @@ export async function GET( ); } const data = await res.json(); - return NextResponse.json(data); + if (bypassCache) { + return NextResponse.json(data, { + headers: { + "Cache-Control": "no-store", + }, + }); + } + return buildCachedJsonResponse( + req, + data, + "public, max-age=0, s-maxage=20, stale-while-revalidate=60", + ); } catch (error) { return NextResponse.json( { error: "Failed to fetch city summary", detail: String(error) }, diff --git a/frontend/app/api/history/[name]/route.ts b/frontend/app/api/history/[name]/route.ts index 1e2e55bb..e091dc34 100644 --- a/frontend/app/api/history/[name]/route.ts +++ b/frontend/app/api/history/[name]/route.ts @@ -1,10 +1,11 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { buildCachedJsonResponse } from "@/lib/http-cache"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; export async function GET( - _req: Request, + req: NextRequest, context: { params: Promise<{ name: string }> }, ) { if (!API_BASE) { @@ -30,7 +31,11 @@ export async function GET( ); } const data = await res.json(); - return NextResponse.json(data); + return buildCachedJsonResponse( + req, + data, + "public, max-age=0, s-maxage=60, stale-while-revalidate=300", + ); } catch (error) { return NextResponse.json( { error: "Failed to fetch history", detail: String(error) }, diff --git a/frontend/components/dashboard/CitySidebar.tsx b/frontend/components/dashboard/CitySidebar.tsx index df198a28..b32a4ffb 100644 --- a/frontend/components/dashboard/CitySidebar.tsx +++ b/frontend/components/dashboard/CitySidebar.tsx @@ -8,22 +8,54 @@ import { CityListItem } from "@/lib/dashboard-types"; type RiskGroupKey = "high" | "medium" | "low" | "other"; +const GROUP_STATE_STORAGE_KEY = "polyWeather_sidebar_groups_v1"; +const DEFAULT_EXPANDED_GROUPS: Record = { + high: true, + medium: true, + low: false, + other: false, +}; + function toRiskGroup(level?: string): RiskGroupKey { if (level === "high" || level === "medium" || level === "low") return level; return "other"; } +function normalizeExpandedGroups( + value: unknown, +): Record { + if (!value || typeof value !== "object") { + return DEFAULT_EXPANDED_GROUPS; + } + const candidate = value as Partial>; + return { + high: + typeof candidate.high === "boolean" + ? candidate.high + : DEFAULT_EXPANDED_GROUPS.high, + medium: + typeof candidate.medium === "boolean" + ? candidate.medium + : DEFAULT_EXPANDED_GROUPS.medium, + low: + typeof candidate.low === "boolean" + ? candidate.low + : DEFAULT_EXPANDED_GROUPS.low, + other: + typeof candidate.other === "boolean" + ? candidate.other + : DEFAULT_EXPANDED_GROUPS.other, + }; +} + export function CitySidebar() { const store = useDashboardStore(); const { t } = useI18n(); const selectedCity = store.selectedCity; const riskOrder = { high: 0, medium: 1, low: 2, other: 3 }; - const [expandedGroups, setExpandedGroups] = useState>({ - high: true, - medium: true, - low: false, - other: false, - }); + const [expandedGroups, setExpandedGroups] = useState< + Record + >(DEFAULT_EXPANDED_GROUPS); const sortedCities = useMemo(() => [...store.cities].sort((a, b) => { const aSelected = a.name === selectedCity; @@ -61,6 +93,26 @@ export function CitySidebar() { ); }, [selectedCity, store.cities]); + useEffect(() => { + if (typeof window === "undefined") return; + const raw = window.localStorage.getItem(GROUP_STATE_STORAGE_KEY); + if (!raw) return; + try { + const parsed = JSON.parse(raw); + setExpandedGroups(normalizeExpandedGroups(parsed)); + } catch {} + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + GROUP_STATE_STORAGE_KEY, + JSON.stringify(expandedGroups), + ); + } catch {} + }, [expandedGroups]); + const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [ { key: "high", label: t("sidebar.group.high") }, { key: "medium", label: t("sidebar.group.medium") }, diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 18c0cc24..0ac5ed1b 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -163,6 +163,9 @@ function getMarketScanCacheKey(cityName: string, targetDate?: string | null) { return `${cityName}::${normalizedDate}`; } +const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1"; +const BACKGROUND_SUMMARY_REFRESH_MS = 30_000; + export function DashboardStoreProvider({ children, }: { @@ -204,6 +207,8 @@ export function DashboardStoreProvider({ const [isGuideOpen, setIsGuideOpen] = useState(false); const mapStopMotionRef = useRef<() => void>(() => {}); + const hydratedSelectionRef = useRef(false); + const backgroundSummaryCheckAtRef = useRef>({}); const citySummariesRef = useRef>( Object.fromEntries( Object.entries(initialCache.details).map(([cityName, detail]) => [ @@ -235,10 +240,55 @@ export function DashboardStoreProvider({ citySummariesRef.current = citySummariesByName; }, [citySummariesByName]); + const scheduleBackgroundDetailRefresh = ( + cityName: string, + cached: CityDetail, + cachedMeta?: { cachedAt: number; revision: string }, + ) => { + const nowTs = Date.now(); + const lastTs = backgroundSummaryCheckAtRef.current[cityName] || 0; + if (nowTs - lastTs < BACKGROUND_SUMMARY_REFRESH_MS) { + return; + } + backgroundSummaryCheckAtRef.current[cityName] = nowTs; + + void dashboardClient + .getCitySummary(cityName) + .then(async (summary) => { + const revision = getCityRevision(summary); + if (!revision || revision === cachedMeta?.revision) { + return; + } + + const latestDetail = await dashboardClient.getCityDetail(cityName, { + force: false, + }); + const detail = mergeAiAnalysisIfStable(cached, latestDetail); + + setCityDetailsByName((current) => ({ + ...current, + [cityName]: detail, + })); + setCitySummariesByName((current) => ({ + ...current, + [cityName]: toCitySummary(detail), + })); + setCityDetailMetaByName((current) => ({ + ...current, + [cityName]: { + cachedAt: Date.now(), + revision: getCityRevision(detail), + }, + })); + }) + .catch(() => {}); + }; + const ensureCityDetail = async (cityName: string, force = false) => { const cached = cityDetailsByName[cityName]; const cachedMeta = cityDetailMetaByName[cityName]; if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) { + scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta); return cached; } @@ -387,6 +437,35 @@ export function DashboardStoreProvider({ } }; + useEffect(() => { + if (typeof window === "undefined") return; + if (selectedCity) { + window.localStorage.setItem(SELECTED_CITY_STORAGE_KEY, selectedCity); + } else { + window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY); + } + }, [selectedCity]); + + useEffect(() => { + if (hydratedSelectionRef.current) return; + if (!cities.length) return; + if (selectedCity) { + hydratedSelectionRef.current = true; + return; + } + if (typeof window === "undefined") return; + + hydratedSelectionRef.current = true; + const stored = String( + window.localStorage.getItem(SELECTED_CITY_STORAGE_KEY) || "", + ) + .trim() + .toLowerCase(); + if (!stored) return; + if (!cities.some((city) => city.name === stored)) return; + void selectCity(stored); + }, [cities, selectedCity, selectCity]); + const refreshSelectedCity = async () => { if (!selectedCity) return; setLoadingState((current) => ({ ...current, refresh: true })); diff --git a/frontend/lib/http-cache.ts b/frontend/lib/http-cache.ts new file mode 100644 index 00000000..a0fc368a --- /dev/null +++ b/frontend/lib/http-cache.ts @@ -0,0 +1,40 @@ +import { createHash } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; + +function normalizeTag(tag: string) { + return tag.trim().replace(/^W\//i, ""); +} + +function ifNoneMatchHit(ifNoneMatch: string | null, etag: string) { + if (!ifNoneMatch) return false; + const target = normalizeTag(etag); + return ifNoneMatch + .split(",") + .map((part) => normalizeTag(part)) + .some((part) => part === "*" || part === target); +} + +function buildEtag(body: string) { + const hash = createHash("sha1").update(body).digest("hex"); + return `"${hash}"`; +} + +export function buildCachedJsonResponse( + req: NextRequest, + payload: unknown, + cacheControl: string, +) { + const body = JSON.stringify(payload); + const etag = buildEtag(body); + const headers = new Headers({ + "Cache-Control": cacheControl, + ETag: etag, + "Content-Type": "application/json; charset=utf-8", + }); + + if (ifNoneMatchHit(req.headers.get("if-none-match"), etag)) { + return new NextResponse(null, { status: 304, headers }); + } + + return new NextResponse(body, { status: 200, headers }); +}