etf-trade-tracker/script.js
kris 711826010f Implement dynamic currency display based on portfolio majority
- Add getMajorityCurrency() to calculate which currency holds most value
- Include both ETF positions and cash accounts in currency calculation
- Update formatCurrency() to use majority currency symbol by default
- Dashboard now displays in EUR/USD/GBP based on portfolio composition
- Automatic currency switching improves user experience and accuracy
- Maintain backward compatibility with optional currency parameter

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 13:37:57 +00:00

2917 lines
117 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = '<p class="no-data">No users found</p>';
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 `
<div class="user-item">
<div class="user-header">
<span class="user-username">${user.username}</span>
<div class="user-badges">
${user.is_admin ? '<span class="admin-badge">Admin</span>' : '<span class="user-badge">User</span>'}
${user.id === this.currentUser.id ? '<span class="current-badge">You</span>' : ''}
</div>
${user.id !== this.currentUser.id ? `<button class="delete-user-btn" onclick="app.deleteUser(${user.id})">Delete</button>` : ''}
</div>
<div class="user-details">
<div class="user-info">
<span>Email: ${user.email || 'Not provided'}</span>
<span>Created: ${createdDate}</span>
<span>Last Login: ${lastLogin}</span>
</div>
</div>
</div>
`;
}).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 = '<p class="no-trades">No trades recorded yet. Add your first trade above!</p>';
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 `
<div class="trade-item ${trade.tradeType}" data-id="${trade.id}">
<div class="trade-header">
<span class="etf-symbol">${trade.etfSymbol}</span>
<span class="trade-type ${trade.tradeType}">${trade.tradeType.toUpperCase()}</span>
<button class="delete-btn" onclick="app.deleteTrade('${trade.id}')">×</button>
</div>
<div class="trade-details">
<div class="trade-info">
<span class="shares">${trade.shares} shares</span>
<span class="price">${currencySymbol}${trade.price.toFixed(2)} per share</span>
<span class="total-value">Total: ${currencySymbol}${trade.totalValue.toFixed(2)}</span>
${trade.fees > 0 ? `<span class="fees">Fees: ${currencySymbol}${trade.fees.toFixed(2)}</span>` : ''}
</div>
<div class="trade-datetime">
<span class="date">${formattedDate}</span>
<span class="time">${formattedTime}</span>
</div>
${trade.notes ? `<div class="trade-notes">${trade.notes}</div>` : ''}
</div>
</div>
`;
}
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 = '<p class="no-data">No ETF positions yet</p>';
return;
}
const etfEntries = Array.from(etfMap.values()).filter(etf => etf.shares > 0);
if (etfEntries.length === 0) {
breakdownList.innerHTML = '<p class="no-data">No current ETF positions</p>';
return;
}
const breakdownHTML = etfEntries.map(etf => {
const currencySymbol = this.getCurrencySymbol(etf.currency);
const avgPrice = etf.shares > 0 ? etf.totalValue / etf.shares : 0;
return `
<div class="etf-item">
<div class="etf-header">
<span class="etf-symbol">${etf.symbol}</span>
<span class="etf-value">${currencySymbol}${etf.totalValue.toFixed(2)}</span>
</div>
<div class="etf-details">
<span class="etf-shares">${etf.shares.toFixed(3)} shares</span>
<span class="etf-avg-price">Avg: ${currencySymbol}${avgPrice.toFixed(2)}</span>
<span class="etf-trades">${etf.trades} trades</span>
</div>
</div>
`;
}).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 = '<p class="no-data">No cash accounts yet</p>';
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 `
<div class="cash-account-breakdown-item">
<div class="cash-account-info">
<div class="cash-account-name">${this.escapeHtml(account.account_name)}</div>
<div class="cash-account-type">${accountTypeMap[account.account_type] || 'Other'}</div>
</div>
<div class="cash-account-balance">${formattedBalance}</div>
</div>
`;
}).join('');
// Add "View All" link if there are more than 5 accounts
if (accounts.length > 5) {
accountsList.innerHTML += `
<div class="cash-account-breakdown-item" style="border-left: 3px solid #17a2b8; cursor: pointer;" onclick="app.showPage('cash-accounts')">
<div class="cash-account-info">
<div class="cash-account-name" style="color: #17a2b8;">View All Cash Accounts</div>
<div class="cash-account-type">+${accounts.length - 5} more accounts</div>
</div>
<div class="cash-account-balance" style="color: #17a2b8;">→</div>
</div>
`;
}
}
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 = '<p class="no-data">No active positions in your portfolio</p>';
return;
}
let totalPortfolioValue = 0;
activeETFs.forEach(etf => {
totalPortfolioValue += etf.totalValue;
});
const portfolioHTML = `
<div class="portfolio-overview">
<h3>Portfolio Overview</h3>
<div class="overview-stats">
<div class="stat-item">
<span class="stat-label">Total Value</span>
<span class="stat-value">${this.formatCurrency(totalPortfolioValue)}</span>
</div>
<div class="stat-item">
<span class="stat-label">Active ETFs</span>
<span class="stat-value">${activeETFs.length}</span>
</div>
<div class="stat-item">
<span class="stat-label">Total Trades</span>
<span class="stat-value">${this.trades.length}</span>
</div>
</div>
</div>
<div class="portfolio-holdings">
<h3>Holdings</h3>
<div class="holdings-grid">
${activeETFs.map(etf => {
const currencySymbol = this.getCurrencySymbol(etf.currency);
const allocation = ((etf.totalValue / totalPortfolioValue) * 100).toFixed(1);
return `
<div class="holding-card">
<div class="holding-header">
<span class="holding-symbol">${etf.symbol}</span>
<span class="holding-allocation">${allocation}%</span>
</div>
<div class="holding-details">
<div class="holding-stat">
<span>Shares</span>
<span>${etf.shares.toFixed(3)}</span>
</div>
<div class="holding-stat">
<span>Value</span>
<span>${currencySymbol}${etf.totalValue.toFixed(2)}</span>
</div>
<div class="holding-stat">
<span>Avg Price</span>
<span>${currencySymbol}${etf.avgPrice.toFixed(2)}</span>
</div>
<div class="holding-stat">
<span>Trades</span>
<span>${etf.trades}</span>
</div>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
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 = '<p class="no-data">No ETF positions to update</p>';
return;
}
const updateHTML = Array.from(etfMap.entries()).map(([symbol, etf]) => {
const currentPrice = this.currentPrices.get(symbol) || '';
const currencySymbol = this.getCurrencySymbol(etf.currency);
return `
<div class="price-update-item">
<div class="price-update-header">
<span class="etf-symbol-update">${symbol}</span>
<span class="etf-position">${etf.shares.toFixed(3)} shares</span>
</div>
<div class="price-update-controls">
<label>Current Price (${currencySymbol})</label>
<input type="number"
step="0.01"
min="0"
placeholder="0.00"
value="${currentPrice}"
class="current-price-input"
data-symbol="${symbol}">
<button class="update-price-btn" data-symbol="${symbol}">Update</button>
<button class="view-history-btn" data-symbol="${symbol}">History</button>
</div>
<div class="price-info">
<span>Avg Cost: ${currencySymbol}${etf.avgPrice.toFixed(2)}</span>
<span class="price-change" data-symbol="${symbol}">-</span>
</div>
</div>
`;
}).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 = '<p class="no-data">No active positions</p>';
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 `
<div class="gains-breakdown-item ${performanceClass}">
<div class="breakdown-header">
<span class="breakdown-symbol">${symbol}</span>
<span class="breakdown-performance">
${gainLoss >= 0 ? '+' : ''}${currencySymbol}${gainLoss.toFixed(2)}
(${gainLoss >= 0 ? '+' : ''}${percentage.toFixed(1)}%)
</span>
</div>
<div class="breakdown-details">
<div class="breakdown-stat">
<span>Shares</span>
<span>${etf.shares.toFixed(3)}</span>
</div>
<div class="breakdown-stat">
<span>Avg Cost</span>
<span>${currencySymbol}${etf.avgPrice.toFixed(2)}</span>
</div>
<div class="breakdown-stat">
<span>Current Price</span>
<span>${currencySymbol}${currentPrice.toFixed(2)} ${!hasRealPrice ? '(est.)' : ''}</span>
</div>
<div class="breakdown-stat">
<span>Current Value</span>
<span>${currencySymbol}${currentValue.toFixed(2)}</span>
</div>
</div>
</div>
`;
}).join('');
breakdownList.innerHTML = breakdownHTML;
}
formatCurrency(amount, currency = null) {
// Use majority currency if none specified
const currencyToUse = currency || this.getMajorityCurrency();
const symbol = this.getCurrencySymbol(currencyToUse);
const absAmount = Math.abs(amount);
if (absAmount >= 1000000) {
return (amount >= 0 ? symbol : '-' + symbol) + (absAmount / 1000000).toFixed(2) + 'M';
} else if (absAmount >= 1000) {
return (amount >= 0 ? symbol : '-' + symbol) + (absAmount / 1000).toFixed(1) + 'K';
} else {
return (amount >= 0 ? symbol : '-' + symbol) + 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 = `
<div class="period-item">
<span class="period-label">Short-term (&lt;1Y):</span>
<span class="period-value">${this.formatCurrency(shortTermGains)}</span>
</div>
<div class="period-item">
<span class="period-label">Long-term (1Y+):</span>
<span class="period-value">${this.formatCurrency(longTermGains)}</span>
</div>
`;
// 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 = '<p class="no-data">No holdings over 8 years</p>';
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 `
<div class="long-term-breakdown-item ${gainsClass}">
<div class="long-term-breakdown-header">
<span class="long-term-symbol">${position.symbol}</span>
<span class="long-term-performance ${performanceClass}">
${position.gains >= 0 ? '+' : ''}${currencySymbol}${position.gains.toFixed(2)}
(${position.gains >= 0 ? '+' : ''}${gainsPercentage.toFixed(1)}%)
</span>
</div>
<div class="long-term-details">
<div class="long-term-stat">
<span>Held</span>
<span>${position.holdingYears.toFixed(1)} years</span>
</div>
<div class="long-term-stat">
<span>Shares</span>
<span>${position.shares.toFixed(3)}</span>
</div>
<div class="long-term-stat">
<span>Avg Cost</span>
<span>${currencySymbol}${position.avgCost.toFixed(2)}</span>
</div>
<div class="long-term-stat">
<span>Current Price</span>
<span>${currencySymbol}${position.currentPrice.toFixed(2)}</span>
</div>
<div class="long-term-stat">
<span>CGT (33%)</span>
<span>-${currencySymbol}${cgtLiability.toFixed(2)}</span>
</div>
<div class="long-term-stat">
<span>After-Tax Gain</span>
<span class="${performanceClass}">${afterTaxGains >= 0 ? '+' : ''}${currencySymbol}${afterTaxGains.toFixed(2)}</span>
</div>
</div>
</div>
`;
}).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 = '<p class="no-data">No cash accounts found. Add your first account above.</p>';
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 `
<div class="account-item" data-account-id="${account.id}">
<div class="account-info">
<div class="account-name">${this.escapeHtml(account.account_name)}</div>
<div class="account-type-badge">${accountTypeMap[account.account_type] || 'Other'}</div>
<div class="account-details">
<div class="account-detail">
<span class="account-detail-label">Balance</span>
<span class="account-balance">${formattedBalance}</span>
</div>
${account.institution_name ? `
<div class="account-detail">
<span class="account-detail-label">Institution</span>
<span>${this.escapeHtml(account.institution_name)}</span>
</div>
` : ''}
${account.interest_rate > 0 ? `
<div class="account-detail">
<span class="account-detail-label">Interest Rate</span>
<span>${account.interest_rate}%</span>
</div>
` : ''}
<div class="account-detail">
<span class="account-detail-label">Created</span>
<span>${createdDate}</span>
</div>
${account.notes ? `
<div class="account-detail">
<span class="account-detail-label">Notes</span>
<span>${this.escapeHtml(account.notes)}</span>
</div>
` : ''}
</div>
</div>
<div class="account-actions-buttons">
<button class="account-edit-btn" data-account-id="${account.id}">Edit</button>
<button class="account-delete-btn" data-account-id="${account.id}">Delete</button>
</div>
</div>
`;
}).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 = '<p class="no-data">No transfers found</p>';
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 `
<div class="transfer-item">
<div class="transfer-info">
<div class="transfer-header">
<span class="transfer-account">${transfer.account_name}</span>
<span class="transfer-type ${transfer.transfer_type}">${transfer.transfer_type}</span>
</div>
<div class="transfer-details">
<span class="transfer-date">${transferDate}</span>
${transfer.description ? `<span class="transfer-description">${transfer.description}</span>` : ''}
</div>
</div>
<div class="transfer-amount ${transfer.transfer_type}">
${currencySymbol}${amount.toFixed(2)}
</div>
</div>
`;
}).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 = '<option value="">Select Account</option>';
// 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 = '<option value="">Select Account</option>';
// 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 = '<p class="no-data">No transfers found</p>';
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 `
<div class="transfer-item">
<div class="transfer-info">
<div class="transfer-header">
<span class="transfer-account">${transfer.account_name}</span>
<span class="transfer-type ${transfer.transfer_type}">${transfer.transfer_type}</span>
</div>
<div class="transfer-details">
<span class="transfer-date">${transferDate}</span>
${transfer.description ? `<span class="transfer-description">${transfer.description}</span>` : ''}
</div>
</div>
<div class="transfer-amount ${transfer.transfer_type}">
${currencySymbol}${amount.toFixed(2)}
</div>
</div>
`;
}).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 = '<p class="no-data">No access tokens found. Create your first token above.</p>';
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 `
<div class="token-item" data-token-id="${token.id}">
<div class="token-info">
<div class="token-name">${this.escapeHtml(token.token_name)}</div>
<div class="token-details">
<div class="token-detail">
<span>Token:</span>
<span class="token-prefix">${token.token_prefix}•••••••</span>
</div>
<div class="token-detail">
<span>Scopes:</span>
<span>${token.scopes}</span>
</div>
<div class="token-detail">
<span>Created:</span>
<span>${createdDate}</span>
</div>
<div class="token-detail">
<span>Last used:</span>
<span>${lastUsed}</span>
</div>
<div class="token-detail">
<span>Expires:</span>
<span>${expiresAt}</span>
</div>
<div class="token-detail">
<span class="token-status ${isExpired ? 'expired' : 'active'}">
${isExpired ? 'Expired' : 'Active'}
</span>
</div>
</div>
</div>
<div class="token-actions-buttons">
<button class="token-edit-btn" data-token-id="${token.id}">Edit</button>
<button class="token-delete-btn" data-token-id="${token.id}">Delete</button>
</div>
</div>
`;
}).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 = `
<div class="modal-content">
<div class="modal-header">
<h3>Price History - ${symbol}</h3>
<span class="close-modal">&times;</span>
</div>
<div class="modal-body">
<div class="price-history-list">
${history.map(entry => `
<div class="price-history-entry">
<span class="history-price">${currencySymbol}${parseFloat(entry.price).toFixed(2)}</span>
<span class="history-date">${new Date(entry.updatedAt).toLocaleString()}</span>
</div>
`).join('')}
</div>
</div>
</div>
`;
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;
}
}
getMajorityCurrency() {
const currencyValues = {
EUR: 0,
USD: 0,
GBP: 0
};
// Calculate total value by currency from ETF positions
const etfMap = this.getActiveETFPositions();
etfMap.forEach((etf, symbol) => {
const currentPrice = this.currentPrices.get(symbol);
const value = currentPrice ? etf.shares * currentPrice : etf.totalValue;
currencyValues[etf.currency] = (currencyValues[etf.currency] || 0) + value;
});
// Add cash accounts to the calculation
if (this.cashSummary && this.cashSummary.accounts) {
this.cashSummary.accounts.forEach(account => {
if (account.is_active && account.balance > 0) {
currencyValues[account.currency] = (currencyValues[account.currency] || 0) + parseFloat(account.balance);
}
});
}
// Find the currency with the highest value
let majorityCurrency = 'EUR'; // default
let maxValue = 0;
for (const [currency, value] of Object.entries(currencyValues)) {
if (value > maxValue) {
maxValue = value;
majorityCurrency = currency;
}
}
return majorityCurrency;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
const app = new ETFTradeTracker();