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,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>
|
||||
Reference in New Issue
Block a user