Files
amexpal/frontend/src/components/UtilityBreakdown.svelte
T
admin c1e22da9d6 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>
2026-03-26 08:37:01 +13:00

250 lines
6.5 KiB
Svelte

<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>