etf-trade-tracker/script.js
kris 16e77850cd Remove collapsible sidebar functionality
- Remove collapse button from sidebar header
- Remove all collapsed state CSS styling
- Remove JavaScript sidebar toggle functionality
- Remove tooltip functionality and data-tooltip attributes
- Restore sidebar to fixed 260px width
- Clean up mobile responsive CSS
- Remove localStorage sidebar state persistence
- Return to original static sidebar behavior

Sidebar now:
- Fixed 260px width (no collapsing)
- Clean header without collapse button
- Standard menu items with icons + text
- Original mobile slide-out behavior maintained
- Simplified codebase without collapse complexity

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 09:11:56 +00:00

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