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) {
|
if (isAuthenticated) {
|
||||||
await this.loadTrades();
|
await this.loadTrades();
|
||||||
await this.loadCGTSettings();
|
await this.loadCGTSettings();
|
||||||
|
await this.loadLatestPrices();
|
||||||
this.renderTrades();
|
this.renderTrades();
|
||||||
this.updateDashboard();
|
this.updateDashboard();
|
||||||
this.showPage('dashboard');
|
this.showPage('dashboard');
|
||||||
@ -1102,6 +1103,7 @@ class ETFTradeTracker {
|
|||||||
class="current-price-input"
|
class="current-price-input"
|
||||||
data-symbol="${symbol}">
|
data-symbol="${symbol}">
|
||||||
<button class="update-price-btn" data-symbol="${symbol}">Update</button>
|
<button class="update-price-btn" data-symbol="${symbol}">Update</button>
|
||||||
|
<button class="view-history-btn" data-symbol="${symbol}">History</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="price-info">
|
<div class="price-info">
|
||||||
<span>Avg Cost: ${currencySymbol}${etf.avgPrice.toFixed(2)}</span>
|
<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
|
// Update on Enter key
|
||||||
document.querySelectorAll('.current-price-input').forEach(input => {
|
document.querySelectorAll('.current-price-input').forEach(input => {
|
||||||
input.addEventListener('keypress', (e) => {
|
input.addEventListener('keypress', (e) => {
|
||||||
@ -1178,12 +1188,89 @@ class ETFTradeTracker {
|
|||||||
return activePositions;
|
return activePositions;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateETFPrice(symbol, currentPrice) {
|
async updateETFPrice(symbol, currentPrice) {
|
||||||
this.currentPrices.set(symbol, currentPrice);
|
// Get currency for this ETF from trades
|
||||||
this.calculateAndDisplayPerformance();
|
const etfMap = this.getActiveETFPositions();
|
||||||
this.updatePriceChangeIndicator(symbol, currentPrice);
|
const etf = etfMap.get(symbol);
|
||||||
this.updateDashboard(); // Update dashboard when prices change
|
const currency = etf ? etf.currency : 'USD';
|
||||||
this.showNotification(`Updated ${symbol} price to ${currentPrice}`, 'success');
|
|
||||||
|
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) {
|
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) {
|
escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
|
|||||||
122
server.js
122
server.js
@ -222,6 +222,27 @@ function initializeDatabase() {
|
|||||||
console.log('Cash transfers table ready');
|
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() {
|
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) => {
|
app.get('/', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'index.html'));
|
res.sendFile(path.join(__dirname, 'index.html'));
|
||||||
});
|
});
|
||||||
|
|||||||
103
styles.css
103
styles.css
@ -2777,4 +2777,107 @@ body {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
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