06/05 Optimize app

This commit is contained in:
2026-06-05 15:23:23 -04:00
parent db44d6057b
commit 9c9aa694c4
9 changed files with 781 additions and 19 deletions
+54 -18
View File
@@ -197,6 +197,17 @@
</div>
</div>
{# Import progress bar (shown only during chunked import) #}
<div id="import-progress-wrap" class="d-none mt-3">
<div class="d-flex justify-content-between align-items-center mb-1" style="font-size:12px;color:var(--muted);">
<span id="import-progress-label">Importing…</span>
<span id="import-progress-pct" class="mono fw-bold">0%</span>
</div>
<div class="progress" style="height:6px;border-radius:3px;">
<div id="import-progress-bar" class="progress-bar bg-success" role="progressbar" style="width:0%;transition:width .3s ease;"></div>
</div>
</div>
{# Parse errors (non-fatal) #}
<div id="parse-warnings" class="alert alert-warning d-none mb-3" style="font-size:12px;">
<strong>Warnings</strong> — these rows were skipped:<br>
@@ -580,31 +591,56 @@
setImportLoading(true);
$('import-error').classList.add('d-none');
fetch('{{ url_for("bank_import.confirm_import") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': CSRF,
},
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows }),
})
.then(r => r.json())
.then(data => {
const CHUNK = 50;
const useChunks = rows.length > CHUNK;
let totalImported = 0, totalSkipped = 0;
function setProgress(done, total) {
const pct = Math.round(done / total * 100);
$('import-progress-bar').style.width = pct + '%';
$('import-progress-pct').textContent = pct + '%';
$('import-progress-label').textContent = `Importing… ${done} of ${total}`;
}
async function sendChunks() {
if (useChunks) {
$('import-progress-wrap').classList.remove('d-none');
setProgress(0, rows.length);
}
const chunks = [];
for (let i = 0; i < rows.length; i += CHUNK) chunks.push(rows.slice(i, i + CHUNK));
let sent = 0;
for (const chunk of chunks) {
const r = await fetch('{{ url_for("bank_import.confirm_import") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows: chunk }),
});
const data = await r.json();
if (data.error) throw new Error(data.error);
totalImported += data.imported;
totalSkipped += data.skipped;
sent += chunk.length;
if (useChunks) setProgress(sent, rows.length);
}
}
sendChunks()
.then(() => {
setImportLoading(false);
if (data.error) {
$('import-error').textContent = data.error;
$('import-error').classList.remove('d-none');
return;
}
$('import-progress-wrap').classList.add('d-none');
$('done-title').textContent =
data.imported + ' transaction' + (data.imported !== 1 ? 's' : '') + ' imported successfully';
totalImported + ' transaction' + (totalImported !== 1 ? 's' : '') + ' imported successfully';
$('done-subtitle').textContent =
data.skipped > 0 ? data.skipped + ' duplicate(s) skipped.' : 'All transactions were new.';
totalSkipped > 0 ? totalSkipped + ' duplicate(s) skipped.' : 'All transactions were new.';
showStep('step-done');
})
.catch(err => {
setImportLoading(false);
$('import-error').textContent = 'Request failed: ' + err;
$('import-progress-wrap').classList.add('d-none');
$('import-error').textContent = 'Import failed: ' + err;
$('import-error').classList.remove('d-none');
});
});
+108
View File
@@ -198,8 +198,88 @@
.sb-overlay.on { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Dark mode ─────────────────────────────────────────────────────── */
body.dark-mode {
--body-bg: #0f172a; --card-bg: #1e293b; --text: #e2e8f0;
--muted: #94a3b8; --border: #334155;
color-scheme: dark;
}
body.dark-mode #topbar { background: #1e293b; border-color: #334155; }
body.dark-mode .pfm-table tbody tr:hover { background: #263548; }
body.dark-mode .pfm-table th, body.dark-mode .pfm-table td { border-color: #334155; }
body.dark-mode .form-control, body.dark-mode .form-select {
background: #0f172a; border-color: #334155; color: #e2e8f0;
}
body.dark-mode .form-control:focus, body.dark-mode .form-select:focus {
background: #0f172a; border-color: #3b82f6; color: #e2e8f0;
box-shadow: 0 0 0 .2rem rgba(59,130,246,.25);
}
body.dark-mode .form-control::placeholder { color: #475569; }
body.dark-mode .form-check-input { background-color: #334155; border-color: #475569; }
body.dark-mode .form-check-input:checked { background-color: #3b82f6; border-color: #3b82f6; }
body.dark-mode .btn-outline-secondary { color: #94a3b8; border-color: #334155; }
body.dark-mode .btn-outline-secondary:hover,
body.dark-mode .btn-outline-secondary:focus { background: #334155; color: #e2e8f0; border-color: #334155; }
body.dark-mode .btn-outline-primary { color: #93c5fd; border-color: #1e40af; }
body.dark-mode .btn-outline-primary:hover { background: #1e40af; color: #fff; }
body.dark-mode .btn-outline-warning { color: #fcd34d; border-color: #92400e; }
body.dark-mode .btn-outline-warning:hover { background: #92400e; color: #fff; }
body.dark-mode .btn-outline-danger { color: #fca5a5; border-color: #991b1b; }
body.dark-mode .btn-outline-danger:hover { background: #991b1b; color: #fff; }
body.dark-mode .dropdown-menu { background: #1e293b; border-color: #334155; }
body.dark-mode .dropdown-item { color: #e2e8f0; }
body.dark-mode .dropdown-item:hover, body.dark-mode .dropdown-item:focus { background: #334155; color: #f1f5f9; }
body.dark-mode .dropdown-divider { border-color: #334155; }
body.dark-mode .modal-content { background: #1e293b; border-color: #334155; color: #e2e8f0; }
body.dark-mode .modal-header, body.dark-mode .modal-footer { border-color: #334155; }
body.dark-mode .modal-header .btn-close { filter: invert(1) grayscale(1); }
body.dark-mode .alert-warning { background: #451a03; border-color: #92400e; color: #fcd34d; }
body.dark-mode .alert-danger { background: #450a0a; border-color: #991b1b; color: #fca5a5; }
body.dark-mode .alert-success { background: #052e16; border-color: #166534; color: #86efac; }
body.dark-mode .alert-info { background: #0c1a2e; border-color: #1e40af; color: #93c5fd; }
body.dark-mode .progress { background: #334155; }
body.dark-mode .table { color: #e2e8f0; }
body.dark-mode .input-group-text { background: #334155; border-color: #334155; color: #94a3b8; }
body.dark-mode .badge-income { background: #064e3b; color: #6ee7b7; }
body.dark-mode .badge-expense { background: #450a0a; color: #fca5a5; }
body.dark-mode .badge-transfer { background: #1e3a5f; color: #93c5fd; }
body.dark-mode #kbd-modal kbd { background: #334155; border-color: #475569; color: #e2e8f0; }
body.dark-mode .report-tab { background: #1e293b; border-color: #334155; color: #94a3b8; }
body.dark-mode .report-tab:hover { background: #334155; color: #e2e8f0; }
body.dark-mode .report-tab.active { background: #e2e8f0; color: #0f172a; border-color: #e2e8f0; }
/* Override common hardcoded light backgrounds in component inline styles */
body.dark-mode [style*="background:#f8fafc"], body.dark-mode [style*="background: #f8fafc"],
body.dark-mode [style*="background:#f1f5f9"], body.dark-mode [style*="background: #f1f5f9"]
{ background: #263548 !important; }
body.dark-mode [style*="background:#eff6ff"], body.dark-mode [style*="background: #eff6ff"]
{ background: #1e3a5f !important; }
body.dark-mode [style*="background:#fef2f2"], body.dark-mode [style*="background: #fef2f2"]
{ background: #450a0a !important; }
body.dark-mode [style*="background:#f0fdf4"], body.dark-mode [style*="background: #f0fdf4"]
{ background: #052e16 !important; }
body.dark-mode [style*="background:#fff5e6"], body.dark-mode [style*="background:#fffbeb"]
{ background: #451a03 !important; }
/* Text color overrides for hardcoded darks */
body.dark-mode [style*="color:#0f172a"] { color: #e2e8f0 !important; }
body.dark-mode [style*="color:#1e293b"] { color: #94a3b8 !important; }
body.dark-mode [style*="color:#374151"] { color: #94a3b8 !important; }
/* Border overrides */
body.dark-mode [style*="border-bottom:1px solid #e2e8f0"],
body.dark-mode [style*="border-bottom: 1px solid #e2e8f0"] { border-color: #334155 !important; }
body.dark-mode [style*="border:1px solid #e2e8f0"],
body.dark-mode [style*="border: 1px solid #e2e8f0"] { border-color: #334155 !important; }
{% block extra_css %}{% endblock %}
</style>
<script>
/* Apply dark mode class to body before first paint to avoid flash */
(function(){ if(localStorage.getItem('pfm_dark')==='1') document.documentElement.setAttribute('data-pfm-dark','1'); })();
</script>
<style>
/* Instant pre-body dark (avoids FOUC) — matches body.dark-mode vars */
html[data-pfm-dark] body { --body-bg:#0f172a; --card-bg:#1e293b; --text:#e2e8f0; --muted:#94a3b8; --border:#334155; }
</style>
</head>
<body>
<div class="sb-overlay" id="sbOverlay"></div>
@@ -318,6 +398,11 @@
<span class="tb-title">{% block page_title %}{% endblock %}</span>
<div class="tb-right">
{% block topbar_actions %}{% endblock %}
<button id="dark-toggle" type="button" title="Toggle dark mode"
style="background:none;border:none;color:var(--muted);font-size:15px;padding:3px 6px;border-radius:6px;cursor:pointer;line-height:1;"
class="d-inline-flex align-items-center">
<i class="bi bi-moon-stars"></i>
</button>
<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;"
@@ -473,6 +558,29 @@
}
});
})();
// ── Dark mode toggle ─────────────────────────────────────────────────
(function () {
var DKEY = 'pfm_dark';
var body = document.body;
var btn = document.getElementById('dark-toggle');
function applyDark(on) {
body.classList.toggle('dark-mode', on);
document.documentElement.setAttribute('data-pfm-dark', on ? '1' : '0');
if (btn) btn.querySelector('i').className = on ? 'bi bi-sun' : 'bi bi-moon-stars';
}
// Initialise from storage
var saved = localStorage.getItem(DKEY) === '1';
applyDark(saved);
if (btn) btn.addEventListener('click', function () {
var next = !body.classList.contains('dark-mode');
localStorage.setItem(DKEY, next ? '1' : '0');
applyDark(next);
});
})();
</script>
{% block extra_js %}{% endblock %}
</body>
+98
View File
@@ -138,6 +138,39 @@
</div>
</div>
<!-- Financial Health Score -->
<div id="health-score-card" class="pcard mb-4" style="display:none;">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<span class="pcard-title mb-0">Financial Health Score</span>
<span id="hs-grade-badge" class="badge fw-bold" style="font-size:13px;padding:4px 12px;"></span>
</div>
<div class="row g-3 align-items-center">
<!-- Score ring -->
<div class="col-12 col-sm-auto d-flex justify-content-center">
<div style="position:relative;width:96px;height:96px;">
<svg viewBox="0 0 36 36" style="width:96px;height:96px;transform:rotate(-90deg);">
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="var(--border)" stroke-width="3"/>
<circle id="hs-ring" cx="18" cy="18" r="15.9155" fill="none" stroke="#10b981" stroke-width="3"
stroke-dasharray="0 100" stroke-linecap="round"
style="transition:stroke-dasharray .8s ease, stroke .4s;"/>
</svg>
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;">
<div id="hs-score" class="mono fw-bold" style="font-size:22px;line-height:1;"></div>
<div style="font-size:10px;color:var(--muted);">/ 100</div>
</div>
</div>
</div>
<!-- Component bars -->
<div class="col">
<div id="hs-components" class="d-flex flex-column gap-2"></div>
</div>
</div>
<!-- Tips -->
<div id="hs-tips" class="mt-3" style="display:none;">
<div class="d-flex flex-wrap gap-2" id="hs-tips-list"></div>
</div>
</div>
<!-- Charts Row -->
<div class="row g-3 mb-4">
<div class="col-12 col-xl-8">
@@ -555,6 +588,71 @@ function refreshFxRate() {
});
})();
// ── Financial health score ───────────────────────────────────────────────────
(function(){
var card = document.getElementById('health-score-card');
var ring = document.getElementById('hs-ring');
var scoreEl = document.getElementById('hs-score');
var gradeEl = document.getElementById('hs-grade-badge');
var compsEl = document.getElementById('hs-components');
var tipsWrap = document.getElementById('hs-tips');
var tipsList = document.getElementById('hs-tips-list');
if (!card) return;
const SYM = '{{ current_user.currency_symbol }}';
fetch('/api/health-score')
.then(r => r.json())
.then(data => {
if (data.error) return;
card.style.display = '';
// Score ring
var circ = 100;
ring.style.strokeDasharray = (data.score / 100 * circ) + ' ' + circ;
ring.style.stroke = data.color;
scoreEl.textContent = data.score;
scoreEl.style.color = data.color;
// Grade badge
gradeEl.textContent = 'Grade ' + data.grade;
gradeEl.style.background = data.color + '22';
gradeEl.style.color = data.color;
// Component bars
var icons = {
savings: 'bi-piggy-bank',
budgets: 'bi-pie-chart',
goals: 'bi-bullseye',
emergency_fund: 'bi-shield-check',
};
compsEl.innerHTML = data.components.map(c => `
<div>
<div class="d-flex justify-content-between align-items-center mb-1" style="font-size:12px;">
<span><i class="bi ${c.icon} me-1" style="color:var(--muted);"></i>${c.name}</span>
<span class="mono" style="color:var(--muted);">${c.points}/${c.max}</span>
</div>
<div class="progress" style="height:5px;border-radius:3px;">
<div class="progress-bar" role="progressbar"
style="width:${c.pct}%;background:${c.pct>=80?'#10b981':c.pct>=50?'#3b82f6':c.pct>=25?'#f59e0b':'#ef4444'};transition:width .6s ease;">
</div>
</div>
${c.label ? `<div style="font-size:10px;color:var(--muted);margin-top:1px;">${c.label}</div>` : ''}
</div>`).join('');
// Tips
if (data.tips && data.tips.length) {
tipsWrap.style.display = '';
tipsList.innerHTML = data.tips.map(t =>
`<span style="font-size:11px;background:#fef3c7;color:#92400e;border-radius:20px;padding:3px 10px;display:inline-block;">
<i class="bi bi-lightbulb me-1"></i>${t}
</span>`
).join('');
}
})
.catch(() => {});
})();
// ── Spending anomalies ───────────────────────────────────────────────────────
(function(){
const card = document.getElementById('anomaly-card');
+141
View File
@@ -94,4 +94,145 @@
</div>
</div>
</div>
<!-- Cash Flow Projection -->
<div class="pcard mt-3">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<span class="pcard-title mb-0">Projected Cash Flow</span>
<div class="d-flex gap-1">
<button class="btn btn-sm proj-btn btn-primary" data-days="30" style="font-size:12px;">30d</button>
<button class="btn btn-sm proj-btn btn-outline-secondary" data-days="60" style="font-size:12px;">60d</button>
<button class="btn btn-sm proj-btn btn-outline-secondary" data-days="90" style="font-size:12px;">90d</button>
</div>
</div>
<!-- Summary stats -->
<div class="row g-2 mb-3" id="proj-stats">
<div class="col-6 col-md-3">
<div class="pcard pcard-sm text-center p-2" style="background:#f0fdf4;">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Income</div>
<div id="proj-income" class="mono text-income fw-bold" style="font-size:15px;"></div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="pcard pcard-sm text-center p-2" style="background:#fef2f2;">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Expenses</div>
<div id="proj-expense" class="mono text-expense fw-bold" style="font-size:15px;"></div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="pcard pcard-sm text-center p-2">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Net</div>
<div id="proj-net" class="mono fw-bold" style="font-size:15px;"></div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="pcard pcard-sm text-center p-2" style="background:#eff6ff;">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">End Balance</div>
<div id="proj-end" class="mono text-invest fw-bold" style="font-size:15px;"></div>
</div>
</div>
</div>
<!-- Chart -->
<div id="proj-chart-wrap" style="position:relative;height:200px;">
<canvas id="projChart"></canvas>
</div>
<div id="proj-loading" class="text-center py-4 text-muted" style="display:none;">
<span class="spinner-border spinner-border-sm me-2"></span>Loading…
</div>
<!-- Event list -->
<div id="proj-events" class="mt-3" style="max-height:260px;overflow-y:auto;"></div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
const SYM = '{{ current_user.currency_symbol }}';
const fmtC = v => SYM + Math.abs(v).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:0});
let projChart = null;
function loadProjection(days) {
// update active button
document.querySelectorAll('.proj-btn').forEach(b => {
const active = b.dataset.days == days;
b.className = 'btn btn-sm proj-btn ' + (active ? 'btn-primary' : 'btn-outline-secondary');
b.style.fontSize = '12px';
});
document.getElementById('proj-loading').style.display = '';
document.getElementById('proj-chart-wrap').style.display = 'none';
fetch('/settings/recurring/projection?days=' + days)
.then(r => r.json())
.then(data => {
document.getElementById('proj-loading').style.display = 'none';
document.getElementById('proj-chart-wrap').style.display = '';
// Summary stats
document.getElementById('proj-income').textContent = fmtC(data.total_income);
document.getElementById('proj-expense').textContent = fmtC(data.total_expense);
const net = data.net;
const netEl = document.getElementById('proj-net');
netEl.textContent = (net >= 0 ? '+' : '-') + fmtC(Math.abs(net));
netEl.style.color = net >= 0 ? '#10b981' : '#ef4444';
document.getElementById('proj-end').textContent = fmtC(data.ending_balance);
// Chart
if (projChart) projChart.destroy();
const ctx = document.getElementById('projChart').getContext('2d');
projChart = new Chart(ctx, {
data: {
labels: data.labels,
datasets: [
{ type:'bar', label:'Income', data:data.income, backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:1.5, borderRadius:3, yAxisID:'y' },
{ type:'bar', label:'Expense', data:data.expense, backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:1.5, borderRadius:3, yAxisID:'y' },
{ type:'line', label:'Balance', data:data.balance, borderColor:'#3b82f6', backgroundColor:'transparent', borderWidth:2, pointRadius:3, tension:.3, yAxisID:'y2' },
]
},
options: {
responsive:true, maintainAspectRatio:false,
interaction:{ mode:'index', intersect:false },
plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 }, boxWidth:10 } } },
scales:{
y: { position:'left', grid:{ color:'#f1f5f9' }, ticks:{ font:{size:10}, callback: v=>fmtC(v) } },
y2: { position:'right', grid:{ display:false }, ticks:{ font:{size:10}, callback: v=>fmtC(v) } },
x: { grid:{ display:false }, ticks:{ font:{size:10} } },
}
}
});
// Event list
const evList = document.getElementById('proj-events');
if (!data.events.length) {
evList.innerHTML = '<p class="text-muted small">No recurring transactions in this period.</p>';
return;
}
evList.innerHTML = data.events.map(ev => `
<div class="d-flex justify-content-between align-items-center py-2" style="border-top:1px solid var(--border);font-size:13px;">
<div>
<span class="fw-medium">${ev.description}</span>
<span class="text-muted ms-2" style="font-size:11px;">${ev.date}</span>
</div>
<span class="mono ${ev.type === 'income' ? 'text-income' : 'text-expense'}" style="font-size:13px;white-space:nowrap;">
${ev.type === 'income' ? '+' : '-'}${SYM}${Math.abs(ev.amount).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}
</span>
</div>`).join('');
})
.catch(() => {
document.getElementById('proj-loading').style.display = 'none';
document.getElementById('proj-chart-wrap').style.display = '';
});
}
document.querySelectorAll('.proj-btn').forEach(btn => {
btn.addEventListener('click', () => loadProjection(parseInt(btn.dataset.days)));
});
// Load 30-day projection on page load
loadProjection(30);
</script>
{% endblock %}