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 %}