first commit
Some checks failed
Deploy / lint (push) Failing after 7s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-20 17:31:01 +01:00
commit 61ab24490d
160 changed files with 17034 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import { useState } from 'react';
import { useCleanupData } from '../../hooks/useAdmin';
export function DataCleanup() {
const [days, setDays] = useState(90);
const cleanup = useCleanupData();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (days < 1) return;
cleanup.mutate(days);
}
return (
<form onSubmit={handleSubmit} className="glass p-5 flex flex-wrap items-end gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="cleanup-days" className="text-xs text-gray-400">Older than (days)</label>
<input
id="cleanup-days"
type="number"
min={1}
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="w-28 input-glass px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
disabled={cleanup.isPending || days < 1}
className="rounded-lg bg-gradient-to-r from-red-600 to-red-500 px-4 py-2 text-sm font-medium text-white hover:from-red-500 hover:to-red-400 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{cleanup.isPending ? 'Cleaning…' : 'Run Cleanup'}
</button>
</form>
);
}

View File

@@ -0,0 +1,82 @@
import { useJobs, useToggleJob, useTriggerJob } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
function formatNextRun(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
const now = new Date();
const diffMs = d.getTime() - now.getTime();
if (diffMs < 0) return 'imminent';
const mins = Math.round(diffMs / 60_000);
if (mins < 60) return `in ${mins}m`;
const hrs = Math.round(mins / 60);
return `in ${hrs}h`;
}
export function JobControls() {
const { data: jobs, isLoading } = useJobs();
const toggleJob = useToggleJob();
const triggerJob = useTriggerJob();
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
return (
<div className="space-y-3">
{jobs?.map((job) => (
<div key={job.name} className="glass p-4 glass-hover">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
{/* Status dot */}
<span
className={`inline-block h-2.5 w-2.5 rounded-full shrink-0 ${
job.enabled
? 'bg-emerald-400 shadow-lg shadow-emerald-400/40'
: 'bg-gray-500'
}`}
/>
<div>
<span className="text-sm font-medium text-gray-200">{job.label}</span>
<div className="flex items-center gap-3 mt-0.5">
<span className={`text-[11px] font-medium ${job.enabled ? 'text-emerald-400' : 'text-gray-500'}`}>
{job.enabled ? 'Active' : 'Inactive'}
</span>
{job.enabled && job.next_run_at && (
<span className="text-[11px] text-gray-500">
Next run {formatNextRun(job.next_run_at)}
</span>
)}
{!job.registered && (
<span className="text-[11px] text-red-400">Not registered</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => toggleJob.mutate({ jobName: job.name, enabled: !job.enabled })}
disabled={toggleJob.isPending}
className={`rounded-lg border px-3 py-1.5 text-xs transition-all duration-200 disabled:opacity-50 ${
job.enabled
? 'border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
}`}
>
{job.enabled ? 'Disable' : 'Enable'}
</button>
<button
type="button"
onClick={() => triggerJob.mutate(job.name)}
disabled={triggerJob.isPending || !job.enabled}
className="btn-gradient px-3 py-1.5 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{triggerJob.isPending ? 'Triggering…' : 'Trigger Now'}</span>
</button>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import { useSettings, useUpdateSetting } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
import type { SystemSetting } from '../../lib/types';
export function SettingsForm() {
const { data: settings, isLoading, isError, error } = useSettings();
const updateSetting = useUpdateSetting();
const [edits, setEdits] = useState<Record<string, string>>({});
function currentValue(setting: SystemSetting) {
return edits[setting.key] ?? setting.value;
}
function handleChange(key: string, value: string) {
setEdits((prev) => ({ ...prev, [key]: value }));
}
function handleSave(key: string) {
const value = edits[key];
if (value === undefined) return;
updateSetting.mutate(
{ key, value },
{ onSuccess: () => setEdits((prev) => { const next = { ...prev }; delete next[key]; return next; }) },
);
}
function handleToggleRegistration(current: string) {
updateSetting.mutate({ key: 'registration', value: current === 'true' ? 'false' : 'true' });
}
if (isLoading) return <SkeletonTable rows={4} cols={2} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>;
if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>;
return (
<div className="space-y-4">
{settings.map((setting) => (
<div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover">
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
{setting.key === 'registration' ? (
<button
type="button"
onClick={() => handleToggleRegistration(setting.value)}
disabled={updateSetting.isPending}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-[#0a0e1a] disabled:opacity-50 ${
setting.value === 'true' ? 'bg-gradient-to-r from-blue-600 to-indigo-600' : 'bg-white/[0.1]'
}`}
role="switch"
aria-checked={setting.value === 'true'}
aria-label={`Toggle ${setting.key}`}
>
<span className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${setting.value === 'true' ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
) : (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={currentValue(setting)}
onChange={(e) => handleChange(setting.key, e.target.value)}
className="flex-1 input-glass px-3 py-2 text-sm"
/>
{edits[setting.key] !== undefined && edits[setting.key] !== setting.value && (
<button
onClick={() => handleSave(setting.key)}
disabled={updateSetting.isPending}
className="btn-gradient px-3 py-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{updateSetting.isPending ? 'Saving…' : 'Save'}</span>
</button>
)}
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react';
import { useTickers, useAddTicker, useDeleteTicker } from '../../hooks/useTickers';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { SkeletonTable } from '../ui/Skeleton';
import { formatDateTime } from '../../lib/format';
export function TickerManagement() {
const { data: tickers, isLoading, isError, error } = useTickers();
const addTicker = useAddTicker();
const deleteTicker = useDeleteTicker();
const [newSymbol, setNewSymbol] = useState('');
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
function handleAdd(e: React.FormEvent) {
e.preventDefault();
const symbol = newSymbol.trim().toUpperCase();
if (!symbol) return;
addTicker.mutate(symbol, { onSuccess: () => setNewSymbol('') });
}
function handleConfirmDelete() {
if (!deleteTarget) return;
deleteTicker.mutate(deleteTarget, { onSuccess: () => setDeleteTarget(null) });
}
return (
<div className="space-y-6">
<form onSubmit={handleAdd} className="flex gap-3">
<input
type="text"
value={newSymbol}
onChange={(e) => setNewSymbol(e.target.value)}
placeholder="Enter ticker symbol (e.g. AAPL)"
className="flex-1 input-glass px-3 py-2.5 text-sm"
/>
<button
type="submit"
disabled={addTicker.isPending || !newSymbol.trim()}
className="btn-gradient px-4 py-2.5 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{addTicker.isPending ? 'Adding…' : 'Add Ticker'}</span>
</button>
</form>
{isLoading && <SkeletonTable rows={5} cols={3} />}
{isError && <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load tickers'}</p>}
{tickers && tickers.length > 0 && (
<div className="glass overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="border-b border-white/[0.06] text-gray-500">
<tr>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Symbol</th>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Added</th>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{tickers.map((ticker) => (
<tr key={ticker.id} className="hover:bg-white/[0.03] transition-all duration-150">
<td className="px-4 py-3 font-medium text-gray-100">{ticker.symbol}</td>
<td className="px-4 py-3 text-gray-400">{formatDateTime(ticker.created_at)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setDeleteTarget(ticker.symbol)}
disabled={deleteTicker.isPending}
className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-1 text-xs text-red-400 hover:bg-red-500/20 disabled:opacity-50 transition-all duration-200"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{tickers && tickers.length === 0 && (
<p className="text-sm text-gray-500">No tickers registered yet. Add one above.</p>
)}
<ConfirmDialog
open={deleteTarget !== null}
title="Delete Ticker"
message={`Are you sure you want to delete ${deleteTarget}? This action cannot be undone.`}
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react';
import { useUsers, useCreateUser, useUpdateAccess, useResetPassword } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
import type { AdminUser } from '../../lib/types';
export function UserTable() {
const { data: users, isLoading, isError, error } = useUsers();
const createUser = useCreateUser();
const updateAccess = useUpdateAccess();
const resetPassword = useResetPassword();
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('user');
const [newAccess, setNewAccess] = useState(true);
const [resetTarget, setResetTarget] = useState<number | null>(null);
const [resetPw, setResetPw] = useState('');
function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newUsername.trim() || !newPassword.trim()) return;
createUser.mutate(
{ username: newUsername.trim(), password: newPassword, role: newRole, has_access: newAccess },
{ onSuccess: () => { setNewUsername(''); setNewPassword(''); setNewRole('user'); setNewAccess(true); } },
);
}
function handleToggleAccess(user: AdminUser) {
updateAccess.mutate({ userId: user.id, hasAccess: !user.has_access });
}
function handleResetPassword(userId: number) {
if (!resetPw.trim()) return;
resetPassword.mutate(
{ userId, password: resetPw },
{ onSuccess: () => { setResetTarget(null); setResetPw(''); } },
);
}
return (
<div className="space-y-6">
{/* Create user form */}
<form onSubmit={handleCreate} className="glass p-5 flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-gray-400">Username</label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)}
placeholder="username" className="input-glass px-3 py-2 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-gray-400">Password</label>
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)}
placeholder="password" className="input-glass px-3 py-2 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-gray-400">Role</label>
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className="input-glass px-3 py-2 text-sm">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<label className="flex items-center gap-2 text-sm text-gray-300 pb-1">
<input type="checkbox" checked={newAccess} onChange={(e) => setNewAccess(e.target.checked)}
className="rounded border-white/[0.1] bg-white/[0.04] text-blue-500 focus:ring-blue-500" />
Access
</label>
<button type="submit" disabled={createUser.isPending || !newUsername.trim() || !newPassword.trim()}
className="btn-gradient px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed">
<span>{createUser.isPending ? 'Creating…' : 'Create User'}</span>
</button>
</form>
{isLoading && <SkeletonTable rows={4} cols={4} />}
{isError && <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load users'}</p>}
{users && users.length > 0 && (
<div className="glass overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="border-b border-white/[0.06] text-gray-500">
<tr>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Username</th>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Role</th>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Access</th>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{users.map((user) => (
<tr key={user.id} className="hover:bg-white/[0.03] transition-all duration-150">
<td className="px-4 py-3 font-medium text-gray-100">{user.username}</td>
<td className="px-4 py-3 text-gray-300 capitalize">{user.role}</td>
<td className="px-4 py-3">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${user.has_access ? 'bg-emerald-400 shadow-lg shadow-emerald-400/40' : 'bg-red-400 shadow-lg shadow-red-400/40'}`} />
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2 flex-wrap">
<button onClick={() => handleToggleAccess(user)} disabled={updateAccess.isPending}
className="rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-xs text-gray-300 hover:bg-white/[0.08] disabled:opacity-50 transition-all duration-200">
{user.has_access ? 'Revoke' : 'Grant'}
</button>
{resetTarget === user.id ? (
<span className="flex items-center gap-1">
<input type="password" value={resetPw} onChange={(e) => setResetPw(e.target.value)}
placeholder="new password" className="w-32 input-glass px-2 py-1 text-xs" />
<button onClick={() => handleResetPassword(user.id)}
disabled={resetPassword.isPending || !resetPw.trim()}
className="btn-gradient px-2 py-1 text-xs disabled:opacity-50">
<span>Save</span>
</button>
<button onClick={() => { setResetTarget(null); setResetPw(''); }}
className="rounded-lg border border-white/[0.08] bg-white/[0.04] px-2 py-1 text-xs text-gray-400 hover:bg-white/[0.08] transition-all duration-200">
Cancel
</button>
</span>
) : (
<button onClick={() => { setResetTarget(user.id); setResetPw(''); }}
className="rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-xs text-gray-300 hover:bg-white/[0.08] transition-all duration-200">
Reset Password
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{users && users.length === 0 && <p className="text-sm text-gray-500">No users found.</p>}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
interface ProtectedRouteProps {
requireAdmin?: boolean;
}
export default function ProtectedRoute({ requireAdmin }: ProtectedRouteProps) {
const token = useAuthStore((s) => s.token);
const role = useAuthStore((s) => s.role);
if (!token) {
return <Navigate to="/login" replace />;
}
if (requireAdmin && role !== 'admin') {
return <Navigate to="/watchlist" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,232 @@
import { useMemo, useRef, useEffect, useCallback } from 'react';
import type { OHLCVBar, SRLevel } from '../../lib/types';
import { formatPrice, formatDate } from '../../lib/format';
interface CandlestickChartProps {
data: OHLCVBar[];
srLevels?: SRLevel[];
maxSRLevels?: number;
}
function filterTopSRLevels(levels: SRLevel[], max: number): SRLevel[] {
if (levels.length <= max) return levels;
return [...levels].sort((a, b) => b.strength - a.strength).slice(0, max);
}
interface TooltipState {
visible: boolean;
x: number;
y: number;
bar: OHLCVBar | null;
}
export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: CandlestickChartProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const tooltipState = useRef<TooltipState>({ visible: false, x: 0, y: 0, bar: null });
const animFrame = useRef<number>(0);
const topLevels = useMemo(() => filterTopSRLevels(srLevels, maxSRLevels), [srLevels, maxSRLevels]);
const draw = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || data.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
const W = rect.width;
const H = 400;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = `${W}px`;
canvas.style.height = `${H}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
// Margins
const ml = 12, mr = 70, mt = 12, mb = 32;
const cw = W - ml - mr;
const ch = H - mt - mb;
// Price range
const allPrices = data.flatMap((b) => [b.high, b.low]);
const srPrices = topLevels.map((l) => l.price_level);
const allVals = [...allPrices, ...srPrices];
const minP = Math.min(...allVals);
const maxP = Math.max(...allVals);
const pad = (maxP - minP) * 0.06 || 1;
const lo = minP - pad;
const hi = maxP + pad;
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
const barW = cw / data.length;
const candleW = Math.max(barW * 0.65, 1);
// Grid lines (horizontal)
const nTicks = 6;
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
ctx.fillStyle = '#6b7280';
ctx.font = '11px Inter, system-ui, sans-serif';
ctx.textAlign = 'right';
for (let i = 0; i <= nTicks; i++) {
const v = lo + ((hi - lo) * i) / nTicks;
const y = yScale(v);
ctx.beginPath();
ctx.moveTo(ml, y);
ctx.lineTo(ml + cw, y);
ctx.stroke();
ctx.fillText(formatPrice(v), W - 8, y + 4);
}
// X-axis labels
ctx.textAlign = 'center';
const labelInterval = Math.max(Math.floor(data.length / 8), 1);
for (let i = 0; i < data.length; i += labelInterval) {
const x = ml + i * barW + barW / 2;
ctx.fillStyle = '#6b7280';
ctx.fillText(formatDate(data[i].date), x, H - 6);
}
// S/R levels
topLevels.forEach((level) => {
const y = yScale(level.price_level);
const isSupport = level.type === 'support';
const color = isSupport ? '#10b981' : '#ef4444';
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.55;
ctx.setLineDash([6, 3]);
ctx.beginPath();
ctx.moveTo(ml, y);
ctx.lineTo(ml + cw, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
// Label
ctx.fillStyle = color;
ctx.font = '10px Inter, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(
`${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`,
ml + cw + 4,
y + 3
);
});
// Candles
data.forEach((bar, i) => {
const x = ml + i * barW + barW / 2;
const bullish = bar.close >= bar.open;
const color = bullish ? '#10b981' : '#ef4444';
const yHigh = yScale(bar.high);
const yLow = yScale(bar.low);
const yOpen = yScale(bar.open);
const yClose = yScale(bar.close);
// Wick
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, yHigh);
ctx.lineTo(x, yLow);
ctx.stroke();
// Body
const bodyTop = Math.min(yOpen, yClose);
const bodyH = Math.max(Math.abs(yOpen - yClose), 1);
ctx.fillStyle = color;
ctx.fillRect(x - candleW / 2, bodyTop, candleW, bodyH);
});
// Store geometry for hit testing
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale };
}, [data, topLevels]);
useEffect(() => {
draw();
const onResize = () => {
cancelAnimationFrame(animFrame.current);
animFrame.current = requestAnimationFrame(draw);
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
cancelAnimationFrame(animFrame.current);
};
}, [draw]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
const tip = tooltipRef.current;
if (!canvas || !tip || data.length === 0) return;
const meta = (canvas as any).__chartMeta;
if (!meta) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const idx = Math.floor((mx - meta.ml) / meta.barW);
if (idx >= 0 && idx < data.length) {
const bar = data[idx];
tooltipState.current = { visible: true, x: e.clientX - rect.left, y: e.clientY - rect.top, bar };
tip.style.display = 'block';
tip.style.left = `${Math.min(mx + 14, rect.width - 180)}px`;
tip.style.top = `${Math.max(e.clientY - rect.top - 80, 8)}px`;
tip.innerHTML = `
<div class="text-gray-300 font-medium mb-1">${formatDate(bar.date)}</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
<span>Open</span><span class="text-right text-gray-200">${formatPrice(bar.open)}</span>
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
<span>Vol</span><span class="text-right text-gray-200">${bar.volume.toLocaleString()}</span>
</div>`;
} else {
tip.style.display = 'none';
}
},
[data]
);
const handleMouseLeave = useCallback(() => {
const tip = tooltipRef.current;
if (tip) tip.style.display = 'none';
}, []);
if (data.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
No OHLCV data available
</div>
);
}
return (
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
<canvas
ref={canvasRef}
className="w-full cursor-crosshair"
style={{ height: 400 }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
<div
ref={tooltipRef}
className="glass absolute pointer-events-none px-3 py-2 text-xs shadow-2xl z-50"
style={{ display: 'none' }}
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import MobileNav from './MobileNav';
export default function AppShell() {
return (
<div className="flex min-h-screen text-gray-100">
<Sidebar />
<div className="flex-1 flex flex-col">
<MobileNav />
<main className="flex-1 p-4 lg:p-8 animate-fade-in">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
const navItems = [
{ to: '/watchlist', label: 'Watchlist' },
{ to: '/scanner', label: 'Scanner' },
{ to: '/rankings', label: 'Rankings' },
];
export default function MobileNav() {
const [open, setOpen] = useState(false);
const { role, username, logout } = useAuthStore();
return (
<div className="lg:hidden">
<div className="flex items-center justify-between px-4 py-3 glass rounded-none border-x-0 border-t-0">
<h1 className="text-lg font-semibold text-gradient">Signal Dashboard</h1>
<button
onClick={() => setOpen((v) => !v)}
className="p-2 text-gray-400 hover:text-gray-200 transition-colors duration-200"
aria-label="Toggle menu"
>
{open ? (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
<div
className={`overflow-hidden transition-all duration-300 ease-out glass rounded-none border-x-0 border-t-0 ${
open ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0 border-b-0'
}`}
>
<nav className="px-3 py-2 space-y-1">
{navItems.map(({ to, label }) => (
<NavLink
key={to}
to={to}
onClick={() => setOpen(false)}
className={({ isActive }) =>
`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-white/[0.08] text-white'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
}`
}
>
{label}
</NavLink>
))}
{role === 'admin' && (
<NavLink
to="/admin"
onClick={() => setOpen(false)}
className={({ isActive }) =>
`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-white/[0.08] text-white'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
}`
}
>
Admin
</NavLink>
)}
</nav>
<div className="px-4 py-3 border-t border-white/[0.06]">
{username && (
<p className="text-xs text-gray-500 mb-2 truncate">{username}</p>
)}
<button
onClick={() => { logout(); setOpen(false); }}
className="w-full text-left px-3 py-2 text-sm text-gray-400 hover:text-gray-200 hover:bg-white/[0.04] rounded-lg transition-all duration-200"
>
Logout
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { NavLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import { check as healthCheck } from '../../api/health';
const navItems = [
{ to: '/watchlist', label: 'Watchlist', icon: '◈' },
{ to: '/scanner', label: 'Scanner', icon: '⬡' },
{ to: '/rankings', label: 'Rankings', icon: '△' },
];
export default function Sidebar() {
const { role, username, logout } = useAuthStore();
const health = useQuery({
queryKey: ['health'],
queryFn: healthCheck,
refetchInterval: 30_000,
retry: 1,
});
const isBackendUp = health.isSuccess;
return (
<aside className="hidden lg:flex lg:flex-col lg:w-64 h-screen sticky top-0 glass border-r border-white/[0.06] rounded-none border-l-0 border-t-0 border-b-0">
{/* Logo area */}
<div className="px-6 py-6 border-b border-white/[0.06]">
<h1 className="text-lg font-semibold text-gradient">Signal Dashboard</h1>
<p className="text-[11px] text-gray-500 mt-0.5 tracking-wide">TRADING INTELLIGENCE</p>
</div>
<nav className="flex-1 px-3 py-5 space-y-1">
{navItems.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-white/[0.08] text-white shadow-lg shadow-blue-500/5 border border-white/[0.08]'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 border border-transparent'
}`
}
>
<span className="text-base opacity-60">{icon}</span>
{label}
</NavLink>
))}
{role === 'admin' && (
<NavLink
to="/admin"
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-white/[0.08] text-white shadow-lg shadow-blue-500/5 border border-white/[0.08]'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 border border-transparent'
}`
}
>
<span className="text-base opacity-60"></span>
Admin
</NavLink>
)}
</nav>
<div className="px-4 py-4 border-t border-white/[0.06] space-y-3">
<div className="flex items-center gap-2 px-1">
<span
className={`inline-block h-2 w-2 rounded-full ${
isBackendUp ? 'bg-emerald-400 shadow-lg shadow-emerald-400/50' : 'bg-red-400 shadow-lg shadow-red-400/50'
}`}
aria-label={isBackendUp ? 'Backend online' : 'Backend offline'}
/>
<span className="text-xs text-gray-500">
{isBackendUp ? 'Backend online' : 'Backend offline'}
</span>
</div>
{username && (
<p className="text-xs text-gray-500 truncate px-1">{username}</p>
)}
<button
onClick={logout}
className="w-full px-3 py-2 text-sm text-gray-400 hover:text-gray-200 hover:bg-white/[0.04] rounded-lg transition-all duration-200"
>
Logout
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,57 @@
import { Link } from 'react-router-dom';
import type { RankingEntry } from '../../lib/types';
interface RankingsTableProps {
rankings: RankingEntry[];
}
function scoreColor(score: number): string {
if (score > 70) return 'text-emerald-400';
if (score >= 40) return 'text-amber-400';
return 'text-red-400';
}
export function RankingsTable({ rankings }: RankingsTableProps) {
if (rankings.length === 0) {
return <p className="py-8 text-center text-sm text-gray-500">No rankings available.</p>;
}
const dimensionNames = rankings.length > 0 ? rankings[0].dimensions.map((d) => d.dimension) : [];
return (
<div className="glass overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Symbol</th>
<th className="px-4 py-3">Composite</th>
{dimensionNames.map((dim) => (
<th key={dim} className="px-4 py-3">{dim}</th>
))}
</tr>
</thead>
<tbody>
{rankings.map((entry, index) => (
<tr key={entry.symbol} className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]">
<td className="px-4 py-3.5 font-mono text-gray-500">{index + 1}</td>
<td className="px-4 py-3.5">
<Link to={`/ticker/${entry.symbol}`} className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150">
{entry.symbol}
</Link>
</td>
<td className={`px-4 py-3.5 font-semibold ${scoreColor(entry.composite_score)}`}>
{Math.round(entry.composite_score)}
</td>
{entry.dimensions.map((dim) => (
<td key={dim.dimension} className={`px-4 py-3.5 font-mono ${scoreColor(dim.score)}`}>
{Math.round(dim.score)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useState, type FormEvent } from 'react';
import { useUpdateWeights } from '../../hooks/useScores';
interface WeightsFormProps {
weights: Record<string, number>;
}
export function WeightsForm({ weights }: WeightsFormProps) {
const [localWeights, setLocalWeights] = useState<Record<string, number>>(weights);
const updateWeights = useUpdateWeights();
const handleChange = (key: string, value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) setLocalWeights((prev) => ({ ...prev, [key]: num }));
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
updateWeights.mutate(localWeights);
};
return (
<form onSubmit={handleSubmit} className="glass p-5">
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-500">
Scoring Weights
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{Object.keys(weights).map((key) => (
<label key={key} className="flex flex-col gap-1.5">
<span className="text-xs text-gray-400 capitalize">{key.replace(/_/g, ' ')}</span>
<input
type="number"
step="any"
value={localWeights[key] ?? 0}
onChange={(e) => handleChange(key, e.target.value)}
className="input-glass px-2.5 py-1.5 text-sm"
/>
</label>
))}
</div>
<button
type="submit"
disabled={updateWeights.isPending}
className="mt-4 btn-gradient px-4 py-2 text-sm disabled:opacity-50"
>
<span>{updateWeights.isPending ? 'Updating…' : 'Update Weights'}</span>
</button>
</form>
);
}

View File

@@ -0,0 +1,81 @@
import { Link } from 'react-router-dom';
import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatDateTime } from '../../lib/format';
export type SortColumn = 'symbol' | 'direction' | 'entry_price' | 'stop_loss' | 'target' | 'rr_ratio' | 'composite_score' | 'detected_at';
export type SortDirection = 'asc' | 'desc';
interface TradeTableProps {
trades: TradeSetup[];
sortColumn: SortColumn;
sortDirection: SortDirection;
onSort: (column: SortColumn) => void;
}
const columns: { key: SortColumn; label: string }[] = [
{ key: 'symbol', label: 'Symbol' },
{ key: 'direction', label: 'Direction' },
{ key: 'entry_price', label: 'Entry' },
{ key: 'stop_loss', label: 'Stop Loss' },
{ key: 'target', label: 'Target' },
{ key: 'rr_ratio', label: 'R:R' },
{ key: 'composite_score', label: 'Score' },
{ key: 'detected_at', label: 'Detected' },
];
function sortIndicator(column: SortColumn, active: SortColumn, dir: SortDirection) {
if (column !== active) return '';
return dir === 'asc' ? ' ▲' : ' ▼';
}
export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeTableProps) {
if (trades.length === 0) {
return <p className="py-8 text-center text-sm text-gray-500">No trade setups match the current filters.</p>;
}
return (
<div className="glass overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
{columns.map((col) => (
<th
key={col.key}
className="cursor-pointer select-none px-4 py-3 hover:text-gray-300 transition-colors duration-150"
onClick={() => onSort(col.key)}
>
{col.label}{sortIndicator(col.key, sortColumn, sortDirection)}
</th>
))}
</tr>
</thead>
<tbody>
{trades.map((trade) => (
<tr key={trade.id} className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]">
<td className="px-4 py-3.5">
<Link to={`/ticker/${trade.symbol}`} className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150">
{trade.symbol}
</Link>
</td>
<td className="px-4 py-3.5">
<span className={trade.direction === 'long' ? 'font-medium text-emerald-400' : 'font-medium text-red-400'}>
{trade.direction}
</span>
</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.entry_price)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.stop_loss)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.target)}</td>
<td className="px-4 py-3.5 font-mono font-semibold text-gray-200">{trade.rr_ratio.toFixed(2)}</td>
<td className="px-4 py-3.5">
<span className={`font-semibold ${trade.composite_score > 70 ? 'text-emerald-400' : trade.composite_score >= 40 ? 'text-amber-400' : 'text-red-400'}`}>
{Math.round(trade.composite_score)}
</span>
</td>
<td className="px-4 py-3.5 text-gray-400">{formatDateTime(trade.detected_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { formatPercent, formatLargeNumber } from '../../lib/format';
import type { FundamentalResponse } from '../../lib/types';
interface FundamentalsPanelProps {
data: FundamentalResponse;
}
export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
const items = [
{ label: 'P/E Ratio', value: data.pe_ratio !== null ? data.pe_ratio.toFixed(2) : '—' },
{ label: 'Revenue Growth', value: data.revenue_growth !== null ? formatPercent(data.revenue_growth) : '—' },
{ label: 'Earnings Surprise', value: data.earnings_surprise !== null ? formatPercent(data.earnings_surprise) : '—' },
{ label: 'Market Cap', value: data.market_cap !== null ? formatLargeNumber(data.market_cap) : '—' },
];
return (
<div className="glass p-5">
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3>
<div className="space-y-2.5 text-sm">
{items.map((item) => (
<div key={item.label} className="flex justify-between">
<span className="text-gray-400">{item.label}</span>
<span className="text-gray-200">{item.value}</span>
</div>
))}
{data.fetched_at && (
<p className="mt-2 text-xs text-gray-500">
Updated {new Date(data.fetched_at).toLocaleDateString()}
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getIndicator, getEMACross } from '../../api/indicators';
import type { IndicatorResult, EMACrossResult } from '../../lib/types';
const INDICATOR_TYPES = ['ADX', 'EMA', 'RSI', 'ATR', 'volume_profile', 'pivot_points'] as const;
interface IndicatorSelectorProps {
symbol: string;
}
const signalColors: Record<string, string> = {
bullish: 'text-emerald-400',
bearish: 'text-red-400',
neutral: 'text-gray-300',
};
function IndicatorResultDisplay({ result }: { result: IndicatorResult }) {
return (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Type</span>
<span className="text-gray-200">{result.indicator_type}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Normalized Score</span>
<span className="text-gray-200">{result.score.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Bars Used</span>
<span className="text-gray-200">{result.bars_used}</span>
</div>
{Object.keys(result.values).length > 0 && (
<div className="mt-2 border-t border-white/[0.06] pt-2">
<p className="mb-1 text-[10px] font-medium uppercase tracking-widest text-gray-500">Values</p>
{Object.entries(result.values).map(([key, val]) => (
<div key={key} className="flex justify-between">
<span className="text-gray-400">{key}</span>
<span className="text-gray-200">{typeof val === 'number' ? val.toFixed(4) : String(val)}</span>
</div>
))}
</div>
)}
</div>
);
}
function EMACrossDisplay({ result }: { result: EMACrossResult }) {
return (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Signal</span>
<span className={signalColors[result.signal] ?? 'text-gray-300'}>{result.signal}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Short EMA ({result.short_period})</span>
<span className="text-gray-200">{result.short_ema.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Long EMA ({result.long_period})</span>
<span className="text-gray-200">{result.long_ema.toFixed(2)}</span>
</div>
</div>
);
}
export function IndicatorSelector({ symbol }: IndicatorSelectorProps) {
const [selectedType, setSelectedType] = useState<string>('');
const [showEMACross, setShowEMACross] = useState(false);
const indicatorQuery = useQuery({
queryKey: ['indicator', symbol, selectedType],
queryFn: () => getIndicator(symbol, selectedType),
enabled: !!symbol && !!selectedType,
});
const emaCrossQuery = useQuery({
queryKey: ['ema-cross', symbol],
queryFn: () => getEMACross(symbol),
enabled: !!symbol && showEMACross,
});
return (
<div className="glass p-5">
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3>
<div className="mb-4">
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
className="w-full input-glass px-3 py-2.5 text-sm"
>
<option value="">Select indicator</option>
{INDICATOR_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
{selectedType && indicatorQuery.isLoading && (
<div className="animate-pulse space-y-2">
<div className="h-4 w-3/4 rounded bg-white/[0.05]" />
<div className="h-4 w-1/2 rounded bg-white/[0.05]" />
<div className="h-4 w-2/3 rounded bg-white/[0.05]" />
</div>
)}
{selectedType && indicatorQuery.isError && (
<p className="text-sm text-red-400">
{indicatorQuery.error instanceof Error ? indicatorQuery.error.message : 'Failed to load indicator'}
</p>
)}
{indicatorQuery.data && <IndicatorResultDisplay result={indicatorQuery.data} />}
<div className="mt-4 border-t border-white/[0.06] pt-4">
<button
onClick={() => setShowEMACross(true)}
disabled={showEMACross && emaCrossQuery.isLoading}
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm text-gray-200 transition-all duration-200 hover:bg-white/[0.07] disabled:opacity-50"
>
{emaCrossQuery.isLoading ? 'Loading EMA Cross…' : 'Show EMA Cross Signal'}
</button>
{emaCrossQuery.isError && (
<p className="mt-2 text-sm text-red-400">
{emaCrossQuery.error instanceof Error ? emaCrossQuery.error.message : 'Failed to load EMA cross'}
</p>
)}
{emaCrossQuery.data && (
<div className="mt-3"><EMACrossDisplay result={emaCrossQuery.data} /></div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { ReferenceLine } from 'recharts';
import type { SRLevel } from '../../lib/types';
import { formatPrice } from '../../lib/format';
interface SROverlayProps {
levels: SRLevel[];
}
export function SROverlay({ levels }: SROverlayProps) {
return (
<>
{levels.map((level) => {
const isSupport = level.type === 'support';
return (
<ReferenceLine
key={level.id}
y={level.price_level}
stroke={isSupport ? '#22c55e' : '#ef4444'}
strokeDasharray="6 3"
strokeWidth={1.5}
label={{
value: formatPrice(level.price_level),
position: 'right',
fill: isSupport ? '#22c55e' : '#ef4444',
fontSize: 11,
}}
/>
);
})}
</>
);
}

View File

@@ -0,0 +1,46 @@
import { formatPercent } from '../../lib/format';
import type { SentimentResponse } from '../../lib/types';
interface SentimentPanelProps {
data: SentimentResponse;
}
const classificationColors: Record<string, string> = {
bullish: 'text-emerald-400',
bearish: 'text-red-400',
neutral: 'text-gray-300',
};
export function SentimentPanel({ data }: SentimentPanelProps) {
const latest = data.scores[0];
return (
<div className="glass p-5">
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Sentiment</h3>
{latest ? (
<div className="space-y-2.5 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Classification</span>
<span className={classificationColors[latest.classification] ?? 'text-gray-300'}>
{latest.classification}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Confidence</span>
<span className="text-gray-200">{formatPercent(latest.confidence)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Dimension Score</span>
<span className="text-gray-200">{data.dimension_score !== null ? Math.round(data.dimension_score) : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Sources</span>
<span className="text-gray-200">{data.count}</span>
</div>
</div>
) : (
<p className="text-sm text-gray-500">No sentiment data available</p>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
const variantStyles: Record<string, string> = {
auto: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
manual: 'bg-violet-500/15 text-violet-400 border-violet-500/20',
default: 'bg-white/[0.06] text-gray-400 border-white/[0.08]',
};
interface BadgeProps {
label: string;
variant?: 'auto' | 'manual' | 'default';
}
export function Badge({ label, variant = 'default' }: BadgeProps) {
return (
<span className={`inline-block rounded-full border px-2.5 py-0.5 text-xs font-medium backdrop-blur-sm ${variantStyles[variant]}`}>
{label}
</span>
);
}

View File

@@ -0,0 +1,35 @@
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({ open, title, message, onConfirm, onCancel }: ConfirmDialogProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onCancel} />
<div className="relative z-10 w-full max-w-md glass p-6 shadow-2xl animate-slide-up">
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
<p className="mt-2 text-sm text-gray-400">{message}</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onCancel}
className="rounded-lg border border-white/[0.1] bg-white/[0.04] px-4 py-2 text-sm text-gray-300 hover:bg-white/[0.08] transition-all duration-200"
>
Cancel
</button>
<button
onClick={onConfirm}
className="rounded-lg bg-gradient-to-r from-red-600 to-red-500 px-4 py-2 text-sm text-white hover:from-red-500 hover:to-red-400 transition-all duration-200"
>
Confirm
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
interface ScoreCardProps {
compositeScore: number | null;
dimensions: { dimension: string; score: number }[];
}
function scoreColor(score: number): string {
if (score > 70) return 'text-emerald-400';
if (score >= 40) return 'text-amber-400';
return 'text-red-400';
}
function ringGradient(score: number): string {
if (score > 70) return '#10b981';
if (score >= 40) return '#f59e0b';
return '#ef4444';
}
function barGradient(score: number): string {
if (score > 70) return 'from-emerald-500 to-emerald-400';
if (score >= 40) return 'from-amber-500 to-amber-400';
return 'from-red-500 to-red-400';
}
function ScoreRing({ score }: { score: number }) {
const radius = 36;
const circumference = 2 * Math.PI * radius;
const clamped = Math.max(0, Math.min(100, score));
const offset = circumference - (clamped / 100) * circumference;
const color = ringGradient(score);
return (
<div className="relative inline-flex items-center justify-center">
<svg width="88" height="88" className="-rotate-90">
<circle cx="44" cy="44" r={radius} fill="none" strokeWidth="6" className="stroke-white/[0.06]" />
<circle
cx="44" cy="44" r={radius} fill="none" strokeWidth="6" strokeLinecap="round"
strokeDasharray={circumference} strokeDashoffset={offset}
stroke={color}
style={{ filter: `drop-shadow(0 0 8px ${color}40)`, transition: 'all 0.6s ease' }}
/>
</svg>
<span className={`absolute text-lg font-bold ${scoreColor(score)}`}>
{Math.round(score)}
</span>
</div>
);
}
export function ScoreCard({ compositeScore, dimensions }: ScoreCardProps) {
return (
<div className="glass p-5">
<div className="flex items-center gap-4">
{compositeScore !== null ? (
<ScoreRing score={compositeScore} />
) : (
<div className="flex h-[88px] w-[88px] items-center justify-center text-sm text-gray-500">N/A</div>
)}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Composite Score</p>
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
{compositeScore !== null ? Math.round(compositeScore) : '—'}
</p>
</div>
</div>
{dimensions.length > 0 && (
<div className="mt-5 space-y-2.5">
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
{dimensions.map((d) => (
<div key={d.dimension} className="flex items-center justify-between text-sm">
<span className="text-gray-300 capitalize">{d.dimension}</span>
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
<div
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
style={{ width: `${Math.max(0, Math.min(100, d.score))}%` }}
/>
</div>
<span className={`w-8 text-right font-medium text-xs ${scoreColor(d.score)}`}>
{Math.round(d.score)}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
const pulse = 'animate-pulse rounded-lg bg-white/[0.05]';
export function SkeletonLine({ className = '' }: { className?: string }) {
return <div className={`${pulse} h-4 w-full ${className}`} />;
}
export function SkeletonCard({ className = '' }: { className?: string }) {
return <div className={`${pulse} h-32 w-full ${className}`} />;
}
export function SkeletonTable({ rows = 5, cols = 4, className = '' }: { rows?: number; cols?: number; className?: string }) {
return (
<div className={`space-y-2 ${className}`}>
{Array.from({ length: rows }, (_, r) => (
<div key={r} className="flex gap-4">
{Array.from({ length: cols }, (_, c) => (
<div key={c} className={`${pulse} h-6 flex-1`} />
))}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
type: ToastType;
message: string;
}
interface ToastContextValue {
addToast: (type: ToastType, message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const MAX_VISIBLE = 3;
const AUTO_DISMISS_MS = 8000;
const typeStyles: Record<ToastType, string> = {
error: 'border-red-500/30 bg-red-500/10 text-red-300',
success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300',
info: 'border-blue-500/30 bg-blue-500/10 text-blue-300',
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(0);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const addToast = useCallback((type: ToastType, message: string) => {
const id = String(nextId.current++);
setToasts((prev) => {
const next = [...prev, { id, type, message }];
return next.length > MAX_VISIBLE ? next.slice(next.length - MAX_VISIBLE) : next;
});
}, []);
return (
<ToastContext.Provider value={{ addToast }}>
{children}
{createPortal(
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-96">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={removeToast} />
))}
</div>,
document.body,
)}
</ToastContext.Provider>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
useEffect(() => {
const timer = setTimeout(() => onDismiss(toast.id), AUTO_DISMISS_MS);
return () => clearTimeout(timer);
}, [toast.id, onDismiss]);
return (
<div
role="alert"
className={`glass px-4 py-3 shadow-2xl animate-slide-up ${typeStyles[toast.type]}`}
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm">{toast.message}</p>
<button
onClick={() => onDismiss(toast.id)}
className="shrink-0 text-gray-400 hover:text-gray-200 transition-colors duration-200"
aria-label="Dismiss"
>
</button>
</div>
</div>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
return ctx;
}

View File

@@ -0,0 +1,33 @@
import { FormEvent, useState } from 'react';
import { useAddToWatchlist } from '../../hooks/useWatchlist';
export function AddTickerForm() {
const [symbol, setSymbol] = useState('');
const addMutation = useAddToWatchlist();
function handleSubmit(e: FormEvent) {
e.preventDefault();
const trimmed = symbol.trim().toUpperCase();
if (!trimmed) return;
addMutation.mutate(trimmed, { onSuccess: () => setSymbol('') });
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
placeholder="Add symbol (e.g. AAPL)"
className="input-glass px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={addMutation.isPending || !symbol.trim()}
className="btn-gradient px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{addMutation.isPending ? 'Adding…' : 'Add'}</span>
</button>
</form>
);
}

View File

@@ -0,0 +1,131 @@
import { Link } from 'react-router-dom';
import type { WatchlistEntry } from '../../lib/types';
import { formatPrice } from '../../lib/format';
import { Badge } from '../ui/Badge';
import { useRemoveFromWatchlist } from '../../hooks/useWatchlist';
function scoreColor(score: number): string {
if (score > 70) return 'text-emerald-400';
if (score >= 40) return 'text-amber-400';
return 'text-red-400';
}
interface WatchlistTableProps {
entries: WatchlistEntry[];
}
export function WatchlistTable({ entries }: WatchlistTableProps) {
const removeMutation = useRemoveFromWatchlist();
if (entries.length === 0) {
return (
<p className="py-8 text-center text-sm text-gray-500">
No watchlist entries yet. Add a symbol above to get started.
</p>
);
}
return (
<div className="glass overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Symbol</th>
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Score</th>
<th className="px-4 py-3">Dimensions</th>
<th className="px-4 py-3">R:R</th>
<th className="px-4 py-3">Direction</th>
<th className="px-4 py-3">S/R Levels</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry.symbol}
className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]"
>
<td className="px-4 py-3.5">
<Link
to={`/ticker/${entry.symbol}`}
className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150"
>
{entry.symbol}
</Link>
</td>
<td className="px-4 py-3.5">
<Badge label={entry.entry_type} variant={entry.entry_type === 'auto' ? 'auto' : 'manual'} />
</td>
<td className="px-4 py-3.5">
{entry.composite_score !== null ? (
<span className={`font-semibold ${scoreColor(entry.composite_score)}`}>
{Math.round(entry.composite_score)}
</span>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5">
{entry.dimensions.length > 0 ? (
<div className="flex flex-wrap gap-1">
{entry.dimensions.map((d) => (
<span
key={d.dimension}
className={`inline-block rounded-md px-1.5 py-0.5 text-xs bg-white/[0.04] ${scoreColor(d.score)}`}
title={d.dimension}
>
{d.dimension.slice(0, 3).toUpperCase()} {Math.round(d.score)}
</span>
))}
</div>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5 font-mono">
{entry.rr_ratio !== null ? (
<span className="text-gray-200">{entry.rr_ratio.toFixed(2)}</span>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5">
{entry.rr_direction ? (
<span className={entry.rr_direction === 'long' ? 'text-emerald-400' : entry.rr_direction === 'short' ? 'text-red-400' : 'text-gray-400'}>
{entry.rr_direction}
</span>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5">
{entry.sr_levels.length > 0 ? (
<div className="flex flex-wrap gap-1">
{entry.sr_levels.map((level, i) => (
<span key={i} className={`text-xs ${level.type === 'support' ? 'text-emerald-400' : 'text-red-400'}`}>
{formatPrice(level.price_level)}
</span>
))}
</div>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5">
<button
onClick={() => removeMutation.mutate(entry.symbol)}
disabled={removeMutation.isPending}
className="rounded-lg px-2.5 py-1 text-xs text-red-400 transition-all duration-150 hover:bg-red-500/10 hover:text-red-300 disabled:opacity-50"
aria-label={`Remove ${entry.symbol}`}
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}