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 { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||||
|
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||||
|
|
||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(req: NextRequest) {
|
||||||
if (!API_BASE) {
|
if (!API_BASE) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||||
@@ -25,7 +26,11 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
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) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to fetch cities", detail: String(error) },
|
{ error: "Failed to fetch cities", detail: String(error) },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||||
|
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||||
|
|
||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export async function GET(
|
|||||||
|
|
||||||
const { name } = await context.params;
|
const { name } = await context.params;
|
||||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
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}`;
|
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -31,7 +33,18 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
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) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to fetch city summary", detail: String(error) },
|
{ 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 { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||||
|
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||||
|
|
||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
req: NextRequest,
|
||||||
context: { params: Promise<{ name: string }> },
|
context: { params: Promise<{ name: string }> },
|
||||||
) {
|
) {
|
||||||
if (!API_BASE) {
|
if (!API_BASE) {
|
||||||
@@ -30,7 +31,11 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
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) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to fetch history", detail: String(error) },
|
{ error: "Failed to fetch history", detail: String(error) },
|
||||||
|
|||||||
@@ -8,22 +8,54 @@ import { CityListItem } from "@/lib/dashboard-types";
|
|||||||
|
|
||||||
type RiskGroupKey = "high" | "medium" | "low" | "other";
|
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 {
|
function toRiskGroup(level?: string): RiskGroupKey {
|
||||||
if (level === "high" || level === "medium" || level === "low") return level;
|
if (level === "high" || level === "medium" || level === "low") return level;
|
||||||
return "other";
|
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() {
|
export function CitySidebar() {
|
||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const selectedCity = store.selectedCity;
|
const selectedCity = store.selectedCity;
|
||||||
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
|
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Record<RiskGroupKey, boolean>>({
|
const [expandedGroups, setExpandedGroups] = useState<
|
||||||
high: true,
|
Record<RiskGroupKey, boolean>
|
||||||
medium: true,
|
>(DEFAULT_EXPANDED_GROUPS);
|
||||||
low: false,
|
|
||||||
other: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
|
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
|
||||||
const aSelected = a.name === selectedCity;
|
const aSelected = a.name === selectedCity;
|
||||||
@@ -61,6 +93,26 @@ export function CitySidebar() {
|
|||||||
);
|
);
|
||||||
}, [selectedCity, store.cities]);
|
}, [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 }> = [
|
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
|
||||||
{ key: "high", label: t("sidebar.group.high") },
|
{ key: "high", label: t("sidebar.group.high") },
|
||||||
{ key: "medium", label: t("sidebar.group.medium") },
|
{ key: "medium", label: t("sidebar.group.medium") },
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ function getMarketScanCacheKey(cityName: string, targetDate?: string | null) {
|
|||||||
return `${cityName}::${normalizedDate}`;
|
return `${cityName}::${normalizedDate}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
|
||||||
|
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
export function DashboardStoreProvider({
|
export function DashboardStoreProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -204,6 +207,8 @@ export function DashboardStoreProvider({
|
|||||||
const [isGuideOpen, setIsGuideOpen] = useState(false);
|
const [isGuideOpen, setIsGuideOpen] = useState(false);
|
||||||
|
|
||||||
const mapStopMotionRef = useRef<() => void>(() => {});
|
const mapStopMotionRef = useRef<() => void>(() => {});
|
||||||
|
const hydratedSelectionRef = useRef(false);
|
||||||
|
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
|
||||||
const citySummariesRef = useRef<Record<string, CitySummary>>(
|
const citySummariesRef = useRef<Record<string, CitySummary>>(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(initialCache.details).map(([cityName, detail]) => [
|
Object.entries(initialCache.details).map(([cityName, detail]) => [
|
||||||
@@ -235,10 +240,55 @@ export function DashboardStoreProvider({
|
|||||||
citySummariesRef.current = citySummariesByName;
|
citySummariesRef.current = citySummariesByName;
|
||||||
}, [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 ensureCityDetail = async (cityName: string, force = false) => {
|
||||||
const cached = cityDetailsByName[cityName];
|
const cached = cityDetailsByName[cityName];
|
||||||
const cachedMeta = cityDetailMetaByName[cityName];
|
const cachedMeta = cityDetailMetaByName[cityName];
|
||||||
if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) {
|
if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) {
|
||||||
|
scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta);
|
||||||
return cached;
|
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 () => {
|
const refreshSelectedCity = async () => {
|
||||||
if (!selectedCity) return;
|
if (!selectedCity) return;
|
||||||
setLoadingState((current) => ({ ...current, refresh: true }));
|
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