etf-trade-tracker/script.js
kris 4fb0d35daf Rebrand from ETF Trade Tracker to Personal Finance Tracker with growth charts
Update application branding to reflect broader financial tracking capabilities beyond just ETF trades. Add comprehensive growth visualization features with time period selectors and three distinct chart views for portfolio, cash, and total net worth tracking.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 13:58:43 +00:00

3896 lines
156 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

class 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);
// Calculate and display 8+ year CGT information
this.calculateAndDisplayLongTermCGT(etfMap, hasCurrentPrices);
this.renderGainsBreakdown(etfMap);
}
renderGainsBreakdown(etfMap) {
const breakdownList = document.getElementById('gains-breakdown-list');
if (etfMap.size === 0) {
breakdownList.innerHTML = '<p class="no-data">No active positions</p>';
return;
}
const breakdownHTML = Array.from(etfMap.entries()).map(([symbol, etf]) => {
const currentPrice = this.currentPrices.get(symbol) || etf.avgPrice;
const currentValue = etf.shares * currentPrice;
const gainLoss = currentValue - etf.totalValue;
const percentage = etf.totalValue > 0 ? ((gainLoss / etf.totalValue) * 100) : 0;
const currencySymbol = this.getCurrencySymbol(etf.currency);
const performanceClass = gainLoss >= 0 ? 'positive' : 'negative';
const hasRealPrice = this.currentPrices.has(symbol);
return `
<div class="gains-breakdown-item ${performanceClass}">
<div class="breakdown-header">
<span class="breakdown-symbol">${symbol}</span>
<span class="breakdown-performance">
${gainLoss >= 0 ? '+' : ''}${currencySymbol}${gainLoss.toFixed(2)}
(${gainLoss >= 0 ? '+' : ''}${percentage.toFixed(1)}%)
</span>
</div>
<div class="breakdown-details">
<div class="breakdown-stat">
<span>Shares</span>
<span>${etf.shares.toFixed(3)}</span>
</div>
<div class="breakdown-stat">
<span>Avg Cost</span>
<span>${currencySymbol}${etf.avgPrice.toFixed(2)}</span>
</div>
<div class="breakdown-stat">
<span>Current Price</span>
<span>${currencySymbol}${currentPrice.toFixed(2)} ${!hasRealPrice ? '(est.)' : ''}</span>
</div>
<div class="breakdown-stat">
<span>Current Value</span>
<span>${currencySymbol}${currentValue.toFixed(2)}</span>
</div>
</div>
</div>
`;
}).join('');
breakdownList.innerHTML = breakdownHTML;
}
formatCurrency(amount, currency = null) {
// Use majority currency if none specified
const currencyToUse = currency || this.getMajorityCurrency();
const symbol = this.getCurrencySymbol(currencyToUse);
const absAmount = Math.abs(amount);
if (absAmount >= 1000000) {
return (amount >= 0 ? symbol : '-' + symbol) + (absAmount / 1000000).toFixed(2) + 'M';
} else if (absAmount >= 1000) {
return (amount >= 0 ? symbol : '-' + symbol) + (absAmount / 1000).toFixed(1) + 'K';
} else {
return (amount >= 0 ? symbol : '-' + symbol) + absAmount.toFixed(2);
}
}
calculateAndDisplayCGT(etfMap, hasCurrentPrices) {
if (!this.cgtSettings || !this.cgtSettings.enabled || !hasCurrentPrices) {
document.getElementById('cgt-summary').style.display = 'none';
return;
}
let totalCGTLiability = 0;
let totalGainsBeforeTax = 0;
let totalAfterTaxGains = 0;
const allHoldingPeriods = {};
// Calculate CGT for each ETF position
etfMap.forEach((etf, symbol) => {
const cgtCalc = this.calculateCGTForPosition(symbol, etf);
totalCGTLiability += cgtCalc.totalCGT;
totalGainsBeforeTax += cgtCalc.totalGains;
totalAfterTaxGains += cgtCalc.afterTaxGains;
// Merge holding periods
Object.keys(cgtCalc.holdingPeriods).forEach(period => {
if (!allHoldingPeriods[period]) {
allHoldingPeriods[period] = { gains: 0, cgt: 0 };
}
allHoldingPeriods[period].gains += cgtCalc.holdingPeriods[period].gains;
allHoldingPeriods[period].cgt += cgtCalc.holdingPeriods[period].cgt;
});
});
// Apply annual exemption
const exemptionUsed = Math.min(totalGainsBeforeTax, this.cgtSettings.annual_exemption);
const taxableGains = Math.max(0, totalGainsBeforeTax - exemptionUsed);
const adjustedCGTLiability = Math.max(0, totalCGTLiability - (exemptionUsed * (totalCGTLiability / totalGainsBeforeTax)));
const finalAfterTaxGains = totalGainsBeforeTax - adjustedCGTLiability;
const effectiveRate = totalGainsBeforeTax > 0 ? (adjustedCGTLiability / totalGainsBeforeTax) * 100 : 0;
// Update CGT summary display
document.getElementById('total-cgt-liability').textContent = this.formatCurrency(adjustedCGTLiability);
document.getElementById('cgt-effective-rate').textContent = `Effective rate: ${effectiveRate.toFixed(1)}%`;
document.getElementById('after-tax-gains').textContent = this.formatCurrency(finalAfterTaxGains);
document.getElementById('exemption-used').textContent = `Exemption: ${this.formatCurrency(exemptionUsed)} used`;
// Update holding periods summary
const holdingPeriodsEl = document.getElementById('holding-periods-summary');
const shortTermGains = (allHoldingPeriods['0-1 Month']?.gains || 0) +
(allHoldingPeriods['1-6 Months']?.gains || 0) +
(allHoldingPeriods['6M-1 Year']?.gains || 0);
const longTermGains = (allHoldingPeriods['1-2 Years']?.gains || 0) +
(allHoldingPeriods['2+ Years']?.gains || 0);
holdingPeriodsEl.innerHTML = `
<div class="period-item">
<span class="period-label">Short-term (&lt;1Y):</span>
<span class="period-value">${this.formatCurrency(shortTermGains)}</span>
</div>
<div class="period-item">
<span class="period-label">Long-term (1Y+):</span>
<span class="period-value">${this.formatCurrency(longTermGains)}</span>
</div>
`;
// Show/update CGT card colors
const cgtCards = document.querySelectorAll('.cgt-card');
cgtCards.forEach(card => {
card.classList.remove('positive', 'negative');
if (card.classList.contains('total-cgt')) {
card.classList.add('negative'); // Tax is always red
} else if (card.classList.contains('after-tax')) {
card.classList.add(finalAfterTaxGains >= 0 ? 'positive' : 'negative');
}
});
document.getElementById('cgt-summary').style.display = 'block';
}
calculateAndDisplayLongTermCGT(etfMap, hasCurrentPrices) {
const longTermSection = document.getElementById('long-term-cgt-section');
if (!hasCurrentPrices) {
longTermSection.style.display = 'none';
return;
}
const now = new Date();
const eightYearsInDays = 8 * 365.25;
const cgtRate = (this.cgtSettings?.rate_8years || 33) / 100; // Rate from settings for 8+ years
let eligiblePositions = [];
let totalEligibleValue = 0;
let totalEligibleGains = 0;
let totalCost = 0;
etfMap.forEach((etf, symbol) => {
const currentPrice = this.currentPrices.get(symbol);
if (!currentPrice) return;
// Get all buy trades for this ETF that are 8+ years old
const longTermTrades = this.trades
.filter(t => t.etfSymbol === symbol && t.tradeType === 'buy')
.filter(t => {
const tradeDate = new Date(t.dateTime);
const holdingDays = Math.floor((now - tradeDate) / (1000 * 60 * 60 * 24));
return holdingDays >= eightYearsInDays;
})
.sort((a, b) => new Date(a.dateTime) - new Date(b.dateTime));
if (longTermTrades.length === 0) return;
let positionShares = 0;
let positionCost = 0;
let oldestTradeDate = null;
longTermTrades.forEach(trade => {
positionShares += trade.shares;
positionCost += trade.totalValue;
if (!oldestTradeDate || new Date(trade.dateTime) < oldestTradeDate) {
oldestTradeDate = new Date(trade.dateTime);
}
});
const currentValue = positionShares * currentPrice;
const gains = currentValue - positionCost;
const holdingDays = Math.floor((now - oldestTradeDate) / (1000 * 60 * 60 * 24));
const holdingYears = holdingDays / 365.25;
if (positionShares > 0) {
eligiblePositions.push({
symbol,
shares: positionShares,
cost: positionCost,
currentValue,
gains,
currentPrice,
avgCost: positionCost / positionShares,
holdingYears,
currency: etf.currency
});
totalEligibleValue += currentValue;
totalEligibleGains += gains;
totalCost += positionCost;
}
});
if (eligiblePositions.length === 0) {
longTermSection.style.display = 'none';
return;
}
// Calculate CGT liability (only on gains)
const taxableGains = Math.max(0, totalEligibleGains);
const cgtLiability = taxableGains * cgtRate;
const afterTaxGains = totalEligibleGains - cgtLiability;
const gainsPercentage = totalCost > 0 ? ((totalEligibleGains / totalCost) * 100) : 0;
// Update display elements
document.getElementById('long-term-eligible-value').textContent = this.formatCurrency(totalEligibleValue);
document.getElementById('long-term-eligible-count').textContent = `${eligiblePositions.length} position${eligiblePositions.length === 1 ? '' : 's'}`;
document.getElementById('long-term-total-gains').textContent = this.formatCurrency(totalEligibleGains);
document.getElementById('long-term-gains-percentage').textContent = `${gainsPercentage >= 0 ? '+' : ''}${gainsPercentage.toFixed(1)}% gain`;
document.getElementById('long-term-cgt-liability').textContent = this.formatCurrency(cgtLiability);
document.getElementById('long-term-after-tax').textContent = this.formatCurrency(afterTaxGains);
document.getElementById('long-term-effective-rate').textContent = `Effective rate: ${cgtRate * 100}%`;
// Render breakdown
this.renderLongTermBreakdown(eligiblePositions);
longTermSection.style.display = 'block';
}
renderLongTermBreakdown(eligiblePositions) {
const breakdownList = document.getElementById('long-term-breakdown-list');
if (eligiblePositions.length === 0) {
breakdownList.innerHTML = '<p class="no-data">No holdings over 8 years</p>';
return;
}
const breakdownHTML = eligiblePositions.map(position => {
const currencySymbol = this.getCurrencySymbol(position.currency);
const gainsClass = position.gains >= 0 ? 'positive' : 'negative';
const performanceClass = position.gains >= 0 ? 'positive' : 'negative';
const gainsPercentage = position.cost > 0 ? ((position.gains / position.cost) * 100) : 0;
const cgtLiability = Math.max(0, position.gains) * 0.33;
const afterTaxGains = position.gains - cgtLiability;
return `
<div class="long-term-breakdown-item ${gainsClass}">
<div class="long-term-breakdown-header">
<span class="long-term-symbol">${position.symbol}</span>
<span class="long-term-performance ${performanceClass}">
${position.gains >= 0 ? '+' : ''}${currencySymbol}${position.gains.toFixed(2)}
(${position.gains >= 0 ? '+' : ''}${gainsPercentage.toFixed(1)}%)
</span>
</div>
<div class="long-term-details">
<div class="long-term-stat">
<span>Held</span>
<span>${position.holdingYears.toFixed(1)} years</span>
</div>
<div class="long-term-stat">
<span>Shares</span>
<span>${position.shares.toFixed(3)}</span>
</div>
<div class="long-term-stat">
<span>Avg Cost</span>
<span>${currencySymbol}${position.avgCost.toFixed(2)}</span>
</div>
<div class="long-term-stat">
<span>Current Price</span>
<span>${currencySymbol}${position.currentPrice.toFixed(2)}</span>
</div>
<div class="long-term-stat">
<span>CGT (33%)</span>
<span>-${currencySymbol}${cgtLiability.toFixed(2)}</span>
</div>
<div class="long-term-stat">
<span>After-Tax Gain</span>
<span class="${performanceClass}">${afterTaxGains >= 0 ? '+' : ''}${currencySymbol}${afterTaxGains.toFixed(2)}</span>
</div>
</div>
</div>
`;
}).join('');
breakdownList.innerHTML = breakdownHTML;
}
// CGT Settings and Calculation Methods
async loadCGTSettings() {
try {
const response = await fetch(`${this.apiUrl}/cgt-settings`, {
method: 'GET',
credentials: 'include'
});
if (response.ok) {
this.cgtSettings = await response.json();
} else {
// Use default settings if fetch fails
this.cgtSettings = {
rate_1month: 40.0,
rate_6months: 35.0,
rate_1year: 30.0,
rate_2years: 20.0,
rate_longterm: 10.0,
rate_8years: 33.0,
annual_exemption: 1270.0,
enabled: true
};
}
} catch (error) {
console.error('Error loading CGT settings:', error);
this.cgtSettings = {
rate_1month: 40.0,
rate_6months: 35.0,
rate_1year: 30.0,
rate_2years: 20.0,
rate_longterm: 10.0,
rate_8years: 33.0,
annual_exemption: 1270.0,
enabled: true
};
}
}
async saveCGTSettings(settings) {
try {
const response = await fetch(`${this.apiUrl}/cgt-settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(settings)
});
if (response.ok) {
this.cgtSettings = settings;
this.showNotification('CGT settings saved successfully!', 'success');
// Refresh gains/losses calculations
if (document.getElementById('gains-losses-page').classList.contains('active')) {
this.renderGainsLossesPage();
}
// Refresh dashboard
this.updateDashboard();
return true;
} else {
const error = await response.json();
this.showNotification(error.error || 'Failed to save CGT settings', 'error');
return false;
}
} catch (error) {
console.error('Error saving CGT settings:', error);
this.showNotification('Failed to save CGT settings', 'error');
return false;
}
}
getCGTRateForHoldingPeriod(holdingDays) {
if (!this.cgtSettings || !this.cgtSettings.enabled) {
return 0;
}
const holdingMonths = holdingDays / 30.44; // Average days per month
const holdingYears = holdingDays / 365.25; // Average days per year
if (holdingDays <= 30) {
return this.cgtSettings.rate_1month;
} else if (holdingMonths <= 6) {
return this.cgtSettings.rate_6months;
} else if (holdingYears <= 1) {
return this.cgtSettings.rate_1year;
} else if (holdingYears <= 2) {
return this.cgtSettings.rate_2years;
} else {
return this.cgtSettings.rate_longterm;
}
}
calculateCGTForPosition(etfSymbol, etfData) {
if (!this.cgtSettings || !this.cgtSettings.enabled) {
return {
totalCGT: 0,
afterTaxGains: etfData.totalGains || 0,
effectiveRate: 0,
holdingPeriods: {}
};
}
const currentPrice = this.currentPrices.get(etfSymbol);
if (!currentPrice) {
return {
totalCGT: 0,
afterTaxGains: 0,
effectiveRate: 0,
holdingPeriods: {}
};
}
// Get all buy trades for this ETF, sorted by date (FIFO method)
const buyTrades = this.trades
.filter(t => t.etfSymbol === etfSymbol && t.tradeType === 'buy')
.sort((a, b) => new Date(a.dateTime) - new Date(b.dateTime));
let totalCGT = 0;
let totalGains = 0;
let totalCost = 0;
const holdingPeriods = {};
const now = new Date();
buyTrades.forEach(trade => {
const tradeDate = new Date(trade.dateTime);
const holdingDays = Math.floor((now - tradeDate) / (1000 * 60 * 60 * 24));
const currentValue = trade.shares * currentPrice;
const cost = trade.totalValue;
const gain = currentValue - cost;
if (gain > 0) {
const cgtRate = this.getCGTRateForHoldingPeriod(holdingDays);
const cgt = (gain * cgtRate) / 100;
totalCGT += cgt;
totalGains += gain;
totalCost += cost;
// Group by holding period for display
const period = this.getHoldingPeriodLabel(holdingDays);
if (!holdingPeriods[period]) {
holdingPeriods[period] = { gains: 0, cgt: 0, rate: cgtRate };
}
holdingPeriods[period].gains += gain;
holdingPeriods[period].cgt += cgt;
}
});
const afterTaxGains = totalGains - totalCGT;
const effectiveRate = totalGains > 0 ? (totalCGT / totalGains) * 100 : 0;
return {
totalCGT,
afterTaxGains,
effectiveRate,
holdingPeriods,
totalGains,
totalCost
};
}
getHoldingPeriodLabel(holdingDays) {
const holdingMonths = holdingDays / 30.44;
const holdingYears = holdingDays / 365.25;
if (holdingDays <= 30) {
return '0-1 Month';
} else if (holdingMonths <= 6) {
return '1-6 Months';
} else if (holdingYears <= 1) {
return '6M-1 Year';
} else if (holdingYears <= 2) {
return '1-2 Years';
} else {
return '2+ Years';
}
}
renderCGTSettingsPage() {
if (!this.cgtSettings) {
this.loadCGTSettings();
return;
}
// Populate form with current settings
document.getElementById('cgt-1month').value = this.cgtSettings.rate_1month;
document.getElementById('cgt-6months').value = this.cgtSettings.rate_6months;
document.getElementById('cgt-1year').value = this.cgtSettings.rate_1year;
document.getElementById('cgt-2years').value = this.cgtSettings.rate_2years;
document.getElementById('cgt-longterm').value = this.cgtSettings.rate_longterm;
document.getElementById('cgt-8years').value = this.cgtSettings.rate_8years;
document.getElementById('cgt-annual-exemption').value = this.cgtSettings.annual_exemption;
document.getElementById('cgt-enabled').checked = this.cgtSettings.enabled;
this.updateCGTPreview();
this.bindCGTEvents();
}
bindCGTEvents() {
const form = document.getElementById('cgt-settings-form');
const resetBtn = document.getElementById('reset-cgt-defaults');
const inputs = document.querySelectorAll('.cgt-rate-input');
// Remove existing listeners to prevent duplicates
form.removeEventListener('submit', this.handleCGTFormSubmit);
resetBtn.removeEventListener('click', this.handleCGTReset);
// Bind form submission
this.handleCGTFormSubmit = async (e) => {
e.preventDefault();
const settings = {
rate_1month: parseFloat(document.getElementById('cgt-1month').value),
rate_6months: parseFloat(document.getElementById('cgt-6months').value),
rate_1year: parseFloat(document.getElementById('cgt-1year').value),
rate_2years: parseFloat(document.getElementById('cgt-2years').value),
rate_longterm: parseFloat(document.getElementById('cgt-longterm').value),
rate_8years: parseFloat(document.getElementById('cgt-8years').value),
annual_exemption: parseFloat(document.getElementById('cgt-annual-exemption').value),
enabled: document.getElementById('cgt-enabled').checked
};
await this.saveCGTSettings(settings);
};
// Bind reset button
this.handleCGTReset = () => {
document.getElementById('cgt-1month').value = 40;
document.getElementById('cgt-6months').value = 35;
document.getElementById('cgt-1year').value = 30;
document.getElementById('cgt-2years').value = 20;
document.getElementById('cgt-longterm').value = 10;
document.getElementById('cgt-8years').value = 33;
document.getElementById('cgt-annual-exemption').value = 1270;
document.getElementById('cgt-enabled').checked = true;
this.updateCGTPreview();
};
form.addEventListener('submit', this.handleCGTFormSubmit);
resetBtn.addEventListener('click', this.handleCGTReset);
// Update preview on input change
inputs.forEach(input => {
input.addEventListener('input', () => this.updateCGTPreview());
});
}
updateCGTPreview() {
const rates = {
'1month': parseFloat(document.getElementById('cgt-1month').value) || 0,
'6months': parseFloat(document.getElementById('cgt-6months').value) || 0,
'1year': parseFloat(document.getElementById('cgt-1year').value) || 0,
'2years': parseFloat(document.getElementById('cgt-2years').value) || 0,
'longterm': parseFloat(document.getElementById('cgt-longterm').value) || 0,
'8years': parseFloat(document.getElementById('cgt-8years').value) || 0
};
Object.keys(rates).forEach(period => {
const rateBar = document.querySelector(`[data-period="${period}"]`);
if (rateBar) {
const rateValue = rateBar.querySelector('.rate-value');
rateValue.textContent = `${rates[period]}%`;
// Update bar height based on rate (max 50% height)
const height = Math.max(10, (rates[period] / 100) * 50);
rateBar.style.setProperty('--rate-height', `${height}%`);
}
});
}
// Cash Accounts Management Methods
async renderCashAccountsPage() {
await this.loadCashAccounts();
await this.loadCashSummary();
await this.renderTransfersSection();
this.bindCashAccountEvents();
}
async loadCashAccounts() {
try {
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
credentials: 'include'
});
if (response.ok) {
const accounts = await response.json();
this.renderAccountsList(accounts);
} else {
this.showNotification('Failed to load cash accounts', 'error');
}
} catch (error) {
console.error('Error loading cash accounts:', error);
this.showNotification('Failed to load cash accounts', 'error');
}
}
async loadCashSummary() {
try {
const response = await fetch(`${this.apiUrl}/cash-summary`, {
credentials: 'include'
});
if (response.ok) {
const summary = await response.json();
this.updateCashSummary(summary);
} else {
console.error('Failed to load cash summary');
}
} catch (error) {
console.error('Error loading cash summary:', error);
}
}
updateCashSummary(summary) {
document.getElementById('total-cash-eur').textContent = `${summary.total_eur.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
document.getElementById('total-cash-usd').textContent = `$${summary.total_usd.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
document.getElementById('account-count').textContent = summary.account_count;
document.getElementById('avg-interest').textContent = `${summary.avg_interest_rate.toFixed(1)}%`;
}
renderAccountsList(accounts) {
const accountsList = document.getElementById('accounts-list');
if (!accounts || accounts.length === 0) {
accountsList.innerHTML = '<p class="no-data">No cash accounts found. Add your first account above.</p>';
return;
}
accountsList.innerHTML = accounts.map(account => {
const createdDate = new Date(account.created_at).toLocaleDateString();
const balance = parseFloat(account.balance);
const currencySymbol = this.getCurrencySymbol(account.currency);
const formattedBalance = `${currencySymbol}${balance.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
const accountTypeMap = {
savings: 'Savings Account',
checking: 'Checking Account',
money_market: 'Money Market',
cd: 'Certificate of Deposit',
other: 'Other'
};
return `
<div class="account-item" data-account-id="${account.id}">
<div class="account-info">
<div class="account-name">${this.escapeHtml(account.account_name)}</div>
<div class="account-type-badge">${accountTypeMap[account.account_type] || 'Other'}</div>
<div class="account-details">
<div class="account-detail">
<span class="account-detail-label">Balance</span>
<span class="account-balance">${formattedBalance}</span>
</div>
${account.institution_name ? `
<div class="account-detail">
<span class="account-detail-label">Institution</span>
<span>${this.escapeHtml(account.institution_name)}</span>
</div>
` : ''}
${account.interest_rate > 0 ? `
<div class="account-detail">
<span class="account-detail-label">Interest Rate</span>
<span>${account.interest_rate}%</span>
</div>
` : ''}
<div class="account-detail">
<span class="account-detail-label">Created</span>
<span>${createdDate}</span>
</div>
${account.notes ? `
<div class="account-detail">
<span class="account-detail-label">Notes</span>
<span>${this.escapeHtml(account.notes)}</span>
</div>
` : ''}
</div>
</div>
<div class="account-actions-buttons">
<button class="account-edit-btn" data-account-id="${account.id}">Edit</button>
<button class="account-delete-btn" data-account-id="${account.id}">Delete</button>
</div>
</div>
`;
}).join('');
}
bindCashAccountEvents() {
const createAccountForm = document.getElementById('create-account-form');
const editAccountForm = document.getElementById('edit-account-form');
const editModal = document.getElementById('edit-account-modal');
const cancelEditBtn = document.getElementById('cancel-edit-account');
// Remove existing listeners
createAccountForm.removeEventListener('submit', this.handleCreateAccount);
editAccountForm.removeEventListener('submit', this.handleEditAccount);
cancelEditBtn.removeEventListener('click', this.handleCancelEdit);
// Create account form
this.handleCreateAccount = async (e) => {
e.preventDefault();
const formData = {
account_name: document.getElementById('account-name').value.trim(),
account_type: document.getElementById('account-type').value,
balance: parseFloat(document.getElementById('account-balance').value) || 0,
currency: document.getElementById('account-currency').value,
institution_name: document.getElementById('institution-name').value.trim(),
interest_rate: parseFloat(document.getElementById('interest-rate').value) || 0,
notes: document.getElementById('account-notes').value.trim()
};
try {
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok) {
createAccountForm.reset();
await this.loadCashAccounts();
await this.loadCashSummary();
this.updateDashboard(); // Refresh dashboard cash data
this.showNotification('Cash account created successfully!', 'success');
} else {
this.showNotification(result.error || 'Failed to create account', 'error');
}
} catch (error) {
console.error('Error creating account:', error);
this.showNotification('Failed to create account', 'error');
}
};
// Edit account form
this.handleEditAccount = async (e) => {
e.preventDefault();
const accountId = editAccountForm.getAttribute('data-account-id');
const formData = {
account_name: document.getElementById('edit-account-name').value.trim(),
account_type: document.getElementById('edit-account-type').value,
balance: parseFloat(document.getElementById('edit-account-balance').value) || 0,
currency: document.getElementById('edit-account-currency').value,
institution_name: document.getElementById('edit-institution-name').value.trim(),
interest_rate: parseFloat(document.getElementById('edit-interest-rate').value) || 0,
notes: document.getElementById('edit-account-notes').value.trim()
};
try {
const response = await fetch(`${this.apiUrl}/cash-accounts/${accountId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok) {
editModal.style.display = 'none';
await this.loadCashAccounts();
await this.loadCashSummary();
this.updateDashboard(); // Refresh dashboard cash data
this.showNotification('Cash account updated successfully!', 'success');
} else {
this.showNotification(result.error || 'Failed to update account', 'error');
}
} catch (error) {
console.error('Error updating account:', error);
this.showNotification('Failed to update account', 'error');
}
};
// Cancel edit
this.handleCancelEdit = () => {
editModal.style.display = 'none';
};
// Bind events
createAccountForm.addEventListener('submit', this.handleCreateAccount);
editAccountForm.addEventListener('submit', this.handleEditAccount);
cancelEditBtn.addEventListener('click', this.handleCancelEdit);
// Bind account action buttons
document.querySelectorAll('.account-delete-btn').forEach(btn => {
btn.addEventListener('click', this.handleDeleteAccount.bind(this));
});
document.querySelectorAll('.account-edit-btn').forEach(btn => {
btn.addEventListener('click', this.handleEditAccountClick.bind(this));
});
// Close modal on backdrop click
editModal.addEventListener('click', (e) => {
if (e.target === editModal) {
this.handleCancelEdit();
}
});
}
async handleDeleteAccount(e) {
const accountId = e.target.getAttribute('data-account-id');
const accountName = e.target.closest('.account-item').querySelector('.account-name').textContent;
if (!confirm(`Are you sure you want to delete the account "${accountName}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`${this.apiUrl}/cash-accounts/${accountId}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
await this.loadCashAccounts();
await this.loadCashSummary();
this.updateDashboard(); // Refresh dashboard cash data
this.showNotification('Cash account deleted successfully', 'success');
} else {
const result = await response.json();
this.showNotification(result.error || 'Failed to delete account', 'error');
}
} catch (error) {
console.error('Error deleting account:', error);
this.showNotification('Failed to delete account', 'error');
}
}
async handleEditAccountClick(e) {
const accountId = e.target.getAttribute('data-account-id');
try {
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
credentials: 'include'
});
if (response.ok) {
const accounts = await response.json();
const account = accounts.find(acc => acc.id == accountId);
if (account) {
// Populate edit form
document.getElementById('edit-account-name').value = account.account_name;
document.getElementById('edit-account-type').value = account.account_type;
document.getElementById('edit-account-balance').value = account.balance;
document.getElementById('edit-account-currency').value = account.currency;
document.getElementById('edit-institution-name').value = account.institution_name || '';
document.getElementById('edit-interest-rate').value = account.interest_rate || '';
document.getElementById('edit-account-notes').value = account.notes || '';
// Set account ID for form submission
document.getElementById('edit-account-form').setAttribute('data-account-id', accountId);
// Show modal
document.getElementById('edit-account-modal').style.display = 'flex';
}
}
} catch (error) {
console.error('Error loading account for edit:', error);
this.showNotification('Failed to load account details', 'error');
}
}
// Cash Transfers Methods
async renderTransfersSection() {
await this.loadTransfers();
this.bindTransferEvents();
}
async loadTransfers() {
try {
const response = await fetch(`${this.apiUrl}/cash-transfers`, {
credentials: 'include'
});
if (response.ok) {
const transfers = await response.json();
this.renderTransfersList(transfers);
} else {
this.showNotification('Failed to load transfers', 'error');
}
} catch (error) {
console.error('Error loading transfers:', error);
this.showNotification('Failed to load transfers', 'error');
}
}
renderTransfersList(transfers) {
const transfersList = document.getElementById('transfers-list');
if (!transfers || transfers.length === 0) {
transfersList.innerHTML = '<p class="no-data">No transfers found</p>';
return;
}
transfersList.innerHTML = transfers.map(transfer => {
const transferDate = new Date(transfer.transfer_date).toLocaleDateString();
const amount = parseFloat(transfer.amount);
const currencySymbol = this.getCurrencySymbol(transfer.currency);
return `
<div class="transfer-item">
<div class="transfer-info">
<div class="transfer-header">
<span class="transfer-account">${transfer.account_name}</span>
<span class="transfer-type ${transfer.transfer_type}">${transfer.transfer_type}</span>
</div>
<div class="transfer-details">
<span class="transfer-date">${transferDate}</span>
${transfer.description ? `<span class="transfer-description">${transfer.description}</span>` : ''}
</div>
</div>
<div class="transfer-amount ${transfer.transfer_type}">
${currencySymbol}${amount.toFixed(2)}
</div>
</div>
`;
}).join('');
}
async populateTransferAccounts() {
try {
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
credentials: 'include'
});
if (response.ok) {
const accounts = await response.json();
const accountSelect = document.getElementById('transfer-account');
// Clear existing options except the first one
accountSelect.innerHTML = '<option value="">Select Account</option>';
// Add active accounts
const activeAccounts = accounts.filter(account => account.is_active);
activeAccounts.forEach(account => {
const currencySymbol = this.getCurrencySymbol(account.currency);
const option = document.createElement('option');
option.value = account.id;
option.textContent = `${account.account_name} (${currencySymbol}${parseFloat(account.balance).toFixed(2)})`;
accountSelect.appendChild(option);
});
}
} catch (error) {
console.error('Error loading accounts for transfer:', error);
}
}
bindTransferEvents() {
const addTransferBtn = document.getElementById('add-transfer-btn');
const transferModal = document.getElementById('transfer-modal');
const transferForm = document.getElementById('transfer-form');
const cancelTransferBtn = document.getElementById('cancel-transfer');
// Remove existing listeners
addTransferBtn.removeEventListener('click', this.handleShowTransferModal);
transferForm.removeEventListener('submit', this.handleCreateTransfer);
cancelTransferBtn.removeEventListener('click', this.handleCancelTransfer);
// Add transfer button
this.handleShowTransferModal = async () => {
await this.populateTransferAccounts();
// Set today's date as default
document.getElementById('transfer-date').value = new Date().toISOString().split('T')[0];
transferModal.style.display = 'flex';
};
// Create transfer form
this.handleCreateTransfer = async (e) => {
e.preventDefault();
const formData = {
account_id: parseInt(document.getElementById('transfer-account').value),
transfer_type: document.getElementById('transfer-type').value,
amount: parseFloat(document.getElementById('transfer-amount').value),
transfer_date: document.getElementById('transfer-date').value,
description: document.getElementById('transfer-description').value.trim()
};
try {
const response = await fetch(`${this.apiUrl}/cash-transfers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok) {
transferModal.style.display = 'none';
transferForm.reset();
await this.loadTransfers();
await this.loadCashAccounts();
await this.loadCashSummary();
this.updateDashboard(); // Refresh dashboard cash data
this.showNotification('Transfer added successfully!', 'success');
} else {
this.showNotification(result.error || 'Failed to add transfer', 'error');
}
} catch (error) {
console.error('Error creating transfer:', error);
this.showNotification('Failed to add transfer', 'error');
}
};
// Cancel transfer
this.handleCancelTransfer = () => {
transferModal.style.display = 'none';
transferForm.reset();
};
// Bind events
addTransferBtn.addEventListener('click', this.handleShowTransferModal);
transferForm.addEventListener('submit', this.handleCreateTransfer);
cancelTransferBtn.addEventListener('click', this.handleCancelTransfer);
// Close modal when clicking outside
transferModal.addEventListener('click', (e) => {
if (e.target === transferModal) {
this.handleCancelTransfer();
}
});
}
// Standalone Add Transfer Page Methods
async renderAddTransferPage() {
await this.populateStandaloneTransferAccounts();
await this.loadStandaloneTransfers();
this.bindStandaloneTransferEvents();
// Set today's date as default
document.getElementById('standalone-transfer-date').value = new Date().toISOString().split('T')[0];
}
async populateStandaloneTransferAccounts() {
try {
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
credentials: 'include'
});
if (response.ok) {
const accounts = await response.json();
const accountSelect = document.getElementById('standalone-transfer-account');
// Clear existing options except the first one
accountSelect.innerHTML = '<option value="">Select Account</option>';
// Add active accounts
const activeAccounts = accounts.filter(account => account.is_active);
activeAccounts.forEach(account => {
const currencySymbol = this.getCurrencySymbol(account.currency);
const option = document.createElement('option');
option.value = account.id;
option.textContent = `${account.account_name} (${currencySymbol}${parseFloat(account.balance).toFixed(2)})`;
accountSelect.appendChild(option);
});
}
} catch (error) {
console.error('Error loading accounts for transfer:', error);
}
}
async loadStandaloneTransfers() {
try {
const response = await fetch(`${this.apiUrl}/cash-transfers`, {
credentials: 'include'
});
if (response.ok) {
const transfers = await response.json();
this.renderStandaloneTransfersList(transfers);
} else {
this.showNotification('Failed to load transfers', 'error');
}
} catch (error) {
console.error('Error loading transfers:', error);
this.showNotification('Failed to load transfers', 'error');
}
}
renderStandaloneTransfersList(transfers) {
const transfersList = document.getElementById('standalone-transfers-list');
if (!transfers || transfers.length === 0) {
transfersList.innerHTML = '<p class="no-data">No transfers found</p>';
return;
}
// Show only the last 10 transfers for the standalone page
const recentTransfers = transfers.slice(0, 10);
transfersList.innerHTML = recentTransfers.map(transfer => {
const transferDate = new Date(transfer.transfer_date).toLocaleDateString();
const amount = parseFloat(transfer.amount);
const currencySymbol = this.getCurrencySymbol(transfer.currency);
return `
<div class="transfer-item">
<div class="transfer-info">
<div class="transfer-header">
<span class="transfer-account">${transfer.account_name}</span>
<span class="transfer-type ${transfer.transfer_type}">${transfer.transfer_type}</span>
</div>
<div class="transfer-details">
<span class="transfer-date">${transferDate}</span>
${transfer.description ? `<span class="transfer-description">${transfer.description}</span>` : ''}
</div>
</div>
<div class="transfer-amount ${transfer.transfer_type}">
${currencySymbol}${amount.toFixed(2)}
</div>
</div>
`;
}).join('');
}
bindStandaloneTransferEvents() {
const transferForm = document.getElementById('standalone-transfer-form');
// Remove existing listener
transferForm.removeEventListener('submit', this.handleStandaloneTransfer);
// Create transfer form handler
this.handleStandaloneTransfer = async (e) => {
e.preventDefault();
const formData = {
account_id: parseInt(document.getElementById('standalone-transfer-account').value),
transfer_type: document.getElementById('standalone-transfer-type').value,
amount: parseFloat(document.getElementById('standalone-transfer-amount').value),
transfer_date: document.getElementById('standalone-transfer-date').value,
description: document.getElementById('standalone-transfer-description').value.trim()
};
try {
const response = await fetch(`${this.apiUrl}/cash-transfers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok) {
transferForm.reset();
// Set today's date as default again
document.getElementById('standalone-transfer-date').value = new Date().toISOString().split('T')[0];
await this.loadStandaloneTransfers();
await this.populateStandaloneTransferAccounts(); // Refresh account balances
this.updateDashboard(); // Refresh dashboard cash data
this.showNotification('Transfer added successfully!', 'success');
} else {
this.showNotification(result.error || 'Failed to add transfer', 'error');
}
} catch (error) {
console.error('Error creating transfer:', error);
this.showNotification('Failed to add transfer', 'error');
}
};
// Bind form submission
transferForm.addEventListener('submit', this.handleStandaloneTransfer);
}
// Token Management Methods
async renderTokensPage() {
await this.loadTokens();
this.bindTokenEvents();
}
async loadTokens() {
try {
const response = await fetch(`${this.apiUrl}/tokens`, {
credentials: 'include'
});
if (response.ok) {
const tokens = await response.json();
this.renderTokensList(tokens);
} else {
this.showNotification('Failed to load tokens', 'error');
}
} catch (error) {
console.error('Error loading tokens:', error);
this.showNotification('Failed to load tokens', 'error');
}
}
renderTokensList(tokens) {
const tokensList = document.getElementById('tokens-list');
if (!tokens || tokens.length === 0) {
tokensList.innerHTML = '<p class="no-data">No access tokens found. Create your first token above.</p>';
return;
}
tokensList.innerHTML = tokens.map(token => {
const createdDate = new Date(token.created_at).toLocaleDateString();
const lastUsed = token.last_used_at ? new Date(token.last_used_at).toLocaleDateString() : 'Never';
const expiresAt = token.expires_at ? new Date(token.expires_at).toLocaleDateString() : 'Never';
const isExpired = token.expires_at && new Date(token.expires_at) < new Date();
return `
<div class="token-item" data-token-id="${token.id}">
<div class="token-info">
<div class="token-name">${this.escapeHtml(token.token_name)}</div>
<div class="token-details">
<div class="token-detail">
<span>Token:</span>
<span class="token-prefix">${token.token_prefix}•••••••</span>
</div>
<div class="token-detail">
<span>Scopes:</span>
<span>${token.scopes}</span>
</div>
<div class="token-detail">
<span>Created:</span>
<span>${createdDate}</span>
</div>
<div class="token-detail">
<span>Last used:</span>
<span>${lastUsed}</span>
</div>
<div class="token-detail">
<span>Expires:</span>
<span>${expiresAt}</span>
</div>
<div class="token-detail">
<span class="token-status ${isExpired ? 'expired' : 'active'}">
${isExpired ? 'Expired' : 'Active'}
</span>
</div>
</div>
</div>
<div class="token-actions-buttons">
<button class="token-edit-btn" data-token-id="${token.id}">Edit</button>
<button class="token-delete-btn" data-token-id="${token.id}">Delete</button>
</div>
</div>
`;
}).join('');
}
bindTokenEvents() {
const createTokenForm = document.getElementById('create-token-form');
const copyTokenBtn = document.getElementById('copy-token-btn');
const closeModalBtn = document.getElementById('close-token-modal');
const tokenModal = document.getElementById('token-modal');
// Remove existing listeners
createTokenForm.removeEventListener('submit', this.handleCreateToken);
copyTokenBtn.removeEventListener('click', this.handleCopyToken);
closeModalBtn.removeEventListener('click', this.handleCloseTokenModal);
// Create token form
this.handleCreateToken = async (e) => {
e.preventDefault();
const formData = {
name: document.getElementById('token-name').value.trim(),
expires_in_days: document.getElementById('token-expires').value || null,
scopes: document.getElementById('token-scopes').value
};
try {
const response = await fetch(`${this.apiUrl}/tokens`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok) {
document.getElementById('new-token-value').value = result.token;
tokenModal.style.display = 'flex';
createTokenForm.reset();
await this.loadTokens();
this.showNotification('Token created successfully!', 'success');
} else {
this.showNotification(result.error || 'Failed to create token', 'error');
}
} catch (error) {
console.error('Error creating token:', error);
this.showNotification('Failed to create token', 'error');
}
};
// Copy token to clipboard
this.handleCopyToken = async () => {
const tokenInput = document.getElementById('new-token-value');
try {
await navigator.clipboard.writeText(tokenInput.value);
copyTokenBtn.textContent = 'Copied!';
copyTokenBtn.classList.add('copied');
setTimeout(() => {
copyTokenBtn.textContent = 'Copy';
copyTokenBtn.classList.remove('copied');
}, 2000);
} catch (error) {
// Fallback for older browsers
tokenInput.select();
document.execCommand('copy');
copyTokenBtn.textContent = 'Copied!';
copyTokenBtn.classList.add('copied');
setTimeout(() => {
copyTokenBtn.textContent = 'Copy';
copyTokenBtn.classList.remove('copied');
}, 2000);
}
};
// Close modal
this.handleCloseTokenModal = () => {
tokenModal.style.display = 'none';
document.getElementById('new-token-value').value = '';
};
// Bind events
createTokenForm.addEventListener('submit', this.handleCreateToken);
copyTokenBtn.addEventListener('click', this.handleCopyToken);
closeModalBtn.addEventListener('click', this.handleCloseTokenModal);
// Bind token action buttons
document.querySelectorAll('.token-delete-btn').forEach(btn => {
btn.addEventListener('click', this.handleDeleteToken.bind(this));
});
document.querySelectorAll('.token-edit-btn').forEach(btn => {
btn.addEventListener('click', this.handleEditToken.bind(this));
});
// Close modal on backdrop click
tokenModal.addEventListener('click', (e) => {
if (e.target === tokenModal) {
this.handleCloseTokenModal();
}
});
}
async handleDeleteToken(e) {
const tokenId = e.target.getAttribute('data-token-id');
const tokenName = e.target.closest('.token-item').querySelector('.token-name').textContent;
if (!confirm(`Are you sure you want to delete the token "${tokenName}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`${this.apiUrl}/tokens/${tokenId}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
await this.loadTokens();
this.showNotification('Token deleted successfully', 'success');
} else {
const result = await response.json();
this.showNotification(result.error || 'Failed to delete token', 'error');
}
} catch (error) {
console.error('Error deleting token:', error);
this.showNotification('Failed to delete token', 'error');
}
}
async handleEditToken(e) {
const tokenId = e.target.getAttribute('data-token-id');
const tokenItem = e.target.closest('.token-item');
const tokenName = tokenItem.querySelector('.token-name').textContent;
const newName = prompt('Enter new token name:', tokenName);
if (!newName || newName.trim() === tokenName) {
return;
}
try {
const response = await fetch(`${this.apiUrl}/tokens/${tokenId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ name: newName.trim() })
});
if (response.ok) {
await this.loadTokens();
this.showNotification('Token updated successfully', 'success');
} else {
const result = await response.json();
this.showNotification(result.error || 'Failed to update token', 'error');
}
} catch (error) {
console.error('Error updating token:', error);
this.showNotification('Failed to update token', 'error');
}
}
async showPriceHistory(symbol) {
try {
const history = await this.loadPriceHistory(symbol);
if (history.length === 0) {
this.showNotification(`No price history found for ${symbol}`, 'info');
return;
}
// Create modal for price history
this.createPriceHistoryModal(symbol, history);
} catch (error) {
console.error('Error showing price history:', error);
this.showNotification('Failed to load price history', 'error');
}
}
createPriceHistoryModal(symbol, history) {
// Remove existing modal if any
const existingModal = document.getElementById('price-history-modal');
if (existingModal) {
existingModal.remove();
}
const currencySymbol = this.getCurrencySymbol(history[0]?.currency);
const modal = document.createElement('div');
modal.id = 'price-history-modal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h3>Price History - ${symbol}</h3>
<span class="close-modal">&times;</span>
</div>
<div class="modal-body">
<div class="price-history-list">
${history.map(entry => `
<div class="price-history-entry">
<span class="history-price">${currencySymbol}${parseFloat(entry.price).toFixed(2)}</span>
<span class="history-date">${new Date(entry.updatedAt).toLocaleString()}</span>
</div>
`).join('')}
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
// Close modal events
const closeBtn = modal.querySelector('.close-modal');
closeBtn.addEventListener('click', () => this.closePriceHistoryModal());
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.closePriceHistoryModal();
}
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closePriceHistoryModal();
}
});
}
closePriceHistoryModal() {
const modal = document.getElementById('price-history-modal');
if (modal) {
modal.remove();
}
}
getCurrencySymbol(currency) {
switch(currency) {
case 'EUR': return '€';
case 'USD': return '$';
case 'GBP': return '£';
default: return currency;
}
}
getMajorityCurrency() {
const currencyValues = {
EUR: 0,
USD: 0,
GBP: 0
};
// Calculate total value by currency from ETF positions
const etfMap = this.getActiveETFPositions();
etfMap.forEach((etf, symbol) => {
const currentPrice = this.currentPrices.get(symbol);
const value = currentPrice ? etf.shares * currentPrice : etf.totalValue;
currencyValues[etf.currency] = (currencyValues[etf.currency] || 0) + value;
});
// Add cash accounts to the calculation
if (this.cashSummary && this.cashSummary.accounts) {
this.cashSummary.accounts.forEach(account => {
if (account.is_active && account.balance > 0) {
currencyValues[account.currency] = (currencyValues[account.currency] || 0) + parseFloat(account.balance);
}
});
}
// Find the currency with the highest value
let majorityCurrency = 'EUR'; // default
let maxValue = 0;
for (const [currency, value] of Object.entries(currencyValues)) {
if (value > maxValue) {
maxValue = value;
majorityCurrency = currency;
}
}
return majorityCurrency;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 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();