|
@@ -1,17 +1,46 @@
|
|
|
'use client';
|
|
'use client';
|
|
|
|
|
|
|
|
-import { useState } from 'react';
|
|
|
|
|
|
|
+import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
|
import type { WeatherMarket, MarketBucket } from '@/lib/types';
|
|
import type { WeatherMarket, MarketBucket } from '@/lib/types';
|
|
|
import { Orderbook } from '@/components/Orderbook';
|
|
import { Orderbook } from '@/components/Orderbook';
|
|
|
|
|
|
|
|
|
|
+// ─── City Timezones ─────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+const CITY_TIMEZONES: Record<string, string> = {
|
|
|
|
|
+ 'New York City': 'America/New_York', 'NYC': 'America/New_York', 'New York': 'America/New_York',
|
|
|
|
|
+ 'London': 'Europe/London', 'Chicago': 'America/Chicago', 'Paris': 'Europe/Paris',
|
|
|
|
|
+ 'Seoul': 'Asia/Seoul', 'Tokyo': 'Asia/Tokyo', 'Miami': 'America/New_York',
|
|
|
|
|
+ 'Buenos Aires': 'America/Argentina/Buenos_Aires', 'Hong Kong': 'Asia/Hong_Kong',
|
|
|
|
|
+ 'Toronto': 'America/Toronto', 'Seattle': 'America/Los_Angeles', 'Dallas': 'America/Chicago',
|
|
|
|
|
+ 'Atlanta': 'America/New_York', 'Munich': 'Europe/Berlin', 'Tel Aviv': 'Asia/Jerusalem',
|
|
|
|
|
+ 'Sao Paulo': 'America/Sao_Paulo', 'São Paulo': 'America/Sao_Paulo', 'Shanghai': 'Asia/Shanghai',
|
|
|
|
|
+ 'Wellington': 'Pacific/Auckland', 'Milan': 'Europe/Rome', 'Lucknow': 'Asia/Kolkata',
|
|
|
|
|
+ 'Singapore': 'Asia/Singapore', 'Ankara': 'Europe/Istanbul', 'Warsaw': 'Europe/Warsaw',
|
|
|
|
|
+ 'Madrid': 'Europe/Madrid', 'Taipei': 'Asia/Taipei', 'Houston': 'America/Chicago',
|
|
|
|
|
+ 'Denver': 'America/Denver', 'Los Angeles': 'America/Los_Angeles', 'LA': 'America/Los_Angeles',
|
|
|
|
|
+ 'Phoenix': 'America/Phoenix', 'San Francisco': 'America/Los_Angeles',
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function getCityLocalTime(city: string): string {
|
|
|
|
|
+ const tz = CITY_TIMEZONES[city];
|
|
|
|
|
+ if (!tz) return '';
|
|
|
|
|
+ try {
|
|
|
|
|
+ return new Date().toLocaleTimeString('en-US', {
|
|
|
|
|
+ timeZone: tz,
|
|
|
|
|
+ hour: 'numeric',
|
|
|
|
|
+ minute: '2-digit',
|
|
|
|
|
+ hour12: true,
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
function ExternalLink({ href, children, className = '' }: { href: string; children: React.ReactNode; className?: string }) {
|
|
function ExternalLink({ href, children, className = '' }: { href: string; children: React.ReactNode; className?: string }) {
|
|
|
return (
|
|
return (
|
|
|
- <a
|
|
|
|
|
- href={href}
|
|
|
|
|
- target="_blank"
|
|
|
|
|
- rel="noopener noreferrer"
|
|
|
|
|
- className={`inline-flex items-center gap-1 hover:underline ${className}`}
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ <a href={href} target="_blank" rel="noopener noreferrer" className={`inline-flex items-center gap-1 hover:underline ${className}`}>
|
|
|
{children}
|
|
{children}
|
|
|
<svg className="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<svg className="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
@@ -20,12 +49,230 @@ function ExternalLink({ href, children, className = '' }: { href: string; childr
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function parseBucketBounds(label: string): { lower: number; upper: number } | null {
|
|
|
|
|
+ const singleMatch = label.match(/^(-?\d+)\s*°[FC]$/i);
|
|
|
|
|
+ if (singleMatch) {
|
|
|
|
|
+ const v = parseInt(singleMatch[1]);
|
|
|
|
|
+ return { lower: v, upper: v };
|
|
|
|
|
+ }
|
|
|
|
|
+ const rangeMatch = label.match(/(-?\d+)\s*[-–]\s*(-?\d+)/);
|
|
|
|
|
+ if (rangeMatch) {
|
|
|
|
|
+ return { lower: parseInt(rangeMatch[1]), upper: parseInt(rangeMatch[2]) };
|
|
|
|
|
+ }
|
|
|
|
|
+ const belowMatch = label.match(/(-?\d+)\s*°[FC]\s*or\s*below/i);
|
|
|
|
|
+ if (belowMatch) {
|
|
|
|
|
+ return { lower: -Infinity, upper: parseInt(belowMatch[1]) };
|
|
|
|
|
+ }
|
|
|
|
|
+ const higherMatch = label.match(/(-?\d+)\s*°[FC]\s*or\s*higher/i);
|
|
|
|
|
+ if (higherMatch) {
|
|
|
|
|
+ return { lower: parseInt(higherMatch[1]), upper: Infinity };
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Get the current local date for a city as "March 19" style string.
|
|
|
|
|
+ * Falls back to browser local date if timezone is unknown.
|
|
|
|
|
+ */
|
|
|
|
|
+function getCityTodayStr(city: string): string {
|
|
|
|
|
+ const tz = CITY_TIMEZONES[city];
|
|
|
|
|
+ if (tz) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Format in the city's timezone
|
|
|
|
|
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, month: 'long', day: 'numeric' }).formatToParts(new Date());
|
|
|
|
|
+ const month = parts.find((p) => p.type === 'month')?.value ?? '';
|
|
|
|
|
+ const day = parts.find((p) => p.type === 'day')?.value ?? '';
|
|
|
|
|
+ return `${month} ${day}`;
|
|
|
|
|
+ } catch { /* fall through */ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const now = new Date();
|
|
|
|
|
+ return `${MONTHS[now.getMonth()]} ${now.getDate()}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Check if a market's date matches the city's current local date */
|
|
|
|
|
+function isTodayForCity(dateStr: string, city: string): boolean {
|
|
|
|
|
+ return dateStr.trim() === getCityTodayStr(city);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Collect all market dates that are "today" for at least one city.
|
|
|
|
|
+ * Used to label the "Today" tab and determine which markets to show.
|
|
|
|
|
+ */
|
|
|
|
|
+function getTodayDates(markets: WeatherMarket[]): Set<string> {
|
|
|
|
|
+ const todayDates = new Set<string>();
|
|
|
|
|
+ for (const m of markets) {
|
|
|
|
|
+ if (isTodayForCity(m.date, m.city)) {
|
|
|
|
|
+ todayDates.add(m.date.trim());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return todayDates;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function tempInBucket(temp: number, label: string): boolean {
|
|
|
|
|
+ const bounds = parseBucketBounds(label);
|
|
|
|
|
+ if (!bounds) return false;
|
|
|
|
|
+ return temp >= bounds.lower && temp <= bounds.upper;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Extract unique date strings from markets, sorted chronologically */
|
|
|
|
|
+function getUniqueDates(markets: WeatherMarket[]): string[] {
|
|
|
|
|
+ const dateSet = new Set(markets.map((m) => m.date.trim()));
|
|
|
|
|
+ const dates = Array.from(dateSet);
|
|
|
|
|
+ const now = new Date();
|
|
|
|
|
+ dates.sort((a, b) => {
|
|
|
|
|
+ const da = new Date(`${a}, ${now.getFullYear()}`);
|
|
|
|
|
+ const db = new Date(`${b}, ${now.getFullYear()}`);
|
|
|
|
|
+ return da.getTime() - db.getTime();
|
|
|
|
|
+ });
|
|
|
|
|
+ return dates;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── Compact Price Bar ──────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function CompactPriceBar({
|
|
|
|
|
+ bucket,
|
|
|
|
|
+ maxPrice,
|
|
|
|
|
+ isHighlighted,
|
|
|
|
|
+}: {
|
|
|
|
|
+ bucket: MarketBucket;
|
|
|
|
|
+ maxPrice: number;
|
|
|
|
|
+ isHighlighted: boolean;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const pct = maxPrice > 0 ? (bucket.marketPrice / maxPrice) * 100 : 0;
|
|
|
|
|
+ const cents = (bucket.marketPrice * 100).toFixed(0);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className={`flex items-center gap-1.5 text-xs px-1.5 py-0.5 rounded transition-colors ${
|
|
|
|
|
+ isHighlighted
|
|
|
|
|
+ ? 'bg-amber-100 dark:bg-amber-500/20 ring-1 ring-amber-400 dark:ring-amber-500/50'
|
|
|
|
|
+ : ''
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className={`font-mono w-[72px] truncate ${isHighlighted ? 'text-amber-700 dark:text-amber-300 font-semibold' : 'text-sub'}`}>
|
|
|
|
|
+ {bucket.label}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div className="flex-1 h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden min-w-[40px]">
|
|
|
|
|
+ <div
|
|
|
|
|
+ className={`h-full rounded-full transition-all ${
|
|
|
|
|
+ isHighlighted ? 'bg-amber-500 dark:bg-amber-400' : 'bg-blue-500'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ style={{ width: `${Math.min(pct, 100)}%` }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span className={`font-mono w-7 text-right ${isHighlighted ? 'text-amber-700 dark:text-amber-300 font-semibold' : 'text-dim'}`}>
|
|
|
|
|
+ {cents}¢
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── Compact Market Card ────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function CompactMarketCard({
|
|
|
|
|
+ market,
|
|
|
|
|
+ tempData,
|
|
|
|
|
+ onExpand,
|
|
|
|
|
+}: {
|
|
|
|
|
+ market: WeatherMarket;
|
|
|
|
|
+ tempData?: { current: number; high: number };
|
|
|
|
|
+ onExpand: () => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const maxPrice = Math.max(...market.buckets.map((b) => b.marketPrice), 0.01);
|
|
|
|
|
+ const today = isTodayForCity(market.date, market.city);
|
|
|
|
|
+
|
|
|
|
|
+ // Live clock for today's cards
|
|
|
|
|
+ const [localTime, setLocalTime] = useState(() => today ? getCityLocalTime(market.city) : '');
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!today) return;
|
|
|
|
|
+ setLocalTime(getCityLocalTime(market.city));
|
|
|
|
|
+ const timer = setInterval(() => setLocalTime(getCityLocalTime(market.city)), 30_000);
|
|
|
|
|
+ return () => clearInterval(timer);
|
|
|
|
|
+ }, [today, market.city]);
|
|
|
|
|
+
|
|
|
|
|
+ const topBucket = market.buckets.reduce((best, b) =>
|
|
|
|
|
+ b.marketPrice > best.marketPrice ? b : best, market.buckets[0]);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className={`card-hover overflow-hidden cursor-pointer transition-all ${
|
|
|
|
|
+ today ? 'ring-1 ring-purple-300 dark:ring-purple-500/40' : ''
|
|
|
|
|
+ }`}
|
|
|
|
|
+ onClick={onExpand}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* Header */}
|
|
|
|
|
+ <div className="flex items-start justify-between px-3 pt-3 pb-1.5">
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
|
|
+ <h3 className="text-sm font-semibold text-heading truncate">{market.city}</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {today && localTime ? (
|
|
|
|
|
+ <p className="text-xs text-purple-600 dark:text-purple-400 font-medium mt-0.5">
|
|
|
|
|
+ {localTime}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <p className="text-[11px] text-sub mt-0.5 truncate">
|
|
|
|
|
+ {market.date}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-right shrink-0 ml-2">
|
|
|
|
|
+ {today && tempData ? (
|
|
|
|
|
+ <div className="leading-tight">
|
|
|
|
|
+ <div className="text-xl font-bold text-amber-600 dark:text-amber-400">
|
|
|
|
|
+ {tempData.current}°{market.tempUnit}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-[10px] text-red-500 dark:text-red-400 font-medium">
|
|
|
|
|
+ H: {tempData.high}°{market.tempUnit}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="text-[11px] text-sub font-mono">
|
|
|
|
|
+ ${market.totalVolume.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Compact bucket bars */}
|
|
|
|
|
+ <div className="px-2 pb-2 space-y-0.5">
|
|
|
|
|
+ {market.buckets.map((bucket) => (
|
|
|
|
|
+ <CompactPriceBar
|
|
|
|
|
+ key={bucket.label}
|
|
|
|
|
+ bucket={bucket}
|
|
|
|
|
+ maxPrice={maxPrice}
|
|
|
|
|
+ isHighlighted={today && tempData != null && tempInBucket(tempData.current, bucket.label)}
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Footer */}
|
|
|
|
|
+ <div className="flex items-center justify-between px-3 py-1.5 border-t border-gray-100 dark:border-slate-700/30 bg-gray-50/50 dark:bg-slate-800/20">
|
|
|
|
|
+ <span className="text-[10px] text-dim font-mono">
|
|
|
|
|
+ Top: {topBucket?.label} @ {(topBucket?.marketPrice * 100).toFixed(0)}¢
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {market.polymarketUrl && (
|
|
|
|
|
+ <span onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
+ <ExternalLink href={market.polymarketUrl} className="text-purple-600 dark:text-purple-400 text-[10px] font-medium">
|
|
|
|
|
+ PM
|
|
|
|
|
+ </ExternalLink>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── Expanded Detail Modal ──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
function PriceBadge({ price }: { price: number }) {
|
|
function PriceBadge({ price }: { price: number }) {
|
|
|
const cents = (price * 100).toFixed(1);
|
|
const cents = (price * 100).toFixed(1);
|
|
|
return (
|
|
return (
|
|
|
<span className="font-mono text-sm">
|
|
<span className="font-mono text-sm">
|
|
|
<span className="text-heading">{cents}</span>
|
|
<span className="text-heading">{cents}</span>
|
|
|
- <span className="text-dim">{'\u00a2'}</span>
|
|
|
|
|
|
|
+ <span className="text-dim">¢</span>
|
|
|
</span>
|
|
</span>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
@@ -34,10 +281,7 @@ function PriceBar({ price, maxPrice }: { price: number; maxPrice: number }) {
|
|
|
const pct = maxPrice > 0 ? (price / maxPrice) * 100 : 0;
|
|
const pct = maxPrice > 0 ? (price / maxPrice) * 100 : 0;
|
|
|
return (
|
|
return (
|
|
|
<div className="w-24 h-2 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
|
<div className="w-24 h-2 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
|
|
- <div
|
|
|
|
|
- className="h-full bg-blue-500 rounded-full transition-all"
|
|
|
|
|
- style={{ width: `${Math.min(pct, 100)}%` }}
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ <div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${Math.min(pct, 100)}%` }} />
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
@@ -61,22 +305,31 @@ function BucketRow({
|
|
|
bucket,
|
|
bucket,
|
|
|
maxPrice,
|
|
maxPrice,
|
|
|
isSelected,
|
|
isSelected,
|
|
|
|
|
+ isHighlighted,
|
|
|
onSelect,
|
|
onSelect,
|
|
|
}: {
|
|
}: {
|
|
|
bucket: MarketBucket;
|
|
bucket: MarketBucket;
|
|
|
maxPrice: number;
|
|
maxPrice: number;
|
|
|
isSelected: boolean;
|
|
isSelected: boolean;
|
|
|
|
|
+ isHighlighted: boolean;
|
|
|
onSelect: () => void;
|
|
onSelect: () => void;
|
|
|
}) {
|
|
}) {
|
|
|
return (
|
|
return (
|
|
|
<tr
|
|
<tr
|
|
|
className={`cursor-pointer transition-colors ${
|
|
className={`cursor-pointer transition-colors ${
|
|
|
- isSelected ? 'bg-blue-50 dark:bg-slate-700/50' : 'hover:bg-gray-50 dark:hover:bg-slate-800/50'
|
|
|
|
|
|
|
+ isHighlighted
|
|
|
|
|
+ ? 'bg-amber-50 dark:bg-amber-500/10'
|
|
|
|
|
+ : isSelected
|
|
|
|
|
+ ? 'bg-blue-50 dark:bg-slate-700/50'
|
|
|
|
|
+ : 'hover:bg-gray-50 dark:hover:bg-slate-800/50'
|
|
|
}`}
|
|
}`}
|
|
|
onClick={onSelect}
|
|
onClick={onSelect}
|
|
|
>
|
|
>
|
|
|
<td className="px-3 py-2">
|
|
<td className="px-3 py-2">
|
|
|
- <span className="font-mono text-sm text-heading">{bucket.label}</span>
|
|
|
|
|
|
|
+ <span className={`font-mono text-sm ${isHighlighted ? 'text-amber-700 dark:text-amber-300 font-bold' : 'text-heading'}`}>
|
|
|
|
|
+ {bucket.label}
|
|
|
|
|
+ {isHighlighted && <span className="ml-1.5 text-[10px] text-amber-600 dark:text-amber-400">● NOW</span>}
|
|
|
|
|
+ </span>
|
|
|
</td>
|
|
</td>
|
|
|
<td className="px-3 py-2">
|
|
<td className="px-3 py-2">
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-2">
|
|
@@ -105,100 +358,243 @@ function BucketRow({
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function MarketCard({ market }: { market: WeatherMarket }) {
|
|
|
|
|
|
|
+function ExpandedMarketCard({
|
|
|
|
|
+ market,
|
|
|
|
|
+ tempData,
|
|
|
|
|
+ onClose,
|
|
|
|
|
+}: {
|
|
|
|
|
+ market: WeatherMarket;
|
|
|
|
|
+ tempData?: { current: number; high: number };
|
|
|
|
|
+ onClose: () => void;
|
|
|
|
|
+}) {
|
|
|
const [selectedBucket, setSelectedBucket] = useState<string | null>(null);
|
|
const [selectedBucket, setSelectedBucket] = useState<string | null>(null);
|
|
|
const maxPrice = Math.max(...market.buckets.map((b) => b.marketPrice), 0.01);
|
|
const maxPrice = Math.max(...market.buckets.map((b) => b.marketPrice), 0.01);
|
|
|
-
|
|
|
|
|
const selectedBucketData = market.buckets.find((b) => b.label === selectedBucket);
|
|
const selectedBucketData = market.buckets.find((b) => b.label === selectedBucket);
|
|
|
|
|
+ const today = isTodayForCity(market.date, market.city);
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div className="card-hover overflow-hidden">
|
|
|
|
|
- {/* Header */}
|
|
|
|
|
- <div className="flex items-start justify-between p-5 pb-3">
|
|
|
|
|
- <div className="flex-1">
|
|
|
|
|
- <div className="flex items-center gap-3">
|
|
|
|
|
- <h3 className="text-lg font-semibold text-heading">{market.city}</h3>
|
|
|
|
|
- {market.polymarketUrl && (
|
|
|
|
|
- <ExternalLink href={market.polymarketUrl} className="text-purple-600 dark:text-purple-400 text-xs font-medium">
|
|
|
|
|
- Polymarket
|
|
|
|
|
- </ExternalLink>
|
|
|
|
|
|
|
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 dark:bg-black/60" onClick={onClose}>
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="card-hover overflow-hidden w-full max-w-2xl max-h-[80vh] overflow-y-auto"
|
|
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* Header */}
|
|
|
|
|
+ <div className="flex items-start justify-between p-5 pb-3">
|
|
|
|
|
+ <div className="flex-1">
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ <h3 className="text-lg font-semibold text-heading">{market.city}</h3>
|
|
|
|
|
+ {today && tempData && (
|
|
|
|
|
+ <span className="text-sm font-bold text-amber-600 dark:text-amber-400">
|
|
|
|
|
+ Now: {tempData.current}°{market.tempUnit}
|
|
|
|
|
+ <span className="ml-2 text-red-500 dark:text-red-400 text-xs font-medium">
|
|
|
|
|
+ H: {tempData.high}°{market.tempUnit}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {market.polymarketUrl && (
|
|
|
|
|
+ <ExternalLink href={market.polymarketUrl} className="text-purple-600 dark:text-purple-400 text-xs font-medium">
|
|
|
|
|
+ Polymarket
|
|
|
|
|
+ </ExternalLink>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p className="text-sm text-sub mt-0.5">
|
|
|
|
|
+ {market.date} · {market.tempUnit === 'F' ? 'Fahrenheit' : 'Celsius'} ·{' '}
|
|
|
|
|
+ {market.buckets.length} buckets
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-right">
|
|
|
|
|
+ <p className="text-sm text-body font-mono">
|
|
|
|
|
+ ${market.totalVolume.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="text-xs text-dim">24h volume</p>
|
|
|
|
|
+ {market.priceSum !== undefined && (
|
|
|
|
|
+ <div className="mt-1">
|
|
|
|
|
+ <ParitySummary priceSum={market.priceSum} />
|
|
|
|
|
+ </div>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
- <p className="text-sm text-sub mt-0.5">
|
|
|
|
|
- {market.date} · {market.tempUnit === 'F' ? 'Fahrenheit' : 'Celsius'} ·{' '}
|
|
|
|
|
- {market.buckets.length} buckets
|
|
|
|
|
- </p>
|
|
|
|
|
|
|
+ <button onClick={onClose} className="ml-3 text-dim hover:text-heading transition-colors text-xl leading-none">
|
|
|
|
|
+ ×
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
- <div className="text-right">
|
|
|
|
|
- <p className="text-sm text-body font-mono">
|
|
|
|
|
- ${market.totalVolume.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
|
|
|
|
- </p>
|
|
|
|
|
- <p className="text-xs text-dim">24h volume</p>
|
|
|
|
|
- {market.priceSum !== undefined && (
|
|
|
|
|
- <div className="mt-1">
|
|
|
|
|
- <ParitySummary priceSum={market.priceSum} />
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+
|
|
|
|
|
+ {/* Price Table */}
|
|
|
|
|
+ <div className="overflow-x-auto">
|
|
|
|
|
+ <table className="w-full">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr className="border-t border-b border-gray-200 dark:border-slate-700/50">
|
|
|
|
|
+ <th className="px-3 py-2 text-left text-xs font-medium text-sub">Bucket</th>
|
|
|
|
|
+ <th className="px-3 py-2 text-left text-xs font-medium text-sub">YES Price</th>
|
|
|
|
|
+ <th className="px-3 py-2 text-right text-xs font-medium text-sub">24h Vol</th>
|
|
|
|
|
+ <th className="px-3 py-2 text-center text-xs font-medium text-sub">Link</th>
|
|
|
|
|
+ <th className="px-3 py-2 w-6" />
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody className="divide-y divide-gray-100 dark:divide-slate-700/30">
|
|
|
|
|
+ {market.buckets.map((bucket) => (
|
|
|
|
|
+ <BucketRow
|
|
|
|
|
+ key={bucket.label}
|
|
|
|
|
+ bucket={bucket}
|
|
|
|
|
+ maxPrice={maxPrice}
|
|
|
|
|
+ isSelected={selectedBucket === bucket.label}
|
|
|
|
|
+ isHighlighted={today && tempData != null && tempInBucket(tempData.current, bucket.label)}
|
|
|
|
|
+ onSelect={() =>
|
|
|
|
|
+ setSelectedBucket(selectedBucket === bucket.label ? null : bucket.label)
|
|
|
|
|
+ }
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- {/* Price Table */}
|
|
|
|
|
- <div className="overflow-x-auto">
|
|
|
|
|
- <table className="w-full">
|
|
|
|
|
- <thead>
|
|
|
|
|
- <tr className="border-t border-b border-gray-200 dark:border-slate-700/50">
|
|
|
|
|
- <th className="px-3 py-2 text-left text-xs font-medium text-sub">Bucket</th>
|
|
|
|
|
- <th className="px-3 py-2 text-left text-xs font-medium text-sub">YES Price</th>
|
|
|
|
|
- <th className="px-3 py-2 text-right text-xs font-medium text-sub">24h Vol</th>
|
|
|
|
|
- <th className="px-3 py-2 text-center text-xs font-medium text-sub">Link</th>
|
|
|
|
|
- <th className="px-3 py-2 w-6" />
|
|
|
|
|
- </tr>
|
|
|
|
|
- </thead>
|
|
|
|
|
- <tbody className="divide-y divide-gray-100 dark:divide-slate-700/30">
|
|
|
|
|
- {market.buckets.map((bucket) => (
|
|
|
|
|
- <BucketRow
|
|
|
|
|
- key={bucket.label}
|
|
|
|
|
- bucket={bucket}
|
|
|
|
|
- maxPrice={maxPrice}
|
|
|
|
|
- isSelected={selectedBucket === bucket.label}
|
|
|
|
|
- onSelect={() =>
|
|
|
|
|
- setSelectedBucket(
|
|
|
|
|
- selectedBucket === bucket.label ? null : bucket.label,
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
- />
|
|
|
|
|
- ))}
|
|
|
|
|
- </tbody>
|
|
|
|
|
- </table>
|
|
|
|
|
|
|
+ {/* Orderbook Panel */}
|
|
|
|
|
+ {selectedBucketData?.yesTokenId && (
|
|
|
|
|
+ <div className="border-t border-gray-200 dark:border-slate-700/50 p-5 bg-gray-50 dark:bg-slate-800/30">
|
|
|
|
|
+ <Orderbook
|
|
|
|
|
+ tokenId={selectedBucketData.yesTokenId}
|
|
|
|
|
+ noTokenId={selectedBucketData.noTokenId}
|
|
|
|
|
+ label={selectedBucketData.label}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- {/* Orderbook Panel */}
|
|
|
|
|
- {selectedBucketData?.yesTokenId && (
|
|
|
|
|
- <div className="border-t border-gray-200 dark:border-slate-700/50 p-5 bg-gray-50 dark:bg-slate-800/30">
|
|
|
|
|
- <Orderbook
|
|
|
|
|
- tokenId={selectedBucketData.yesTokenId}
|
|
|
|
|
- noTokenId={selectedBucketData.noTokenId}
|
|
|
|
|
- label={selectedBucketData.label}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+// ─── Date Tabs ──────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function DateTabs({
|
|
|
|
|
+ dates,
|
|
|
|
|
+ selected,
|
|
|
|
|
+ onSelect,
|
|
|
|
|
+ marketCounts,
|
|
|
|
|
+ todayCount,
|
|
|
|
|
+}: {
|
|
|
|
|
+ dates: string[];
|
|
|
|
|
+ selected: string;
|
|
|
|
|
+ onSelect: (date: string) => void;
|
|
|
|
|
+ marketCounts: Record<string, number>;
|
|
|
|
|
+ todayCount: number;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const tabStyle = (active: boolean, highlight?: boolean) =>
|
|
|
|
|
+ `shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
|
|
|
|
+ active
|
|
|
|
|
+ ? 'bg-purple-600 text-white'
|
|
|
|
|
+ : highlight
|
|
|
|
|
+ ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300 hover:bg-purple-200 dark:hover:bg-purple-500/30'
|
|
|
|
|
+ : 'bg-gray-100 dark:bg-slate-800 text-sub hover:bg-gray-200 dark:hover:bg-slate-700'
|
|
|
|
|
+ }`;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none">
|
|
|
|
|
+ <button onClick={() => onSelect('ALL')} className={tabStyle(selected === 'ALL')}>
|
|
|
|
|
+ All ({Object.values(marketCounts).reduce((a, b) => a + b, 0)})
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {todayCount > 0 && (
|
|
|
|
|
+ <button onClick={() => onSelect('TODAY')} className={tabStyle(selected === 'TODAY', true)}>
|
|
|
|
|
+ Today ({todayCount})
|
|
|
|
|
+ </button>
|
|
|
)}
|
|
)}
|
|
|
|
|
+ {dates.map((date) => (
|
|
|
|
|
+ <button key={date} onClick={() => onSelect(date)} className={tabStyle(selected === date)}>
|
|
|
|
|
+ {date} ({marketCounts[date] || 0})
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// ─── Main Component ─────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
export function LiveMarketsClient({ markets }: { markets: WeatherMarket[] }) {
|
|
export function LiveMarketsClient({ markets }: { markets: WeatherMarket[] }) {
|
|
|
|
|
+ const allDates = useMemo(() => getUniqueDates(markets), [markets]);
|
|
|
|
|
+
|
|
|
|
|
+ // Compute which markets are "today" per city timezone
|
|
|
|
|
+ const todayMarketIds = useMemo(() => {
|
|
|
|
|
+ const ids = new Set<string>();
|
|
|
|
|
+ for (const m of markets) {
|
|
|
|
|
+ if (isTodayForCity(m.date, m.city)) ids.add(m.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ return ids;
|
|
|
|
|
+ }, [markets]);
|
|
|
|
|
+
|
|
|
|
|
+ const todayCount = todayMarketIds.size;
|
|
|
|
|
+
|
|
|
|
|
+ // Default to "TODAY" if there are today markets, otherwise show all
|
|
|
|
|
+ const [selectedDate, setSelectedDate] = useState<string>(todayCount > 0 ? 'TODAY' : 'ALL');
|
|
|
const [search, setSearch] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
+ const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
|
|
|
+ const [currentTemps, setCurrentTemps] = useState<Record<string, { current: number; high: number }>>({});
|
|
|
|
|
+
|
|
|
|
|
+ const marketCounts = useMemo(() => {
|
|
|
|
|
+ const counts: Record<string, number> = {};
|
|
|
|
|
+ for (const m of markets) {
|
|
|
|
|
+ const d = m.date.trim();
|
|
|
|
|
+ counts[d] = (counts[d] || 0) + 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ return counts;
|
|
|
|
|
+ }, [markets]);
|
|
|
|
|
+
|
|
|
|
|
+ // Filter by date + search
|
|
|
|
|
+ const filtered = useMemo(() => {
|
|
|
|
|
+ let result = markets;
|
|
|
|
|
+ if (selectedDate === 'TODAY') {
|
|
|
|
|
+ result = result.filter((m) => todayMarketIds.has(m.id));
|
|
|
|
|
+ } else if (selectedDate !== 'ALL') {
|
|
|
|
|
+ result = result.filter((m) => m.date.trim() === selectedDate);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (search) {
|
|
|
|
|
+ const q = search.toLowerCase();
|
|
|
|
|
+ result = result.filter(
|
|
|
|
|
+ (m) => m.city.toLowerCase().includes(q) || m.date.toLowerCase().includes(q),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }, [markets, selectedDate, search, todayMarketIds]);
|
|
|
|
|
+
|
|
|
|
|
+ // Fetch current temps for today's markets
|
|
|
|
|
+ const fetchTemps = useCallback(async () => {
|
|
|
|
|
+ const todayMarkets = markets.filter((m) => todayMarketIds.has(m.id));
|
|
|
|
|
+ if (todayMarkets.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ const cities = todayMarkets.map((m) => ({ name: m.city, tempUnit: m.tempUnit }));
|
|
|
|
|
+ const unique = cities.filter((c, i) => cities.findIndex((x) => x.name === c.name) === i);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/current-temps', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ cities: unique }),
|
|
|
|
|
+ });
|
|
|
|
|
+ if (res.ok) {
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ setCurrentTemps(data.temps || {});
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Silently fail
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [markets, todayMarketIds]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ fetchTemps();
|
|
|
|
|
+ const interval = setInterval(fetchTemps, 5 * 60 * 1000);
|
|
|
|
|
+ return () => clearInterval(interval);
|
|
|
|
|
+ }, [fetchTemps]);
|
|
|
|
|
|
|
|
- const filtered = search
|
|
|
|
|
- ? markets.filter(
|
|
|
|
|
- (m) =>
|
|
|
|
|
- m.city.toLowerCase().includes(search.toLowerCase()) ||
|
|
|
|
|
- m.date.toLowerCase().includes(search.toLowerCase()),
|
|
|
|
|
- )
|
|
|
|
|
- : markets;
|
|
|
|
|
|
|
+ const expandedMarket = expandedId ? markets.find((m) => m.id === expandedId) : null;
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-4">
|
|
|
|
|
+ {/* Date Tabs */}
|
|
|
|
|
+ <DateTabs
|
|
|
|
|
+ dates={allDates}
|
|
|
|
|
+ selected={selectedDate}
|
|
|
|
|
+ onSelect={setSelectedDate}
|
|
|
|
|
+ marketCounts={marketCounts}
|
|
|
|
|
+ todayCount={todayCount}
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
{/* Search */}
|
|
{/* Search */}
|
|
|
<div className="relative">
|
|
<div className="relative">
|
|
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
@@ -206,25 +602,39 @@ export function LiveMarketsClient({ markets }: { markets: WeatherMarket[] }) {
|
|
|
</svg>
|
|
</svg>
|
|
|
<input
|
|
<input
|
|
|
type="text"
|
|
type="text"
|
|
|
- placeholder="Filter by city or date..."
|
|
|
|
|
|
|
+ placeholder="Filter by city..."
|
|
|
value={search}
|
|
value={search}
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
|
className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-sm text-heading placeholder-gray-400 dark:placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500"
|
|
className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-sm text-heading placeholder-gray-400 dark:placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500"
|
|
|
/>
|
|
/>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Market Grid */}
|
|
|
|
|
- <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
|
|
|
|
|
+ {/* Compact Market Grid */}
|
|
|
|
|
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
|
|
|
{filtered.map((market) => (
|
|
{filtered.map((market) => (
|
|
|
- <MarketCard key={market.id} market={market} />
|
|
|
|
|
|
|
+ <CompactMarketCard
|
|
|
|
|
+ key={market.id}
|
|
|
|
|
+ market={market}
|
|
|
|
|
+ tempData={currentTemps[market.city]}
|
|
|
|
|
+ onExpand={() => setExpandedId(market.id)}
|
|
|
|
|
+ />
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{filtered.length === 0 && (
|
|
{filtered.length === 0 && (
|
|
|
<p className="text-dim text-center py-8">
|
|
<p className="text-dim text-center py-8">
|
|
|
- No markets match "{search}"
|
|
|
|
|
|
|
+ {search ? `No markets match "${search}"` : 'No markets for this date.'}
|
|
|
</p>
|
|
</p>
|
|
|
)}
|
|
)}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Expanded Detail Modal */}
|
|
|
|
|
+ {expandedMarket && (
|
|
|
|
|
+ <ExpandedMarketCard
|
|
|
|
|
+ market={expandedMarket}
|
|
|
|
|
+ tempData={currentTemps[expandedMarket.city]}
|
|
|
|
|
+ onClose={() => setExpandedId(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|