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
+102 -1
View File
@@ -169,7 +169,10 @@
.table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* Remove h-100 height constraint on table wrappers so overflow-x works */
.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 */
.d-mob-none { display: none !important; }
/* Topbar title: truncate so action buttons always fit */
@@ -187,6 +190,10 @@
.tb-right .btn { padding-left: 8px; padding-right: 8px; }
.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.on { display: block; }
@@ -311,6 +318,12 @@
<span class="tb-title">{% block page_title %}{% endblock %}</span>
<div class="tb-right">
{% 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"
>{{ current_user.display_name or current_user.username }}</span
>
@@ -339,6 +352,27 @@
<!-- 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>
(function () {
@@ -372,6 +406,73 @@
}, 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>
{% block extra_js %}{% endblock %}
</body>
+88 -2
View File
@@ -238,6 +238,15 @@
</div>
{% 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 -->
<div class="row g-3">
<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>
</div>
{% if recent_txns %}
<div class="table-wrap">
<table class="pfm-table">
<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>
<tbody>
{% for txn in recent_txns %}
@@ -299,7 +309,7 @@
<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>
</td>
<td>
<td class="d-mob-none">
{% if txn.category %}
<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 %}
@@ -311,6 +321,7 @@
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted small">No transactions yet. <a href="{{ url_for('transactions.new', type='expense') }}">Add one</a>.</p>
{% endif %}
@@ -345,6 +356,50 @@
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></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(){
const ctx = document.getElementById('cashflowChart').getContext('2d');
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(){
const wrap = document.getElementById('fxChartWrap');
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
+75 -5
View File
@@ -134,12 +134,67 @@
</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 -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="pcard">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="pcard-title mb-0">Net Worth History</span>
<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>
{% 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">
<form method="POST" action="{{ url_for('reports.manual_snapshot') }}">
<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 %}
(function(){
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, {
type: 'line',
data: {
labels: {{ nw_history.labels | tojson }},
labels: allLabels,
datasets: [
{ label:'Net Worth', data: {{ nw_history['values'] | tojson }}, 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:'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: {% 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 } } } } }