feat(plans): make plans route canonical

This commit is contained in:
root 2026-05-06 18:53:38 +00:00
parent 9b6cbc1e67
commit ac353e8de5
5 changed files with 67 additions and 5 deletions

View File

@ -53,6 +53,17 @@ vi.mock('../../views/SettingsView', () => ({
}));
describe('AppShell routing fallback', () => {
it('keeps /simple as a redirect alias to the plans view', async () => {
render(
<MemoryRouter initialEntries={['/simple?setupId=setup-1']}>
<AppShell />
</MemoryRouter>,
);
expect(await screen.findByText('Simple view')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Route not found' })).not.toBeInTheDocument();
});
it('shows a useful 404 state for unknown routes', () => {
render(
<MemoryRouter initialEntries={['/missing/workspace']}>

View File

@ -1,5 +1,5 @@
import { Suspense, lazy } from 'react';
import { Link, Routes, Route, useLocation } from 'react-router-dom';
import { Link, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import { RightPanel } from './RightPanel';
@ -73,6 +73,11 @@ function NotFoundView() {
);
}
function SimpleAliasRedirect() {
const location = useLocation();
return <Navigate to={`/plans${location.search}${location.hash}`} replace />;
}
export function AppShell() {
return (
<div className="dashboard-shell">
@ -93,7 +98,8 @@ export function AppShell() {
<Route path="/" element={<HomeView />} />
<Route path="/portfolio" element={<PortfolioView />} />
<Route path="/research" element={<ResearchView />} />
<Route path="/simple" element={<SimpleView />} />
<Route path="/plans" element={<SimpleView />} />
<Route path="/simple" element={<SimpleAliasRedirect />} />
<Route path="/markets" element={<MarketsView />} />
<Route path="/screener" element={<ScreenerView />} />
<Route path="/watchlist" element={<WatchlistView />} />

View File

@ -9,7 +9,7 @@ const NAV = [
{ to: '/', icon: Home, label: 'Home', end: true },
{ to: '/portfolio', icon: Briefcase, label: 'Portfolio', end: false },
{ to: '/research', icon: FlaskConical, label: 'Research', end: false },
{ to: '/simple', icon: Target, label: 'Plans', end: false },
{ to: '/plans', icon: Target, label: 'Plans', end: false },
{ to: '/markets', icon: TrendingUp, label: 'Markets', end: false },
{ to: '/screener', icon: SlidersHorizontal, label: 'Screener', end: false },
{ to: '/watchlist', icon: Star, label: 'Watchlist', end: false },

View File

@ -44,7 +44,7 @@ export function PortfolioView() {
symbol: position.symbol,
});
if (action !== 'open-plan' && position.tradeId) params.set('tradeId', position.tradeId);
navigate(`/simple?${params.toString()}`);
navigate(`/plans?${params.toString()}`);
}}
/>
)}

View File

@ -1437,7 +1437,7 @@ export function SimpleView() {
<div className="space-y-4">
{savedSetups.length === 0 && (
<div className="rounded-[1.5rem] border border-dashed border-[var(--border)] bg-[var(--card-elevated)] px-5 py-8 text-sm text-[var(--muted-foreground)]">
No Simple setups saved yet.
No Trade Plans saved yet.
</div>
)}
@ -1450,6 +1450,18 @@ export function SimpleView() {
const updatedAt = formatSetupUpdatedAt(entry);
const eventHistory = deriveSimpleEventHistory(entry, runtimeEvents);
const holdingMode = normalizeHoldingMode(entry.holding_mode);
const linkedHoldingTradeId = String(entry.linked_trade_id || '').trim();
const linkedHolding = side === 'sell'
? availableSellHoldings.find((holding) => {
if (linkedHoldingTradeId) {
return holding.tradeId === linkedHoldingTradeId;
}
return (
holding.symbol === String(entry.symbol || '').trim().toUpperCase()
&& String(holding.profileId || '').trim() === String(entry.profile_id || '').trim()
);
}) || null
: null;
const canConvertToLongTerm = side === 'buy' && holdingMode === 'short_term' && runtimeSnapshot?.stage === 'filled';
const canResumeExitManagement = side === 'buy' && holdingMode === 'long_term' && runtimeSnapshot?.stage === 'filled';
return (
@ -1585,6 +1597,39 @@ export function SimpleView() {
) : null}
</div>
{side === 'sell' ? (
<div className="mt-4 rounded-2xl border border-[var(--border)] bg-[var(--background)] px-4 py-3">
<div className="text-[11px] font-black uppercase tracking-[0.22em] text-[var(--muted-foreground)]">
Linked holding
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-[var(--foreground)]">
<span className="rounded-full border border-[var(--border)] px-3 py-1">
{linkedHolding?.symbol || String(entry.symbol || '').trim().toUpperCase()}
</span>
{(linkedHolding?.tradeId || linkedHoldingTradeId) ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1 font-mono text-xs">
Trade {linkedHolding?.tradeId || linkedHoldingTradeId}
</span>
) : null}
{(linkedHolding?.profileId || entry.profile_id) ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1 font-mono text-xs">
Profile {String(linkedHolding?.profileId || entry.profile_id)}
</span>
) : null}
{linkedHolding?.entryPrice ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1">
Entry {Number(linkedHolding.entryPrice).toFixed(4)}
</span>
) : null}
{linkedHolding?.size ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1">
Qty {linkedHolding.size}
</span>
) : null}
</div>
</div>
) : null}
<div className="mt-4 grid gap-2 md:grid-cols-5">
{SIMPLE_TIMELINE_STEPS.map((step) => {
const complete = isTimelineStepComplete(runtimeSnapshot?.stage, step);