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.setDefaultDateTime(); // Check if user is logged in const isAuthenticated = await this.checkAuthentication(); if (isAuthenticated) { await this.loadTrades(); await this.loadCGTSettings(); await this.loadLatestPrices(); 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'); } }); } 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', 'tokens': 'API Tokens', 'cash-accounts': 'Cash Accounts', 'add-transfer': 'Add Transfer', '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 === 'tokens') { this.renderTokensPage(); } else if (pageId === 'cash-accounts') { this.renderCashAccountsPage(); } else if (pageId === 'add-transfer') { this.renderAddTransferPage(); } 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 = this.getCurrencySymbol(trade.currency); 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(); this.loadDashboardCashData(); } 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); // Update total holdings card with portfolio data this.updateTotalHoldingsCard(); } 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 = this.getCurrencySymbol(etf.currency); 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; } async loadDashboardCashData() { try { // Load cash summary const summaryResponse = await fetch(`${this.apiUrl}/cash-summary`, { credentials: 'include' }); if (summaryResponse.ok) { const summary = await summaryResponse.json(); this.updateDashboardCashSummary(summary); // If there are cash accounts, also load detailed account list if (summary.account_count > 0) { const accountsResponse = await fetch(`${this.apiUrl}/cash-accounts`, { credentials: 'include' }); if (accountsResponse.ok) { const accounts = await accountsResponse.json(); this.renderDashboardCashAccounts(accounts); // Show the cash breakdown section const cashBreakdown = document.getElementById('cash-breakdown'); if (cashBreakdown) { cashBreakdown.style.display = 'block'; } } } else { // Hide the cash breakdown section if no accounts const cashBreakdown = document.getElementById('cash-breakdown'); if (cashBreakdown) { cashBreakdown.style.display = 'none'; } } } } catch (error) { console.error('Error loading dashboard cash data:', error); // Hide the cash breakdown section on error const cashBreakdown = document.getElementById('cash-breakdown'); if (cashBreakdown) { cashBreakdown.style.display = 'none'; } } } updateDashboardCashSummary(summary) { // Update EUR amount document.getElementById('dashboard-cash-eur').textContent = `€${summary.total_eur.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; // Update USD amount and show/hide USD line based on amount const usdLine = document.getElementById('dashboard-cash-usd-line'); const usdAmount = document.getElementById('dashboard-cash-usd'); usdAmount.textContent = `$${summary.total_usd.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; if (summary.total_usd > 0) { usdLine.style.display = 'flex'; } else { usdLine.style.display = 'none'; } // Update account count const accountCountText = summary.account_count === 1 ? '1 account' : `${summary.account_count} accounts`; document.getElementById('dashboard-account-count').textContent = accountCountText; // Update average interest and show/hide based on whether there are accounts with interest const avgInterestDisplay = document.getElementById('dashboard-avg-interest-display'); if (summary.avg_interest_rate > 0) { avgInterestDisplay.textContent = `Avg: ${summary.avg_interest_rate.toFixed(1)}%`; avgInterestDisplay.style.display = 'block'; } else { avgInterestDisplay.style.display = 'none'; } // Update total holdings card after cash summary is updated this.updateTotalHoldingsCard(); } updateTotalHoldingsCard() { // Get current portfolio value (from dashboard metric) const portfolioValueElement = document.getElementById('dashboard-current-value'); let portfolioValue = 0; if (portfolioValueElement && portfolioValueElement.textContent) { // Parse the portfolio value (remove € and commas) const portfolioText = portfolioValueElement.textContent.replace(/[€,]/g, '').trim(); portfolioValue = parseFloat(portfolioText) || 0; } // Get current cash savings total (EUR equivalent) let totalCashEUR = 0; // Get EUR cash const eurCashElement = document.getElementById('dashboard-cash-eur'); if (eurCashElement && eurCashElement.textContent) { const eurText = eurCashElement.textContent.replace(/[€,]/g, '').trim(); totalCashEUR += parseFloat(eurText) || 0; } // Get USD cash and convert to EUR (simplified conversion - ideally use actual rates) const usdCashElement = document.getElementById('dashboard-cash-usd'); if (usdCashElement && usdCashElement.textContent && usdCashElement.closest('#dashboard-cash-usd-line').style.display !== 'none') { const usdText = usdCashElement.textContent.replace(/[\$,]/g, '').trim(); const usdAmount = parseFloat(usdText) || 0; // Convert USD to EUR (using approximate rate of 0.85, should be made configurable) totalCashEUR += usdAmount * 0.85; } // Calculate total const totalHoldings = portfolioValue + totalCashEUR; // Update the display document.getElementById('total-holdings-portfolio').textContent = `€${portfolioValue.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; document.getElementById('total-holdings-cash').textContent = `€${totalCashEUR.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; document.getElementById('total-holdings-combined').textContent = `€${totalHoldings.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; } renderDashboardCashAccounts(accounts) { const accountsList = document.getElementById('dashboard-cash-accounts-list'); if (!accounts || accounts.length === 0) { accountsList.innerHTML = '

