etf-trade-tracker/script.js

3896 lines
156 KiB
JavaScript
Raw Permalink Normal View History

class PersonalFinanceTracker {
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');
const subscriptionForm = document.getElementById('subscription-form');
form.addEventListener('submit', (e) => this.handleSubmit(e));
clearBtn.addEventListener('click', () => this.clearAllTrades());
exportBtn.addEventListener('click', () => this.exportTrades());
if (subscriptionForm) {
subscriptionForm.addEventListener('submit', (e) => this.handleSubscriptionSubmit(e));
}
const editSubscriptionForm = document.getElementById('edit-subscription-form');
if (editSubscriptionForm) {
editSubscriptionForm.addEventListener('submit', (e) => this.handleEditSubscriptionSubmit(e));
}
}
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.navigateToPage(page);
});
});
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');
}
});
}
navigateToPage(pageId) {
const menuItems = document.querySelectorAll('.menu-item');
const sidebar = document.querySelector('.sidebar');
// Show the page
this.showPage(pageId);
// Update active menu item
menuItems.forEach(mi => mi.classList.remove('active'));
const targetMenuItem = document.querySelector(`[data-page="${pageId}"]`);
if (targetMenuItem) {
targetMenuItem.classList.add('active');
}
// Hide sidebar on mobile after selection
if (window.innerWidth <= 768) {
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',
'subscriptions': 'Subscriptions',
'add-subscription': 'Add Subscription',
'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();
// Auto-scroll to add account form if coming from quick action
if (document.referrer === '' || window.location.hash === '#add-account') {
setTimeout(() => {
const addAccountForm = document.getElementById('create-account-form');
if (addAccountForm) {
addAccountForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
const firstInput = addAccountForm.querySelector('input[type="text"]');
if (firstInput) firstInput.focus();
}
}, 100);
}
} else if (pageId === 'add-transfer') {
this.renderAddTransferPage();
} else if (pageId === 'subscriptions') {
this.renderSubscriptionsPage();
} else if (pageId === 'add-subscription') {
this.renderAddSubscriptionPage();
// Auto-focus first input when coming from quick action
setTimeout(() => {
const firstInput = document.getElementById('subscription-service-name');
if (firstInput) firstInput.focus();
}, 100);
} else if (pageId === 'add-trade') {
// Auto-focus first input when coming from quick action
setTimeout(() => {
const firstInput = document.getElementById('etf-symbol');
if (firstInput) firstInput.focus();
}, 100);
} 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.calculateSimplifiedDashboard();
}
async calculateSimplifiedDashboard() {
try {
// Calculate portfolio totals
const portfolioData = this.calculatePortfolioTotals();
// Load cash and subscription data in parallel
const [cashData, subscriptionData] = await Promise.all([
this.fetchCashSummary(),
this.fetchSubscriptionsSummary()
]);
// Update dashboard with calculated data
this.updateDashboardSummary(portfolioData, cashData, subscriptionData);
} catch (error) {
console.error('Error updating dashboard:', error);
}
}
calculatePortfolioTotals() {
let totalCostBasis = 0;
let totalCurrentValue = 0;
let hasCurrentPrices = false;
const etfMap = this.getActiveETFPositions();
etfMap.forEach((etf, symbol) => {
totalCostBasis += etf.totalValue;
const currentPrice = this.currentPrices.get(symbol);
if (currentPrice) {
totalCurrentValue += etf.shares * currentPrice;
hasCurrentPrices = true;
} else {
totalCurrentValue += etf.totalValue;
}
});
const totalGains = totalCurrentValue - totalCostBasis;
const gainsPercentage = totalCostBasis > 0 ? ((totalGains / totalCostBasis) * 100) : 0;
return {
currentValue: totalCurrentValue,
totalGains,
gainsPercentage,
hasCurrentPrices
};
}
async fetchCashSummary() {
try {
const response = await fetch(`${this.apiUrl}/cash-summary`, {
credentials: 'include'
});
if (response.ok) {
return await response.json();
}
} catch (error) {
console.error('Error fetching cash summary:', error);
}
return { total_eur: 0, total_usd: 0, account_count: 0 };
}
async fetchSubscriptionsSummary() {
try {
const response = await fetch(`${this.apiUrl}/subscriptions-summary`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
console.log('Subscription summary data:', data); // Debug log
return data;
}
} catch (error) {
console.error('Error fetching subscriptions summary:', error);
}
return [];
}
updateDashboardSummary(portfolioData, cashData, subscriptionData) {
// Portfolio card - force EUR display
document.getElementById('dashboard-current-value').textContent = this.formatCurrency(portfolioData.currentValue, 'EUR');
document.getElementById('dashboard-total-gains').textContent =
`${portfolioData.totalGains >= 0 ? '+' : ''}${this.formatCurrency(portfolioData.totalGains, 'EUR')}`;
// Cash card - force EUR display
const totalCash = cashData.total_eur + (cashData.total_usd * 1.1); // Simple EUR conversion
document.getElementById('dashboard-cash-total').textContent = this.formatCurrency(totalCash, 'EUR');
document.getElementById('dashboard-account-count').textContent = `${cashData.account_count} accounts`;
// Subscriptions card - using summary data structure
let monthlyTotal = 0;
let subscriptionCount = 0;
if (subscriptionData && Array.isArray(subscriptionData) && subscriptionData.length > 0) {
// If we get an array of summary objects (grouped by currency)
subscriptionData.forEach(summary => {
monthlyTotal += summary.total_monthly_cost || 0;
subscriptionCount += summary.total_subscriptions || 0;
});
} else if (subscriptionData && subscriptionData.total_monthly_cost !== undefined) {
// If we get a single summary object
monthlyTotal = subscriptionData.total_monthly_cost || 0;
subscriptionCount = subscriptionData.total_subscriptions || 0;
}
document.getElementById('dashboard-subscription-monthly').textContent = this.formatCurrency(monthlyTotal, 'EUR') + '/mo';
document.getElementById('dashboard-subscription-count').textContent = `${subscriptionCount} services`;
// Total value card - force EUR display
const totalValue = portfolioData.currentValue + totalCash;
document.getElementById('dashboard-total-combined').textContent = this.formatCurrency(totalValue, 'EUR');
document.getElementById('dashboard-performance').textContent =
`${portfolioData.gainsPercentage >= 0 ? '+' : ''}${portfolioData.gainsPercentage.toFixed(1)}%`;
// Load and display top accounts for each segment
this.loadDashboardTopAccounts();
// Initialize and update growth charts
this.initializeGrowthCharts();
this.updateGrowthCharts();
}
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');
}
}
}
async loadDashboardTopAccounts() {
try {
// Load data for all segments in parallel
const [portfolioData, cashData, subscriptionData] = await Promise.all([
this.getTopPortfolioHoldings(),
this.getTopCashAccounts(),
this.getTopSubscriptions()
]);
// Render each segment
this.renderTopPortfolio(portfolioData);
this.renderTopCash(cashData);
this.renderTopSubscriptions(subscriptionData);
} catch (error) {
console.error('Error loading dashboard top accounts:', error);
}
}
getTopPortfolioHoldings() {
const etfMap = this.getActiveETFPositions();
const etfArray = Array.from(etfMap.values())
.filter(etf => etf.shares > 0)
.map(etf => {
const currentPrice = this.currentPrices.get(etf.symbol);
const currentValue = currentPrice ? etf.shares * currentPrice : etf.totalValue;
return {
name: etf.symbol,
value: currentValue,
details: `${etf.shares.toFixed(3)} shares`,
currency: etf.currency
};
})
.sort((a, b) => b.value - a.value)
.slice(0, 5);
return etfArray;
}
async getTopCashAccounts() {
try {
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
credentials: 'include'
});
if (response.ok) {
const accounts = await response.json();
return accounts
.sort((a, b) => b.balance - a.balance)
.slice(0, 5)
.map(account => ({
name: account.name,
value: account.balance,
details: account.account_type,
currency: account.currency
}));
}
} catch (error) {
console.error('Error fetching top cash accounts:', error);
}
return [];
}
async getTopSubscriptions() {
try {
const response = await fetch(`${this.apiUrl}/subscriptions`, {
credentials: 'include'
});
if (response.ok) {
const subscriptions = await response.json();
return subscriptions
.sort((a, b) => {
const aCost = a.billingCycle === 'monthly' ? a.monthlyPrice : (a.annualPrice / 12);
const bCost = b.billingCycle === 'monthly' ? b.monthlyPrice : (b.annualPrice / 12);
return bCost - aCost;
})
.slice(0, 5)
.map(sub => ({
name: sub.serviceName,
value: sub.billingCycle === 'monthly' ? sub.monthlyPrice : (sub.annualPrice / 12),
details: sub.category,
currency: sub.currency
}));
}
} catch (error) {
console.error('Error fetching top subscriptions:', error);
}
return [];
}
renderTopPortfolio(holdings) {
const container = document.getElementById('dashboard-top-portfolio');
if (!holdings || holdings.length === 0) {
container.innerHTML = '<div class="no-data-small">No positions yet</div>';
return;
}
const html = holdings.map(holding => `
<div class="top-account-item">
<div class="top-account-name">${holding.name}</div>
<div class="top-account-details">${holding.details}</div>
<div class="top-account-value">${this.formatCurrency(holding.value, holding.currency)}</div>
</div>
`).join('');
container.innerHTML = html;
}
renderTopCash(accounts) {
const container = document.getElementById('dashboard-top-cash');
if (!accounts || accounts.length === 0) {
container.innerHTML = '<div class="no-data-small">No accounts yet</div>';
return;
}
const html = accounts.map(account => `
<div class="top-account-item">
<div class="top-account-name">${account.name}</div>
<div class="top-account-details">${account.details}</div>
<div class="top-account-value">${this.formatCurrency(account.value, account.currency)}</div>
</div>
`).join('');
container.innerHTML = html;
}
renderTopSubscriptions(subscriptions) {
const container = document.getElementById('dashboard-top-subscriptions');
if (!subscriptions || subscriptions.length === 0) {
container.innerHTML = '<div class="no-data-small">No subscriptions yet</div>';
return;
}
const html = subscriptions.map(sub => `
<div class="top-account-item">
<div class="top-account-name">${sub.name}</div>
<div class="top-account-details">${sub.details}</div>
<div class="top-account-value">${this.formatCurrency(sub.value, sub.currency)}/mo</div>
</div>
`).join('');
container.innerHTML = html;
}
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);
Add 8+ Year Long-Term CGT Section with 33% Tax Rate Features: - New section specifically for holdings over 8 years - Fixed 33% CGT rate for ultra long-term positions - Automatic identification of 8+ year old trades - Comprehensive breakdown showing individual position performance - Visual cards displaying key metrics: - Eligible holdings count and total value - Total gains on 8+ year positions - CGT liability at 33% rate - After-tax gains calculation Calculation Logic: - Identifies all buy trades that are 8+ years old (≥2920 days) - Groups trades by ETF symbol for position-level calculations - Calculates current value vs original cost basis - Applies 33% tax rate only to positive gains - Shows holding period for each position in years - Displays both gross and net (after-tax) performance Visual Design: - Distinct blue color scheme to differentiate from other CGT calculations - Calendar icon (📅) to emphasize long-term nature - Detailed breakdown cards for each qualifying position - Color-coded positive/negative performance indicators - Professional grid layout with responsive design Display Features: - Only appears when current prices are updated - Hidden when no positions qualify (under 8 years) - Shows exact holding period in years for each position - Individual CGT liability and after-tax gains per position - Clear distinction from existing variable-rate CGT system 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 10:39:27 +01:00
// 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';
}
Add 8+ Year Long-Term CGT Section with 33% Tax Rate Features: - New section specifically for holdings over 8 years - Fixed 33% CGT rate for ultra long-term positions - Automatic identification of 8+ year old trades - Comprehensive breakdown showing individual position performance - Visual cards displaying key metrics: - Eligible holdings count and total value - Total gains on 8+ year positions - CGT liability at 33% rate - After-tax gains calculation Calculation Logic: - Identifies all buy trades that are 8+ years old (≥2920 days) - Groups trades by ETF symbol for position-level calculations - Calculates current value vs original cost basis - Applies 33% tax rate only to positive gains - Shows holding period for each position in years - Displays both gross and net (after-tax) performance Visual Design: - Distinct blue color scheme to differentiate from other CGT calculations - Calendar icon (📅) to emphasize long-term nature - Detailed breakdown cards for each qualifying position - Color-coded positive/negative performance indicators - Professional grid layout with responsive design Display Features: - Only appears when current prices are updated - Hidden when no positions qualify (under 8 years) - Shows exact holding period in years for each position - Individual CGT liability and after-tax gains per position - Clear distinction from existing variable-rate CGT system 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 10:39:27 +01:00
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
Add 8+ Year Long-Term CGT Section with 33% Tax Rate Features: - New section specifically for holdings over 8 years - Fixed 33% CGT rate for ultra long-term positions - Automatic identification of 8+ year old trades - Comprehensive breakdown showing individual position performance - Visual cards displaying key metrics: - Eligible holdings count and total value - Total gains on 8+ year positions - CGT liability at 33% rate - After-tax gains calculation Calculation Logic: - Identifies all buy trades that are 8+ years old (≥2920 days) - Groups trades by ETF symbol for position-level calculations - Calculates current value vs original cost basis - Applies 33% tax rate only to positive gains - Shows holding period for each position in years - Displays both gross and net (after-tax) performance Visual Design: - Distinct blue color scheme to differentiate from other CGT calculations - Calendar icon (📅) to emphasize long-term nature - Detailed breakdown cards for each qualifying position - Color-coded positive/negative performance indicators - Professional grid layout with responsive design Display Features: - Only appears when current prices are updated - Hidden when no positions qualify (under 8 years) - Shows exact holding period in years for each position - Individual CGT liability and after-tax gains per position - Clear distinction from existing variable-rate CGT system 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 10:39:27 +01:00
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);
Add 8+ Year Long-Term CGT Section with 33% Tax Rate Features: - New section specifically for holdings over 8 years - Fixed 33% CGT rate for ultra long-term positions - Automatic identification of 8+ year old trades - Comprehensive breakdown showing individual position performance - Visual cards displaying key metrics: - Eligible holdings count and total value - Total gains on 8+ year positions - CGT liability at 33% rate - After-tax gains calculation Calculation Logic: - Identifies all buy trades that are 8+ years old (≥2920 days) - Groups trades by ETF symbol for position-level calculations - Calculates current value vs original cost basis - Applies 33% tax rate only to positive gains - Shows holding period for each position in years - Displays both gross and net (after-tax) performance Visual Design: - Distinct blue color scheme to differentiate from other CGT calculations - Calendar icon (📅) to emphasize long-term nature - Detailed breakdown cards for each qualifying position - Color-coded positive/negative performance indicators - Professional grid layout with responsive design Display Features: - Only appears when current prices are updated - Hidden when no positions qualify (under 8 years) - Shows exact holding period in years for each position - Individual CGT liability and after-tax gains per position - Clear distinction from existing variable-rate CGT system 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 10:39:27 +01:00
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;
}
// Subscription Management Functions
async renderSubscriptionsPage() {
await this.loadSubscriptions();
await this.loadSubscriptionsSummary();
}
async renderAddSubscriptionPage() {
// Set default date to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('subscription-start-date').value = today;
}
async loadSubscriptions() {
try {
const response = await fetch(`${this.apiUrl}/subscriptions`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch subscriptions');
}
const subscriptions = await response.json();
this.renderSubscriptionsList(subscriptions);
} catch (error) {
console.error('Error loading subscriptions:', error);
this.showNotification('Failed to load subscriptions', 'error');
}
}
async loadSubscriptionsSummary() {
try {
const response = await fetch(`${this.apiUrl}/subscriptions-summary`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch subscriptions summary');
}
const summary = await response.json();
this.renderSubscriptionsSummary(summary);
} catch (error) {
console.error('Error loading subscriptions summary:', error);
}
}
renderSubscriptionsList(subscriptions) {
const container = document.getElementById('subscriptions-list');
if (subscriptions.length === 0) {
container.innerHTML = '<p class="no-data">No subscriptions recorded yet. Add your first subscription above!</p>';
return;
}
const subscriptionsHTML = subscriptions.map(subscription => this.createSubscriptionHTML(subscription)).join('');
container.innerHTML = subscriptionsHTML;
}
createSubscriptionHTML(subscription) {
const monthlyPrice = subscription.billingCycle === 'monthly'
? subscription.monthlyPrice
: (subscription.annualPrice / 12);
const currency = this.getCurrencySymbol(subscription.currency);
const nextBilling = this.calculateNextBilling(subscription.startDate, subscription.billingCycle);
const startDate = new Date(subscription.startDate).toLocaleDateString();
return `
<div class="subscription-item ${subscription.billingCycle}" data-id="${subscription.id}">
<div class="subscription-header">
<span class="service-name">${this.escapeHtml(subscription.serviceName)}</span>
<span class="billing-cycle-badge ${subscription.billingCycle}">${subscription.billingCycle.toUpperCase()}</span>
<div class="subscription-header-right">
<span class="price">${currency}${monthlyPrice.toFixed(2)}/month</span>
${subscription.website_url ? `<a href="${subscription.website_url}" target="_blank" rel="noopener noreferrer" class="website-link" title="Visit ${subscription.serviceName}">🔗</a>` : ''}
<div class="subscription-actions">
<button class="edit-btn" onclick="app.openEditSubscriptionModal('${subscription.id}')">Edit</button>
<button class="delete-btn" onclick="app.deleteSubscription('${subscription.id}')">×</button>
</div>
</div>
</div>
<div class="subscription-details">
<div class="subscription-info">
${subscription.billingCycle === 'annual' ? `<span class="annual-price">(${currency}${subscription.annualPrice.toFixed(2)}/year)</span>` : ''}
<span class="category">${subscription.category}</span>
${subscription.freeTrialDays > 0 ? `<span class="free-trial">Free trial: ${subscription.freeTrialDays} days</span>` : ''}
</div>
<div class="subscription-dates">
<span class="start-date">Started: ${startDate}</span>
<span class="next-billing">Next billing: ${nextBilling}</span>
${subscription.endDate ? `<span class="end-date">Ends: ${new Date(subscription.endDate).toLocaleDateString()}</span>` : ''}
</div>
${subscription.notes ? `<div class="subscription-notes">${this.escapeHtml(subscription.notes)}</div>` : ''}
</div>
</div>
`;
}
renderSubscriptionsSummary(summary) {
const monthlyTotal = document.getElementById('monthly-total');
const annualTotal = document.getElementById('annual-total');
const totalSubscriptions = document.getElementById('total-subscriptions');
if (summary.length > 0) {
let totalMonthly = 0;
let totalAnnual = 0;
let totalSubs = 0;
summary.forEach(s => {
totalMonthly += s.total_monthly_cost || 0;
totalAnnual += s.total_annual_cost || 0;
totalSubs += s.total_subscriptions || 0;
});
monthlyTotal.textContent = `${totalMonthly.toFixed(2)}`;
annualTotal.textContent = `${totalAnnual.toFixed(2)}`;
totalSubscriptions.textContent = totalSubs;
} else {
monthlyTotal.textContent = '€0.00';
annualTotal.textContent = '€0.00';
totalSubscriptions.textContent = '0';
}
}
async handleSubscriptionSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const subscriptionData = {
serviceName: formData.get('serviceName'),
category: formData.get('category'),
billingCycle: formData.get('billingCycle'),
currency: formData.get('currency'),
monthlyPrice: formData.get('monthlyPrice') ? parseFloat(formData.get('monthlyPrice')) : null,
annualPrice: formData.get('annualPrice') ? parseFloat(formData.get('annualPrice')) : null,
startDate: formData.get('startDate'),
endDate: formData.get('endDate') || null,
freeTrialDays: parseInt(formData.get('freeTrialDays')) || 0,
notes: formData.get('notes'),
websiteUrl: formData.get('websiteUrl')
};
if (!subscriptionData.monthlyPrice && !subscriptionData.annualPrice) {
this.showNotification('Please provide either monthly or annual price', 'error');
return;
}
try {
const response = await fetch(`${this.apiUrl}/subscriptions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(subscriptionData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to add subscription');
}
this.showNotification('Subscription added successfully!', 'success');
e.target.reset();
this.showPage('subscriptions');
this.updateDashboard(); // Refresh dashboard data
} catch (error) {
console.error('Error adding subscription:', error);
this.showNotification(error.message, 'error');
}
}
async deleteSubscription(subscriptionId) {
if (confirm('Are you sure you want to delete this subscription?')) {
try {
const response = await fetch(`${this.apiUrl}/subscriptions/${subscriptionId}`, {
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete subscription');
}
await this.loadSubscriptions();
await this.loadSubscriptionsSummary();
this.updateDashboard(); // Refresh dashboard data
this.showNotification('Subscription deleted successfully!', 'success');
} catch (error) {
console.error('Error deleting subscription:', error);
this.showNotification(error.message || 'Failed to delete subscription', 'error');
}
}
}
// Subscription Modal Functions
openEditSubscriptionModal(subscriptionId) {
// First fetch the subscription data
this.fetchSubscriptions().then(subscriptions => {
const subscription = subscriptions.find(s => s.id == subscriptionId);
if (!subscription) {
this.showNotification('Subscription not found', 'error');
return;
}
// Populate the form
document.getElementById('edit-subscription-service-name').value = subscription.serviceName;
document.getElementById('edit-subscription-category').value = subscription.category;
document.getElementById('edit-subscription-billing-cycle').value = subscription.billingCycle;
document.getElementById('edit-subscription-currency').value = subscription.currency;
document.getElementById('edit-subscription-monthly-price').value = subscription.monthlyPrice || '';
document.getElementById('edit-subscription-annual-price').value = subscription.annualPrice || '';
document.getElementById('edit-subscription-start-date').value = subscription.startDate;
document.getElementById('edit-subscription-end-date').value = subscription.endDate || '';
document.getElementById('edit-subscription-free-trial').value = subscription.freeTrialDays;
document.getElementById('edit-subscription-notes').value = subscription.notes || '';
document.getElementById('edit-subscription-website-url').value = subscription.website_url || '';
// Store the subscription ID for later use
const form = document.getElementById('edit-subscription-form');
form.dataset.subscriptionId = subscriptionId;
// Show the modal
document.getElementById('edit-subscription-modal').style.display = 'block';
}).catch(error => {
console.error('Error fetching subscription for edit:', error);
this.showNotification('Failed to load subscription data', 'error');
});
}
closeEditSubscriptionModal() {
document.getElementById('edit-subscription-modal').style.display = 'none';
}
async handleEditSubscriptionSubmit(e) {
e.preventDefault();
const subscriptionId = e.target.dataset.subscriptionId;
const formData = new FormData(e.target);
const subscriptionData = {
serviceName: formData.get('serviceName'),
category: formData.get('category'),
billingCycle: formData.get('billingCycle'),
currency: formData.get('currency'),
monthlyPrice: formData.get('monthlyPrice') ? parseFloat(formData.get('monthlyPrice')) : null,
annualPrice: formData.get('annualPrice') ? parseFloat(formData.get('annualPrice')) : null,
startDate: formData.get('startDate'),
endDate: formData.get('endDate') || null,
freeTrialDays: parseInt(formData.get('freeTrialDays')) || 0,
notes: formData.get('notes'),
websiteUrl: formData.get('websiteUrl')
};
if (!subscriptionData.monthlyPrice && !subscriptionData.annualPrice) {
this.showNotification('Please provide either monthly or annual price', 'error');
return;
}
try {
const response = await fetch(`${this.apiUrl}/subscriptions/${subscriptionId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(subscriptionData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to update subscription');
}
this.closeEditSubscriptionModal();
await this.loadSubscriptions();
await this.loadSubscriptionsSummary();
this.updateDashboard();
this.showNotification('Subscription updated successfully!', 'success');
} catch (error) {
console.error('Error updating subscription:', error);
this.showNotification(error.message, 'error');
}
}
exportSubscriptions() {
// First fetch all subscriptions
this.fetchSubscriptions().then(subscriptions => {
if (subscriptions.length === 0) {
this.showNotification('No subscriptions to export', 'error');
return;
}
const dataStr = JSON.stringify(subscriptions, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `subscriptions_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.showNotification('Subscriptions exported successfully!', 'success');
}).catch(error => {
console.error('Error exporting subscriptions:', error);
this.showNotification('Failed to export subscriptions', 'error');
});
}
calculateNextBilling(startDate, billingCycle) {
const start = new Date(startDate);
const today = new Date();
let nextBilling = new Date(start);
while (nextBilling <= today) {
if (billingCycle === 'monthly') {
nextBilling.setMonth(nextBilling.getMonth() + 1);
} else {
nextBilling.setFullYear(nextBilling.getFullYear() + 1);
}
}
return nextBilling.toLocaleDateString();
}
// Dashboard Subscription Functions
async loadDashboardSubscriptionData() {
try {
const [subscriptions, summary] = await Promise.all([
this.fetchSubscriptions(),
this.fetchSubscriptionsSummary()
]);
this.updateDashboardSubscriptionSummary(summary);
this.renderDashboardSubscriptions(subscriptions);
// Update the holdings section
this.updateHoldingsSubscriptions(summary);
} catch (error) {
console.error('Error loading dashboard subscription data:', error);
}
}
async fetchSubscriptions() {
try {
const response = await fetch(`${this.apiUrl}/subscriptions`, {
credentials: 'include'
});
return response.ok ? await response.json() : [];
} catch (error) {
console.error('Error fetching subscriptions:', error);
return [];
}
}
async fetchSubscriptionsSummary() {
try {
const response = await fetch(`${this.apiUrl}/subscriptions-summary`, {
credentials: 'include'
});
return response.ok ? await response.json() : [];
} catch (error) {
console.error('Error fetching subscriptions summary:', error);
return [];
}
}
updateDashboardSubscriptionSummary(summary) {
const monthlyTotal = document.getElementById('dashboard-subscription-monthly');
const annualTotal = document.getElementById('dashboard-subscription-annual');
const subscriptionCount = document.getElementById('dashboard-subscription-count');
const nextBilling = document.getElementById('dashboard-next-billing');
if (summary.length > 0) {
let totalMonthly = 0;
let totalAnnual = 0;
let totalSubs = 0;
summary.forEach(s => {
totalMonthly += s.total_monthly_cost || 0;
totalAnnual += s.total_annual_cost || 0;
totalSubs += s.total_subscriptions || 0;
});
if (monthlyTotal) monthlyTotal.textContent = `${totalMonthly.toFixed(2)}`;
if (annualTotal) annualTotal.textContent = `${totalAnnual.toFixed(2)}`;
if (subscriptionCount) subscriptionCount.textContent = `${totalSubs} subscription${totalSubs !== 1 ? 's' : ''}`;
// Show subscription breakdown if there are subscriptions
const subscriptionBreakdown = document.getElementById('subscription-breakdown');
if (subscriptionBreakdown && totalSubs > 0) {
subscriptionBreakdown.style.display = 'block';
}
} else {
if (monthlyTotal) monthlyTotal.textContent = '€0.00';
if (annualTotal) annualTotal.textContent = '€0.00';
if (subscriptionCount) subscriptionCount.textContent = '0 subscriptions';
if (nextBilling) nextBilling.textContent = 'Next billing: --';
}
}
renderDashboardSubscriptions(subscriptions) {
const container = document.getElementById('dashboard-subscriptions-list');
if (!container) return;
if (subscriptions.length === 0) {
container.innerHTML = '<p class="no-data">No subscriptions yet</p>';
return;
}
// Show top 5 subscriptions by monthly cost
const sortedSubscriptions = subscriptions
.map(sub => ({
...sub,
monthlyEquivalent: sub.billingCycle === 'monthly'
? sub.monthlyPrice
: (sub.annualPrice / 12)
}))
.sort((a, b) => b.monthlyEquivalent - a.monthlyEquivalent)
.slice(0, 5);
let nextBillingDate = null;
let nextBillingService = '';
const subscriptionItems = sortedSubscriptions.map(subscription => {
const currency = this.getCurrencySymbol(subscription.currency);
const nextBilling = this.calculateNextBilling(subscription.startDate, subscription.billingCycle);
// Track the nearest billing date
const billingDateObj = new Date(nextBilling.split('/').reverse().join('-'));
if (!nextBillingDate || billingDateObj < nextBillingDate) {
nextBillingDate = billingDateObj;
nextBillingService = subscription.serviceName;
}
return `
<div class="subscription-breakdown-item" style="border-left: 3px solid #6f42c1; cursor: pointer;" onclick="app.showPage('subscriptions')">
<div class="subscription-name">${this.escapeHtml(subscription.serviceName)}</div>
<div class="subscription-details">
<span class="subscription-price">${currency}${subscription.monthlyEquivalent.toFixed(2)}/month</span>
<span class="subscription-cycle">${subscription.billingCycle}</span>
<span class="subscription-category">${subscription.category}</span>
</div>
</div>
`;
}).join('');
container.innerHTML = subscriptionItems;
// Update next billing information
const nextBillingElement = document.getElementById('dashboard-next-billing');
if (nextBillingElement && nextBillingDate) {
const billingText = `Next billing: ${nextBillingService} on ${nextBillingDate.toLocaleDateString()}`;
nextBillingElement.textContent = billingText;
}
}
updateHoldingsSubscriptions(summary) {
const subscriptionsValue = document.getElementById('total-holdings-subscriptions');
if (!subscriptionsValue) return;
if (summary.length > 0) {
let totalMonthly = 0;
summary.forEach(s => {
totalMonthly += s.total_monthly_cost || 0;
});
subscriptionsValue.textContent = `${totalMonthly.toFixed(2)}`;
} else {
subscriptionsValue.textContent = '€0.00';
}
}
// Growth Charts Functionality
initializeGrowthCharts() {
// Initialize chart data storage
this.chartData = {
portfolio: [],
cash: [],
total: [],
currentPeriod: '7d'
};
// Bind period selector events
const periodButtons = document.querySelectorAll('.period-btn');
periodButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
// Update active button
periodButtons.forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
// Update period and refresh charts
this.chartData.currentPeriod = e.target.dataset.period;
this.updateGrowthCharts();
});
});
// Generate initial mock data
this.generateMockChartData();
}
generateMockChartData() {
// Generate mock historical data for different time periods
const periods = {
'7d': 7,
'1m': 30,
'3m': 90,
'6m': 180,
'1y': 365
};
Object.keys(periods).forEach(period => {
const days = periods[period];
const portfolioData = [];
const cashData = [];
const totalData = [];
// Get current values as base
const currentPortfolio = this.calculatePortfolioTotals().currentValue;
const currentCash = 5000; // Mock current cash value
const currentTotal = currentPortfolio + currentCash;
// Generate historical values with some growth trend
for (let i = days; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
// Add some randomness but overall upward trend
const growthFactor = Math.pow(1.0008, days - i); // 0.08% daily growth on average
const randomFactor = 0.95 + Math.random() * 0.1; // ±5% random variation
const portfolioValue = currentPortfolio / growthFactor * randomFactor;
const cashValue = currentCash + (Math.random() - 0.5) * 1000; // Cash fluctuates more
portfolioData.push({ date, value: portfolioValue });
cashData.push({ date, value: Math.max(0, cashValue) });
totalData.push({ date, value: portfolioValue + Math.max(0, cashValue) });
}
this.chartData[`portfolio_${period}`] = portfolioData;
this.chartData[`cash_${period}`] = cashData;
this.chartData[`total_${period}`] = totalData;
});
}
updateGrowthCharts() {
const period = this.chartData.currentPeriod;
// Update portfolio chart
this.renderChart('portfolio-chart', this.chartData[`portfolio_${period}`], '#28a745');
this.updateChartStats('portfolio', this.chartData[`portfolio_${period}`]);
// Update cash chart
this.renderChart('cash-chart', this.chartData[`cash_${period}`], '#17a2b8');
this.updateChartStats('cash', this.chartData[`cash_${period}`]);
// Update total chart
this.renderChart('total-chart', this.chartData[`total_${period}`], '#667eea');
this.updateChartStats('total', this.chartData[`total_${period}`]);
}
renderChart(canvasId, data, color) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
// Set canvas size
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
// Clear canvas
ctx.clearRect(0, 0, rect.width, rect.height);
if (!data || data.length < 2) return;
// Calculate bounds
const values = data.map(d => d.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const valueRange = maxValue - minValue;
const padding = 20;
// Draw grid lines
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border-light');
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
for (let i = 0; i <= 4; i++) {
const y = padding + (i * (rect.height - 2 * padding)) / 4;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(rect.width - padding, y);
ctx.stroke();
}
ctx.setLineDash([]);
// Draw line chart
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
data.forEach((point, index) => {
const x = padding + (index * (rect.width - 2 * padding)) / (data.length - 1);
const y = rect.height - padding - ((point.value - minValue) / valueRange) * (rect.height - 2 * padding);
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Fill area under curve
ctx.globalAlpha = 0.1;
ctx.fillStyle = color;
ctx.lineTo(rect.width - padding, rect.height - padding);
ctx.lineTo(padding, rect.height - padding);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
}
updateChartStats(type, data) {
if (!data || data.length < 2) return;
const latest = data[data.length - 1];
const previous = data[0];
const change = ((latest.value - previous.value) / previous.value) * 100;
// Update value display
const valueElement = document.getElementById(`${type}-graph-value`);
const changeElement = document.getElementById(`${type}-graph-change`);
if (valueElement) {
valueElement.textContent = this.formatCurrency(latest.value, 'EUR');
}
if (changeElement) {
const changeText = `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`;
changeElement.textContent = changeText;
// Update change indicator classes
changeElement.className = 'change-indicator';
if (change > 0) {
changeElement.classList.add('positive');
} else if (change < 0) {
changeElement.classList.add('negative');
} else {
changeElement.classList.add('neutral');
}
}
}
}
const app = new PersonalFinanceTracker();