06/05 Optimize app: report upgrades
This commit is contained in:
+102
-1
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 } } } } }
|
||||
|
||||
Reference in New Issue
Block a user