Files
Personal-Finance-Management/app/templates/investments/index.html
T
2026-06-01 17:34:33 -04:00

480 lines
21 KiB
HTML

{% extends "base.html" %}
{% block title %}Investments{% endblock %}
{% block page_title %}Investment Portfolio{% endblock %}
{% block topbar_actions %}
<form method="POST" action="{{ url_for('investments.refresh_prices') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-secondary me-1" style="font-size:12px;" title="Refresh prices for all tickers">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh Prices
</button>
</form>
<a href="{{ url_for('investments.new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Add Holding</a>
{% endblock %}
{% block extra_css %}
<style>
.chg-badge {
display: inline-flex; align-items: center; gap: 3px;
font-size: 11px; font-weight: 600; font-family: 'DM Mono', monospace;
padding: 2px 7px; border-radius: 4px; white-space: nowrap;
}
.chg-up { background: #d1fae5; color: #065f46; }
.chg-down { background: #fee2e2; color: #991b1b; }
.chg-flat { background: #f1f5f9; color: #64748b; }
.chg-loading { background: #f1f5f9; color: #94a3b8; animation: pulse .8s ease infinite alternate; }
@keyframes pulse { from { opacity: .5; } to { opacity: 1; } }
.expand-btn {
background: none; border: none; padding: 3px 6px;
color: var(--muted); border-radius: 4px; cursor: pointer;
transition: all .15s; line-height: 1;
}
.expand-btn:hover { background: var(--border); color: var(--text); }
.expand-btn.open { color: var(--accent); }
.chart-row td { padding: 0 !important; border-bottom: 2px solid var(--accent) !important; }
.chart-panel {
padding: 16px 20px;
background: linear-gradient(180deg, #f8faff 0%, #ffffff 100%);
}
.tf-btn {
font-size: 11px; font-weight: 600; padding: 3px 10px;
border-radius: 4px; border: 1px solid var(--border);
background: #fff; color: var(--muted); cursor: pointer;
transition: all .15s;
}
.tf-btn:hover { border-color: var(--accent); color: var(--accent); }
.tf-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.chart-wrap { position: relative; height: 180px; margin-top: 12px; }
.chart-meta { font-size: 12px; color: var(--muted); }
</style>
{% endblock %}
{% block content %}
{% if portfolio.count == 0 %}
<div class="pcard text-center py-5">
<i class="bi bi-graph-up-arrow text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No investments yet</h5>
<p class="text-muted small mb-3">Track stocks, ETFs, crypto, real estate, and more.</p>
<a href="{{ url_for('investments.new') }}" class="btn btn-primary btn-sm">Add First Holding</a>
</div>
{% else %}
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Total Value</div>
<div class="stat-value text-invest">{{ portfolio.total_value | currency }}</div>
</div>
<div class="stat-icon" style="background:#dbeafe;color:#1e40af;"><i class="bi bi-graph-up"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Total Cost</div>
<div class="stat-value">{{ portfolio.total_cost | currency }}</div>
</div>
<div class="stat-icon" style="background:#f1f5f9;color:#64748b;"><i class="bi bi-cash-stack"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Unrealized P&amp;L</div>
<div class="stat-value {% if portfolio.total_gain >= 0 %}text-income{% else %}text-expense{% endif %}">
{{ portfolio.total_gain | currency }}
</div>
</div>
<div class="stat-icon" style="background:{% if portfolio.total_gain >= 0 %}#d1fae5{% else %}#fee2e2{% endif %};color:{% if portfolio.total_gain >= 0 %}#065f46{% else %}#991b1b{% endif %};">
<i class="bi bi-{% if portfolio.total_gain >= 0 %}trending-up{% else %}trending-down{% endif %}"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Return</div>
<div class="stat-value {% if portfolio.total_gain_pct >= 0 %}text-income{% else %}text-expense{% endif %}">
{% if portfolio.total_gain_pct >= 0 %}+{% endif %}{{ portfolio.total_gain_pct }}%
</div>
</div>
<div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-percent"></i></div>
</div>
</div>
</div>
</div>
<!-- Chart + Allocation -->
<div class="row g-3 mb-4">
<div class="col-12 col-lg-4">
<div class="pcard h-100">
<div class="pcard-title mb-3">Allocation</div>
<div style="position:relative;height:200px;display:flex;align-items:center;justify-content:center;">
<canvas id="allocChart"></canvas>
</div>
<div class="mt-3">
{% for item in portfolio.allocation %}
<div class="d-flex justify-content-between align-items-center py-1" style="border-bottom:1px solid var(--border);">
<div class="d-flex align-items-center gap-2">
<div style="width:10px;height:10px;border-radius:2px;background:{{ item.color }};flex-shrink:0;"></div>
<span style="font-size:13px;">{{ item.type | replace('_',' ') | title }}</span>
</div>
<div class="d-flex gap-3">
<span class="mono text-muted" style="font-size:12px;">{{ item.value | currency }}</span>
<span class="mono" style="font-size:12px;width:40px;text-align:right;">{{ item.pct }}%</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Holdings table -->
<div class="col-12 col-lg-8">
<div class="pcard p-0 h-100">
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">Holdings</span>
<span class="text-muted" style="font-size:11px;">Click <i class="bi bi-bar-chart-line"></i> to expand price chart</span>
</div>
<table class="pfm-table" id="holdingsTable">
<thead>
<tr>
<th style="padding-left:20px;">Asset</th>
<th class="text-end">Price</th>
<th class="text-end">1D Chg</th>
<th class="text-end">Value</th>
<th class="text-end" style="padding-right:8px;">P&amp;L</th>
<th style="width:36px;"></th>
</tr>
</thead>
<tbody>
{% for inv in portfolio.investments %}
{# Main holding row #}
<tr id="row-{{ inv.id }}" style="cursor:pointer;"
onclick="location.href='{{ url_for('investments.detail', id=inv.id) }}'">
<td style="padding-left:20px;">
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:7px;background:{{ asset_colors.get(inv.asset_type,'#94a3b8') }}22;color:{{ asset_colors.get(inv.asset_type,'#94a3b8') }};display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">
{{ (inv.ticker or inv.asset_name[:2]).upper()[:2] }}
</div>
<div>
<div style="font-size:13px;font-weight:500;">{{ inv.asset_name }}</div>
<div style="font-size:11px;color:var(--muted);">
{% if inv.ticker %}<span class="mono">{{ inv.ticker }}</span> · {% endif %}
{{ asset_labels.get(inv.asset_type, inv.asset_type) }}
</div>
</div>
</div>
</td>
<td class="text-end">
{% if inv.current_price %}
<span class="mono" style="font-size:12px;">{{ inv.current_price | currency }}</span>
{% else %}
<span class="text-muted" style="font-size:12px;"></span>
{% endif %}
</td>
<td class="text-end" id="chg-{{ inv.id }}">
{% if inv.ticker %}
<span class="chg-badge chg-loading"></span>
{% else %}
<span class="text-muted" style="font-size:12px;"></span>
{% endif %}
</td>
<td class="text-end mono" style="font-size:13px;font-weight:600;">{{ inv.current_value | currency }}</td>
<td class="text-end" style="padding-right:8px;">
<div class="mono {% if inv.unrealized_gain >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:12px;font-weight:600;">
{% if inv.unrealized_gain >= 0 %}+{% endif %}{{ inv.unrealized_gain | currency }}
</div>
<div style="font-size:10px;color:var(--muted);">
{% if inv.unrealized_gain_pct >= 0 %}+{% endif %}{{ inv.unrealized_gain_pct }}%
</div>
</td>
<td onclick="event.stopPropagation();" style="padding:0 8px;">
{% if inv.ticker %}
<button class="expand-btn" id="expand-{{ inv.id }}"
onclick="toggleChart({{ inv.id }}, '{{ inv.ticker }}', '{{ asset_colors.get(inv.asset_type,'#3b82f6') }}')"
title="Show price chart">
<i class="bi bi-bar-chart-line" style="font-size:14px;"></i>
</button>
{% endif %}
</td>
</tr>
{# Hidden chart row for this holding #}
{% if inv.ticker %}
<tr class="chart-row" id="chart-row-{{ inv.id }}" style="display:none;">
<td colspan="6">
<div class="chart-panel" id="chart-panel-{{ inv.id }}">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span style="font-size:13px;font-weight:600;color:var(--text);">{{ inv.asset_name }}</span>
<span class="mono text-muted" style="font-size:11px;">{{ inv.ticker }}</span>
<span id="chart-chg-{{ inv.id }}" class="chg-badge chg-flat" style="display:none;"></span>
<div class="ms-auto d-flex gap-1 flex-wrap">
{% for tf in ['1W','1M','3M','6M','1Y'] %}
<button class="tf-btn{% if tf == '1M' %} active{% endif %}"
onclick="loadChart({{ inv.id }}, '{{ inv.ticker }}', '{{ tf }}', '{{ asset_colors.get(inv.asset_type,'#3b82f6') }}')">
{{ tf }}
</button>
{% endfor %}
</div>
</div>
<div class="chart-meta mt-1" id="chart-info-{{ inv.id }}">Loading…</div>
<div class="chart-wrap">
<canvas id="canvas-{{ inv.id }}"></canvas>
</div>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
{% if portfolio.count > 0 %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function(){
'use strict';
// ── Allocation doughnut ────────────────────────────────────────────────────
(function(){
const ctx = document.getElementById('allocChart').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: {{ chart_labels | tojson }},
datasets: [{
data: {{ chart_values | tojson }},
backgroundColor: {{ chart_colors | tojson }},
borderWidth: 2, borderColor: '#ffffff', hoverOffset: 6,
}]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '68%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ' {{ current_user.currency_symbol }}' +
ctx.parsed.toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})
}
}
}
}
});
})();
// ── Per-holding state ─────────────────────────────────────────────────────
const chartInstances = {}; // inv_id → Chart instance
const loadedTf = {}; // inv_id → last loaded timeframe
// ── Helpers ───────────────────────────────────────────────────────────────
const sym = '{{ current_user.currency_symbol }}';
function fmtPrice(v) {
return sym + parseFloat(v).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:4});
}
function fmtChg(change, pct) {
const sign = change >= 0 ? '+' : '';
return `${sign}${fmtPrice(change)} (${sign}${parseFloat(pct).toFixed(2)}%)`;
}
function chgClass(v) {
return v > 0 ? 'chg-up' : v < 0 ? 'chg-down' : 'chg-flat';
}
function chgIcon(v) {
return v > 0 ? 'bi-arrow-up-short' : v < 0 ? 'bi-arrow-down-short' : 'bi-dash';
}
function setBadge(el, change, pct) {
el.className = 'chg-badge ' + chgClass(change);
el.innerHTML = `<i class="bi ${chgIcon(change)}"></i>${fmtChg(change, pct)}`;
el.style.display = '';
}
// ── Load 1D change badges for all tickers ─────────────────────────────────
const tickerRows = [
{% for inv in portfolio.investments %}{% if inv.ticker %}
{ id: {{ inv.id }}, ticker: '{{ inv.ticker }}' },
{% endif %}{% endfor %}
];
// Fetch today's open→current day change for every ticker (parallel)
// Uses the lightweight /api/daychange/ endpoint (meta only, no history)
Promise.all(
tickerRows.map(r =>
fetch(`{{ url_for('investments.api_day_change', ticker='__T__') }}`.replace('__T__', r.ticker))
.then(res => res.ok ? res.json() : null)
.then(data => ({ id: r.id, data }))
.catch(() => ({ id: r.id, data: null }))
)
).then(results => {
results.forEach(({ id, data }) => {
const cell = document.getElementById('chg-' + id);
if (!cell) return;
if (data && data.day_change !== undefined) {
const badge = cell.querySelector('.chg-badge') || cell;
badge.className = 'chg-badge ' + chgClass(data.day_change);
badge.title = `Today: open ${fmtPrice(data.open)} → current ${fmtPrice(data.current)}`;
const sign = data.day_change >= 0 ? '+' : '';
badge.innerHTML =
`<i class="bi ${chgIcon(data.day_change)}"></i>` +
`${sign}${parseFloat(data.day_change_pct).toFixed(2)}%` +
`<span style="font-size:10px;opacity:.7;margin-left:3px;">${sign}${fmtPrice(data.day_change)}</span>`;
} else {
const badge = cell.querySelector('.chg-badge');
if (badge) {
badge.className = 'chg-badge chg-flat';
badge.title = 'Price data unavailable — check System Logs';
badge.textContent = '—';
}
}
});
});
// ── Toggle chart row ──────────────────────────────────────────────────────
window.toggleChart = function(id, ticker, color) {
const row = document.getElementById('chart-row-' + id);
const btn = document.getElementById('expand-' + id);
const isOpen = row.style.display !== 'none';
if (isOpen) {
row.style.display = 'none';
btn.classList.remove('open');
} else {
row.style.display = '';
btn.classList.add('open');
// Load default timeframe on first open
if (!loadedTf[id]) {
loadChart(id, ticker, '1M', color);
}
}
};
// ── Load / refresh chart for a holding ────────────────────────────────────
window.loadChart = function(id, ticker, tf, color) {
// Update active timeframe button
const panel = document.getElementById('chart-panel-' + id);
panel.querySelectorAll('.tf-btn').forEach(b => {
b.classList.toggle('active', b.textContent.trim() === tf);
});
const infoEl = document.getElementById('chart-info-' + id);
infoEl.textContent = 'Loading…';
const url = `{{ url_for('investments.api_price_history', ticker='__T__') }}`
.replace('__T__', ticker) + '?tf=' + tf;
fetch(url)
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(data => renderChart(id, data, color, tf))
.catch(err => {
infoEl.textContent = 'Could not load price data.';
});
};
function renderChart(id, data, color, tf) {
const infoEl = document.getElementById('chart-info-' + id);
const chgEl = document.getElementById('chart-chg-' + id);
// Update period change badge in chart header
setBadge(chgEl, data.period_change, data.period_change_pct);
// Info line
const tfLabel = { '1W':'1 Week','1M':'1 Month','3M':'3 Months','6M':'6 Months','1Y':'1 Year' }[tf] || tf;
const dayChgHtml = data.day_change !== undefined
? `Today (open→now): <strong class="${data.day_change >= 0 ? 'text-income' : 'text-expense'}">${
fmtChg(data.day_change, data.day_change_pct)
}</strong> &nbsp;(open ${fmtPrice(data.open_price)}) &nbsp;·&nbsp; `
: '';
infoEl.innerHTML =
`Current: <strong>${fmtPrice(data.current)}</strong> &nbsp;·&nbsp; ` +
dayChgHtml +
`${tfLabel}: <strong class="${data.period_change >= 0 ? 'text-income' : 'text-expense'}">${
fmtChg(data.period_change, data.period_change_pct)
}</strong>`;
const lineColor = data.period_change >= 0 ? '#10b981' : '#ef4444';
const fillColor = data.period_change >= 0 ? '#10b98118' : '#ef444418';
const canvas = document.getElementById('canvas-' + id);
const ctx = canvas.getContext('2d');
// Destroy previous chart if exists
if (chartInstances[id]) {
chartInstances[id].destroy();
}
chartInstances[id] = new Chart(ctx, {
type: 'line',
data: {
labels: data.dates,
datasets: [{
label: data.ticker,
data: data.closes,
borderColor: lineColor,
backgroundColor: fillColor,
borderWidth: 2,
pointRadius: data.closes.length > 60 ? 0 : 2,
pointHoverRadius: 4,
tension: 0.3,
fill: true,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: c => ' ' + fmtPrice(c.parsed.y),
}
}
},
scales: {
x: {
grid: { display: false },
ticks: {
font: { size: 10 },
maxTicksLimit: 8,
maxRotation: 0,
}
},
y: {
position: 'right',
grid: { color: '#f1f5f9' },
ticks: {
font: { size: 10 },
callback: v => sym + parseFloat(v).toLocaleString(undefined, {minimumFractionDigits:2}),
}
}
}
}
});
loadedTf[id] = tf;
}
})();
</script>
{% endif %}
{% endblock %}