fix(B2,B3): wire ticker header actions
Route the ticker header watchlist and alert controls to their existing screens and replace the symbol placeholder with the research profile company name while preserving a symbol fallback. Refs: docs/AUDIT_REDESIGN.md items B2 and B3. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
This commit is contained in:
parent
ff738b6b40
commit
ed8175eb1f
87
web/src/views/HomeView.dom.test.tsx
Normal file
87
web/src/views/HomeView.dom.test.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { AppContext, type AppContextValue } from '../context/AppContext';
|
||||||
|
import { TickerHeader } from './HomeView';
|
||||||
|
|
||||||
|
const { fetchResearchProfileMock } = vi.hoisted(() => ({
|
||||||
|
fetchResearchProfileMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../lib/marketApi', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../lib/marketApi')>('../lib/marketApi');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetchResearchProfile: fetchResearchProfileMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const appContext: AppContextValue = {
|
||||||
|
botState: {
|
||||||
|
settings: { isAlgoEnabled: true },
|
||||||
|
symbols: {
|
||||||
|
AAPL: {
|
||||||
|
price: 212.42,
|
||||||
|
changeToday: 1.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 },
|
||||||
|
alerts: [],
|
||||||
|
positions: [],
|
||||||
|
orders: [],
|
||||||
|
history: [],
|
||||||
|
uptime: 0,
|
||||||
|
} as any,
|
||||||
|
socket: null,
|
||||||
|
connected: true,
|
||||||
|
activeSymbol: 'AAPL',
|
||||||
|
setActiveSymbol: vi.fn(),
|
||||||
|
isAdmin: false,
|
||||||
|
user: { id: 'u1' },
|
||||||
|
profile: {},
|
||||||
|
showBacktestTab: false,
|
||||||
|
showMarketplaceTab: false,
|
||||||
|
handleSignOut: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderTickerHeader() {
|
||||||
|
return render(
|
||||||
|
<AppContext.Provider value={appContext}>
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<TickerHeader symbol="AAPL" />} />
|
||||||
|
<Route path="/watchlist" element={<div>Watchlist route</div>} />
|
||||||
|
<Route path="/alerts" element={<div>Alerts route</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</AppContext.Provider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TickerHeader', () => {
|
||||||
|
it('loads the company name from the research profile', async () => {
|
||||||
|
fetchResearchProfileMock.mockResolvedValueOnce({ companyName: 'Apple Inc.' });
|
||||||
|
|
||||||
|
renderTickerHeader();
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'AAPL' })).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(screen.getByText('Apple Inc.')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens watchlist and alerts routes from header actions', async () => {
|
||||||
|
fetchResearchProfileMock.mockResolvedValue({ companyName: 'Apple Inc.' });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const firstRender = renderTickerHeader();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /open watchlist/i }));
|
||||||
|
expect(screen.getByText('Watchlist route')).toBeInTheDocument();
|
||||||
|
firstRender.unmount();
|
||||||
|
|
||||||
|
renderTickerHeader();
|
||||||
|
await user.click(screen.getByRole('button', { name: /open alerts/i }));
|
||||||
|
expect(screen.getByText('Alerts route')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Star, Bell, BarChart2, Loader2 } from 'lucide-react';
|
import { Star, Bell, BarChart2, Loader2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, Bar, ComposedChart, Line, LineChart, XAxis, YAxis, Tooltip,
|
AreaChart, Area, Bar, ComposedChart, Line, LineChart, XAxis, YAxis, Tooltip,
|
||||||
@ -132,14 +133,35 @@ function calculateBollingerBands(closes: number[], period = 20, deviations = 2)
|
|||||||
return { upper, middle, lower };
|
return { upper, middle, lower };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeResearchProfile(profile: any): any {
|
||||||
|
return Array.isArray(profile) ? profile[0] : profile;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Ticker header ────────────────────────────────────────────────────────────
|
// ─── Ticker header ────────────────────────────────────────────────────────────
|
||||||
function TickerHeader({ symbol }: { symbol: string }) {
|
export function TickerHeader({ symbol }: { symbol: string }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { botState } = useAppContext();
|
const { botState } = useAppContext();
|
||||||
const data = botState.symbols?.[symbol];
|
const data = botState.symbols?.[symbol];
|
||||||
const price = data?.price ?? 0;
|
const price = data?.price ?? 0;
|
||||||
const change = data?.changeToday ?? 0;
|
const change = data?.changeToday ?? 0;
|
||||||
const changePct = price > 0 ? (change / (price - change)) * 100 : 0;
|
const changePct = price > 0 ? (change / (price - change)) * 100 : 0;
|
||||||
const positive = change >= 0;
|
const positive = change >= 0;
|
||||||
|
const [companyName, setCompanyName] = useState(symbol);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setCompanyName(symbol);
|
||||||
|
fetchResearchProfile(symbol)
|
||||||
|
.then(profile => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const company = normalizeResearchProfile(profile)?.companyName;
|
||||||
|
setCompanyName(typeof company === 'string' && company.trim() ? company.trim() : symbol);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCompanyName(symbol);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [symbol]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
@ -148,13 +170,15 @@ function TickerHeader({ symbol }: { symbol: string }) {
|
|||||||
{symbol}
|
{symbol}
|
||||||
</h1>
|
</h1>
|
||||||
<span style={{ fontSize: 13, color: '#6B7280', fontWeight: 500, marginTop: 4 }}>
|
<span style={{ fontSize: 13, color: '#6B7280', fontWeight: 500, marginTop: 4 }}>
|
||||||
{/* Company name placeholder — Phase 4 will fill from FMP */}
|
{companyName}
|
||||||
{symbol}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||||
{/* Saved badge */}
|
<button
|
||||||
<button style={{
|
type="button"
|
||||||
|
aria-label={`Open watchlist for ${symbol}`}
|
||||||
|
onClick={() => navigate('/watchlist')}
|
||||||
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
padding: '5px 12px', borderRadius: 20,
|
padding: '5px 12px', borderRadius: 20,
|
||||||
background: '#F0FDF4', border: '1px solid #86EFAC',
|
background: '#F0FDF4', border: '1px solid #86EFAC',
|
||||||
@ -162,7 +186,11 @@ function TickerHeader({ symbol }: { symbol: string }) {
|
|||||||
}}>
|
}}>
|
||||||
<Star size={13} fill="#16A34A" /> Watchlist
|
<Star size={13} fill="#16A34A" /> Watchlist
|
||||||
</button>
|
</button>
|
||||||
<button style={{
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Open alerts for ${symbol}`}
|
||||||
|
onClick={() => navigate('/alerts')}
|
||||||
|
style={{
|
||||||
width: 32, height: 32, borderRadius: '50%',
|
width: 32, height: 32, borderRadius: '50%',
|
||||||
border: '1px solid #E5E7EB', background: '#fff',
|
border: '1px solid #E5E7EB', background: '#fff',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
@ -549,7 +577,7 @@ function ResearchCards({ symbol }: { symbol: string }) {
|
|||||||
fetchResearchEarnings(symbol),
|
fetchResearchEarnings(symbol),
|
||||||
]).then(([p, m, e]) => {
|
]).then(([p, m, e]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (p.status === 'fulfilled') setProfile(Array.isArray(p.value) ? p.value[0] : p.value);
|
if (p.status === 'fulfilled') setProfile(normalizeResearchProfile(p.value));
|
||||||
if (m.status === 'fulfilled') setMetrics(Array.isArray(m.value) ? m.value[0] : m.value);
|
if (m.status === 'fulfilled') setMetrics(Array.isArray(m.value) ? m.value[0] : m.value);
|
||||||
if (e.status === 'fulfilled') setEarnings(e.value ?? []);
|
if (e.status === 'fulfilled') setEarnings(e.value ?? []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user