No cash accounts yet

'; return; } const accountTypeMap = { savings: 'Savings', checking: 'Checking', money_market: 'Money Market', cd: 'Certificate of Deposit', other: 'Other' }; accountsList.innerHTML = accounts.slice(0, 5).map(account => { const balance = parseFloat(account.balance); const currencySymbol = this.getCurrencySymbol(account.currency); const formattedBalance = `${currencySymbol}${balance.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; return `
${formattedBalance}
`; }).join(''); // Add "View All" link if there are more than 5 accounts if (accounts.length > 5) { accountsList.innerHTML += `
`; } } 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 = this.getCurrencySymbol(etf.currency); 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 = this.getCurrencySymbol(etf.currency); 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'); } }); }); // Bind history view events document.querySelectorAll('.view-history-btn').forEach(btn => { btn.addEventListener('click', async (e) => { const symbol = e.target.dataset.symbol; await this.showPriceHistory(symbol); }); }); // 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; } async updateETFPrice(symbol, currentPrice) { // Get currency for this ETF from trades const etfMap = this.getActiveETFPositions(); const etf = etfMap.get(symbol); const currency = etf ? etf.currency : 'USD'; try { // Save to database await this.savePriceToHistory(symbol, currentPrice, currency); // Update in-memory price this.currentPrices.set(symbol, currentPrice); this.calculateAndDisplayPerformance(); this.updatePriceChangeIndicator(symbol, currentPrice); this.updateDashboard(); this.showNotification(`Updated ${symbol} price to ${currentPrice} and saved to history`, 'success'); } catch (error) { console.error('Error updating price:', error); this.showNotification('Error updating price: ' + error.message, 'error'); } } async loadLatestPrices() { try { const response = await fetch(`${this.apiUrl}/latest-prices`, { method: 'GET', credentials: 'include' }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const prices = await response.json(); // Load into memory for (const [symbol, priceData] of Object.entries(prices)) { this.currentPrices.set(symbol, priceData.price); } } catch (error) { console.error('Error loading latest prices:', error); } } async savePriceToHistory(etf_symbol, price, currency) { const response = await fetch(`${this.apiUrl}/price-history`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ etf_symbol, price: parseFloat(price), currency }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to save price history'); } return await response.json(); } async loadPriceHistory(symbol, limit = 50) { try { const response = await fetch(`${this.apiUrl}/price-history/${symbol}?limit=${limit}`, { method: 'GET', credentials: 'include' }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } catch (error) { console.error('Error loading price history:', error); throw error; } } 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 = this.getCurrencySymbol(etf.currency); 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); // Calculate and display 8+ year CGT information this.calculateAndDisplayLongTermCGT(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 = this.getCurrencySymbol(etf.currency); 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'; } calculateAndDisplayLongTermCGT(etfMap, hasCurrentPrices) { const longTermSection = document.getElementById('long-term-cgt-section'); if (!hasCurrentPrices) { longTermSection.style.display = 'none'; return; } const now = new Date(); const eightYearsInDays = 8 * 365.25; const cgtRate = (this.cgtSettings?.rate_8years || 33) / 100; // Rate from settings for 8+ years let eligiblePositions = []; let totalEligibleValue = 0; let totalEligibleGains = 0; let totalCost = 0; etfMap.forEach((etf, symbol) => { const currentPrice = this.currentPrices.get(symbol); if (!currentPrice) return; // Get all buy trades for this ETF that are 8+ years old const longTermTrades = this.trades .filter(t => t.etfSymbol === symbol && t.tradeType === 'buy') .filter(t => { const tradeDate = new Date(t.dateTime); const holdingDays = Math.floor((now - tradeDate) / (1000 * 60 * 60 * 24)); return holdingDays >= eightYearsInDays; }) .sort((a, b) => new Date(a.dateTime) - new Date(b.dateTime)); if (longTermTrades.length === 0) return; let positionShares = 0; let positionCost = 0; let oldestTradeDate = null; longTermTrades.forEach(trade => { positionShares += trade.shares; positionCost += trade.totalValue; if (!oldestTradeDate || new Date(trade.dateTime) < oldestTradeDate) { oldestTradeDate = new Date(trade.dateTime); } }); const currentValue = positionShares * currentPrice; const gains = currentValue - positionCost; const holdingDays = Math.floor((now - oldestTradeDate) / (1000 * 60 * 60 * 24)); const holdingYears = holdingDays / 365.25; if (positionShares > 0) { eligiblePositions.push({ symbol, shares: positionShares, cost: positionCost, currentValue, gains, currentPrice, avgCost: positionCost / positionShares, holdingYears, currency: etf.currency }); totalEligibleValue += currentValue; totalEligibleGains += gains; totalCost += positionCost; } }); if (eligiblePositions.length === 0) { longTermSection.style.display = 'none'; return; } // Calculate CGT liability (only on gains) const taxableGains = Math.max(0, totalEligibleGains); const cgtLiability = taxableGains * cgtRate; const afterTaxGains = totalEligibleGains - cgtLiability; const gainsPercentage = totalCost > 0 ? ((totalEligibleGains / totalCost) * 100) : 0; // Update display elements document.getElementById('long-term-eligible-value').textContent = this.formatCurrency(totalEligibleValue); document.getElementById('long-term-eligible-count').textContent = `${eligiblePositions.length} position${eligiblePositions.length === 1 ? '' : 's'}`; document.getElementById('long-term-total-gains').textContent = this.formatCurrency(totalEligibleGains); document.getElementById('long-term-gains-percentage').textContent = `${gainsPercentage >= 0 ? '+' : ''}${gainsPercentage.toFixed(1)}% gain`; document.getElementById('long-term-cgt-liability').textContent = this.formatCurrency(cgtLiability); document.getElementById('long-term-after-tax').textContent = this.formatCurrency(afterTaxGains); document.getElementById('long-term-effective-rate').textContent = `Effective rate: ${cgtRate * 100}%`; // Render breakdown this.renderLongTermBreakdown(eligiblePositions); longTermSection.style.display = 'block'; } renderLongTermBreakdown(eligiblePositions) { const breakdownList = document.getElementById('long-term-breakdown-list'); if (eligiblePositions.length === 0) { breakdownList.innerHTML = '

No holdings over 8 years

'; return; } const breakdownHTML = eligiblePositions.map(position => { const currencySymbol = this.getCurrencySymbol(position.currency); const gainsClass = position.gains >= 0 ? 'positive' : 'negative'; const performanceClass = position.gains >= 0 ? 'positive' : 'negative'; const gainsPercentage = position.cost > 0 ? ((position.gains / position.cost) * 100) : 0; const cgtLiability = Math.max(0, position.gains) * 0.33; const afterTaxGains = position.gains - cgtLiability; return `
${position.symbol} ${position.gains >= 0 ? '+' : ''}${currencySymbol}${position.gains.toFixed(2)} (${position.gains >= 0 ? '+' : ''}${gainsPercentage.toFixed(1)}%)
Held ${position.holdingYears.toFixed(1)} years
Shares ${position.shares.toFixed(3)}
Avg Cost ${currencySymbol}${position.avgCost.toFixed(2)}
Current Price ${currencySymbol}${position.currentPrice.toFixed(2)}
CGT (33%) -${currencySymbol}${cgtLiability.toFixed(2)}
After-Tax Gain ${afterTaxGains >= 0 ? '+' : ''}${currencySymbol}${afterTaxGains.toFixed(2)}
`; }).join(''); breakdownList.innerHTML = breakdownHTML; } // 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, rate_8years: 33.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, rate_8years: 33.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-8years').value = this.cgtSettings.rate_8years; 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), rate_8years: parseFloat(document.getElementById('cgt-8years').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-8years').value = 33; 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, '8years': parseFloat(document.getElementById('cgt-8years').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}%`); } }); } // Cash Accounts Management Methods async renderCashAccountsPage() { await this.loadCashAccounts(); await this.loadCashSummary(); await this.renderTransfersSection(); this.bindCashAccountEvents(); } async loadCashAccounts() { try { const response = await fetch(`${this.apiUrl}/cash-accounts`, { credentials: 'include' }); if (response.ok) { const accounts = await response.json(); this.renderAccountsList(accounts); } else { this.showNotification('Failed to load cash accounts', 'error'); } } catch (error) { console.error('Error loading cash accounts:', error); this.showNotification('Failed to load cash accounts', 'error'); } } async loadCashSummary() { try { const response = await fetch(`${this.apiUrl}/cash-summary`, { credentials: 'include' }); if (response.ok) { const summary = await response.json(); this.updateCashSummary(summary); } else { console.error('Failed to load cash summary'); } } catch (error) { console.error('Error loading cash summary:', error); } } updateCashSummary(summary) { document.getElementById('total-cash-eur').textContent = `€${summary.total_eur.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; document.getElementById('total-cash-usd').textContent = `$${summary.total_usd.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; document.getElementById('account-count').textContent = summary.account_count; document.getElementById('avg-interest').textContent = `${summary.avg_interest_rate.toFixed(1)}%`; } renderAccountsList(accounts) { const accountsList = document.getElementById('accounts-list'); if (!accounts || accounts.length === 0) { accountsList.innerHTML = '

