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
+12
View File
@@ -253,6 +253,18 @@ def reconcile_api():
}) })
@dashboard_bp.route('/api/health-score')
@login_required
def health_score_api():
from app.services.health_score_service import compute_health_score
try:
data = compute_health_score()
except Exception as e:
log.warning('[dashboard] health_score_api failed: %s', e)
return jsonify({'error': str(e)}), 500
return jsonify(data)
@dashboard_bp.route('/api/anomalies') @dashboard_bp.route('/api/anomalies')
@login_required @login_required
def anomalies_api(): def anomalies_api():
+16 -1
View File
@@ -1,7 +1,7 @@
import os import os
import uuid import uuid
from flask import (Blueprint, render_template, redirect, url_for, flash, from flask import (Blueprint, render_template, redirect, url_for, flash,
request, current_app, send_from_directory) request, current_app, send_from_directory, jsonify)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app.utils.audit import audit from app.utils.audit import audit
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
@@ -208,6 +208,21 @@ def recurring():
return render_template('settings/recurring.html', rules=rules, upcoming=upcoming) return render_template('settings/recurring.html', rules=rules, upcoming=upcoming)
@settings_bp.route('/recurring/projection')
@login_required
def recurring_projection():
days = request.args.get('days', 30, type=int)
if days not in (30, 60, 90):
days = 30
from app.services.recurring_service import projected_cash_flow
data = projected_cash_flow(days)
# Serialize events (date objects → string)
data['events'] = [
{**ev, 'date': ev['date'].strftime('%b %d')} for ev in data['events']
]
return jsonify(data)
@settings_bp.route('/recurring/new', methods=['GET', 'POST']) @settings_bp.route('/recurring/new', methods=['GET', 'POST'])
@login_required @login_required
def recurring_new(): def recurring_new():
+9
View File
@@ -443,12 +443,21 @@ def ocr_receipt_file():
from app.models.category import Category from app.models.category import Category
from flask import current_app from flask import current_app
from app.models.receipt import Receipt
data = request.get_json() data = request.get_json()
if not data or not data.get('filename'): if not data or not data.get('filename'):
return jsonify({'error': 'No filename provided'}), 400 return jsonify({'error': 'No filename provided'}), 400
# Security: only allow basenames, no path traversal # Security: only allow basenames, no path traversal
filename = os.path.basename(data['filename']) filename = os.path.basename(data['filename'])
# Ownership check: filename must exist in receipts table (single-user, but
# prevents OCR extraction from arbitrary files on disk via a crafted request)
receipt_record = Receipt.query.filter_by(filename=filename).first()
if not receipt_record:
return jsonify({'error': 'Receipt not found'}), 404
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads') upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
file_path = os.path.join(upload_dir, filename) file_path = os.path.join(upload_dir, filename)
+245
View File
@@ -0,0 +1,245 @@
"""
Financial Health Score — synthesises savings rate, budget adherence, goal
progress, and emergency fund coverage into a single 0100 score.
Each component is worth 25 points. Returns the total score plus a breakdown
so the UI can show per-component detail and suggestions.
"""
from datetime import date
from dateutil.relativedelta import relativedelta
from sqlalchemy import func
from app.extensions import db
from app.models.transaction import Transaction
from app.models.goal import Goal
# ── Component scorers ────────────────────────────────────────────────────────
def _score_savings(max_pts=25):
"""
Avg savings rate over the last 3 full months.
≥ 20% → full marks; 1020% → 18; 110% → 10; ≤ 0% → 0.
"""
today = date.today()
month_start = today.replace(day=1)
# Collect last 3 complete months
incomes, expenses = [], []
for i in range(1, 4):
mo_end = (month_start - relativedelta(days=1))
mo_start = mo_end.replace(day=1)
month_start = mo_start
inc = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0))
.filter(Transaction.transaction_type == 'income',
Transaction.date >= mo_start,
Transaction.date <= mo_end).scalar())
exp = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0))
.filter(Transaction.transaction_type == 'expense',
Transaction.date >= mo_start,
Transaction.date <= mo_end).scalar())
incomes.append(inc)
expenses.append(exp)
total_inc = sum(incomes)
total_exp = sum(expenses)
rate = ((total_inc - total_exp) / total_inc * 100) if total_inc > 0 else 0
if rate >= 20:
pts = max_pts
elif rate >= 10:
pts = round(max_pts * 0.72) # 18/25
elif rate > 0:
pts = round(max_pts * 0.40) # 10/25
else:
pts = 0
return {
'points': pts,
'max': max_pts,
'value': round(rate, 1),
'label': f'{rate:+.1f}% savings rate (3-mo avg)',
'tip': None if rate >= 20 else (
'Aim for 20%+ savings rate.' if rate < 10 else
'Good start — push toward 20%.'),
}
def _score_budgets(max_pts=25):
"""
What fraction of budgeted categories are currently under their limit?
All under → full marks; scales linearly.
"""
from app.services.budget_service import get_budget_summary
month_str = date.today().strftime('%Y-%m')
summary = [s for s in get_budget_summary(month_str) if s['has_budget']]
if not summary:
return {
'points': max_pts, # no budgets set → not penalised
'max': max_pts,
'value': None,
'label': 'No budgets set',
'tip': 'Set monthly budgets to track spending limits.',
}
under = sum(1 for s in summary if not s['is_over'])
ratio = under / len(summary)
pts = round(ratio * max_pts)
over_cats = [s['category'].name for s in summary if s['is_over']]
tip = None
if over_cats:
tip = f'Over budget: {", ".join(over_cats[:3])}{"" if len(over_cats) > 3 else ""}.'
return {
'points': pts,
'max': max_pts,
'value': round(ratio * 100, 1),
'label': f'{under}/{len(summary)} categories under budget',
'tip': tip,
}
def _score_goals(max_pts=25):
"""
Average completion % across active (non-completed) goals.
100% avg → full marks; scales linearly.
"""
goals = Goal.query.filter_by(is_completed=False).all()
if not goals:
completed = Goal.query.filter_by(is_completed=True).count()
return {
'points': max_pts if completed else round(max_pts * 0.5),
'max': max_pts,
'value': 100.0 if completed else 0.0,
'label': 'All goals completed!' if completed else 'No savings goals set',
'tip': None if completed else 'Create a savings goal to track progress.',
}
pcts = []
for g in goals:
target = float(g.target_amount)
if target > 0:
pcts.append(min(float(g.current_amount) / target * 100, 100))
avg = (sum(pcts) / len(pcts)) if pcts else 0
pts = round(avg / 100 * max_pts)
behind = [g.name for g, p in zip(goals, pcts) if p < 25]
tip = None
if behind:
tip = f'Behind on: {", ".join(behind[:2])}{"" if len(behind) > 2 else ""}.'
return {
'points': pts,
'max': max_pts,
'value': round(avg, 1),
'label': f'{round(avg, 0):.0f}% avg goal progress ({len(goals)} active)',
'tip': tip,
}
def _score_emergency_fund(max_pts=25):
"""
Liquid assets vs 3-month expense target.
≥ 3 months → full marks; scales linearly up to 3 months.
"""
from app.services.goal_service import get_emergency_fund_status
ef = get_emergency_fund_status()
liquid = ef['liquid_assets']
target3 = ef['target_3mo']
covered = ef['months_covered']
pct3 = ef['pct_3mo'] # 0100, capped
pts = round(pct3 / 100 * max_pts)
if covered >= 3:
tip = None
elif covered >= 1:
short = target3 - liquid
tip = f'Build to 3-month emergency fund (need ${short:,.0f} more).'
else:
tip = 'Start an emergency fund — aim for 1 month of expenses first.'
return {
'points': pts,
'max': max_pts,
'value': round(covered, 1),
'label': f'{covered:.1f} months emergency fund',
'tip': tip,
}
# ── Public API ───────────────────────────────────────────────────────────────
def compute_health_score():
"""
Compute the overall financial health score.
Returns:
{
score: int 0100
grade: str 'A' | 'B' | 'C' | 'D' | 'F'
color: str CSS color
components: list of component dicts
}
"""
components = {
'savings': _score_savings(),
'budgets': _score_budgets(),
'goals': _score_goals(),
'emergency_fund': _score_emergency_fund(),
}
score = sum(c['points'] for c in components.values())
if score >= 85:
grade, color = 'A', '#10b981'
elif score >= 70:
grade, color = 'B', '#3b82f6'
elif score >= 55:
grade, color = 'C', '#f59e0b'
elif score >= 40:
grade, color = 'D', '#f97316'
else:
grade, color = 'F', '#ef4444'
# Add display names for the template
labels = {
'savings': 'Savings Rate',
'budgets': 'Budget Adherence',
'goals': 'Goal Progress',
'emergency_fund': 'Emergency Fund',
}
icons = {
'savings': 'bi-piggy-bank',
'budgets': 'bi-pie-chart',
'goals': 'bi-bullseye',
'emergency_fund': 'bi-shield-check',
}
component_list = [
{
'key': key,
'name': labels[key],
'icon': icons[key],
'points': c['points'],
'max': c['max'],
'value': c['value'],
'label': c['label'],
'tip': c['tip'],
'pct': round(c['points'] / c['max'] * 100),
}
for key, c in components.items()
]
return {
'score': score,
'grade': grade,
'color': color,
'components': component_list,
'tips': [c['tip'] for c in component_list if c['tip']],
}
+98
View File
@@ -94,6 +94,104 @@ def process_due_rules(dry_run=False):
return created return created
def projected_cash_flow(days=90):
"""
Build a projected cash flow from all active recurring rules over the next
N days. Returns weekly-bucketed chart data plus a flat event list.
Returns dict:
labels — list of 'Mon DD' strings (week-start dates)
income — list of floats (income per week bucket)
expense — list of floats (expense per week bucket)
balance — list of floats (running balance at end of each bucket)
events — list of {date, description, amount, type, rule_id}
starting_balance — float
ending_balance — float
total_income — float
total_expense — float
net — float
"""
from app.models.account import Account
from sqlalchemy import func
from app.extensions import db
today = date.today()
cutoff = today + timedelta(days=days)
# Starting balance = sum of all active account balances
starting_balance = float(
db.session.query(func.coalesce(func.sum(Account.balance), 0))
.filter(Account.is_active == True)
.scalar()
)
# Enumerate all occurrences of active rules within the window
rules = RecurringRule.query.filter_by(is_active=True).all()
events = []
for rule in rules:
run_date = rule.next_run or rule.start_date
# Advance to window start if rule fires before today
while run_date < today:
run_date = next_occurrence(run_date, rule.frequency)
while run_date <= cutoff:
if rule.end_date and run_date > rule.end_date:
break
events.append({
'date': run_date,
'description': rule.description,
'amount': float(rule.amount),
'type': rule.transaction_type,
'rule_id': rule.id,
})
run_date = next_occurrence(run_date, rule.frequency)
events.sort(key=lambda e: e['date'])
# Build weekly buckets: each bucket starts on Monday
# Find the Monday on or before today
week_start = today - timedelta(days=today.weekday())
buckets = []
ws = week_start
while ws <= cutoff:
buckets.append(ws)
ws += timedelta(weeks=1)
bucket_income = [0.0] * len(buckets)
bucket_expense = [0.0] * len(buckets)
for ev in events:
# Find which bucket this event falls in
idx = (ev['date'] - week_start).days // 7
if 0 <= idx < len(buckets):
if ev['type'] == 'income':
bucket_income[idx] += ev['amount']
else:
bucket_expense[idx] += ev['amount']
# Running balance
running = starting_balance
bucket_balance = []
for inc, exp in zip(bucket_income, bucket_expense):
running += inc - exp
bucket_balance.append(round(running, 2))
total_income = sum(bucket_income)
total_expense = sum(bucket_expense)
return {
'labels': [b.strftime('%b %d') for b in buckets],
'income': [round(v, 2) for v in bucket_income],
'expense': [round(v, 2) for v in bucket_expense],
'balance': bucket_balance,
'events': events,
'starting_balance': round(starting_balance, 2),
'ending_balance': round(bucket_balance[-1], 2) if bucket_balance else round(starting_balance, 2),
'total_income': round(total_income, 2),
'total_expense': round(total_expense, 2),
'net': round(total_income - total_expense, 2),
}
def get_upcoming(days=30): def get_upcoming(days=30):
"""Return list of upcoming recurring transactions in the next N days.""" """Return list of upcoming recurring transactions in the next N days."""
today = date.today() today = date.today()
+54 -18
View File
@@ -197,6 +197,17 @@
</div> </div>
</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) #} {# Parse errors (non-fatal) #}
<div id="parse-warnings" class="alert alert-warning d-none mb-3" style="font-size:12px;"> <div id="parse-warnings" class="alert alert-warning d-none mb-3" style="font-size:12px;">
<strong>Warnings</strong> — these rows were skipped:<br> <strong>Warnings</strong> — these rows were skipped:<br>
@@ -580,31 +591,56 @@
setImportLoading(true); setImportLoading(true);
$('import-error').classList.add('d-none'); $('import-error').classList.add('d-none');
fetch('{{ url_for("bank_import.confirm_import") }}', { const CHUNK = 50;
method: 'POST', const useChunks = rows.length > CHUNK;
headers: { let totalImported = 0, totalSkipped = 0;
'Content-Type': 'application/json',
'X-CSRFToken': CSRF, function setProgress(done, total) {
}, const pct = Math.round(done / total * 100);
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows }), $('import-progress-bar').style.width = pct + '%';
}) $('import-progress-pct').textContent = pct + '%';
.then(r => r.json()) $('import-progress-label').textContent = `Importing… ${done} of ${total}`;
.then(data => {
setImportLoading(false);
if (data.error) {
$('import-error').textContent = data.error;
$('import-error').classList.remove('d-none');
return;
} }
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);
$('import-progress-wrap').classList.add('d-none');
$('done-title').textContent = $('done-title').textContent =
data.imported + ' transaction' + (data.imported !== 1 ? 's' : '') + ' imported successfully'; totalImported + ' transaction' + (totalImported !== 1 ? 's' : '') + ' imported successfully';
$('done-subtitle').textContent = $('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'); showStep('step-done');
}) })
.catch(err => { .catch(err => {
setImportLoading(false); 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'); $('import-error').classList.remove('d-none');
}); });
}); });
+108
View File
@@ -198,8 +198,88 @@
.sb-overlay.on { display: block; } .sb-overlay.on { display: block; }
@keyframes spin { to { transform: rotate(360deg); } } @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 %} {% block extra_css %}{% endblock %}
</style> </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> </head>
<body> <body>
<div class="sb-overlay" id="sbOverlay"></div> <div class="sb-overlay" id="sbOverlay"></div>
@@ -318,6 +398,11 @@
<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 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" <button type="button" data-bs-toggle="modal" data-bs-target="#kbd-modal"
title="Keyboard shortcuts (?)" 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;" 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> </script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>
+98
View File
@@ -138,6 +138,39 @@
</div> </div>
</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 --> <!-- Charts Row -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-12 col-xl-8"> <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 ─────────────────────────────────────────────────────── // ── Spending anomalies ───────────────────────────────────────────────────────
(function(){ (function(){
const card = document.getElementById('anomaly-card'); const card = document.getElementById('anomaly-card');
+141
View File
@@ -94,4 +94,145 @@
</div> </div>
</div> </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 %} {% endblock %}