Initial commit — AmexPal statement analyser

Python/Flask backend with pdfplumber parser, Svelte 4 frontend,
Docker multi-stage build. Includes category analysis, insights,
monthly/weekly charts, subscription audit, and annualised projections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 08:37:01 +13:00
commit c1e22da9d6
28 changed files with 5203 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
**/__pycache__
**/*.pyc
**/*.pyo
.env
.git
.gitignore
frontend/node_modules
frontend/dist
AGENT.MD
+8
View File
@@ -0,0 +1,8 @@
# Copy this to .env and fill in values to enable email sending.
# .env is gitignored — never commit real credentials.
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=you@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=you@gmail.com
+18
View File
@@ -0,0 +1,18 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
# Environment — never commit secrets
.env
# Frontend build artefacts
frontend/node_modules/
frontend/dist/
# Editor
.vscode/
*.swp
+50
View File
@@ -0,0 +1,50 @@
Plan
What we're building
A Python-based tool with two main capabilities:
Statement Analysis — Upload AMEX PDF statements, parse transactions, and produce spending insights (categories, trends, totals, etc.)
Automated Statement Retrieval — A web interface that can log into your AMEX account and download statements automatically.
Architecture
Component 1: PDF Statement Parser & Analyser
Python script using pdfplumber or tabula-py to extract transaction data from AMEX PDF statements
Parse out: date, description, amount, and (optionally) category
Produce summary stats: total spend, category breakdown, merchant frequency, monthly trends
Output as structured data (CSV/JSON) + summary report
Component 2: Web App with AMEX Login Automation
A lightweight Flask or FastAPI web app
File upload endpoint for manual PDF analysis
Automated login to AMEX using Playwright (headless browser) — this handles the JS-heavy AMEX site, MFA challenges, etc.
Statement download triggered from the web UI
Component 3: Dashboard / Reporting
Either a simple HTML dashboard (React artifact) or generated charts
Spending by category, merchant, time period
Key Considerations
AMEX login automation is fragile — AMEX actively blocks bots, requires MFA, and changes their UI. Playwright is the most resilient approach but you'll likely need to handle MFA interactively (e.g. pause for SMS/email code entry).
PDF parsing varies by statement format — NZ AMEX statements may differ from US ones. We'll need a sample to tune the parser.
Security — credentials should never be stored in code; we'll use environment variables or a .env file.
Proof of Concept Scope
For the POC, I'll build:
amex_parser.py — PDF statement parser that extracts transactions into structured data
amex_analyser.py — Analysis module (totals, categories, trends)
app.py — Flask web app with upload UI + results display
amex_scraper.py — Playwright-based AMEX login & statement downloader (interactive MFA)
Frontend — Clean upload + dashboard UI
Tech Stack
Python 3.11+
pdfplumber for PDF parsing
Flask for web app
Playwright for browser automation
pandas for data analysis
plotly or chart.js for visualisation
+34
View File
@@ -0,0 +1,34 @@
# ── Stage 1: Build Svelte frontend ───────────────────────────────────────────
FROM node:20-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# ── Stage 2: Python / Flask ───────────────────────────────────────────────────
FROM python:3.12-slim
# pdfplumber needs these system libs
RUN apt-get update && apt-get install -y --no-install-recommends \
libpoppler-cpp-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY amex_parser.py amex_analyser.py app.py ./
# Copy the built frontend from Stage 1
COPY --from=frontend-build /app/frontend/dist ./frontend/dist
EXPOSE 5000
# Gunicorn for production (threaded, no debug mode)
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
+352
View File
@@ -0,0 +1,352 @@
"""
AMEX Statement Analyser — categorisation, insights, monthly/weekly breakdown.
"""
import re
from collections import defaultdict
from datetime import datetime
from amex_parser import Transaction
# ── Payment processor prefixes to strip before categorising / displaying ─────
# WINDCAVE* is a common NZ payment gateway used by many merchants
_NOISE_PREFIX_RE = re.compile(
r'^(WINDCAVE\*|SQ \*|SP |PAYPAL \*|PAYPAL\*)',
re.IGNORECASE,
)
def normalise(description: str) -> str:
"""Strip payment processor prefixes for cleaner display and matching."""
return _NOISE_PREFIX_RE.sub('', description).strip()
# ── Category rules (checked in order — first match wins) ─────────────────────
# IMPORTANT: Fuel is before Public Transport so petrol stations don't fall into Public Transport.
CATEGORY_RULES: list[tuple[str, list[str]]] = [
('Payments', [
'PAYMENT - THANK YOU',
]),
('Groceries', [
'NEW WORLD', 'COUNTDOWN', 'WOOLWORTHS', 'PAK N SAVE', 'PAKNSAVE',
'FOUR SQUARE', 'FRESH CHOICE', 'HARRIS FARM', 'MOORE WILSON',
'FARRO', 'NOSH', 'HUCKLEBERRY', 'COMMONSENSE',
]),
('Dining & Takeaway', [
'MCDONALD', 'UBER EATS', 'ST PIERRE', 'KFC', 'UMU PIZZA',
'DOMINO', 'WILDFLOUR', 'SUBWAY', 'BURGER KING', 'PIZZA HUT',
'NOODLE', 'SUSHI', 'RESTAURANT', 'CAFE', 'BAKERY', 'COFFEE',
'STARBUCKS', 'MUFFIN BREAK', 'THAI', 'INDIAN', 'CHINESE',
'KEBAB', 'TAKEAWAY', 'PITA PIT', 'OPORTO', "WENDY'S", 'GRILL',
'BISTRO', 'BARBEQUE', 'BBQ', 'EGGS', 'PANCAKE',
]),
('Fuel', [
'MOBIL', 'BP ', 'Z ENERGY', 'CALTEX', 'GULL ', 'CHALLENGE ',
'NPD ', 'WAITOMO ', 'NIGHT N DAY',
]),
('Public Transport', [
'AT HOP', 'AT METRO', 'AUCKLAND TRANSPORT', 'AUCKLAND COUNCIL TR',
'PUBLIC TRANSPORT', 'INTERCITY', 'NAKED BUS', 'MANA COACH',
'TRANZ METRO', 'RITCHIES', 'GO BUS', 'NZ BUS',
'SNAPPER', 'METLINK', 'EBUS', 'ORBITER',
'UBER TRIP', 'UBER*TRIP',
]),
('Utilities', [
'SPARK', 'SLINGSHOT', 'POWERSHOP', 'CONTACT ENERGY', 'MERCURY ',
'GENESIS ', 'WATERCARE', 'CHORUS', 'VODAFONE', 'TWO DEGREES',
'2DEGREES', 'SKINNY', 'TRUSTPOWER', 'ORCON',
]),
('Subscriptions', [
'GOOGLE', 'APPLE.COM', 'EMBY', 'MYOB', 'NETFLIX', 'SPOTIFY',
'MICROSOFT', 'ADOBE', 'DROPBOX', 'AMAZON PRIME', 'DISNEY',
'SKYDRIVE', 'YOUTUBE', 'ICLOUD', 'GITHUB', 'CANVA', 'XERO',
'ZOOM', 'SLACK', 'ATLASSIAN', '1PASSWORD', 'LASTPASS', 'NORDVPN',
'PARAMOUNT', 'BINGE', 'NEON ', 'LIGHTROOM',
]),
('Health & Pharmacy', [
'CHEMIST WAREHOUSE', 'PHARMACY', 'UNICHEM', 'LIFE PHARMACY',
'GREEN CROSS', 'DOCTOR', 'MEDICAL', 'DENTAL', 'DENTIST',
'OPTOMETRIST', 'PHYSIO', 'HEALTHZONE', 'HEALTH FOOD',
'SPECSAVERS', 'VISION',
]),
('Home & Hardware', [
'BUNNINGS', 'MITRE 10', 'PLACEMAKERS', 'WISELIVING',
'IKEA', 'FREEDOM ', 'SPOTLIGHT', 'BED BATH', 'ADAIRS',
'BRISCOES', 'STEVENS ', 'LIVING AND GIVING', 'KMART HOMEWARE',
'BABY FACTORY', 'BABY CITY',
]),
('Electronics & Appliances', [
'HARVEY NORMAN', 'NOEL LEEMING', 'JB HI-FI', 'THE GOOD GUYS',
'PB TECH', 'COMPUTER LOUNGE', 'MIGHTY APE', 'PLAYTECH',
]),
('Shopping & Apparel', [
'KMART', 'THE WAREHOUSE', 'WAREHOUSE STATIONERY', 'FARMERS ',
'COTTON ON', 'GLASSONS', 'HALLENSTEINS', 'KATHMANDU', 'REBEL ',
'FOOTLOCKER', 'POSTIE', 'STIRLING SPORTS', 'LULULEMON', 'EZIBUY',
'WHITCOULLS', 'PAPER PLUS', 'SMITHS CITY', 'HANNAHS',
'NUMBER ONE SHOES', 'SHOE WAREHOUSE',
]),
('Entertainment', [
'HOYTS', 'READING CINEMA', 'EVENT CINEMA', 'TICKETEK',
'TICKETMASTER', 'EVENTFINDA', 'SKY TV', 'TIMEZONE',
'LASER STRIKE', 'ARCHIE BROTHERS', 'BOWLING', 'PAINTBALL',
]),
('Travel & Accommodation', [
'AIRBNB', 'BOOKING.COM', 'HOTEL', 'MOTEL', 'HOSTEL',
'AIR NEW ZEALAND', 'JETSTAR', 'QANTAS', 'VIRGIN AUSTRALIA',
'SCENIC HOTEL', 'HOLIDAY INN', 'NOVOTEL', 'IBIS ',
'TRIVAGO', 'EXPEDIA',
]),
('Personal Care', [
'HAIRCUT', 'BARBER', ' SALON', 'DAY SPA', 'BEAUTY', 'LASER ',
' NAIL ', 'WAXING', 'MASSAGE',
]),
('Food & Specialty', [
'SABATO', 'TANK ', 'ORIGIN COFFEE', 'ATOMIC COFFEE',
]),
('Pets', [
'VET ', 'VETCARE', 'PETBARN', 'PET STOCK', 'ANIMATES',
'HOLLYWOOD FISH', 'PETCO',
]),
]
CATEGORY_ICONS = {
'Groceries': '🛒',
'Dining & Takeaway': '🍔',
'Fuel': '',
'Public Transport': '🚌',
'Utilities': '',
'Subscriptions': '📱',
'Health & Pharmacy': '💊',
'Home & Hardware': '🏠',
'Electronics & Appliances':'💻',
'Shopping & Apparel': '🛍️',
'Entertainment': '🎬',
'Travel & Accommodation': '✈️',
'Personal Care': '💅',
'Food & Specialty': '🍷',
'Pets': '🐾',
'Other': '📦',
'Payments': '💳',
}
def categorise(description: str) -> str:
# Normalise first so WINDCAVE*DOMINOS → DOMINOS → Dining & Takeaway
clean = normalise(description).upper()
for category, keywords in CATEGORY_RULES:
for kw in keywords:
if kw.upper() in clean:
return category
return 'Other'
def _parse_date(date_str: str) -> datetime | None:
try:
return datetime.strptime(date_str, '%d.%m.%y')
except ValueError:
return None
def _clean_merchant(description: str) -> str:
"""Normalised merchant name for grouping (strip noise prefix + location suffix)."""
name = normalise(description)
# Strip trailing store-number IDs like "9518", "8212"
name = re.sub(r'\s+\d{3,6}$', '', name).strip()
# Strip trailing all-caps location tokens that aren't the brand name
parts = name.split()
while len(parts) > 1 and parts[-1].isupper() and len(parts[-1]) <= 15:
candidate = ' '.join(parts[:-1])
if any(kw.upper() in candidate.upper() for _, kws in CATEGORY_RULES for kw in kws):
parts = parts[:-1]
else:
break
return ' '.join(parts)
def _short_merchant(description: str) -> str:
"""Short display name for insights."""
name = normalise(description)
name = name.split('HTTPS://')[0].strip()
name = re.sub(r'\*\w+', '', name).strip()
return name.title()
def monthly_breakdown(enriched: list[dict]) -> dict:
month_data: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
month_sort: dict[str, str] = {}
for tx in enriched:
if tx['is_credit']:
continue
dt = _parse_date(tx['date'])
if not dt:
continue
label = dt.strftime('%b %Y')
sort_key = dt.strftime('%Y-%m')
month_data[label][tx['category']] += tx['amount']
month_sort[label] = sort_key
months = sorted(month_data.keys(), key=lambda m: month_sort[m])
all_cats = sorted(
{c for m in months for c in month_data[m]},
key=lambda c: -sum(month_data[m].get(c, 0) for m in months),
)
return {
'months': months,
'categories': all_cats,
'by_month': {m: {c: round(month_data[m].get(c, 0), 2) for c in all_cats} for m in months},
'totals': {m: round(sum(month_data[m].values()), 2) for m in months},
}
def weekly_breakdown(enriched: list[dict]) -> dict:
week_data: dict[str, float] = defaultdict(float)
for tx in enriched:
if tx['is_credit']:
continue
dt = _parse_date(tx['date'])
if not dt:
continue
week_num = (dt.day - 1) // 7 + 1
week_data[f'Week {week_num}'] += tx['amount']
weeks = sorted(week_data.keys())
return {'weeks': weeks, 'totals': {w: round(week_data[w], 2) for w in weeks}}
def generate_insights(enriched: list[dict], by_category: dict, total_spend: float) -> list[dict]:
spend_txns = [t for t in enriched if not t['is_credit']]
insights = []
# Grocery trips
grocery_txns = [t for t in spend_txns if t['category'] == 'Groceries']
if grocery_txns:
total = by_category.get('Groceries', 0)
count = len(grocery_txns)
avg = total / count if count else 0
pct = (total / total_spend * 100) if total_spend else 0
insights.append({
'icon': '🛒', 'title': 'Grocery Shopping',
'stat': f'${total:.2f}',
'detail': f'{count} trips · avg ${avg:.0f} each · {pct:.0f}% of spend',
'color': '#27ae60',
})
# Dining & takeaway
dining_txns = [t for t in spend_txns if t['category'] == 'Dining & Takeaway']
if dining_txns:
total = by_category.get('Dining & Takeaway', 0)
count = len(dining_txns)
insights.append({
'icon': '🍔', 'title': 'Dining & Takeaway',
'stat': f'${total:.2f}',
'detail': f'{count} orders this period',
'color': '#e67e22',
})
# Subscriptions
sub_txns = [t for t in spend_txns if t['category'] == 'Subscriptions']
if sub_txns:
total = by_category.get('Subscriptions', 0)
names = list(dict.fromkeys(_short_merchant(t['description']) for t in sub_txns))
insights.append({
'icon': '📱', 'title': 'Subscriptions',
'stat': f'${total:.2f}',
'detail': ', '.join(names[:5]),
'color': '#8e44ad',
})
# Utilities
util_txns = [t for t in spend_txns if t['category'] == 'Utilities']
if util_txns:
total = by_category.get('Utilities', 0)
names = list(dict.fromkeys(_short_merchant(t['description']) for t in util_txns))
insights.append({
'icon': '', 'title': 'Utilities',
'stat': f'${total:.2f}',
'detail': ', '.join(names[:4]),
'color': '#f39c12',
})
# Biggest single purchase
if spend_txns:
biggest = max(spend_txns, key=lambda t: t['amount'])
insights.append({
'icon': '💳', 'title': 'Largest Purchase',
'stat': f'${biggest["amount"]:.2f}',
'detail': _short_merchant(biggest['description']),
'color': '#e74c3c',
})
# Daily average
active_days = len({t['date'] for t in spend_txns})
if active_days > 0 and total_spend > 0:
insights.append({
'icon': '📅', 'title': 'Daily Average',
'stat': f'${total_spend / active_days:.2f}',
'detail': f'Across {active_days} active spending days',
'color': '#006fcf',
})
return insights
def analyse(enriched_transactions: list[dict] | None = None,
transactions: list[Transaction] | None = None) -> dict:
"""Accept either pre-enriched dicts or raw Transaction objects."""
if enriched_transactions is None:
enriched_transactions = []
for tx in (transactions or []):
enriched_transactions.append({
'date': tx.date,
'description': normalise(tx.description),
'amount': tx.amount,
'is_credit': tx.is_credit,
'category': categorise(tx.description),
})
spend_txns = [t for t in enriched_transactions if not t['is_credit']]
credit_txns = [t for t in enriched_transactions if t['is_credit']]
payment_txns = [t for t in credit_txns if t['category'] == 'Payments']
total_spend = round(sum(t['amount'] for t in spend_txns), 2)
total_credits = round(sum(t['amount'] for t in credit_txns), 2)
total_payments = round(sum(t['amount'] for t in payment_txns), 2)
by_category: dict[str, float] = defaultdict(float)
for t in spend_txns:
by_category[t['category']] += t['amount']
by_category = {k: round(v, 2) for k, v in sorted(by_category.items(), key=lambda x: -x[1])}
merchant_map: dict[str, dict] = defaultdict(lambda: {'total': 0.0, 'count': 0})
for t in spend_txns:
name = _clean_merchant(t['description'])
merchant_map[name]['total'] += t['amount']
merchant_map[name]['count'] += 1
top_merchants = sorted(
[{'name': k, 'total': round(v['total'], 2), 'count': v['count']}
for k, v in merchant_map.items()],
key=lambda x: -x['total'],
)[:10]
monthly = monthly_breakdown(enriched_transactions)
weekly = weekly_breakdown(enriched_transactions)
insights = generate_insights(enriched_transactions, by_category, total_spend)
# Stable numeric IDs for frontend list keying
for i, tx in enumerate(enriched_transactions):
tx.setdefault('id', i)
return {
'total_spend': total_spend,
'total_credits': total_credits,
'total_payments': total_payments,
'transaction_count': len(enriched_transactions),
'spend_count': len(spend_txns),
'by_category': by_category,
'category_icons': CATEGORY_ICONS,
'top_merchants': top_merchants,
'insights': insights,
'monthly': monthly,
'weekly': weekly,
'transactions': enriched_transactions,
}
+204
View File
@@ -0,0 +1,204 @@
"""
AMEX NZ PDF Statement Parser
Handles the Airpoints Platinum Card statement format where transactions
appear as: DD.MM.YY MERCHANT NAME [LOCATION] AMOUNT [CR]
"""
import re
import pdfplumber
from dataclasses import dataclass, field
from typing import Optional
DATE_RE = re.compile(r'^(\d{2}\.\d{2}\.\d{2})\s+(.*)')
AMOUNT_RE = re.compile(r'^([\d,]+\.\d{2})(CR)?$')
AMOUNT_INLINE_RE = re.compile(r'^(.*?)\s+([\d,]+\.\d{2})\s*(CR)?$')
SKIP_PATTERNS = [
r'^MATTHEW BRUCE COHEN',
r'^XXXX-XXXXXX-\d+',
r'^Page \d+ / \d+',
r'^Details\s+Foreign Spending',
r'^Amount \$',
r'^Prepared for',
r'^Membership Number',
r'^Opening Date',
r'^Closing Date',
r'^Airpoints Platinum Card',
r'^Statement',
r'^americanexpress',
r'^Please check all',
r'^Total of New Transactions',
r'^Opening Balance',
r'^Credit Summary',
r'^Current Rate of Interest',
r'^Statement Period',
r'^Annual Rate',
r'^Credit Limit',
r'^\d+\.\d{2}\s*[-+]', # summary balance line
r'^Minimum Payment',
r'^Due by',
r'^NZD \d', # foreign currency note
r'^\d+\.\d{2} UNITED STATES',
r'^DOLLAR',
r'^NZD \d+\.\d{2} includes',
r'^\.\.\.',
r'^If you',
r'^Please pay',
r'^Visit www',
r'^balance\.',
r'^interest\.',
r'^American Express',
r'^Incorporated',
r'^PO Box',
r'^Auckland',
r'^New Zealand',
]
SKIP_RES = [re.compile(p, re.IGNORECASE) for p in SKIP_PATTERNS]
@dataclass
class Transaction:
date: str # DD.MM.YY
description: str
amount: float
is_credit: bool
def _should_skip(line: str) -> bool:
for pattern in SKIP_RES:
if pattern.match(line):
return True
return False
def _parse_amount(text: str) -> tuple[Optional[float], bool]:
"""Extract amount and credit flag from a string like '1,242.55CR' or '21.45'."""
text = text.strip()
m = AMOUNT_RE.match(text)
if m:
amount = float(m.group(1).replace(',', ''))
is_credit = bool(m.group(2))
return amount, is_credit
return None, False
def _extract_lines(page) -> list[str]:
"""
Use word bounding boxes to reconstruct lines.
Groups words by Y-position and sorts each group by X.
Amounts (rightmost column) are appended at end of line with a tab separator.
"""
words = page.extract_words(x_tolerance=5, y_tolerance=4)
if not words:
return []
# Determine amount-column threshold (rightmost ~22% of page)
page_width = page.width
amount_threshold = page_width * 0.78
# Group words by rounded Y position
buckets: dict[int, dict] = {}
for w in words:
key = round(w['top'] / 4) * 4
if key not in buckets:
buckets[key] = {'left': [], 'right': []}
if w['x0'] >= amount_threshold:
buckets[key]['right'].append((w['x0'], w['text']))
else:
buckets[key]['left'].append((w['x0'], w['text']))
lines = []
for y in sorted(buckets):
left = ' '.join(t for _, t in sorted(buckets[y]['left']))
right = ' '.join(t for _, t in sorted(buckets[y]['right']))
line = left
if right:
line = f"{left}\t{right}" if left else right
lines.append(line.strip())
return [l for l in lines if l]
def parse_statement(pdf_path: str) -> list[Transaction]:
"""Parse all transactions from an AMEX NZ PDF statement."""
transactions: list[Transaction] = []
pending: Optional[dict] = None
def commit(tx):
if tx and tx.get('amount') is not None:
transactions.append(Transaction(
date=tx['date'],
description=tx['description'].strip(),
amount=tx['amount'],
is_credit=tx['is_credit'],
))
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
for raw_line in _extract_lines(page):
# Split into description part and amount part (tab-separated)
if '\t' in raw_line:
desc_part, amount_part = raw_line.split('\t', 1)
else:
desc_part, amount_part = raw_line, ''
desc_part = desc_part.strip()
amount_part = amount_part.strip()
if _should_skip(desc_part) and not amount_part:
continue
# Check if line starts a new transaction
date_match = DATE_RE.match(desc_part)
if date_match:
commit(pending)
date = date_match.group(1)
remainder = date_match.group(2).strip()
# Amount might be inline in the description part
inline = AMOUNT_INLINE_RE.match(remainder)
if inline:
description = inline.group(1).strip()
amount = float(inline.group(2).replace(',', ''))
is_credit = bool(inline.group(3))
else:
description = remainder
amount = None
is_credit = False
# Check right-column amount
if amount_part:
a, c = _parse_amount(amount_part)
if a is not None:
amount, is_credit = a, c
pending = {
'date': date,
'description': description,
'amount': amount,
'is_credit': is_credit,
}
elif pending:
# Continuation line — could be amount or CR
if amount_part and pending['amount'] is None:
a, c = _parse_amount(amount_part)
if a is not None:
pending['amount'] = a
pending['is_credit'] = c
# Plain amount on its own line (no tab split)
stripped = desc_part.strip()
if not amount_part:
a, c = _parse_amount(stripped)
if a is not None and pending['amount'] is None:
pending['amount'] = a
pending['is_credit'] = c
elif stripped == 'CR':
pending['is_credit'] = True
commit(pending)
return transactions
+238
View File
@@ -0,0 +1,238 @@
"""
AMEX Statement Analyser — Flask API
"""
import os
import smtplib
import tempfile
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from dotenv import load_dotenv
from flask import Flask, jsonify, request
from flask_cors import CORS
from amex_analyser import analyse, generate_insights, CATEGORY_ICONS
from amex_parser import parse_statement
load_dotenv()
app = Flask(__name__, static_folder='frontend/dist', static_url_path='')
CORS(app, resources={r'/api/*': {'origins': '*'}})
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50 MB
def allowed(filename: str) -> bool:
return filename.lower().endswith('.pdf')
@app.route('/')
def index():
return app.send_static_file('index.html')
@app.route('/api/analyse', methods=['POST'])
def analyse_statement():
files = request.files.getlist('files')
if not files or all(f.filename == '' for f in files):
# Fallback: single-file field name
single = request.files.get('file')
if single:
files = [single]
else:
return jsonify({'error': 'No files provided'}), 400
all_enriched = []
tmp_paths = []
try:
for f in files:
if not f.filename or not allowed(f.filename):
continue
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
f.save(tmp.name)
tmp_paths.append(tmp.name)
txns = parse_statement(tmp_paths[-1])
for tx in txns:
from amex_analyser import categorise, normalise
all_enriched.append({
'date': tx.date,
'description': normalise(tx.description), # strip WINDCAVE* etc.
'amount': tx.amount,
'is_credit': tx.is_credit,
'category': categorise(tx.description),
})
if not all_enriched:
return jsonify({'error': 'No transactions found. Check the PDF is an AMEX NZ statement.'}), 422
result = analyse(enriched_transactions=all_enriched)
return jsonify(result)
except Exception as e:
return jsonify({'error': f'Failed to parse statement: {str(e)}'}), 500
finally:
for p in tmp_paths:
try:
os.unlink(p)
except Exception:
pass
@app.route('/api/email', methods=['POST'])
def send_email():
data = request.get_json()
to_email = (data or {}).get('to', '').strip()
result = (data or {}).get('result')
if not to_email:
return jsonify({'error': 'No email address provided'}), 400
smtp_host = os.getenv('SMTP_HOST', '')
smtp_port = int(os.getenv('SMTP_PORT', 587))
smtp_user = os.getenv('SMTP_USER', '')
smtp_pass = os.getenv('SMTP_PASS', '')
from_addr = os.getenv('EMAIL_FROM', smtp_user)
if not all([smtp_host, smtp_user, smtp_pass]):
return jsonify({
'error': 'Email not configured. Create a .env file — see .env.example for SMTP settings.'
}), 503
try:
html = _build_email_html(result)
msg = MIMEMultipart('alternative')
msg['Subject'] = _email_subject(result)
msg['From'] = from_addr
msg['To'] = to_email
msg.attach(MIMEText(html, 'html'))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo()
server.starttls()
server.login(smtp_user, smtp_pass)
server.sendmail(from_addr, [to_email], msg.as_string())
return jsonify({'success': True})
except Exception as e:
return jsonify({'error': str(e)}), 500
def _email_subject(result: dict) -> str:
txns = result.get('transactions', [])
if txns:
dates = sorted(t['date'] for t in txns if not t['is_credit'])
if dates:
from datetime import datetime
try:
d = datetime.strptime(dates[-1], '%d.%m.%y')
return f"AMEX Statement Summary — {d.strftime('%B %Y')}"
except Exception:
pass
return 'AMEX Statement Summary'
def _build_email_html(result: dict) -> str:
total_spend = result.get('total_spend', 0)
total_payments = result.get('total_payments', 0)
tx_count = result.get('transaction_count', 0)
by_category = result.get('by_category', {})
insights = result.get('insights', [])
top_merchants = result.get('top_merchants', [])[:5]
cat_rows = ''
for cat, amount in by_category.items():
if cat == 'Payments':
continue
pct = int(amount / total_spend * 100) if total_spend else 0
icon = CATEGORY_ICONS.get(cat, '📦')
cat_rows += f"""
<tr>
<td style="padding:6px 0;font-size:13px;color:#444;width:180px">{icon} {cat}</td>
<td style="padding:6px 8px">
<div style="background:#f0f2f7;border-radius:4px;height:8px;width:100%">
<div style="background:#006fcf;border-radius:4px;height:8px;width:{pct}%"></div>
</div>
</td>
<td style="padding:6px 0;font-size:13px;font-weight:600;color:#1a1a2e;text-align:right;white-space:nowrap">${amount:.2f}</td>
</tr>"""
insight_rows = ''
for ins in insights:
insight_rows += f"""
<tr>
<td style="padding:6px 12px 6px 0;font-size:13px;color:#555">{ins['icon']} {ins['title']}</td>
<td style="padding:6px 0;font-size:13px;font-weight:700;color:#1a1a2e;text-align:right">{ins['stat']}</td>
</tr>
<tr><td colspan="2" style="padding:0 0 8px;font-size:12px;color:#888">{ins['detail']}</td></tr>"""
merchant_rows = ''
for m in top_merchants:
merchant_rows += f"""
<tr>
<td style="padding:5px 12px 5px 0;font-size:13px;color:#444">{m['name']}</td>
<td style="padding:5px 0;font-size:12px;color:#aaa;text-align:center">{m['count']}×</td>
<td style="padding:5px 0;font-size:13px;font-weight:600;color:#1a1a2e;text-align:right">${m['total']:.2f}</td>
</tr>"""
return f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:20px;background:#f5f6fa;font-family:-apple-system,Arial,sans-serif">
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 2px 20px rgba(0,0,0,0.08)">
<div style="background:linear-gradient(135deg,#006fcf,#0051a0);color:#fff;padding:28px 32px">
<div style="font-size:11px;letter-spacing:0.1em;opacity:0.75;text-transform:uppercase;margin-bottom:6px">American Express</div>
<div style="font-size:24px;font-weight:700;margin-bottom:4px">Statement Summary</div>
<div style="font-size:13px;opacity:0.85">{tx_count} transactions analysed</div>
</div>
<div style="padding:24px 32px">
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px">
<tr>
<td style="text-align:center;background:#f0f7ff;border-radius:10px;padding:16px;width:33%">
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:0.05em">Total Spend</div>
<div style="font-size:22px;font-weight:700;color:#006fcf;margin-top:4px">${total_spend:.2f}</div>
</td>
<td style="width:12px"></td>
<td style="text-align:center;background:#f0faf4;border-radius:10px;padding:16px;width:33%">
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:0.05em">Payments</div>
<div style="font-size:22px;font-weight:700;color:#27ae60;margin-top:4px">${total_payments:.2f}</div>
</td>
<td style="width:12px"></td>
<td style="text-align:center;background:#fff8f0;border-radius:10px;padding:16px;width:33%">
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:0.05em">Transactions</div>
<div style="font-size:22px;font-weight:700;color:#e67e22;margin-top:4px">{tx_count}</div>
</td>
</tr>
</table>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:#aaa;margin-bottom:12px">Spending by Category</div>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px">
{cat_rows}
</table>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:#aaa;margin-bottom:12px">Key Insights</div>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px">
{insight_rows}
</table>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:#aaa;margin-bottom:12px">Top Merchants</div>
<table width="100%" cellpadding="0" cellspacing="0">
{merchant_rows}
</table>
</div>
<div style="padding:16px 32px;border-top:1px solid #f0f2f7;font-size:11px;color:#ccc;text-align:center">
Generated by AMEX Statement Analyser · Your data never leaves your device
</div>
</div>
</body>
</html>"""
if __name__ == '__main__':
# use_reloader=False: prevents Werkzeug spawning a second process (avoids duplicate instances)
# threaded=True: each request gets its own thread so PDF parsing doesn't block the browser
app.run(debug=True, port=5000, threaded=True, use_reloader=False)
+8
View File
@@ -0,0 +1,8 @@
services:
amexpal:
build: .
ports:
- "5000:5000"
env_file:
- .env # optional — only needed for email sending
restart: unless-stopped
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AmexPal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1320
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "amex-analyser",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.2.0",
"vite": "^5.0.0"
},
"dependencies": {
"chart.js": "^4.4.0"
}
}
+371
View File
@@ -0,0 +1,371 @@
<script>
import UploadZone from './components/UploadZone.svelte'
import SummaryCards from './components/SummaryCards.svelte'
import CategoryChart from './components/CategoryChart.svelte'
import Insights from './components/Insights.svelte'
import MonthlyChart from './components/MonthlyChart.svelte'
import AnnualisedProjection from './components/AnnualisedProjection.svelte'
import SubscriptionAudit from './components/SubscriptionAudit.svelte'
import FixedVariableSplit from './components/FixedVariableSplit.svelte'
import GroceryDiningChart from './components/GroceryDiningChart.svelte'
import UtilityBreakdown from './components/UtilityBreakdown.svelte'
import TransactionTable from './components/TransactionTable.svelte'
import EmailModal from './components/EmailModal.svelte'
let result = null
let loading = false
let error = null
let fileNames = []
let showEmail = false
async function handleUpload(event) {
const files = event.detail // array of File objects
fileNames = files.map(f => f.name)
loading = true
error = null
result = null
const formData = new FormData()
for (const f of files) formData.append('files', f)
try {
const res = await fetch('/api/analyse', { method: 'POST', body: formData })
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Upload failed')
result = data
} catch (err) {
error = err.message
} finally {
loading = false
}
}
function reset() {
result = null
error = null
fileNames = []
}
$: multiMonth = result && result.monthly.months.length > 1
</script>
<div class="app">
<header>
<div class="header-inner">
<div class="logo">
<span class="logo-box">💳</span>
<span class="logo-text"><span class="logo-amex">Amex</span>Pal</span>
</div>
{#if result}
<div class="header-actions">
<button class="btn-ghost" on:click={reset}>
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 0 1 1 1v2.101a7.002 7.002 0 0 1 11.601 2.566 1 1 0 1 1-1.885.666A5.002 5.002 0 0 0 5.999 7H9a1 1 0 0 1 0 2H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Zm.008 9.057a1 1 0 0 1 1.276.61A5.002 5.002 0 0 0 14.001 13H11a1 1 0 1 1 0-2h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-2.101a7.002 7.002 0 0 1-11.601-2.566 1 1 0 0 1 .61-1.276Z" clip-rule="evenodd"/></svg>
<span>New</span>
</button>
<button class="btn-primary" on:click={() => showEmail = true}>
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 4a2 2 0 0 0-2 2v1.161l8.441 4.221a1.25 1.25 0 0 0 1.118 0L19 7.162V6a2 2 0 0 0-2-2H3Z"/><path d="m19 8.839-7.77 3.885a2.75 2.75 0 0 1-2.46 0L1 8.839V14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.839Z"/></svg>
<span>Email Report</span>
</button>
</div>
{/if}
</div>
</header>
<main>
{#if !result && !loading}
<div class="upload-section">
<div class="upload-hero">
<div class="hero-icon">
<svg viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="14" fill="#e8f3ff"/>
<path d="M16 30c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4" stroke="#006fcf" stroke-width="2" stroke-linecap="round"/>
<path d="M24 12v16M18 18l6-6 6 6" stroke="#006fcf" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h1>AmexPal</h1>
<p>Upload one or more AMEX PDF statements to get instant insights — categories, trends, and a personalised summary you can email yourself.</p>
</div>
<UploadZone on:upload={handleUpload} />
{#if error}
<div class="error-banner">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd"/></svg>
{error}
</div>
{/if}
</div>
{:else if loading}
<div class="loading-section">
<div class="spinner"></div>
<p>Parsing {fileNames.length > 1 ? `${fileNames.length} statements` : fileNames[0]}</p>
</div>
{:else if result}
<div class="results">
<div class="results-header">
<div>
<h2>{multiMonth ? `${result.monthly.months[0]} ${result.monthly.months[result.monthly.months.length - 1]}` : (result.monthly.months[0] || 'Statement Analysis')}</h2>
<p class="file-label">{fileNames.join(', ')}</p>
</div>
<button class="btn-primary mobile-hide" on:click={() => showEmail = true}>
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 4a2 2 0 0 0-2 2v1.161l8.441 4.221a1.25 1.25 0 0 0 1.118 0L19 7.162V6a2 2 0 0 0-2-2H3Z"/><path d="m19 8.839-7.77 3.885a2.75 2.75 0 0 1-2.46 0L1 8.839V14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.839Z"/></svg>
Email Report
</button>
</div>
<SummaryCards {result} />
<AnnualisedProjection transactions={result.transactions} byCategory={result.by_category} />
<Insights insights={result.insights} />
<div class="three-col">
<FixedVariableSplit byCategory={result.by_category} totalSpend={result.total_spend} />
<SubscriptionAudit transactions={result.transactions} />
</div>
<div class="two-col">
<CategoryChart byCategory={result.by_category} categoryIcons={result.category_icons} />
<div class="top-merchants card">
<h3>Top Merchants</h3>
{#each result.top_merchants as m, i}
<div class="merchant-row">
<span class="merchant-rank">{i + 1}</span>
<span class="merchant-name">{m.name}</span>
<span class="merchant-visits">{m.count}×</span>
<span class="merchant-amount">${m.total.toFixed(2)}</span>
</div>
{/each}
</div>
</div>
<MonthlyChart monthly={result.monthly} weekly={result.weekly} />
<GroceryDiningChart transactions={result.transactions} />
<UtilityBreakdown transactions={result.transactions} />
<TransactionTable transactions={result.transactions} />
</div>
<!-- Mobile floating email button -->
<button class="fab mobile-only" on:click={() => showEmail = true} aria-label="Email Report">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 4a2 2 0 0 0-2 2v1.161l8.441 4.221a1.25 1.25 0 0 0 1.118 0L19 7.162V6a2 2 0 0 0-2-2H3Z"/><path d="m19 8.839-7.77 3.885a2.75 2.75 0 0 1-2.46 0L1 8.839V14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.839Z"/></svg>
</button>
{/if}
</main>
{#if showEmail}
<EmailModal {result} on:close={() => showEmail = false} />
{/if}
</div>
<style>
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
:global(body) {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: #f4f6fb;
color: #1a1a2e;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
:global(.card) {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.app { min-height: 100vh; display: flex; flex-direction: column; }
/* ── Header ── */
header {
background: #fff;
border-bottom: 1px solid #e8eaf0;
padding: 0 1.25rem;
height: 58px;
display: flex;
align-items: center;
position: sticky;
top: 0;
z-index: 50;
}
.header-inner {
max-width: 1120px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.logo { display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0; }
.logo-box {
font-size: 1.25rem;
line-height: 1;
}
.logo-text { font-weight: 700; font-size: 1.05rem; letter-spacing: -0.01em; }
.logo-amex { color: #006fcf; }
.header-actions { display: flex; align-items: center; gap: 0.5rem; }
.btn-ghost {
display: flex; align-items: center; gap: 0.35rem;
background: transparent;
border: 1px solid #e0e4ee;
color: #666;
padding: 7px 12px;
border-radius: 8px;
font-size: 0.82rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.btn-ghost svg { width: 14px; height: 14px; }
.btn-ghost:hover { border-color: #006fcf; color: #006fcf; }
.btn-primary {
display: flex; align-items: center; gap: 0.4rem;
background: #006fcf;
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
white-space: nowrap;
}
.btn-primary svg { width: 15px; height: 15px; }
.btn-primary:hover { background: #0058a8; }
.btn-primary:active { transform: scale(0.97); }
/* ── Main ── */
main {
flex: 1;
max-width: 1120px;
width: 100%;
margin: 0 auto;
padding: 1.75rem 1.25rem 5rem;
}
/* ── Upload ── */
.upload-section { max-width: 560px; margin: 2rem auto; }
.upload-hero { text-align: center; margin-bottom: 2rem; }
.hero-icon { margin: 0 auto 1.25rem; width: 64px; height: 64px; }
.hero-icon svg { width: 100%; height: 100%; }
.upload-hero h1 { font-size: 1.6rem; font-weight: 750; margin-bottom: 0.6rem; }
.upload-hero p { color: #666; line-height: 1.6; font-size: 0.95rem; }
/* ── Loading ── */
.loading-section {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 1.25rem; padding: 6rem 0; color: #666;
}
.spinner {
width: 40px; height: 40px;
border: 3px solid #e0e4f0;
border-top-color: #006fcf;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Results ── */
.results { display: flex; flex-direction: column; gap: 1.5rem; }
.results-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.results-header h2 { font-size: 1.35rem; font-weight: 750; }
.file-label { font-size: 0.78rem; color: #aaa; margin-top: 2px; }
/* ── Layout grids ── */
.three-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.two-col {
display: grid;
grid-template-columns: 1fr 300px;
gap: 1.25rem;
align-items: start;
}
@media (max-width: 760px) {
.three-col { grid-template-columns: 1fr; }
}
/* ── Top Merchants ── */
.top-merchants h3 {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 1rem;
}
.merchant-row {
display: grid;
grid-template-columns: 22px 1fr auto auto;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0;
border-bottom: 1px solid #f5f6fa;
font-size: 0.875rem;
}
.merchant-row:last-child { border-bottom: none; }
.merchant-rank { font-size: 0.72rem; color: #ccc; font-weight: 600; text-align: center; }
.merchant-name { font-weight: 500; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.merchant-visits { font-size: 0.72rem; color: #bbb; white-space: nowrap; }
.merchant-amount { font-weight: 650; color: #1a1a2e; white-space: nowrap; }
/* ── FAB ── */
.fab {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: 56px;
height: 56px;
border-radius: 50%;
background: #006fcf;
color: #fff;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(0, 111, 207, 0.4);
cursor: pointer;
z-index: 40;
transition: background 0.15s, transform 0.1s;
}
.fab:hover { background: #0058a8; }
.fab:active { transform: scale(0.94); }
.fab svg { width: 22px; height: 22px; }
/* ── Error ── */
.error-banner {
display: flex; align-items: center; gap: 0.6rem;
margin-top: 1rem;
background: #fff5f5;
border: 1px solid #fecaca;
color: #b91c1c;
border-radius: 10px;
padding: 0.85rem 1rem;
font-size: 0.875rem;
}
.error-banner svg { width: 18px; height: 18px; flex-shrink: 0; }
/* ── Responsive ── */
.mobile-hide { display: flex; }
.mobile-only { display: none; }
@media (max-width: 760px) {
.two-col { grid-template-columns: 1fr; }
.mobile-hide { display: none !important; }
.mobile-only { display: flex !important; }
main { padding: 1.25rem 1rem 6rem; }
}
</style>
@@ -0,0 +1,206 @@
<script>
export let transactions = []
export let byCategory = {}
// ── Statement period in days (min→max transaction date) ───────────────────
$: periodDays = (() => {
const spends = transactions.filter(t => !t.is_credit)
if (spends.length < 2) return 30
const ms = spends.map(t => {
const [d, m, y] = t.date.split('.').map(Number)
return new Date(2000 + y, m - 1, d).getTime()
})
const days = Math.round((Math.max(...ms) - Math.min(...ms)) / 86400000) + 1
return Math.max(days, 1)
})()
$: multiplier = 365 / periodDays
// ── Annualised totals per category ────────────────────────────────────────
$: rows = Object.entries(byCategory)
.filter(([k]) => k !== 'Payments')
.map(([cat, amount]) => ({
cat,
period: amount,
annual: Math.round(amount * multiplier),
}))
.sort((a, b) => b.annual - a.annual)
$: totalPeriod = rows.reduce((s, r) => s + r.period, 0)
$: totalAnnual = Math.round(totalPeriod * multiplier)
// Biggest single annual line
$: biggestCat = rows[0]
// Friendly period label
$: periodLabel = periodDays <= 31
? `Based on ${periodDays} days of spending`
: `Based on ${Math.round(periodDays / 7)} weeks of spending`
</script>
<div class="card">
<div class="card-header">
<div>
<h3 class="section-title">At this rate…</h3>
<p class="sub">{periodLabel} · extrapolated to 12 months</p>
</div>
<div class="hero-number">
<span class="tilde">~</span><span class="amount">${totalAnnual.toLocaleString()}</span>
<span class="per-year">/year</span>
</div>
</div>
{#if biggestCat}
<p class="lead-insight">
<strong>{biggestCat.cat}</strong> is your biggest line item — on track for
<strong>~${biggestCat.annual.toLocaleString()}/year</strong>.
</p>
{/if}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Category</th>
<th class="right">This period</th>
<th class="right">Annual est.</th>
<th class="bar-col"></th>
</tr>
</thead>
<tbody>
{#each rows as r}
<tr>
<td class="cat">{r.cat}</td>
<td class="right muted">${r.period.toFixed(0)}</td>
<td class="right bold">${r.annual.toLocaleString()}</td>
<td class="bar-col">
<div class="bar-track">
<div
class="bar-fill"
style="width:{totalAnnual > 0 ? (r.annual / totalAnnual * 100).toFixed(1) : 0}%"
></div>
</div>
</td>
</tr>
{/each}
<tr class="total-row">
<td>Total</td>
<td class="right">${totalPeriod.toFixed(0)}</td>
<td class="right bold">${totalAnnual.toLocaleString()}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
<p class="disclaimer">
⚠️ Estimate only — based on a single statement. One-off purchases (appliances, gifts) will inflate the projection.
</p>
</div>
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 2px;
}
.sub { font-size: 0.78rem; color: #bbb; }
.hero-number {
display: flex;
align-items: baseline;
gap: 2px;
color: #e74c3c;
flex-shrink: 0;
}
.tilde { font-size: 1.2rem; opacity: 0.6; }
.amount { font-size: 2rem; font-weight: 800; line-height: 1; letter-spacing: -0.03em; }
.per-year { font-size: 0.9rem; font-weight: 600; opacity: 0.7; margin-left: 2px; }
.lead-insight {
font-size: 0.875rem;
color: #555;
background: #fff8f5;
border-left: 3px solid #e74c3c;
border-radius: 0 8px 8px 0;
padding: 0.6rem 1rem;
margin-bottom: 1.25rem;
line-height: 1.5;
}
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
thead th {
text-align: left;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #bbb;
padding: 6px 8px 8px;
border-bottom: 2px solid #f5f6fa;
}
td {
padding: 8px 8px;
border-bottom: 1px solid #f9fafd;
color: #444;
}
.cat { font-weight: 500; white-space: nowrap; }
.right { text-align: right; white-space: nowrap; }
.muted { color: #aaa; }
.bold { font-weight: 700; color: #1a1a2e; }
/* Inline bar */
.bar-col { width: 100px; padding-left: 12px; }
.bar-track {
height: 6px;
background: #f0f2f7;
border-radius: 99px;
overflow: hidden;
min-width: 60px;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, #006fcf, #0891b2);
border-radius: 99px;
transition: width 0.5s ease;
}
/* Total row */
.total-row td {
font-weight: 700;
color: #1a1a2e;
border-top: 2px solid #f0f2f7;
border-bottom: none;
padding-top: 10px;
}
.disclaimer {
margin-top: 1rem;
font-size: 0.75rem;
color: #bbb;
line-height: 1.5;
}
@media (max-width: 500px) {
.hero-number { width: 100%; justify-content: flex-end; }
.bar-col { display: none; }
}
</style>
@@ -0,0 +1,168 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { Chart, DoughnutController, ArcElement, Tooltip, Legend } from 'chart.js'
Chart.register(DoughnutController, ArcElement, Tooltip, Legend)
export let byCategory = {}
export let categoryIcons = {}
const PALETTE = [
'#006fcf','#16a34a','#7c3aed','#d97706','#e74c3c',
'#0891b2','#065f46','#92400e','#6d28d9','#be185d',
'#0369a1','#15803d',
]
let canvas
let chart
let chartReady = false
// Read entries directly here so Svelte tracks it as a dependency.
// Previously this called updateChart() which Svelte can't introspect.
$: entries = Object.entries(byCategory).filter(([k]) => k !== 'Payments')
$: hasData = entries.length > 0
// Reactive update: inline the data writes so Svelte tracks `entries` + `chart`
$: if (chart && entries.length) {
chart.data.labels = entries.map(([k]) => k)
chart.data.datasets[0].data = entries.map(([, v]) => v)
chart.data.datasets[0].backgroundColor = PALETTE.slice(0, entries.length)
chart.update('none')
}
onMount(() => {
if (!hasData) return
const labels = entries.map(([k]) => k)
const values = entries.map(([, v]) => v)
const isMobile = window.innerWidth < 600
chart = new Chart(canvas, {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: PALETTE,
borderWidth: 3,
borderColor: '#fff',
hoverOffset: 8,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
animation: { onComplete: () => { chartReady = true } },
plugins: {
legend: {
position: isMobile ? 'bottom' : 'right',
labels: {
font: { family: 'Inter', size: 12 },
padding: isMobile ? 10 : 14,
usePointStyle: true,
pointStyleWidth: 8,
generateLabels(chart) {
const data = chart.data
const total = data.datasets[0].data.reduce((a, b) => a + b, 0)
return data.labels.map((label, i) => {
const value = data.datasets[0].data[i]
const pct = total > 0 ? ((value / total) * 100).toFixed(0) : 0
const icon = categoryIcons[label] || ''
return {
text: `${icon} ${label} $${value.toFixed(0)} (${pct}%)`,
fillStyle: data.datasets[0].backgroundColor[i],
strokeStyle: '#fff',
pointStyle: 'circle',
index: i,
hidden: false,
}
})
},
},
},
tooltip: {
callbacks: {
label(ctx) {
const total = ctx.dataset.data.reduce((a, b) => a + b, 0)
const pct = total > 0 ? ((ctx.parsed / total) * 100).toFixed(1) : 0
return ` $${ctx.parsed.toFixed(2)} · ${pct}%`
},
},
},
},
},
})
})
onDestroy(() => chart?.destroy())
</script>
<div class="card">
<h3 class="section-title">Spending by Category</h3>
{#if hasData}
<div class="chart-wrap" class:loading={!chartReady}>
<canvas bind:this={canvas}></canvas>
{#if !chartReady}
<div class="shimmer" aria-hidden="true"></div>
{/if}
</div>
{:else}
<div class="empty">
<span class="empty-icon">📊</span>
<p>No category data available</p>
</div>
{/if}
</div>
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 1rem;
}
.chart-wrap {
position: relative;
height: 280px;
}
.chart-wrap canvas {
transition: opacity 0.3s;
}
.chart-wrap.loading canvas {
opacity: 0;
}
.shimmer {
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(90deg, #f0f2f7 25%, #e8eaf0 50%, #f0f2f7 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 0.75rem;
color: #bbb;
}
.empty-icon { font-size: 2rem; }
.empty p { font-size: 0.875rem; }
@media (max-width: 500px) {
.chart-wrap { height: 380px; }
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
export let result = null
let email = ''
let state = 'idle' // idle | sending | success | error
let errMsg = ''
async function send() {
if (!email || !email.includes('@')) {
errMsg = 'Please enter a valid email address.'
return
}
state = 'sending'
errMsg = ''
try {
const res = await fetch('/api/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: email, result }),
})
const data = await res.json()
if (!res.ok) {
errMsg = data.error || 'Failed to send email.'
state = 'error'
} else {
state = 'success'
}
} catch (err) {
errMsg = 'Network error — is the Flask server running?'
state = 'error'
}
}
function close() { dispatch('close') }
function onBackdrop(e) {
if (e.target === e.currentTarget) close()
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="backdrop" on:click={onBackdrop} role="dialog" aria-modal="true">
<div class="modal">
<div class="modal-header">
<div class="modal-title">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 4a2 2 0 0 0-2 2v1.161l8.441 4.221a1.25 1.25 0 0 0 1.118 0L19 7.162V6a2 2 0 0 0-2-2H3Z"/><path d="m19 8.839-7.77 3.885a2.75 2.75 0 0 1-2.46 0L1 8.839V14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.839Z"/></svg>
Email Report
</div>
<button class="close-btn" on:click={close} aria-label="Close">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"/></svg>
</button>
</div>
{#if state === 'success'}
<div class="success-state">
<div class="success-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
</div>
<h3>Report Sent!</h3>
<p>Your statement summary has been sent to <strong>{email}</strong></p>
<button class="btn-primary" on:click={close}>Done</button>
</div>
{:else}
<div class="modal-body">
<!-- Summary preview -->
<div class="summary-preview">
<div class="preview-row">
<span>Total Spend</span>
<strong>${result?.total_spend?.toFixed(2) ?? '—'}</strong>
</div>
<div class="preview-row">
<span>Payments</span>
<strong class="green">${result?.total_payments?.toFixed(2) ?? '—'}</strong>
</div>
<div class="preview-row">
<span>Transactions</span>
<strong>{result?.transaction_count ?? '—'}</strong>
</div>
{#if result?.insights?.length > 0}
<div class="preview-divider"></div>
{#each result.insights.slice(0, 3) as ins}
<div class="preview-row insight-row">
<span>{ins.icon} {ins.title}</span>
<strong>{ins.stat}</strong>
</div>
{/each}
{/if}
</div>
<p class="send-label">Send this summary to:</p>
<div class="input-row">
<input
type="email"
placeholder="you@example.com"
bind:value={email}
disabled={state === 'sending'}
on:keydown={e => e.key === 'Enter' && send()}
/>
</div>
{#if state === 'error'}
<div class="error-msg">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14Zm0-9.5a.75.75 0 0 0-.75.75v3.5a.75.75 0 0 0 1.5 0v-3.5A.75.75 0 0 0 8 5.5Zm0 7a.875.875 0 1 0 0-1.75.875.875 0 0 0 0 1.75Z"/></svg>
{errMsg}
</div>
{/if}
<div class="modal-actions">
<button class="btn-ghost" on:click={close}>Cancel</button>
<button class="btn-primary" on:click={send} disabled={state === 'sending'}>
{#if state === 'sending'}
<span class="btn-spinner"></span> Sending…
{:else}
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M3.105 2.288a.75.75 0 0 0-.826.95l1.414 4.926A1.5 1.5 0 0 0 5.135 9.25h6.115a.75.75 0 0 1 0 1.5H5.135a1.5 1.5 0 0 0-1.442 1.086l-1.414 4.926a.75.75 0 0 0 .826.95 28.897 28.897 0 0 0 15.293-7.154.75.75 0 0 0 0-1.115A28.897 28.897 0 0 0 3.105 2.288Z"/></svg>
Send Report
{/if}
</button>
</div>
<p class="smtp-note">
Requires SMTP configured in <code>.env</code> — see <code>.env.example</code>
</p>
</div>
{/if}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(10, 15, 30, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 100;
padding: 0;
}
@media (min-width: 600px) {
.backdrop { align-items: center; padding: 1.5rem; }
}
.modal {
background: #fff;
border-radius: 20px 20px 0 0;
width: 100%;
max-width: 460px;
overflow: hidden;
animation: slideUp 0.25s ease;
box-shadow: 0 -4px 40px rgba(0,0,0,0.15);
}
@media (min-width: 600px) {
.modal { border-radius: 20px; animation: fadeScale 0.2s ease; }
}
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes fadeScale { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 1.5rem;
border-bottom: 1px solid #f0f2f7;
}
.modal-title {
display: flex; align-items: center; gap: 0.6rem;
font-size: 1rem; font-weight: 650; color: #1a1a2e;
}
.modal-title svg { width: 18px; height: 18px; color: #006fcf; }
.close-btn {
width: 32px; height: 32px; border-radius: 8px;
background: #f5f6fa; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #888; transition: background 0.15s;
}
.close-btn:hover { background: #e8eaf0; color: #333; }
.close-btn svg { width: 16px; height: 16px; }
.modal-body { padding: 1.25rem 1.5rem; }
/* Summary preview */
.summary-preview {
background: #f8f9fb;
border: 1px solid #e8eaf0;
border-radius: 12px;
padding: 0.85rem 1rem;
margin-bottom: 1.25rem;
}
.preview-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0;
font-size: 0.875rem;
}
.preview-row span { color: #777; }
.preview-row strong { color: #1a1a2e; }
.green { color: #16a34a !important; }
.preview-divider { border-top: 1px solid #e8eaf0; margin: 0.5rem 0; }
.insight-row span { font-size: 0.82rem; }
.send-label { font-size: 0.82rem; font-weight: 500; color: #555; margin-bottom: 0.5rem; }
.input-row { margin-bottom: 1rem; }
input[type="email"] {
width: 100%;
font-family: inherit;
font-size: 0.95rem;
border: 1px solid #e0e4ee;
border-radius: 10px;
padding: 12px 14px;
color: #333;
background: #fff;
outline: none;
transition: border-color 0.15s;
min-height: 48px;
}
input[type="email"]:focus { border-color: #006fcf; }
input[type="email"]:disabled { opacity: 0.6; }
.error-msg {
display: flex; align-items: flex-start; gap: 0.5rem;
background: #fff5f5;
border: 1px solid #fecaca;
color: #b91c1c;
border-radius: 8px;
padding: 0.75rem;
font-size: 0.82rem;
margin-bottom: 1rem;
line-height: 1.4;
}
.error-msg svg { width: 14px; height: 14px; flex-shrink: 0; margin-top: 1px; }
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-bottom: 0.85rem;
}
.btn-ghost {
padding: 10px 18px;
background: transparent;
border: 1px solid #e0e4ee;
color: #666;
border-radius: 10px;
font-size: 0.875rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
min-height: 44px;
}
.btn-ghost:hover { border-color: #aaa; color: #333; }
.btn-primary {
display: flex; align-items: center; gap: 0.4rem;
padding: 10px 20px;
background: #006fcf;
color: #fff;
border: none;
border-radius: 10px;
font-size: 0.875rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
min-height: 44px;
}
.btn-primary svg { width: 15px; height: 15px; }
.btn-primary:hover { background: #0058a8; }
.btn-primary:disabled { opacity: 0.65; cursor: not-allowed; }
.btn-spinner {
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.smtp-note {
font-size: 0.72rem;
color: #ccc;
text-align: center;
}
.smtp-note code {
background: #f0f2f7;
padding: 1px 4px;
border-radius: 3px;
color: #888;
font-size: 0.7rem;
}
/* Success state */
.success-state {
padding: 2.5rem 1.5rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.success-icon {
width: 56px; height: 56px;
background: #f0faf4;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: #16a34a;
}
.success-icon svg { width: 28px; height: 28px; }
.success-state h3 { font-size: 1.1rem; font-weight: 700; }
.success-state p { font-size: 0.875rem; color: #666; }
</style>
@@ -0,0 +1,235 @@
<script>
export let byCategory = {}
export let totalSpend = 0
// Categories treated as committed/fixed monthly costs
const FIXED_CATS = new Set(['Utilities', 'Subscriptions'])
$: fixedEntries = Object.entries(byCategory).filter(([k]) => FIXED_CATS.has(k) && k !== 'Payments')
$: variableEntries = Object.entries(byCategory).filter(([k]) => !FIXED_CATS.has(k) && k !== 'Payments')
$: fixedTotal = fixedEntries.reduce((s, [, v]) => s + v, 0)
$: variableTotal = variableEntries.reduce((s, [, v]) => s + v, 0)
$: fixedPct = totalSpend > 0 ? (fixedTotal / totalSpend * 100) : 0
$: variablePct = totalSpend > 0 ? (variableTotal / totalSpend * 100) : 0
$: hasData = totalSpend > 0
// Variable breakdown sorted desc
$: variableSorted = [...variableEntries].sort((a, b) => b[1] - a[1])
$: fixedSorted = [...fixedEntries].sort((a, b) => b[1] - a[1])
// Insight copy
$: insightText = (() => {
if (!hasData) return ''
const f = fixedPct.toFixed(0)
const v = variablePct.toFixed(0)
if (fixedPct < 15) return `Your committed costs are low at ${f}% — most of your spending is fully in your control.`
if (fixedPct > 40) return `${f}% of spend is locked in to fixed commitments — review subscriptions and utilities for savings.`
return `${f}% committed, ${v}% discretionary — a healthy split. Your floor is $${fixedTotal.toFixed(0)}/month.`
})()
</script>
{#if hasData}
<div class="card">
<h3 class="section-title">Fixed vs Variable Spending</h3>
<!-- Visual split bar -->
<div class="split-bar-wrap">
<div class="split-bar">
<div
class="seg fixed"
style="width:{fixedPct.toFixed(1)}%"
title="Fixed: ${fixedTotal.toFixed(2)}"
></div>
<div
class="seg variable"
style="width:{variablePct.toFixed(1)}%"
title="Variable: ${variableTotal.toFixed(2)}"
></div>
</div>
<div class="split-legend">
<div class="legend-item">
<span class="dot fixed-dot"></span>
<span class="leg-label">Fixed</span>
<span class="leg-pct">{fixedPct.toFixed(0)}%</span>
<span class="leg-amt">${fixedTotal.toFixed(0)}</span>
</div>
<div class="legend-item">
<span class="dot variable-dot"></span>
<span class="leg-label">Variable</span>
<span class="leg-pct">{variablePct.toFixed(0)}%</span>
<span class="leg-amt">${variableTotal.toFixed(0)}</span>
</div>
</div>
</div>
<!-- Two columns: Fixed | Variable breakdown -->
<div class="two-col">
<div class="breakdown-col">
<p class="col-label fixed-label">🔒 Committed (fixed)</p>
{#if fixedSorted.length > 0}
{#each fixedSorted as [cat, amt]}
<div class="breakdown-row">
<span class="br-cat">{cat}</span>
<span class="br-bar-wrap">
<span
class="br-bar fixed-bar"
style="width:{totalSpend > 0 ? (amt / totalSpend * 100).toFixed(1) : 0}%"
></span>
</span>
<span class="br-amt">${amt.toFixed(0)}</span>
</div>
{/each}
{:else}
<p class="none">None detected</p>
{/if}
</div>
<div class="breakdown-col">
<p class="col-label variable-label">🎛️ Discretionary (variable)</p>
{#each variableSorted as [cat, amt]}
<div class="breakdown-row">
<span class="br-cat">{cat}</span>
<span class="br-bar-wrap">
<span
class="br-bar variable-bar"
style="width:{totalSpend > 0 ? (amt / totalSpend * 100).toFixed(1) : 0}%"
></span>
</span>
<span class="br-amt">${amt.toFixed(0)}</span>
</div>
{/each}
</div>
</div>
{#if insightText}
<p class="insight">{insightText}</p>
{/if}
</div>
{/if}
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 1.25rem;
}
/* ── Split bar ── */
.split-bar-wrap { margin-bottom: 1.5rem; }
.split-bar {
display: flex;
height: 14px;
border-radius: 99px;
overflow: hidden;
background: #f0f2f7;
margin-bottom: 0.75rem;
}
.seg { height: 100%; transition: width 0.6s cubic-bezier(0.4,0,0.2,1); }
.seg.fixed { background: linear-gradient(90deg, #7c3aed, #8e44ad); }
.seg.variable { background: linear-gradient(90deg, #006fcf, #0891b2); }
.split-legend {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.875rem;
}
.dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.fixed-dot { background: #7c3aed; }
.variable-dot { background: #006fcf; }
.leg-label { font-weight: 500; color: #444; }
.leg-pct { font-size: 0.78rem; color: #888; }
.leg-amt { font-weight: 700; color: #1a1a2e; margin-left: 2px; }
/* ── Breakdown columns ── */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1rem;
}
@media (max-width: 560px) {
.two-col { grid-template-columns: 1fr; gap: 1rem; }
}
.col-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.65rem;
}
.fixed-label { color: #7c3aed; }
.variable-label { color: #006fcf; }
.breakdown-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 5px 0;
border-bottom: 1px solid #f9fafd;
font-size: 0.82rem;
}
.breakdown-row:last-child { border-bottom: none; }
.br-cat {
width: 105px;
flex-shrink: 0;
color: #555;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
}
.br-bar-wrap {
flex: 1;
height: 5px;
background: #f0f2f7;
border-radius: 99px;
overflow: hidden;
}
.br-bar {
display: block;
height: 100%;
border-radius: 99px;
transition: width 0.5s ease;
}
.fixed-bar { background: #7c3aed; }
.variable-bar { background: #006fcf; }
.br-amt { font-weight: 600; color: #1a1a2e; white-space: nowrap; font-size: 0.82rem; }
.none { font-size: 0.8rem; color: #ccc; }
.insight {
font-size: 0.845rem;
color: #555;
background: #f9fafb;
border-left: 3px solid #888;
border-radius: 0 8px 8px 0;
padding: 0.65rem 1rem;
line-height: 1.5;
margin-top: 0.5rem;
}
</style>
@@ -0,0 +1,217 @@
<script>
import { onMount, onDestroy } from 'svelte'
import {
Chart, BarController, CategoryScale, LinearScale,
BarElement, Tooltip, Legend,
} from 'chart.js'
Chart.register(BarController, CategoryScale, LinearScale, BarElement, Tooltip, Legend)
export let transactions = []
let canvas
let chart
let chartReady = false
// ── Compute totals ─────────────────────────────────────────────────────────
$: grocTotal = transactions
.filter(t => !t.is_credit && t.category === 'Groceries')
.reduce((s, t) => s + t.amount, 0)
$: diningTotal = transactions
.filter(t => !t.is_credit && t.category === 'Dining & Takeaway')
.reduce((s, t) => s + t.amount, 0)
$: hasData = grocTotal > 0 || diningTotal > 0
// ── Week-by-week breakdown ─────────────────────────────────────────────────
$: weeklyData = computeWeekly(transactions)
function computeWeekly(txns) {
const weeks = {}
for (const tx of txns) {
if (tx.is_credit) continue
if (tx.category !== 'Groceries' && tx.category !== 'Dining & Takeaway') continue
const [d] = tx.date.split('.').map(Number)
const wk = `Week ${Math.ceil(d / 7)}`
if (!weeks[wk]) weeks[wk] = { Groceries: 0, Dining: 0 }
if (tx.category === 'Groceries') weeks[wk].Groceries += tx.amount
else weeks[wk].Dining += tx.amount
}
const labels = Object.keys(weeks).sort()
return {
labels,
groceries: labels.map(w => +(weeks[w].Groceries.toFixed(2))),
dining: labels.map(w => +(weeks[w].Dining.toFixed(2))),
}
}
// ── Insight sentence ───────────────────────────────────────────────────────
$: ratio = diningTotal > 0 ? grocTotal / diningTotal : null
$: combined = grocTotal + diningTotal
$: diningPct = combined > 0 ? Math.round((diningTotal / combined) * 100) : 0
$: grocPct = combined > 0 ? Math.round((grocTotal / combined) * 100) : 0
$: insight = (() => {
if (!hasData) return ''
if (diningTotal === 0) return `All food spending was groceries — great home-cooking habit! 🥗`
if (grocTotal === 0) return `No supermarket spending detected — all food was dining out. 🍔`
if (ratio !== null && ratio < 0.8)
return `Dining out dominates at ${diningPct}% of food spend ($${diningTotal.toFixed(0)}). Cooking more could save ~$${Math.round(diningTotal * 0.6)}/month. 🍳`
if (ratio !== null && ratio < 1.5)
return `Balanced split — ${grocPct}% groceries vs ${diningPct}% dining. A good mix! 🥘`
return `Strong home-cooking habit — groceries are ${ratio !== null ? ratio.toFixed(1) : '?'}× your dining spend. 🛒`
})()
onMount(() => {
if (!hasData || weeklyData.labels.length === 0) return
chart = new Chart(canvas, {
type: 'bar',
data: {
labels: weeklyData.labels,
datasets: [
{
label: 'Groceries',
data: weeklyData.groceries,
backgroundColor: '#16a34a',
borderRadius: 5,
borderSkipped: false,
},
{
label: 'Dining & Takeaway',
data: weeklyData.dining,
backgroundColor: '#e67e22',
borderRadius: 5,
borderSkipped: false,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 400, onComplete: () => { chartReady = true } },
plugins: {
legend: {
position: 'bottom',
labels: {
font: { family: 'Inter', size: 12 },
padding: 16,
usePointStyle: true,
pointStyleWidth: 8,
},
},
tooltip: {
callbacks: {
label(ctx) {
return ` ${ctx.dataset.label}: $${ctx.parsed.y.toFixed(2)}`
},
},
},
},
scales: {
x: {
grid: { display: false },
ticks: { font: { family: 'Inter', size: 12 }, color: '#999' },
},
y: {
grid: { color: '#f0f2f7' },
ticks: {
font: { family: 'Inter', size: 11 },
color: '#aaa',
callback: v => `$${v}`,
},
},
},
},
})
})
onDestroy(() => chart?.destroy())
</script>
{#if hasData}
<div class="card">
<div class="header">
<h3 class="section-title">Groceries vs Dining & Takeaway</h3>
<div class="totals">
<span class="pill groc">🛒 ${grocTotal.toFixed(0)}</span>
<span class="pill dine">🍔 ${diningTotal.toFixed(0)}</span>
</div>
</div>
<div class="chart-wrap" class:loading={!chartReady}>
<canvas bind:this={canvas}></canvas>
{#if !chartReady}
<div class="shimmer" aria-hidden="true"></div>
{/if}
</div>
{#if insight}
<p class="insight">{insight}</p>
{/if}
</div>
{/if}
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
}
.totals { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.pill {
font-size: 0.8rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
}
.pill.groc { background: #f0faf4; color: #16a34a; }
.pill.dine { background: #fff8f0; color: #e67e22; }
.chart-wrap {
position: relative;
height: 260px;
}
.chart-wrap canvas { transition: opacity 0.3s; }
.chart-wrap.loading canvas { opacity: 0; }
.shimmer {
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(90deg, #f0f2f7 25%, #e8eaf0 50%, #f0f2f7 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.insight {
margin-top: 1rem;
font-size: 0.875rem;
color: #555;
background: #f9fafb;
border-left: 3px solid #006fcf;
border-radius: 0 8px 8px 0;
padding: 0.65rem 1rem;
line-height: 1.5;
}
@media (max-width: 500px) {
.chart-wrap { height: 220px; }
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script>
export let insights = []
</script>
{#if insights.length > 0}
<div class="section">
<h3 class="section-title">Key Insights</h3>
<div class="grid">
{#each insights as ins}
<div class="insight-card" style="--color:{ins.color}">
<div class="top">
<span class="emoji">{ins.icon}</span>
<span class="title">{ins.title}</span>
<span class="stat">{ins.stat}</span>
</div>
<p class="detail">{ins.detail}</p>
</div>
{/each}
</div>
</div>
{/if}
<style>
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 0.85rem;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.85rem;
}
@media (max-width: 860px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 500px) { .grid { grid-template-columns: 1fr; } }
.insight-card {
background: #fff;
border: 1px solid #e8eaf0;
border-left: 3px solid var(--color);
border-radius: 12px;
padding: 1rem 1.1rem;
transition: box-shadow 0.15s;
}
.insight-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.07); }
.top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
flex-wrap: wrap;
}
.emoji { font-size: 1.1rem; flex-shrink: 0; }
.title {
font-size: 0.82rem;
font-weight: 600;
color: #555;
flex: 1;
min-width: 0;
}
.stat {
font-size: 0.95rem;
font-weight: 750;
color: var(--color);
white-space: nowrap;
margin-left: auto;
}
.detail {
font-size: 0.78rem;
color: #999;
line-height: 1.5;
padding-left: 1.6rem;
}
</style>
+272
View File
@@ -0,0 +1,272 @@
<script>
import { onMount, onDestroy } from 'svelte'
import {
Chart, BarController, CategoryScale, LinearScale,
BarElement, Tooltip, Legend,
} from 'chart.js'
Chart.register(BarController, CategoryScale, LinearScale, BarElement, Tooltip, Legend)
export let monthly = {}
export let weekly = {}
const PALETTE = [
'#006fcf','#16a34a','#7c3aed','#d97706','#e74c3c',
'#0891b2','#065f46','#92400e','#6d28d9','#be185d',
]
let canvas
let chart
let chartReady = false
let mode = 'category' // 'category' | 'total'
$: multiMonth = (monthly.months || []).length > 1
$: title = multiMonth ? 'Month-over-Month Spending' : 'Weekly Spending Breakdown'
// No data at all
$: weeklyEmpty = !weekly.weeks || weekly.weeks.length === 0
$: monthlyEmpty = !monthly.months || monthly.months.length === 0
$: noData = weeklyEmpty && monthlyEmpty
// ── Mode toggle: destroy + rebuild without reactive magic ──────────────────
function setMode(m) {
if (m === mode) return
mode = m
if (chart) { chart.destroy(); chart = null; chartReady = false }
buildChart()
}
// ── Chart builders ─────────────────────────────────────────────────────────
function buildChart() {
if (!canvas) return
if (noData) return
if (multiMonth) buildMonthlyChart()
else buildWeeklyChart()
}
function buildMonthlyChart() {
const months = monthly.months || []
const cats = (monthly.categories || []).filter(c => c !== 'Payments').slice(0, 8)
if (months.length === 0) return
let datasets
if (mode === 'category') {
datasets = cats.map((cat, i) => ({
label: cat,
data: months.map(m => monthly.by_month?.[m]?.[cat] ?? 0),
backgroundColor: PALETTE[i % PALETTE.length],
borderRadius: 4,
borderSkipped: false,
}))
} else {
datasets = [{
label: 'Total Spend',
data: months.map(m => monthly.totals?.[m] ?? 0),
backgroundColor: '#006fcf',
borderRadius: 6,
borderSkipped: false,
}]
}
chart = new Chart(canvas, {
type: 'bar',
data: { labels: months, datasets },
options: makeOptions(mode === 'category'),
})
chart.options.animation = {
onComplete: () => { chartReady = true }
}
chart.update()
}
function buildWeeklyChart() {
const weeks = weekly.weeks || []
if (weeks.length === 0) return
chart = new Chart(canvas, {
type: 'bar',
data: {
labels: weeks,
datasets: [{
label: 'Spend',
data: weeks.map(w => weekly.totals?.[w] ?? 0),
backgroundColor: weeks.map((_, i) => PALETTE[i % PALETTE.length]),
borderRadius: 8,
borderSkipped: false,
}],
},
options: makeOptions(false),
})
chart.options.animation = {
onComplete: () => { chartReady = true }
}
chart.update()
}
function makeOptions(stacked) {
return {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 400 },
plugins: {
legend: {
display: stacked && (monthly.categories || []).filter(c => c !== 'Payments').length > 1,
position: 'bottom',
labels: {
font: { family: 'Inter', size: 11 },
padding: 12,
usePointStyle: true,
pointStyleWidth: 8,
},
},
tooltip: {
callbacks: {
label(ctx) {
return ` ${ctx.dataset.label}: $${ctx.parsed.y.toFixed(2)}`
},
},
},
},
scales: {
x: {
stacked,
grid: { display: false },
ticks: { font: { family: 'Inter', size: 12 }, color: '#999' },
},
y: {
stacked,
grid: { color: '#f0f2f7' },
ticks: {
font: { family: 'Inter', size: 11 },
color: '#aaa',
callback: v => `$${v}`,
},
},
},
}
}
onMount(() => buildChart())
onDestroy(() => chart?.destroy())
</script>
<div class="card">
<div class="header">
<h3 class="section-title">{title}</h3>
{#if multiMonth}
<div class="toggle">
<button class:active={mode === 'category'} on:click={() => setMode('category')}>By Category</button>
<button class:active={mode === 'total'} on:click={() => setMode('total')}>Total</button>
</div>
{/if}
</div>
{#if noData}
<div class="empty">
<span class="empty-icon">📅</span>
<p class="empty-title">Not enough data</p>
<p class="empty-sub">Upload multiple statements to compare month-over-month spending.</p>
</div>
{:else}
<div class="chart-wrap" class:loading={!chartReady}>
<canvas bind:this={canvas}></canvas>
{#if !chartReady}
<div class="shimmer" aria-hidden="true"></div>
{/if}
</div>
{#if !multiMonth}
<p class="hint">Upload more statements to unlock month-over-month comparison.</p>
{/if}
{/if}
</div>
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
}
.toggle {
display: flex;
background: #f0f2f7;
border-radius: 8px;
padding: 3px;
gap: 2px;
}
.toggle button {
padding: 5px 12px;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 500;
color: #888;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
white-space: nowrap;
}
.toggle button.active {
background: #fff;
color: #1a1a2e;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.chart-wrap {
position: relative;
height: 300px;
}
.chart-wrap canvas {
transition: opacity 0.3s;
}
.chart-wrap.loading canvas { opacity: 0; }
.shimmer {
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(90deg, #f0f2f7 25%, #e8eaf0 50%, #f0f2f7 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 220px;
gap: 0.5rem;
color: #bbb;
text-align: center;
padding: 1rem;
}
.empty-icon { font-size: 2rem; }
.empty-title { font-size: 0.95rem; font-weight: 600; color: #ccc; }
.empty-sub { font-size: 0.82rem; color: #bbb; max-width: 280px; line-height: 1.5; }
.hint {
margin-top: 0.75rem;
font-size: 0.75rem;
color: #bbb;
text-align: center;
}
@media (max-width: 500px) {
.chart-wrap { height: 240px; }
}
</style>
@@ -0,0 +1,223 @@
<script>
export let transactions = []
// ── Statement period for annualising ─────────────────────────────────────
$: periodDays = (() => {
const spends = transactions.filter(t => !t.is_credit)
if (spends.length < 2) return 30
const ms = spends.map(t => {
const [d, m, y] = t.date.split('.').map(Number)
return new Date(2000 + y, m - 1, d).getTime()
})
const days = Math.round((Math.max(...ms) - Math.min(...ms)) / 86400000) + 1
return Math.max(days, 1)
})()
$: multiplier = 365 / periodDays
// ── Clean subscription name from raw description ──────────────────────────
const NAME_MAP = [
['GOOGLE', 'Google', '🔵'],
['APPLE.COM', 'Apple', '🍎'],
['NETFLIX', 'Netflix', '🎬'],
['SPOTIFY', 'Spotify', '🎵'],
['DISNEY', 'Disney+', '🏰'],
['MICROSOFT', 'Microsoft', '🪟'],
['ADOBE', 'Adobe', '🎨'],
['DROPBOX', 'Dropbox', '📦'],
['AMAZON', 'Amazon', '📦'],
['MYOB', 'MYOB', '📊'],
['EMBY', 'Emby', '🎞️'],
['SKYDRIVE', 'Skydrive', '☁️'],
['YOUTUBE', 'YouTube', '▶️'],
['ICLOUD', 'iCloud', '☁️'],
['GITHUB', 'GitHub', '🐙'],
['ATLASSIAN', 'Atlassian', '🔷'],
['CANVA', 'Canva', '✏️'],
['LASTPASS', 'LastPass', '🔑'],
['1PASSWORD', '1Password', '🔑'],
]
function cleanSub(desc) {
const upper = desc.toUpperCase()
for (const [key, name, emoji] of NAME_MAP) {
if (upper.includes(key)) return { name, emoji }
}
const word = desc.split(/\s+/)[0]
return {
name: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
emoji: '📱',
}
}
// ── Group subscription transactions ───────────────────────────────────────
$: subs = (() => {
const subTxns = transactions.filter(t => !t.is_credit && t.category === 'Subscriptions')
const map = {}
for (const tx of subTxns) {
const { name, emoji } = cleanSub(tx.description)
if (!map[name]) map[name] = { name, emoji, total: 0, count: 0 }
map[name].total += tx.amount
map[name].count++
}
return Object.values(map).sort((a, b) => b.total - a.total)
})()
$: hasData = subs.length > 0
$: periodTotal = subs.reduce((s, r) => s + r.total, 0)
$: annualTotal = Math.round(periodTotal * multiplier)
// Flag the biggest subscription
$: biggest = subs[0]
</script>
{#if hasData}
<div class="card">
<div class="card-header">
<div>
<h3 class="section-title">📱 Subscription Audit</h3>
<p class="sub">{subs.length} active subscription{subs.length !== 1 ? 's' : ''} detected</p>
</div>
<div class="totals">
<div class="total-pill">
<span class="t-label">This period</span>
<span class="t-val">${periodTotal.toFixed(2)}</span>
</div>
<div class="total-pill annual">
<span class="t-label">Est. annual</span>
<span class="t-val">~${annualTotal.toLocaleString()}</span>
</div>
</div>
</div>
<div class="sub-list">
{#each subs as s}
<div class="sub-row">
<span class="sub-emoji">{s.emoji}</span>
<span class="sub-name">{s.name}</span>
{#if s.count > 1}
<span class="sub-count">{s.count}×</span>
{/if}
<span class="sub-spacer"></span>
<div class="sub-amounts">
<span class="sub-period">${s.total.toFixed(2)}</span>
<span class="sub-annual">~${Math.round(s.total * multiplier).toLocaleString()}/yr</span>
</div>
</div>
{/each}
<div class="sub-row total-row">
<span class="sub-emoji"></span>
<span class="sub-name">Total</span>
<span class="sub-spacer"></span>
<div class="sub-amounts">
<span class="sub-period">${periodTotal.toFixed(2)}</span>
<span class="sub-annual bold">~${annualTotal.toLocaleString()}/yr</span>
</div>
</div>
</div>
{#if biggest && biggest.total > 20}
<p class="insight">
💡 <strong>{biggest.name}</strong> is your costliest subscription at
${biggest.total.toFixed(2)} this period
(~${Math.round(biggest.total * multiplier).toLocaleString()}/yr).
Worth reviewing if it's still earning its keep.
</p>
{:else}
<p class="insight">
💡 Your subscriptions total ~${annualTotal.toLocaleString()}/year.
Review each annually — unused subscriptions are pure waste.
</p>
{/if}
</div>
{/if}
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 2px;
}
.sub { font-size: 0.78rem; color: #bbb; }
.totals { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.total-pill {
display: flex;
flex-direction: column;
align-items: flex-end;
background: #f9fafb;
border: 1px solid #eee;
border-radius: 10px;
padding: 6px 12px;
min-width: 90px;
}
.total-pill.annual { background: #f5f0ff; border-color: #d8c8ff; }
.t-label { font-size: 0.68rem; color: #bbb; text-transform: uppercase; letter-spacing: 0.05em; }
.t-val { font-size: 1rem; font-weight: 700; color: #1a1a2e; }
.total-pill.annual .t-val { color: #7c3aed; }
/* Sub list */
.sub-list { display: flex; flex-direction: column; gap: 0; }
.sub-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 9px 4px;
border-bottom: 1px solid #f5f6fa;
font-size: 0.875rem;
}
.sub-row:last-child { border-bottom: none; }
.sub-emoji { font-size: 1rem; flex-shrink: 0; width: 22px; text-align: center; }
.sub-name { font-weight: 500; color: #333; }
.sub-count { font-size: 0.72rem; color: #bbb; }
.sub-spacer { flex: 1; }
.sub-amounts {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
}
.sub-period { font-weight: 600; color: #1a1a2e; font-size: 0.875rem; }
.sub-annual { font-size: 0.72rem; color: #888; }
.total-row {
border-top: 2px solid #f0f2f7;
border-bottom: none !important;
padding-top: 12px;
margin-top: 2px;
}
.total-row .sub-name { font-weight: 700; color: #1a1a2e; }
.bold { font-weight: 700 !important; color: #7c3aed !important; font-size: 0.82rem !important; }
.insight {
margin-top: 1rem;
font-size: 0.845rem;
color: #555;
background: #faf8ff;
border-left: 3px solid #7c3aed;
border-radius: 0 8px 8px 0;
padding: 0.65rem 1rem;
line-height: 1.5;
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<script>
export let result
$: topCat = (() => {
const entries = Object.entries(result.by_category).filter(([k]) => k !== 'Payments')
if (!entries.length) return { name: '—', amount: 0 }
const [name, amount] = entries[0]
return { name, amount }
})()
$: cards = [
{
label: 'Total Spend',
value: `$${result.total_spend.toLocaleString('en-NZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
sub: `${result.spend_count} transactions`,
accent: '#006fcf',
bg: '#f0f7ff',
icon: 'M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z',
},
{
label: 'Payments Made',
value: `$${result.total_payments.toLocaleString('en-NZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
sub: 'Credits received',
accent: '#16a34a',
bg: '#f0faf4',
icon: 'M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z',
},
{
label: 'Top Category',
value: topCat.name,
sub: `$${topCat.amount.toFixed(2)}`,
accent: '#7c3aed',
bg: '#f5f3ff',
icon: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z',
},
{
label: 'Transactions',
value: result.transaction_count,
sub: 'This period',
accent: '#d97706',
bg: '#fffbeb',
icon: 'M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z',
},
]
</script>
<div class="cards">
{#each cards as c}
<div class="card" style="--accent:{c.accent};--bg:{c.bg}">
<div class="card-top">
<span class="label">{c.label}</span>
<span class="icon-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d={c.icon}/>
</svg>
</span>
</div>
<div class="value">{c.value}</div>
<div class="sub">{c.sub}</div>
</div>
{/each}
</div>
<style>
.cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 700px) { .cards { grid-template-columns: repeat(2, 1fr); } }
.card {
background: var(--bg);
border: 1px solid color-mix(in srgb, var(--accent) 15%, #e8eaf0);
border-radius: 16px;
padding: 1.1rem 1.25rem 1.25rem;
transition: box-shadow 0.15s;
}
.card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.07); }
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.6rem; }
.label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #999; }
.icon-wrap {
width: 28px; height: 28px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: var(--accent);
}
.icon-wrap svg { width: 14px; height: 14px; }
.value {
font-size: 1.45rem;
font-weight: 750;
color: var(--accent);
line-height: 1.2;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub { font-size: 0.75rem; color: #aaa; }
@media (max-width: 420px) {
.value { font-size: 1.2rem; }
}
</style>
@@ -0,0 +1,281 @@
<script>
export let transactions = []
const PAGE = 20
let search = ''
let filterCat = 'All'
let filterType = 'All'
let sortCol = 'date'
let sortDir = -1 // -1 = desc (newest first)
let limit = PAGE
// ── Derived ──────────────────────────────────────────────────────────────
$: cats = ['All', ...new Set(transactions.map(t => t.category).sort())]
$: filtered = transactions
.filter(t => {
if (filterCat !== 'All' && t.category !== filterCat) return false
if (filterType === 'Debits' && t.is_credit) return false
if (filterType === 'Credits' && !t.is_credit) return false
if (search.trim()) {
const q = search.trim().toLowerCase()
return t.description.toLowerCase().includes(q)
|| t.category.toLowerCase().includes(q)
}
return true
})
.sort((a, b) => {
let va = a[sortCol], vb = b[sortCol]
if (sortCol === 'date') {
// DD.MM.YY → YYMMDD for correct chronological sort
const s = v => v.split('.').reverse().join('')
va = s(va); vb = s(vb)
}
if (va < vb) return sortDir
if (va > vb) return -sortDir
return 0
})
// Plain slice — no boolean flag that Svelte can silently reset
$: visible = filtered.slice(0, limit)
$: remaining = filtered.length - limit
$: hasMore = remaining > 0
// ── Helpers ───────────────────────────────────────────────────────────────
function resetPage() { limit = PAGE }
function sortBy(col) {
if (sortCol === col) sortDir *= -1
else { sortCol = col; sortDir = col === 'amount' ? -1 : 1 }
}
function arrow(col) {
if (sortCol !== col) return ''
return sortDir === 1 ? ' ↑' : ' ↓'
}
</script>
<div class="card">
<!-- ── Toolbar ─────────────────────────────────────────────────────────── -->
<div class="toolbar">
<h3 class="section-title">Transactions</h3>
<div class="controls">
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z" clip-rule="evenodd"/>
</svg>
<input
type="search"
placeholder="Search description or category…"
bind:value={search}
on:input={resetPage}
/>
</div>
<select bind:value={filterCat} on:change={resetPage}>
{#each cats as c}<option>{c}</option>{/each}
</select>
<select bind:value={filterType} on:change={resetPage}>
<option>All</option>
<option>Debits</option>
<option>Credits</option>
</select>
</div>
</div>
<!-- ── Desktop table ──────────────────────────────────────────────────── -->
<div class="table-wrap desktop-only">
<table>
<thead>
<tr>
<th on:click={() => sortBy('date')}>Date{arrow('date')}</th>
<th on:click={() => sortBy('description')}>Description{arrow('description')}</th>
<th on:click={() => sortBy('category')}>Category{arrow('category')}</th>
<th class="right" on:click={() => sortBy('amount')}>Amount{arrow('amount')}</th>
</tr>
</thead>
<tbody>
{#each visible as tx (tx.id ?? tx.date + tx.description + tx.amount)}
<tr class:credit={tx.is_credit}>
<td class="date-cell">{tx.date}</td>
<td class="desc-cell">{tx.description}</td>
<td><span class="badge">{tx.category}</span></td>
<td class="amount-cell right" class:credit-amt={tx.is_credit}>
{tx.is_credit ? '+' : ''}{tx.amount.toFixed(2)}
</td>
</tr>
{:else}
<tr><td colspan="4" class="empty">No transactions match your filters.</td></tr>
{/each}
</tbody>
</table>
</div>
<!-- ── Mobile card list ───────────────────────────────────────────────── -->
<div class="card-list mobile-only">
{#each visible as tx (tx.id ?? tx.date + tx.description + tx.amount)}
<div class="tx-card" class:credit={tx.is_credit}>
<div class="tx-left">
<span class="tx-date">{tx.date}</span>
<span class="tx-desc">{tx.description}</span>
<span class="badge">{tx.category}</span>
</div>
<span class="tx-amount" class:credit-amt={tx.is_credit}>
{tx.is_credit ? '+' : ''}${tx.amount.toFixed(2)}
</span>
</div>
{:else}
<p class="empty">No transactions match your filters.</p>
{/each}
</div>
<!-- ── Footer ─────────────────────────────────────────────────────────── -->
<div class="footer-row">
<span class="count">
{Math.min(limit, filtered.length)} of {filtered.length} transactions
</span>
<div class="footer-btns">
{#if hasMore}
<button class="show-btn" on:click={() => limit += PAGE}>
Show {Math.min(PAGE, remaining)} more
</button>
<button class="show-btn show-all" on:click={() => limit = filtered.length}>
Show all {filtered.length}
</button>
{:else if limit > PAGE && filtered.length > PAGE}
<button class="show-btn" on:click={() => limit = PAGE}>
Show less ↑
</button>
{/if}
</div>
</div>
</div>
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
/* ── Toolbar ── */
.toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
padding-top: 2px;
}
.controls { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-icon {
position: absolute; left: 9px;
width: 14px; height: 14px; color: #aaa; pointer-events: none;
}
.search-wrap input {
font-family: inherit; font-size: 0.82rem;
border: 1px solid #e0e4ee; border-radius: 8px;
padding: 7px 10px 7px 28px;
color: #333; background: #fafbfd;
outline: none; width: 210px;
transition: border-color 0.15s;
}
.search-wrap input:focus { border-color: #006fcf; background: #fff; }
select {
font-family: inherit; font-size: 0.82rem;
border: 1px solid #e0e4ee; border-radius: 8px;
padding: 7px 10px; color: #555; background: #fafbfd;
outline: none; cursor: pointer; min-height: 34px;
transition: border-color 0.15s;
}
select:focus { border-color: #006fcf; }
/* ── Desktop table ── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
thead th {
text-align: left; padding: 8px 12px;
font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em;
color: #bbb; border-bottom: 1px solid #f0f2f7;
cursor: pointer; user-select: none; white-space: nowrap;
}
thead th:hover { color: #006fcf; }
tbody tr { border-bottom: 1px solid #f8f9fb; transition: background 0.1s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: #fafbfe; }
tbody tr.credit { background: #f7fdf9; }
tbody tr.credit:hover { background: #edf9f2; }
td { padding: 10px 12px; color: #333; }
.date-cell { color: #aaa; font-size: 0.8rem; white-space: nowrap; }
.desc-cell { font-weight: 500; max-width: 300px; }
.right { text-align: right; }
.amount-cell { font-weight: 650; font-variant-numeric: tabular-nums; white-space: nowrap; }
.credit-amt { color: #16a34a; }
/* ── Mobile cards ── */
.card-list { display: flex; flex-direction: column; gap: 0.5rem; }
.tx-card {
display: flex; align-items: center;
justify-content: space-between; gap: 0.75rem;
padding: 0.75rem 0.85rem;
background: #fafbfd; border: 1px solid #f0f2f7; border-radius: 10px;
}
.tx-card.credit { background: #f7fdf9; border-color: #dcf5e7; }
.tx-left { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; min-width: 0; }
.tx-date { font-size: 0.72rem; color: #bbb; }
.tx-desc { font-size: 0.875rem; font-weight: 500; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tx-amount { font-size: 1rem; font-weight: 700; white-space: nowrap; color: #1a1a2e; flex-shrink: 0; }
.tx-amount.credit-amt { color: #16a34a; }
/* ── Badge ── */
.badge {
display: inline-block; background: #f0f2f7; color: #666;
border-radius: 4px; padding: 2px 6px;
font-size: 0.7rem; font-weight: 500; white-space: nowrap; align-self: flex-start;
}
.empty { text-align: center; padding: 2rem; color: #ccc; font-size: 0.875rem; }
/* ── Footer ── */
.footer-row {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; margin-top: 0.85rem; padding-top: 0.85rem;
border-top: 1px solid #f5f6fa; flex-wrap: wrap;
}
.count { font-size: 0.75rem; color: #ccc; }
.footer-btns { display: flex; gap: 0.5rem; }
.show-btn {
font-size: 0.8rem; font-weight: 500; color: #006fcf;
background: none; border: 1px solid #d8eeff;
border-radius: 6px; padding: 4px 10px;
cursor: pointer; font-family: inherit; transition: background 0.15s;
}
.show-btn:hover { background: #f0f7ff; }
.show-all { border-color: #c0d8ff; font-weight: 600; }
/* ── Responsive ── */
.desktop-only { display: block; }
.mobile-only { display: none; }
@media (max-width: 640px) {
.desktop-only { display: none; }
.mobile-only { display: flex; flex-direction: column; }
.controls { width: 100%; }
.search-wrap { flex: 1; }
.search-wrap input { width: 100%; }
select { flex: 1; }
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
let dragging = false
let inputEl
let stagedFiles = []
function addFiles(fileList) {
const pdfs = Array.from(fileList).filter(f => f.type === 'application/pdf' || f.name.endsWith('.pdf'))
if (!pdfs.length) return
// Deduplicate by name
const existing = new Set(stagedFiles.map(f => f.name))
for (const f of pdfs) if (!existing.has(f.name)) stagedFiles.push(f)
stagedFiles = [...stagedFiles]
}
function onDrop(e) {
e.preventDefault()
dragging = false
addFiles(e.dataTransfer.files)
}
function onFileChange(e) {
addFiles(e.target.files)
// Reset input so same file can be re-added after removal
e.target.value = ''
}
function removeFile(name) {
stagedFiles = stagedFiles.filter(f => f.name !== name)
}
function submit() {
if (stagedFiles.length > 0) dispatch('upload', stagedFiles)
}
function formatSize(bytes) {
return bytes > 1024 * 1024
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
: `${Math.round(bytes / 1024)} KB`
}
</script>
<div class="upload-area">
<!-- Drop zone -->
<div
class="drop-zone"
class:dragging
class:has-files={stagedFiles.length > 0}
role="button"
tabindex="0"
on:click={() => inputEl.click()}
on:keydown={e => e.key === 'Enter' && inputEl.click()}
on:dragover|preventDefault={() => (dragging = true)}
on:dragleave={() => (dragging = false)}
on:drop={onDrop}
>
<div class="dz-icon">
{#if dragging}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m6.75 12-3-3m0 0-3 3m3-3v6m-1.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
</svg>
{/if}
</div>
<p class="dz-primary">{dragging ? 'Drop to add' : 'Drop PDF statements here'}</p>
<p class="dz-secondary">or <span class="link">browse files</span> · Multiple statements supported</p>
<input bind:this={inputEl} type="file" accept=".pdf,application/pdf" multiple on:change={onFileChange} style="display:none" />
</div>
<!-- Staged files list -->
{#if stagedFiles.length > 0}
<div class="file-list">
{#each stagedFiles as f}
<div class="file-item">
<svg class="file-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h4.586A2 2 0 0 1 12 2.586L15.414 6A2 2 0 0 1 16 7.414V16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4Zm2 6a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H7a1 1 0 0 1-1-1Zm1 3a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2H7Z" clip-rule="evenodd"/>
</svg>
<span class="file-name">{f.name}</span>
<span class="file-size">{formatSize(f.size)}</span>
<button class="remove-btn" on:click|stopPropagation={() => removeFile(f.name)} aria-label="Remove">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z"/></svg>
</button>
</div>
{/each}
</div>
<button class="analyse-btn" on:click={submit}>
Analyse {stagedFiles.length === 1 ? 'Statement' : `${stagedFiles.length} Statements`}
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 10a.75.75 0 0 1 .75-.75h10.638L10.23 5.29a.75.75 0 1 1 1.04-1.08l5.5 5.25a.75.75 0 0 1 0 1.08l-5.5 5.25a.75.75 0 1 1-1.04-1.08l4.158-3.96H3.75A.75.75 0 0 1 3 10Z" clip-rule="evenodd"/></svg>
</button>
{/if}
</div>
<style>
.upload-area { display: flex; flex-direction: column; gap: 1rem; }
.drop-zone {
border: 2px dashed #c8d0e0;
border-radius: 16px;
padding: 2.5rem 1.5rem;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
background: #fff;
outline: none;
-webkit-tap-highlight-color: transparent;
}
.drop-zone:hover, .drop-zone:focus-visible {
border-color: #006fcf;
background: #f0f7ff;
}
.drop-zone.dragging {
border-color: #006fcf;
background: #e6f2ff;
border-style: solid;
}
.drop-zone.has-files { padding: 1.5rem; }
.dz-icon { width: 40px; height: 40px; margin: 0 auto 0.85rem; color: #006fcf; }
.dz-icon svg { width: 100%; height: 100%; }
.dz-primary { font-weight: 600; font-size: 0.95rem; color: #333; margin-bottom: 0.3rem; }
.dz-secondary { font-size: 0.82rem; color: #999; }
.link { color: #006fcf; text-decoration: underline; }
/* File list */
.file-list { display: flex; flex-direction: column; gap: 0.5rem; }
.file-item {
display: flex;
align-items: center;
gap: 0.6rem;
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 10px;
padding: 0.65rem 0.9rem;
}
.file-icon { width: 18px; height: 18px; color: #006fcf; flex-shrink: 0; }
.file-name { flex: 1; font-size: 0.85rem; font-weight: 500; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { font-size: 0.75rem; color: #aaa; flex-shrink: 0; }
.remove-btn {
width: 24px; height: 24px; border-radius: 50%;
background: #f0f2f7; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #999; flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.remove-btn:hover { background: #fce8e8; color: #e74c3c; }
.remove-btn svg { width: 12px; height: 12px; }
/* Analyse button */
.analyse-btn {
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
width: 100%;
background: #006fcf;
color: #fff;
border: none;
padding: 14px 20px;
border-radius: 12px;
font-size: 1rem;
font-weight: 650;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
min-height: 52px;
}
.analyse-btn svg { width: 18px; height: 18px; }
.analyse-btn:hover { background: #0058a8; }
.analyse-btn:active { transform: scale(0.98); }
</style>
@@ -0,0 +1,249 @@
<script>
import { onMount, onDestroy } from 'svelte'
import {
Chart, BarController, CategoryScale, LinearScale,
BarElement, Tooltip,
} from 'chart.js'
Chart.register(BarController, CategoryScale, LinearScale, BarElement, Tooltip)
export let transactions = []
const UTIL_COLORS = {
'Spark': '#0a84ff',
'Slingshot': '#30b8c4',
'Powershop': '#f7b731',
'Mercury': '#e74c3c',
'Genesis': '#8e44ad',
'Contact': '#27ae60',
'Watercare': '#2980b9',
}
const DEFAULT_COLOR = '#006fcf'
function providerColor(name) {
const key = Object.keys(UTIL_COLORS).find(k => name.toUpperCase().includes(k.toUpperCase()))
return key ? UTIL_COLORS[key] : DEFAULT_COLOR
}
function cleanName(desc) {
// "SPARK PAY MONTHLY AUCKL" → "Spark"
// "SLINGSHOT SLINGSHOT" → "Slingshot"
// "POWERSHOP POWERSHOP" → "Powershop"
const known = ['Spark', 'Slingshot', 'Powershop', 'Mercury', 'Genesis', 'Contact', 'Watercare']
const match = known.find(n => desc.toUpperCase().includes(n.toUpperCase()))
if (match) return match
// Fallback: title-case first word
return desc.split(' ')[0].charAt(0).toUpperCase() + desc.split(' ')[0].slice(1).toLowerCase()
}
$: utilTxns = transactions.filter(t => !t.is_credit && t.category === 'Utilities')
$: hasData = utilTxns.length > 0
$: providers = (() => {
const map = {}
for (const tx of utilTxns) {
const name = cleanName(tx.description)
if (!map[name]) map[name] = { total: 0, count: 0, color: providerColor(tx.description) }
map[name].total += tx.amount
map[name].count++
}
return Object.entries(map)
.map(([name, v]) => ({ name, total: +v.total.toFixed(2), count: v.count, color: v.color }))
.sort((a, b) => b.total - a.total)
})()
$: totalUtils = providers.reduce((s, p) => s + p.total, 0)
$: maxTotal = providers.length ? providers[0].total : 1
let canvas
let chart
let chartReady = false
onMount(() => {
if (!hasData || providers.length === 0) return
chart = new Chart(canvas, {
type: 'bar',
data: {
labels: providers.map(p => p.name),
datasets: [{
data: providers.map(p => p.total),
backgroundColor: providers.map(p => p.color),
borderRadius: 6,
borderSkipped: false,
}],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
animation: { duration: 400, onComplete: () => { chartReady = true } },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(ctx) { return ` $${ctx.parsed.x.toFixed(2)}` },
},
},
},
scales: {
x: {
grid: { color: '#f0f2f7' },
ticks: {
font: { family: 'Inter', size: 11 },
color: '#aaa',
callback: v => `$${v}`,
},
},
y: {
grid: { display: false },
ticks: { font: { family: 'Inter', size: 13 }, color: '#555' },
},
},
},
})
})
onDestroy(() => chart?.destroy())
</script>
{#if hasData}
<div class="card">
<div class="header">
<h3 class="section-title">⚡ Utility Spend</h3>
<span class="total-badge">${totalUtils.toFixed(2)} total</span>
</div>
<!-- Provider detail rows -->
<div class="providers">
{#each providers as p}
<div class="row">
<div class="row-top">
<span class="provider-name" style="color:{p.color}">{p.name}</span>
<span class="provider-amount">${p.total.toFixed(2)}</span>
</div>
<div class="bar-track">
<div
class="bar-fill"
style="width:{(p.total / maxTotal * 100).toFixed(1)}%; background:{p.color}"
></div>
</div>
<span class="row-sub">{p.count} payment{p.count !== 1 ? 's' : ''} this period</span>
</div>
{/each}
</div>
<!-- Bar chart (shows nicely when 2+ providers) -->
{#if providers.length > 1}
<div class="chart-wrap" class:loading={!chartReady}>
<canvas bind:this={canvas}></canvas>
{#if !chartReady}
<div class="shimmer" aria-hidden="true"></div>
{/if}
</div>
{/if}
<p class="footer-note">
💡 Utilities account for {totalUtils > 0 ? Math.round(totalUtils / (totalUtils + 1) * 100) : 0}% of tracked recurring costs.
Review providers annually for better deals.
</p>
</div>
{/if}
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
}
.total-badge {
background: #fff8e8;
color: #d97706;
font-size: 0.82rem;
font-weight: 700;
padding: 4px 12px;
border-radius: 20px;
}
/* Provider rows */
.providers {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.25rem;
}
.row { display: flex; flex-direction: column; gap: 3px; }
.row-top {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.provider-name {
font-size: 0.95rem;
font-weight: 600;
}
.provider-amount {
font-size: 1rem;
font-weight: 700;
color: #1a1a2e;
}
.bar-track {
height: 6px;
background: #f0f2f7;
border-radius: 99px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.row-sub {
font-size: 0.75rem;
color: #bbb;
}
/* Chart */
.chart-wrap {
position: relative;
height: 160px;
margin-bottom: 1rem;
}
.chart-wrap canvas { transition: opacity 0.3s; }
.chart-wrap.loading canvas { opacity: 0; }
.shimmer {
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(90deg, #f0f2f7 25%, #e8eaf0 50%, #f0f2f7 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.footer-note {
font-size: 0.78rem;
color: #aaa;
line-height: 1.5;
border-top: 1px solid #f5f6fa;
padding-top: 0.75rem;
margin-top: 0.25rem;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import App from './App.svelte'
const app = new App({ target: document.getElementById('app') })
export default app
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:5000',
},
},
build: {
outDir: 'dist',
},
})
+6
View File
@@ -0,0 +1,6 @@
flask
flask-cors
pdfplumber
pandas
python-dotenv
gunicorn