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>
3896 lines
156 KiB
JavaScript
3896 lines
156 KiB
JavaScript
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 (<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">×</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(); |