|
|
@@ -0,0 +1,319 @@
|
|
|
+'use client';
|
|
|
+
|
|
|
+import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
|
+import { SignalTable } from '@/components/SignalTable';
|
|
|
+import { OrderPanel } from '@/components/OrderPanel';
|
|
|
+import { HKOTempCard, HKODailyHighCard } from './components/HKOTempCard';
|
|
|
+import { HKOForecastChart } from './components/HKOForecastChart';
|
|
|
+import type { Signal, WeatherMarket, HKOCurrentTemp, HKOForecast } from '@/lib/types';
|
|
|
+
|
|
|
+const strategyTypes = ['ALL', 'TAIL_NO', 'OBSERVED_YES', 'OBSERVED_NO'] as const;
|
|
|
+const strategyInfo: Record<string, { icon: string; label: string }> = {
|
|
|
+ ALL: { icon: '📊', label: 'All' },
|
|
|
+ TAIL_NO: { icon: '🎯', label: 'Tail NO' },
|
|
|
+ OBSERVED_YES: { icon: '🌡️', label: 'Obs YES' },
|
|
|
+ OBSERVED_NO: { icon: '❄️', label: 'Obs NO' },
|
|
|
+};
|
|
|
+
|
|
|
+interface Props {
|
|
|
+ signals: Signal[];
|
|
|
+ markets: WeatherMarket[];
|
|
|
+}
|
|
|
+
|
|
|
+export function HongKongClient({ signals, markets }: Props) {
|
|
|
+ const [currentTemp, setCurrentTemp] = useState<HKOCurrentTemp | null>(null);
|
|
|
+ const [forecast, setForecast] = useState<HKOForecast | null>(null);
|
|
|
+ const [tempLoading, setTempLoading] = useState(true);
|
|
|
+ const [forecastLoading, setForecastLoading] = useState(true);
|
|
|
+ const [filterType, setFilterType] = useState<string>('ALL');
|
|
|
+ const [selectionMode, setSelectionMode] = useState(false);
|
|
|
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
|
+ const [showOrderPanel, setShowOrderPanel] = useState(false);
|
|
|
+ const [hktTime, setHktTime] = useState('');
|
|
|
+
|
|
|
+ // Fetch HKO current temperature
|
|
|
+ const fetchCurrent = useCallback(async () => {
|
|
|
+ try {
|
|
|
+ const res = await fetch('/api/hko-current');
|
|
|
+ if (res.ok) {
|
|
|
+ const data = await res.json();
|
|
|
+ setCurrentTemp(data);
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // ignore
|
|
|
+ } finally {
|
|
|
+ setTempLoading(false);
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // Fetch HKO forecast
|
|
|
+ const fetchForecast = useCallback(async () => {
|
|
|
+ try {
|
|
|
+ const res = await fetch('/api/hko-forecast');
|
|
|
+ if (res.ok) {
|
|
|
+ const data = await res.json();
|
|
|
+ setForecast(data);
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // ignore
|
|
|
+ } finally {
|
|
|
+ setForecastLoading(false);
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // Initialize data + refresh intervals
|
|
|
+ useEffect(() => {
|
|
|
+ fetchCurrent();
|
|
|
+ fetchForecast();
|
|
|
+ const tempInterval = setInterval(fetchCurrent, 3 * 60 * 1000); // 3 min
|
|
|
+ const forecastInterval = setInterval(fetchForecast, 30 * 60 * 1000); // 30 min
|
|
|
+ return () => {
|
|
|
+ clearInterval(tempInterval);
|
|
|
+ clearInterval(forecastInterval);
|
|
|
+ };
|
|
|
+ }, [fetchCurrent, fetchForecast]);
|
|
|
+
|
|
|
+ // Live HKT clock
|
|
|
+ useEffect(() => {
|
|
|
+ const update = () => {
|
|
|
+ setHktTime(
|
|
|
+ new Date().toLocaleTimeString('en-HK', {
|
|
|
+ timeZone: 'Asia/Hong_Kong',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ second: '2-digit',
|
|
|
+ hour12: false,
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ };
|
|
|
+ update();
|
|
|
+ const interval = setInterval(update, 1000);
|
|
|
+ return () => clearInterval(interval);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // Filter signals by strategy type
|
|
|
+ const filteredSignals = useMemo(() => {
|
|
|
+ if (filterType === 'ALL') return signals;
|
|
|
+ return signals.filter((s) => s.type === filterType);
|
|
|
+ }, [signals, filterType]);
|
|
|
+
|
|
|
+ // Selection handlers
|
|
|
+ const handleToggleSelect = (id: string) => {
|
|
|
+ setSelectedIds((prev) => {
|
|
|
+ const next = new Set(prev);
|
|
|
+ if (next.has(id)) next.delete(id);
|
|
|
+ else next.add(id);
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const selectedSignals = useMemo(
|
|
|
+ () => filteredSignals.filter((s) => selectedIds.has(s.id)),
|
|
|
+ [filteredSignals, selectedIds],
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-6">
|
|
|
+ {/* Header */}
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <div>
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <span className="text-2xl">🇭🇰</span>
|
|
|
+ <h1 className="text-2xl font-bold text-heading">Hong Kong</h1>
|
|
|
+ </div>
|
|
|
+ <p className="text-sm text-dim mt-1">
|
|
|
+ HKO-powered temperature trading • {hktTime} HKT
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <a
|
|
|
+ href="https://www.weather.gov.hk/en/wxinfo/currwx/current.htm"
|
|
|
+ target="_blank"
|
|
|
+ rel="noopener noreferrer"
|
|
|
+ className="btn-secondary text-xs px-3 py-1.5 rounded-lg"
|
|
|
+ >
|
|
|
+ HKO Website →
|
|
|
+ </a>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Top row: Temp card + Daily High + Forecast chart */}
|
|
|
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
|
+ <div className="lg:col-span-1 flex flex-col gap-6">
|
|
|
+ <HKOTempCard data={currentTemp} loading={tempLoading} />
|
|
|
+ <div className="flex-1">
|
|
|
+ <HKODailyHighCard data={currentTemp} loading={tempLoading} />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="lg:col-span-2">
|
|
|
+ <HKOForecastChart
|
|
|
+ forecast={forecast}
|
|
|
+ currentTemp={currentTemp}
|
|
|
+ loading={forecastLoading}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Markets section */}
|
|
|
+ {markets.length > 0 && (
|
|
|
+ <div className="card p-6">
|
|
|
+ <h3 className="text-sm font-medium text-heading mb-4">
|
|
|
+ Active Markets ({markets.length})
|
|
|
+ </h3>
|
|
|
+ <div className="space-y-4">
|
|
|
+ {markets.map((market) => (
|
|
|
+ <div key={market.id} className="border border-gray-200 dark:border-slate-700 rounded-lg p-4">
|
|
|
+ <div className="flex items-center justify-between mb-3">
|
|
|
+ <div>
|
|
|
+ <span className="text-sm font-medium text-heading">{market.date}</span>
|
|
|
+ <span className="text-xs text-dim ml-2">
|
|
|
+ Vol: ${market.totalVolume.toLocaleString()}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ {market.polymarketUrl && (
|
|
|
+ <a
|
|
|
+ href={market.polymarketUrl}
|
|
|
+ target="_blank"
|
|
|
+ rel="noopener noreferrer"
|
|
|
+ className="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
|
|
|
+ >
|
|
|
+ Polymarket →
|
|
|
+ </a>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
|
|
|
+ {market.buckets
|
|
|
+ .sort((a, b) => {
|
|
|
+ const aNum = parseFloat(a.label.match(/\d+/)?.[0] ?? '0');
|
|
|
+ const bNum = parseFloat(b.label.match(/\d+/)?.[0] ?? '0');
|
|
|
+ return aNum - bNum;
|
|
|
+ })
|
|
|
+ .map((bucket) => {
|
|
|
+ const pricePct = (bucket.marketPrice * 100).toFixed(0);
|
|
|
+ const isHighlighted =
|
|
|
+ currentTemp &&
|
|
|
+ bucket.label.includes('°C') &&
|
|
|
+ (() => {
|
|
|
+ const match = bucket.label.match(/(\d+)-(\d+)/);
|
|
|
+ if (!match) return false;
|
|
|
+ const lo = parseInt(match[1]);
|
|
|
+ const hi = parseInt(match[2]);
|
|
|
+ return currentTemp.current.temp >= lo && currentTemp.current.temp <= hi;
|
|
|
+ })();
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={bucket.label}
|
|
|
+ className={`rounded-lg px-2 py-1.5 text-xs ${
|
|
|
+ isHighlighted
|
|
|
+ ? 'bg-amber-50 border border-amber-300 dark:bg-amber-500/10 dark:border-amber-500/30'
|
|
|
+ : 'bg-gray-50 dark:bg-slate-800/50'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ <div className="font-medium text-heading truncate" title={bucket.label}>
|
|
|
+ {bucket.label}
|
|
|
+ </div>
|
|
|
+ <div className="mt-1 flex items-center gap-1">
|
|
|
+ <div
|
|
|
+ className="h-1.5 rounded-full bg-blue-500"
|
|
|
+ style={{ width: `${Math.min(bucket.marketPrice * 100, 100)}%`, minWidth: '2px' }}
|
|
|
+ />
|
|
|
+ <span className="text-dim tabular-nums">{pricePct}¢</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Signals section */}
|
|
|
+ <div className="card p-6">
|
|
|
+ <div className="flex items-center justify-between mb-4">
|
|
|
+ <h3 className="text-sm font-medium text-heading">
|
|
|
+ Signals ({filteredSignals.length})
|
|
|
+ </h3>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ {/* Strategy filter pills */}
|
|
|
+ <div className="flex gap-1">
|
|
|
+ {strategyTypes.map((type) => (
|
|
|
+ <button
|
|
|
+ key={type}
|
|
|
+ onClick={() => setFilterType(type)}
|
|
|
+ className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
|
|
+ filterType === type
|
|
|
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'
|
|
|
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {strategyInfo[type].icon} {strategyInfo[type].label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ {/* Selection mode toggle */}
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ setSelectionMode(!selectionMode);
|
|
|
+ setSelectedIds(new Set());
|
|
|
+ setShowOrderPanel(false);
|
|
|
+ }}
|
|
|
+ className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
|
|
|
+ selectionMode
|
|
|
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'
|
|
|
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-slate-800 dark:text-slate-400'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {selectionMode ? 'Cancel' : 'Manual Order'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {filteredSignals.length > 0 ? (
|
|
|
+ <SignalTable
|
|
|
+ signals={filteredSignals}
|
|
|
+ selectionMode={selectionMode}
|
|
|
+ selectedIds={selectedIds}
|
|
|
+ onToggleSelect={handleToggleSelect}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <p className="text-dim text-sm text-center py-8">
|
|
|
+ No Hong Kong signals detected yet.
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Selection action bar */}
|
|
|
+ {selectionMode && selectedIds.size > 0 && (
|
|
|
+ <div className="mt-4 flex items-center justify-between p-3 rounded-lg bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20">
|
|
|
+ <span className="text-sm text-blue-700 dark:text-blue-400">
|
|
|
+ {selectedIds.size} signal{selectedIds.size > 1 ? 's' : ''} selected
|
|
|
+ </span>
|
|
|
+ <button
|
|
|
+ onClick={() => setShowOrderPanel(true)}
|
|
|
+ className="btn-primary text-xs px-4 py-1.5 rounded-lg"
|
|
|
+ >
|
|
|
+ Place Orders →
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Order Panel Modal */}
|
|
|
+ {showOrderPanel && selectedSignals.length > 0 && (
|
|
|
+ <OrderPanel
|
|
|
+ signals={selectedSignals}
|
|
|
+ onClose={() => {
|
|
|
+ setShowOrderPanel(false);
|
|
|
+ setSelectedIds(new Set());
|
|
|
+ setSelectionMode(false);
|
|
|
+ }}
|
|
|
+ onComplete={() => {
|
|
|
+ setShowOrderPanel(false);
|
|
|
+ setSelectedIds(new Set());
|
|
|
+ setSelectionMode(false);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|