Redesign: phosphor-terminal identity and simplified 4-page structure
Information architecture (6 nav destinations -> 4): - New Overview home: metric strip (live setups, high confidence, hit rate, expectancy), top-5 setups, watchlist pulse - Market = Watchlist + Rankings merged as tabs; scoring weights moved into a collapsible disclosure - Signals = Scanner + Performance merged as tabs (Setups | Track Record) with actions inside the panels - Legacy routes redirect (/watchlist, /rankings, /scanner, /performance) Visual identity: - Warm ash-green dark palette replaces cold navy; citron lime accent replaces blue (Tailwind gray/blue remapped at config level so all components reskin) - Primary buttons: lime with ink text; long/short stays emerald/red - Typography: Bricolage Grotesque display, Instrument Sans body, IBM Plex Mono for all numerals incl. chart canvas labels - Atmosphere: graph-paper grid + citron glow + film grain; pulsing brand dot; mono-numbered nav Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useWatchlist } from '../hooks/useWatchlist';
|
||||
import { useRankings } from '../hooks/useScores';
|
||||
import { WatchlistTable } from '../components/watchlist/WatchlistTable';
|
||||
import { AddTickerForm } from '../components/watchlist/AddTickerForm';
|
||||
import { RankingsTable } from '../components/rankings/RankingsTable';
|
||||
import { WeightsForm } from '../components/rankings/WeightsForm';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Disclosure } from '../components/ui/Disclosure';
|
||||
import { Select } from '../components/ui/Field';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { Tabs } from '../components/ui/Tabs';
|
||||
import type { WatchlistEntry } from '../lib/types';
|
||||
|
||||
const tabs = ['Watchlist', 'Rankings'] as const;
|
||||
type Tab = (typeof tabs)[number];
|
||||
|
||||
type SortMode = 'name_asc' | 'name_desc' | 'score_desc' | 'score_asc';
|
||||
|
||||
function sortEntries(entries: WatchlistEntry[], mode: SortMode): WatchlistEntry[] {
|
||||
const sorted = [...entries];
|
||||
|
||||
if (mode === 'name_asc') {
|
||||
sorted.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
if (mode === 'name_desc') {
|
||||
sorted.sort((a, b) => b.symbol.localeCompare(a.symbol));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
if (mode === 'score_desc') {
|
||||
sorted.sort((a, b) => {
|
||||
const aScore = a.composite_score ?? Number.NEGATIVE_INFINITY;
|
||||
const bScore = b.composite_score ?? Number.NEGATIVE_INFINITY;
|
||||
if (aScore === bScore) return a.symbol.localeCompare(b.symbol);
|
||||
return bScore - aScore;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
const aScore = a.composite_score ?? Number.POSITIVE_INFINITY;
|
||||
const bScore = b.composite_score ?? Number.POSITIVE_INFINITY;
|
||||
if (aScore === bScore) return a.symbol.localeCompare(b.symbol);
|
||||
return aScore - bScore;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function WatchlistPanel() {
|
||||
const { data, isLoading, isError, error } = useWatchlist();
|
||||
const [sortMode, setSortMode] = useState<SortMode>('score_desc');
|
||||
|
||||
const sortedEntries = useMemo(
|
||||
() => (data ? sortEntries(data, sortMode) : []),
|
||||
[data, sortMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{isLoading && <SkeletonTable rows={6} cols={8} />}
|
||||
|
||||
{isError && <Callout variant="error">{error?.message || 'Failed to load watchlist'}</Callout>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<AddTickerForm />
|
||||
<label className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>Sort by</span>
|
||||
<Select
|
||||
value={sortMode}
|
||||
onChange={(event) => setSortMode(event.target.value as SortMode)}
|
||||
className="!py-1 !text-xs"
|
||||
>
|
||||
<option value="score_desc">Score (high → low)</option>
|
||||
<option value="score_asc">Score (low → high)</option>
|
||||
<option value="name_asc">Name (A → Z)</option>
|
||||
<option value="name_desc">Name (Z → A)</option>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<WatchlistTable entries={sortedEntries} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankingsPanel() {
|
||||
const { data, isLoading, isError, error } = useRankings();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isLoading && <SkeletonTable rows={8} cols={6} />}
|
||||
|
||||
{isError && (
|
||||
<Callout variant="error">Failed to load rankings: {(error as Error).message}</Callout>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Disclosure summary="Tune scoring weights">
|
||||
<WeightsForm weights={data.weights} />
|
||||
</Disclosure>
|
||||
<RankingsTable rankings={data.rankings} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MarketPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab: Tab = searchParams.get('tab') === 'rankings' ? 'Rankings' : 'Watchlist';
|
||||
|
||||
const setTab = (tab: Tab) => {
|
||||
setSearchParams(tab === 'Rankings' ? { tab: 'rankings' } : {}, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader
|
||||
title="Market"
|
||||
subtitle="Your watchlist and the full composite-score leaderboard"
|
||||
/>
|
||||
|
||||
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
|
||||
|
||||
<div className="animate-fade-in" key={activeTab}>
|
||||
{activeTab === 'Watchlist' ? <WatchlistPanel /> : <RankingsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user