commit c1e22da9d6b75b33d8b7536514136da36227fdbb Author: ponzischeme89 Date: Thu Mar 26 08:37:01 2026 +1300 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8806553 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/__pycache__ +**/*.pyc +**/*.pyo +.env +.git +.gitignore +frontend/node_modules +frontend/dist +AGENT.MD diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d9b3b34 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e28e30a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENT.MD b/AGENT.MD new file mode 100644 index 0000000..48b340b --- /dev/null +++ b/AGENT.MD @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0889cd2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/amex_analyser.py b/amex_analyser.py new file mode 100644 index 0000000..1d83c39 --- /dev/null +++ b/amex_analyser.py @@ -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, + } diff --git a/amex_parser.py b/amex_parser.py new file mode 100644 index 0000000..90eb352 --- /dev/null +++ b/amex_parser.py @@ -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 diff --git a/app.py b/app.py new file mode 100644 index 0000000..26eba32 --- /dev/null +++ b/app.py @@ -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""" + + {icon} {cat} + +
+
+
+ + ${amount:.2f} + """ + + insight_rows = '' + for ins in insights: + insight_rows += f""" + + {ins['icon']} {ins['title']} + {ins['stat']} + + {ins['detail']}""" + + merchant_rows = '' + for m in top_merchants: + merchant_rows += f""" + + {m['name']} + {m['count']}× + ${m['total']:.2f} + """ + + return f""" + + + +
+ +
+
American Express
+
Statement Summary
+
{tx_count} transactions analysed
+
+ +
+ + + + + + + + +
+
Total Spend
+
${total_spend:.2f}
+
+
Payments
+
${total_payments:.2f}
+
+
Transactions
+
{tx_count}
+
+ +
Spending by Category
+ + {cat_rows} +
+ +
Key Insights
+ + {insight_rows} +
+ +
Top Merchants
+ + {merchant_rows} +
+
+ +
+ Generated by AMEX Statement Analyser · Your data never leaves your device +
+
+ +""" + + +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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..195b395 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + amexpal: + build: . + ports: + - "5000:5000" + env_file: + - .env # optional — only needed for email sending + restart: unless-stopped diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..45a90cc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + AmexPal + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a729323 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1320 @@ +{ + "name": "amex-analyser", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "amex-analyser", + "version": "1.0.0", + "dependencies": { + "chart.js": "^4.4.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.2.0", + "vite": "^5.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", + "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", + "debug": "^4.3.4", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.10", + "svelte-hmr": "^0.16.0", + "vitefu": "^0.2.5" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz", + "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/code-red": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", + "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@types/estree": "^1.0.1", + "acorn": "^8.10.0", + "estree-walker": "^3.0.3", + "periscopic": "^3.1.0" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/periscopic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^3.0.0", + "is-reference": "^3.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz", + "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/estree": "^1.0.1", + "acorn": "^8.9.0", + "aria-query": "^5.3.0", + "axobject-query": "^4.0.0", + "code-red": "^1.0.3", + "css-tree": "^2.3.1", + "estree-walker": "^3.0.3", + "is-reference": "^3.0.1", + "locate-character": "^3.0.0", + "magic-string": "^0.30.4", + "periscopic": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/svelte-hmr": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", + "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^12.20 || ^14.13.1 || >= 16" + }, + "peerDependencies": { + "svelte": "^3.19.0 || ^4.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", + "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..aaee724 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..c2ccdf8 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,371 @@ + + +
+
+
+ + {#if result} +
+ + +
+ {/if} +
+
+ +
+ {#if !result && !loading} +
+
+
+ + + + + +
+

AmexPal

+

Upload one or more AMEX PDF statements to get instant insights — categories, trends, and a personalised summary you can email yourself.

+
+ + {#if error} +
+ + {error} +
+ {/if} +
+ + {:else if loading} +
+
+

Parsing {fileNames.length > 1 ? `${fileNames.length} statements` : fileNames[0]}…

+
+ + {:else if result} +
+
+
+

{multiMonth ? `${result.monthly.months[0]} – ${result.monthly.months[result.monthly.months.length - 1]}` : (result.monthly.months[0] || 'Statement Analysis')}

+

{fileNames.join(', ')}

+
+ +
+ + + + + +
+ + +
+ +
+ +
+

Top Merchants

+ {#each result.top_merchants as m, i} +
+ {i + 1} + {m.name} + {m.count}× + ${m.total.toFixed(2)} +
+ {/each} +
+
+ + + + + +
+ + + + {/if} +
+ + {#if showEmail} + showEmail = false} /> + {/if} +
+ + diff --git a/frontend/src/components/AnnualisedProjection.svelte b/frontend/src/components/AnnualisedProjection.svelte new file mode 100644 index 0000000..cfdacbb --- /dev/null +++ b/frontend/src/components/AnnualisedProjection.svelte @@ -0,0 +1,206 @@ + + +
+
+
+

At this rate…

+

{periodLabel} · extrapolated to 12 months

+
+
+ ~${totalAnnual.toLocaleString()} + /year +
+
+ + {#if biggestCat} +

+ {biggestCat.cat} is your biggest line item — on track for + ~${biggestCat.annual.toLocaleString()}/year. +

+ {/if} + +
+ + + + + + + + + + + {#each rows as r} + + + + + + + {/each} + + + + + + + +
CategoryThis periodAnnual est.
{r.cat}${r.period.toFixed(0)}${r.annual.toLocaleString()} +
+
+
+
Total${totalPeriod.toFixed(0)}${totalAnnual.toLocaleString()}
+
+ +

+ ⚠️ Estimate only — based on a single statement. One-off purchases (appliances, gifts) will inflate the projection. +

+
+ + diff --git a/frontend/src/components/CategoryChart.svelte b/frontend/src/components/CategoryChart.svelte new file mode 100644 index 0000000..8c76400 --- /dev/null +++ b/frontend/src/components/CategoryChart.svelte @@ -0,0 +1,168 @@ + + +
+

Spending by Category

+ {#if hasData} +
+ + {#if !chartReady} + + {/if} +
+ {:else} +
+ 📊 +

No category data available

+
+ {/if} +
+ + diff --git a/frontend/src/components/EmailModal.svelte b/frontend/src/components/EmailModal.svelte new file mode 100644 index 0000000..9f9f6cd --- /dev/null +++ b/frontend/src/components/EmailModal.svelte @@ -0,0 +1,322 @@ + + + + + + diff --git a/frontend/src/components/FixedVariableSplit.svelte b/frontend/src/components/FixedVariableSplit.svelte new file mode 100644 index 0000000..c24775e --- /dev/null +++ b/frontend/src/components/FixedVariableSplit.svelte @@ -0,0 +1,235 @@ + + +{#if hasData} +
+

Fixed vs Variable Spending

+ + +
+
+
+
+
+
+
+ + Fixed + {fixedPct.toFixed(0)}% + ${fixedTotal.toFixed(0)} +
+
+ + Variable + {variablePct.toFixed(0)}% + ${variableTotal.toFixed(0)} +
+
+
+ + +
+
+

🔒 Committed (fixed)

+ {#if fixedSorted.length > 0} + {#each fixedSorted as [cat, amt]} +
+ {cat} + + + + ${amt.toFixed(0)} +
+ {/each} + {:else} +

None detected

+ {/if} +
+ +
+

🎛️ Discretionary (variable)

+ {#each variableSorted as [cat, amt]} +
+ {cat} + + + + ${amt.toFixed(0)} +
+ {/each} +
+
+ + {#if insightText} +

{insightText}

+ {/if} +
+{/if} + + diff --git a/frontend/src/components/GroceryDiningChart.svelte b/frontend/src/components/GroceryDiningChart.svelte new file mode 100644 index 0000000..947ecef --- /dev/null +++ b/frontend/src/components/GroceryDiningChart.svelte @@ -0,0 +1,217 @@ + + +{#if hasData} +
+
+

Groceries vs Dining & Takeaway

+
+ 🛒 ${grocTotal.toFixed(0)} + 🍔 ${diningTotal.toFixed(0)} +
+
+ +
+ + {#if !chartReady} + + {/if} +
+ + {#if insight} +

{insight}

+ {/if} +
+{/if} + + diff --git a/frontend/src/components/Insights.svelte b/frontend/src/components/Insights.svelte new file mode 100644 index 0000000..bd7cacd --- /dev/null +++ b/frontend/src/components/Insights.svelte @@ -0,0 +1,79 @@ + + +{#if insights.length > 0} +
+

Key Insights

+
+ {#each insights as ins} +
+
+ {ins.icon} + {ins.title} + {ins.stat} +
+

{ins.detail}

+
+ {/each} +
+
+{/if} + + diff --git a/frontend/src/components/MonthlyChart.svelte b/frontend/src/components/MonthlyChart.svelte new file mode 100644 index 0000000..d996069 --- /dev/null +++ b/frontend/src/components/MonthlyChart.svelte @@ -0,0 +1,272 @@ + + +
+
+

{title}

+ {#if multiMonth} +
+ + +
+ {/if} +
+ + {#if noData} +
+ 📅 +

Not enough data

+

Upload multiple statements to compare month-over-month spending.

+
+ {:else} +
+ + {#if !chartReady} + + {/if} +
+ {#if !multiMonth} +

Upload more statements to unlock month-over-month comparison.

+ {/if} + {/if} +
+ + diff --git a/frontend/src/components/SubscriptionAudit.svelte b/frontend/src/components/SubscriptionAudit.svelte new file mode 100644 index 0000000..ca775e8 --- /dev/null +++ b/frontend/src/components/SubscriptionAudit.svelte @@ -0,0 +1,223 @@ + + +{#if hasData} +
+
+
+

📱 Subscription Audit

+

{subs.length} active subscription{subs.length !== 1 ? 's' : ''} detected

+
+
+
+ This period + ${periodTotal.toFixed(2)} +
+
+ Est. annual + ~${annualTotal.toLocaleString()} +
+
+
+ +
+ {#each subs as s} +
+ {s.emoji} + {s.name} + {#if s.count > 1} + {s.count}× + {/if} + +
+ ${s.total.toFixed(2)} + ~${Math.round(s.total * multiplier).toLocaleString()}/yr +
+
+ {/each} + +
+ + Total + +
+ ${periodTotal.toFixed(2)} + ~${annualTotal.toLocaleString()}/yr +
+
+
+ + {#if biggest && biggest.total > 20} +

+ 💡 {biggest.name} 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. +

+ {:else} +

+ 💡 Your subscriptions total ~${annualTotal.toLocaleString()}/year. + Review each annually — unused subscriptions are pure waste. +

+ {/if} +
+{/if} + + diff --git a/frontend/src/components/SummaryCards.svelte b/frontend/src/components/SummaryCards.svelte new file mode 100644 index 0000000..b9a0cbc --- /dev/null +++ b/frontend/src/components/SummaryCards.svelte @@ -0,0 +1,107 @@ + + +
+ {#each cards as c} +
+
+ {c.label} + + + + + +
+
{c.value}
+
{c.sub}
+
+ {/each} +
+ + diff --git a/frontend/src/components/TransactionTable.svelte b/frontend/src/components/TransactionTable.svelte new file mode 100644 index 0000000..2eddfa7 --- /dev/null +++ b/frontend/src/components/TransactionTable.svelte @@ -0,0 +1,281 @@ + + +
+ +
+

Transactions

+
+
+ + + + +
+ + +
+
+ + +
+ + + + + + + + + + + {#each visible as tx (tx.id ?? tx.date + tx.description + tx.amount)} + + + + + + + {:else} + + {/each} + +
sortBy('date')}>Date{arrow('date')} sortBy('description')}>Description{arrow('description')} sortBy('category')}>Category{arrow('category')} sortBy('amount')}>Amount{arrow('amount')}
{tx.date}{tx.description}{tx.category} + {tx.is_credit ? '+' : ''}{tx.amount.toFixed(2)} +
No transactions match your filters.
+
+ + +
+ {#each visible as tx (tx.id ?? tx.date + tx.description + tx.amount)} +
+
+ {tx.date} + {tx.description} + {tx.category} +
+ + {tx.is_credit ? '+' : '–'}${tx.amount.toFixed(2)} + +
+ {:else} +

No transactions match your filters.

+ {/each} +
+ + + +
+ + diff --git a/frontend/src/components/UploadZone.svelte b/frontend/src/components/UploadZone.svelte new file mode 100644 index 0000000..9427115 --- /dev/null +++ b/frontend/src/components/UploadZone.svelte @@ -0,0 +1,173 @@ + + +
+ +
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} + > +
+ {#if dragging} + + + + {:else} + + + + {/if} +
+

{dragging ? 'Drop to add' : 'Drop PDF statements here'}

+

or browse files · Multiple statements supported

+ +
+ + + {#if stagedFiles.length > 0} +
+ {#each stagedFiles as f} +
+ + + + {f.name} + {formatSize(f.size)} + +
+ {/each} +
+ + + {/if} +
+ + diff --git a/frontend/src/components/UtilityBreakdown.svelte b/frontend/src/components/UtilityBreakdown.svelte new file mode 100644 index 0000000..676e45e --- /dev/null +++ b/frontend/src/components/UtilityBreakdown.svelte @@ -0,0 +1,249 @@ + + +{#if hasData} +
+
+

⚡ Utility Spend

+ ${totalUtils.toFixed(2)} total +
+ + +
+ {#each providers as p} +
+
+ {p.name} + ${p.total.toFixed(2)} +
+
+
+
+ {p.count} payment{p.count !== 1 ? 's' : ''} this period +
+ {/each} +
+ + + {#if providers.length > 1} +
+ + {#if !chartReady} + + {/if} +
+ {/if} + + +
+{/if} + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..0c6c37f --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import App from './App.svelte' + +const app = new App({ target: document.getElementById('app') }) + +export default app diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs new file mode 100644 index 0000000..fbb473b --- /dev/null +++ b/frontend/vite.config.mjs @@ -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', + }, +}) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6dc92df --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask +flask-cors +pdfplumber +pandas +python-dotenv +gunicorn