diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 249ce039..034907f3 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -269,6 +269,7 @@ const MAX_TERMINAL_GRID_SIDE = 3; const MAX_TERMINAL_CHARTS = 9; const MOBILE_TERMINAL_CHARTS = 1; const DEFAULT_TERMINAL_GRID_SIDE = 2; +const TERMINAL_SLOTS_STORAGE_KEY = "polyweather_terminal_slots"; function clampGridSide(value: number) { if (!Number.isFinite(value)) return DEFAULT_TERMINAL_GRID_SIDE; @@ -295,6 +296,45 @@ function normalizeSlotList(slots: Array, totalSlots: number) { return [...slots, ...Array(totalSlots - slots.length).fill(null)]; } +function readStoredTerminalSlots(totalSlots = MAX_TERMINAL_CHARTS) { + const emptySlots = Array(totalSlots).fill(null) as Array; + if (typeof window === "undefined") { + return { slots: emptySlots, hasPersistedSlots: false }; + } + try { + const stored = localStorage.getItem(TERMINAL_SLOTS_STORAGE_KEY); + if (stored === null) return { slots: emptySlots, hasPersistedSlots: false }; + const parsed = JSON.parse(stored); + if (!Array.isArray(parsed)) return { slots: emptySlots, hasPersistedSlots: true }; + const normalized = parsed.map((value) => { + const text = String(value || "").toLowerCase().trim(); + return text || null; + }); + return { + slots: normalizeSlotList(normalized, totalSlots), + hasPersistedSlots: true, + }; + } catch { + return { slots: emptySlots, hasPersistedSlots: false }; + } +} + +function shouldAutofillInitialSlots({ + filteredRegionRows, + hasPersistedSlots, + slots, +}: { + filteredRegionRows: ScanOpportunityRow[]; + hasPersistedSlots: boolean; + slots: Array; +}) { + return ( + !hasPersistedSlots && + filteredRegionRows.length > 0 && + slots.every((slot) => slot === null) + ); +} + function t(key: keyof typeof TERM, isEn: boolean) { return isEn ? TERM[key].en : TERM[key].zh; } @@ -662,19 +702,11 @@ function PolyWeatherTerminal({ const totalSlots = getSlotCount(gridCols, gridRows); + const hasPersistedSlots = useRef(false); const [slots, setSlots] = useState>(() => { - if (typeof window !== "undefined") { - try { - const stored = localStorage.getItem("polyweather_terminal_slots"); - if (stored) { - const parsed = JSON.parse(stored); - if (Array.isArray(parsed)) { - return normalizeSlotList(parsed, MAX_TERMINAL_CHARTS); - } - } - } catch {} - } - return Array(MAX_TERMINAL_CHARTS).fill(null); + const stored = readStoredTerminalSlots(); + hasPersistedSlots.current = stored.hasPersistedSlots; + return stored.slots; }); const [activeSlotIndex, setActiveSlotIndex] = useState(0); const [maximizedSlotIndex, setMaximizedSlotIndex] = useState(null); @@ -758,13 +790,20 @@ function PolyWeatherTerminal({ }, [rows, selectedRegionKey]); useEffect(() => { - if (filteredRegionRows.length && slots.every((s) => s === null)) { + if ( + shouldAutofillInitialSlots({ + filteredRegionRows, + hasPersistedSlots: hasPersistedSlots.current, + slots, + }) + ) { const next = Array(MAX_TERMINAL_CHARTS) .fill(null) .map((_, idx) => filteredRegionRows[idx]?.city || null); setSlots(next); + hasPersistedSlots.current = true; try { - localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next)); + localStorage.setItem(TERMINAL_SLOTS_STORAGE_KEY, JSON.stringify(next)); } catch {} } }, [filteredRegionRows, slots]); @@ -774,8 +813,9 @@ function PolyWeatherTerminal({ const next = [...slots]; next[index] = city; setSlots(next); + hasPersistedSlots.current = true; try { - localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next)); + localStorage.setItem(TERMINAL_SLOTS_STORAGE_KEY, JSON.stringify(next)); } catch {} }; @@ -1008,7 +1048,6 @@ function PolyWeatherTerminal({ }} onReportIssue={openChartFeedback} isMaximized={true} - disableClose={visibleSlots.filter(Boolean).length <= 1} /> {activeSearchSlotIndex === maximizedSlotIndex && ( @@ -1097,7 +1136,6 @@ function PolyWeatherTerminal({ }} onReportIssue={openChartFeedback} isMaximized={false} - disableClose={visibleSlots.filter(Boolean).length <= 1} /> {activeSearchSlotIndex === slotIndex && ( diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts index 1756c546..3a3bfce9 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts @@ -57,6 +57,16 @@ export function runTests() { dashboardSource.includes("disableClose={true}"), "mobile terminal should render one selected chart and suggest landscape for the full grid", ); + assert( + !dashboardSource.includes("disableClose={visibleSlots.filter(Boolean).length <= 1}"), + "desktop chart slots must allow clearing the last visible city so users can close a 1x1 chart", + ); + assert( + dashboardSource.includes("readStoredTerminalSlots") && + dashboardSource.includes("hasPersistedSlots") && + dashboardSource.includes("shouldAutofillInitialSlots"), + "terminal slots must distinguish first-load default fill from a user-cleared all-empty layout", + ); assert( selectorSource.includes("[1, 2, 3].map") && selectorSource.includes("grid grid-cols-3"),