06/05 Optimize app
This commit is contained in:
@@ -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')
|
||||
@login_required
|
||||
def anomalies_api():
|
||||
|
||||
+16
-1
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import uuid
|
||||
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 app.utils.audit import audit
|
||||
from flask_wtf import FlaskForm
|
||||
@@ -208,6 +208,21 @@ def recurring():
|
||||
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'])
|
||||
@login_required
|
||||
def recurring_new():
|
||||
|
||||
@@ -443,12 +443,21 @@ def ocr_receipt_file():
|
||||
from app.models.category import Category
|
||||
from flask import current_app
|
||||
|
||||
from app.models.receipt import Receipt
|
||||
|
||||
data = request.get_json()
|
||||
if not data or not data.get('filename'):
|
||||
return jsonify({'error': 'No filename provided'}), 400
|
||||
|
||||
# Security: only allow basenames, no path traversal
|
||||
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')
|
||||
file_path = os.path.join(upload_dir, filename)
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Financial Health Score — synthesises savings rate, budget adherence, goal
|
||||
progress, and emergency fund coverage into a single 0–100 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; 10–20% → 18; 1–10% → 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'] # 0–100, 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 0–100
|
||||
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']],
|
||||
}
|
||||
@@ -94,6 +94,104 @@ def process_due_rules(dry_run=False):
|
||||
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):
|
||||
"""Return list of upcoming recurring transactions in the next N days."""
|
||||
today = date.today()
|
||||
|
||||
@@ -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 => {
|
||||
setImportLoading(false);
|
||||
if (data.error) {
|
||||
$('import-error').textContent = data.error;
|
||||
$('import-error').classList.remove('d-none');
|
||||
return;
|
||||
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);
|
||||
$('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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user