diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 50c243a8..52c8295d 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -2,106 +2,124 @@
@tailwind components;
@tailwind utilities;
-:root {
- --background: 223 53% 4%;
- --foreground: 210 40% 98%;
- --card: 223 46% 8%;
- --card-foreground: 210 40% 98%;
- --primary: 190 95% 56%;
- --primary-foreground: 222 47% 8%;
- --secondary: 224 30% 14%;
- --secondary-foreground: 210 40% 98%;
- --accent: 217 30% 18%;
- --accent-foreground: 210 40% 98%;
- --border: 221 38% 22%;
-}
-
-* {
- border-color: hsl(var(--border));
-}
-
-body {
- font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
- background:
- radial-gradient(circle at 10% -10%, rgba(34, 211, 238, 0.2), transparent 40%),
- radial-gradient(circle at 90% 0%, rgba(59, 130, 246, 0.14), transparent 36%),
- radial-gradient(circle at 80% 100%, rgba(8, 47, 73, 0.5), transparent 48%),
- hsl(var(--background));
- color: hsl(var(--foreground));
-}
-
-.leaflet-container {
- width: 100%;
- height: 100%;
- font-family: inherit;
-}
-
-.leaflet-control-zoom a {
- background: rgba(2, 6, 23, 0.82) !important;
- border-color: rgba(71, 85, 105, 0.5) !important;
- color: #e2e8f0 !important;
-}
-
-.leaflet-control-zoom a:hover {
- background: rgba(15, 23, 42, 0.95) !important;
-}
-
-.map-pill {
- min-width: 52px;
- border-radius: 9999px;
- border: 1px solid rgba(255, 255, 255, 0.2);
- padding: 5px 10px;
- color: #f8fafc;
- font-size: 11px;
- font-weight: 700;
- line-height: 1;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- backdrop-filter: blur(8px);
- box-shadow:
- 0 2px 14px rgba(2, 6, 23, 0.45),
- inset 0 1px 0 rgba(255, 255, 255, 0.2);
-}
-
-.map-pill.high {
- background: linear-gradient(135deg, #ef4444, #b91c1c);
-}
-
-.map-pill.medium {
- background: linear-gradient(135deg, #f59e0b, #b45309);
-}
-
-.map-pill.low {
- background: linear-gradient(135deg, #10b981, #047857);
-}
-
-.map-pill.active {
- transform: translateY(-2px) scale(1.05);
- box-shadow:
- 0 8px 24px rgba(34, 211, 238, 0.4),
- inset 0 1px 0 rgba(255, 255, 255, 0.35);
-}
-
-.glass {
- background: linear-gradient(
- 180deg,
- rgba(15, 23, 42, 0.9) 0%,
- rgba(2, 6, 23, 0.75) 100%
- );
- backdrop-filter: blur(10px);
-}
-
-.fade-up {
- animation: fadeUp 450ms ease-out;
-}
-
-@keyframes fadeUp {
- from {
- opacity: 0;
- transform: translateY(10px);
+@layer base {
+ :root {
+ --background: 223 53% 4%;
+ --foreground: 210 40% 98%;
+ --card: 223 46% 8%;
+ --card-foreground: 210 40% 98%;
+ --primary: 190 95% 56%;
+ --primary-foreground: 222 47% 8%;
+ --secondary: 224 30% 14%;
+ --secondary-foreground: 210 40% 98%;
+ --accent: 217 30% 18%;
+ --accent-foreground: 210 40% 98%;
+ --border: 221 38% 22%;
}
- to {
- opacity: 1;
- transform: translateY(0);
+
+ * {
+ @apply border-border;
+ }
+
+ body {
+ font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
+ background:
+ radial-gradient(
+ circle at 10% -10%,
+ rgba(34, 211, 238, 0.2),
+ transparent 40%
+ ),
+ radial-gradient(
+ circle at 90% 0%,
+ rgba(59, 130, 246, 0.14),
+ transparent 36%
+ ),
+ radial-gradient(
+ circle at 80% 100%,
+ rgba(8, 47, 73, 0.5),
+ transparent 48%
+ ),
+ hsl(var(--background));
+ color: hsl(var(--foreground));
+ }
+}
+
+@layer components {
+ .leaflet-container {
+ width: 100%;
+ height: 100%;
+ font-family: inherit;
+ }
+
+ .leaflet-control-zoom a {
+ background: rgba(2, 6, 23, 0.82) !important;
+ border-color: rgba(71, 85, 105, 0.5) !important;
+ color: #e2e8f0 !important;
+ }
+
+ .leaflet-control-zoom a:hover {
+ background: rgba(15, 23, 42, 0.95) !important;
+ }
+
+ .map-pill {
+ min-width: 52px;
+ border-radius: 9999px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ padding: 5px 10px;
+ color: #f8fafc;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ backdrop-filter: blur(8px);
+ box-shadow:
+ 0 2px 14px rgba(2, 6, 23, 0.45),
+ inset 0 1px 0 rgba(255, 255, 255, 0.2);
+ }
+
+ .map-pill.high {
+ background: linear-gradient(135deg, #ef4444, #b91c1c);
+ }
+
+ .map-pill.medium {
+ background: linear-gradient(135deg, #f59e0b, #b45309);
+ }
+
+ .map-pill.low {
+ background: linear-gradient(135deg, #10b981, #047857);
+ }
+
+ .map-pill.active {
+ transform: translateY(-2px) scale(1.05);
+ box-shadow:
+ 0 8px 24px rgba(34, 211, 238, 0.4),
+ inset 0 1px 0 rgba(255, 255, 255, 0.35);
+ }
+
+ .glass {
+ background: linear-gradient(
+ 180deg,
+ rgba(15, 23, 42, 0.9) 0%,
+ rgba(2, 6, 23, 0.75) 100%
+ );
+ backdrop-filter: blur(10px);
+ }
+
+ .fade-up {
+ animation: fadeUp 450ms ease-out;
+ }
+}
+
+@layer utilities {
+ @keyframes fadeUp {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
}
diff --git a/frontend/components/terminal/AnalyticsPanel.tsx b/frontend/components/terminal/AnalyticsPanel.tsx
new file mode 100644
index 00000000..1154586c
--- /dev/null
+++ b/frontend/components/terminal/AnalyticsPanel.tsx
@@ -0,0 +1,254 @@
+"use client";
+
+import React from "react";
+import {
+ BarChart2,
+ Target,
+ ShieldAlert,
+ Zap,
+ Info,
+ Activity,
+ ArrowUpRight,
+} from "lucide-react";
+import { Badge } from "../ui/badge";
+import { Sparkline } from "./Sparkline";
+import { CityDetail } from "../../lib/types";
+
+interface AnalyticsPanelProps {
+ data: CityDetail;
+ t?: any; // Optional translations
+}
+
+export function AnalyticsPanel({
+ data,
+ t = {}, // Default empty for now, can be expanded via context or props
+}: AnalyticsPanelProps) {
+ const { overview, market_scan, models, ai_analysis } = data;
+
+ const modelEntries = Object.entries(models)
+ .filter(([_, v]) => v !== undefined && v !== null)
+ .map(([label, value]) => ({ label, value: value as number }))
+ .sort((a, b) => b.value - a.value);
+
+ const signalColor =
+ market_scan.signal_label === "BUY YES"
+ ? "border-emerald-500/30 bg-emerald-500/5 text-emerald-400"
+ : market_scan.signal_label === "BUY NO"
+ ? "border-rose-500/30 bg-rose-500/5 text-rose-400"
+ : "border-zinc-800 bg-zinc-900/50 text-zinc-400";
+
+ const signalIconColor =
+ market_scan.signal_label === "BUY YES"
+ ? "text-emerald-500"
+ : market_scan.signal_label === "BUY NO"
+ ? "text-rose-500"
+ : "text-zinc-500";
+
+ return (
+
+ {/* Header Stat Area */}
+
+
+
+ {t.marketAnalysis || "MARKET ANALYSIS"}
+
+
+ {t.realTime || "LIVE UPLINK"}
+
+
+
+
+
+
+ {t.liveMetar || "REAL-TIME METAR"}
+
+
+
+ {overview.current_temp?.toFixed(1) || "--"}
+
+
+ {overview.temp_symbol}
+
+
+
+
+
+ {t.ensembleDeb || "ENSEMBLE DEB"}
+
+
+
+ {overview.deb_prediction?.toFixed(1) || "--"}
+
+
+ {overview.temp_symbol}
+
+
+
+
+
+ {/* Trade Execution Signal */}
+
+
+
+
+ {t.signalStrategy || "SIGNAL STRATEGY"}
+
+
+
+
+ {market_scan.signal_label}
+
+
+
+
+
+ {t.confidence || "CONFIDENCE"}
+
+
+ {market_scan.confidence}
+
+
+
+
+ 0
+ ? market_scan.sparkline
+ : [1, 1, 1]
+ }
+ width={360}
+ height={35}
+ color={
+ market_scan.signal_label === "BUY YES"
+ ? "#10b981"
+ : market_scan.signal_label === "BUY NO"
+ ? "#f43f5e"
+ : "#52525b"
+ }
+ />
+
+
+
+
+ {/* Probability & Market Pricing */}
+
+
+
+
+
+ {t.yieldArbitrage || "YIELD ARBITRAGE"}
+
+
+
+
+
+ {t.probComparison || "PROB COMPARISON"}
+
+ {t.predictionGap || "GAP"}:{" "}
+ {market_scan.edge_percent?.toFixed(1) || "0.0"}%
+
+
+ {/* Visual Probability Bar */}
+
+
+
+
+
+
+ {t.marketPriceLabel || "MIDPOINT"}
+
+
+ ${market_scan.market_price?.toFixed(2) || "--"}
+
+
+
+
+ {t.modelProbLabel || "MODEL PROB"}
+
+
+ {((market_scan.model_probability || 0) * 100).toFixed(1)}%
+
+
+
+
+
+
+
+
+
+
+ {t.multiModelDivergence || "MULTI-MODEL DIVERGENCE"}
+
+
+
+ {modelEntries.map((m) => (
+
+
+
+ {m.label}
+
+
+ {m.value.toFixed(1)}°
+
+
+
+
25
+ ? "bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.5)]"
+ : m.value > 15
+ ? "bg-amber-500"
+ : "bg-cyan-500 shadow-[0_0_8px_rgba(34,211,238,0.5)]"
+ }`}
+ style={{ width: `${(m.value / 40) * 100}%` }}
+ />
+
+
+ ))}
+
+
+
+ {ai_analysis && (
+
+
+
+
+ AI COGNITIVE ANALYSIS
+
+
+
+
+
+
+
+ {ai_analysis}
+
+
+
+ )}
+
+
+ {/* Execute Scan Footer */}
+
+
+
+
+ );
+}
diff --git a/frontend/components/terminal/Sidebar.tsx b/frontend/components/terminal/Sidebar.tsx
new file mode 100644
index 00000000..cf957758
--- /dev/null
+++ b/frontend/components/terminal/Sidebar.tsx
@@ -0,0 +1,135 @@
+import React from "react";
+import { Search, Globe, ChevronRight, Activity } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { CityInfo } from "@/lib/types";
+import { Sparkline } from "./Sparkline";
+
+interface CitySidebarProps {
+ cities: CityInfo[];
+ activeCity: string;
+ onSelectCity: (name: string) => void;
+ t: any;
+}
+
+export function CitySidebar({
+ cities,
+ activeCity,
+ onSelectCity,
+ t,
+}: CitySidebarProps) {
+ return (
+
+ );
+}
diff --git a/frontend/components/terminal/Sparkline.tsx b/frontend/components/terminal/Sparkline.tsx
new file mode 100644
index 00000000..c74c1371
--- /dev/null
+++ b/frontend/components/terminal/Sparkline.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import React from "react";
+
+interface SparklineProps {
+ data: number[];
+ color?: string;
+ height?: number;
+ width?: number;
+ strokeWidth?: number;
+ className?: string;
+}
+
+export function Sparkline({
+ data,
+ color = "#22d3ee",
+ height = 40,
+ width = 120,
+ strokeWidth = 2,
+ className = "",
+}: SparklineProps) {
+ if (!data || data.length < 2) return
;
+
+ const min = Math.min(...data);
+ const max = Math.max(...data);
+ const range = max - min || 1;
+
+ const points = data
+ .map((val, i) => {
+ const x = (i / (data.length - 1)) * width;
+ const y = height - ((val - min) / range) * height;
+ return `${x},${y}`;
+ })
+ .join(" ");
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/components/terminal/TerminalDashboard.tsx b/frontend/components/terminal/TerminalDashboard.tsx
new file mode 100644
index 00000000..80a6538f
--- /dev/null
+++ b/frontend/components/terminal/TerminalDashboard.tsx
@@ -0,0 +1,307 @@
+"use client";
+
+import React, { useState } from "react";
+import { CitySidebar } from "./Sidebar";
+import { AnalyticsPanel } from "./AnalyticsPanel";
+import {
+ RefreshCw,
+ LayoutGrid,
+ Maximize2,
+ Layers,
+ Languages,
+ Sun,
+ Moon,
+} from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+
+interface TerminalDashboardProps {
+ cities: any[];
+ activeCity: string;
+ onSelectCity: (name: string) => void;
+ cityData: any;
+ marketData: any;
+ officialData: any;
+ isLoading: boolean;
+ refresh: () => void;
+ lang: any;
+ setLang: (lang: any) => void;
+ theme: string;
+ toggleTheme: () => void;
+ t: any;
+ children: React.ReactNode; // For the Map
+}
+
+function NavLink({
+ href,
+ label,
+ active = false,
+}: {
+ href: string;
+ label: string;
+ active?: boolean;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+export function TerminalDashboard({
+ cities,
+ activeCity,
+ onSelectCity,
+ cityData,
+ marketData,
+ officialData,
+ isLoading,
+ refresh,
+ lang,
+ setLang,
+ theme,
+ toggleTheme,
+ t,
+ children,
+}: TerminalDashboardProps) {
+ const currentTemp = cityData?.current?.temp ?? null;
+ const debPrediction = cityData?.deb?.prediction ?? null;
+ const marketPrice = marketData?.markets?.[0]?.price ?? 0.62; // Placeholder
+
+ const modelEntries = Object.entries(cityData?.multi_model ?? {})
+ .map(([label, value]) => ({ label, value: value as number }))
+ .filter((e) => typeof e.value === "number");
+
+ return (
+
+ {/* Top Global Ticker / Nav */}
+
+
+
+
+
+
+
+
+
+
+ {t.node || "Node"}:
+
+
+ CLOUD-ALPHA-01
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NODE: CLOUD-ALPHA-01
+
+
+
+
+
+ {/* Main Container */}
+
+ {/* Left: Component List */}
+
+
+ {/* Center: Interactive Map Area */}
+
+ {/* Map UI Overlay */}
+
+
+
+
+ Current Focus
+
+
+ {activeCity}
+
+
+
+
+
+
+
+
+
+
+ {/* The Map */}
+
+ {children}
+
+
+ {/* Bottom Map Info Bar */}
+
+
+
+ Projection: Mercator · Layer: Dynamic Thermal Ensemble
+
+
+
+
+ STABLE VIBE
+
+
+
+
+
+ {/* Right: Intelligence Panel */}
+
+
+
+ {/* Footer Ticker */}
+
+
+ );
+}
diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx
new file mode 100644
index 00000000..a96a0740
--- /dev/null
+++ b/frontend/components/ui/badge.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "../../lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-black uppercase tracking-[0.16em] transition-colors",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-cyan-500/30 bg-cyan-500/10 text-cyan-600 dark:text-cyan-400",
+ secondary:
+ "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300",
+ success:
+ "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
+ danger:
+ "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400",
+ warning:
+ "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400",
+ outline: "border-border bg-transparent text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+export interface BadgeProps
+ extends
+ React.HTMLAttributes
,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx
new file mode 100644
index 00000000..a1e86cdf
--- /dev/null
+++ b/frontend/components/ui/button.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/40 disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-cyan-500 text-white hover:bg-cyan-400 shadow-[0_0_0_1px_rgba(34,211,238,0.2)]",
+ secondary:
+ "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800",
+ ghost:
+ "text-slate-500 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-100 dark:hover:bg-slate-900",
+ outline:
+ "border border-border bg-transparent hover:bg-slate-100 dark:hover:bg-slate-900",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-8 rounded-lg px-3 text-xs",
+ lg: "h-11 px-5",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx
new file mode 100644
index 00000000..a56786fa
--- /dev/null
+++ b/frontend/components/ui/card.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx
new file mode 100644
index 00000000..ae3d166e
--- /dev/null
+++ b/frontend/components/ui/input.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/frontend/components/ui/separator.tsx b/frontend/components/ui/separator.tsx
new file mode 100644
index 00000000..8e82472a
--- /dev/null
+++ b/frontend/components/ui/separator.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+}: React.HTMLAttributes & {
+ orientation?: "horizontal" | "vertical";
+}) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/frontend/components/ui/skeleton.tsx b/frontend/components/ui/skeleton.tsx
new file mode 100644
index 00000000..c1e47bb7
--- /dev/null
+++ b/frontend/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils";
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
new file mode 100644
index 00000000..e2528d45
--- /dev/null
+++ b/frontend/lib/types.ts
@@ -0,0 +1,357 @@
+// --- Polymarket Market Types ---
+
+export interface MarketBook {
+ best_bid: number;
+ best_ask: number;
+ bid_levels: [number, number][]; // [price, size]
+ ask_levels: [number, number][]; // [price, size]
+}
+
+export interface MarketToken {
+ outcome: string;
+ token_id: string;
+ implied_probability: number;
+ buy_price: number;
+ sell_price: number;
+ midpoint: number;
+ last_trade_price: number;
+ book?: MarketBook;
+}
+
+export interface Trade {
+ id: string;
+ price: number;
+ size: number;
+ side: "buy" | "sell";
+ timestamp: string;
+ timestamp_iso?: string | null;
+ outcome?: string | null;
+ asset?: string | null;
+ transaction_hash?: string | null;
+}
+
+export interface MarketSnapshot {
+ id: string;
+ question: string;
+ title: string;
+ slug: string;
+ event_slug: string;
+ condition_id: string;
+ target_date: string;
+ active: boolean;
+ closed: boolean;
+ archived: boolean;
+ enable_order_book: boolean;
+ liquidity: number;
+ volume: number;
+ start_date: string;
+ end_date: string;
+ tokens: MarketToken[];
+ recent_trades: Trade[];
+}
+
+export interface MarketPlatformData {
+ city: string;
+ target_date: string;
+ fetched_at: string;
+ market_count: number;
+ markets: MarketSnapshot[];
+ websocket: {
+ market_url: string;
+ asset_ids: string[];
+ condition_ids: string[];
+ };
+}
+
+// --- Official Weather Types (Aviation) ---
+
+export interface METARObservation {
+ source: string;
+ icao: string;
+ station_name: string;
+ timestamp: string;
+ observation_time: string;
+ current: {
+ temp: number;
+ max_temp_so_far: number;
+ max_temp_time: string;
+ dewpoint: number;
+ wind_speed_kt: number;
+ wind_dir: number;
+ visibility_mi: string | number;
+ wx_desc: string | null;
+ altimeter: number;
+ clouds: any[];
+ };
+ recent_temps: number[];
+ today_obs: any[];
+ recent_obs: any[];
+ unit: "celsius" | "fahrenheit";
+}
+
+export interface AviationWeatherData {
+ available: boolean;
+ source: string;
+ icao: string;
+ observation: METARObservation;
+}
+
+// --- Official Weather Types (Weather.gov) ---
+
+export interface WeatherGovPeriod {
+ name: string;
+ start_time?: string;
+ end_time?: string;
+ is_daytime?: boolean;
+ temperature: number;
+ temperature_unit: string;
+ wind_speed?: string;
+ wind_direction?: string;
+ short_forecast?: string;
+ detailed_forecast?: string;
+}
+
+export interface WeatherGovAlert {
+ id: string;
+ event: string;
+ severity: string;
+ certainty: string;
+ urgency: string;
+ headline: string;
+ onset: string;
+ expires: string;
+}
+
+export interface WeatherGovData {
+ available: boolean;
+ source: string;
+ city: string;
+ grid: any;
+ nearest_station: {
+ station_identifier?: string;
+ name?: string;
+ timezone?: string;
+ elevation_m?: number | null;
+ } | null;
+ latest_observation: {
+ station_identifier?: string;
+ temperature_c?: number | null;
+ dewpoint_c?: number | null;
+ wind_direction_deg?: number | null;
+ wind_speed_kmh?: number | null;
+ text_description?: string | null;
+ timestamp?: string;
+ } | null;
+ stations: any[];
+ forecast_periods: WeatherGovPeriod[];
+ hourly_periods: WeatherGovPeriod[];
+ active_alerts: WeatherGovAlert[];
+}
+
+export interface OfficialWeatherData {
+ city: string;
+ fetched_at: string;
+ aviation_weather?: AviationWeatherData;
+ weather_gov?: WeatherGovData;
+}
+
+// --- City List Types ---
+
+export interface CityInfo {
+ name: string;
+ display_name: string;
+ lat: number;
+ lon: number;
+ risk_level: "low" | "medium" | "high";
+ risk_emoji: string;
+ airport: string;
+ icao: string;
+ temp_unit: "celsius" | "fahrenheit";
+ is_major: boolean;
+}
+
+export interface CitiesResponse {
+ cities: CityInfo[];
+}
+
+// --- Full City Analysis Types ---
+
+export interface ModelComparison {
+ [key: string]: number | undefined;
+ "Open-Meteo"?: number;
+ ECMWF?: number;
+ GFS?: number;
+ ICON?: number;
+ GEM?: number;
+ JMA?: number;
+ MGM?: number;
+ NWS?: number;
+ Meteoblue?: number;
+}
+
+export interface DEBAnalysis {
+ prediction: number | null;
+ weights_info?: string;
+}
+
+export interface TrendInfo {
+ direction: string;
+ recent: Array<{ time: string; temp: number }>;
+ is_cooling: boolean;
+ is_dead_market: boolean;
+}
+
+export interface PeakInfo {
+ hours: string[];
+ first_h: number;
+ last_h: number;
+ status: string;
+}
+
+export interface CityAnalysis {
+ name: string;
+ display_name: string;
+ lat: number;
+ lon: number;
+ temp_symbol: string;
+ local_time: string;
+ local_date: string;
+ risk: {
+ level: string;
+ emoji: string;
+ airport: string;
+ icao: string;
+ distance_km: number;
+ warning: string;
+ };
+ current: {
+ temp: number | null;
+ max_so_far: number | null;
+ max_temp_time: string;
+ wu_settlement: number | null;
+ obs_time: string;
+ obs_age_min: number | null;
+ wind_speed_kt: number | null;
+ wind_dir: number | null;
+ humidity: number | null;
+ cloud_desc: string;
+ clouds_raw: Array<{ cover: string; base: number | null }>;
+ visibility_mi: number | null;
+ wx_desc: string | null;
+ };
+ mgm?: {
+ temp?: number | null;
+ time?: string;
+ today_high?: number | null;
+ today_low?: number | null;
+ };
+ forecast: {
+ today_high: number | null;
+ daily: any[];
+ };
+ multi_model: ModelComparison;
+ deb: DEBAnalysis;
+ probabilities: {
+ mu?: number | null;
+ distribution?: any[];
+ };
+ hourly?: any;
+ metar_recent_obs?: any[];
+ trend?: TrendInfo;
+ peak?: PeakInfo;
+ ai_analysis: string;
+ updated_at: string;
+}
+
+// --- Aggregated Detail Types ---
+
+export interface MarketScan {
+ available: boolean;
+ reason: string | null;
+ primary_market: any | null;
+ selected_date: string | null;
+ selected_condition_id: string | null;
+ selected_slug: string | null;
+ temperature_bucket: any | null;
+ model_probability: number | null;
+ market_price: number | null;
+ edge_percent: number | null;
+ signal_label: "BUY YES" | "BUY NO" | "MONITOR";
+ confidence: "low" | "medium" | "high";
+ yes_token: MarketToken | null;
+ no_token: MarketToken | null;
+ yes_buy: number | null;
+ yes_sell: number | null;
+ no_buy: number | null;
+ no_sell: number | null;
+ last_trade_price: number | null;
+ liquidity: number | null;
+ volume: number | null;
+ sparkline: number[];
+ recent_trades: Trade[];
+ websocket: any;
+}
+
+export interface CityDetail {
+ city: string;
+ fetched_at: string;
+ overview: {
+ name: string;
+ display_name: string;
+ icao: string;
+ airport: string;
+ lat: number;
+ lon: number;
+ local_time: string;
+ local_date: string;
+ temp_symbol: string;
+ current_temp: number | null;
+ deb_prediction: number | null;
+ risk_level: string;
+ risk_warning: string;
+ updated_at: string;
+ };
+ official: {
+ available: boolean;
+ metar: any;
+ weather_gov: WeatherGovData;
+ mgm: any;
+ mgm_nearby: any[];
+ };
+ timeseries: {
+ metar_recent_obs: any[];
+ metar_today_obs: any[];
+ hourly: any;
+ mgm_hourly: any[];
+ forecast_daily: any[];
+ };
+ models: ModelComparison;
+ probabilities: {
+ mu: number | null;
+ distribution: any[];
+ };
+ market_scan: MarketScan;
+ risk: any;
+ ai_analysis: string;
+ errors: Record;
+}
+
+export interface CitySummary {
+ name: string;
+ display_name: string;
+ icao: string;
+ local_time: string;
+ temp_symbol: string;
+ current: {
+ temp: number | null;
+ obs_time: string;
+ };
+ deb: {
+ prediction: number | null;
+ };
+ risk: {
+ level: string;
+ warning: string;
+ };
+ updated_at: string;
+}
diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts
new file mode 100644
index 00000000..04a3f035
--- /dev/null
+++ b/frontend/lib/utils.ts
@@ -0,0 +1,49 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+/**
+ * Utility for merging tailwind classes safely.
+ */
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+/**
+ * Formats a number as a currency string.
+ */
+export function formatCurrency(value: number, decimals: number = 2) {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ }).format(value);
+}
+
+/**
+ * Formats a percentage from midpoint.
+ */
+export function formatPercent(value: number) {
+ return (value * 100).toFixed(1) + "%";
+}
+
+/**
+ * Converts Fahrenheit to Celsius.
+ */
+export function fToC(f: number) {
+ return ((f - 32) * 5) / 9;
+}
+
+/**
+ * Formats a date/time string to HH:MM:SS.
+ */
+export function formatTime(date: Date | string) {
+ const d = typeof date === "string" ? new Date(date) : date;
+ if (isNaN(d.getTime())) return String(date);
+ return d.toLocaleTimeString("zh-CN", {
+ hour12: false,
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}