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:
Saravana Achu Mac 2026-05-04 16:07:31 -07:00
parent ff738b6b40
commit ed8175eb1f
2 changed files with 122 additions and 7 deletions

View 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();
});
});

View File

@ -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);