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:
@@ -0,0 +1,9 @@
|
|||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
**/*.pyo
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
AGENT.MD
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
amexpal:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
env_file:
|
||||||
|
- .env # optional — only needed for email sending
|
||||||
|
restart: unless-stopped
|
||||||
@@ -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>
|
||||||
Generated
+1320
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import App from './App.svelte'
|
||||||
|
|
||||||
|
const app = new App({ target: document.getElementById('app') })
|
||||||
|
|
||||||
|
export default app
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
flask
|
||||||
|
flask-cors
|
||||||
|
pdfplumber
|
||||||
|
pandas
|
||||||
|
python-dotenv
|
||||||
|
gunicorn
|
||||||
Reference in New Issue
Block a user