207 lines
5.5 KiB
Svelte
207 lines
5.5 KiB
Svelte
|
|
<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>
|