Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main, develop]

permissions:
contents: read

jobs:
quality:
name: Type-check · Lint · Format · Test · Build
Expand All @@ -14,6 +17,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@v4
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ dist-ssr

# Environment variables — NEVER commit real .env files
.env
.env.local
.env.*.local
.env.*
!.env.example

# Editor directories — share settings but not personal configs
.vscode/*
Expand Down
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"dependencies": {
"axios": "^1.18.1",
"globe.gl": "^2.46.1",
"lightweight-charts": "^5.2.0",
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
Expand Down
9 changes: 0 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,6 @@ function AppContent() {
}

const initializeSession = async () => {
// Extract Google OAuth token from URL hash if present on the callback route
const hash = window.location.hash;
if (hash.includes('/auth/callback') && hash.includes('token=')) {
const tokenMatch = hash.match(/token=([^&]+)/);
if (tokenMatch && tokenMatch[1]) {
useUserStore.getState().setAccessToken(tokenMatch[1]);
}
}

await initAuth();
setIsInitializing(false);
};
Expand Down
4 changes: 2 additions & 2 deletions src/components/ForgotPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export const ForgotPassword: React.FC<ForgotPasswordProps> = ({ onBackToLogin })
setIsLoading(true);
await authService.forgotPassword(email);
setIsSuccess(true);
} catch (err) {
console.error('Forgot password failed', err);
} catch (err: any) {
console.error('Forgot password failed:', err?.message, 'Status:', err?.response?.status);
// The OpenAPI spec says it always returns a generic success message
// to prevent account enumeration, so we can just show success anyway.
setIsSuccess(true);
Expand Down
2 changes: 1 addition & 1 deletion src/components/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const Login: React.FC<LoginProps> = ({
setUser(userProfile);
onLoginSuccess();
} catch (err: any) {
console.error('Login failed', err);
console.error('Login failed:', err?.message, 'Status:', err?.response?.status);
if (err.response?.status === 403) {
setErrorMsg(err.response.data?.detail || 'Please verify your email before logging in.');
} else if (err.response?.status === 401 || err.response?.status === 400) {
Expand Down
18 changes: 15 additions & 3 deletions src/components/PortfolioView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { portfolioService } from '@/services/portfolio.service';
import type { Portfolio } from '@/services/portfolio.service';
import '@/styles/components/portfolio.css';

export const PortfolioView: React.FC = () => {
interface PortfolioViewProps {
onSelectStock?: (symbol: string) => void;
}

export const PortfolioView: React.FC<PortfolioViewProps> = ({ onSelectStock }) => {
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [portfolioName, setPortfolioName] = useState('');
Expand Down Expand Up @@ -192,7 +196,12 @@ export const PortfolioView: React.FC = () => {
</div>
) : (
portfolio.holdings.map((holding) => (
<div key={holding.id} className="holding-item">
<div
key={holding.id}
className="holding-item"
onClick={() => onSelectStock && onSelectStock(holding.symbol)}
style={{ cursor: onSelectStock ? 'pointer' : 'default' }}
>
<div className="holding-info">
<div className="holding-avatar">{holding.symbol.substring(0, 2)}</div>
<div className="holding-details">
Expand Down Expand Up @@ -262,7 +271,10 @@ export const PortfolioView: React.FC = () => {
)}

<button
onClick={() => handleRemoveHolding(holding.symbol)}
onClick={(e) => {
e.stopPropagation();
handleRemoveHolding(holding.symbol);
}}
className="remove-btn"
>
Remove
Expand Down
2 changes: 1 addition & 1 deletion src/components/Register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onNavigat
await authService.register({ username, email, password });
onRegisterSuccess();
} catch (err: any) {
console.error('Registration failed', err);
console.error('Registration failed:', err?.message, 'Status:', err?.response?.status);
// Display the validation detail from FastAPI if present
setErrorMsg(
err.response?.data?.detail?.[0]?.msg ||
Expand Down
2 changes: 1 addition & 1 deletion src/components/ResetPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const ResetPassword: React.FC<ResetPasswordProps> = ({ onBackToLogin }) =
await authService.resetPassword(token, password);
setIsSuccess(true);
} catch (err: any) {
console.error('Reset failed', err);
console.error('Reset failed:', err?.message, 'Status:', err?.response?.status);
setErrorMsg(
err.response?.data?.detail?.[0]?.msg ||
'Failed to reset password. The link may have expired.',
Expand Down
145 changes: 145 additions & 0 deletions src/components/TradingViewChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React, { useEffect, useRef, useState } from 'react';
import { createChart, CandlestickSeries } from 'lightweight-charts';
import type { IChartApi } from 'lightweight-charts';
import { marketService } from '@/services/market.service';

interface TradingViewChartProps {
symbol: string | null;
}

export const TradingViewChart: React.FC<TradingViewChartProps> = ({ symbol }) => {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const [analysisData, setAnalysisData] = useState<any>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!symbol || !chartContainerRef.current) return;

const fetchAndDrawChart = async () => {
setLoading(true);
setError(null);
setAnalysisData(null);

try {
const response = await marketService.analyzeStock(symbol);
setAnalysisData(response.analysis);

// Clear existing chart if any
if (chartRef.current) {
chartRef.current.remove();
chartRef.current = null;
}

if (response.chart_data && response.chart_data.length > 0) {
// Initialize TradingView Lightweight Chart
const chart = createChart(chartContainerRef.current, {
layout: {
background: { color: '#0b1120' }, // Match Vercel dark theme
textColor: '#d1d5db',
},
grid: {
vertLines: { color: '#1f2937' },
horzLines: { color: '#1f2937' },
},
width: chartContainerRef.current.clientWidth,
height: 400,
});

const candlestickSeries = chart.addSeries(CandlestickSeries, {
upColor: '#10b981',
downColor: '#ef4444',
borderVisible: false,
wickUpColor: '#10b981',
wickDownColor: '#ef4444',
});

// Lightweight charts requires time to be YYYY-MM-DD
candlestickSeries.setData(response.chart_data);
chart.timeScale().fitContent();

chartRef.current = chart;
}
} catch (err: any) {
setError(err.message || 'Failed to fetch chart data');
} finally {
setLoading(false);
}
};

fetchAndDrawChart();

const handleResize = () => {
if (chartRef.current && chartContainerRef.current) {
chartRef.current.applyOptions({ width: chartContainerRef.current.clientWidth });
}
};

window.addEventListener('resize', handleResize);

return () => {
window.removeEventListener('resize', handleResize);
if (chartRef.current) {
chartRef.current.remove();
chartRef.current = null;
}
};
}, [symbol]);
Comment on lines +20 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Async useEffect bodies lack a cancellation token. Both effects await a service call and then commit results unconditionally; their cleanup functions only tear down synchronous resources, so a response that lands after the dependency changed still mutates state (and in the chart's case, allocates an unowned chart instance).

  • src/components/TradingViewChart.tsx#L20-L88: add a let cancelled = false set in the cleanup, and bail before setAnalysisData/createChart so a stale symbol never builds a chart that outlives the effect.
  • src/components/StockSearchBar.tsx#L34-L49: add the same flag (or an AbortSignal) so a slow earlier query can't repopulate results after a newer keystroke.
📍 Affects 2 files
  • src/components/TradingViewChart.tsx#L20-L88 (this comment)
  • src/components/StockSearchBar.tsx#L34-L49
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/TradingViewChart.tsx` around lines 20 - 88, Guard both
asynchronous effects against stale responses: in
src/components/TradingViewChart.tsx lines 20-88, add a cancellation flag set
during cleanup and check it after analyzeStock resolves before committing
analysis state or creating the chart; in src/components/StockSearchBar.tsx lines
34-49, add the same cancellation mechanism so outdated queries cannot update
results, and ensure each effect’s cleanup marks its request cancelled.


if (!symbol) return null;

return (
<div style={{ width: '100%', marginBottom: '32px', padding: '24px', background: '#111827', borderRadius: '12px', border: '1px solid #1f2937' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 'bold' }}>{symbol} Chart Analysis</h2>

{analysisData && (
<div style={{ display: 'flex', gap: '12px' }}>
<span style={{
padding: '6px 12px',
borderRadius: '6px',
background: analysisData.trend === 'BULLISH' ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: analysisData.trend === 'BULLISH' ? '#10b981' : '#ef4444',
fontWeight: 'bold',
border: `1px solid ${analysisData.trend === 'BULLISH' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)'}`
}}>
TREND: {analysisData.trend}
</span>
<span style={{
padding: '6px 12px',
borderRadius: '6px',
background: '#374151',
color: '#f3f4f6',
fontWeight: 'bold'
}}>
{analysisData.recommendation}
</span>
</div>
)}
</div>

<div style={{ position: 'relative', width: '100%', height: '400px' }}>
{loading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#9ca3af', zIndex: 10 }}>
Analyzing {symbol} Data...
</div>
)}
{error && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444', zIndex: 10 }}>
{error}
</div>
)}
<div
ref={chartContainerRef}
style={{
width: '100%',
height: '100%',
opacity: (loading || error) ? 0 : 1,
pointerEvents: (loading || error) ? 'none' : 'auto'
}}
/>
</div>
</div>
);
};
9 changes: 7 additions & 2 deletions src/features/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import React, { useState } from 'react';
import type { UserResponse } from '@/features/auth/auth.types';
import { PortfolioView } from '@/components/PortfolioView';
import { Sidebar } from '@/components/Sidebar';
import { TradingViewChart } from '@/components/TradingViewChart';
import '@/styles/components/dashboard.css';

interface DashboardPageProps {
Expand All @@ -13,6 +14,8 @@ interface DashboardPageProps {
* DashboardPage — Post-login dashboard landing.
*/
export const DashboardPage: React.FC<DashboardPageProps> = ({ user, onLogout }) => {
const [selectedStock, setSelectedStock] = useState<string | null>(null);

return (
<div
style={{
Expand All @@ -35,7 +38,9 @@ export const DashboardPage: React.FC<DashboardPageProps> = ({ user, onLogout })
Monitor and manage your active stock portfolios in real-time.
</p>

<PortfolioView />
<TradingViewChart symbol={selectedStock} />

<PortfolioView onSelectStock={setSelectedStock} />
</div>
</main>
</div>
Expand Down
22 changes: 18 additions & 4 deletions src/lib/api.client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import axios from 'axios';
import { useUserStore } from '@/stores/userStore';

const API_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
const MARKET_API_URL = import.meta.env.VITE_MARKET_API_BASE_URL || 'http://localhost:8001';
const API_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000';
const MARKET_API_URL = import.meta.env.VITE_MARKET_API_BASE_URL || 'http://127.0.0.1:8001';

export const apiClient = axios.create({
baseURL: API_URL,
Expand Down Expand Up @@ -34,6 +34,8 @@ apiClient.interceptors.request.use((config) => {
return config;
});

let refreshPromise: Promise<string> | null = null;

// Reusable response interceptor to handle 401s and automatic token refresh
const handleResponseError = (client: any) => async (error: any) => {
const originalRequest = error.config;
Expand All @@ -46,9 +48,21 @@ const handleResponseError = (client: any) => async (error: any) => {

originalRequest._retry = true;

if (!refreshPromise) {
refreshPromise = axios
.post(`${API_URL}/auth/refresh`, {}, { withCredentials: true, timeout: 5000 })
.then((res) => {
refreshPromise = null;
return res.data.access_token;
})
.catch((err) => {
refreshPromise = null;
throw err;
});
}

try {
const { data } = await axios.post(`${API_URL}/auth/refresh`, {}, { withCredentials: true });
const newAccessToken = data.access_token;
const newAccessToken = await refreshPromise;
useUserStore.getState().setAccessToken(newAccessToken);

originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
Expand Down
Loading
Loading