class ETFTradeTracker { constructor() { this.trades = []; this.currentPrices = new Map(); // Store current market prices this.cgtSettings = null; // Store CGT settings this.currentUser = null; this.apiUrl = '/api'; this.initializeApp(); } async initializeApp() { this.bindEvents(); this.bindNavigation(); this.bindAuthEvents(); this.bindSidebarToggle(); this.setDefaultDateTime(); // Check if user is logged in const isAuthenticated = await this.checkAuthentication(); if (isAuthenticated) { await this.loadTrades(); await this.loadCGTSettings(); this.renderTrades(); this.updateDashboard(); this.showPage('dashboard'); this.showAppContent(); } else { this.showLoginPage(); } } bindEvents() { const form = document.getElementById('trade-form'); const clearBtn = document.getElementById('clear-trades'); const exportBtn = document.getElementById('export-trades'); form.addEventListener('submit', (e) => this.handleSubmit(e)); clearBtn.addEventListener('click', () => this.clearAllTrades()); exportBtn.addEventListener('click', () => this.exportTrades()); } bindNavigation() { const menuItems = document.querySelectorAll('.menu-item'); const sidebarToggle = document.querySelector('.sidebar-toggle'); const sidebar = document.querySelector('.sidebar'); menuItems.forEach(item => { item.addEventListener('click', () => { const page = item.dataset.page; this.showPage(page); // Update active menu item menuItems.forEach(mi => mi.classList.remove('active')); item.classList.add('active'); // Hide sidebar on mobile after selection if (window.innerWidth <= 768) { sidebar.classList.remove('open'); } }); }); sidebarToggle.addEventListener('click', () => { sidebar.classList.toggle('open'); }); // Close sidebar when clicking outside on mobile document.addEventListener('click', (e) => { if (window.innerWidth <= 768 && !sidebar.contains(e.target) && !sidebarToggle.contains(e.target)) { sidebar.classList.remove('open'); } }); } bindSidebarToggle() { const sidebarCollapseBtn = document.getElementById('sidebar-collapse-btn'); const sidebar = document.querySelector('.sidebar'); const mainContent = document.querySelector('.main-content'); // Load saved sidebar state from localStorage const savedState = localStorage.getItem('sidebarCollapsed'); if (savedState === 'true') { sidebar.classList.add('collapsed'); mainContent.classList.add('sidebar-collapsed'); sidebarCollapseBtn.setAttribute('title', 'Expand sidebar'); sidebarCollapseBtn.innerHTML = '›'; } sidebarCollapseBtn.addEventListener('click', () => { const isCollapsed = sidebar.classList.contains('collapsed'); if (isCollapsed) { // Expand sidebar sidebar.classList.remove('collapsed'); mainContent.classList.remove('sidebar-collapsed'); sidebarCollapseBtn.setAttribute('title', 'Collapse sidebar'); sidebarCollapseBtn.innerHTML = '‹'; localStorage.setItem('sidebarCollapsed', 'false'); } else { // Collapse sidebar sidebar.classList.add('collapsed'); mainContent.classList.add('sidebar-collapsed'); sidebarCollapseBtn.setAttribute('title', 'Expand sidebar'); sidebarCollapseBtn.innerHTML = '›'; localStorage.setItem('sidebarCollapsed', 'true'); } }); // Handle mobile sidebar toggle (existing functionality) const sidebarToggle = document.querySelector('.sidebar-toggle'); if (sidebarToggle) { sidebarToggle.addEventListener('click', () => { sidebar.classList.toggle('open'); }); } } showPage(pageId) { const pages = document.querySelectorAll('.page'); const pageTitle = document.getElementById('page-title'); pages.forEach(page => { page.classList.remove('active'); }); const targetPage = document.getElementById(`${pageId}-page`); if (targetPage) { targetPage.classList.add('active'); } // Update page title const titles = { 'dashboard': 'Dashboard', 'add-trade': 'Add Trade', 'trade-history': 'Trade History', 'portfolio': 'Portfolio', 'gains-losses': 'Gains & Losses', 'cgt-settings': 'CGT Settings', 'admin': 'Admin Panel' }; pageTitle.textContent = titles[pageId] || 'Dashboard'; // Update specific pages when shown if (pageId === 'portfolio') { this.renderPortfolioPage(); } else if (pageId === 'gains-losses') { this.renderGainsLossesPage(); } else if (pageId === 'cgt-settings') { this.renderCGTSettingsPage(); } else if (pageId === 'admin') { this.renderAdminPage(); } } bindAuthEvents() { const loginForm = document.getElementById('login-form'); const logoutBtn = document.getElementById('logout-btn'); const createUserForm = document.getElementById('create-user-form'); if (loginForm) { loginForm.addEventListener('submit', (e) => this.handleLogin(e)); } if (logoutBtn) { logoutBtn.addEventListener('click', () => this.handleLogout()); } if (createUserForm) { createUserForm.addEventListener('submit', (e) => this.handleCreateUser(e)); } } async checkAuthentication() { try { const response = await fetch(`${this.apiUrl}/me`, { method: 'GET', credentials: 'include' }); if (response.ok) { this.currentUser = await response.json(); this.updateUserInfo(); return true; } } catch (error) { console.log('Not authenticated'); } return false; } showLoginPage() { document.querySelector('.sidebar').style.display = 'none'; document.getElementById('login-page').classList.add('active'); document.getElementById('page-title').textContent = 'Login'; // Hide all other pages document.querySelectorAll('.page:not(#login-page)').forEach(page => { page.classList.remove('active'); }); } showAppContent() { document.querySelector('.sidebar').style.display = 'block'; document.getElementById('login-page').classList.remove('active'); } async handleLogin(e) { e.preventDefault(); const username = document.getElementById('login-username').value; const password = document.getElementById('login-password').value; try { const response = await fetch(`${this.apiUrl}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ username, password }) }); if (response.ok) { this.currentUser = await response.json(); this.updateUserInfo(); this.showAppContent(); // Load user data await this.loadTrades(); this.renderTrades(); this.updateDashboard(); this.showPage('dashboard'); this.showNotification('Login successful!', 'success'); } else { const error = await response.json(); this.showNotification(error.error || 'Login failed', 'error'); } } catch (error) { console.error('Login error:', error); this.showNotification('Login failed', 'error'); } } async handleLogout() { try { await fetch(`${this.apiUrl}/logout`, { method: 'POST', credentials: 'include' }); this.currentUser = null; this.trades = []; this.currentPrices.clear(); this.showLoginPage(); this.showNotification('Logged out successfully', 'success'); } catch (error) { console.error('Logout error:', error); } } updateUserInfo() { const userInfo = document.getElementById('user-info'); const currentUserSpan = document.getElementById('current-user'); const adminMenus = document.querySelectorAll('.admin-only'); if (this.currentUser) { userInfo.style.display = 'block'; currentUserSpan.textContent = `${this.currentUser.username} ${this.currentUser.isAdmin ? '(Admin)' : ''}`; // Show/hide admin menus adminMenus.forEach(menu => { menu.style.display = this.currentUser.isAdmin ? 'block' : 'none'; }); } else { userInfo.style.display = 'none'; adminMenus.forEach(menu => { menu.style.display = 'none'; }); } } async handleCreateUser(e) { e.preventDefault(); const username = document.getElementById('new-username').value; const password = document.getElementById('new-password').value; const email = document.getElementById('new-email').value; const isAdmin = document.getElementById('new-is-admin').checked; try { const response = await fetch(`${this.apiUrl}/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ username, password, email, isAdmin }) }); if (response.ok) { document.getElementById('create-user-form').reset(); this.loadUsers(); // Refresh users list this.showNotification('User created successfully!', 'success'); } else { const error = await response.json(); this.showNotification(error.error || 'Failed to create user', 'error'); } } catch (error) { console.error('Create user error:', error); this.showNotification('Failed to create user', 'error'); } } async renderAdminPage() { if (!this.currentUser || !this.currentUser.isAdmin) { this.showNotification('Admin access required', 'error'); this.showPage('dashboard'); return; } this.loadUsers(); } async loadUsers() { try { const response = await fetch(`${this.apiUrl}/admin/users`, { method: 'GET', credentials: 'include' }); if (response.ok) { const users = await response.json(); this.renderUsersList(users); } else { this.showNotification('Failed to load users', 'error'); } } catch (error) { console.error('Load users error:', error); this.showNotification('Failed to load users', 'error'); } } renderUsersList(users) { const usersList = document.getElementById('users-list'); if (users.length === 0) { usersList.innerHTML = '

No users found

'; return; } const usersHTML = users.map(user => { const createdDate = new Date(user.created_at).toLocaleDateString(); const lastLogin = user.last_login ? new Date(user.last_login).toLocaleDateString() : 'Never'; return `
${user.username}
${user.is_admin ? 'Admin' : 'User'} ${user.id === this.currentUser.id ? 'You' : ''}
${user.id !== this.currentUser.id ? `` : ''}
`; }).join(''); usersList.innerHTML = usersHTML; } async deleteUser(userId) { if (!confirm('Are you sure you want to delete this user? This will also delete all their trades.')) { return; } try { const response = await fetch(`${this.apiUrl}/admin/users/${userId}`, { method: 'DELETE', credentials: 'include' }); if (response.ok) { this.loadUsers(); // Refresh users list this.showNotification('User deleted successfully', 'success'); } else { const error = await response.json(); this.showNotification(error.error || 'Failed to delete user', 'error'); } } catch (error) { console.error('Delete user error:', error); this.showNotification('Failed to delete user', 'error'); } } setDefaultDateTime() { const now = new Date(); const dateInput = document.getElementById('trade-date'); const timeInput = document.getElementById('trade-time'); dateInput.value = now.toISOString().split('T')[0]; timeInput.value = now.toTimeString().split(' ')[0].substring(0, 5); } async handleSubmit(e) { e.preventDefault(); const formData = new FormData(e.target); const trade = this.createTradeFromForm(formData); if (this.validateTrade(trade)) { await this.addTrade(trade); } } createTradeFromForm(formData) { const etfSymbol = document.getElementById('etf-symbol').value.trim().toUpperCase(); const tradeType = document.getElementById('trade-type').value; const shares = parseFloat(document.getElementById('shares').value); const price = parseFloat(document.getElementById('price').value); const currency = document.getElementById('currency').value; const tradeDate = document.getElementById('trade-date').value; const tradeTime = document.getElementById('trade-time').value; const fees = parseFloat(document.getElementById('fees').value) || 0; const notes = document.getElementById('notes').value.trim(); const dateTime = new Date(`${tradeDate}T${tradeTime}`); return { etfSymbol, tradeType, shares, price, currency, dateTime: dateTime.toISOString(), fees, notes }; } validateTrade(trade) { if (!trade.etfSymbol || !trade.tradeType || !trade.shares || !trade.price || !trade.currency) { this.showNotification('Please fill in all required fields', 'error'); return false; } if (trade.shares <= 0 || trade.price <= 0) { this.showNotification('Shares and price must be greater than 0', 'error'); return false; } return true; } async addTrade(trade) { try { const response = await fetch(`${this.apiUrl}/trades`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(trade) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to add trade'); } const newTrade = await response.json(); this.trades.unshift(newTrade); this.renderTrades(); this.updateDashboard(); this.resetForm(); this.showNotification('Trade added successfully!', 'success'); // Navigate to dashboard after successful trade setTimeout(() => { this.showPage('dashboard'); const menuItems = document.querySelectorAll('.menu-item'); menuItems.forEach(mi => mi.classList.remove('active')); document.querySelector('[data-page="dashboard"]').classList.add('active'); }, 1500); } catch (error) { console.error('Error adding trade:', error); this.showNotification(error.message || 'Failed to add trade', 'error'); } } renderTrades() { const tradesList = document.getElementById('trades-list'); if (this.trades.length === 0) { tradesList.innerHTML = '

No trades recorded yet. Add your first trade above!

'; return; } const tradesHTML = this.trades.map(trade => this.createTradeHTML(trade)).join(''); tradesList.innerHTML = tradesHTML; } createTradeHTML(trade) { const dateTime = new Date(trade.dateTime); const formattedDate = dateTime.toLocaleDateString(); const formattedTime = dateTime.toTimeString().split(' ')[0]; const currencySymbol = trade.currency === 'EUR' ? '€' : '$'; return `
${trade.etfSymbol} ${trade.tradeType.toUpperCase()}
${trade.shares} shares ${currencySymbol}${trade.price.toFixed(2)} per share Total: ${currencySymbol}${trade.totalValue.toFixed(2)} ${trade.fees > 0 ? `Fees: ${currencySymbol}${trade.fees.toFixed(2)}` : ''}
${formattedDate} ${formattedTime}
${trade.notes ? `
${trade.notes}
` : ''}
`; } async deleteTrade(tradeId) { if (confirm('Are you sure you want to delete this trade?')) { try { const response = await fetch(`${this.apiUrl}/trades/${tradeId}`, { method: 'DELETE' }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to delete trade'); } this.trades = this.trades.filter(trade => trade.id !== tradeId); this.renderTrades(); this.updateDashboard(); this.showNotification('Trade deleted successfully!', 'success'); } catch (error) { console.error('Error deleting trade:', error); this.showNotification(error.message || 'Failed to delete trade', 'error'); } } } async clearAllTrades() { if (confirm('Are you sure you want to delete all trades? This action cannot be undone.')) { try { const response = await fetch(`${this.apiUrl}/trades`, { method: 'DELETE' }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to clear trades'); } this.trades = []; this.renderTrades(); this.updateDashboard(); this.showNotification('All trades cleared!', 'success'); } catch (error) { console.error('Error clearing trades:', error); this.showNotification(error.message || 'Failed to clear trades', 'error'); } } } exportTrades() { if (this.trades.length === 0) { this.showNotification('No trades to export', 'error'); return; } const dataStr = JSON.stringify(this.trades, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(dataBlob); link.download = `etf-trades-${new Date().toISOString().split('T')[0]}.json`; link.click(); this.showNotification('Trades exported successfully!', 'success'); } resetForm() { document.getElementById('trade-form').reset(); this.setDefaultDateTime(); } async loadTrades() { try { const response = await fetch(`${this.apiUrl}/trades`); if (!response.ok) { throw new Error('Failed to load trades'); } this.trades = await response.json(); } catch (error) { console.error('Error loading trades:', error); this.showNotification('Failed to load trades from server', 'error'); this.trades = []; } } showNotification(message, type = 'info') { const notification = document.createElement('div'); notification.className = `notification ${type}`; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.classList.add('show'); }, 100); setTimeout(() => { notification.classList.remove('show'); setTimeout(() => { document.body.removeChild(notification); }, 300); }, 3000); } async getPortfolioSummary() { try { const response = await fetch(`${this.apiUrl}/portfolio-summary`); if (!response.ok) { throw new Error('Failed to fetch portfolio summary'); } return await response.json(); } catch (error) { console.error('Error fetching portfolio summary:', error); return []; } } updateDashboard() { this.calculateDashboardMetrics(); this.renderETFBreakdown(); } updateDashboardColors(totalGains) { const gainsCard = document.querySelector('.dashboard-card.total-gains'); const currentValueCard = document.querySelector('.dashboard-card.total-value'); const costBasisCard = document.querySelector('.dashboard-card.cost-basis'); if (gainsCard) { gainsCard.classList.remove('positive', 'negative', 'neutral'); if (totalGains > 0) { gainsCard.classList.add('positive'); } else if (totalGains < 0) { gainsCard.classList.add('negative'); } else { gainsCard.classList.add('neutral'); } } // Add subtle indicators to other cards if (currentValueCard) { currentValueCard.classList.remove('positive', 'negative'); if (totalGains !== 0) { currentValueCard.classList.add(totalGains > 0 ? 'positive' : 'negative'); } } if (costBasisCard) { costBasisCard.classList.remove('positive', 'negative'); if (totalGains !== 0) { costBasisCard.classList.add(totalGains > 0 ? 'positive' : 'negative'); } } } calculateDashboardMetrics() { const now = new Date(); const currentMonth = now.getMonth(); const currentYear = now.getFullYear(); let totalCostBasis = 0; let totalCurrentValue = 0; let totalShares = 0; let monthlyInvestment = 0; let yearlyInvestment = 0; let monthlyTrades = 0; let yearlyTrades = 0; let uniqueETFs = new Set(); let hasCurrentPrices = false; // Calculate portfolio metrics using ETF positions const etfMap = this.getActiveETFPositions(); etfMap.forEach((etf, symbol) => { totalCostBasis += etf.totalValue; totalShares += etf.shares; uniqueETFs.add(symbol); // Calculate current value using updated prices or cost basis const currentPrice = this.currentPrices.get(symbol); if (currentPrice) { totalCurrentValue += etf.shares * currentPrice; hasCurrentPrices = true; } else { totalCurrentValue += etf.totalValue; // Use cost basis if no current price } }); // Calculate trade-based metrics (monthly/yearly) this.trades.forEach(trade => { const tradeDate = new Date(trade.dateTime); const tradeValue = trade.tradeType === 'buy' ? trade.totalValue : -trade.totalValue; yearlyInvestment += tradeValue; yearlyTrades++; if (tradeDate.getMonth() === currentMonth && tradeDate.getFullYear() === currentYear) { monthlyInvestment += tradeValue; monthlyTrades++; } }); // Calculate gains/losses const totalGains = totalCurrentValue - totalCostBasis; const gainsPercentage = totalCostBasis > 0 ? ((totalGains / totalCostBasis) * 100) : 0; const avgReturn = gainsPercentage; // Update dashboard elements document.getElementById('dashboard-current-value').textContent = this.formatCurrency(totalCurrentValue); document.getElementById('dashboard-total-gains').textContent = this.formatCurrency(totalGains); document.getElementById('dashboard-gains-percentage').textContent = `${totalGains >= 0 ? '+' : ''}${gainsPercentage.toFixed(1)}%`; document.getElementById('dashboard-cost-basis').textContent = this.formatCurrency(totalCostBasis); document.getElementById('dashboard-avg-return').textContent = `Avg return: ${avgReturn >= 0 ? '+' : ''}${avgReturn.toFixed(1)}%`; document.getElementById('total-shares').textContent = totalShares.toFixed(3); document.getElementById('unique-etfs').textContent = `${uniqueETFs.size} ETFs`; document.getElementById('monthly-investment').textContent = this.formatCurrency(monthlyInvestment); document.getElementById('monthly-trades').textContent = `${monthlyTrades} trades`; document.getElementById('yearly-investment').textContent = this.formatCurrency(yearlyInvestment); document.getElementById('yearly-trades').textContent = `${yearlyTrades} trades`; // Update status indicators const lastUpdatedElement = document.getElementById('dashboard-last-updated'); if (hasCurrentPrices) { lastUpdatedElement.textContent = `Updated: ${new Date().toLocaleString()}`; } else { lastUpdatedElement.textContent = 'Using cost basis - update prices in Gains/Losses'; } // Update dashboard card colors for gains/losses this.updateDashboardColors(totalGains); } renderETFBreakdown() { const etfMap = new Map(); this.trades.forEach(trade => { if (!etfMap.has(trade.etfSymbol)) { etfMap.set(trade.etfSymbol, { symbol: trade.etfSymbol, shares: 0, totalValue: 0, currency: trade.currency, trades: 0 }); } const etf = etfMap.get(trade.etfSymbol); const multiplier = trade.tradeType === 'buy' ? 1 : -1; etf.shares += trade.shares * multiplier; etf.totalValue += trade.totalValue * multiplier; etf.trades++; }); const breakdownList = document.getElementById('etf-breakdown-list'); if (etfMap.size === 0) { breakdownList.innerHTML = '

No ETF positions yet

'; return; } const etfEntries = Array.from(etfMap.values()).filter(etf => etf.shares > 0); if (etfEntries.length === 0) { breakdownList.innerHTML = '

No current ETF positions

'; return; } const breakdownHTML = etfEntries.map(etf => { const currencySymbol = etf.currency === 'EUR' ? '€' : '$'; const avgPrice = etf.shares > 0 ? etf.totalValue / etf.shares : 0; return `
${etf.symbol} ${currencySymbol}${etf.totalValue.toFixed(2)}
${etf.shares.toFixed(3)} shares Avg: ${currencySymbol}${avgPrice.toFixed(2)} ${etf.trades} trades
`; }).join(''); breakdownList.innerHTML = breakdownHTML; } renderPortfolioPage() { const portfolioSummary = document.getElementById('portfolio-summary'); const etfMap = new Map(); this.trades.forEach(trade => { if (!etfMap.has(trade.etfSymbol)) { etfMap.set(trade.etfSymbol, { symbol: trade.etfSymbol, shares: 0, totalValue: 0, currency: trade.currency, trades: 0, avgPrice: 0 }); } const etf = etfMap.get(trade.etfSymbol); const multiplier = trade.tradeType === 'buy' ? 1 : -1; etf.shares += trade.shares * multiplier; etf.totalValue += trade.totalValue * multiplier; etf.trades++; etf.avgPrice = etf.shares > 0 ? etf.totalValue / etf.shares : 0; }); const activeETFs = Array.from(etfMap.values()).filter(etf => etf.shares > 0); if (activeETFs.length === 0) { portfolioSummary.innerHTML = '

No active positions in your portfolio

'; return; } let totalPortfolioValue = 0; activeETFs.forEach(etf => { totalPortfolioValue += etf.totalValue; }); const portfolioHTML = `

Portfolio Overview

Total Value ${this.formatCurrency(totalPortfolioValue)}
Active ETFs ${activeETFs.length}
Total Trades ${this.trades.length}

Holdings

${activeETFs.map(etf => { const currencySymbol = etf.currency === 'EUR' ? '€' : '$'; const allocation = ((etf.totalValue / totalPortfolioValue) * 100).toFixed(1); return `
${etf.symbol} ${allocation}%
Shares ${etf.shares.toFixed(3)}
Value ${currencySymbol}${etf.totalValue.toFixed(2)}
Avg Price ${currencySymbol}${etf.avgPrice.toFixed(2)}
Trades ${etf.trades}
`; }).join('')}
`; portfolioSummary.innerHTML = portfolioHTML; } renderGainsLossesPage() { this.renderPriceUpdateList(); this.calculateAndDisplayPerformance(); this.bindGainsLossesEvents(); } bindGainsLossesEvents() { const updateAllBtn = document.getElementById('update-all-prices'); if (updateAllBtn && !updateAllBtn.hasEventListener) { updateAllBtn.addEventListener('click', () => this.updateAllPrices()); updateAllBtn.hasEventListener = true; } } renderPriceUpdateList() { const priceUpdateList = document.getElementById('price-update-list'); const etfMap = this.getActiveETFPositions(); if (etfMap.size === 0) { priceUpdateList.innerHTML = '

No ETF positions to update

'; return; } const updateHTML = Array.from(etfMap.entries()).map(([symbol, etf]) => { const currentPrice = this.currentPrices.get(symbol) || ''; const currencySymbol = etf.currency === 'EUR' ? '€' : '$'; return `
${symbol} ${etf.shares.toFixed(3)} shares
Avg Cost: ${currencySymbol}${etf.avgPrice.toFixed(2)} -
`; }).join(''); priceUpdateList.innerHTML = updateHTML; // Bind individual update events document.querySelectorAll('.update-price-btn').forEach(btn => { btn.addEventListener('click', (e) => { const symbol = e.target.dataset.symbol; const input = document.querySelector(`input[data-symbol="${symbol}"]`); const price = parseFloat(input.value); if (price > 0) { this.updateETFPrice(symbol, price); } else { this.showNotification('Please enter a valid price', 'error'); } }); }); // Update on Enter key document.querySelectorAll('.current-price-input').forEach(input => { input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { const symbol = e.target.dataset.symbol; const price = parseFloat(e.target.value); if (price > 0) { this.updateETFPrice(symbol, price); } } }); }); } getActiveETFPositions() { const etfMap = new Map(); this.trades.forEach(trade => { if (!etfMap.has(trade.etfSymbol)) { etfMap.set(trade.etfSymbol, { symbol: trade.etfSymbol, shares: 0, totalValue: 0, currency: trade.currency, trades: 0, avgPrice: 0 }); } const etf = etfMap.get(trade.etfSymbol); const multiplier = trade.tradeType === 'buy' ? 1 : -1; etf.shares += trade.shares * multiplier; etf.totalValue += trade.totalValue * multiplier; etf.trades++; etf.avgPrice = etf.shares > 0 ? etf.totalValue / etf.shares : 0; }); // Filter out positions with 0 or negative shares const activePositions = new Map(); etfMap.forEach((etf, symbol) => { if (etf.shares > 0) { activePositions.set(symbol, etf); } }); return activePositions; } updateETFPrice(symbol, currentPrice) { this.currentPrices.set(symbol, currentPrice); this.calculateAndDisplayPerformance(); this.updatePriceChangeIndicator(symbol, currentPrice); this.updateDashboard(); // Update dashboard when prices change this.showNotification(`Updated ${symbol} price to ${currentPrice}`, 'success'); } updatePriceChangeIndicator(symbol, currentPrice) { const etfMap = this.getActiveETFPositions(); const etf = etfMap.get(symbol); if (etf) { const changeAmount = currentPrice - etf.avgPrice; const changePercent = ((changeAmount / etf.avgPrice) * 100); const currencySymbol = etf.currency === 'EUR' ? '€' : '$'; const changeElement = document.querySelector(`[data-symbol="${symbol}"].price-change`); if (changeElement) { const changeClass = changeAmount >= 0 ? 'positive' : 'negative'; const changeSign = changeAmount >= 0 ? '+' : ''; changeElement.className = `price-change ${changeClass}`; changeElement.textContent = `${changeSign}${currencySymbol}${changeAmount.toFixed(2)} (${changeSign}${changePercent.toFixed(1)}%)`; } } } updateAllPrices() { const inputs = document.querySelectorAll('.current-price-input'); let updatedCount = 0; inputs.forEach(input => { const price = parseFloat(input.value); if (price > 0) { this.updateETFPrice(input.dataset.symbol, price); updatedCount++; } }); if (updatedCount > 0) { this.showNotification(`Updated ${updatedCount} ETF prices`, 'success'); } else { this.showNotification('Please enter valid prices first', 'error'); } } calculateAndDisplayPerformance() { const etfMap = this.getActiveETFPositions(); let totalCost = 0; let totalCurrentValue = 0; let hasCurrentPrices = false; etfMap.forEach((etf, symbol) => { totalCost += etf.totalValue; const currentPrice = this.currentPrices.get(symbol); if (currentPrice) { totalCurrentValue += etf.shares * currentPrice; hasCurrentPrices = true; } else { totalCurrentValue += etf.totalValue; // Use cost basis if no current price } }); const totalGainLoss = totalCurrentValue - totalCost; const totalPercentage = totalCost > 0 ? ((totalGainLoss / totalCost) * 100) : 0; // Update performance summary document.getElementById('total-performance').textContent = this.formatCurrency(totalGainLoss); document.getElementById('total-percentage').textContent = `${totalPercentage >= 0 ? '+' : ''}${totalPercentage.toFixed(1)}%`; document.getElementById('unrealized-performance').textContent = this.formatCurrency(totalGainLoss); document.getElementById('unrealized-percentage').textContent = `${totalPercentage >= 0 ? '+' : ''}${totalPercentage.toFixed(1)}%`; document.getElementById('current-portfolio-value').textContent = this.formatCurrency(totalCurrentValue); // Update performance card colors const performanceCards = document.querySelectorAll('.performance-card'); performanceCards.forEach(card => { if (card.classList.contains('total-gains') || card.classList.contains('unrealized-gains')) { card.classList.remove('positive', 'negative'); card.classList.add(totalGainLoss >= 0 ? 'positive' : 'negative'); } }); // Update last updated timestamp const lastUpdated = document.getElementById('last-updated'); if (hasCurrentPrices) { lastUpdated.textContent = `Updated: ${new Date().toLocaleString()}`; } else { lastUpdated.textContent = 'Using cost basis - update prices'; } // Calculate and display CGT information this.calculateAndDisplayCGT(etfMap, hasCurrentPrices); this.renderGainsBreakdown(etfMap); } renderGainsBreakdown(etfMap) { const breakdownList = document.getElementById('gains-breakdown-list'); if (etfMap.size === 0) { breakdownList.innerHTML = '

No active positions

'; return; } const breakdownHTML = Array.from(etfMap.entries()).map(([symbol, etf]) => { const currentPrice = this.currentPrices.get(symbol) || etf.avgPrice; const currentValue = etf.shares * currentPrice; const gainLoss = currentValue - etf.totalValue; const percentage = etf.totalValue > 0 ? ((gainLoss / etf.totalValue) * 100) : 0; const currencySymbol = etf.currency === 'EUR' ? '€' : '$'; const performanceClass = gainLoss >= 0 ? 'positive' : 'negative'; const hasRealPrice = this.currentPrices.has(symbol); return `
${symbol} ${gainLoss >= 0 ? '+' : ''}${currencySymbol}${gainLoss.toFixed(2)} (${gainLoss >= 0 ? '+' : ''}${percentage.toFixed(1)}%)
Shares ${etf.shares.toFixed(3)}
Avg Cost ${currencySymbol}${etf.avgPrice.toFixed(2)}
Current Price ${currencySymbol}${currentPrice.toFixed(2)} ${!hasRealPrice ? '(est.)' : ''}
Current Value ${currencySymbol}${currentValue.toFixed(2)}
`; }).join(''); breakdownList.innerHTML = breakdownHTML; } formatCurrency(amount) { const absAmount = Math.abs(amount); if (absAmount >= 1000000) { return (amount >= 0 ? '€' : '-€') + (absAmount / 1000000).toFixed(2) + 'M'; } else if (absAmount >= 1000) { return (amount >= 0 ? '€' : '-€') + (absAmount / 1000).toFixed(1) + 'K'; } else { return (amount >= 0 ? '€' : '-€') + absAmount.toFixed(2); } } calculateAndDisplayCGT(etfMap, hasCurrentPrices) { if (!this.cgtSettings || !this.cgtSettings.enabled || !hasCurrentPrices) { document.getElementById('cgt-summary').style.display = 'none'; return; } let totalCGTLiability = 0; let totalGainsBeforeTax = 0; let totalAfterTaxGains = 0; const allHoldingPeriods = {}; // Calculate CGT for each ETF position etfMap.forEach((etf, symbol) => { const cgtCalc = this.calculateCGTForPosition(symbol, etf); totalCGTLiability += cgtCalc.totalCGT; totalGainsBeforeTax += cgtCalc.totalGains; totalAfterTaxGains += cgtCalc.afterTaxGains; // Merge holding periods Object.keys(cgtCalc.holdingPeriods).forEach(period => { if (!allHoldingPeriods[period]) { allHoldingPeriods[period] = { gains: 0, cgt: 0 }; } allHoldingPeriods[period].gains += cgtCalc.holdingPeriods[period].gains; allHoldingPeriods[period].cgt += cgtCalc.holdingPeriods[period].cgt; }); }); // Apply annual exemption const exemptionUsed = Math.min(totalGainsBeforeTax, this.cgtSettings.annual_exemption); const taxableGains = Math.max(0, totalGainsBeforeTax - exemptionUsed); const adjustedCGTLiability = Math.max(0, totalCGTLiability - (exemptionUsed * (totalCGTLiability / totalGainsBeforeTax))); const finalAfterTaxGains = totalGainsBeforeTax - adjustedCGTLiability; const effectiveRate = totalGainsBeforeTax > 0 ? (adjustedCGTLiability / totalGainsBeforeTax) * 100 : 0; // Update CGT summary display document.getElementById('total-cgt-liability').textContent = this.formatCurrency(adjustedCGTLiability); document.getElementById('cgt-effective-rate').textContent = `Effective rate: ${effectiveRate.toFixed(1)}%`; document.getElementById('after-tax-gains').textContent = this.formatCurrency(finalAfterTaxGains); document.getElementById('exemption-used').textContent = `Exemption: ${this.formatCurrency(exemptionUsed)} used`; // Update holding periods summary const holdingPeriodsEl = document.getElementById('holding-periods-summary'); const shortTermGains = (allHoldingPeriods['0-1 Month']?.gains || 0) + (allHoldingPeriods['1-6 Months']?.gains || 0) + (allHoldingPeriods['6M-1 Year']?.gains || 0); const longTermGains = (allHoldingPeriods['1-2 Years']?.gains || 0) + (allHoldingPeriods['2+ Years']?.gains || 0); holdingPeriodsEl.innerHTML = `
Short-term (<1Y): ${this.formatCurrency(shortTermGains)}
Long-term (1Y+): ${this.formatCurrency(longTermGains)}
`; // Show/update CGT card colors const cgtCards = document.querySelectorAll('.cgt-card'); cgtCards.forEach(card => { card.classList.remove('positive', 'negative'); if (card.classList.contains('total-cgt')) { card.classList.add('negative'); // Tax is always red } else if (card.classList.contains('after-tax')) { card.classList.add(finalAfterTaxGains >= 0 ? 'positive' : 'negative'); } }); document.getElementById('cgt-summary').style.display = 'block'; } // CGT Settings and Calculation Methods async loadCGTSettings() { try { const response = await fetch(`${this.apiUrl}/cgt-settings`, { method: 'GET', credentials: 'include' }); if (response.ok) { this.cgtSettings = await response.json(); } else { // Use default settings if fetch fails this.cgtSettings = { rate_1month: 40.0, rate_6months: 35.0, rate_1year: 30.0, rate_2years: 20.0, rate_longterm: 10.0, annual_exemption: 1270.0, enabled: true }; } } catch (error) { console.error('Error loading CGT settings:', error); this.cgtSettings = { rate_1month: 40.0, rate_6months: 35.0, rate_1year: 30.0, rate_2years: 20.0, rate_longterm: 10.0, annual_exemption: 1270.0, enabled: true }; } } async saveCGTSettings(settings) { try { const response = await fetch(`${this.apiUrl}/cgt-settings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(settings) }); if (response.ok) { this.cgtSettings = settings; this.showNotification('CGT settings saved successfully!', 'success'); // Refresh gains/losses calculations if (document.getElementById('gains-losses-page').classList.contains('active')) { this.renderGainsLossesPage(); } // Refresh dashboard this.updateDashboard(); return true; } else { const error = await response.json(); this.showNotification(error.error || 'Failed to save CGT settings', 'error'); return false; } } catch (error) { console.error('Error saving CGT settings:', error); this.showNotification('Failed to save CGT settings', 'error'); return false; } } getCGTRateForHoldingPeriod(holdingDays) { if (!this.cgtSettings || !this.cgtSettings.enabled) { return 0; } const holdingMonths = holdingDays / 30.44; // Average days per month const holdingYears = holdingDays / 365.25; // Average days per year if (holdingDays <= 30) { return this.cgtSettings.rate_1month; } else if (holdingMonths <= 6) { return this.cgtSettings.rate_6months; } else if (holdingYears <= 1) { return this.cgtSettings.rate_1year; } else if (holdingYears <= 2) { return this.cgtSettings.rate_2years; } else { return this.cgtSettings.rate_longterm; } } calculateCGTForPosition(etfSymbol, etfData) { if (!this.cgtSettings || !this.cgtSettings.enabled) { return { totalCGT: 0, afterTaxGains: etfData.totalGains || 0, effectiveRate: 0, holdingPeriods: {} }; } const currentPrice = this.currentPrices.get(etfSymbol); if (!currentPrice) { return { totalCGT: 0, afterTaxGains: 0, effectiveRate: 0, holdingPeriods: {} }; } // Get all buy trades for this ETF, sorted by date (FIFO method) const buyTrades = this.trades .filter(t => t.etfSymbol === etfSymbol && t.tradeType === 'buy') .sort((a, b) => new Date(a.dateTime) - new Date(b.dateTime)); let totalCGT = 0; let totalGains = 0; let totalCost = 0; const holdingPeriods = {}; const now = new Date(); buyTrades.forEach(trade => { const tradeDate = new Date(trade.dateTime); const holdingDays = Math.floor((now - tradeDate) / (1000 * 60 * 60 * 24)); const currentValue = trade.shares * currentPrice; const cost = trade.totalValue; const gain = currentValue - cost; if (gain > 0) { const cgtRate = this.getCGTRateForHoldingPeriod(holdingDays); const cgt = (gain * cgtRate) / 100; totalCGT += cgt; totalGains += gain; totalCost += cost; // Group by holding period for display const period = this.getHoldingPeriodLabel(holdingDays); if (!holdingPeriods[period]) { holdingPeriods[period] = { gains: 0, cgt: 0, rate: cgtRate }; } holdingPeriods[period].gains += gain; holdingPeriods[period].cgt += cgt; } }); const afterTaxGains = totalGains - totalCGT; const effectiveRate = totalGains > 0 ? (totalCGT / totalGains) * 100 : 0; return { totalCGT, afterTaxGains, effectiveRate, holdingPeriods, totalGains, totalCost }; } getHoldingPeriodLabel(holdingDays) { const holdingMonths = holdingDays / 30.44; const holdingYears = holdingDays / 365.25; if (holdingDays <= 30) { return '0-1 Month'; } else if (holdingMonths <= 6) { return '1-6 Months'; } else if (holdingYears <= 1) { return '6M-1 Year'; } else if (holdingYears <= 2) { return '1-2 Years'; } else { return '2+ Years'; } } renderCGTSettingsPage() { if (!this.cgtSettings) { this.loadCGTSettings(); return; } // Populate form with current settings document.getElementById('cgt-1month').value = this.cgtSettings.rate_1month; document.getElementById('cgt-6months').value = this.cgtSettings.rate_6months; document.getElementById('cgt-1year').value = this.cgtSettings.rate_1year; document.getElementById('cgt-2years').value = this.cgtSettings.rate_2years; document.getElementById('cgt-longterm').value = this.cgtSettings.rate_longterm; document.getElementById('cgt-annual-exemption').value = this.cgtSettings.annual_exemption; document.getElementById('cgt-enabled').checked = this.cgtSettings.enabled; this.updateCGTPreview(); this.bindCGTEvents(); } bindCGTEvents() { const form = document.getElementById('cgt-settings-form'); const resetBtn = document.getElementById('reset-cgt-defaults'); const inputs = document.querySelectorAll('.cgt-rate-input'); // Remove existing listeners to prevent duplicates form.removeEventListener('submit', this.handleCGTFormSubmit); resetBtn.removeEventListener('click', this.handleCGTReset); // Bind form submission this.handleCGTFormSubmit = async (e) => { e.preventDefault(); const settings = { rate_1month: parseFloat(document.getElementById('cgt-1month').value), rate_6months: parseFloat(document.getElementById('cgt-6months').value), rate_1year: parseFloat(document.getElementById('cgt-1year').value), rate_2years: parseFloat(document.getElementById('cgt-2years').value), rate_longterm: parseFloat(document.getElementById('cgt-longterm').value), annual_exemption: parseFloat(document.getElementById('cgt-annual-exemption').value), enabled: document.getElementById('cgt-enabled').checked }; await this.saveCGTSettings(settings); }; // Bind reset button this.handleCGTReset = () => { document.getElementById('cgt-1month').value = 40; document.getElementById('cgt-6months').value = 35; document.getElementById('cgt-1year').value = 30; document.getElementById('cgt-2years').value = 20; document.getElementById('cgt-longterm').value = 10; document.getElementById('cgt-annual-exemption').value = 1270; document.getElementById('cgt-enabled').checked = true; this.updateCGTPreview(); }; form.addEventListener('submit', this.handleCGTFormSubmit); resetBtn.addEventListener('click', this.handleCGTReset); // Update preview on input change inputs.forEach(input => { input.addEventListener('input', () => this.updateCGTPreview()); }); } updateCGTPreview() { const rates = { '1month': parseFloat(document.getElementById('cgt-1month').value) || 0, '6months': parseFloat(document.getElementById('cgt-6months').value) || 0, '1year': parseFloat(document.getElementById('cgt-1year').value) || 0, '2years': parseFloat(document.getElementById('cgt-2years').value) || 0, 'longterm': parseFloat(document.getElementById('cgt-longterm').value) || 0 }; Object.keys(rates).forEach(period => { const rateBar = document.querySelector(`[data-period="${period}"]`); if (rateBar) { const rateValue = rateBar.querySelector('.rate-value'); rateValue.textContent = `${rates[period]}%`; // Update bar height based on rate (max 50% height) const height = Math.max(10, (rates[period] / 100) * 50); rateBar.style.setProperty('--rate-height', `${height}%`); } }); } } const app = new ETFTradeTracker();