175 lines
6.7 KiB
TypeScript
175 lines
6.7 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 res = await fetch("/api/stocks", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ symbol }),
|
|
});
|
|
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)
|
|
fetch(`/api/analyze`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ticker: symbol, background: true }) }).catch(() => {});
|
|
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>
|
|
);
|
|
}
|