06/05 Optimize app: report upgrades

This commit is contained in:
2026-06-05 14:22:19 -04:00
parent 2f62417c9f
commit 04acefd9f8
6 changed files with 486 additions and 25 deletions
+15
View File
@@ -10,6 +10,9 @@ from app.services.ai_service import get_latest_daily_insight
from app.services.account_service import get_total_assets, get_total_liabilities from app.services.account_service import get_total_assets, get_total_liabilities
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import calendar import calendar
import logging
log = logging.getLogger('app.dashboard')
dashboard_bp = Blueprint('dashboard', __name__) dashboard_bp = Blueprint('dashboard', __name__)
@@ -250,6 +253,18 @@ def reconcile_api():
}) })
@dashboard_bp.route('/api/anomalies')
@login_required
def anomalies_api():
from app.services.report_service import spending_anomalies
try:
items = spending_anomalies()
except Exception as e:
log.warning('[dashboard] anomalies_api failed: %s', e)
items = []
return jsonify({'anomalies': items})
@dashboard_bp.route('/api/fx-history') @dashboard_bp.route('/api/fx-history')
@login_required @login_required
def fx_history_api(): def fx_history_api():
+7 -3
View File
@@ -5,7 +5,7 @@ from app.models.transaction import Transaction
from app.services.report_service import ( from app.services.report_service import (
monthly_report, quarterly_report, yearly_report, monthly_report, quarterly_report, yearly_report,
net_worth_history, category_trends, tax_year_summary, net_worth_history, category_trends, tax_year_summary,
take_net_worth_snapshot, take_net_worth_snapshot, category_mom_comparison,
) )
from app.services.export_service import ( from app.services.export_service import (
transactions_to_csv, transactions_to_excel, transactions_to_csv, transactions_to_excel,
@@ -24,16 +24,16 @@ _CUR_MONTH = date.today().month
@login_required @login_required
def index(): def index():
today = date.today() today = date.today()
# Default: current month summary
report = monthly_report(today.year, today.month) report = monthly_report(today.year, today.month)
nw_history = net_worth_history() nw_history = net_worth_history()
cat_trend = category_trends(6) cat_trend = category_trends(6)
mom_data = category_mom_comparison()
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1)) years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
return render_template('reports/index.html', return render_template('reports/index.html',
report=report, report=report,
nw_history=nw_history, nw_history=nw_history,
cat_trend=cat_trend, cat_trend=cat_trend,
mom_data=mom_data,
years=years, years=years,
current_year=_CUR_YEAR, current_year=_CUR_YEAR,
current_month=_CUR_MONTH, current_month=_CUR_MONTH,
@@ -51,11 +51,13 @@ def monthly():
report = monthly_report(year, month) report = monthly_report(year, month)
nw_history = net_worth_history() nw_history = net_worth_history()
cat_trend = category_trends(6) cat_trend = category_trends(6)
mom_data = category_mom_comparison()
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1)) years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
return render_template('reports/index.html', return render_template('reports/index.html',
report=report, report=report,
nw_history=nw_history, nw_history=nw_history,
cat_trend=cat_trend, cat_trend=cat_trend,
mom_data=mom_data,
years=years, years=years,
current_year=_CUR_YEAR, current_year=_CUR_YEAR,
current_month=_CUR_MONTH, current_month=_CUR_MONTH,
@@ -78,6 +80,7 @@ def quarterly():
report=report, report=report,
nw_history=nw_history, nw_history=nw_history,
cat_trend=cat_trend, cat_trend=cat_trend,
mom_data=[],
years=years, years=years,
current_year=_CUR_YEAR, current_year=_CUR_YEAR,
current_month=_CUR_MONTH, current_month=_CUR_MONTH,
@@ -99,6 +102,7 @@ def yearly():
report=report, report=report,
nw_history=nw_history, nw_history=nw_history,
cat_trend=cat_trend, cat_trend=cat_trend,
mom_data=[],
years=years, years=years,
current_year=_CUR_YEAR, current_year=_CUR_YEAR,
current_month=_CUR_MONTH, current_month=_CUR_MONTH,
+188 -3
View File
@@ -4,8 +4,9 @@ net worth history, category trends, and tax year reports.
""" """
import calendar import calendar
from datetime import date, datetime from collections import defaultdict
from sqlalchemy import func from datetime import date, datetime, timedelta
from sqlalchemy import func, extract
from app.extensions import db from app.extensions import db
from app.models.transaction import Transaction from app.models.transaction import Transaction
from app.models.category import Category from app.models.category import Category
@@ -172,18 +173,48 @@ def yearly_report(year):
# ── Net worth history ───────────────────────────────────────────────────────── # ── Net worth history ─────────────────────────────────────────────────────────
def net_worth_history(): def net_worth_history():
from dateutil.relativedelta import relativedelta
snapshots = NetWorthSnapshot.query\ snapshots = NetWorthSnapshot.query\
.order_by(NetWorthSnapshot.snapshot_date.asc())\ .order_by(NetWorthSnapshot.snapshot_date.asc())\
.all() .all()
return {
result = {
'snapshots': snapshots, 'snapshots': snapshots,
'labels': [s.snapshot_date.strftime('%b %Y') for s in snapshots], 'labels': [s.snapshot_date.strftime('%b %Y') for s in snapshots],
'values': [float(s.net_worth) for s in snapshots], 'values': [float(s.net_worth) for s in snapshots],
'assets': [float(s.total_assets) for s in snapshots], 'assets': [float(s.total_assets) for s in snapshots],
'liabilities': [float(s.total_liabilities) for s in snapshots], 'liabilities': [float(s.total_liabilities) for s in snapshots],
'count': len(snapshots), 'count': len(snapshots),
'proj_labels': [],
'proj_values': [],
'projected_1yr': None,
'monthly_delta': None,
} }
if len(snapshots) >= 3:
recent = snapshots[-6:] # up to last 6 data points
deltas = [
float(recent[i].net_worth) - float(recent[i - 1].net_worth)
for i in range(1, len(recent))
]
avg_delta = sum(deltas) / len(deltas)
last_nw = float(snapshots[-1].net_worth)
last_date = snapshots[-1].snapshot_date
proj_labels, proj_values = [], []
for i in range(1, 13):
proj_labels.append((last_date + relativedelta(months=i)).strftime('%b %Y'))
proj_values.append(round(last_nw + avg_delta * i, 2))
result['proj_labels'] = proj_labels
result['proj_values'] = proj_values
result['projected_1yr'] = proj_values[-1]
result['monthly_delta'] = round(avg_delta, 2)
return result
# ── Category spending trends (last 6 months) ────────────────────────────────── # ── Category spending trends (last 6 months) ──────────────────────────────────
@@ -268,6 +299,160 @@ def tax_year_summary(year):
} }
# ── Month-over-month category comparison ─────────────────────────────────────
def category_mom_comparison():
"""
Returns per-category expense totals for: this month, last month, and the
3-month rolling average (last 3 complete months). Sorted by this-month
spend descending.
"""
today = date.today()
this_start = today.replace(day=1)
this_end = today
last_end = this_start - timedelta(days=1)
last_start = last_end.replace(day=1)
# Build month ranges for the 3-month rolling average (the 3 complete months
# ending with last month)
avg_ranges = []
cursor = last_start
for _ in range(3):
me = cursor - timedelta(days=1)
ms = me.replace(day=1)
avg_ranges.append((ms, me))
cursor = ms
def _totals_by_cat(start, end):
rows = db.session.query(
Category.id,
Category.name,
Category.color,
Category.icon,
func.sum(Transaction.amount).label('total'),
).join(Transaction, Transaction.category_id == Category.id)\
.filter(
Transaction.transaction_type == 'expense',
Transaction.date >= start,
Transaction.date <= end,
).group_by(Category.id).all()
return {r.id: {'name': r.name, 'color': r.color, 'icon': r.icon,
'total': float(r.total)} for r in rows}
this_data = _totals_by_cat(this_start, this_end)
last_data = _totals_by_cat(last_start, last_end)
avg_data = [_totals_by_cat(s, e) for s, e in avg_ranges]
# Collect all known category IDs + their metadata
cat_meta = {}
for src in [this_data, last_data] + avg_data:
for cid, info in src.items():
if cid not in cat_meta:
cat_meta[cid] = {k: info[k] for k in ('name', 'color', 'icon')}
rows = []
for cid, meta in cat_meta.items():
this_amt = this_data.get(cid, {}).get('total', 0.0)
last_amt = last_data.get(cid, {}).get('total', 0.0)
avg_monthly = (
sum(md.get(cid, {}).get('total', 0.0) for md in avg_data) / len(avg_data)
if avg_data else 0.0
)
change_pct = (
round((this_amt - last_amt) / last_amt * 100, 1)
if last_amt > 0 else None
)
rows.append({
**meta,
'id': cid,
'this_month': round(this_amt, 2),
'last_month': round(last_amt, 2),
'avg_3mo': round(avg_monthly, 2),
'change_pct': change_pct,
})
rows.sort(key=lambda r: r['this_month'], reverse=True)
return rows
# ── Spending anomaly detection ────────────────────────────────────────────────
def spending_anomalies(days_back=30, multiplier=2.0, min_avg=10.0, min_amount=10.0):
"""
Find transactions in the last `days_back` days whose amount is more than
`multiplier` × the category's average monthly spend over the prior 3 months.
Returns a list of dicts with transaction details + context, capped at 5.
"""
today = date.today()
window_start = today - timedelta(days=days_back)
# Baseline: the 3 complete months before today's month
base_end = today.replace(day=1) - timedelta(days=1)
base_start = (base_end.replace(day=1) - timedelta(days=60)).replace(day=1)
# Per-category, per-month totals over baseline
rows = db.session.query(
Transaction.category_id,
extract('year', Transaction.date).label('yr'),
extract('month', Transaction.date).label('mo'),
func.sum(Transaction.amount).label('total'),
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= base_start,
Transaction.date <= base_end,
Transaction.category_id.isnot(None),
).group_by(
Transaction.category_id,
extract('year', Transaction.date),
extract('month', Transaction.date),
).all()
cat_month_totals = defaultdict(list)
for r in rows:
cat_month_totals[r.category_id].append(float(r.total))
cat_avg = {
cid: sum(totals) / len(totals)
for cid, totals in cat_month_totals.items()
}
# Recent transactions in the window
recent = (
Transaction.query
.filter(
Transaction.transaction_type == 'expense',
Transaction.date >= window_start,
Transaction.date <= today,
Transaction.category_id.isnot(None),
)
.order_by(Transaction.date.desc())
.all()
)
anomalies = []
for txn in recent:
avg = cat_avg.get(txn.category_id)
amt = float(txn.amount)
if avg and avg >= min_avg and amt >= min_amount and amt > avg * multiplier:
anomalies.append({
'id': txn.id,
'date': txn.date.strftime('%b %d'),
'description': txn.description,
'amount': amt,
'category': txn.category.name if txn.category else 'Other',
'category_color': txn.category.color if txn.category else '#94a3b8',
'category_icon': txn.category.icon if txn.category else 'bi-tag',
'avg': round(avg, 2),
'multiple': round(amt / avg, 1),
})
# Sort by multiple desc (biggest outliers first), cap at 5
anomalies.sort(key=lambda a: a['multiple'], reverse=True)
return anomalies[:5]
# ── Snapshot helpers ────────────────────────────────────────────────────────── # ── Snapshot helpers ──────────────────────────────────────────────────────────
def take_net_worth_snapshot(): def take_net_worth_snapshot():
+102 -1
View File
@@ -169,7 +169,10 @@
.table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; } .table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* Remove h-100 height constraint on table wrappers so overflow-x works */ /* Remove h-100 height constraint on table wrappers so overflow-x works */
.pcard.p-0.h-100 { height: auto !important; } .pcard.p-0.h-100 { height: auto !important; }
.pfm-table { min-width: 560px; } /* Default min-width — hides .d-mob-none columns first, then scroll */
.pfm-table { min-width: 420px; }
/* Wider tables (investments, etc.) can opt in to more space */
.pfm-table.wide { min-width: 700px; }
/* Hide low-priority columns */ /* Hide low-priority columns */
.d-mob-none { display: none !important; } .d-mob-none { display: none !important; }
/* Topbar title: truncate so action buttons always fit */ /* Topbar title: truncate so action buttons always fit */
@@ -187,6 +190,10 @@
.tb-right .btn { padding-left: 8px; padding-right: 8px; } .tb-right .btn { padding-left: 8px; padding-right: 8px; }
.stat-card .stat-value { font-size: 16px; } .stat-card .stat-value { font-size: 16px; }
} }
/* Keyboard shortcut cheatsheet modal */
#kbd-modal .kbd-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
#kbd-modal .kbd-row:last-child { border: none; }
#kbd-modal kbd { background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: 4px; padding: 2px 7px; font-size: 12px; font-family: 'DM Mono', monospace; }
.sb-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; } .sb-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; }
.sb-overlay.on { display: block; } .sb-overlay.on { display: block; }
@@ -311,6 +318,12 @@
<span class="tb-title">{% block page_title %}{% endblock %}</span> <span class="tb-title">{% block page_title %}{% endblock %}</span>
<div class="tb-right"> <div class="tb-right">
{% block topbar_actions %}{% endblock %} {% block topbar_actions %}{% endblock %}
<button type="button" data-bs-toggle="modal" data-bs-target="#kbd-modal"
title="Keyboard shortcuts (?)"
style="background:none;border:none;color:var(--muted);font-size:13px;padding:3px 6px;border-radius:6px;cursor:pointer;line-height:1;"
class="d-none d-md-inline-flex align-items-center">
<i class="bi bi-keyboard"></i>
</button>
<span class="d-none d-sm-inline small text-muted mono" <span class="d-none d-sm-inline small text-muted mono"
>{{ current_user.display_name or current_user.username }}</span >{{ current_user.display_name or current_user.username }}</span
> >
@@ -339,6 +352,27 @@
<!-- MAIN --> <!-- MAIN -->
<main id="main">{% block content %}{% endblock %}</main> <main id="main">{% block content %}{% endblock %}</main>
<!-- Keyboard shortcuts cheatsheet modal -->
<div class="modal fade" id="kbd-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2 px-3">
<h6 class="modal-title mb-0"><i class="bi bi-keyboard me-2"></i>Keyboard Shortcuts</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body px-3 py-2">
<div class="kbd-row"><span>New expense</span><kbd>n</kbd></div>
<div class="kbd-row"><span>New income</span><kbd>i</kbd></div>
<div class="kbd-row"><span>Focus search</span><kbd>/</kbd></div>
<div class="kbd-row"><span>Go to dashboard</span><kbd>g</kbd> then <kbd>h</kbd></div>
<div class="kbd-row"><span>Go to transactions</span><kbd>g</kbd> then <kbd>t</kbd></div>
<div class="kbd-row"><span>Go to accounts</span><kbd>g</kbd> then <kbd>a</kbd></div>
<div class="kbd-row"><span>Show this help</span><kbd>?</kbd></div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
(function () { (function () {
@@ -372,6 +406,73 @@
}, 4500); }, 4500);
}); });
})(); })();
// ── Keyboard shortcuts ────────────────────────────────────────────────
(function () {
var gPending = false, gTimer = null;
function inInput() {
var t = document.activeElement;
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
t.tagName === 'SELECT' || t.isContentEditable);
}
document.addEventListener('keydown', function (e) {
if (inInput()) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
var key = e.key;
// ? → show shortcut modal
if (key === '?') {
e.preventDefault();
bootstrap.Modal.getOrCreateInstance(
document.getElementById('kbd-modal')
).show();
return;
}
// g-chord for navigation
if (gPending) {
clearTimeout(gTimer);
gPending = false;
if (key === 'h') { window.location.href = '/'; return; }
if (key === 't') { window.location.href = '{{ url_for("transactions.index") }}'; return; }
if (key === 'a') { window.location.href = '{{ url_for("accounts.index") }}'; return; }
return;
}
if (key === 'g') {
gPending = true;
gTimer = setTimeout(function () { gPending = false; }, 1000);
return;
}
// n → new expense
if (key === 'n') {
e.preventDefault();
window.location.href = '{{ url_for("transactions.new", type="expense") }}';
return;
}
// i → new income
if (key === 'i') {
e.preventDefault();
window.location.href = '{{ url_for("transactions.new", type="income") }}';
return;
}
// / → focus search input
if (key === '/') {
var s = document.querySelector('input[name="q"], input[type="search"], .search-input');
if (s) {
e.preventDefault();
s.focus();
s.select();
}
}
});
})();
</script> </script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>
+88 -2
View File
@@ -238,6 +238,15 @@
</div> </div>
{% endif %} {% endif %}
<!-- Spending Anomalies -->
<div id="anomaly-card" class="pcard mb-4" style="display:none;">
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-exclamation-triangle" style="color:#f59e0b;font-size:15px;"></i>
<span class="pcard-title mb-0">Unusual Spending Detected</span>
</div>
<div id="anomaly-list"></div>
</div>
<!-- Accounts + Recent Transactions --> <!-- Accounts + Recent Transactions -->
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-lg-4"> <div class="col-12 col-lg-4">
@@ -287,9 +296,10 @@
<a href="{{ url_for('transactions.index') }}" style="font-size:12px;color:#3b82f6;">View all</a> <a href="{{ url_for('transactions.index') }}" style="font-size:12px;color:#3b82f6;">View all</a>
</div> </div>
{% if recent_txns %} {% if recent_txns %}
<div class="table-wrap">
<table class="pfm-table"> <table class="pfm-table">
<thead> <thead>
<tr><th>Date</th><th>Description</th><th>Category</th><th class="text-end">Amount</th></tr> <tr><th>Date</th><th>Description</th><th class="d-mob-none">Category</th><th class="text-end">Amount</th></tr>
</thead> </thead>
<tbody> <tbody>
{% for txn in recent_txns %} {% for txn in recent_txns %}
@@ -299,7 +309,7 @@
<div style="font-size:13px;font-weight:500;">{{ txn.description }}</div> <div style="font-size:13px;font-weight:500;">{{ txn.description }}</div>
<div style="font-size:11px;color:var(--muted);">{{ txn.account.name if txn.account else '—' }}</div> <div style="font-size:11px;color:var(--muted);">{{ txn.account.name if txn.account else '—' }}</div>
</td> </td>
<td> <td class="d-mob-none">
{% if txn.category %} {% if txn.category %}
<span style="font-size:12px;"><i class="bi {{ txn.category.icon }}" style="color:{{ txn.category.color }};"></i> {{ txn.category.name }}</span> <span style="font-size:12px;"><i class="bi {{ txn.category.icon }}" style="color:{{ txn.category.color }};"></i> {{ txn.category.name }}</span>
{% else %}<span class="text-muted" style="font-size:12px;"></span>{% endif %} {% else %}<span class="text-muted" style="font-size:12px;"></span>{% endif %}
@@ -311,6 +321,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% else %} {% else %}
<p class="text-muted small">No transactions yet. <a href="{{ url_for('transactions.new', type='expense') }}">Add one</a>.</p> <p class="text-muted small">No transactions yet. <a href="{{ url_for('transactions.new', type='expense') }}">Add one</a>.</p>
{% endif %} {% endif %}
@@ -345,6 +356,50 @@
{% block extra_js %} {% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script> <script>
// ── Period memory (localStorage) ─────────────────────────────────────────────
(function () {
var KEY_P = 'pfm_dash_period', KEY_Q = 'pfm_dash_params';
var params = new URLSearchParams(window.location.search);
// If landing with no period param, restore last saved period
if (!params.has('period') && !params.has('date_from')) {
var saved = localStorage.getItem(KEY_Q);
if (saved) {
window.location.replace('/?' + saved);
return;
}
}
// Save current period whenever a period button is clicked
document.querySelectorAll('a.period-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var url = new URL(this.href, location.origin);
var p = url.searchParams.get('period');
if (p) {
localStorage.setItem(KEY_P, p);
localStorage.setItem(KEY_Q, url.search.slice(1));
}
});
});
// Save when custom range form is submitted
var customForm = document.querySelector('#customModal form');
if (customForm) {
customForm.addEventListener('submit', function () {
var fd = new FormData(this);
localStorage.setItem(KEY_P, 'custom');
localStorage.setItem(KEY_Q, new URLSearchParams(fd).toString());
});
}
// Save the current period (handles direct URL visits with ?period=xxx)
var cur = params.get('period');
if (cur) {
localStorage.setItem(KEY_P, cur);
localStorage.setItem(KEY_Q, params.toString());
}
})();
(function(){ (function(){
const ctx = document.getElementById('cashflowChart').getContext('2d'); const ctx = document.getElementById('cashflowChart').getContext('2d');
new Chart(ctx, { new Chart(ctx, {
@@ -500,6 +555,37 @@ function refreshFxRate() {
}); });
})(); })();
// ── Spending anomalies ───────────────────────────────────────────────────────
(function(){
const card = document.getElementById('anomaly-card');
const list = document.getElementById('anomaly-list');
if (!card || !list) return;
const SYM = '{{ current_user.currency_symbol }}';
function fmt(n){ return SYM + Math.abs(n).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2}); }
fetch('/api/anomalies')
.then(r => r.json())
.then(data => {
const items = data.anomalies || [];
if (!items.length) return; // stay hidden
list.innerHTML = items.map(a => `
<div class="d-flex justify-content-between align-items-center py-2" style="border-bottom:1px solid var(--border);font-size:13px;">
<div class="d-flex align-items-center gap-2">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#f59e0b;flex-shrink:0;"></span>
<div>
<div class="fw-medium">${a.description}</div>
<div class="text-muted" style="font-size:11px;">${a.category} · ${a.date}</div>
</div>
</div>
<div class="text-end flex-shrink-0 ms-3">
<div class="mono text-expense">${fmt(a.amount)}</div>
<div style="font-size:11px;color:#f59e0b;">${a.multiple.toFixed(1)}× avg ${fmt(a.avg_amount)}</div>
</div>
</div>`).join('');
card.style.display = '';
})
.catch(() => {}); // silently ignore on error
})();
function toggleFxChart(){ function toggleFxChart(){
const wrap = document.getElementById('fxChartWrap'); const wrap = document.getElementById('fxChartWrap');
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none'; wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
+74 -4
View File
@@ -134,12 +134,67 @@
</div> </div>
</div> </div>
<!-- Month-over-month comparison (monthly report only) -->
{% if report_type == 'monthly' and mom_data %}
<div class="row g-3 mb-4">
<div class="col-12">
<div class="pcard">
<div class="pcard-title mb-3">Month-over-Month Comparison</div>
<div class="table-responsive">
<table class="table table-sm" style="font-size:13px;">
<thead>
<tr style="border-bottom:2px solid var(--border);">
<th>Category</th>
<th class="text-end">This Month</th>
<th class="text-end">Last Month</th>
<th class="text-end d-none d-md-table-cell">3-Mo Avg</th>
<th class="text-end">Change</th>
</tr>
</thead>
<tbody>
{% for row in mom_data %}
<tr>
<td>
<span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:{{ row.color }};margin-right:6px;"></span>
{{ row.icon }} {{ row.name }}
</td>
<td class="text-end mono">{{ row.this_month | currency }}</td>
<td class="text-end mono text-muted">{{ row.last_month | currency }}</td>
<td class="text-end mono text-muted d-none d-md-table-cell">{{ row.avg_3mo | currency }}</td>
<td class="text-end">
{% if row.change_pct is none %}
<span class="text-muted"></span>
{% elif row.change_pct > 0 %}
<span class="badge text-expense" style="background:#ef444415;font-size:11px;">+{{ row.change_pct | round(0) | int }}%</span>
{% elif row.change_pct < 0 %}
<span class="badge text-income" style="background:#10b98115;font-size:11px;">{{ row.change_pct | round(0) | int }}%</span>
{% else %}
<span class="text-muted" style="font-size:11px;">0%</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Net worth history --> <!-- Net worth history -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-12"> <div class="col-12">
<div class="pcard"> <div class="pcard">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="pcard-title mb-0">Net Worth History</span> <span class="pcard-title mb-0">Net Worth History</span>
{% if nw_history.projected_1yr is defined and nw_history.projected_1yr %}
<span class="badge" style="background:#3b82f615;color:#3b82f6;font-size:11px;font-weight:500;">
Proj. 1yr: {{ nw_history.projected_1yr | currency }}
</span>
{% endif %}
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<form method="POST" action="{{ url_for('reports.manual_snapshot') }}"> <form method="POST" action="{{ url_for('reports.manual_snapshot') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -257,13 +312,28 @@ const fmtCur = v => sym + Math.abs(v).toLocaleString(undefined, {minimumFraction
{% if nw_history.count > 1 %} {% if nw_history.count > 1 %}
(function(){ (function(){
const ctx = document.getElementById('nwChart').getContext('2d'); const ctx = document.getElementById('nwChart').getContext('2d');
const histLabels = {{ nw_history.labels | tojson }};
const histValues = {{ nw_history['values'] | tojson }};
{% if nw_history.proj_labels is defined and nw_history.proj_labels %}
const projLabels = {{ nw_history.proj_labels | tojson }};
const projValues = {{ nw_history.proj_values | tojson }};
// Stitch: last actual point is first projected point
const allLabels = histLabels.concat(projLabels);
const histPad = new Array(projLabels.length).fill(null);
const projPad = new Array(histLabels.length - 1).fill(null);
{% else %}
const allLabels = histLabels;
{% endif %}
new Chart(ctx, { new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: {{ nw_history.labels | tojson }}, labels: allLabels,
datasets: [ datasets: [
{ label:'Net Worth', data: {{ nw_history['values'] | tojson }}, borderColor:'#3b82f6', backgroundColor:'#3b82f611', borderWidth:2, pointRadius:3, tension:.3, fill:true }, { label:'Net Worth', data: {% if nw_history.proj_labels is defined and nw_history.proj_labels %}histValues.concat(histPad){% else %}histValues{% endif %}, borderColor:'#3b82f6', backgroundColor:'#3b82f611', borderWidth:2, pointRadius:3, tension:.3, fill:true },
{ label:'Assets', data: {{ nw_history.assets | tojson }}, borderColor:'#10b981', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[4,3] }, { label:'Assets', data: {% if nw_history.proj_labels is defined and nw_history.proj_labels %}{{ nw_history.assets | tojson }}.concat(histPad){% else %}{{ nw_history.assets | tojson }}{% endif %}, borderColor:'#10b981', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[4,3] },
{% if nw_history.proj_labels is defined and nw_history.proj_labels %}
{ label:'Projected', data: projPad.concat([histValues[histValues.length-1]]).concat(projValues), borderColor:'#3b82f6', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[6,4], backgroundColor:'transparent' },
{% endif %}
] ]
}, },
options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 } } } }, scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false }, ticks:{ font:{ size:10 } } } } } options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 } } } }, scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false }, ticks:{ font:{ size:10 } } } } }