Files
Personal-Finance-Management/app/templates/investments/index.html
T
2026-06-05 17:50:46 -04:00

552 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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>
<form method="POST" action="{{ url_for('investments.sync_schwab') }}" 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="Pull latest holdings and balances from Schwab">
<i class="bi bi-bank2 me-1"></i>Sync Schwab
</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>
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="{{ url_for('investments.new') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Add Holding
</a>
<form method="POST" action="{{ url_for('investments.sync_schwab') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-bank2 me-1"></i>Sync from Schwab
</button>
</form>
</div>
</div>
{% else %}
<!-- Price alerts banner (populated by AJAX) -->
<div id="price-alert-banner" style="display:none;background:#fef3c7;border:1px solid #fcd34d;color:#78350f;font-size:13px;border-radius:8px;padding:10px 14px;" class="d-flex align-items-start gap-2 mb-3">
<i class="bi bi-graph-up-arrow flex-shrink-0 mt-1" style="color:#d97706;"></i>
<div class="flex-grow-1" id="price-alert-text"></div>
<button type="button" onclick="document.getElementById('price-alert-banner').style.display='none';"
style="background:none;border:none;color:#92400e;cursor:pointer;font-size:16px;line-height:1;padding:0 4px;" title="Dismiss">×</button>
</div>
<!-- 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>
{# ── Reusable holdings table macro ──────────────────────────────────────── #}
{% macro holdings_table(inv_list) %}
<div class="table-wrap">
<table class="pfm-table wide">
<thead>
<tr>
<th style="padding-left:20px;">Asset</th>
<th class="text-end">Price</th>
<th class="text-end d-mob-none">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 inv_list %}
<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 d-mob-none" 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>
{% 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>
{% endmacro %}
<!-- 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>
{# Per-account balance breakdown #}
{% if portfolio.account_groups %}
<div class="mt-3 pt-2" style="border-top:1px solid var(--border);">
<div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;">By Account</div>
{% for grp in portfolio.account_groups %}
<div class="d-flex justify-content-between py-1">
<span style="font-size:12px;">{{ grp.account.name if grp.account else 'Unlinked' }}</span>
<span class="mono text-invest" style="font-size:12px;font-weight:600;">{{ grp.total_value | currency }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<!-- Holdings: one section per account when multi-account, single table otherwise -->
<div class="col-12 col-lg-8">
{% if portfolio.account_groups %}
{% for grp in portfolio.account_groups %}
<div class="pcard p-0 {% if not loop.last %}mb-3{% endif %}">
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
<div class="d-flex align-items-center gap-3">
<span class="pcard-title mb-0">{{ grp.account.name if grp.account else 'Unlinked Holdings' }}</span>
<span class="mono text-invest" style="font-size:14px;font-weight:700;">{{ grp.total_value | currency }}</span>
</div>
<span class="text-muted" style="font-size:11px;">{{ grp.investments | length }} holding(s)</span>
</div>
{{ holdings_table(grp.investments) }}
</div>
{% endfor %}
{% else %}
<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>
{{ holdings_table(portfolio.investments) }}
</div>
{% endif %}
</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;
}
})();
// ── Price alert banner ────────────────────────────────────────────────────────
(function () {
fetch('{{ url_for("investments.api_price_alerts") }}')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data || !data.alerts || !data.alerts.length) return;
const sym = '{{ current_user.currency_symbol or "$" }}';
const parts = data.alerts.map(a => {
const sign = a.day_change_pct >= 0 ? '+' : '';
const color = a.day_change_pct >= 0 ? '#065f46' : '#991b1b';
return `<strong style="color:${color};">${a.ticker} (${sign}${a.day_change_pct}%)</strong>`;
});
const banner = document.getElementById('price-alert-banner');
const textEl = document.getElementById('price-alert-text');
const label = data.alerts.length === 1 ? 'holding moved' : 'holdings moved';
textEl.innerHTML = `<strong>${data.alerts.length} ${label} ≥5% today:</strong> ${parts.join(', ')}`;
banner.style.display = '';
})
.catch(() => {});
})();
</script>
{% endif %}
{% endblock %}