Initial commit — AmexPal statement analyser

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 08:37:01 +13:00
commit c1e22da9d6
28 changed files with 5203 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AmexPal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1320
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "amex-analyser",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.2.0",
"vite": "^5.0.0"
},
"dependencies": {
"chart.js": "^4.4.0"
}
}
+371
View File
@@ -0,0 +1,371 @@
<script>
import UploadZone from './components/UploadZone.svelte'
import SummaryCards from './components/SummaryCards.svelte'
import CategoryChart from './components/CategoryChart.svelte'
import Insights from './components/Insights.svelte'
import MonthlyChart from './components/MonthlyChart.svelte'
import AnnualisedProjection from './components/AnnualisedProjection.svelte'
import SubscriptionAudit from './components/SubscriptionAudit.svelte'
import FixedVariableSplit from './components/FixedVariableSplit.svelte'
import GroceryDiningChart from './components/GroceryDiningChart.svelte'
import UtilityBreakdown from './components/UtilityBreakdown.svelte'
import TransactionTable from './components/TransactionTable.svelte'
import EmailModal from './components/EmailModal.svelte'
let result = null
let loading = false
let error = null
let fileNames = []
let showEmail = false
async function handleUpload(event) {
const files = event.detail // array of File objects
fileNames = files.map(f => f.name)
loading = true
error = null
result = null
const formData = new FormData()
for (const f of files) formData.append('files', f)
try {
const res = await fetch('/api/analyse', { method: 'POST', body: formData })
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Upload failed')
result = data
} catch (err) {
error = err.message
} finally {
loading = false
}
}
function reset() {
result = null
error = null
fileNames = []
}
$: multiMonth = result && result.monthly.months.length > 1
</script>
<div class="app">
<header>
<div class="header-inner">
<div class="logo">
<span class="logo-box">💳</span>
<span class="logo-text"><span class="logo-amex">Amex</span>Pal</span>
</div>
{#if result}
<div class="header-actions">
<button class="btn-ghost" on:click={reset}>
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 0 1 1 1v2.101a7.002 7.002 0 0 1 11.601 2.566 1 1 0 1 1-1.885.666A5.002 5.002 0 0 0 5.999 7H9a1 1 0 0 1 0 2H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Zm.008 9.057a1 1 0 0 1 1.276.61A5.002 5.002 0 0 0 14.001 13H11a1 1 0 1 1 0-2h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-2.101a7.002 7.002 0 0 1-11.601-2.566 1 1 0 0 1 .61-1.276Z" clip-rule="evenodd"/></svg>
<span>New</span>
</button>
<button class="btn-primary" on:click={() => showEmail = true}>
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 4a2 2 0 0 0-2 2v1.161l8.441 4.221a1.25 1.25 0 0 0 1.118 0L19 7.162V6a2 2 0 0 0-2-2H3Z"/><path d="m19 8.839-7.77 3.885a2.75 2.75 0 0 1-2.46 0L1 8.839V14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.839Z"/></svg>
<span>Email Report</span>
</button>
</div>
{/if}
</div>
</header>
<main>
{#if !result && !loading}
<div class="upload-section">
<div class="upload-hero">
<div class="hero-icon">
<svg viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="14" fill="#e8f3ff"/>
<path d="M16 30c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4" stroke="#006fcf" stroke-width="2" stroke-linecap="round"/>
<path d="M24 12v16M18 18l6-6 6 6" stroke="#006fcf" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h1>AmexPal</h1>
<p>Upload one or more AMEX PDF statements to get instant insights — categories, trends, and a personalised summary you can email yourself.</p>
</div>
<UploadZone on:upload={handleUpload} />
{#if error}
<div class="error-banner">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd"/></svg>
{error}
</div>
{/if}
</div>
{:else if loading}
<div class="loading-section">
<div class="spinner"></div>
<p>Parsing {fileNames.length > 1 ? `${fileNames.length} statements` : fileNames[0]}</p>
</div>
{:else if result}
<div class="results">
<div class="results-header">
<div>
<h2>{multiMonth ? `${result.monthly.months[0]} ${result.monthly.months[result.monthly.months.length - 1]}` : (result.monthly.months[0] || 'Statement Analysis')}</h2>
<p class="file-label">{fileNames.join(', ')}</p>
</div>
<button class="btn-primary mobile-hide" on:click={() => showEmail = true}>
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 4a2 2 0 0 0-2 2v1.161l8.441 4.221a1.25 1.25 0 0 0 1.118 0L19 7.162V6a2 2 0 0 0-2-2H3Z"/><path d="m19 8.839-7.77 3.885a2.75 2.75 0 0 1-2.46 0L1 8.839V14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.839Z"/></svg>
Email Report
</button>
</div>
<SummaryCards {result} />
<AnnualisedProjection transactions={result.transactions} byCategory={result.by_category} />
<Insights insights={result.insights} />
<div class="three-col">
<FixedVariableSplit byCategory={result.by_category} totalSpend={result.total_spend} />
<SubscriptionAudit transactions={result.transactions} />
</div>
<div class="two-col">
<CategoryChart byCategory={result.by_category} categoryIcons={result.category_icons} />
<div class="top-merchants card">
<h3>Top Merchants</h3>
{#each result.top_merchants as m, i}
<div class="merchant-row">
<span class="merchant-rank">{i + 1}</span>
<span class="merchant-name">{m.name}</span>
<span class="merchant-visits">{m.count}×</span>
<span class="merchant-amount">${m.total.toFixed(2)}</span>
</div>
{/each}
</div>
</div>
<MonthlyChart monthly={result.monthly} weekly={result.weekly} />
<GroceryDiningChart transactions={result.transactions} />
<UtilityBreakdown transactions={result.transactions} />
<TransactionTable transactions={result.transactions} />
</div>
<!-- Mobile floating email button -->
<button class="fab mobile-only" on:click={() => showEmail = true} aria-label="Email Report">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 4a2 2 0 0 0-2 2v1.161l8.441 4.221a1.25 1.25 0 0 0 1.118 0L19 7.162V6a2 2 0 0 0-2-2H3Z"/><path d="m19 8.839-7.77 3.885a2.75 2.75 0 0 1-2.46 0L1 8.839V14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.839Z"/></svg>
</button>
{/if}
</main>
{#if showEmail}
<EmailModal {result} on:close={() => showEmail = false} />
{/if}
</div>
<style>
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
:global(body) {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: #f4f6fb;
color: #1a1a2e;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
:global(.card) {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.app { min-height: 100vh; display: flex; flex-direction: column; }
/* ── Header ── */
header {
background: #fff;
border-bottom: 1px solid #e8eaf0;
padding: 0 1.25rem;
height: 58px;
display: flex;
align-items: center;
position: sticky;
top: 0;
z-index: 50;
}
.header-inner {
max-width: 1120px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.logo { display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0; }
.logo-box {
font-size: 1.25rem;
line-height: 1;
}
.logo-text { font-weight: 700; font-size: 1.05rem; letter-spacing: -0.01em; }
.logo-amex { color: #006fcf; }
.header-actions { display: flex; align-items: center; gap: 0.5rem; }
.btn-ghost {
display: flex; align-items: center; gap: 0.35rem;
background: transparent;
border: 1px solid #e0e4ee;
color: #666;
padding: 7px 12px;
border-radius: 8px;
font-size: 0.82rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.btn-ghost svg { width: 14px; height: 14px; }
.btn-ghost:hover { border-color: #006fcf; color: #006fcf; }
.btn-primary {
display: flex; align-items: center; gap: 0.4rem;
background: #006fcf;
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
white-space: nowrap;
}
.btn-primary svg { width: 15px; height: 15px; }
.btn-primary:hover { background: #0058a8; }
.btn-primary:active { transform: scale(0.97); }
/* ── Main ── */
main {
flex: 1;
max-width: 1120px;
width: 100%;
margin: 0 auto;
padding: 1.75rem 1.25rem 5rem;
}
/* ── Upload ── */
.upload-section { max-width: 560px; margin: 2rem auto; }
.upload-hero { text-align: center; margin-bottom: 2rem; }
.hero-icon { margin: 0 auto 1.25rem; width: 64px; height: 64px; }
.hero-icon svg { width: 100%; height: 100%; }
.upload-hero h1 { font-size: 1.6rem; font-weight: 750; margin-bottom: 0.6rem; }
.upload-hero p { color: #666; line-height: 1.6; font-size: 0.95rem; }
/* ── Loading ── */
.loading-section {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 1.25rem; padding: 6rem 0; color: #666;
}
.spinner {
width: 40px; height: 40px;
border: 3px solid #e0e4f0;
border-top-color: #006fcf;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Results ── */
.results { display: flex; flex-direction: column; gap: 1.5rem; }
.results-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.results-header h2 { font-size: 1.35rem; font-weight: 750; }
.file-label { font-size: 0.78rem; color: #aaa; margin-top: 2px; }
/* ── Layout grids ── */
.three-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.two-col {
display: grid;
grid-template-columns: 1fr 300px;
gap: 1.25rem;
align-items: start;
}
@media (max-width: 760px) {
.three-col { grid-template-columns: 1fr; }
}
/* ── Top Merchants ── */
.top-merchants h3 {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 1rem;
}
.merchant-row {
display: grid;
grid-template-columns: 22px 1fr auto auto;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0;
border-bottom: 1px solid #f5f6fa;
font-size: 0.875rem;
}
.merchant-row:last-child { border-bottom: none; }
.merchant-rank { font-size: 0.72rem; color: #ccc; font-weight: 600; text-align: center; }
.merchant-name { font-weight: 500; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.merchant-visits { font-size: 0.72rem; color: #bbb; white-space: nowrap; }
.merchant-amount { font-weight: 650; color: #1a1a2e; white-space: nowrap; }
/* ── FAB ── */
.fab {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: 56px;
height: 56px;
border-radius: 50%;
background: #006fcf;
color: #fff;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(0, 111, 207, 0.4);
cursor: pointer;
z-index: 40;
transition: background 0.15s, transform 0.1s;
}
.fab:hover { background: #0058a8; }
.fab:active { transform: scale(0.94); }
.fab svg { width: 22px; height: 22px; }
/* ── Error ── */
.error-banner {
display: flex; align-items: center; gap: 0.6rem;
margin-top: 1rem;
background: #fff5f5;
border: 1px solid #fecaca;
color: #b91c1c;
border-radius: 10px;
padding: 0.85rem 1rem;
font-size: 0.875rem;
}
.error-banner svg { width: 18px; height: 18px; flex-shrink: 0; }
/* ── Responsive ── */
.mobile-hide { display: flex; }
.mobile-only { display: none; }
@media (max-width: 760px) {
.two-col { grid-template-columns: 1fr; }
.mobile-hide { display: none !important; }
.mobile-only { display: flex !important; }
main { padding: 1.25rem 1rem 6rem; }
}
</style>
@@ -0,0 +1,206 @@
<script>
export let transactions = []
export let byCategory = {}
// ── Statement period in days (min→max transaction date) ───────────────────
$: periodDays = (() => {
const spends = transactions.filter(t => !t.is_credit)
if (spends.length < 2) return 30
const ms = spends.map(t => {
const [d, m, y] = t.date.split('.').map(Number)
return new Date(2000 + y, m - 1, d).getTime()
})
const days = Math.round((Math.max(...ms) - Math.min(...ms)) / 86400000) + 1
return Math.max(days, 1)
})()
$: multiplier = 365 / periodDays
// ── Annualised totals per category ────────────────────────────────────────
$: rows = Object.entries(byCategory)
.filter(([k]) => k !== 'Payments')
.map(([cat, amount]) => ({
cat,
period: amount,
annual: Math.round(amount * multiplier),
}))
.sort((a, b) => b.annual - a.annual)
$: totalPeriod = rows.reduce((s, r) => s + r.period, 0)
$: totalAnnual = Math.round(totalPeriod * multiplier)
// Biggest single annual line
$: biggestCat = rows[0]
// Friendly period label
$: periodLabel = periodDays <= 31
? `Based on ${periodDays} days of spending`
: `Based on ${Math.round(periodDays / 7)} weeks of spending`
</script>
<div class="card">
<div class="card-header">
<div>
<h3 class="section-title">At this rate…</h3>
<p class="sub">{periodLabel} · extrapolated to 12 months</p>
</div>
<div class="hero-number">
<span class="tilde">~</span><span class="amount">${totalAnnual.toLocaleString()}</span>
<span class="per-year">/year</span>
</div>
</div>
{#if biggestCat}
<p class="lead-insight">
<strong>{biggestCat.cat}</strong> is your biggest line item — on track for
<strong>~${biggestCat.annual.toLocaleString()}/year</strong>.
</p>
{/if}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Category</th>
<th class="right">This period</th>
<th class="right">Annual est.</th>
<th class="bar-col"></th>
</tr>
</thead>
<tbody>
{#each rows as r}
<tr>
<td class="cat">{r.cat}</td>
<td class="right muted">${r.period.toFixed(0)}</td>
<td class="right bold">${r.annual.toLocaleString()}</td>
<td class="bar-col">
<div class="bar-track">
<div
class="bar-fill"
style="width:{totalAnnual > 0 ? (r.annual / totalAnnual * 100).toFixed(1) : 0}%"
></div>
</div>
</td>
</tr>
{/each}
<tr class="total-row">
<td>Total</td>
<td class="right">${totalPeriod.toFixed(0)}</td>
<td class="right bold">${totalAnnual.toLocaleString()}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
<p class="disclaimer">
⚠️ Estimate only — based on a single statement. One-off purchases (appliances, gifts) will inflate the projection.
</p>
</div>
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 2px;
}
.sub { font-size: 0.78rem; color: #bbb; }
.hero-number {
display: flex;
align-items: baseline;
gap: 2px;
color: #e74c3c;
flex-shrink: 0;
}
.tilde { font-size: 1.2rem; opacity: 0.6; }
.amount { font-size: 2rem; font-weight: 800; line-height: 1; letter-spacing: -0.03em; }
.per-year { font-size: 0.9rem; font-weight: 600; opacity: 0.7; margin-left: 2px; }
.lead-insight {
font-size: 0.875rem;
color: #555;
background: #fff8f5;
border-left: 3px solid #e74c3c;
border-radius: 0 8px 8px 0;
padding: 0.6rem 1rem;
margin-bottom: 1.25rem;
line-height: 1.5;
}
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
thead th {
text-align: left;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #bbb;
padding: 6px 8px 8px;
border-bottom: 2px solid #f5f6fa;
}
td {
padding: 8px 8px;
border-bottom: 1px solid #f9fafd;
color: #444;
}
.cat { font-weight: 500; white-space: nowrap; }
.right { text-align: right; white-space: nowrap; }
.muted { color: #aaa; }
.bold { font-weight: 700; color: #1a1a2e; }
/* Inline bar */
.bar-col { width: 100px; padding-left: 12px; }
.bar-track {
height: 6px;
background: #f0f2f7;
border-radius: 99px;
overflow: hidden;
min-width: 60px;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, #006fcf, #0891b2);
border-radius: 99px;
transition: width 0.5s ease;
}
/* Total row */
.total-row td {
font-weight: 700;
color: #1a1a2e;
border-top: 2px solid #f0f2f7;
border-bottom: none;
padding-top: 10px;
}
.disclaimer {
margin-top: 1rem;
font-size: 0.75rem;
color: #bbb;
line-height: 1.5;
}
@media (max-width: 500px) {
.hero-number { width: 100%; justify-content: flex-end; }
.bar-col { display: none; }
}
</style>
@@ -0,0 +1,168 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { Chart, DoughnutController, ArcElement, Tooltip, Legend } from 'chart.js'
Chart.register(DoughnutController, ArcElement, Tooltip, Legend)
export let byCategory = {}
export let categoryIcons = {}
const PALETTE = [
'#006fcf','#16a34a','#7c3aed','#d97706','#e74c3c',
'#0891b2','#065f46','#92400e','#6d28d9','#be185d',
'#0369a1','#15803d',
]
let canvas
let chart
let chartReady = false
// Read entries directly here so Svelte tracks it as a dependency.
// Previously this called updateChart() which Svelte can't introspect.
$: entries = Object.entries(byCategory).filter(([k]) => k !== 'Payments')
$: hasData = entries.length > 0
// Reactive update: inline the data writes so Svelte tracks `entries` + `chart`
$: if (chart && entries.length) {
chart.data.labels = entries.map(([k]) => k)
chart.data.datasets[0].data = entries.map(([, v]) => v)
chart.data.datasets[0].backgroundColor = PALETTE.slice(0, entries.length)
chart.update('none')
}
onMount(() => {
if (!hasData) return
const labels = entries.map(([k]) => k)
const values = entries.map(([, v]) => v)
const isMobile = window.innerWidth < 600
chart = new Chart(canvas, {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: PALETTE,
borderWidth: 3,
borderColor: '#fff',
hoverOffset: 8,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
animation: { onComplete: () => { chartReady = true } },
plugins: {
legend: {
position: isMobile ? 'bottom' : 'right',
labels: {
font: { family: 'Inter', size: 12 },
padding: isMobile ? 10 : 14,
usePointStyle: true,
pointStyleWidth: 8,
generateLabels(chart) {
const data = chart.data
const total = data.datasets[0].data.reduce((a, b) => a + b, 0)
return data.labels.map((label, i) => {
const value = data.datasets[0].data[i]
const pct = total > 0 ? ((value / total) * 100).toFixed(0) : 0
const icon = categoryIcons[label] || ''
return {
text: `${icon} ${label} $${value.toFixed(0)} (${pct}%)`,
fillStyle: data.datasets[0].backgroundColor[i],
strokeStyle: '#fff',
pointStyle: 'circle',
index: i,
hidden: false,
}
})
},
},
},
tooltip: {
callbacks: {
label(ctx) {
const total = ctx.dataset.data.reduce((a, b) => a + b, 0)
const pct = total > 0 ? ((ctx.parsed / total) * 100).toFixed(1) : 0
return ` $${ctx.parsed.toFixed(2)} · ${pct}%`
},
},
},
},
},
})
})
onDestroy(() => chart?.destroy())
</script>
<div class="card">
<h3 class="section-title">Spending by Category</h3>
{#if hasData}
<div class="chart-wrap" class:loading={!chartReady}>
<canvas bind:this={canvas}></canvas>
{#if !chartReady}
<div class="shimmer" aria-hidden="true"></div>
{/if}
</div>
{:else}
<div class="empty">
<span class="empty-icon">📊</span>
<p>No category data available</p>
</div>
{/if}
</div>
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 1rem;
}
.chart-wrap {
position: relative;
height: 280px;
}
.chart-wrap canvas {
transition: opacity 0.3s;
}
.chart-wrap.loading canvas {
opacity: 0;
}
.shimmer {
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(90deg, #f0f2f7 25%, #e8eaf0 50%, #f0f2f7 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 0.75rem;
color: #bbb;
}
.empty-icon { font-size: 2rem; }
.empty p { font-size: 0.875rem; }
@media (max-width: 500px) {
.chart-wrap { height: 380px; }
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
export let result = null
let email = ''
let state = 'idle' // idle | sending | success | error
let errMsg = ''
async function send() {
if (!email || !email.includes('@')) {
errMsg = 'Please enter a valid email address.'
return
}
state = 'sending'
errMsg = ''
try {
const res = await fetch('/api/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: email, result }),
})
const data = await res.json()
if (!res.ok) {
errMsg = data.error || 'Failed to send email.'
state = 'error'
} else {
state = 'success'
}
} catch (err) {
errMsg = 'Network error — is the Flask server running?'
state = 'error'
}
}
function close() { dispatch('close') }
function onBackdrop(e) {
if (e.target === e.currentTarget) close()
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="backdrop" on:click={onBackdrop} role="dialog" aria-modal="true">
<div class="modal">
<div class="modal-header">
<div class="modal-title">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 4a2 2 0 0 0-2 2v1.161l8.441 4.221a1.25 1.25 0 0 0 1.118 0L19 7.162V6a2 2 0 0 0-2-2H3Z"/><path d="m19 8.839-7.77 3.885a2.75 2.75 0 0 1-2.46 0L1 8.839V14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.839Z"/></svg>
Email Report
</div>
<button class="close-btn" on:click={close} aria-label="Close">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"/></svg>
</button>
</div>
{#if state === 'success'}
<div class="success-state">
<div class="success-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
</div>
<h3>Report Sent!</h3>
<p>Your statement summary has been sent to <strong>{email}</strong></p>
<button class="btn-primary" on:click={close}>Done</button>
</div>
{:else}
<div class="modal-body">
<!-- Summary preview -->
<div class="summary-preview">
<div class="preview-row">
<span>Total Spend</span>
<strong>${result?.total_spend?.toFixed(2) ?? '—'}</strong>
</div>
<div class="preview-row">
<span>Payments</span>
<strong class="green">${result?.total_payments?.toFixed(2) ?? '—'}</strong>
</div>
<div class="preview-row">
<span>Transactions</span>
<strong>{result?.transaction_count ?? '—'}</strong>
</div>
{#if result?.insights?.length > 0}
<div class="preview-divider"></div>
{#each result.insights.slice(0, 3) as ins}
<div class="preview-row insight-row">
<span>{ins.icon} {ins.title}</span>
<strong>{ins.stat}</strong>
</div>
{/each}
{/if}
</div>
<p class="send-label">Send this summary to:</p>
<div class="input-row">
<input
type="email"
placeholder="you@example.com"
bind:value={email}
disabled={state === 'sending'}
on:keydown={e => e.key === 'Enter' && send()}
/>
</div>
{#if state === 'error'}
<div class="error-msg">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14Zm0-9.5a.75.75 0 0 0-.75.75v3.5a.75.75 0 0 0 1.5 0v-3.5A.75.75 0 0 0 8 5.5Zm0 7a.875.875 0 1 0 0-1.75.875.875 0 0 0 0 1.75Z"/></svg>
{errMsg}
</div>
{/if}
<div class="modal-actions">
<button class="btn-ghost" on:click={close}>Cancel</button>
<button class="btn-primary" on:click={send} disabled={state === 'sending'}>
{#if state === 'sending'}
<span class="btn-spinner"></span> Sending…
{:else}
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M3.105 2.288a.75.75 0 0 0-.826.95l1.414 4.926A1.5 1.5 0 0 0 5.135 9.25h6.115a.75.75 0 0 1 0 1.5H5.135a1.5 1.5 0 0 0-1.442 1.086l-1.414 4.926a.75.75 0 0 0 .826.95 28.897 28.897 0 0 0 15.293-7.154.75.75 0 0 0 0-1.115A28.897 28.897 0 0 0 3.105 2.288Z"/></svg>
Send Report
{/if}
</button>
</div>
<p class="smtp-note">
Requires SMTP configured in <code>.env</code> — see <code>.env.example</code>
</p>
</div>
{/if}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(10, 15, 30, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 100;
padding: 0;
}
@media (min-width: 600px) {
.backdrop { align-items: center; padding: 1.5rem; }
}
.modal {
background: #fff;
border-radius: 20px 20px 0 0;
width: 100%;
max-width: 460px;
overflow: hidden;
animation: slideUp 0.25s ease;
box-shadow: 0 -4px 40px rgba(0,0,0,0.15);
}
@media (min-width: 600px) {
.modal { border-radius: 20px; animation: fadeScale 0.2s ease; }
}
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes fadeScale { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 1.5rem;
border-bottom: 1px solid #f0f2f7;
}
.modal-title {
display: flex; align-items: center; gap: 0.6rem;
font-size: 1rem; font-weight: 650; color: #1a1a2e;
}
.modal-title svg { width: 18px; height: 18px; color: #006fcf; }
.close-btn {
width: 32px; height: 32px; border-radius: 8px;
background: #f5f6fa; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #888; transition: background 0.15s;
}
.close-btn:hover { background: #e8eaf0; color: #333; }
.close-btn svg { width: 16px; height: 16px; }
.modal-body { padding: 1.25rem 1.5rem; }
/* Summary preview */
.summary-preview {
background: #f8f9fb;
border: 1px solid #e8eaf0;
border-radius: 12px;
padding: 0.85rem 1rem;
margin-bottom: 1.25rem;
}
.preview-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0;
font-size: 0.875rem;
}
.preview-row span { color: #777; }
.preview-row strong { color: #1a1a2e; }
.green { color: #16a34a !important; }
.preview-divider { border-top: 1px solid #e8eaf0; margin: 0.5rem 0; }
.insight-row span { font-size: 0.82rem; }
.send-label { font-size: 0.82rem; font-weight: 500; color: #555; margin-bottom: 0.5rem; }
.input-row { margin-bottom: 1rem; }
input[type="email"] {
width: 100%;
font-family: inherit;
font-size: 0.95rem;
border: 1px solid #e0e4ee;
border-radius: 10px;
padding: 12px 14px;
color: #333;
background: #fff;
outline: none;
transition: border-color 0.15s;
min-height: 48px;
}
input[type="email"]:focus { border-color: #006fcf; }
input[type="email"]:disabled { opacity: 0.6; }
.error-msg {
display: flex; align-items: flex-start; gap: 0.5rem;
background: #fff5f5;
border: 1px solid #fecaca;
color: #b91c1c;
border-radius: 8px;
padding: 0.75rem;
font-size: 0.82rem;
margin-bottom: 1rem;
line-height: 1.4;
}
.error-msg svg { width: 14px; height: 14px; flex-shrink: 0; margin-top: 1px; }
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-bottom: 0.85rem;
}
.btn-ghost {
padding: 10px 18px;
background: transparent;
border: 1px solid #e0e4ee;
color: #666;
border-radius: 10px;
font-size: 0.875rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
min-height: 44px;
}
.btn-ghost:hover { border-color: #aaa; color: #333; }
.btn-primary {
display: flex; align-items: center; gap: 0.4rem;
padding: 10px 20px;
background: #006fcf;
color: #fff;
border: none;
border-radius: 10px;
font-size: 0.875rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
min-height: 44px;
}
.btn-primary svg { width: 15px; height: 15px; }
.btn-primary:hover { background: #0058a8; }
.btn-primary:disabled { opacity: 0.65; cursor: not-allowed; }
.btn-spinner {
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.smtp-note {
font-size: 0.72rem;
color: #ccc;
text-align: center;
}
.smtp-note code {
background: #f0f2f7;
padding: 1px 4px;
border-radius: 3px;
color: #888;
font-size: 0.7rem;
}
/* Success state */
.success-state {
padding: 2.5rem 1.5rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.success-icon {
width: 56px; height: 56px;
background: #f0faf4;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: #16a34a;
}
.success-icon svg { width: 28px; height: 28px; }
.success-state h3 { font-size: 1.1rem; font-weight: 700; }
.success-state p { font-size: 0.875rem; color: #666; }
</style>
@@ -0,0 +1,235 @@
<script>
export let byCategory = {}
export let totalSpend = 0
// Categories treated as committed/fixed monthly costs
const FIXED_CATS = new Set(['Utilities', 'Subscriptions'])
$: fixedEntries = Object.entries(byCategory).filter(([k]) => FIXED_CATS.has(k) && k !== 'Payments')
$: variableEntries = Object.entries(byCategory).filter(([k]) => !FIXED_CATS.has(k) && k !== 'Payments')
$: fixedTotal = fixedEntries.reduce((s, [, v]) => s + v, 0)
$: variableTotal = variableEntries.reduce((s, [, v]) => s + v, 0)
$: fixedPct = totalSpend > 0 ? (fixedTotal / totalSpend * 100) : 0
$: variablePct = totalSpend > 0 ? (variableTotal / totalSpend * 100) : 0
$: hasData = totalSpend > 0
// Variable breakdown sorted desc
$: variableSorted = [...variableEntries].sort((a, b) => b[1] - a[1])
$: fixedSorted = [...fixedEntries].sort((a, b) => b[1] - a[1])
// Insight copy
$: insightText = (() => {
if (!hasData) return ''
const f = fixedPct.toFixed(0)
const v = variablePct.toFixed(0)
if (fixedPct < 15) return `Your committed costs are low at ${f}% — most of your spending is fully in your control.`
if (fixedPct > 40) return `${f}% of spend is locked in to fixed commitments — review subscriptions and utilities for savings.`
return `${f}% committed, ${v}% discretionary — a healthy split. Your floor is $${fixedTotal.toFixed(0)}/month.`
})()
</script>
{#if hasData}
<div class="card">
<h3 class="section-title">Fixed vs Variable Spending</h3>
<!-- Visual split bar -->
<div class="split-bar-wrap">
<div class="split-bar">
<div
class="seg fixed"
style="width:{fixedPct.toFixed(1)}%"
title="Fixed: ${fixedTotal.toFixed(2)}"
></div>
<div
class="seg variable"
style="width:{variablePct.toFixed(1)}%"
title="Variable: ${variableTotal.toFixed(2)}"
></div>
</div>
<div class="split-legend">
<div class="legend-item">
<span class="dot fixed-dot"></span>
<span class="leg-label">Fixed</span>
<span class="leg-pct">{fixedPct.toFixed(0)}%</span>
<span class="leg-amt">${fixedTotal.toFixed(0)}</span>
</div>
<div class="legend-item">
<span class="dot variable-dot"></span>
<span class="leg-label">Variable</span>
<span class="leg-pct">{variablePct.toFixed(0)}%</span>
<span class="leg-amt">${variableTotal.toFixed(0)}</span>
</div>
</div>
</div>
<!-- Two columns: Fixed | Variable breakdown -->
<div class="two-col">
<div class="breakdown-col">
<p class="col-label fixed-label">🔒 Committed (fixed)</p>
{#if fixedSorted.length > 0}
{#each fixedSorted as [cat, amt]}
<div class="breakdown-row">
<span class="br-cat">{cat}</span>
<span class="br-bar-wrap">
<span
class="br-bar fixed-bar"
style="width:{totalSpend > 0 ? (amt / totalSpend * 100).toFixed(1) : 0}%"
></span>
</span>
<span class="br-amt">${amt.toFixed(0)}</span>
</div>
{/each}
{:else}
<p class="none">None detected</p>
{/if}
</div>
<div class="breakdown-col">
<p class="col-label variable-label">🎛️ Discretionary (variable)</p>
{#each variableSorted as [cat, amt]}
<div class="breakdown-row">
<span class="br-cat">{cat}</span>
<span class="br-bar-wrap">
<span
class="br-bar variable-bar"
style="width:{totalSpend > 0 ? (amt / totalSpend * 100).toFixed(1) : 0}%"
></span>
</span>
<span class="br-amt">${amt.toFixed(0)}</span>
</div>
{/each}
</div>
</div>
{#if insightText}
<p class="insight">{insightText}</p>
{/if}
</div>
{/if}
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 1.25rem;
}
/* ── Split bar ── */
.split-bar-wrap { margin-bottom: 1.5rem; }
.split-bar {
display: flex;
height: 14px;
border-radius: 99px;
overflow: hidden;
background: #f0f2f7;
margin-bottom: 0.75rem;
}
.seg { height: 100%; transition: width 0.6s cubic-bezier(0.4,0,0.2,1); }
.seg.fixed { background: linear-gradient(90deg, #7c3aed, #8e44ad); }
.seg.variable { background: linear-gradient(90deg, #006fcf, #0891b2); }
.split-legend {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.875rem;
}
.dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.fixed-dot { background: #7c3aed; }
.variable-dot { background: #006fcf; }
.leg-label { font-weight: 500; color: #444; }
.leg-pct { font-size: 0.78rem; color: #888; }
.leg-amt { font-weight: 700; color: #1a1a2e; margin-left: 2px; }
/* ── Breakdown columns ── */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1rem;
}
@media (max-width: 560px) {
.two-col { grid-template-columns: 1fr; gap: 1rem; }
}
.col-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.65rem;
}
.fixed-label { color: #7c3aed; }
.variable-label { color: #006fcf; }
.breakdown-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 5px 0;
border-bottom: 1px solid #f9fafd;
font-size: 0.82rem;
}
.breakdown-row:last-child { border-bottom: none; }
.br-cat {
width: 105px;
flex-shrink: 0;
color: #555;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
}
.br-bar-wrap {
flex: 1;
height: 5px;
background: #f0f2f7;
border-radius: 99px;
overflow: hidden;
}
.br-bar {
display: block;
height: 100%;
border-radius: 99px;
transition: width 0.5s ease;
}
.fixed-bar { background: #7c3aed; }
.variable-bar { background: #006fcf; }
.br-amt { font-weight: 600; color: #1a1a2e; white-space: nowrap; font-size: 0.82rem; }
.none { font-size: 0.8rem; color: #ccc; }
.insight {
font-size: 0.845rem;
color: #555;
background: #f9fafb;
border-left: 3px solid #888;
border-radius: 0 8px 8px 0;
padding: 0.65rem 1rem;
line-height: 1.5;
margin-top: 0.5rem;
}
</style>
@@ -0,0 +1,217 @@
<script>
import { onMount, onDestroy } from 'svelte'
import {
Chart, BarController, CategoryScale, LinearScale,
BarElement, Tooltip, Legend,
} from 'chart.js'
Chart.register(BarController, CategoryScale, LinearScale, BarElement, Tooltip, Legend)
export let transactions = []
let canvas
let chart
let chartReady = false
// ── Compute totals ─────────────────────────────────────────────────────────
$: grocTotal = transactions
.filter(t => !t.is_credit && t.category === 'Groceries')
.reduce((s, t) => s + t.amount, 0)
$: diningTotal = transactions
.filter(t => !t.is_credit && t.category === 'Dining & Takeaway')
.reduce((s, t) => s + t.amount, 0)
$: hasData = grocTotal > 0 || diningTotal > 0
// ── Week-by-week breakdown ─────────────────────────────────────────────────
$: weeklyData = computeWeekly(transactions)
function computeWeekly(txns) {
const weeks = {}
for (const tx of txns) {
if (tx.is_credit) continue
if (tx.category !== 'Groceries' && tx.category !== 'Dining & Takeaway') continue
const [d] = tx.date.split('.').map(Number)
const wk = `Week ${Math.ceil(d / 7)}`
if (!weeks[wk]) weeks[wk] = { Groceries: 0, Dining: 0 }
if (tx.category === 'Groceries') weeks[wk].Groceries += tx.amount
else weeks[wk].Dining += tx.amount
}
const labels = Object.keys(weeks).sort()
return {
labels,
groceries: labels.map(w => +(weeks[w].Groceries.toFixed(2))),
dining: labels.map(w => +(weeks[w].Dining.toFixed(2))),
}
}
// ── Insight sentence ───────────────────────────────────────────────────────
$: ratio = diningTotal > 0 ? grocTotal / diningTotal : null
$: combined = grocTotal + diningTotal
$: diningPct = combined > 0 ? Math.round((diningTotal / combined) * 100) : 0
$: grocPct = combined > 0 ? Math.round((grocTotal / combined) * 100) : 0
$: insight = (() => {
if (!hasData) return ''
if (diningTotal === 0) return `All food spending was groceries — great home-cooking habit! 🥗`
if (grocTotal === 0) return `No supermarket spending detected — all food was dining out. 🍔`
if (ratio !== null && ratio < 0.8)
return `Dining out dominates at ${diningPct}% of food spend ($${diningTotal.toFixed(0)}). Cooking more could save ~$${Math.round(diningTotal * 0.6)}/month. 🍳`
if (ratio !== null && ratio < 1.5)
return `Balanced split — ${grocPct}% groceries vs ${diningPct}% dining. A good mix! 🥘`
return `Strong home-cooking habit — groceries are ${ratio !== null ? ratio.toFixed(1) : '?'}× your dining spend. 🛒`
})()
onMount(() => {
if (!hasData || weeklyData.labels.length === 0) return
chart = new Chart(canvas, {
type: 'bar',
data: {
labels: weeklyData.labels,
datasets: [
{
label: 'Groceries',
data: weeklyData.groceries,
backgroundColor: '#16a34a',
borderRadius: 5,
borderSkipped: false,
},
{
label: 'Dining & Takeaway',
data: weeklyData.dining,
backgroundColor: '#e67e22',
borderRadius: 5,
borderSkipped: false,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 400, onComplete: () => { chartReady = true } },
plugins: {
legend: {
position: 'bottom',
labels: {
font: { family: 'Inter', size: 12 },
padding: 16,
usePointStyle: true,
pointStyleWidth: 8,
},
},
tooltip: {
callbacks: {
label(ctx) {
return ` ${ctx.dataset.label}: $${ctx.parsed.y.toFixed(2)}`
},
},
},
},
scales: {
x: {
grid: { display: false },
ticks: { font: { family: 'Inter', size: 12 }, color: '#999' },
},
y: {
grid: { color: '#f0f2f7' },
ticks: {
font: { family: 'Inter', size: 11 },
color: '#aaa',
callback: v => `$${v}`,
},
},
},
},
})
})
onDestroy(() => chart?.destroy())
</script>
{#if hasData}
<div class="card">
<div class="header">
<h3 class="section-title">Groceries vs Dining & Takeaway</h3>
<div class="totals">
<span class="pill groc">🛒 ${grocTotal.toFixed(0)}</span>
<span class="pill dine">🍔 ${diningTotal.toFixed(0)}</span>
</div>
</div>
<div class="chart-wrap" class:loading={!chartReady}>
<canvas bind:this={canvas}></canvas>
{#if !chartReady}
<div class="shimmer" aria-hidden="true"></div>
{/if}
</div>
{#if insight}
<p class="insight">{insight}</p>
{/if}
</div>
{/if}
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
}
.totals { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.pill {
font-size: 0.8rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
}
.pill.groc { background: #f0faf4; color: #16a34a; }
.pill.dine { background: #fff8f0; color: #e67e22; }
.chart-wrap {
position: relative;
height: 260px;
}
.chart-wrap canvas { transition: opacity 0.3s; }
.chart-wrap.loading canvas { opacity: 0; }
.shimmer {
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(90deg, #f0f2f7 25%, #e8eaf0 50%, #f0f2f7 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.insight {
margin-top: 1rem;
font-size: 0.875rem;
color: #555;
background: #f9fafb;
border-left: 3px solid #006fcf;
border-radius: 0 8px 8px 0;
padding: 0.65rem 1rem;
line-height: 1.5;
}
@media (max-width: 500px) {
.chart-wrap { height: 220px; }
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script>
export let insights = []
</script>
{#if insights.length > 0}
<div class="section">
<h3 class="section-title">Key Insights</h3>
<div class="grid">
{#each insights as ins}
<div class="insight-card" style="--color:{ins.color}">
<div class="top">
<span class="emoji">{ins.icon}</span>
<span class="title">{ins.title}</span>
<span class="stat">{ins.stat}</span>
</div>
<p class="detail">{ins.detail}</p>
</div>
{/each}
</div>
</div>
{/if}
<style>
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 0.85rem;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.85rem;
}
@media (max-width: 860px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 500px) { .grid { grid-template-columns: 1fr; } }
.insight-card {
background: #fff;
border: 1px solid #e8eaf0;
border-left: 3px solid var(--color);
border-radius: 12px;
padding: 1rem 1.1rem;
transition: box-shadow 0.15s;
}
.insight-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.07); }
.top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
flex-wrap: wrap;
}
.emoji { font-size: 1.1rem; flex-shrink: 0; }
.title {
font-size: 0.82rem;
font-weight: 600;
color: #555;
flex: 1;
min-width: 0;
}
.stat {
font-size: 0.95rem;
font-weight: 750;
color: var(--color);
white-space: nowrap;
margin-left: auto;
}
.detail {
font-size: 0.78rem;
color: #999;
line-height: 1.5;
padding-left: 1.6rem;
}
</style>
+272
View File
@@ -0,0 +1,272 @@
<script>
import { onMount, onDestroy } from 'svelte'
import {
Chart, BarController, CategoryScale, LinearScale,
BarElement, Tooltip, Legend,
} from 'chart.js'
Chart.register(BarController, CategoryScale, LinearScale, BarElement, Tooltip, Legend)
export let monthly = {}
export let weekly = {}
const PALETTE = [
'#006fcf','#16a34a','#7c3aed','#d97706','#e74c3c',
'#0891b2','#065f46','#92400e','#6d28d9','#be185d',
]
let canvas
let chart
let chartReady = false
let mode = 'category' // 'category' | 'total'
$: multiMonth = (monthly.months || []).length > 1
$: title = multiMonth ? 'Month-over-Month Spending' : 'Weekly Spending Breakdown'
// No data at all
$: weeklyEmpty = !weekly.weeks || weekly.weeks.length === 0
$: monthlyEmpty = !monthly.months || monthly.months.length === 0
$: noData = weeklyEmpty && monthlyEmpty
// ── Mode toggle: destroy + rebuild without reactive magic ──────────────────
function setMode(m) {
if (m === mode) return
mode = m
if (chart) { chart.destroy(); chart = null; chartReady = false }
buildChart()
}
// ── Chart builders ─────────────────────────────────────────────────────────
function buildChart() {
if (!canvas) return
if (noData) return
if (multiMonth) buildMonthlyChart()
else buildWeeklyChart()
}
function buildMonthlyChart() {
const months = monthly.months || []
const cats = (monthly.categories || []).filter(c => c !== 'Payments').slice(0, 8)
if (months.length === 0) return
let datasets
if (mode === 'category') {
datasets = cats.map((cat, i) => ({
label: cat,
data: months.map(m => monthly.by_month?.[m]?.[cat] ?? 0),
backgroundColor: PALETTE[i % PALETTE.length],
borderRadius: 4,
borderSkipped: false,
}))
} else {
datasets = [{
label: 'Total Spend',
data: months.map(m => monthly.totals?.[m] ?? 0),
backgroundColor: '#006fcf',
borderRadius: 6,
borderSkipped: false,
}]
}
chart = new Chart(canvas, {
type: 'bar',
data: { labels: months, datasets },
options: makeOptions(mode === 'category'),
})
chart.options.animation = {
onComplete: () => { chartReady = true }
}
chart.update()
}
function buildWeeklyChart() {
const weeks = weekly.weeks || []
if (weeks.length === 0) return
chart = new Chart(canvas, {
type: 'bar',
data: {
labels: weeks,
datasets: [{
label: 'Spend',
data: weeks.map(w => weekly.totals?.[w] ?? 0),
backgroundColor: weeks.map((_, i) => PALETTE[i % PALETTE.length]),
borderRadius: 8,
borderSkipped: false,
}],
},
options: makeOptions(false),
})
chart.options.animation = {
onComplete: () => { chartReady = true }
}
chart.update()
}
function makeOptions(stacked) {
return {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 400 },
plugins: {
legend: {
display: stacked && (monthly.categories || []).filter(c => c !== 'Payments').length > 1,
position: 'bottom',
labels: {
font: { family: 'Inter', size: 11 },
padding: 12,
usePointStyle: true,
pointStyleWidth: 8,
},
},
tooltip: {
callbacks: {
label(ctx) {
return ` ${ctx.dataset.label}: $${ctx.parsed.y.toFixed(2)}`
},
},
},
},
scales: {
x: {
stacked,
grid: { display: false },
ticks: { font: { family: 'Inter', size: 12 }, color: '#999' },
},
y: {
stacked,
grid: { color: '#f0f2f7' },
ticks: {
font: { family: 'Inter', size: 11 },
color: '#aaa',
callback: v => `$${v}`,
},
},
},
}
}
onMount(() => buildChart())
onDestroy(() => chart?.destroy())
</script>
<div class="card">
<div class="header">
<h3 class="section-title">{title}</h3>
{#if multiMonth}
<div class="toggle">
<button class:active={mode === 'category'} on:click={() => setMode('category')}>By Category</button>
<button class:active={mode === 'total'} on:click={() => setMode('total')}>Total</button>
</div>
{/if}
</div>
{#if noData}
<div class="empty">
<span class="empty-icon">📅</span>
<p class="empty-title">Not enough data</p>
<p class="empty-sub">Upload multiple statements to compare month-over-month spending.</p>
</div>
{:else}
<div class="chart-wrap" class:loading={!chartReady}>
<canvas bind:this={canvas}></canvas>
{#if !chartReady}
<div class="shimmer" aria-hidden="true"></div>
{/if}
</div>
{#if !multiMonth}
<p class="hint">Upload more statements to unlock month-over-month comparison.</p>
{/if}
{/if}
</div>
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
}
.toggle {
display: flex;
background: #f0f2f7;
border-radius: 8px;
padding: 3px;
gap: 2px;
}
.toggle button {
padding: 5px 12px;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 500;
color: #888;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
white-space: nowrap;
}
.toggle button.active {
background: #fff;
color: #1a1a2e;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.chart-wrap {
position: relative;
height: 300px;
}
.chart-wrap canvas {
transition: opacity 0.3s;
}
.chart-wrap.loading canvas { opacity: 0; }
.shimmer {
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(90deg, #f0f2f7 25%, #e8eaf0 50%, #f0f2f7 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 220px;
gap: 0.5rem;
color: #bbb;
text-align: center;
padding: 1rem;
}
.empty-icon { font-size: 2rem; }
.empty-title { font-size: 0.95rem; font-weight: 600; color: #ccc; }
.empty-sub { font-size: 0.82rem; color: #bbb; max-width: 280px; line-height: 1.5; }
.hint {
margin-top: 0.75rem;
font-size: 0.75rem;
color: #bbb;
text-align: center;
}
@media (max-width: 500px) {
.chart-wrap { height: 240px; }
}
</style>
@@ -0,0 +1,223 @@
<script>
export let transactions = []
// ── Statement period for annualising ─────────────────────────────────────
$: periodDays = (() => {
const spends = transactions.filter(t => !t.is_credit)
if (spends.length < 2) return 30
const ms = spends.map(t => {
const [d, m, y] = t.date.split('.').map(Number)
return new Date(2000 + y, m - 1, d).getTime()
})
const days = Math.round((Math.max(...ms) - Math.min(...ms)) / 86400000) + 1
return Math.max(days, 1)
})()
$: multiplier = 365 / periodDays
// ── Clean subscription name from raw description ──────────────────────────
const NAME_MAP = [
['GOOGLE', 'Google', '🔵'],
['APPLE.COM', 'Apple', '🍎'],
['NETFLIX', 'Netflix', '🎬'],
['SPOTIFY', 'Spotify', '🎵'],
['DISNEY', 'Disney+', '🏰'],
['MICROSOFT', 'Microsoft', '🪟'],
['ADOBE', 'Adobe', '🎨'],
['DROPBOX', 'Dropbox', '📦'],
['AMAZON', 'Amazon', '📦'],
['MYOB', 'MYOB', '📊'],
['EMBY', 'Emby', '🎞️'],
['SKYDRIVE', 'Skydrive', '☁️'],
['YOUTUBE', 'YouTube', '▶️'],
['ICLOUD', 'iCloud', '☁️'],
['GITHUB', 'GitHub', '🐙'],
['ATLASSIAN', 'Atlassian', '🔷'],
['CANVA', 'Canva', '✏️'],
['LASTPASS', 'LastPass', '🔑'],
['1PASSWORD', '1Password', '🔑'],
]
function cleanSub(desc) {
const upper = desc.toUpperCase()
for (const [key, name, emoji] of NAME_MAP) {
if (upper.includes(key)) return { name, emoji }
}
const word = desc.split(/\s+/)[0]
return {
name: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
emoji: '📱',
}
}
// ── Group subscription transactions ───────────────────────────────────────
$: subs = (() => {
const subTxns = transactions.filter(t => !t.is_credit && t.category === 'Subscriptions')
const map = {}
for (const tx of subTxns) {
const { name, emoji } = cleanSub(tx.description)
if (!map[name]) map[name] = { name, emoji, total: 0, count: 0 }
map[name].total += tx.amount
map[name].count++
}
return Object.values(map).sort((a, b) => b.total - a.total)
})()
$: hasData = subs.length > 0
$: periodTotal = subs.reduce((s, r) => s + r.total, 0)
$: annualTotal = Math.round(periodTotal * multiplier)
// Flag the biggest subscription
$: biggest = subs[0]
</script>
{#if hasData}
<div class="card">
<div class="card-header">
<div>
<h3 class="section-title">📱 Subscription Audit</h3>
<p class="sub">{subs.length} active subscription{subs.length !== 1 ? 's' : ''} detected</p>
</div>
<div class="totals">
<div class="total-pill">
<span class="t-label">This period</span>
<span class="t-val">${periodTotal.toFixed(2)}</span>
</div>
<div class="total-pill annual">
<span class="t-label">Est. annual</span>
<span class="t-val">~${annualTotal.toLocaleString()}</span>
</div>
</div>
</div>
<div class="sub-list">
{#each subs as s}
<div class="sub-row">
<span class="sub-emoji">{s.emoji}</span>
<span class="sub-name">{s.name}</span>
{#if s.count > 1}
<span class="sub-count">{s.count}×</span>
{/if}
<span class="sub-spacer"></span>
<div class="sub-amounts">
<span class="sub-period">${s.total.toFixed(2)}</span>
<span class="sub-annual">~${Math.round(s.total * multiplier).toLocaleString()}/yr</span>
</div>
</div>
{/each}
<div class="sub-row total-row">
<span class="sub-emoji"></span>
<span class="sub-name">Total</span>
<span class="sub-spacer"></span>
<div class="sub-amounts">
<span class="sub-period">${periodTotal.toFixed(2)}</span>
<span class="sub-annual bold">~${annualTotal.toLocaleString()}/yr</span>
</div>
</div>
</div>
{#if biggest && biggest.total > 20}
<p class="insight">
💡 <strong>{biggest.name}</strong> is your costliest subscription at
${biggest.total.toFixed(2)} this period
(~${Math.round(biggest.total * multiplier).toLocaleString()}/yr).
Worth reviewing if it's still earning its keep.
</p>
{:else}
<p class="insight">
💡 Your subscriptions total ~${annualTotal.toLocaleString()}/year.
Review each annually — unused subscriptions are pure waste.
</p>
{/if}
</div>
{/if}
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
margin-bottom: 2px;
}
.sub { font-size: 0.78rem; color: #bbb; }
.totals { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.total-pill {
display: flex;
flex-direction: column;
align-items: flex-end;
background: #f9fafb;
border: 1px solid #eee;
border-radius: 10px;
padding: 6px 12px;
min-width: 90px;
}
.total-pill.annual { background: #f5f0ff; border-color: #d8c8ff; }
.t-label { font-size: 0.68rem; color: #bbb; text-transform: uppercase; letter-spacing: 0.05em; }
.t-val { font-size: 1rem; font-weight: 700; color: #1a1a2e; }
.total-pill.annual .t-val { color: #7c3aed; }
/* Sub list */
.sub-list { display: flex; flex-direction: column; gap: 0; }
.sub-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 9px 4px;
border-bottom: 1px solid #f5f6fa;
font-size: 0.875rem;
}
.sub-row:last-child { border-bottom: none; }
.sub-emoji { font-size: 1rem; flex-shrink: 0; width: 22px; text-align: center; }
.sub-name { font-weight: 500; color: #333; }
.sub-count { font-size: 0.72rem; color: #bbb; }
.sub-spacer { flex: 1; }
.sub-amounts {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
}
.sub-period { font-weight: 600; color: #1a1a2e; font-size: 0.875rem; }
.sub-annual { font-size: 0.72rem; color: #888; }
.total-row {
border-top: 2px solid #f0f2f7;
border-bottom: none !important;
padding-top: 12px;
margin-top: 2px;
}
.total-row .sub-name { font-weight: 700; color: #1a1a2e; }
.bold { font-weight: 700 !important; color: #7c3aed !important; font-size: 0.82rem !important; }
.insight {
margin-top: 1rem;
font-size: 0.845rem;
color: #555;
background: #faf8ff;
border-left: 3px solid #7c3aed;
border-radius: 0 8px 8px 0;
padding: 0.65rem 1rem;
line-height: 1.5;
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<script>
export let result
$: topCat = (() => {
const entries = Object.entries(result.by_category).filter(([k]) => k !== 'Payments')
if (!entries.length) return { name: '—', amount: 0 }
const [name, amount] = entries[0]
return { name, amount }
})()
$: cards = [
{
label: 'Total Spend',
value: `$${result.total_spend.toLocaleString('en-NZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
sub: `${result.spend_count} transactions`,
accent: '#006fcf',
bg: '#f0f7ff',
icon: 'M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z',
},
{
label: 'Payments Made',
value: `$${result.total_payments.toLocaleString('en-NZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
sub: 'Credits received',
accent: '#16a34a',
bg: '#f0faf4',
icon: 'M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z',
},
{
label: 'Top Category',
value: topCat.name,
sub: `$${topCat.amount.toFixed(2)}`,
accent: '#7c3aed',
bg: '#f5f3ff',
icon: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z',
},
{
label: 'Transactions',
value: result.transaction_count,
sub: 'This period',
accent: '#d97706',
bg: '#fffbeb',
icon: 'M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z',
},
]
</script>
<div class="cards">
{#each cards as c}
<div class="card" style="--accent:{c.accent};--bg:{c.bg}">
<div class="card-top">
<span class="label">{c.label}</span>
<span class="icon-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d={c.icon}/>
</svg>
</span>
</div>
<div class="value">{c.value}</div>
<div class="sub">{c.sub}</div>
</div>
{/each}
</div>
<style>
.cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 700px) { .cards { grid-template-columns: repeat(2, 1fr); } }
.card {
background: var(--bg);
border: 1px solid color-mix(in srgb, var(--accent) 15%, #e8eaf0);
border-radius: 16px;
padding: 1.1rem 1.25rem 1.25rem;
transition: box-shadow 0.15s;
}
.card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.07); }
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.6rem; }
.label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #999; }
.icon-wrap {
width: 28px; height: 28px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: var(--accent);
}
.icon-wrap svg { width: 14px; height: 14px; }
.value {
font-size: 1.45rem;
font-weight: 750;
color: var(--accent);
line-height: 1.2;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub { font-size: 0.75rem; color: #aaa; }
@media (max-width: 420px) {
.value { font-size: 1.2rem; }
}
</style>
@@ -0,0 +1,281 @@
<script>
export let transactions = []
const PAGE = 20
let search = ''
let filterCat = 'All'
let filterType = 'All'
let sortCol = 'date'
let sortDir = -1 // -1 = desc (newest first)
let limit = PAGE
// ── Derived ──────────────────────────────────────────────────────────────
$: cats = ['All', ...new Set(transactions.map(t => t.category).sort())]
$: filtered = transactions
.filter(t => {
if (filterCat !== 'All' && t.category !== filterCat) return false
if (filterType === 'Debits' && t.is_credit) return false
if (filterType === 'Credits' && !t.is_credit) return false
if (search.trim()) {
const q = search.trim().toLowerCase()
return t.description.toLowerCase().includes(q)
|| t.category.toLowerCase().includes(q)
}
return true
})
.sort((a, b) => {
let va = a[sortCol], vb = b[sortCol]
if (sortCol === 'date') {
// DD.MM.YY → YYMMDD for correct chronological sort
const s = v => v.split('.').reverse().join('')
va = s(va); vb = s(vb)
}
if (va < vb) return sortDir
if (va > vb) return -sortDir
return 0
})
// Plain slice — no boolean flag that Svelte can silently reset
$: visible = filtered.slice(0, limit)
$: remaining = filtered.length - limit
$: hasMore = remaining > 0
// ── Helpers ───────────────────────────────────────────────────────────────
function resetPage() { limit = PAGE }
function sortBy(col) {
if (sortCol === col) sortDir *= -1
else { sortCol = col; sortDir = col === 'amount' ? -1 : 1 }
}
function arrow(col) {
if (sortCol !== col) return ''
return sortDir === 1 ? ' ↑' : ' ↓'
}
</script>
<div class="card">
<!-- ── Toolbar ─────────────────────────────────────────────────────────── -->
<div class="toolbar">
<h3 class="section-title">Transactions</h3>
<div class="controls">
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z" clip-rule="evenodd"/>
</svg>
<input
type="search"
placeholder="Search description or category…"
bind:value={search}
on:input={resetPage}
/>
</div>
<select bind:value={filterCat} on:change={resetPage}>
{#each cats as c}<option>{c}</option>{/each}
</select>
<select bind:value={filterType} on:change={resetPage}>
<option>All</option>
<option>Debits</option>
<option>Credits</option>
</select>
</div>
</div>
<!-- ── Desktop table ──────────────────────────────────────────────────── -->
<div class="table-wrap desktop-only">
<table>
<thead>
<tr>
<th on:click={() => sortBy('date')}>Date{arrow('date')}</th>
<th on:click={() => sortBy('description')}>Description{arrow('description')}</th>
<th on:click={() => sortBy('category')}>Category{arrow('category')}</th>
<th class="right" on:click={() => sortBy('amount')}>Amount{arrow('amount')}</th>
</tr>
</thead>
<tbody>
{#each visible as tx (tx.id ?? tx.date + tx.description + tx.amount)}
<tr class:credit={tx.is_credit}>
<td class="date-cell">{tx.date}</td>
<td class="desc-cell">{tx.description}</td>
<td><span class="badge">{tx.category}</span></td>
<td class="amount-cell right" class:credit-amt={tx.is_credit}>
{tx.is_credit ? '+' : ''}{tx.amount.toFixed(2)}
</td>
</tr>
{:else}
<tr><td colspan="4" class="empty">No transactions match your filters.</td></tr>
{/each}
</tbody>
</table>
</div>
<!-- ── Mobile card list ───────────────────────────────────────────────── -->
<div class="card-list mobile-only">
{#each visible as tx (tx.id ?? tx.date + tx.description + tx.amount)}
<div class="tx-card" class:credit={tx.is_credit}>
<div class="tx-left">
<span class="tx-date">{tx.date}</span>
<span class="tx-desc">{tx.description}</span>
<span class="badge">{tx.category}</span>
</div>
<span class="tx-amount" class:credit-amt={tx.is_credit}>
{tx.is_credit ? '+' : ''}${tx.amount.toFixed(2)}
</span>
</div>
{:else}
<p class="empty">No transactions match your filters.</p>
{/each}
</div>
<!-- ── Footer ─────────────────────────────────────────────────────────── -->
<div class="footer-row">
<span class="count">
{Math.min(limit, filtered.length)} of {filtered.length} transactions
</span>
<div class="footer-btns">
{#if hasMore}
<button class="show-btn" on:click={() => limit += PAGE}>
Show {Math.min(PAGE, remaining)} more
</button>
<button class="show-btn show-all" on:click={() => limit = filtered.length}>
Show all {filtered.length}
</button>
{:else if limit > PAGE && filtered.length > PAGE}
<button class="show-btn" on:click={() => limit = PAGE}>
Show less ↑
</button>
{/if}
</div>
</div>
</div>
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
/* ── Toolbar ── */
.toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
padding-top: 2px;
}
.controls { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-icon {
position: absolute; left: 9px;
width: 14px; height: 14px; color: #aaa; pointer-events: none;
}
.search-wrap input {
font-family: inherit; font-size: 0.82rem;
border: 1px solid #e0e4ee; border-radius: 8px;
padding: 7px 10px 7px 28px;
color: #333; background: #fafbfd;
outline: none; width: 210px;
transition: border-color 0.15s;
}
.search-wrap input:focus { border-color: #006fcf; background: #fff; }
select {
font-family: inherit; font-size: 0.82rem;
border: 1px solid #e0e4ee; border-radius: 8px;
padding: 7px 10px; color: #555; background: #fafbfd;
outline: none; cursor: pointer; min-height: 34px;
transition: border-color 0.15s;
}
select:focus { border-color: #006fcf; }
/* ── Desktop table ── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
thead th {
text-align: left; padding: 8px 12px;
font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em;
color: #bbb; border-bottom: 1px solid #f0f2f7;
cursor: pointer; user-select: none; white-space: nowrap;
}
thead th:hover { color: #006fcf; }
tbody tr { border-bottom: 1px solid #f8f9fb; transition: background 0.1s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: #fafbfe; }
tbody tr.credit { background: #f7fdf9; }
tbody tr.credit:hover { background: #edf9f2; }
td { padding: 10px 12px; color: #333; }
.date-cell { color: #aaa; font-size: 0.8rem; white-space: nowrap; }
.desc-cell { font-weight: 500; max-width: 300px; }
.right { text-align: right; }
.amount-cell { font-weight: 650; font-variant-numeric: tabular-nums; white-space: nowrap; }
.credit-amt { color: #16a34a; }
/* ── Mobile cards ── */
.card-list { display: flex; flex-direction: column; gap: 0.5rem; }
.tx-card {
display: flex; align-items: center;
justify-content: space-between; gap: 0.75rem;
padding: 0.75rem 0.85rem;
background: #fafbfd; border: 1px solid #f0f2f7; border-radius: 10px;
}
.tx-card.credit { background: #f7fdf9; border-color: #dcf5e7; }
.tx-left { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; min-width: 0; }
.tx-date { font-size: 0.72rem; color: #bbb; }
.tx-desc { font-size: 0.875rem; font-weight: 500; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tx-amount { font-size: 1rem; font-weight: 700; white-space: nowrap; color: #1a1a2e; flex-shrink: 0; }
.tx-amount.credit-amt { color: #16a34a; }
/* ── Badge ── */
.badge {
display: inline-block; background: #f0f2f7; color: #666;
border-radius: 4px; padding: 2px 6px;
font-size: 0.7rem; font-weight: 500; white-space: nowrap; align-self: flex-start;
}
.empty { text-align: center; padding: 2rem; color: #ccc; font-size: 0.875rem; }
/* ── Footer ── */
.footer-row {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; margin-top: 0.85rem; padding-top: 0.85rem;
border-top: 1px solid #f5f6fa; flex-wrap: wrap;
}
.count { font-size: 0.75rem; color: #ccc; }
.footer-btns { display: flex; gap: 0.5rem; }
.show-btn {
font-size: 0.8rem; font-weight: 500; color: #006fcf;
background: none; border: 1px solid #d8eeff;
border-radius: 6px; padding: 4px 10px;
cursor: pointer; font-family: inherit; transition: background 0.15s;
}
.show-btn:hover { background: #f0f7ff; }
.show-all { border-color: #c0d8ff; font-weight: 600; }
/* ── Responsive ── */
.desktop-only { display: block; }
.mobile-only { display: none; }
@media (max-width: 640px) {
.desktop-only { display: none; }
.mobile-only { display: flex; flex-direction: column; }
.controls { width: 100%; }
.search-wrap { flex: 1; }
.search-wrap input { width: 100%; }
select { flex: 1; }
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
let dragging = false
let inputEl
let stagedFiles = []
function addFiles(fileList) {
const pdfs = Array.from(fileList).filter(f => f.type === 'application/pdf' || f.name.endsWith('.pdf'))
if (!pdfs.length) return
// Deduplicate by name
const existing = new Set(stagedFiles.map(f => f.name))
for (const f of pdfs) if (!existing.has(f.name)) stagedFiles.push(f)
stagedFiles = [...stagedFiles]
}
function onDrop(e) {
e.preventDefault()
dragging = false
addFiles(e.dataTransfer.files)
}
function onFileChange(e) {
addFiles(e.target.files)
// Reset input so same file can be re-added after removal
e.target.value = ''
}
function removeFile(name) {
stagedFiles = stagedFiles.filter(f => f.name !== name)
}
function submit() {
if (stagedFiles.length > 0) dispatch('upload', stagedFiles)
}
function formatSize(bytes) {
return bytes > 1024 * 1024
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
: `${Math.round(bytes / 1024)} KB`
}
</script>
<div class="upload-area">
<!-- Drop zone -->
<div
class="drop-zone"
class:dragging
class:has-files={stagedFiles.length > 0}
role="button"
tabindex="0"
on:click={() => inputEl.click()}
on:keydown={e => e.key === 'Enter' && inputEl.click()}
on:dragover|preventDefault={() => (dragging = true)}
on:dragleave={() => (dragging = false)}
on:drop={onDrop}
>
<div class="dz-icon">
{#if dragging}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m6.75 12-3-3m0 0-3 3m3-3v6m-1.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
</svg>
{/if}
</div>
<p class="dz-primary">{dragging ? 'Drop to add' : 'Drop PDF statements here'}</p>
<p class="dz-secondary">or <span class="link">browse files</span> · Multiple statements supported</p>
<input bind:this={inputEl} type="file" accept=".pdf,application/pdf" multiple on:change={onFileChange} style="display:none" />
</div>
<!-- Staged files list -->
{#if stagedFiles.length > 0}
<div class="file-list">
{#each stagedFiles as f}
<div class="file-item">
<svg class="file-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h4.586A2 2 0 0 1 12 2.586L15.414 6A2 2 0 0 1 16 7.414V16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4Zm2 6a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H7a1 1 0 0 1-1-1Zm1 3a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2H7Z" clip-rule="evenodd"/>
</svg>
<span class="file-name">{f.name}</span>
<span class="file-size">{formatSize(f.size)}</span>
<button class="remove-btn" on:click|stopPropagation={() => removeFile(f.name)} aria-label="Remove">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z"/></svg>
</button>
</div>
{/each}
</div>
<button class="analyse-btn" on:click={submit}>
Analyse {stagedFiles.length === 1 ? 'Statement' : `${stagedFiles.length} Statements`}
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 10a.75.75 0 0 1 .75-.75h10.638L10.23 5.29a.75.75 0 1 1 1.04-1.08l5.5 5.25a.75.75 0 0 1 0 1.08l-5.5 5.25a.75.75 0 1 1-1.04-1.08l4.158-3.96H3.75A.75.75 0 0 1 3 10Z" clip-rule="evenodd"/></svg>
</button>
{/if}
</div>
<style>
.upload-area { display: flex; flex-direction: column; gap: 1rem; }
.drop-zone {
border: 2px dashed #c8d0e0;
border-radius: 16px;
padding: 2.5rem 1.5rem;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
background: #fff;
outline: none;
-webkit-tap-highlight-color: transparent;
}
.drop-zone:hover, .drop-zone:focus-visible {
border-color: #006fcf;
background: #f0f7ff;
}
.drop-zone.dragging {
border-color: #006fcf;
background: #e6f2ff;
border-style: solid;
}
.drop-zone.has-files { padding: 1.5rem; }
.dz-icon { width: 40px; height: 40px; margin: 0 auto 0.85rem; color: #006fcf; }
.dz-icon svg { width: 100%; height: 100%; }
.dz-primary { font-weight: 600; font-size: 0.95rem; color: #333; margin-bottom: 0.3rem; }
.dz-secondary { font-size: 0.82rem; color: #999; }
.link { color: #006fcf; text-decoration: underline; }
/* File list */
.file-list { display: flex; flex-direction: column; gap: 0.5rem; }
.file-item {
display: flex;
align-items: center;
gap: 0.6rem;
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 10px;
padding: 0.65rem 0.9rem;
}
.file-icon { width: 18px; height: 18px; color: #006fcf; flex-shrink: 0; }
.file-name { flex: 1; font-size: 0.85rem; font-weight: 500; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { font-size: 0.75rem; color: #aaa; flex-shrink: 0; }
.remove-btn {
width: 24px; height: 24px; border-radius: 50%;
background: #f0f2f7; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #999; flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.remove-btn:hover { background: #fce8e8; color: #e74c3c; }
.remove-btn svg { width: 12px; height: 12px; }
/* Analyse button */
.analyse-btn {
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
width: 100%;
background: #006fcf;
color: #fff;
border: none;
padding: 14px 20px;
border-radius: 12px;
font-size: 1rem;
font-weight: 650;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
min-height: 52px;
}
.analyse-btn svg { width: 18px; height: 18px; }
.analyse-btn:hover { background: #0058a8; }
.analyse-btn:active { transform: scale(0.98); }
</style>
@@ -0,0 +1,249 @@
<script>
import { onMount, onDestroy } from 'svelte'
import {
Chart, BarController, CategoryScale, LinearScale,
BarElement, Tooltip,
} from 'chart.js'
Chart.register(BarController, CategoryScale, LinearScale, BarElement, Tooltip)
export let transactions = []
const UTIL_COLORS = {
'Spark': '#0a84ff',
'Slingshot': '#30b8c4',
'Powershop': '#f7b731',
'Mercury': '#e74c3c',
'Genesis': '#8e44ad',
'Contact': '#27ae60',
'Watercare': '#2980b9',
}
const DEFAULT_COLOR = '#006fcf'
function providerColor(name) {
const key = Object.keys(UTIL_COLORS).find(k => name.toUpperCase().includes(k.toUpperCase()))
return key ? UTIL_COLORS[key] : DEFAULT_COLOR
}
function cleanName(desc) {
// "SPARK PAY MONTHLY AUCKL" → "Spark"
// "SLINGSHOT SLINGSHOT" → "Slingshot"
// "POWERSHOP POWERSHOP" → "Powershop"
const known = ['Spark', 'Slingshot', 'Powershop', 'Mercury', 'Genesis', 'Contact', 'Watercare']
const match = known.find(n => desc.toUpperCase().includes(n.toUpperCase()))
if (match) return match
// Fallback: title-case first word
return desc.split(' ')[0].charAt(0).toUpperCase() + desc.split(' ')[0].slice(1).toLowerCase()
}
$: utilTxns = transactions.filter(t => !t.is_credit && t.category === 'Utilities')
$: hasData = utilTxns.length > 0
$: providers = (() => {
const map = {}
for (const tx of utilTxns) {
const name = cleanName(tx.description)
if (!map[name]) map[name] = { total: 0, count: 0, color: providerColor(tx.description) }
map[name].total += tx.amount
map[name].count++
}
return Object.entries(map)
.map(([name, v]) => ({ name, total: +v.total.toFixed(2), count: v.count, color: v.color }))
.sort((a, b) => b.total - a.total)
})()
$: totalUtils = providers.reduce((s, p) => s + p.total, 0)
$: maxTotal = providers.length ? providers[0].total : 1
let canvas
let chart
let chartReady = false
onMount(() => {
if (!hasData || providers.length === 0) return
chart = new Chart(canvas, {
type: 'bar',
data: {
labels: providers.map(p => p.name),
datasets: [{
data: providers.map(p => p.total),
backgroundColor: providers.map(p => p.color),
borderRadius: 6,
borderSkipped: false,
}],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
animation: { duration: 400, onComplete: () => { chartReady = true } },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(ctx) { return ` $${ctx.parsed.x.toFixed(2)}` },
},
},
},
scales: {
x: {
grid: { color: '#f0f2f7' },
ticks: {
font: { family: 'Inter', size: 11 },
color: '#aaa',
callback: v => `$${v}`,
},
},
y: {
grid: { display: false },
ticks: { font: { family: 'Inter', size: 13 }, color: '#555' },
},
},
},
})
})
onDestroy(() => chart?.destroy())
</script>
{#if hasData}
<div class="card">
<div class="header">
<h3 class="section-title">⚡ Utility Spend</h3>
<span class="total-badge">${totalUtils.toFixed(2)} total</span>
</div>
<!-- Provider detail rows -->
<div class="providers">
{#each providers as p}
<div class="row">
<div class="row-top">
<span class="provider-name" style="color:{p.color}">{p.name}</span>
<span class="provider-amount">${p.total.toFixed(2)}</span>
</div>
<div class="bar-track">
<div
class="bar-fill"
style="width:{(p.total / maxTotal * 100).toFixed(1)}%; background:{p.color}"
></div>
</div>
<span class="row-sub">{p.count} payment{p.count !== 1 ? 's' : ''} this period</span>
</div>
{/each}
</div>
<!-- Bar chart (shows nicely when 2+ providers) -->
{#if providers.length > 1}
<div class="chart-wrap" class:loading={!chartReady}>
<canvas bind:this={canvas}></canvas>
{#if !chartReady}
<div class="shimmer" aria-hidden="true"></div>
{/if}
</div>
{/if}
<p class="footer-note">
💡 Utilities account for {totalUtils > 0 ? Math.round(totalUtils / (totalUtils + 1) * 100) : 0}% of tracked recurring costs.
Review providers annually for better deals.
</p>
</div>
{/if}
<style>
.card {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #aaa;
}
.total-badge {
background: #fff8e8;
color: #d97706;
font-size: 0.82rem;
font-weight: 700;
padding: 4px 12px;
border-radius: 20px;
}
/* Provider rows */
.providers {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.25rem;
}
.row { display: flex; flex-direction: column; gap: 3px; }
.row-top {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.provider-name {
font-size: 0.95rem;
font-weight: 600;
}
.provider-amount {
font-size: 1rem;
font-weight: 700;
color: #1a1a2e;
}
.bar-track {
height: 6px;
background: #f0f2f7;
border-radius: 99px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.row-sub {
font-size: 0.75rem;
color: #bbb;
}
/* Chart */
.chart-wrap {
position: relative;
height: 160px;
margin-bottom: 1rem;
}
.chart-wrap canvas { transition: opacity 0.3s; }
.chart-wrap.loading canvas { opacity: 0; }
.shimmer {
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(90deg, #f0f2f7 25%, #e8eaf0 50%, #f0f2f7 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.footer-note {
font-size: 0.78rem;
color: #aaa;
line-height: 1.5;
border-top: 1px solid #f5f6fa;
padding-top: 0.75rem;
margin-top: 0.25rem;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import App from './App.svelte'
const app = new App({ target: document.getElementById('app') })
export default app
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:5000',
},
},
build: {
outDir: 'dist',
},
})