No cash accounts found. Add your first account above.

'; return; } accountsList.innerHTML = accounts.map(account => { const createdDate = new Date(account.created_at).toLocaleDateString(); const balance = parseFloat(account.balance); const currencySymbol = this.getCurrencySymbol(account.currency); const formattedBalance = `${currencySymbol}${balance.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; const accountTypeMap = { savings: 'Savings Account', checking: 'Checking Account', money_market: 'Money Market', cd: 'Certificate of Deposit', other: 'Other' }; return `
`; }).join(''); } bindCashAccountEvents() { const createAccountForm = document.getElementById('create-account-form'); const editAccountForm = document.getElementById('edit-account-form'); const editModal = document.getElementById('edit-account-modal'); const cancelEditBtn = document.getElementById('cancel-edit-account'); // Remove existing listeners createAccountForm.removeEventListener('submit', this.handleCreateAccount); editAccountForm.removeEventListener('submit', this.handleEditAccount); cancelEditBtn.removeEventListener('click', this.handleCancelEdit); // Create account form this.handleCreateAccount = async (e) => { e.preventDefault(); const formData = { account_name: document.getElementById('account-name').value.trim(), account_type: document.getElementById('account-type').value, balance: parseFloat(document.getElementById('account-balance').value) || 0, currency: document.getElementById('account-currency').value, institution_name: document.getElementById('institution-name').value.trim(), interest_rate: parseFloat(document.getElementById('interest-rate').value) || 0, notes: document.getElementById('account-notes').value.trim() }; try { const response = await fetch(`${this.apiUrl}/cash-accounts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(formData) }); const result = await response.json(); if (response.ok) { createAccountForm.reset(); await this.loadCashAccounts(); await this.loadCashSummary(); this.updateDashboard(); // Refresh dashboard cash data this.showNotification('Cash account created successfully!', 'success'); } else { this.showNotification(result.error || 'Failed to create account', 'error'); } } catch (error) { console.error('Error creating account:', error); this.showNotification('Failed to create account', 'error'); } }; // Edit account form this.handleEditAccount = async (e) => { e.preventDefault(); const accountId = editAccountForm.getAttribute('data-account-id'); const formData = { account_name: document.getElementById('edit-account-name').value.trim(), account_type: document.getElementById('edit-account-type').value, balance: parseFloat(document.getElementById('edit-account-balance').value) || 0, currency: document.getElementById('edit-account-currency').value, institution_name: document.getElementById('edit-institution-name').value.trim(), interest_rate: parseFloat(document.getElementById('edit-interest-rate').value) || 0, notes: document.getElementById('edit-account-notes').value.trim() }; try { const response = await fetch(`${this.apiUrl}/cash-accounts/${accountId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(formData) }); const result = await response.json(); if (response.ok) { editModal.style.display = 'none'; await this.loadCashAccounts(); await this.loadCashSummary(); this.updateDashboard(); // Refresh dashboard cash data this.showNotification('Cash account updated successfully!', 'success'); } else { this.showNotification(result.error || 'Failed to update account', 'error'); } } catch (error) { console.error('Error updating account:', error); this.showNotification('Failed to update account', 'error'); } }; // Cancel edit this.handleCancelEdit = () => { editModal.style.display = 'none'; }; // Bind events createAccountForm.addEventListener('submit', this.handleCreateAccount); editAccountForm.addEventListener('submit', this.handleEditAccount); cancelEditBtn.addEventListener('click', this.handleCancelEdit); // Bind account action buttons document.querySelectorAll('.account-delete-btn').forEach(btn => { btn.addEventListener('click', this.handleDeleteAccount.bind(this)); }); document.querySelectorAll('.account-edit-btn').forEach(btn => { btn.addEventListener('click', this.handleEditAccountClick.bind(this)); }); // Close modal on backdrop click editModal.addEventListener('click', (e) => { if (e.target === editModal) { this.handleCancelEdit(); } }); } async handleDeleteAccount(e) { const accountId = e.target.getAttribute('data-account-id'); const accountName = e.target.closest('.account-item').querySelector('.account-name').textContent; if (!confirm(`Are you sure you want to delete the account "${accountName}"? This action cannot be undone.`)) { return; } try { const response = await fetch(`${this.apiUrl}/cash-accounts/${accountId}`, { method: 'DELETE', credentials: 'include' }); if (response.ok) { await this.loadCashAccounts(); await this.loadCashSummary(); this.updateDashboard(); // Refresh dashboard cash data this.showNotification('Cash account deleted successfully', 'success'); } else { const result = await response.json(); this.showNotification(result.error || 'Failed to delete account', 'error'); } } catch (error) { console.error('Error deleting account:', error); this.showNotification('Failed to delete account', 'error'); } } async handleEditAccountClick(e) { const accountId = e.target.getAttribute('data-account-id'); try { const response = await fetch(`${this.apiUrl}/cash-accounts`, { credentials: 'include' }); if (response.ok) { const accounts = await response.json(); const account = accounts.find(acc => acc.id == accountId); if (account) { // Populate edit form document.getElementById('edit-account-name').value = account.account_name; document.getElementById('edit-account-type').value = account.account_type; document.getElementById('edit-account-balance').value = account.balance; document.getElementById('edit-account-currency').value = account.currency; document.getElementById('edit-institution-name').value = account.institution_name || ''; document.getElementById('edit-interest-rate').value = account.interest_rate || ''; document.getElementById('edit-account-notes').value = account.notes || ''; // Set account ID for form submission document.getElementById('edit-account-form').setAttribute('data-account-id', accountId); // Show modal document.getElementById('edit-account-modal').style.display = 'flex'; } } } catch (error) { console.error('Error loading account for edit:', error); this.showNotification('Failed to load account details', 'error'); } } // Cash Transfers Methods async renderTransfersSection() { await this.loadTransfers(); this.bindTransferEvents(); } async loadTransfers() { try { const response = await fetch(`${this.apiUrl}/cash-transfers`, { credentials: 'include' }); if (response.ok) { const transfers = await response.json(); this.renderTransfersList(transfers); } else { this.showNotification('Failed to load transfers', 'error'); } } catch (error) { console.error('Error loading transfers:', error); this.showNotification('Failed to load transfers', 'error'); } } renderTransfersList(transfers) { const transfersList = document.getElementById('transfers-list'); if (!transfers || transfers.length === 0) { transfersList.innerHTML = '

No transfers found

'; return; } transfersList.innerHTML = transfers.map(transfer => { const transferDate = new Date(transfer.transfer_date).toLocaleDateString(); const amount = parseFloat(transfer.amount); const currencySymbol = this.getCurrencySymbol(transfer.currency); return `
${transfer.transfer_type}
${transferDate} ${transfer.description ? `${transfer.description}` : ''}
${currencySymbol}${amount.toFixed(2)}
`; }).join(''); } async populateTransferAccounts() { try { const response = await fetch(`${this.apiUrl}/cash-accounts`, { credentials: 'include' }); if (response.ok) { const accounts = await response.json(); const accountSelect = document.getElementById('transfer-account'); // Clear existing options except the first one accountSelect.innerHTML = ''; // Add active accounts const activeAccounts = accounts.filter(account => account.is_active); activeAccounts.forEach(account => { const currencySymbol = this.getCurrencySymbol(account.currency); const option = document.createElement('option'); option.value = account.id; option.textContent = `${account.account_name} (${currencySymbol}${parseFloat(account.balance).toFixed(2)})`; accountSelect.appendChild(option); }); } } catch (error) { console.error('Error loading accounts for transfer:', error); } } bindTransferEvents() { const addTransferBtn = document.getElementById('add-transfer-btn'); const transferModal = document.getElementById('transfer-modal'); const transferForm = document.getElementById('transfer-form'); const cancelTransferBtn = document.getElementById('cancel-transfer'); // Remove existing listeners addTransferBtn.removeEventListener('click', this.handleShowTransferModal); transferForm.removeEventListener('submit', this.handleCreateTransfer); cancelTransferBtn.removeEventListener('click', this.handleCancelTransfer); // Add transfer button this.handleShowTransferModal = async () => { await this.populateTransferAccounts(); // Set today's date as default document.getElementById('transfer-date').value = new Date().toISOString().split('T')[0]; transferModal.style.display = 'flex'; }; // Create transfer form this.handleCreateTransfer = async (e) => { e.preventDefault(); const formData = { account_id: parseInt(document.getElementById('transfer-account').value), transfer_type: document.getElementById('transfer-type').value, amount: parseFloat(document.getElementById('transfer-amount').value), transfer_date: document.getElementById('transfer-date').value, description: document.getElementById('transfer-description').value.trim() }; try { const response = await fetch(`${this.apiUrl}/cash-transfers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(formData) }); const result = await response.json(); if (response.ok) { transferModal.style.display = 'none'; transferForm.reset(); await this.loadTransfers(); await this.loadCashAccounts(); await this.loadCashSummary(); this.updateDashboard(); // Refresh dashboard cash data this.showNotification('Transfer added successfully!', 'success'); } else { this.showNotification(result.error || 'Failed to add transfer', 'error'); } } catch (error) { console.error('Error creating transfer:', error); this.showNotification('Failed to add transfer', 'error'); } }; // Cancel transfer this.handleCancelTransfer = () => { transferModal.style.display = 'none'; transferForm.reset(); }; // Bind events addTransferBtn.addEventListener('click', this.handleShowTransferModal); transferForm.addEventListener('submit', this.handleCreateTransfer); cancelTransferBtn.addEventListener('click', this.handleCancelTransfer); // Close modal when clicking outside transferModal.addEventListener('click', (e) => { if (e.target === transferModal) { this.handleCancelTransfer(); } }); } // Standalone Add Transfer Page Methods async renderAddTransferPage() { await this.populateStandaloneTransferAccounts(); await this.loadStandaloneTransfers(); this.bindStandaloneTransferEvents(); // Set today's date as default document.getElementById('standalone-transfer-date').value = new Date().toISOString().split('T')[0]; } async populateStandaloneTransferAccounts() { try { const response = await fetch(`${this.apiUrl}/cash-accounts`, { credentials: 'include' }); if (response.ok) { const accounts = await response.json(); const accountSelect = document.getElementById('standalone-transfer-account'); // Clear existing options except the first one accountSelect.innerHTML = ''; // Add active accounts const activeAccounts = accounts.filter(account => account.is_active); activeAccounts.forEach(account => { const currencySymbol = this.getCurrencySymbol(account.currency); const option = document.createElement('option'); option.value = account.id; option.textContent = `${account.account_name} (${currencySymbol}${parseFloat(account.balance).toFixed(2)})`; accountSelect.appendChild(option); }); } } catch (error) { console.error('Error loading accounts for transfer:', error); } } async loadStandaloneTransfers() { try { const response = await fetch(`${this.apiUrl}/cash-transfers`, { credentials: 'include' }); if (response.ok) { const transfers = await response.json(); this.renderStandaloneTransfersList(transfers); } else { this.showNotification('Failed to load transfers', 'error'); } } catch (error) { console.error('Error loading transfers:', error); this.showNotification('Failed to load transfers', 'error'); } } renderStandaloneTransfersList(transfers) { const transfersList = document.getElementById('standalone-transfers-list'); if (!transfers || transfers.length === 0) { transfersList.innerHTML = '

No transfers found

'; return; } // Show only the last 10 transfers for the standalone page const recentTransfers = transfers.slice(0, 10); transfersList.innerHTML = recentTransfers.map(transfer => { const transferDate = new Date(transfer.transfer_date).toLocaleDateString(); const amount = parseFloat(transfer.amount); const currencySymbol = this.getCurrencySymbol(transfer.currency); return `
${transfer.transfer_type}
${transferDate} ${transfer.description ? `${transfer.description}` : ''}
${currencySymbol}${amount.toFixed(2)}
`; }).join(''); } bindStandaloneTransferEvents() { const transferForm = document.getElementById('standalone-transfer-form'); // Remove existing listener transferForm.removeEventListener('submit', this.handleStandaloneTransfer); // Create transfer form handler this.handleStandaloneTransfer = async (e) => { e.preventDefault(); const formData = { account_id: parseInt(document.getElementById('standalone-transfer-account').value), transfer_type: document.getElementById('standalone-transfer-type').value, amount: parseFloat(document.getElementById('standalone-transfer-amount').value), transfer_date: document.getElementById('standalone-transfer-date').value, description: document.getElementById('standalone-transfer-description').value.trim() }; try { const response = await fetch(`${this.apiUrl}/cash-transfers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(formData) }); const result = await response.json(); if (response.ok) { transferForm.reset(); // Set today's date as default again document.getElementById('standalone-transfer-date').value = new Date().toISOString().split('T')[0]; await this.loadStandaloneTransfers(); await this.populateStandaloneTransferAccounts(); // Refresh account balances this.updateDashboard(); // Refresh dashboard cash data this.showNotification('Transfer added successfully!', 'success'); } else { this.showNotification(result.error || 'Failed to add transfer', 'error'); } } catch (error) { console.error('Error creating transfer:', error); this.showNotification('Failed to add transfer', 'error'); } }; // Bind form submission transferForm.addEventListener('submit', this.handleStandaloneTransfer); } // Token Management Methods async renderTokensPage() { await this.loadTokens(); this.bindTokenEvents(); } async loadTokens() { try { const response = await fetch(`${this.apiUrl}/tokens`, { credentials: 'include' }); if (response.ok) { const tokens = await response.json(); this.renderTokensList(tokens); } else { this.showNotification('Failed to load tokens', 'error'); } } catch (error) { console.error('Error loading tokens:', error); this.showNotification('Failed to load tokens', 'error'); } } renderTokensList(tokens) { const tokensList = document.getElementById('tokens-list'); if (!tokens || tokens.length === 0) { tokensList.innerHTML = '

No access tokens found. Create your first token above.

'; return; } tokensList.innerHTML = tokens.map(token => { const createdDate = new Date(token.created_at).toLocaleDateString(); const lastUsed = token.last_used_at ? new Date(token.last_used_at).toLocaleDateString() : 'Never'; const expiresAt = token.expires_at ? new Date(token.expires_at).toLocaleDateString() : 'Never'; const isExpired = token.expires_at && new Date(token.expires_at) < new Date(); return `
${this.escapeHtml(token.token_name)}
Token: ${token.token_prefix}•••••••
Scopes: ${token.scopes}
Created: ${createdDate}
Last used: ${lastUsed}
Expires: ${expiresAt}
${isExpired ? 'Expired' : 'Active'}
`; }).join(''); } bindTokenEvents() { const createTokenForm = document.getElementById('create-token-form'); const copyTokenBtn = document.getElementById('copy-token-btn'); const closeModalBtn = document.getElementById('close-token-modal'); const tokenModal = document.getElementById('token-modal'); // Remove existing listeners createTokenForm.removeEventListener('submit', this.handleCreateToken); copyTokenBtn.removeEventListener('click', this.handleCopyToken); closeModalBtn.removeEventListener('click', this.handleCloseTokenModal); // Create token form this.handleCreateToken = async (e) => { e.preventDefault(); const formData = { name: document.getElementById('token-name').value.trim(), expires_in_days: document.getElementById('token-expires').value || null, scopes: document.getElementById('token-scopes').value }; try { const response = await fetch(`${this.apiUrl}/tokens`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(formData) }); const result = await response.json(); if (response.ok) { document.getElementById('new-token-value').value = result.token; tokenModal.style.display = 'flex'; createTokenForm.reset(); await this.loadTokens(); this.showNotification('Token created successfully!', 'success'); } else { this.showNotification(result.error || 'Failed to create token', 'error'); } } catch (error) { console.error('Error creating token:', error); this.showNotification('Failed to create token', 'error'); } }; // Copy token to clipboard this.handleCopyToken = async () => { const tokenInput = document.getElementById('new-token-value'); try { await navigator.clipboard.writeText(tokenInput.value); copyTokenBtn.textContent = 'Copied!'; copyTokenBtn.classList.add('copied'); setTimeout(() => { copyTokenBtn.textContent = 'Copy'; copyTokenBtn.classList.remove('copied'); }, 2000); } catch (error) { // Fallback for older browsers tokenInput.select(); document.execCommand('copy'); copyTokenBtn.textContent = 'Copied!'; copyTokenBtn.classList.add('copied'); setTimeout(() => { copyTokenBtn.textContent = 'Copy'; copyTokenBtn.classList.remove('copied'); }, 2000); } }; // Close modal this.handleCloseTokenModal = () => { tokenModal.style.display = 'none'; document.getElementById('new-token-value').value = ''; }; // Bind events createTokenForm.addEventListener('submit', this.handleCreateToken); copyTokenBtn.addEventListener('click', this.handleCopyToken); closeModalBtn.addEventListener('click', this.handleCloseTokenModal); // Bind token action buttons document.querySelectorAll('.token-delete-btn').forEach(btn => { btn.addEventListener('click', this.handleDeleteToken.bind(this)); }); document.querySelectorAll('.token-edit-btn').forEach(btn => { btn.addEventListener('click', this.handleEditToken.bind(this)); }); // Close modal on backdrop click tokenModal.addEventListener('click', (e) => { if (e.target === tokenModal) { this.handleCloseTokenModal(); } }); } async handleDeleteToken(e) { const tokenId = e.target.getAttribute('data-token-id'); const tokenName = e.target.closest('.token-item').querySelector('.token-name').textContent; if (!confirm(`Are you sure you want to delete the token "${tokenName}"? This action cannot be undone.`)) { return; } try { const response = await fetch(`${this.apiUrl}/tokens/${tokenId}`, { method: 'DELETE', credentials: 'include' }); if (response.ok) { await this.loadTokens(); this.showNotification('Token deleted successfully', 'success'); } else { const result = await response.json(); this.showNotification(result.error || 'Failed to delete token', 'error'); } } catch (error) { console.error('Error deleting token:', error); this.showNotification('Failed to delete token', 'error'); } } async handleEditToken(e) { const tokenId = e.target.getAttribute('data-token-id'); const tokenItem = e.target.closest('.token-item'); const tokenName = tokenItem.querySelector('.token-name').textContent; const newName = prompt('Enter new token name:', tokenName); if (!newName || newName.trim() === tokenName) { return; } try { const response = await fetch(`${this.apiUrl}/tokens/${tokenId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ name: newName.trim() }) }); if (response.ok) { await this.loadTokens(); this.showNotification('Token updated successfully', 'success'); } else { const result = await response.json(); this.showNotification(result.error || 'Failed to update token', 'error'); } } catch (error) { console.error('Error updating token:', error); this.showNotification('Failed to update token', 'error'); } } async showPriceHistory(symbol) { try { const history = await this.loadPriceHistory(symbol); if (history.length === 0) { this.showNotification(`No price history found for ${symbol}`, 'info'); return; } // Create modal for price history this.createPriceHistoryModal(symbol, history); } catch (error) { console.error('Error showing price history:', error); this.showNotification('Failed to load price history', 'error'); } } createPriceHistoryModal(symbol, history) { // Remove existing modal if any const existingModal = document.getElementById('price-history-modal'); if (existingModal) { existingModal.remove(); } const currencySymbol = this.getCurrencySymbol(history[0]?.currency); const modal = document.createElement('div'); modal.id = 'price-history-modal'; modal.className = 'modal'; modal.innerHTML = ` `; document.body.appendChild(modal); modal.style.display = 'block'; // Close modal events const closeBtn = modal.querySelector('.close-modal'); closeBtn.addEventListener('click', () => this.closePriceHistoryModal()); modal.addEventListener('click', (e) => { if (e.target === modal) { this.closePriceHistoryModal(); } }); // Close on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.closePriceHistoryModal(); } }); } closePriceHistoryModal() { const modal = document.getElementById('price-history-modal'); if (modal) { modal.remove(); } } getCurrencySymbol(currency) { switch(currency) { case 'EUR': return '€'; case 'USD': return '$'; case 'GBP': return '£'; default: return currency; } } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } const app = new ETFTradeTracker();