06/05 Optimize app: report upgrades
This commit is contained in:
@@ -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():
|
||||||
|
|||||||
+12
-8
@@ -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,
|
||||||
@@ -46,16 +46,18 @@ def index():
|
|||||||
@reports_bp.route('/monthly')
|
@reports_bp.route('/monthly')
|
||||||
@login_required
|
@login_required
|
||||||
def monthly():
|
def monthly():
|
||||||
year = request.args.get('year', _CUR_YEAR, type=int)
|
year = request.args.get('year', _CUR_YEAR, type=int)
|
||||||
month = request.args.get('month', _CUR_MONTH, type=int)
|
month = request.args.get('month', _CUR_MONTH, type=int)
|
||||||
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,
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
'snapshots': snapshots,
|
result = {
|
||||||
'labels': [s.snapshot_date.strftime('%b %Y') for s in snapshots],
|
'snapshots': snapshots,
|
||||||
'values': [float(s.net_worth) for s in snapshots],
|
'labels': [s.snapshot_date.strftime('%b %Y') for s in snapshots],
|
||||||
'assets': [float(s.total_assets) for s in snapshots],
|
'values': [float(s.net_worth) for s in snapshots],
|
||||||
'liabilities': [float(s.total_liabilities) for s in snapshots],
|
'assets': [float(s.total_assets) for s in snapshots],
|
||||||
'count': len(snapshots),
|
'liabilities': [float(s.total_liabilities) for s in 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
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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">
|
||||||
<span class="pcard-title mb-0">Net Worth History</span>
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<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 } } } } }
|
||||||
|
|||||||
Reference in New Issue
Block a user