Files
AITrader/app/components/MostActiveStocks.tsx
T

188 lines
7.3 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router";
import type { MostActiveStock } from "../types";
function formatVolume(vol: number): string {
if (vol >= 1_000_000_000) return `${(vol / 1_000_000_000).toFixed(1)}B`;
if (vol >= 1_000_000) return `${(vol / 1_000_000).toFixed(1)}M`;
if (vol >= 1_000) return `${(vol / 1_000).toFixed(1)}K`;
return vol.toString();
}
function formatPrice(price: number): string {
return `$${price.toFixed(2)}`;
}
function formatChangePercent(pct: number): string {
return `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`;
}
export default function MostActiveStocks() {
const [stocks, setStocks] = useState<MostActiveStock[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState<Record<string, boolean>>({});
const [saved, setSaved] = useState<Record<string, boolean>>({});
const fetchData = useCallback(async () => {
try {
setError(null);
const res = await fetch("/api/stocks/most-actives");
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to fetch data");
}
const data = await res.json();
setStocks(data);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch most active stocks.";
setError(message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, [fetchData]);
const handleSave = async (symbol: string) => {
setSaving((p) => ({ ...p, [symbol]: true }));
setSaved((p) => ({ ...p, [symbol]: false }));
try {
const form = new FormData();
form.set("ticker", symbol);
const res = await fetch("/api/stocks", {
method: "POST",
body: form,
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || "Failed to save stock");
}
// trigger analysis in background (non-blocking) and persist jobId to stock record
try {
const analyzeRes = await fetch(`/api/analyze`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ticker: symbol, background: true }) });
const analyzeData = await analyzeRes.json().catch(() => null);
if (analyzeRes.ok && analyzeData?.jobId) {
const fd = new FormData();
fd.append("ticker", symbol);
fd.append("lastJobId", analyzeData.jobId.toString());
await fetch("/api/stocks", { method: "POST", body: fd });
setSaved((p) => ({ ...p, [symbol]: true }));
}
} catch (err) {
console.warn("Failed to enqueue background analyze:", err);
}
setSaved((p) => ({ ...p, [symbol]: true }));
} catch (err) {
console.error(err);
} finally {
setSaving((p) => ({ ...p, [symbol]: false }));
}
};
if (loading) {
return (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
<div className="space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="animate-pulse flex gap-4 py-3 border-b border-gray-100 last:border-0">
<div className="h-5 bg-gray-200 rounded w-16" />
<div className="h-5 bg-gray-200 rounded w-32" />
<div className="h-5 bg-gray-200 rounded w-20" />
<div className="h-5 bg-gray-200 rounded w-20" />
<div className="h-5 bg-gray-200 rounded w-24" />
</div>
))}
</div>
</div>
);
}
if (error && stocks.length === 0) {
return (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button
onClick={fetchData}
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
Retry
</button>
</div>
</div>
);
}
if (stocks.length === 0) {
return (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
<p className="text-gray-600 text-center py-8">No data available</p>
</div>
);
}
return (
<div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden">
{error && (
<div className="bg-red-50 border-b border-red-200 px-6 py-3">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-6 py-4 text-sm font-semibold text-gray-600">Symbol</th>
<th className="text-left px-6 py-4 text-sm font-semibold text-gray-600">Name</th>
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Price</th>
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Change %</th>
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Volume</th>
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Actions</th>
</tr>
</thead>
<tbody>
{stocks.map((stock) => (
<tr key={stock.symbol} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<Link
to={`/analyze/${stock.symbol}`}
className="text-blue-600 font-semibold hover:text-blue-700 hover:underline"
>
{stock.symbol}
</Link>
</td>
<td className="px-6 py-4 text-gray-600">{stock.name}</td>
<td className="px-6 py-4 text-right font-mono text-gray-900">{formatPrice(stock.price)}</td>
<td className={`px-6 py-4 text-right font-mono font-medium ${stock.changePercent >= 0 ? "text-green-600" : "text-red-600"}`}>
{formatChangePercent(stock.changePercent)}
</td>
<td className="px-6 py-4 text-right text-gray-600">{formatVolume(stock.volume)}</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleSave(stock.symbol)}
disabled={!!saving[stock.symbol]}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
saving[stock.symbol]
? "bg-gray-300 text-gray-700 cursor-not-allowed"
: saved[stock.symbol]
? "bg-green-600 text-white"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}
>
{saving[stock.symbol] ? "Saving..." : saved[stock.symbol] ? "Saved" : "Save"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}