money: quick-add bar, server-filtered transactions page, CSV import dedupe + Revolut statements

This commit is contained in:
2026-10-24 00:50:46 +00:00
parent 9a1b21ec39
commit 00c1e8b1e7
9 changed files with 976 additions and 36 deletions

View File

@@ -0,0 +1,269 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Plus from 'lucide-svelte/icons/plus';
import { descriptionSuggestions, type SuggestionSource } from './quick-add.js';
let {
accounts,
transactions,
today
}: {
accounts: { id: number; name: string }[];
transactions: SuggestionSource[];
today: string;
} = $props();
const storageKey = 'money-tracker:last-account';
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
let kind = $state<'expense' | 'income'>('expense');
let description = $state('');
let amount = $state('');
let accountId = $state('');
let date = $state('');
let listOpen = $state(false);
let highlighted = $state(-1);
let message = $state('');
let failed = $state(false);
let saving = $state(false);
let descriptionInput = $state<HTMLInputElement | null>(null);
let amountInput = $state<HTMLInputElement | null>(null);
let suggestions = $derived(descriptionSuggestions(transactions, today));
let matches = $derived.by(() => {
const query = description.trim().toLowerCase();
if (!query) return [];
return suggestions
.filter((item) => item.key.includes(query) && item.key !== query)
.sort((a, b) => Number(b.key.startsWith(query)) - Number(a.key.startsWith(query)))
.slice(0, 6);
});
let signedAmount = $derived.by(() => {
const raw = amount.trim().replace(',', '.').replace(/^[-+]/, '');
return raw ? (kind === 'expense' ? '-' : '') + raw : '';
});
$effect.pre(() => {
if (!date) date = today;
if (!accountId && accounts.length) {
const remembered =
typeof localStorage === 'undefined' ? null : localStorage.getItem(storageKey);
accountId = String(
accounts.find((account) => String(account.id) === remembered)?.id ?? accounts[0].id
);
}
});
async function choose(index: number) {
const item = matches[index];
if (!item) return;
description = item.description;
amount = (Math.abs(item.amountCents) / 100).toFixed(2);
kind = item.amountCents < 0 ? 'expense' : 'income';
if (item.accountId && accounts.some((account) => account.id === item.accountId))
accountId = String(item.accountId);
listOpen = false;
highlighted = -1;
await tick();
amountInput?.select();
}
function keydown(event: KeyboardEvent) {
if (!matches.length) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
listOpen = true;
const step = event.key === 'ArrowDown' ? 1 : -1;
highlighted = (highlighted + step + matches.length) % matches.length;
} else if (event.key === 'Enter' && listOpen && highlighted >= 0) {
event.preventDefault();
choose(highlighted);
} else if (event.key === 'Escape') {
listOpen = false;
highlighted = -1;
} else if (event.key === 'Tab' && listOpen && highlighted >= 0) choose(highlighted);
}
</script>
<form
class="quick-add"
method="POST"
action="?/saveTransaction"
aria-label="Quick add transaction"
use:enhance={() => {
saving = true;
const saved = { description, signedAmount };
return async ({ result, update }) => {
saving = false;
if (result.type === 'success') {
localStorage.setItem(storageKey, accountId);
failed = false;
message = `Saved ${saved.description} · ${euro.format(Number(saved.signedAmount))}`;
description = '';
amount = '';
await update({ reset: false });
descriptionInput?.focus();
} else {
failed = true;
message =
result.type === 'failure' && typeof result.data?.message === 'string'
? result.data.message
: 'Could not save this transaction.';
}
};
}}
>
<div class="quick-kind" role="group" aria-label="Transaction type">
<Button
type="button"
variant={kind === 'expense' ? 'default' : 'ghost'}
aria-pressed={kind === 'expense'}
onclick={() => (kind = 'expense')}>Expense</Button
><Button
type="button"
variant={kind === 'income' ? 'default' : 'ghost'}
aria-pressed={kind === 'income'}
onclick={() => (kind = 'income')}>Income</Button
>
</div>
<div class="quick-description">
<label class="sr-only" for="quick-description">Description</label>
<Input
id="quick-description"
name="description"
autocomplete="off"
placeholder="What was it? e.g. Albert Heijn"
required
maxlength={500}
role="combobox"
aria-expanded={listOpen && matches.length > 0}
aria-controls="quick-suggestions"
aria-autocomplete="list"
aria-activedescendant={highlighted >= 0 ? `quick-suggestion-${highlighted}` : undefined}
bind:ref={descriptionInput}
bind:value={description}
oninput={() => {
listOpen = true;
highlighted = -1;
}}
onkeydown={keydown}
onfocus={() => (listOpen = true)}
onblur={() => setTimeout(() => (listOpen = false), 120)}
/>
{#if listOpen && matches.length}
<ul id="quick-suggestions" role="listbox" class="quick-suggestions">
{#each matches as item, index (item.key)}
<li
id={`quick-suggestion-${index}`}
role="option"
aria-selected={index === highlighted}
class:highlighted={index === highlighted}
onmousedown={(event) => {
event.preventDefault();
choose(index);
}}
>
<span>{item.description}</span>
<span class="muted"
>{euro.format(item.amountCents / 100)} · {accounts.find(
(a) => a.id === item.accountId
)?.name ?? ''}</span
>
</li>
{/each}
</ul>
{/if}
</div>
<label class="sr-only" for="quick-amount">Amount in euros</label>
<Input
id="quick-amount"
class="quick-amount"
inputmode="decimal"
placeholder="0.00"
required
pattern="[-+]?[0-9]+([.,][0-9]{(1, 2)})?"
bind:ref={amountInput}
bind:value={amount}
/>
<input type="hidden" name="amount" value={signedAmount} />
<label class="sr-only" for="quick-account">Account</label>
<NativeSelect id="quick-account" name="accountId" bind:value={accountId} required
>{#each accounts as account (account.id)}<option value={String(account.id)}
>{account.name}</option
>{/each}</NativeSelect
>
<label class="sr-only" for="quick-date">Date</label>
<Input id="quick-date" class="quick-date" type="date" name="date" bind:value={date} required />
<Button type="submit" disabled={saving || !accounts.length}><Plus size={16} /> Add</Button>
{#if message}<p class="quick-message" class:form-error={failed} role="status">{message}</p>{/if}
</form>
<style>
.quick-add {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0 0 20px;
padding: 12px;
border: 1px solid var(--site-border);
border-radius: 10px;
}
.quick-kind {
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid var(--site-border);
border-radius: 8px;
}
.quick-description {
position: relative;
flex: 1 1 220px;
}
.quick-add :global(.quick-amount) {
width: 110px;
text-align: right;
}
.quick-add :global(.quick-date) {
width: 150px;
}
.quick-suggestions {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: 4px;
list-style: none;
background: var(--site-bg);
border: 1px solid var(--site-border);
border-radius: 8px;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
}
.quick-suggestions li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.quick-suggestions li.highlighted,
.quick-suggestions li:hover {
background: var(--site-border);
}
.quick-message {
flex-basis: 100%;
margin: 0;
font-size: 13px;
}
.muted {
opacity: 0.65;
}
</style>

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { descriptionSuggestions } from './quick-add';
describe('quick-add suggestions', () => {
it('ranks by use and fills the usual amount and latest account', () => {
const rows = [
{ description: 'PLUS', amountCents: -1200, accountId: 1, date: '2026-10-01' },
{ description: 'plus', amountCents: -1200, accountId: 1, date: '2026-10-03' },
{ description: 'Plus', amountCents: -800, accountId: 4, date: '2026-10-05' },
{ description: 'From Juhee', amountCents: 5000, accountId: 4, date: '2026-10-02' },
{ description: 'Future', amountCents: -1, accountId: 1, date: '2026-12-01' }
];
const [first, second, ...rest] = descriptionSuggestions(rows, '2026-10-24');
expect(first).toMatchObject({
key: 'plus',
description: 'Plus',
amountCents: -1200,
accountId: 4,
count: 3
});
expect(second).toMatchObject({ description: 'From Juhee', amountCents: 5000 });
expect(rest).toEqual([]);
});
});

View File

@@ -0,0 +1,73 @@
export type SuggestionSource = {
description: string;
amountCents: number;
accountId: number | null;
date: string;
};
export type Suggestion = {
key: string;
description: string;
/** Most common amount for this description (latest wins ties). */
amountCents: number;
/** Account used most recently with this description. */
accountId: number | null;
count: number;
lastDate: string;
};
/** Past descriptions ranked by use, each with its usual amount and latest account. */
export function descriptionSuggestions(rows: SuggestionSource[], today: string): Suggestion[] {
const groups = new Map<
string,
{
description: string;
amounts: Map<number, number>;
accountId: number | null;
count: number;
lastDate: string;
lastAmount: number;
}
>();
for (const row of rows) {
if (row.date > today) continue;
const key = row.description.trim().toLowerCase();
if (!key) continue;
const group = groups.get(key) ?? {
description: row.description.trim(),
amounts: new Map(),
accountId: row.accountId,
count: 0,
lastDate: '',
lastAmount: row.amountCents
};
group.count++;
group.amounts.set(row.amountCents, (group.amounts.get(row.amountCents) ?? 0) + 1);
if (row.date >= group.lastDate) {
group.lastDate = row.date;
group.description = row.description.trim();
group.accountId = row.accountId;
group.lastAmount = row.amountCents;
}
groups.set(key, group);
}
return [...groups.entries()]
.map(([key, group]) => {
let amountCents = group.lastAmount;
let best = group.amounts.get(amountCents) ?? 0;
for (const [value, uses] of group.amounts)
if (uses > best) {
best = uses;
amountCents = value;
}
return {
key,
description: group.description,
amountCents,
accountId: group.accountId,
count: group.count,
lastDate: group.lastDate
};
})
.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
}

View File

@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { cents, csvDate, importCents, parseCsv, parseTransactionImport } from './money-tracker';
import {
cents,
csvDate,
importCents,
parseCsv,
parseTransactionImport,
planImport
} from './money-tracker';
describe('transaction import parsing', () => {
it('handles quoted commas and semicolon-separated European amounts', () => {
@@ -63,3 +70,50 @@ describe('transaction import parsing', () => {
).toThrow('Quote values containing commas');
});
});
describe('revolut statements and import dedupe', () => {
const statement =
'Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance\n' +
'CARD_PAYMENT,Current,2026-10-01 10:00:00,2026-10-02 09:00:00,Albert Heijn,-12.34,0.00,EUR,COMPLETED,100.00\n' +
'TOPUP,Current,2026-10-03 10:00:00,,Pending top-up,50.00,0.00,EUR,PENDING,\n' +
'TRANSFER,Current,2026-10-04 10:00:00,2026-10-04 10:00:01,To Joint,-100.00,0.50,EUR,COMPLETED,0.00\n' +
'CARD_PAYMENT,Current,2026-10-05 10:00:00,2026-10-05 11:00:00,Coffee,-3.00,0.00,USD,COMPLETED,0.00\n';
it('keeps completed EUR rows, uses the completed date and deducts fees', () => {
const rows = parseTransactionImport(statement, 'Revolut');
expect(rows.map((row) => [row.date, row.account, row.description, row.amountCents])).toEqual([
['2026-10-02', 'Revolut', 'Albert Heijn', -1234],
['2026-10-04', 'Revolut', 'To Joint', -10050]
]);
expect(rows[0].externalId).toMatch(/^csv:[0-9a-f]{32}$/);
expect(parseTransactionImport(statement, 'Revolut')[0].externalId).toBe(rows[0].externalId);
expect(() => parseTransactionImport(statement)).toThrow('Choose which account');
});
it('gives identical rows in one file distinct, stable ids', () => {
const file =
'date,account,description,amount\n2026-10-01,Cash,Kiosk,-2\n2026-10-01,Cash,Kiosk,-2\n';
const [a, b] = parseTransactionImport(file);
expect(a.externalId).not.toBe(b.externalId);
expect(parseTransactionImport(file).map((row) => row.externalId)).toEqual([
a.externalId,
b.externalId
]);
});
it('skips rows already imported or already entered by hand (±1 day)', () => {
const rows = [
{ accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:a' },
{ accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:b' },
{ accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' },
{ accountId: 2, date: '2026-10-04', amountCents: -500, externalId: 'csv:d' }
];
const existing = [
{ id: 10, accountId: 1, date: '2026-10-01', amountCents: -1234, externalId: null },
{ id: 11, accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' }
];
const { insert, duplicates } = planImport(rows, existing);
expect(insert.map((row) => row.externalId)).toEqual(['csv:b', 'csv:d']);
expect(duplicates).toBe(2);
});
});

View File

@@ -1,4 +1,5 @@
import { fail } from '@sveltejs/kit';
import { createHash } from 'node:crypto';
export const utilityKinds = ['electricity', 'gas', 'water'] as const;
export const cadences = ['weekly', 'monthly', 'yearly'] as const;
@@ -130,21 +131,93 @@ export type ImportedTransaction = {
account: string;
description: string;
amountCents: number;
/** Stable per-row id ("csv:<hash>") so importing the same file twice adds nothing. */
externalId: string;
};
export function parseTransactionImport(input: string): ImportedTransaction[] {
const rows = parseCsv(input);
if (rows.length < 2 || rows.length > 20_001)
throw new Error('The file must have a header and 1–20,000 rows.');
const header = rows.shift()!.map((value) =>
export type ImportFormat = 'tracker' | 'revolut';
function headerOf(rows: string[][]) {
return rows[0].map((value) =>
value
.replace(/^\uFEFF/, '')
.trim()
.toLowerCase()
);
}
/** Revolut app statement export: Type, Product, Started Date, Completed Date, Description, Amount, Fee, Currency, State, Balance. */
export function isRevolutHeader(header: string[]) {
return ['type', 'started date', 'description', 'amount', 'currency', 'state'].every((key) =>
header.includes(key)
);
}
function importId(parts: (string | number)[], seen: Map<string, number>) {
const base = parts.join('\u001f');
// Identical rows in one file (two coffees, same day) stay distinct but stable.
const occurrence = (seen.get(base) ?? 0) + 1;
seen.set(base, occurrence);
return (
'csv:' + createHash('sha256').update(`${base}\u001f${occurrence}`).digest('hex').slice(0, 32)
);
}
/**
* Parses either the tracker's own export (date, account, description, amount) or a Revolut
* statement. Revolut files have no account column, so `defaultAccount` names the tracker
* account they belong to; only COMPLETED EUR rows are kept and the fee is deducted.
*/
export function parseTransactionImport(
input: string,
defaultAccount?: string
): ImportedTransaction[] {
const rows = parseCsv(input);
if (rows.length < 2 || rows.length > 20_001)
throw new Error('The file must have a header and 1–20,000 rows.');
const header = headerOf(rows);
rows.shift();
const seen = new Map<string, number>();
if (isRevolutHeader(header)) {
const account = defaultAccount?.trim();
if (!account) throw new Error('Choose which account this Revolut statement belongs to.');
const col = (key: string) => header.indexOf(key);
const parsed: ImportedTransaction[] = [];
for (const [index, row] of rows.entries()) {
const state = (row[col('state')] ?? '').trim().toUpperCase();
if (state && state !== 'COMPLETED') continue;
const currency = (row[col('currency')] ?? 'EUR').trim().toUpperCase();
if (currency && currency !== 'EUR') continue;
const started = (row[col('started date')] ?? '').trim();
const completed = col('completed date') >= 0 ? (row[col('completed date')] ?? '').trim() : '';
const date = csvDate((completed || started).slice(0, 10));
const amount = importCents(row[col('amount')]);
const fee = col('fee') >= 0 ? (importCents(row[col('fee')] || '0') ?? undefined) : 0;
const description = (row[col('description')] ?? '').trim().slice(0, 500) || 'Revolut';
if (!date || amount == null || fee == null)
throw new Error(`Invalid data in statement row ${index + 1}. No rows were imported.`);
const amountCents = amount - fee;
if (!amountCents) continue;
parsed.push({
date,
account,
description,
amountCents,
externalId: importId(
['revolut', account.toLowerCase(), started, amount, fee, description],
seen
)
});
}
if (!parsed.length) throw new Error('No completed EUR transactions found in this statement.');
return parsed.sort((a, b) => a.date.localeCompare(b.date));
}
const columns = ['date', 'account', 'description', 'amount'].map((key) => header.indexOf(key));
if (columns.some((index) => index < 0))
throw new Error('Headers must include date, account, description and amount.');
const accountColumn = columns[1];
if (columns[0] < 0 || columns[2] < 0 || columns[3] < 0 || (accountColumn < 0 && !defaultAccount))
throw new Error(
'Headers must include date, account, description and amount (or choose an account), or be a Revolut statement.'
);
const parsed: ImportedTransaction[] = [];
for (const [index, row] of rows.entries()) {
if (row.length !== header.length)
@@ -152,7 +225,8 @@ export function parseTransactionImport(input: string): ImportedTransaction[] {
`Data row ${index + 1} has ${row.length} columns; expected ${header.length}. Quote values containing commas. No rows were imported.`
);
const date = csvDate(row[columns[0]] ?? '');
const account = (row[columns[1]] ?? '').trim();
const account =
(accountColumn >= 0 ? (row[accountColumn] ?? '') : '').trim() || defaultAccount?.trim() || '';
const description = (row[columns[2]] ?? '').trim();
const amountCents = importCents(row[columns[3]]);
if (
@@ -164,7 +238,64 @@ export function parseTransactionImport(input: string): ImportedTransaction[] {
amountCents == null
)
throw new Error(`Invalid data in transaction ${index + 1}. No rows were imported.`);
parsed.push({ date, account, description, amountCents });
parsed.push({
date,
account,
description,
amountCents,
externalId: importId([date, account.toLowerCase(), description, amountCents], seen)
});
}
return parsed.sort((a, b) => a.date.localeCompare(b.date));
}
export type ExistingImportRow = {
id: number;
accountId: number;
date: string;
amountCents: number;
externalId: string | null;
};
function daysApart(a: string, b: string) {
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
}
/**
* Splits imported rows into new ones and duplicates: same import id already stored, or an
* existing row on the same account with the same amount within ±1 day (each existing row
* can only absorb one imported row).
*/
export function planImport<
T extends { accountId: number; date: string; amountCents: number; externalId: string }
>(rows: T[], existing: ExistingImportRow[]) {
const ids = new Set(
existing.flatMap((row) => (row.externalId ? [`${row.accountId}:${row.externalId}`] : []))
);
const byKey = new Map<string, ExistingImportRow[]>();
for (const row of existing) {
const key = `${row.accountId}:${row.amountCents}`;
byKey.set(key, [...(byKey.get(key) ?? []), row]);
}
const claimed = new Set<number>();
const insert: T[] = [];
let duplicates = 0;
for (const row of rows) {
const idKey = `${row.accountId}:${row.externalId}`;
if (ids.has(idKey)) {
duplicates++;
continue;
}
const match = (byKey.get(`${row.accountId}:${row.amountCents}`) ?? [])
.filter((candidate) => !claimed.has(candidate.id) && daysApart(candidate.date, row.date) <= 1)
.sort((a, b) => daysApart(a.date, row.date) - daysApart(b.date, row.date))[0];
if (match) {
claimed.add(match.id);
duplicates++;
continue;
}
ids.add(idKey);
insert.push(row);
}
return { insert, duplicates };
}