"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 = { 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 { 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 { locale, t } = useI18n(); const selectedCity = store.selectedCity; const riskOrder = { high: 0, medium: 1, low: 2, other: 3 }; const [expandedGroups, setExpandedGroups] = useState< Record >(DEFAULT_EXPANDED_GROUPS); const [searchQuery, setSearchQuery] = useState(""); const cityItemRefs = useRef>({}); 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 = { 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 ( ); }