From 41191432831c6de60b86ce8a01f405a41a5d681e Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 1 Sep 2025 15:42:00 +0000 Subject: [PATCH] Add historical price tracking with persistent storage and UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create price_history table for storing ETF price updates over time - Add API endpoints for saving/retrieving price history per user - Auto-load latest prices on app initialization for continuity - Add "History" button to view price update timeline in modal - Enhance price update notifications to confirm database saves - Implement responsive modal design for price history viewing - Maintain user data isolation for all price history features 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- script.js | 176 +++++++++++++++++++++++++++++++++++++++++++++++++++-- server.js | 122 +++++++++++++++++++++++++++++++++++++ styles.css | 103 +++++++++++++++++++++++++++++++ 3 files changed, 395 insertions(+), 6 deletions(-) diff --git a/script.js b/script.js index 9f4dab9..0964b71 100644 --- a/script.js +++ b/script.js @@ -19,6 +19,7 @@ class ETFTradeTracker { if (isAuthenticated) { await this.loadTrades(); await this.loadCGTSettings(); + await this.loadLatestPrices(); this.renderTrades(); this.updateDashboard(); this.showPage('dashboard'); @@ -1102,6 +1103,7 @@ class ETFTradeTracker { class="current-price-input" data-symbol="${symbol}"> +
Avg Cost: ${currencySymbol}${etf.avgPrice.toFixed(2)} @@ -1128,6 +1130,14 @@ class ETFTradeTracker { }); }); + // 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) => { @@ -1178,12 +1188,89 @@ class ETFTradeTracker { return activePositions; } - updateETFPrice(symbol, currentPrice) { - this.currentPrices.set(symbol, currentPrice); - this.calculateAndDisplayPerformance(); - this.updatePriceChangeIndicator(symbol, currentPrice); - this.updateDashboard(); // Update dashboard when prices change - this.showNotification(`Updated ${symbol} price to ${currentPrice}`, 'success'); + 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) { @@ -2692,6 +2779,83 @@ class ETFTradeTracker { } } + 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 = history[0]?.currency === 'EUR' ? '€' : '$'; + + const modal = document.createElement('div'); + modal.id = 'price-history-modal'; + modal.className = 'modal'; + modal.innerHTML = ` + + `; + + 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(); + } + } + escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; diff --git a/server.js b/server.js index 81abf1d..7f41229 100644 --- a/server.js +++ b/server.js @@ -222,6 +222,27 @@ function initializeDatabase() { console.log('Cash transfers table ready'); } }); + + // Create price history table + const createPriceHistoryTableSQL = ` + CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + etf_symbol TEXT NOT NULL, + price REAL NOT NULL CHECK (price > 0), + currency TEXT NOT NULL CHECK (currency IN ('EUR', 'USD')), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + `; + + db.run(createPriceHistoryTableSQL, (err) => { + if (err) { + console.error('Error creating price history table:', err.message); + } else { + console.log('Price history table ready'); + } + }); } async function createDefaultAdmin() { @@ -1199,6 +1220,107 @@ app.get('/api/portfolio-summary', requireAuthOrToken, (req, res) => { }); }); +// Save price update to history +app.post('/api/price-history', requireAuthOrToken, (req, res) => { + const { etf_symbol, price, currency } = req.body; + + if (!etf_symbol || !price || !currency) { + return res.status(400).json({ error: 'Missing required fields: etf_symbol, price, currency' }); + } + + if (price <= 0) { + return res.status(400).json({ error: 'Price must be greater than 0' }); + } + + if (!['EUR', 'USD'].includes(currency)) { + return res.status(400).json({ error: 'Currency must be EUR or USD' }); + } + + const sql = ` + INSERT INTO price_history (user_id, etf_symbol, price, currency) + VALUES (?, ?, ?, ?) + `; + + db.run(sql, [req.session.userId, etf_symbol, price, currency], function(err) { + if (err) { + console.error('Error saving price history:', err.message); + res.status(500).json({ error: 'Failed to save price history' }); + return; + } + + res.json({ + id: this.lastID, + etf_symbol, + price, + currency, + updated_at: new Date().toISOString() + }); + }); +}); + +// Get price history for a specific ETF +app.get('/api/price-history/:symbol', requireAuthOrToken, (req, res) => { + const { symbol } = req.params; + const { limit = 50 } = req.query; + + const sql = ` + SELECT etf_symbol, price, currency, updated_at + FROM price_history + WHERE user_id = ? AND etf_symbol = ? + ORDER BY updated_at DESC + LIMIT ? + `; + + db.all(sql, [req.session.userId, symbol, parseInt(limit)], (err, rows) => { + if (err) { + console.error('Error fetching price history:', err.message); + res.status(500).json({ error: 'Failed to fetch price history' }); + return; + } + + const history = rows.map(row => ({ + symbol: row.etf_symbol, + price: row.price, + currency: row.currency, + updatedAt: row.updated_at + })); + + res.json(history); + }); +}); + +// Get latest prices for all ETFs +app.get('/api/latest-prices', requireAuthOrToken, (req, res) => { + const sql = ` + SELECT DISTINCT etf_symbol, + FIRST_VALUE(price) OVER (PARTITION BY etf_symbol ORDER BY updated_at DESC) as latest_price, + FIRST_VALUE(currency) OVER (PARTITION BY etf_symbol ORDER BY updated_at DESC) as currency, + FIRST_VALUE(updated_at) OVER (PARTITION BY etf_symbol ORDER BY updated_at DESC) as updated_at + FROM price_history + WHERE user_id = ? + ORDER BY etf_symbol + `; + + db.all(sql, [req.session.userId], (err, rows) => { + if (err) { + console.error('Error fetching latest prices:', err.message); + res.status(500).json({ error: 'Failed to fetch latest prices' }); + return; + } + + const prices = {}; + rows.forEach(row => { + prices[row.etf_symbol] = { + price: row.latest_price, + currency: row.currency, + updatedAt: row.updated_at + }; + }); + + res.json(prices); + }); +}); + app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'index.html')); }); diff --git a/styles.css b/styles.css index 939ea05..9a45d09 100644 --- a/styles.css +++ b/styles.css @@ -2777,4 +2777,107 @@ body { align-items: flex-start; gap: 8px; } +} + +/* Price History Styles */ +.view-history-btn { + background: #007bff; + color: white; + border: none; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 0.85em; + margin-left: 8px; + transition: background-color 0.2s; +} + +.view-history-btn:hover { + background: #0056b3; +} + +.price-history-list { + max-height: 400px; + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 8px; + padding: 16px; +} + +.price-history-entry { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + border-bottom: 1px solid #eee; + transition: background-color 0.2s; +} + +.price-history-entry:last-child { + border-bottom: none; +} + +.price-history-entry:hover { + background-color: #f8f9fa; +} + +.history-price { + font-weight: 600; + font-size: 1.1em; + color: #007bff; +} + +.history-date { + color: #666; + font-size: 0.9em; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + border-bottom: 1px solid #eee; + padding-bottom: 16px; +} + +.modal-header h3 { + margin: 0; + color: #333; + font-size: 1.4em; +} + +.close-modal { + font-size: 28px; + font-weight: bold; + color: #aaa; + cursor: pointer; + line-height: 1; +} + +.close-modal:hover { + color: #333; +} + +@media (max-width: 768px) { + .price-update-controls { + flex-direction: column; + gap: 8px; + } + + .view-history-btn { + margin-left: 0; + width: 100%; + } + + .modal-content { + padding: 20px; + width: 95%; + } + + .price-history-entry { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } } \ No newline at end of file