feat: Implement HTTP caching for API routes and add a dashboard sidebar with city grouping and state persistence.
This commit is contained in:
@@ -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) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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<RiskGroupKey, boolean> = {
|
||||
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<RiskGroupKey, boolean> {
|
||||
if (!value || typeof value !== "object") {
|
||||
return DEFAULT_EXPANDED_GROUPS;
|
||||
}
|
||||
const candidate = value as Partial<Record<RiskGroupKey, unknown>>;
|
||||
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<Record<RiskGroupKey, boolean>>({
|
||||
high: true,
|
||||
medium: true,
|
||||
low: false,
|
||||
other: false,
|
||||
});
|
||||
const [expandedGroups, setExpandedGroups] = useState<
|
||||
Record<RiskGroupKey, boolean>
|
||||
>(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") },
|
||||
|
||||
@@ -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<Record<string, number>>({});
|
||||
const citySummariesRef = useRef<Record<string, CitySummary>>(
|
||||
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 }));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user