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