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 { useNavigate } from 'react-router-dom';
|
||||
import { Star, Bell, BarChart2, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
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 };
|
||||
}
|
||||
|
||||
function normalizeResearchProfile(profile: any): any {
|
||||
return Array.isArray(profile) ? profile[0] : profile;
|
||||
}
|
||||
|
||||
// ─── Ticker header ────────────────────────────────────────────────────────────
|
||||
function TickerHeader({ symbol }: { symbol: string }) {
|
||||
export function TickerHeader({ symbol }: { symbol: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { botState } = useAppContext();
|
||||
const data = botState.symbols?.[symbol];
|
||||
const price = data?.price ?? 0;
|
||||
const change = data?.changeToday ?? 0;
|
||||
const changePct = price > 0 ? (change / (price - change)) * 100 : 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 (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@ -148,13 +170,15 @@ function TickerHeader({ symbol }: { symbol: string }) {
|
||||
{symbol}
|
||||
</h1>
|
||||
<span style={{ fontSize: 13, color: '#6B7280', fontWeight: 500, marginTop: 4 }}>
|
||||
{/* Company name placeholder — Phase 4 will fill from FMP */}
|
||||
{symbol}
|
||||
{companyName}
|
||||
</span>
|
||||
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||
{/* Saved badge */}
|
||||
<button style={{
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open watchlist for ${symbol}`}
|
||||
onClick={() => navigate('/watchlist')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '5px 12px', borderRadius: 20,
|
||||
background: '#F0FDF4', border: '1px solid #86EFAC',
|
||||
@ -162,7 +186,11 @@ function TickerHeader({ symbol }: { symbol: string }) {
|
||||
}}>
|
||||
<Star size={13} fill="#16A34A" /> Watchlist
|
||||
</button>
|
||||
<button style={{
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open alerts for ${symbol}`}
|
||||
onClick={() => navigate('/alerts')}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: '50%',
|
||||
border: '1px solid #E5E7EB', background: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@ -549,7 +577,7 @@ function ResearchCards({ symbol }: { symbol: string }) {
|
||||
fetchResearchEarnings(symbol),
|
||||
]).then(([p, m, e]) => {
|
||||
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 (e.status === 'fulfilled') setEarnings(e.value ?? []);
|
||||
setLoading(false);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user