diff --git a/frontend/app/favicon-16x16.png b/frontend/app/favicon-16x16.png deleted file mode 100644 index 6658c578..00000000 Binary files a/frontend/app/favicon-16x16.png and /dev/null differ diff --git a/frontend/app/favicon-32x32.png b/frontend/app/favicon-32x32.png deleted file mode 100644 index 67e039f8..00000000 Binary files a/frontend/app/favicon-32x32.png and /dev/null differ diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico deleted file mode 100644 index be9ce9db..00000000 Binary files a/frontend/app/favicon.ico and /dev/null differ diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 7d2a9bf3..9daf5448 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -20,7 +20,7 @@ const jetbrainsMono = JetBrains_Mono({ export const metadata: Metadata = { title: "PolyWeather | Weather Intelligence", description: - "PolyWeather pro dashboard with global weather risk map and city analytics.", + "PolyWeather paid professional terminal for weather-market intelligence, city decision cards, and subscription-only analytics.", manifest: "/site.webmanifest", icons: { icon: [ diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 40bd0dec..af7fd8f8 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,11 +1,11 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { DashboardEntry } from "@/components/dashboard/DashboardEntry"; +import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage"; export const metadata: Metadata = { - title: "PolyWeather - Global Weather Intelligence Map", + title: "PolyWeather | Institutional Weather Market Intelligence", description: - "PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and AI weather decision cards.", + "PolyWeather is a paid professional weather-market intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.", }; export default async function HomePage({ @@ -29,5 +29,5 @@ export default async function HomePage({ const qs = usp.toString(); redirect(`/auth/callback${qs ? `?${qs}` : ""}`); } - return ; + return ; } diff --git a/frontend/app/terminal/page.tsx b/frontend/app/terminal/page.tsx new file mode 100644 index 00000000..2ace10f9 --- /dev/null +++ b/frontend/app/terminal/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard"; + +export const metadata: Metadata = { + title: "PolyWeather Terminal | Paid Product", + description: + "Paid PolyWeather decision terminal for weather-market analysis and city decision cards.", +}; + +export default function TerminalPage() { + return ; +} diff --git a/frontend/components/account/account-copy.ts b/frontend/components/account/account-copy.ts index 021f9a9e..a6a1c01d 100644 --- a/frontend/components/account/account-copy.ts +++ b/frontend/components/account/account-copy.ts @@ -7,7 +7,7 @@ export function createAccountCopy(isEn: boolean): Record { signOut: isEn ? "Sign Out" : "退出", signIn: isEn ? "Sign In" : "登录", upgradePro: isEn ? "Upgrade Pro" : "升级 Pro", - guestUser: isEn ? "Guest User" : "游客用户", + guestUser: isEn ? "Signed-out account" : "未登录账户", joinedAt: isEn ? "Joined" : "加入时间", totalPoints: isEn ? "Total Points" : "总积分 (荣誉)", weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)", @@ -129,7 +129,7 @@ export function createAccountCopy(isEn: boolean): Record { ? "Wallet bound. Creating order and sending payment..." : "钱包已绑定,正在创建订单并发起支付...", proMember: "PRO MEMBER", - freeTier: "FREE TIER", + freeTier: isEn ? "UNSUBSCRIBED" : "未订阅", proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)", noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅", proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期", diff --git a/frontend/components/dashboard/RegisterSW.tsx b/frontend/components/dashboard/RegisterSW.tsx index 70dda4fd..dffad22c 100644 --- a/frontend/components/dashboard/RegisterSW.tsx +++ b/frontend/components/dashboard/RegisterSW.tsx @@ -4,7 +4,19 @@ import { useEffect } from "react"; export function RegisterSW() { useEffect(() => { - if ("serviceWorker" in navigator) { + if (process.env.NODE_ENV !== "production") { + if (typeof navigator !== "undefined" && "serviceWorker" in navigator) { + navigator.serviceWorker + .getRegistrations() + .then((registrations) => + Promise.all(registrations.map((registration) => registration.unregister())), + ) + .catch(() => {}); + } + return; + } + + if (typeof navigator !== "undefined" && "serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => {}); } }, []); diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 63aef76b..92e6834d 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -1,36 +1,28 @@ "use client"; import clsx from "clsx"; -import dynamic from "next/dynamic"; -import { RefreshCw } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import styles from "./Dashboard.module.css"; -import { scanRootClass } from "./scan-root-styles"; +import Link from "next/link"; import { - DashboardStoreProvider, - useDashboardStore, - useProAccess, -} from "@/hooks/useDashboardStore"; -import { I18nProvider, useI18n } from "@/hooks/useI18n"; -import type { ScanOpportunityRow } from "@/lib/dashboard-types"; -import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView"; -import { MobileCityPicker } from "@/components/dashboard/scan-terminal/MobileCityPicker"; -import { WelcomeOverlay } from "@/components/dashboard/scan-terminal/WelcomeOverlay"; -import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; -import { - ScanPaywallModal, - ScanTerminalLoadingScreen, - ScanTerminalTopBar, - type ScanTerminalContentView, -} from "@/components/dashboard/scan-terminal/ScanTerminalShellParts"; -import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils"; -import { - findRowForCity, - normalizeCityKey, - rowMatchesCity, - sortRowsByUserTime, -} from "@/components/dashboard/scan-terminal/decision-utils"; -import { useAiPinnedCityWorkspace } from "@/components/dashboard/scan-terminal/use-ai-pinned-city-workspace"; + Activity, + BarChart3, + Bell, + CloudSun, + CreditCard, + Gauge, + LineChart, + LockKeyhole, + LogIn, + Menu, + RefreshCw, + Search, + Table2, + UserRound, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; +import { getInitialLocaleFromNavigator } from "@/lib/i18n"; +import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; +import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils"; import { useScanTerminalQuery } from "@/components/dashboard/scan-terminal/use-scan-terminal-query"; import { useScanTerminalTheme, @@ -38,587 +30,735 @@ import { } from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state"; import { useRelativeTime } from "@/hooks/useRelativeTime"; -const CityDetailPanel = dynamic( - () => - import("@/components/dashboard/DetailPanel").then( - (module) => module.DetailPanel, - ), - { ssr: false }, -); +function createEmptyAccess(loading = true): ProAccessState { + return { + loading, + authenticated: false, + userId: null, + subscriptionActive: false, + subscriptionPlanCode: null, + subscriptionExpiresAt: null, + subscriptionTotalExpiresAt: null, + subscriptionQueuedDays: 0, + points: 0, + error: null, + }; +} -const FutureForecastModal = dynamic( - () => - import("@/components/dashboard/FutureForecastModal").then( - (module) => module.FutureForecastModal, - ), - { ssr: false }, -); +function createLocalAccess(): ProAccessState { + return { + loading: false, + authenticated: true, + userId: "local-dev", + subscriptionActive: true, + subscriptionPlanCode: "local-full-access", + subscriptionExpiresAt: "2099-12-31T23:59:59Z", + subscriptionTotalExpiresAt: "2099-12-31T23:59:59Z", + subscriptionQueuedDays: 0, + points: 999_999, + error: null, + }; +} -const MapCanvas = dynamic( - () => - import("@/components/dashboard/MapCanvas").then( - (module) => module.MapCanvas, - ), - { ssr: false }, -); +function rowName(row?: ScanOpportunityRow | null) { + return row?.city_display_name || row?.display_name || row?.city || "--"; +} + +function pct(value: unknown, digits = 1) { + const n = Number(value); + if (!Number.isFinite(n)) return "--"; + return `${(Math.abs(n) <= 1 ? n * 100 : n).toFixed(digits)}%`; +} + +function money(value: unknown) { + const n = Number(value); + if (!Number.isFinite(n)) return "--"; + return `$${Math.round(n).toLocaleString()}`; +} + +function temp(value: unknown, unit?: string | null) { + const n = Number(value); + if (!Number.isFinite(n)) return "--"; + return `${n.toFixed(1)}${unit || "°"}`; +} + +function edgeClass(value: unknown) { + const n = Number(value); + if (!Number.isFinite(n) || n === 0) return "text-slate-500"; + return n > 0 ? "text-emerald-700" : "text-red-600"; +} + +function decisionLabel(row?: ScanOpportunityRow | null) { + const raw = + row?.ai_decision || + row?.v4_metar_decision || + row?.action || + row?.signal_status || + ""; + const value = String(raw || "").toLowerCase(); + if (value.includes("approve")) return "Approve"; + if (value.includes("veto")) return "Veto"; + if (value.includes("watch")) return "Watch"; + if (value.includes("downgrade")) return "Downgrade"; + if (row?.tradable) return "Tradable"; + return "Monitor"; +} + +function decisionClass(label: string) { + if (label === "Approve" || label === "Tradable") { + return "border-emerald-200 bg-emerald-50 text-emerald-700"; + } + if (label === "Veto" || label === "Downgrade") { + return "border-red-200 bg-red-50 text-red-700"; + } + return "border-amber-200 bg-amber-50 text-amber-700"; +} + +function MiniSparkline({ tone = "blue" }: { tone?: "blue" | "emerald" | "red" }) { + const stroke = + tone === "emerald" ? "#059669" : tone === "red" ? "#dc2626" : "#2563eb"; + return ( + + ); +} + +function Panel({ + children, + className, + title, +}: { + children: React.ReactNode; + className?: string; + title: string; +}) { + return ( +
+
+

+ {title} +

+ + ↗ + +
+ {children} +
+ ); +} + +function MarketTable({ + onSelect, + rows, + selectedId, +}: { + onSelect: (row: ScanOpportunityRow) => void; + rows: ScanOpportunityRow[]; + selectedId?: string | null; +}) { + return ( +
+ + + + + + + + + + + + + + {rows.map((row) => { + const label = decisionLabel(row); + return ( + onSelect(row)} + > + + + + + + + + + ); + })} + +
City / ContractLiveDEBMktEdgeLiqSignal
+
{rowName(row)}
+
+ {row.target_label || row.market_question || row.airport || "--"} +
+
+ {temp(row.current_max_so_far ?? row.current_temp, row.temp_symbol)} + + {temp(row.deb_prediction, row.temp_symbol)} + + {pct(row.market_probability ?? row.market_event_probability)} + + {pct(row.edge_percent ?? row.signed_gap ?? row.gap)} + + {money(row.book_liquidity ?? row.market_liquidity ?? row.volume)} + + + {label} + +
+
+ ); +} + +function KoyfinWeatherTerminal({ + generatedText, + isEn, + onRefresh, + refreshing, + rows, + selectedRow, + setSelectedRow, + userLocalTime, +}: { + generatedText: string; + isEn: boolean; + onRefresh: () => void; + refreshing: boolean; + rows: ScanOpportunityRow[]; + selectedRow: ScanOpportunityRow | null; + setSelectedRow: (row: ScanOpportunityRow) => void; + userLocalTime: string; +}) { + const topRows = rows.slice(0, 18); + const approveRows = rows + .filter((row) => ["Approve", "Tradable"].includes(decisionLabel(row))) + .slice(0, 8); + const watchRows = rows + .filter((row) => decisionLabel(row) === "Watch" || !row.tradable) + .slice(0, 8); + const selectedLabel = decisionLabel(selectedRow); + const avgEdge = + rows.reduce((sum, row) => sum + Number(row.edge_percent || 0), 0) / + Math.max(rows.length, 1); + const totalLiquidity = rows.reduce( + (sum, row) => + sum + Number(row.book_liquidity || row.market_liquidity || row.volume || 0), + 0, + ); + + return ( +
+ + +
+
+
+
+ + + Search city, contract, station, or signal + + + / + +
+
+ + {isEn ? "Weather Market Dashboard" : "天气市场数据仪表盘"} +
+
+
+ {userLocalTime} + + + + +
+
+ +
+
+
+ +
+
+
+ Rows +
+
{rows.length}
+
+
+
+ Avg Edge +
+
+ {pct(avgEdge)} +
+
+
+
+ Liquidity +
+
+ {money(totalLiquidity)} +
+
+
+ +
+ + +
+ {(approveRows.length ? approveRows : topRows.slice(0, 5)).map((row) => ( + + ))} +
+
+
+ +
+ +
+
+
+

+ {rowName(selectedRow)} +

+ + {selectedLabel} + +
+

+ {selectedRow?.ai_city_thesis_en || + selectedRow?.ai_reason_en || + selectedRow?.market_question || + "Select a weather contract to inspect model edge, market price, and live evidence."} +

+
+ {[ + ["Live", temp(selectedRow?.current_max_so_far ?? selectedRow?.current_temp, selectedRow?.temp_symbol)], + ["DEB", temp(selectedRow?.deb_prediction, selectedRow?.temp_symbol)], + ["Model", pct(selectedRow?.model_probability ?? selectedRow?.model_event_probability)], + ["Market", pct(selectedRow?.market_probability ?? selectedRow?.market_event_probability)], + ].map(([label, value]) => ( +
+
+ {label} +
+
{value}
+
+ ))} +
+
+
+
+ Intraday Performance +
+ = 0 ? "emerald" : "red"} + /> +
+ + Edge {pct(selectedRow?.edge_percent)} + + + Spread {pct(selectedRow?.spread)} + +
+
+
+
+ + +
+ {[ + ["Model Probability", selectedRow?.model_probability ?? selectedRow?.model_event_probability, "blue"], + ["Market Probability", selectedRow?.market_probability ?? selectedRow?.market_event_probability, "emerald"], + ["Peak Probability", selectedRow?.peak_probability, "red"], + ].map(([label, value, tone]) => ( +
+
+ {label} + {pct(value)} +
+ +
+ ))} +
+
+ + + + +
+ +
+ +
+ {(watchRows.length ? watchRows : topRows.slice(0, 8)).map((row) => ( + + ))} +
+
+ + +
+ {[ + ["Heat", rows.filter((r) => r.risk_level === "high").length], + ["Active", rows.filter((r) => r.active).length], + ["Tradable", rows.filter((r) => r.tradable).length], + ["Primary", rows.filter((r) => r.is_primary_signal).length], + ["AI", rows.filter((r) => r.ai_decision).length], + ["Closed", rows.filter((r) => r.closed).length], + ].map(([label, value]) => ( +
+
{value}
+
+ {label} +
+
+ ))} +
+
+ + +
+
+ Data + + {generatedText || "live"} + +
+
+ Access + paid +
+
+ Layout + Koyfin-style grid +
+
+
+
+
+
+
+
+ ); +} + +function ProductAccessRequired({ + isAuthenticated, + isEn, + userLocalTime, +}: { + isAuthenticated: boolean; + isEn: boolean; + userLocalTime: string; +}) { + return ( +
+
+ ); +} function ScanTerminalScreen() { - const store = useDashboardStore(); - const { proAccess } = useProAccess(); - const { locale, toggleLocale } = useI18n(); - const isEn = locale === "en-US"; - const isPro = proAccess.subscriptionActive; - const accountHref = proAccess.authenticated - ? "/account" - : "/auth/login?next=%2Faccount"; - const { refreshScanTerminalManually, scanError, scanLoading, terminalData } = - useScanTerminalQuery({ - isPro, - proAccessLoading: proAccess.loading, - }); - const [selectedRowId, setSelectedRowId] = useState(null); - const [activeView, setActiveView] = useState("map"); - const [mapSelectedCityName, setMapSelectedCityName] = useState( - null, + const [proAccess, setProAccess] = useState(() => + createEmptyAccess(true), ); - const [showScanPaywall, setShowScanPaywall] = useState(false); - const [isMobileViewport, setIsMobileViewport] = useState(false); + const [locale, setLocale] = useState<"zh-CN" | "en-US">("zh-CN"); + const isEn = locale === "en-US"; + const [hydrated, setHydrated] = useState(false); + const [localFullAccess, setLocalFullAccess] = useState(false); + const canUseLocalFullAccess = hydrated && localFullAccess; + const isAuthenticated = + hydrated && (proAccess.authenticated || canUseLocalFullAccess); + const isPro = + hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess); + const userLocalTime = useUserLocalClock(); + useScanTerminalTheme(); useEffect(() => { - if (typeof window === "undefined" || !window.matchMedia) return; - const media = window.matchMedia("(max-width: 768px)"); - const syncMobileViewport = () => { - setIsMobileViewport(media.matches); - if (media.matches) { - setActiveView((current) => (current === "map" ? "city-list" : current)); - } else { - setActiveView((current) => (current === "city-list" ? "map" : current)); - } + let cancelled = false; + setHydrated(true); + setLocale(getInitialLocaleFromNavigator()); + const localAccess = isBrowserLocalFullAccess(); + setLocalFullAccess(localAccess); + if (localAccess) { + setProAccess(createLocalAccess()); + return () => { + cancelled = true; + }; + } + if (typeof fetch !== "function") { + setProAccess(createEmptyAccess(false)); + return () => { + cancelled = true; + }; + } + fetch("/api/auth/me", { + cache: "no-store", + headers: { Accept: "application/json" }, + }) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise<{ + authenticated?: boolean; + user_id?: string | null; + subscription_active?: boolean | null; + subscription_plan_code?: string | null; + subscription_expires_at?: string | null; + subscription_total_expires_at?: string | null; + subscription_queued_days?: number | null; + points?: number | null; + }>; + }) + .then((payload) => { + if (cancelled) return; + setProAccess({ + loading: false, + authenticated: Boolean(payload.authenticated), + userId: payload.user_id ?? null, + subscriptionActive: payload.subscription_active === true, + subscriptionPlanCode: payload.subscription_plan_code ?? null, + subscriptionExpiresAt: payload.subscription_expires_at ?? null, + subscriptionTotalExpiresAt: + payload.subscription_total_expires_at ?? + payload.subscription_expires_at ?? + null, + subscriptionQueuedDays: Math.max( + 0, + Number(payload.subscription_queued_days ?? 0), + ), + points: Number(payload.points ?? 0), + error: null, + }); + }) + .catch((error) => { + if (cancelled) return; + setProAccess({ + ...createEmptyAccess(false), + error: String(error), + }); + }); + return () => { + cancelled = true; }; - syncMobileViewport(); - media.addEventListener("change", syncMobileViewport); - return () => media.removeEventListener("change", syncMobileViewport); }, []); - const userLocalTime = useUserLocalClock(); - const { setThemeMode, themeMode } = useScanTerminalTheme(); - const lastMapSelectedCityRef = useRef(""); - const lastFetchedAtRef = useRef(0); - const serverAgeText = useRelativeTime(terminalData?.generated_at ?? null); - const localAgeText = useRelativeTime( - lastFetchedAtRef.current - ? new Date(lastFetchedAtRef.current).toISOString() - : null, - ); - - useEffect(() => { - if (terminalData?.generated_at) { - lastFetchedAtRef.current = Date.now(); - } - }, [terminalData?.generated_at]); - - useEffect(() => { - if (activeView === "map") { - const timer = setTimeout(() => { - window.dispatchEvent(new Event("resize")); - }, 150); - return () => clearTimeout(timer); - } - }, [activeView]); - - const scanTerminalRootClassName = clsx( - styles.root, - scanRootClass, - themeMode === "light" && "light", - ); - - const timeSortedRows = useMemo( + const { refreshScanTerminalManually, scanLoading, terminalData } = + useScanTerminalQuery({ + isPro, + proAccessLoading: !hydrated || (proAccess.loading && !canUseLocalFullAccess), + }); + const rows = useMemo( () => sortRowsByUserTime(terminalData?.rows || []), [terminalData?.rows], ); - - const cityListRows = useMemo( - () => - store.cities.map((city, index) => { - const cityKey = normalizeCityKey(city.name); - const summary = - store.citySummariesByName[cityKey] ?? - Object.values(store.citySummariesByName).find( - (s) => normalizeCityKey(s?.name) === cityKey, - ) ?? - null; - return { - id: `city-fallback:${cityKey}:${index}`, - city: cityKey, - city_display_name: city.display_name || city.name, - display_name: city.display_name || city.name, - temp_symbol: city.temp_unit === "fahrenheit" ? "°F" : "°C", - current_temp: summary?.current?.temp ?? null, - current_max_so_far: summary?.current?.temp ?? null, - deb_prediction: summary?.deb?.prediction ?? null, - airport: city.airport || null, - local_time: summary?.local_time ?? null, - risk_level: city.risk_level || "low", - market_slug: null, - market_question: null, - target_label: null, - side: null, - edge_percent: null, - final_score: null, - window_phase: null, - tradable: false, - active: false, - closed: false, - accepting_orders: false, - } satisfies ScanOpportunityRow; - }), - [store.cities, store.citySummariesByName], + const [selectedId, setSelectedId] = useState(null); + const selectedRow = useMemo( + () => rows.find((row) => row.id === selectedId) || rows[0] || null, + [rows, selectedId], ); - const { - addAiPinnedCity, - aiPinnedCities, - refreshAiPinnedCityDetail, - removeAiPinnedCity, - } = useAiPinnedCityWorkspace({ - locale, - store, - timeSortedRows, - }); - const selectedRow = useMemo(() => { - if (!timeSortedRows.length) return null; + const generatedText = useRelativeTime(terminalData?.generated_at ?? null); + + if (!hydrated || (proAccess.loading && !canUseLocalFullAccess)) { return ( - timeSortedRows.find((row) => row.id === selectedRowId) || - timeSortedRows[0] || - null - ); - }, [timeSortedRows, selectedRowId]); - - useEffect(() => { - if (!timeSortedRows.length) return; - if (selectedRowId && timeSortedRows.some((row) => row.id === selectedRowId)) - return; - setSelectedRowId(timeSortedRows[0].id); - }, [selectedRowId, timeSortedRows]); - - const mapFocusedRow = useMemo(() => { - return findRowForCity( - timeSortedRows, - mapSelectedCityName || store.selectedCity, - ); - }, [mapSelectedCityName, store.selectedCity, timeSortedRows]); - const mapFallbackRow = useMemo(() => { - const rawCityName = mapSelectedCityName || store.selectedCity; - const cityKey = normalizeCityKey(rawCityName); - if (!cityKey || mapFocusedRow) return null; - const selectedDetail = - store.selectedDetail && - normalizeCityKey(store.selectedDetail.name) === cityKey - ? store.selectedDetail - : Object.values(store.cityDetailsByName).find( - (detail) => normalizeCityKey(detail?.name) === cityKey, - ) || null; - const selectedSummary = - Object.values(store.citySummariesByName).find( - (summary) => normalizeCityKey(summary?.name) === cityKey, - ) || null; - const selectedCityItem = - store.cities.find( - (city) => - normalizeCityKey(city.name) === cityKey || - normalizeCityKey(city.display_name) === cityKey, - ) || null; - const canonicalCity = - selectedDetail?.name || - selectedSummary?.name || - selectedCityItem?.name || - String(rawCityName || "").trim(); - if (!canonicalCity) return null; - - const tempSymbol = - selectedDetail?.temp_symbol || - selectedSummary?.temp_symbol || - (selectedCityItem?.temp_unit === "fahrenheit" ? "°F" : "°C"); - const displayName = - selectedDetail?.display_name || - selectedSummary?.display_name || - selectedCityItem?.display_name || - canonicalCity; - const currentTemp = - selectedDetail?.current?.temp ?? selectedSummary?.current?.temp ?? null; - - return { - id: `map-city:${canonicalCity}`, - city: canonicalCity, - city_display_name: displayName, - display_name: displayName, - selected_date: selectedDetail?.local_date || null, - local_date: selectedDetail?.local_date || null, - local_time: - selectedDetail?.local_time || selectedSummary?.local_time || null, - temp_symbol: tempSymbol, - current_temp: currentTemp, - current_max_so_far: - selectedDetail?.current?.max_so_far ?? currentTemp ?? null, - deb_prediction: - selectedDetail?.deb?.prediction ?? - selectedSummary?.deb?.prediction ?? - null, - airport: - selectedDetail?.risk?.airport || - selectedCityItem?.airport || - selectedCityItem?.settlement_station_label || - null, - risk_level: - selectedDetail?.risk?.level || - selectedSummary?.risk?.level || - selectedCityItem?.risk_level || - "low", - market_slug: null, - market_question: isEn ? "City briefing" : "城市简报", - target_label: isEn ? "City snapshot" : "城市概况", - side: null, - edge_percent: null, - final_score: null, - window_phase: "city_snapshot", - tradable: false, - active: false, - closed: false, - accepting_orders: false, - } satisfies ScanOpportunityRow; - }, [ - isEn, - mapFocusedRow, - mapSelectedCityName, - store.cityDetailsByName, - store.citySummariesByName, - store.cities, - store.selectedCity, - store.selectedDetail, - ]); - - const resolvedView: ScanTerminalContentView = activeView; - const mapFocusedCity = mapSelectedCityName || store.selectedCity; - const activeDetailRow = - resolvedView === "map" && mapFocusedCity - ? mapFocusedRow || mapFallbackRow - : selectedRow; - const scanStatus = terminalData?.status || "ready"; - const staleReason = terminalData?.stale_reason || null; - const proPreviewItems = isEn - ? [ - "Real-time METAR & official station data for 52 cities", - "Multi-model DEB blend vs market-implied temperature", - "Probability buckets mapped to Polymarket contracts", - "Live observation deviation & mispricing signals", - "City decision cards for current & future settlement dates", - ] - : [ - "52 城实时机场报文与官方观测站数据", - "多模型 DEB 融合预测 vs 市场隐含温度", - "概率分布桶对照 Polymarket 合约", - "实时观测偏差与错价信号", - "当前日与未来结算日的城市决策卡", - "未来日期城市决策卡", - ]; - - useEffect(() => { - if (!activeDetailRow) return; - if (!findDetailForCity(store.cityDetailsByName, activeDetailRow.city)) { - void store - .ensureCityDetail(activeDetailRow.city, false, "panel") - .catch(() => {}); - } - }, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]); - - const handleMapCitySelect = useCallback( - (cityName: string) => { - setMapSelectedCityName(cityName); - lastMapSelectedCityRef.current = normalizeCityKey(cityName); - const matchedRow = findRowForCity(timeSortedRows, cityName); - if (matchedRow) { - store.preloadCityFromRow(matchedRow); - setSelectedRowId(matchedRow.id); - } else { - void store.ensureCityDetail(cityName, false, "panel").catch(() => {}); - setSelectedRowId(null); - } - void store.selectCity(cityName); - addAiPinnedCity(cityName); - setActiveView("analysis"); - }, - [store, timeSortedRows, addAiPinnedCity], - ); - - useEffect(() => { - if (activeView !== "map") return; - const selectedCity = String(store.selectedCity || "").trim(); - const selectedKey = normalizeCityKey(selectedCity); - if (!selectedKey || selectedKey === lastMapSelectedCityRef.current) return; - lastMapSelectedCityRef.current = selectedKey; - setMapSelectedCityName(selectedCity); - const matchedRow = findRowForCity(timeSortedRows, selectedCity); - setSelectedRowId(matchedRow?.id || null); - addAiPinnedCity(selectedCity); - }, [activeView, addAiPinnedCity, store.selectedCity, timeSortedRows]); - - const handleSelectRow = useCallback( - (row: ScanOpportunityRow) => { - const cityName = - row.city || row.city_display_name || row.display_name || ""; - if (!cityName) return; - setSelectedRowId(row.id); - store.preloadCityFromRow(row); - const selectedCityKey = normalizeCityKey(store.selectedCity); - const rowCityKey = normalizeCityKey(cityName); - const hasCachedDetail = - Boolean(findDetailForCity(store.cityDetailsByName, cityName)) || - Object.values(store.cityDetailsByName).some((detail) => - rowMatchesCity(row, detail?.name || detail?.display_name || ""), - ); - if (store.isPanelOpen && selectedCityKey === rowCityKey) { - if (!hasCachedDetail) { - void store.ensureCityDetail(cityName, false, "panel").catch(() => {}); - } - return; - } - void store.selectCity(cityName); - }, - [store], - ); - - const handleOpenDecisionRow = useCallback( - (row: ScanOpportunityRow) => { - const cityName = - row.city || row.city_display_name || row.display_name || ""; - if (!cityName) return; - setSelectedRowId(row.id); - store.preloadCityFromRow(row); - addAiPinnedCity(cityName); - setActiveView("analysis"); - void store.selectCity(cityName); - }, - [addAiPinnedCity, store], - ); - - const openScanPaywall = useCallback(() => { - setShowScanPaywall(true); - }, []); - - const renderMainView = () => { - if (resolvedView === "city-list") { - return ( - - ); - } - // Keep MapCanvas always mounted — hiding with CSS avoids Leaflet - // reinitialization that causes a white background on tab switches. - // The analysis view overlays on top when active. - return ( - <> -
-
- -
+
+
+ Loading paid terminal...
- {resolvedView === "analysis" ? ( - isPro ? ( - - ) : ( -
- -
- ) - ) : null} - +
); - }; + } - if (proAccess.loading) { + if (!isAuthenticated || !isPro) { return ( - ); } return ( -
-
-
- - - - -
-
-
- {isMobileViewport ? ( - - ) : ( - - )} - -
-
- {terminalData?.generated_at ? ( - - {isEn ? "Updated" : "已更新"} {serverAgeText || ""} - - ) : null} - {terminalData?.stale && localAgeText ? ( - - {isEn ? "Local fetch " : "本地下发 "} - {localAgeText} - - ) : null} - {isPro ? ( - - ) : null} -
-
- - {scanStatus === "failed" && !terminalData ? ( -
-
- {isEn ? "Scan failed" : "扫描失败"} -
-
{staleReason}
- -
- ) : ( - renderMainView() - )} -
-
- - -
- {}} /> - - {showScanPaywall ? ( - setShowScanPaywall(false)} - /> - ) : null} -
+ setSelectedId(row.id)} + userLocalTime={userLocalTime} + /> ); } export function ScanTerminalDashboard() { - return ( - - - - - - ); + return ; } diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index 56f6f874..bcc84baf 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -102,12 +102,12 @@ export function AiPinnedForecastView({
- {isEn ? "Click a city on the map" : "从分布视图点击城市"} + {isEn ? "Select a city from the market list" : "从市场列表选择城市"}
{isEn ? "Selected cities will appear here as deep analysis blocks." - : "被点击的城市会加入深度分析页,并保留为城市分析区块。"} + : "选中的城市会加入深度分析页,并保留为城市分析区块。"}
@@ -127,8 +127,8 @@ export function AiPinnedForecastView({

{isEn - ? "Map clicks add cities here. City analysis stays here until you remove it." - : "地图点击会把城市加入这里;城市分析会保留,直到你手动移除。"} + ? "Market-list selections add cities here. City analysis stays here until you remove it." + : "市场列表选择会把城市加入这里;城市分析会保留,直到你手动移除。"}

diff --git a/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx b/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx index 52e18c98..a693c809 100644 --- a/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx +++ b/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx @@ -3,12 +3,12 @@ import clsx from "clsx"; import Link from "next/link"; import type { Dispatch, SetStateAction } from "react"; -import { LogIn, MessageCircle, Moon, Sun, UserRound } from "lucide-react"; +import { LogIn, UserRound } from "lucide-react"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal"; import type { Locale } from "@/lib/i18n"; -export type ScanTerminalContentView = "city-list" | "analysis" | "map"; +export type ScanTerminalContentView = "city-list" | "analysis"; type ThemeMode = "dark" | "light"; @@ -34,8 +34,8 @@ export function ScanTerminalLoadingScreen({ {isEn - ? "Start from the map, then open city cards to verify weather evidence" - : "从地图选城市,再打开决策卡验证天气证据"} + ? "Loading paid decision cards, city evidence, and market signals" + : "正在加载付费决策卡、城市证据和市场信号"}
@@ -91,8 +91,8 @@ export function ScanTerminalTopBar({ {isEn - ? "Start from the map, then open city cards to verify weather evidence" - : "从地图选城市,再打开决策卡验证天气证据"} + ? "Paid workspace for city evidence, model signals, and weather-market decisions" + : "面向付费用户的城市证据、模型信号与天气市场决策工作区"}
diff --git a/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts b/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts index 9b7f5864..7f19ead6 100644 --- a/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts +++ b/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts @@ -37,6 +37,9 @@ export function useScanTerminalQuery({ showLoading?: boolean; } = {}) => { if (proAccessLoading || !isPro) return; + if (typeof fetch !== "function" || typeof AbortController === "undefined") { + return; + } if (forceRefresh) { lastForcedScanRefreshAtRef.current = Date.now(); } @@ -75,6 +78,13 @@ export function useScanTerminalQuery({ useEffect(() => { if (proAccessLoading || !isPro) return; + if ( + typeof window === "undefined" || + typeof window.setInterval !== "function" || + typeof window.clearInterval !== "function" + ) { + return; + } const intervalId = window.setInterval(() => { if ( !shouldRunAutoTerminalRefresh({ diff --git a/frontend/components/dashboard/scan-terminal/use-scan-terminal-ui-state.ts b/frontend/components/dashboard/scan-terminal/use-scan-terminal-ui-state.ts index 89b259d3..f90aa898 100644 --- a/frontend/components/dashboard/scan-terminal/use-scan-terminal-ui-state.ts +++ b/frontend/components/dashboard/scan-terminal/use-scan-terminal-ui-state.ts @@ -10,6 +10,13 @@ export function useUserLocalClock() { useEffect(() => { setUserLocalTime(formatUserLocalTime()); + if ( + typeof window === "undefined" || + typeof window.setInterval !== "function" || + typeof window.clearInterval !== "function" + ) { + return; + } const intervalId = window.setInterval(() => { setUserLocalTime(formatUserLocalTime()); }, 10_000); diff --git a/frontend/components/landing/InstitutionalLandingPage.tsx b/frontend/components/landing/InstitutionalLandingPage.tsx new file mode 100644 index 00000000..de10ec18 --- /dev/null +++ b/frontend/components/landing/InstitutionalLandingPage.tsx @@ -0,0 +1,283 @@ +import Link from "next/link"; +import { + Activity, + ArrowRight, + BarChart3, + CheckCircle2, + CloudSun, + Gauge, + LineChart, + LockKeyhole, + Radar, + ShieldCheck, + TrendingUp, +} from "lucide-react"; + +const marketRows = [ + ["New York", "91.8°F", "+2.4", "High", "Long Yes"], + ["Austin", "103.1°F", "+1.1", "Medium", "Wait"], + ["Seoul", "83.4°F", "-0.7", "Low", "No Trade"], + ["Tokyo", "88.2°F", "+1.8", "High", "Long Yes"], + ["London", "72.6°F", "-0.2", "Low", "Observe"], +]; + +const coverage = [ + "Live airport observations", + "DEB blend forecast", + "Market-implied temperature", + "Intraday settlement windows", + "AI weather evidence", + "Paid Telegram alerts", +]; + +const platformCards = [ + { + icon: Radar, + title: "Live Evidence", + body: "Airport observations and official station data are structured for settlement-aware decisions.", + }, + { + icon: Gauge, + title: "Decision Workflow", + body: "City cards combine model forecast, current deviation, risk, and target contract context.", + }, + { + icon: ShieldCheck, + title: "Paid Access", + body: "The product workspace is locked until the user has an active subscription.", + }, +]; + +export function InstitutionalLandingPage() { + return ( +
+
+
+ + + + + PolyWeather + + +
+ + Log in + + + Enter Product + + +
+
+
+ +
+
+
+
+ + Paid professional terminal +
+

+ Institutional weather market intelligence for paid users. +

+

+ PolyWeather turns live METAR observations, DEB forecast blends, + model probabilities, and market settlement logic into one + professional decision workspace. +

+
+ + Enter product + + + + Subscribe / Manage account + +
+

+ No free product access. Subscription is required before the + terminal opens. +

+
+ +
+
+
+ + Weather Markets Dashboard +
+
+ + Live +
+
+
+
+
+ Temperature Contracts + + Price / Edge / Signal + +
+
+ {marketRows.map((row) => ( +
+ {row[0]} + {row[1]} + + {row[2]} + + + {row[3]} + + + {row[4]} + +
+ ))} +
+
+
+
+
+ Model Stack + +
+
+ {["DEB Blend", "Live METAR", "Market Implied"].map( + (label, index) => ( +
+
+ {label} + {[82, 67, 74][index]}% +
+
+
+
+
+ ), + )} +
+
+
+
+ + Current Signal +
+

+ New York high-temperature market shows a positive + observation deviation with confirmed airport evidence. +

+
+
+
+
+
+ +
+
+ {platformCards.map(({ body, icon: Icon, title }) => ( +
+ +

{title}

+

{body}

+
+ ))} +
+
+ +
+
+
+

+ Data Coverage +

+

+ Everything weather-market users need in one place. +

+
+

+ Built for repeat professional use: dense tables, clear status + chips, restrained color, and fast entry into paid workflows. +

+
+
+ {coverage.map((item) => ( +
+ + {item} +
+ ))} +
+
+ +
+
+
+

+ Subscription Required +

+

+ Product access starts after payment. +

+

+ Users can read the landing page and account/payment guide + publicly, but the decision terminal opens only for active paid + accounts. +

+
+ + + Subscribe now + +
+
+
+
+ ); +} diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index aa6e32c7..2dfdfc91 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -154,7 +154,16 @@ type StoredProAccessState = ProAccessState & { }; function wait(ms: number) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); + return new Promise((resolve) => { + if ( + typeof window !== "undefined" && + typeof window.setTimeout === "function" + ) { + window.setTimeout(resolve, ms); + return; + } + resolve(undefined); + }); } function getSubscriptionExpiryMs(access: Pick< @@ -1147,6 +1156,12 @@ export function DashboardStoreProvider({ } }; } + if ( + typeof window.setTimeout !== "function" || + typeof window.clearTimeout !== "function" + ) { + return; + } const timer = window.setTimeout(schedule, 2000); return () => window.clearTimeout(timer); }, [cities.length]); @@ -1351,10 +1366,15 @@ 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); + try { + if (!window.localStorage) return; + if (selectedCity) { + window.localStorage.setItem(SELECTED_CITY_STORAGE_KEY, selectedCity); + } else { + window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY); + } + } catch { + // Storage can be unavailable in embedded/private browser contexts. } }, [selectedCity]); @@ -1368,7 +1388,11 @@ export function DashboardStoreProvider({ if (typeof window === "undefined") return; hydratedSelectionRef.current = true; - window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY); + try { + window.localStorage?.removeItem(SELECTED_CITY_STORAGE_KEY); + } catch { + // Storage can be unavailable in embedded/private browser contexts. + } }, [cities, selectedCity]); const refreshSelectedCity = async () => { diff --git a/frontend/hooks/useRelativeTime.ts b/frontend/hooks/useRelativeTime.ts index 7262f546..1d947079 100644 --- a/frontend/hooks/useRelativeTime.ts +++ b/frontend/hooks/useRelativeTime.ts @@ -23,12 +23,26 @@ export function useRelativeTime(isoString: string | null | undefined): string { useEffect(() => { if (!date) return; - const interval = setInterval(() => setTick((n) => n + 1), 30_000); + const canUseInterval = + typeof window !== "undefined" && + typeof window.setInterval === "function" && + typeof window.clearInterval === "function"; + const canUseVisibility = + typeof document !== "undefined" && + typeof document.addEventListener === "function" && + typeof document.removeEventListener === "function"; + const interval = canUseInterval + ? window.setInterval(() => setTick((n) => n + 1), 30_000) + : null; const onVisible = () => setTick((n) => n + 1); - document.addEventListener("visibilitychange", onVisible); + if (canUseVisibility) { + document.addEventListener("visibilitychange", onVisible); + } return () => { - clearInterval(interval); - document.removeEventListener("visibilitychange", onVisible); + if (interval != null) window.clearInterval(interval); + if (canUseVisibility) { + document.removeEventListener("visibilitychange", onVisible); + } }; }, [date]); diff --git a/frontend/lib/app-analytics.ts b/frontend/lib/app-analytics.ts index 8879659a..dd16be3d 100644 --- a/frontend/lib/app-analytics.ts +++ b/frontend/lib/app-analytics.ts @@ -54,9 +54,10 @@ export function getAnalyticsSessionId() { export function markAnalyticsOnce(key: string, scope: "local" | "session" = "session") { if (!isClient()) return false; - const storage = scope === "local" ? window.localStorage : window.sessionStorage; const normalizedKey = `polyweather:analytics:once:${key}`; try { + const storage = scope === "local" ? window.localStorage : window.sessionStorage; + if (!storage) return true; if (storage.getItem(normalizedKey) === "1") { return false; } diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index 15c32c99..2f839cd0 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -58,8 +58,14 @@ async function fetchJson( options?: { cache?: RequestCache; timeoutMs?: number }, ): Promise { const timeoutMs = options?.timeoutMs; - const controller = timeoutMs ? new AbortController() : null; - const timeoutId = controller + const controller = + timeoutMs && typeof AbortController !== "undefined" + ? new AbortController() + : null; + const timeoutId = + controller && + typeof window !== "undefined" && + typeof window.setTimeout === "function" ? window.setTimeout(() => controller.abort(), timeoutMs) : null; const headers = await buildBrowserBackendHeaders({ @@ -80,7 +86,9 @@ async function fetchJson( throw error; } finally { if (timeoutId != null) { - window.clearTimeout(timeoutId); + if (typeof window.clearTimeout === "function") { + window.clearTimeout(timeoutId); + } } } @@ -222,7 +230,11 @@ function readLegacyCache(raw: string): CityCacheBundle { export const dashboardClient = { clearCityDetailCache() { if (!isClient()) return; - window.sessionStorage.removeItem(CACHE_KEY); + try { + window.sessionStorage?.removeItem(CACHE_KEY); + } catch { + // Storage can be unavailable in embedded/private browser contexts. + } }, async getCities() { @@ -232,13 +244,24 @@ export const dashboardClient = { sendPriorityWarmHint(timezone?: string | null) { if (!isClient()) return; + if ( + typeof fetch !== "function" || + typeof Intl === "undefined" || + !window.sessionStorage + ) { + return; + } const tz = String( timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "", ).trim(); if (!tz) return; const cacheKey = `${PRIORITY_WARM_SESSION_KEY}:${tz}`; - if (window.sessionStorage.getItem(cacheKey)) return; - window.sessionStorage.setItem(cacheKey, "1"); + try { + if (window.sessionStorage.getItem(cacheKey)) return; + window.sessionStorage.setItem(cacheKey, "1"); + } catch { + return; + } const params = new URLSearchParams({ timezone: tz }); void fetch(`/api/system/priority-warm?${params.toString()}`, { method: "POST", @@ -532,10 +555,11 @@ export const dashboardClient = { const entries = Object.fromEntries( topEntries.map((e) => [e.cityName, { cachedAt: e.cachedAt, detail: e.detail, revision: e.revision }]), ); - window.sessionStorage.setItem( - CACHE_KEY, - JSON.stringify({ entries }), - ); + try { + window.sessionStorage?.setItem(CACHE_KEY, JSON.stringify({ entries })); + } catch { + // Storage can be unavailable in embedded/private browser contexts. + } }, writeCityDetailCache(data: Record) { diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts index 760e5329..1ad463e6 100644 --- a/frontend/lib/i18n.ts +++ b/frontend/lib/i18n.ts @@ -382,7 +382,7 @@ export function normalizeLocale(value?: string | null): Locale { export function getInitialLocaleFromNavigator(): Locale { if (typeof window === "undefined") return DEFAULT_LOCALE; - return normalizeLocale(window.navigator.language); + return normalizeLocale(window.navigator?.language); } export function formatMessage( diff --git a/frontend/middleware.ts b/frontend/middleware.ts index fc3b9da8..4f9b9ffc 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -179,6 +179,7 @@ export async function middleware(request: NextRequest) { export const config = { matcher: [ "/account/:path*", + "/terminal/:path*", "/ops/:path*", "/api/auth/:path*", "/api/ops/:path*", diff --git a/frontend/playwright-scroll-check.png b/frontend/playwright-scroll-check.png deleted file mode 100644 index 72397be8..00000000 Binary files a/frontend/playwright-scroll-check.png and /dev/null differ diff --git a/frontend/playwright-views-check.png b/frontend/playwright-views-check.png deleted file mode 100644 index 85b6254b..00000000 Binary files a/frontend/playwright-views-check.png and /dev/null differ diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index f0ae366c..07c7048e 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -16,7 +16,7 @@ import re import threading import time import unicodedata -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple import httpx @@ -97,6 +97,14 @@ MARKET_CITY_SLUG_ALIASES: Dict[str, str] = { } +def _city_local_date(city_key: str) -> str: + """Return ISO date string (YYYY-MM-DD) for the city's local timezone.""" + city = CITY_REGISTRY.get(city_key, {}) + tz_offset = city.get("tz_offset", 0) + local_dt = datetime.now(timezone.utc) + timedelta(seconds=tz_offset) + return local_dt.strftime("%Y-%m-%d") + + def _resolve_market_city_key(city_key: str) -> str: return MARKET_CITY_ALIASES.get(city_key, city_key) @@ -2316,6 +2324,79 @@ class PolymarketReadOnlyLayer: out.append(market) return out + def resolve_city_clob_tokens(self, city_key: str) -> List[Dict[str, Any]]: + """Resolve CLOB token IDs for a city using its local date.""" + local_date = _city_local_date(city_key) + market_slug = self._build_weather_event_slug(city_key, local_date) + if not market_slug: + return [] + markets = self._load_event_markets(market_slug) + tokens: List[Dict[str, Any]] = [] + for m in markets: + clob_ids = _json_or_list(m.get("clobTokenIds")) + question = str(m.get("question") or "").strip() + prices = _json_or_list(m.get("outcomePrices")) + if len(clob_ids) < 2: + continue + tokens.append({ + "city": city_key, + "local_date": local_date, + "question": question, + "slug": str(m.get("slug") or "").strip(), + "yes_token": clob_ids[0], + "no_token": clob_ids[1], + "yes_price": _safe_float(prices[0]) if len(prices) > 0 else None, + "no_price": _safe_float(prices[1]) if len(prices) > 1 else None, + }) + return tokens + + def resolve_all_cities_clob_tokens( + self, + cities: Optional[List[str]] = None, + ) -> Dict[str, List[Dict[str, Any]]]: + """Resolve CLOB tokens for all configured cities using local dates. + + Returns dict keyed by city_key, each value is a list of bucket token dicts. + """ + if cities is None: + cities = list(CITY_REGISTRY.keys()) + result: Dict[str, List[Dict[str, Any]]] = {} + for city_key in cities: + try: + buckets = self.resolve_city_clob_tokens(city_key) + if buckets: + result[city_key] = buckets + logger.info( + "polymarket market discovery city={} buckets={} date={}", + city_key, + len(buckets), + buckets[0]["local_date"] if buckets else "N/A", + ) + except Exception as exc: + logger.warning( + "polymarket market discovery failed city={} error={}", + city_key, + exc, + ) + return result + + def collect_all_clob_token_ids( + self, + cities: Optional[List[str]] = None, + ) -> List[str]: + """Collect all unique YES/NO CLOB token IDs for the given cities.""" + all_tokens = self.resolve_all_cities_clob_tokens(cities) + seen: set = set() + token_ids: List[str] = [] + for city_buckets in all_tokens.values(): + for bucket in city_buckets: + for key in ("yes_token", "no_token"): + tid = str(bucket.get(key) or "").strip() + if tid and tid not in seen: + seen.add(tid) + token_ids.append(tid) + return token_ids + def _extract_market_bucket_label( self, market: Dict[str, Any],