Add historical price tracking with persistent storage and UI

- 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 <noreply@anthropic.com>
This commit is contained in:
kris 2025-09-01 15:42:00 +00:00
parent f10332d9f5
commit 4119143283
3 changed files with 395 additions and 6 deletions

176
script.js
View File

@ -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}">
<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>
@ -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 = `
<div class="modal-content">
<div class="modal-header">
<h3>Price History - ${symbol}</h3>
<span class="close-modal">&times;</span>
</div>
<div class="modal-body">
<div class="price-history-list">
${history.map(entry => `
<div class="price-history-entry">
<span class="history-price">${currencySymbol}${parseFloat(entry.price).toFixed(2)}</span>
<span class="history-date">${new Date(entry.updatedAt).toLocaleString()}</span>
</div>
`).join('')}
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
// Close modal events
const closeBtn = modal.querySelector('.close-modal');
closeBtn.addEventListener('click', () => this.closePriceHistoryModal());
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.closePriceHistoryModal();
}
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closePriceHistoryModal();
}
});
}
closePriceHistoryModal() {
const modal = document.getElementById('price-history-modal');
if (modal) {
modal.remove();
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;

122
server.js
View File

@ -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'));
});

View File

@ -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;
}
}