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();
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',
'admin': 'Admin Panel'
};
pageTitle.textContent = titles[pageId] || 'Dashboard';
// Update specific pages when shown
if (pageId === 'portfolio') {
this.renderPortfolioPage();
} else if (pageId === 'gains-losses') {
this.renderGainsLossesPage();
} else if (pageId === 'cgt-settings') {
this.renderCGTSettingsPage();
} else if (pageId === 'admin') {
this.renderAdminPage();
}
}
bindAuthEvents() {
const loginForm = document.getElementById('login-form');
const logoutBtn = document.getElementById('logout-btn');
const createUserForm = document.getElementById('create-user-form');
if (loginForm) {
loginForm.addEventListener('submit', (e) => this.handleLogin(e));
}
if (logoutBtn) {
logoutBtn.addEventListener('click', () => this.handleLogout());
}
if (createUserForm) {
createUserForm.addEventListener('submit', (e) => this.handleCreateUser(e));
}
}
async checkAuthentication() {
try {
const response = await fetch(`${this.apiUrl}/me`, {
method: 'GET',
credentials: 'include'
});
if (response.ok) {
this.currentUser = await response.json();
this.updateUserInfo();
return true;
}
} catch (error) {
console.log('Not authenticated');
}
return false;
}
showLoginPage() {
document.querySelector('.sidebar').style.display = 'none';
document.getElementById('login-page').classList.add('active');
document.getElementById('page-title').textContent = 'Login';
// Hide all other pages
document.querySelectorAll('.page:not(#login-page)').forEach(page => {
page.classList.remove('active');
});
}
showAppContent() {
document.querySelector('.sidebar').style.display = 'block';
document.getElementById('login-page').classList.remove('active');
}
async handleLogin(e) {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const response = await fetch(`${this.apiUrl}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ username, password })
});
if (response.ok) {
this.currentUser = await response.json();
this.updateUserInfo();
this.showAppContent();
// Load user data
await this.loadTrades();
this.renderTrades();
this.updateDashboard();
this.showPage('dashboard');
this.showNotification('Login successful!', 'success');
} else {
const error = await response.json();
this.showNotification(error.error || 'Login failed', 'error');
}
} catch (error) {
console.error('Login error:', error);
this.showNotification('Login failed', 'error');
}
}
async handleLogout() {
try {
await fetch(`${this.apiUrl}/logout`, {
method: 'POST',
credentials: 'include'
});
this.currentUser = null;
this.trades = [];
this.currentPrices.clear();
this.showLoginPage();
this.showNotification('Logged out successfully', 'success');
} catch (error) {
console.error('Logout error:', error);
}
}
updateUserInfo() {
const userInfo = document.getElementById('user-info');
const currentUserSpan = document.getElementById('current-user');
const adminMenus = document.querySelectorAll('.admin-only');
if (this.currentUser) {
userInfo.style.display = 'block';
currentUserSpan.textContent = `${this.currentUser.username} ${this.currentUser.isAdmin ? '(Admin)' : ''}`;
// Show/hide admin menus
adminMenus.forEach(menu => {
menu.style.display = this.currentUser.isAdmin ? 'block' : 'none';
});
} else {
userInfo.style.display = 'none';
adminMenus.forEach(menu => {
menu.style.display = 'none';
});
}
}
async handleCreateUser(e) {
e.preventDefault();
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const email = document.getElementById('new-email').value;
const isAdmin = document.getElementById('new-is-admin').checked;
try {
const response = await fetch(`${this.apiUrl}/admin/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ username, password, email, isAdmin })
});
if (response.ok) {
document.getElementById('create-user-form').reset();
this.loadUsers(); // Refresh users list
this.showNotification('User created successfully!', 'success');
} else {
const error = await response.json();
this.showNotification(error.error || 'Failed to create user', 'error');
}
} catch (error) {
console.error('Create user error:', error);
this.showNotification('Failed to create user', 'error');
}
}
async renderAdminPage() {
if (!this.currentUser || !this.currentUser.isAdmin) {
this.showNotification('Admin access required', 'error');
this.showPage('dashboard');
return;
}
this.loadUsers();
}
async loadUsers() {
try {
const response = await fetch(`${this.apiUrl}/admin/users`, {
method: 'GET',
credentials: 'include'
});
if (response.ok) {
const users = await response.json();
this.renderUsersList(users);
} else {
this.showNotification('Failed to load users', 'error');
}
} catch (error) {
console.error('Load users error:', error);
this.showNotification('Failed to load users', 'error');
}
}
renderUsersList(users) {
const usersList = document.getElementById('users-list');
if (users.length === 0) {
usersList.innerHTML = '
No users found
';
return;
}
const usersHTML = users.map(user => {
const createdDate = new Date(user.created_at).toLocaleDateString();
const lastLogin = user.last_login ? new Date(user.last_login).toLocaleDateString() : 'Never';
return `
Email: ${user.email || 'Not provided'}
Created: ${createdDate}
Last Login: ${lastLogin}
`;
}).join('');
usersList.innerHTML = usersHTML;
}
async deleteUser(userId) {
if (!confirm('Are you sure you want to delete this user? This will also delete all their trades.')) {
return;
}
try {
const response = await fetch(`${this.apiUrl}/admin/users/${userId}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
this.loadUsers(); // Refresh users list
this.showNotification('User deleted successfully', 'success');
} else {
const error = await response.json();
this.showNotification(error.error || 'Failed to delete user', 'error');
}
} catch (error) {
console.error('Delete user error:', error);
this.showNotification('Failed to delete user', 'error');
}
}
setDefaultDateTime() {
const now = new Date();
const dateInput = document.getElementById('trade-date');
const timeInput = document.getElementById('trade-time');
dateInput.value = now.toISOString().split('T')[0];
timeInput.value = now.toTimeString().split(' ')[0].substring(0, 5);
}
async handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const trade = this.createTradeFromForm(formData);
if (this.validateTrade(trade)) {
await this.addTrade(trade);
}
}
createTradeFromForm(formData) {
const etfSymbol = document.getElementById('etf-symbol').value.trim().toUpperCase();
const tradeType = document.getElementById('trade-type').value;
const shares = parseFloat(document.getElementById('shares').value);
const price = parseFloat(document.getElementById('price').value);
const currency = document.getElementById('currency').value;
const tradeDate = document.getElementById('trade-date').value;
const tradeTime = document.getElementById('trade-time').value;
const fees = parseFloat(document.getElementById('fees').value) || 0;
const notes = document.getElementById('notes').value.trim();
const dateTime = new Date(`${tradeDate}T${tradeTime}`);
return {
etfSymbol,
tradeType,
shares,
price,
currency,
dateTime: dateTime.toISOString(),
fees,
notes
};
}
validateTrade(trade) {
if (!trade.etfSymbol || !trade.tradeType || !trade.shares || !trade.price || !trade.currency) {
this.showNotification('Please fill in all required fields', 'error');
return false;
}
if (trade.shares <= 0 || trade.price <= 0) {
this.showNotification('Shares and price must be greater than 0', 'error');
return false;
}
return true;
}
async addTrade(trade) {
try {
const response = await fetch(`${this.apiUrl}/trades`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(trade)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to add trade');
}
const newTrade = await response.json();
this.trades.unshift(newTrade);
this.renderTrades();
this.updateDashboard();
this.resetForm();
this.showNotification('Trade added successfully!', 'success');
// Navigate to dashboard after successful trade
setTimeout(() => {
this.showPage('dashboard');
const menuItems = document.querySelectorAll('.menu-item');
menuItems.forEach(mi => mi.classList.remove('active'));
document.querySelector('[data-page="dashboard"]').classList.add('active');
}, 1500);
} catch (error) {
console.error('Error adding trade:', error);
this.showNotification(error.message || 'Failed to add trade', 'error');
}
}
renderTrades() {
const tradesList = document.getElementById('trades-list');
if (this.trades.length === 0) {
tradesList.innerHTML = 'No trades recorded yet. Add your first trade above!
';
return;
}
const tradesHTML = this.trades.map(trade => this.createTradeHTML(trade)).join('');
tradesList.innerHTML = tradesHTML;
}
createTradeHTML(trade) {
const dateTime = new Date(trade.dateTime);
const formattedDate = dateTime.toLocaleDateString();
const formattedTime = dateTime.toTimeString().split(' ')[0];
const currencySymbol = trade.currency === 'EUR' ? '€' : '$';
return `
${trade.shares} shares
${currencySymbol}${trade.price.toFixed(2)} per share
Total: ${currencySymbol}${trade.totalValue.toFixed(2)}
${trade.fees > 0 ? `Fees: ${currencySymbol}${trade.fees.toFixed(2)}` : ''}
${formattedDate}
${formattedTime}
${trade.notes ? `
${trade.notes}
` : ''}
`;
}
async deleteTrade(tradeId) {
if (confirm('Are you sure you want to delete this trade?')) {
try {
const response = await fetch(`${this.apiUrl}/trades/${tradeId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete trade');
}
this.trades = this.trades.filter(trade => trade.id !== tradeId);
this.renderTrades();
this.updateDashboard();
this.showNotification('Trade deleted successfully!', 'success');
} catch (error) {
console.error('Error deleting trade:', error);
this.showNotification(error.message || 'Failed to delete trade', 'error');
}
}
}
async clearAllTrades() {
if (confirm('Are you sure you want to delete all trades? This action cannot be undone.')) {
try {
const response = await fetch(`${this.apiUrl}/trades`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to clear trades');
}
this.trades = [];
this.renderTrades();
this.updateDashboard();
this.showNotification('All trades cleared!', 'success');
} catch (error) {
console.error('Error clearing trades:', error);
this.showNotification(error.message || 'Failed to clear trades', 'error');
}
}
}
exportTrades() {
if (this.trades.length === 0) {
this.showNotification('No trades to export', 'error');
return;
}
const dataStr = JSON.stringify(this.trades, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `etf-trades-${new Date().toISOString().split('T')[0]}.json`;
link.click();
this.showNotification('Trades exported successfully!', 'success');
}
resetForm() {
document.getElementById('trade-form').reset();
this.setDefaultDateTime();
}
async loadTrades() {
try {
const response = await fetch(`${this.apiUrl}/trades`);
if (!response.ok) {
throw new Error('Failed to load trades');
}
this.trades = await response.json();
} catch (error) {
console.error('Error loading trades:', error);
this.showNotification('Failed to load trades from server', 'error');
this.trades = [];
}
}
showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('show');
}, 100);
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
async getPortfolioSummary() {
try {
const response = await fetch(`${this.apiUrl}/portfolio-summary`);
if (!response.ok) {
throw new Error('Failed to fetch portfolio summary');
}
return await response.json();
} catch (error) {
console.error('Error fetching portfolio summary:', error);
return [];
}
}
updateDashboard() {
this.calculateDashboardMetrics();
this.renderETFBreakdown();
}
updateDashboardColors(totalGains) {
const gainsCard = document.querySelector('.dashboard-card.total-gains');
const currentValueCard = document.querySelector('.dashboard-card.total-value');
const costBasisCard = document.querySelector('.dashboard-card.cost-basis');
if (gainsCard) {
gainsCard.classList.remove('positive', 'negative', 'neutral');
if (totalGains > 0) {
gainsCard.classList.add('positive');
} else if (totalGains < 0) {
gainsCard.classList.add('negative');
} else {
gainsCard.classList.add('neutral');
}
}
// Add subtle indicators to other cards
if (currentValueCard) {
currentValueCard.classList.remove('positive', 'negative');
if (totalGains !== 0) {
currentValueCard.classList.add(totalGains > 0 ? 'positive' : 'negative');
}
}
if (costBasisCard) {
costBasisCard.classList.remove('positive', 'negative');
if (totalGains !== 0) {
costBasisCard.classList.add(totalGains > 0 ? 'positive' : 'negative');
}
}
}
calculateDashboardMetrics() {
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
let totalCostBasis = 0;
let totalCurrentValue = 0;
let totalShares = 0;
let monthlyInvestment = 0;
let yearlyInvestment = 0;
let monthlyTrades = 0;
let yearlyTrades = 0;
let uniqueETFs = new Set();
let hasCurrentPrices = false;
// Calculate portfolio metrics using ETF positions
const etfMap = this.getActiveETFPositions();
etfMap.forEach((etf, symbol) => {
totalCostBasis += etf.totalValue;
totalShares += etf.shares;
uniqueETFs.add(symbol);
// Calculate current value using updated prices or cost basis
const currentPrice = this.currentPrices.get(symbol);
if (currentPrice) {
totalCurrentValue += etf.shares * currentPrice;
hasCurrentPrices = true;
} else {
totalCurrentValue += etf.totalValue; // Use cost basis if no current price
}
});
// Calculate trade-based metrics (monthly/yearly)
this.trades.forEach(trade => {
const tradeDate = new Date(trade.dateTime);
const tradeValue = trade.tradeType === 'buy' ? trade.totalValue : -trade.totalValue;
yearlyInvestment += tradeValue;
yearlyTrades++;
if (tradeDate.getMonth() === currentMonth && tradeDate.getFullYear() === currentYear) {
monthlyInvestment += tradeValue;
monthlyTrades++;
}
});
// Calculate gains/losses
const totalGains = totalCurrentValue - totalCostBasis;
const gainsPercentage = totalCostBasis > 0 ? ((totalGains / totalCostBasis) * 100) : 0;
const avgReturn = gainsPercentage;
// Update dashboard elements
document.getElementById('dashboard-current-value').textContent = this.formatCurrency(totalCurrentValue);
document.getElementById('dashboard-total-gains').textContent = this.formatCurrency(totalGains);
document.getElementById('dashboard-gains-percentage').textContent = `${totalGains >= 0 ? '+' : ''}${gainsPercentage.toFixed(1)}%`;
document.getElementById('dashboard-cost-basis').textContent = this.formatCurrency(totalCostBasis);
document.getElementById('dashboard-avg-return').textContent = `Avg return: ${avgReturn >= 0 ? '+' : ''}${avgReturn.toFixed(1)}%`;
document.getElementById('total-shares').textContent = totalShares.toFixed(3);
document.getElementById('unique-etfs').textContent = `${uniqueETFs.size} ETFs`;
document.getElementById('monthly-investment').textContent = this.formatCurrency(monthlyInvestment);
document.getElementById('monthly-trades').textContent = `${monthlyTrades} trades`;
document.getElementById('yearly-investment').textContent = this.formatCurrency(yearlyInvestment);
document.getElementById('yearly-trades').textContent = `${yearlyTrades} trades`;
// Update status indicators
const lastUpdatedElement = document.getElementById('dashboard-last-updated');
if (hasCurrentPrices) {
lastUpdatedElement.textContent = `Updated: ${new Date().toLocaleString()}`;
} else {
lastUpdatedElement.textContent = 'Using cost basis - update prices in Gains/Losses';
}
// Update dashboard card colors for gains/losses
this.updateDashboardColors(totalGains);
}
renderETFBreakdown() {
const etfMap = new Map();
this.trades.forEach(trade => {
if (!etfMap.has(trade.etfSymbol)) {
etfMap.set(trade.etfSymbol, {
symbol: trade.etfSymbol,
shares: 0,
totalValue: 0,
currency: trade.currency,
trades: 0
});
}
const etf = etfMap.get(trade.etfSymbol);
const multiplier = trade.tradeType === 'buy' ? 1 : -1;
etf.shares += trade.shares * multiplier;
etf.totalValue += trade.totalValue * multiplier;
etf.trades++;
});
const breakdownList = document.getElementById('etf-breakdown-list');
if (etfMap.size === 0) {
breakdownList.innerHTML = 'No ETF positions yet
';
return;
}
const etfEntries = Array.from(etfMap.values()).filter(etf => etf.shares > 0);
if (etfEntries.length === 0) {
breakdownList.innerHTML = 'No current ETF positions
';
return;
}
const breakdownHTML = etfEntries.map(etf => {
const currencySymbol = etf.currency === 'EUR' ? '€' : '$';
const avgPrice = etf.shares > 0 ? etf.totalValue / etf.shares : 0;
return `
${etf.shares.toFixed(3)} shares
Avg: ${currencySymbol}${avgPrice.toFixed(2)}
${etf.trades} trades
`;
}).join('');
breakdownList.innerHTML = breakdownHTML;
}
renderPortfolioPage() {
const portfolioSummary = document.getElementById('portfolio-summary');
const etfMap = new Map();
this.trades.forEach(trade => {
if (!etfMap.has(trade.etfSymbol)) {
etfMap.set(trade.etfSymbol, {
symbol: trade.etfSymbol,
shares: 0,
totalValue: 0,
currency: trade.currency,
trades: 0,
avgPrice: 0
});
}
const etf = etfMap.get(trade.etfSymbol);
const multiplier = trade.tradeType === 'buy' ? 1 : -1;
etf.shares += trade.shares * multiplier;
etf.totalValue += trade.totalValue * multiplier;
etf.trades++;
etf.avgPrice = etf.shares > 0 ? etf.totalValue / etf.shares : 0;
});
const activeETFs = Array.from(etfMap.values()).filter(etf => etf.shares > 0);
if (activeETFs.length === 0) {
portfolioSummary.innerHTML = 'No active positions in your portfolio
';
return;
}
let totalPortfolioValue = 0;
activeETFs.forEach(etf => {
totalPortfolioValue += etf.totalValue;
});
const portfolioHTML = `
Portfolio Overview
Total Value
${this.formatCurrency(totalPortfolioValue)}
Active ETFs
${activeETFs.length}
Total Trades
${this.trades.length}
Holdings
${activeETFs.map(etf => {
const currencySymbol = etf.currency === 'EUR' ? '€' : '$';
const allocation = ((etf.totalValue / totalPortfolioValue) * 100).toFixed(1);
return `
Shares
${etf.shares.toFixed(3)}
Value
${currencySymbol}${etf.totalValue.toFixed(2)}
Avg Price
${currencySymbol}${etf.avgPrice.toFixed(2)}
Trades
${etf.trades}
`;
}).join('')}
`;
portfolioSummary.innerHTML = portfolioHTML;
}
renderGainsLossesPage() {
this.renderPriceUpdateList();
this.calculateAndDisplayPerformance();
this.bindGainsLossesEvents();
}
bindGainsLossesEvents() {
const updateAllBtn = document.getElementById('update-all-prices');
if (updateAllBtn && !updateAllBtn.hasEventListener) {
updateAllBtn.addEventListener('click', () => this.updateAllPrices());
updateAllBtn.hasEventListener = true;
}
}
renderPriceUpdateList() {
const priceUpdateList = document.getElementById('price-update-list');
const etfMap = this.getActiveETFPositions();
if (etfMap.size === 0) {
priceUpdateList.innerHTML = 'No ETF positions to update
';
return;
}
const updateHTML = Array.from(etfMap.entries()).map(([symbol, etf]) => {
const currentPrice = this.currentPrices.get(symbol) || '';
const currencySymbol = etf.currency === 'EUR' ? '€' : '$';
return `
`;
}).join('');
priceUpdateList.innerHTML = updateHTML;
// Bind individual update events
document.querySelectorAll('.update-price-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const symbol = e.target.dataset.symbol;
const input = document.querySelector(`input[data-symbol="${symbol}"]`);
const price = parseFloat(input.value);
if (price > 0) {
this.updateETFPrice(symbol, price);
} else {
this.showNotification('Please enter a valid price', 'error');
}
});
});
// Update on Enter key
document.querySelectorAll('.current-price-input').forEach(input => {
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const symbol = e.target.dataset.symbol;
const price = parseFloat(e.target.value);
if (price > 0) {
this.updateETFPrice(symbol, price);
}
}
});
});
}
getActiveETFPositions() {
const etfMap = new Map();
this.trades.forEach(trade => {
if (!etfMap.has(trade.etfSymbol)) {
etfMap.set(trade.etfSymbol, {
symbol: trade.etfSymbol,
shares: 0,
totalValue: 0,
currency: trade.currency,
trades: 0,
avgPrice: 0
});
}
const etf = etfMap.get(trade.etfSymbol);
const multiplier = trade.tradeType === 'buy' ? 1 : -1;
etf.shares += trade.shares * multiplier;
etf.totalValue += trade.totalValue * multiplier;
etf.trades++;
etf.avgPrice = etf.shares > 0 ? etf.totalValue / etf.shares : 0;
});
// Filter out positions with 0 or negative shares
const activePositions = new Map();
etfMap.forEach((etf, symbol) => {
if (etf.shares > 0) {
activePositions.set(symbol, etf);
}
});
return activePositions;
}
updateETFPrice(symbol, currentPrice) {
this.currentPrices.set(symbol, currentPrice);
this.calculateAndDisplayPerformance();
this.updatePriceChangeIndicator(symbol, currentPrice);
this.updateDashboard(); // Update dashboard when prices change
this.showNotification(`Updated ${symbol} price to ${currentPrice}`, 'success');
}
updatePriceChangeIndicator(symbol, currentPrice) {
const etfMap = this.getActiveETFPositions();
const etf = etfMap.get(symbol);
if (etf) {
const changeAmount = currentPrice - etf.avgPrice;
const changePercent = ((changeAmount / etf.avgPrice) * 100);
const currencySymbol = etf.currency === 'EUR' ? '€' : '$';
const changeElement = document.querySelector(`[data-symbol="${symbol}"].price-change`);
if (changeElement) {
const changeClass = changeAmount >= 0 ? 'positive' : 'negative';
const changeSign = changeAmount >= 0 ? '+' : '';
changeElement.className = `price-change ${changeClass}`;
changeElement.textContent = `${changeSign}${currencySymbol}${changeAmount.toFixed(2)} (${changeSign}${changePercent.toFixed(1)}%)`;
}
}
}
updateAllPrices() {
const inputs = document.querySelectorAll('.current-price-input');
let updatedCount = 0;
inputs.forEach(input => {
const price = parseFloat(input.value);
if (price > 0) {
this.updateETFPrice(input.dataset.symbol, price);
updatedCount++;
}
});
if (updatedCount > 0) {
this.showNotification(`Updated ${updatedCount} ETF prices`, 'success');
} else {
this.showNotification('Please enter valid prices first', 'error');
}
}
calculateAndDisplayPerformance() {
const etfMap = this.getActiveETFPositions();
let totalCost = 0;
let totalCurrentValue = 0;
let hasCurrentPrices = false;
etfMap.forEach((etf, symbol) => {
totalCost += etf.totalValue;
const currentPrice = this.currentPrices.get(symbol);
if (currentPrice) {
totalCurrentValue += etf.shares * currentPrice;
hasCurrentPrices = true;
} else {
totalCurrentValue += etf.totalValue; // Use cost basis if no current price
}
});
const totalGainLoss = totalCurrentValue - totalCost;
const totalPercentage = totalCost > 0 ? ((totalGainLoss / totalCost) * 100) : 0;
// Update performance summary
document.getElementById('total-performance').textContent = this.formatCurrency(totalGainLoss);
document.getElementById('total-percentage').textContent = `${totalPercentage >= 0 ? '+' : ''}${totalPercentage.toFixed(1)}%`;
document.getElementById('unrealized-performance').textContent = this.formatCurrency(totalGainLoss);
document.getElementById('unrealized-percentage').textContent = `${totalPercentage >= 0 ? '+' : ''}${totalPercentage.toFixed(1)}%`;
document.getElementById('current-portfolio-value').textContent = this.formatCurrency(totalCurrentValue);
// Update performance card colors
const performanceCards = document.querySelectorAll('.performance-card');
performanceCards.forEach(card => {
if (card.classList.contains('total-gains') || card.classList.contains('unrealized-gains')) {
card.classList.remove('positive', 'negative');
card.classList.add(totalGainLoss >= 0 ? 'positive' : 'negative');
}
});
// Update last updated timestamp
const lastUpdated = document.getElementById('last-updated');
if (hasCurrentPrices) {
lastUpdated.textContent = `Updated: ${new Date().toLocaleString()}`;
} else {
lastUpdated.textContent = 'Using cost basis - update prices';
}
// Calculate and display CGT information
this.calculateAndDisplayCGT(etfMap, hasCurrentPrices);
// 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 = etf.currency === 'EUR' ? '€' : '$';
const performanceClass = gainLoss >= 0 ? 'positive' : 'negative';
const hasRealPrice = this.currentPrices.has(symbol);
return `
`;
}).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 = 0.33; // 33% rate 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 = position.currency === 'EUR' ? '€' : '$';
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 `
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,
annual_exemption: 1270.0,
enabled: true
};
}
} catch (error) {
console.error('Error loading CGT settings:', error);
this.cgtSettings = {
rate_1month: 40.0,
rate_6months: 35.0,
rate_1year: 30.0,
rate_2years: 20.0,
rate_longterm: 10.0,
annual_exemption: 1270.0,
enabled: true
};
}
}
async saveCGTSettings(settings) {
try {
const response = await fetch(`${this.apiUrl}/cgt-settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(settings)
});
if (response.ok) {
this.cgtSettings = settings;
this.showNotification('CGT settings saved successfully!', 'success');
// Refresh gains/losses calculations
if (document.getElementById('gains-losses-page').classList.contains('active')) {
this.renderGainsLossesPage();
}
// Refresh dashboard
this.updateDashboard();
return true;
} else {
const error = await response.json();
this.showNotification(error.error || 'Failed to save CGT settings', 'error');
return false;
}
} catch (error) {
console.error('Error saving CGT settings:', error);
this.showNotification('Failed to save CGT settings', 'error');
return false;
}
}
getCGTRateForHoldingPeriod(holdingDays) {
if (!this.cgtSettings || !this.cgtSettings.enabled) {
return 0;
}
const holdingMonths = holdingDays / 30.44; // Average days per month
const holdingYears = holdingDays / 365.25; // Average days per year
if (holdingDays <= 30) {
return this.cgtSettings.rate_1month;
} else if (holdingMonths <= 6) {
return this.cgtSettings.rate_6months;
} else if (holdingYears <= 1) {
return this.cgtSettings.rate_1year;
} else if (holdingYears <= 2) {
return this.cgtSettings.rate_2years;
} else {
return this.cgtSettings.rate_longterm;
}
}
calculateCGTForPosition(etfSymbol, etfData) {
if (!this.cgtSettings || !this.cgtSettings.enabled) {
return {
totalCGT: 0,
afterTaxGains: etfData.totalGains || 0,
effectiveRate: 0,
holdingPeriods: {}
};
}
const currentPrice = this.currentPrices.get(etfSymbol);
if (!currentPrice) {
return {
totalCGT: 0,
afterTaxGains: 0,
effectiveRate: 0,
holdingPeriods: {}
};
}
// Get all buy trades for this ETF, sorted by date (FIFO method)
const buyTrades = this.trades
.filter(t => t.etfSymbol === etfSymbol && t.tradeType === 'buy')
.sort((a, b) => new Date(a.dateTime) - new Date(b.dateTime));
let totalCGT = 0;
let totalGains = 0;
let totalCost = 0;
const holdingPeriods = {};
const now = new Date();
buyTrades.forEach(trade => {
const tradeDate = new Date(trade.dateTime);
const holdingDays = Math.floor((now - tradeDate) / (1000 * 60 * 60 * 24));
const currentValue = trade.shares * currentPrice;
const cost = trade.totalValue;
const gain = currentValue - cost;
if (gain > 0) {
const cgtRate = this.getCGTRateForHoldingPeriod(holdingDays);
const cgt = (gain * cgtRate) / 100;
totalCGT += cgt;
totalGains += gain;
totalCost += cost;
// Group by holding period for display
const period = this.getHoldingPeriodLabel(holdingDays);
if (!holdingPeriods[period]) {
holdingPeriods[period] = { gains: 0, cgt: 0, rate: cgtRate };
}
holdingPeriods[period].gains += gain;
holdingPeriods[period].cgt += cgt;
}
});
const afterTaxGains = totalGains - totalCGT;
const effectiveRate = totalGains > 0 ? (totalCGT / totalGains) * 100 : 0;
return {
totalCGT,
afterTaxGains,
effectiveRate,
holdingPeriods,
totalGains,
totalCost
};
}
getHoldingPeriodLabel(holdingDays) {
const holdingMonths = holdingDays / 30.44;
const holdingYears = holdingDays / 365.25;
if (holdingDays <= 30) {
return '0-1 Month';
} else if (holdingMonths <= 6) {
return '1-6 Months';
} else if (holdingYears <= 1) {
return '6M-1 Year';
} else if (holdingYears <= 2) {
return '1-2 Years';
} else {
return '2+ Years';
}
}
renderCGTSettingsPage() {
if (!this.cgtSettings) {
this.loadCGTSettings();
return;
}
// Populate form with current settings
document.getElementById('cgt-1month').value = this.cgtSettings.rate_1month;
document.getElementById('cgt-6months').value = this.cgtSettings.rate_6months;
document.getElementById('cgt-1year').value = this.cgtSettings.rate_1year;
document.getElementById('cgt-2years').value = this.cgtSettings.rate_2years;
document.getElementById('cgt-longterm').value = this.cgtSettings.rate_longterm;
document.getElementById('cgt-annual-exemption').value = this.cgtSettings.annual_exemption;
document.getElementById('cgt-enabled').checked = this.cgtSettings.enabled;
this.updateCGTPreview();
this.bindCGTEvents();
}
bindCGTEvents() {
const form = document.getElementById('cgt-settings-form');
const resetBtn = document.getElementById('reset-cgt-defaults');
const inputs = document.querySelectorAll('.cgt-rate-input');
// Remove existing listeners to prevent duplicates
form.removeEventListener('submit', this.handleCGTFormSubmit);
resetBtn.removeEventListener('click', this.handleCGTReset);
// Bind form submission
this.handleCGTFormSubmit = async (e) => {
e.preventDefault();
const settings = {
rate_1month: parseFloat(document.getElementById('cgt-1month').value),
rate_6months: parseFloat(document.getElementById('cgt-6months').value),
rate_1year: parseFloat(document.getElementById('cgt-1year').value),
rate_2years: parseFloat(document.getElementById('cgt-2years').value),
rate_longterm: parseFloat(document.getElementById('cgt-longterm').value),
annual_exemption: parseFloat(document.getElementById('cgt-annual-exemption').value),
enabled: document.getElementById('cgt-enabled').checked
};
await this.saveCGTSettings(settings);
};
// Bind reset button
this.handleCGTReset = () => {
document.getElementById('cgt-1month').value = 40;
document.getElementById('cgt-6months').value = 35;
document.getElementById('cgt-1year').value = 30;
document.getElementById('cgt-2years').value = 20;
document.getElementById('cgt-longterm').value = 10;
document.getElementById('cgt-annual-exemption').value = 1270;
document.getElementById('cgt-enabled').checked = true;
this.updateCGTPreview();
};
form.addEventListener('submit', this.handleCGTFormSubmit);
resetBtn.addEventListener('click', this.handleCGTReset);
// Update preview on input change
inputs.forEach(input => {
input.addEventListener('input', () => this.updateCGTPreview());
});
}
updateCGTPreview() {
const rates = {
'1month': parseFloat(document.getElementById('cgt-1month').value) || 0,
'6months': parseFloat(document.getElementById('cgt-6months').value) || 0,
'1year': parseFloat(document.getElementById('cgt-1year').value) || 0,
'2years': parseFloat(document.getElementById('cgt-2years').value) || 0,
'longterm': parseFloat(document.getElementById('cgt-longterm').value) || 0
};
Object.keys(rates).forEach(period => {
const rateBar = document.querySelector(`[data-period="${period}"]`);
if (rateBar) {
const rateValue = rateBar.querySelector('.rate-value');
rateValue.textContent = `${rates[period]}%`;
// Update bar height based on rate (max 50% height)
const height = Math.max(10, (rates[period] / 100) * 50);
rateBar.style.setProperty('--rate-height', `${height}%`);
}
});
}
}
const app = new ETFTradeTracker();