Files
amexpal/frontend/src/components/AnnualisedProjection.svelte
T

207 lines
5.5 KiB
Svelte
Raw Normal View History

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