Files
PolyWeather/frontend/components/dashboard/CitySidebar.tsx
T
2026-04-23 03:16:30 +08:00

379 lines
13 KiB
TypeScript

"use client";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { Clock, Search } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { CityListItem, DeviationMonitor } 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 toPerformanceGroup(city: CityListItem): RiskGroupKey {
return toRiskGroup(city.deb_recent_tier);
}
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 { locale, t } = useI18n();
const selectedCity = store.selectedCity;
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
const [expandedGroups, setExpandedGroups] = useState<
Record<RiskGroupKey, boolean>
>(DEFAULT_EXPANDED_GROUPS);
const [searchQuery, setSearchQuery] = useState("");
const cityItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const normalizedQuery = searchQuery.trim().toLowerCase();
const sortedCities = useMemo(
() =>
[...store.cities].sort((a, b) => {
const aGroup = toPerformanceGroup(a);
const bGroup = toPerformanceGroup(b);
const aHitRate = Number(a.deb_recent_hit_rate ?? -1);
const bHitRate = Number(b.deb_recent_hit_rate ?? -1);
const aSamples = Number(a.deb_recent_sample_count ?? 0);
const bSamples = Number(b.deb_recent_sample_count ?? 0);
return (
(riskOrder[aGroup] ?? 3) - (riskOrder[bGroup] ?? 3) ||
bHitRate - aHitRate ||
bSamples - aSamples ||
a.display_name.localeCompare(b.display_name)
);
}),
[store.cities],
);
const groupedCities = useMemo(() => {
const groups: Record<RiskGroupKey, CityListItem[]> = {
high: [],
medium: [],
low: [],
other: [],
};
sortedCities.forEach((city) => {
const summary = store.citySummariesByName[city.name];
const detail = store.cityDetailsByName[city.name];
const localizedName = getLocalizedCityName(
city.name,
summary?.display_name || detail?.display_name || city.display_name,
locale,
);
if (normalizedQuery) {
const searchCorpus = [
city.name,
city.display_name,
city.airport,
city.icao,
localizedName,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!searchCorpus.includes(normalizedQuery)) return;
}
groups[toPerformanceGroup(city)].push(city);
});
return groups;
}, [
locale,
normalizedQuery,
sortedCities,
store.cityDetailsByName,
store.citySummariesByName,
]);
useEffect(() => {
if (!selectedCity) return;
const selected = store.cities.find((city) => city.name === selectedCity);
if (!selected) return;
const groupKey = toPerformanceGroup(selected);
setExpandedGroups((current) =>
current[groupKey] ? current : { ...current, [groupKey]: true },
);
}, [selectedCity, store.cities]);
useEffect(() => {
if (!selectedCity) return;
const selected = store.cities.find((city) => city.name === selectedCity);
if (!selected) return;
const groupKey = toPerformanceGroup(selected);
if (!expandedGroups[groupKey]) return;
const frameId = window.requestAnimationFrame(() => {
cityItemRefs.current[selectedCity]?.scrollIntoView({
block: "center",
inline: "nearest",
behavior: "smooth",
});
});
return () => window.cancelAnimationFrame(frameId);
}, [expandedGroups, 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 formatDeviationText = (monitor?: DeviationMonitor | null) => {
if (!monitor?.available) return "";
const label = locale === "en-US" ? monitor.label_en : monitor.label_zh;
const trendLabel =
locale === "en-US" ? monitor.trend_label_en : monitor.trend_label_zh;
if (!label) return "";
return trendLabel ? `${label} · ${trendLabel}` : label;
};
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
{ key: "high", label: t("sidebar.group.high") },
{ key: "medium", label: t("sidebar.group.medium") },
{ key: "low", label: t("sidebar.group.low") },
{ key: "other", label: t("sidebar.group.other") },
];
const syncTime = useMemo(
() =>
new Intl.DateTimeFormat(locale === "en-US" ? "en-US" : "zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date()),
[locale],
);
return (
<nav className="city-list">
<div className="city-list-header">
<span>{t("sidebar.title")}</span>
<span className="city-count">{store.cities.length}</span>
</div>
<div className="city-search">
<Search size={14} strokeWidth={2} aria-hidden="true" />
<input
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={locale === "en-US" ? "Search city" : "搜索城市"}
aria-label={locale === "en-US" ? "Search city" : "搜索城市"}
/>
</div>
<div className="city-list-items">
{groupMeta.map((group) => {
const citiesInGroup = groupedCities[group.key];
if (!citiesInGroup.length) return null;
const expanded = expandedGroups[group.key];
return (
<section
key={group.key}
className={clsx("city-group", !expanded && "collapsed")}
>
<button
type="button"
className="city-group-header"
aria-expanded={expanded}
onClick={() =>
setExpandedGroups((current) => ({
...current,
[group.key]: !current[group.key],
}))
}
>
<span className="city-group-title">
<span
className={clsx("city-group-indicator", group.key)}
aria-hidden="true"
/>
{group.label}
</span>
<span className="city-group-meta">
<span className="city-group-count">
{citiesInGroup.length}
</span>
<span
className={clsx("city-group-arrow", expanded && "expanded")}
>
</span>
</span>
</button>
<div className="city-group-items">
{citiesInGroup.map((city) => {
const detail = store.cityDetailsByName[city.name];
const summary = store.citySummariesByName[city.name];
const snapshot = detail || summary;
const localizedCityName = getLocalizedCityName(
city.name,
snapshot?.display_name || city.display_name,
locale,
);
const isActive = store.selectedCity === city.name;
const tempSymbol = snapshot?.temp_symbol || "°C";
const currentTempText =
snapshot?.current?.temp != null
? t("sidebar.currentTemp", {
temp: `${snapshot.current.temp}${tempSymbol}`,
})
: t("common.na");
const deviationText = formatDeviationText(
snapshot?.deviation_monitor,
);
const peakTempText =
detail?.current?.max_so_far != null &&
detail.current.max_temp_time
? t("sidebar.peakTempAt", {
temp: `${detail.current.max_so_far}${tempSymbol}`,
time: detail.current.max_temp_time,
})
: detail?.current?.max_temp_time
? t("sidebar.peakAt", {
time: detail.current.max_temp_time,
})
: "";
const deviationDirection =
snapshot?.deviation_monitor?.direction || "normal";
const deviationSeverity =
snapshot?.deviation_monitor?.severity || "normal";
const secondaryText = deviationText || peakTempText;
const performanceTier = toPerformanceGroup(city);
return (
<button
key={city.name}
ref={(node) => {
cityItemRefs.current[city.name] = node;
}}
type="button"
className={clsx("city-item", isActive && "active")}
onClick={() =>
startTransition(() => {
void store.focusCity(city.name);
})
}
>
<div className="city-item-main">
<span className={clsx("risk-dot", performanceTier)} />
<span className="city-name-text">
{localizedCityName}
</span>
<span
className={clsx(
"city-temp",
snapshot?.current?.temp != null && "loaded",
)}
>
{currentTempText}
</span>
</div>
<div className="city-item-info">
<span className="city-local-time">
{snapshot?.local_time ? (
<>
<Clock
size={10}
strokeWidth={2}
className="city-clock-icon"
/>
{snapshot.local_time}
</>
) : (
""
)}
</span>
<span
className={clsx(
"city-max-info",
deviationText && "city-deviation-info",
deviationText &&
`city-deviation-${deviationDirection}`,
deviationText &&
deviationSeverity === "strong" &&
"strong",
)}
>
{secondaryText}
</span>
</div>
</button>
);
})}
</div>
</section>
);
})}
</div>
<div className="sidebar-footer">
<div>
{locale === "en-US" ? "Data sync" : "数据更新"} {syncTime}
</div>
<div>
{locale === "en-US"
? "Sources: METAR · Open-Meteo · DEB · Market"
: "数据来源:METAR · Open-Meteo · DEB · 市场"}
</div>
</div>
</nav>
);
}