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:
parent
f10332d9f5
commit
4119143283
176
script.js
176
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}">
|
||||
<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">×</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
122
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'));
|
||||
});
|
||||
|
||||
103
styles.css
103
styles.css
@ -2778,3 +2778,106 @@ body {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user