money: archived accounts (accounts.archived) — closed banks stay in history, net worth and charts but leave the quick balances, quick add and account pickers; archived section in the accounts panel; quick add defaults to Revolut
All checks were successful
Deploy production / deploy (push) Successful in 1m7s

This commit is contained in:
2026-10-25 15:42:40 +00:00
parent 968ec34ac5
commit 9114b5433b
9 changed files with 27157 additions and 49 deletions

View File

@@ -128,6 +128,7 @@ export const actions: Actions = {
const openingBalanceCents = cents(form.get('openingBalance'));
const kind = String(form.get('kind') ?? 'spendable');
const includeInTotal = form.get('includeInTotal') === 'on';
const archived = form.get('archived') === 'on';
const colorRaw = String(form.get('color') ?? '').trim();
const color = colorRaw ? colorRaw.toLowerCase() : null;
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
@@ -138,14 +139,20 @@ export const actions: Actions = {
if (id) {
const [row] = await db
.update(moneyAccounts)
.set({ name, openingBalanceCents, kind, includeInTotal, color })
.set({ name, openingBalanceCents, kind, includeInTotal, archived, color })
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id)))
.returning({ id: moneyAccounts.id });
if (!row) error(404, 'Account not found');
} else
await db
.insert(moneyAccounts)
.values({ userId: user.id, name, openingBalanceCents, kind, includeInTotal, color });
await db.insert(moneyAccounts).values({
userId: user.id,
name,
openingBalanceCents,
kind,
includeInTotal,
archived,
color
});
} catch (cause) {
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
return invalid('An account with that name already exists.');

View File

@@ -101,6 +101,23 @@
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
let balances = $derived(accountBalances(data.accounts, actualTransactions));
let totalAccounts = $derived(data.accounts.filter((account) => account.includeInTotal !== false));
// Archived accounts (closed banks) stay in history, net worth and the charts, but not in the
// quick balances or the account pickers.
let openAccounts = $derived(data.accounts.filter((account) => !account.archived));
let archivedAccounts = $derived(data.accounts.filter((account) => account.archived));
let archivedTotal = $derived(
archivedAccounts
.filter((account) => account.includeInTotal !== false)
.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
);
let defaultAccountId = $derived(
openAccounts.find((account) => account.name.toLowerCase() === 'revolut')?.id ??
openAccounts[0]?.id
);
/** Account picker options: open accounts, plus the archived one an edited row already uses. */
let pickerAccounts = $derived(
data.accounts.filter((account) => !account.archived || account.id === active.accountId)
);
let totalIds = $derived(new Set(totalAccounts.map((account) => account.id)));
let total = $derived(
totalAccounts.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
@@ -399,6 +416,41 @@
>
{/snippet}
{#snippet accountRow(account: (typeof data.accounts)[number])}
<li class:selected={accountFilter.includes(account.id)}>
<Button
variant="ghost"
type="button"
class="item-main"
aria-pressed={accountFilter.includes(account.id)}
onclick={() => toggleAccount(account.id)}
>{@render swatch(account.id)}<span
><strong>{account.name}</strong><small class="capitalize"
>{account.kind}{#if account.archived}&nbsp;· archived{/if}{#if account.includeInTotal === false}&nbsp;·
not in total{/if} · {#if account.bankBalanceAt}bank
{amount(account.bankBalanceCents ?? 0)}, {new Date(
account.bankBalanceAt
).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}{#if account.bankBalanceCents !== (balances.get(account.id) ?? 0)}<span
class="text-destructive"
>
· differs from tracker</span
>{/if}{:else}opening {amount(account.openingBalanceCents)}{/if}</small
></span
></Button
><strong class="item-amount">{@render signed(balances.get(account.id) ?? 0)}</strong
>{@render rowActions(
account.name,
() => open('account', account),
() => deletion(account.name, account.id, 'deleteAccount')
)}
</li>
{/snippet}
<div class="money-app mx-auto max-w-screen-2xl px-4 py-8 sm:px-6 sm:py-10">
<header class="app-header">
<div>
@@ -449,7 +501,7 @@
</div>
</section>
<QuickAdd accounts={data.accounts} transactions={data.transactions} today={data.today} />
<QuickAdd accounts={openAccounts} transactions={data.transactions} today={data.today} />
{#if importNotice}<p class="mb-4 text-sm" role="status">{importNotice}</p>{/if}
<div class="dashboard">
@@ -576,43 +628,24 @@
onclick={() => open('account')}><Plus size={16} /> Add</Button
>
</div>
{#if data.accounts.length}<ul class="item-list">
{#each data.accounts as account (account.id)}<li
class:selected={accountFilter.includes(account.id)}
>
<Button
variant="ghost"
type="button"
class="item-main"
aria-pressed={accountFilter.includes(account.id)}
onclick={() => toggleAccount(account.id)}
>{@render swatch(account.id)}<span
><strong>{account.name}</strong><small class="capitalize"
>{account.kind}{#if account.includeInTotal === false}&nbsp;· not in total{/if} · {#if account.bankBalanceAt}bank
{amount(account.bankBalanceCents ?? 0)}, {new Date(
account.bankBalanceAt
).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}{#if account.bankBalanceCents !== (balances.get(account.id) ?? 0)}<span
class="text-destructive"
>
· differs from tracker</span
>{/if}{:else}opening {amount(account.openingBalanceCents)}{/if}</small
></span
></Button
><strong class="item-amount">{@render signed(balances.get(account.id) ?? 0)}</strong
>{@render rowActions(
account.name,
() => open('account', account),
() => deletion(account.name, account.id, 'deleteAccount')
)}
</li>{/each}
</ul>{:else}<p class="empty">
{#if openAccounts.length}<ul class="item-list">
{#each openAccounts as account (account.id)}{@render accountRow(account)}{/each}
</ul>{:else if !archivedAccounts.length}<p class="empty">
Add an account or import your transactions to get started.
</p>{/if}
{#if archivedAccounts.length}<details class="archived-accounts">
<summary
>Archived accounts ({archivedAccounts.length}){#if archivedTotal}&nbsp;·
{@render signed(archivedTotal)} still in net worth{/if}</summary
>
<p class="muted archived-note">
Closed or unused accounts. Their history stays in transactions, net worth and the
charts; they are hidden from quick add and the account pickers.
</p>
<ul class="item-list">
{#each archivedAccounts as account (account.id)}{@render accountRow(account)}{/each}
</ul>
</details>{/if}
</section>
<section class="panel span-8" id="transactions">
@@ -658,7 +691,11 @@
>{#each data.accounts as account (account.id)}<Button
variant="ghost"
type="button"
class={accountFilter.includes(account.id) ? 'current' : ''}
class={[
accountFilter.includes(account.id) && 'current',
account.archived && 'archived-chip'
]}
title={account.archived ? `${account.name} (archived)` : undefined}
aria-pressed={accountFilter.includes(account.id)}
onclick={() => toggleAccount(account.id)}
>{@render swatch(account.id)}{account.name}</Button
@@ -1038,7 +1075,7 @@
accounts)<NativeSelect name="accountId" bind:value={importAccountId}
><option value="">From the file</option
>{#each data.accounts as account (account.id)}<option value={String(account.id)}
>{account.name}</option
>{account.name}{account.archived ? ' (archived)' : ''}</option
>{/each}</NativeSelect
></label
>
@@ -1109,6 +1146,10 @@
><Checkbox name="includeInTotal" value="on" checked={active.includeInTotal !== false} /> Count
toward total wealth</label
>
<label class="field check-field"
><Checkbox name="archived" value="on" checked={Boolean(active.archived)} /> Archived (closed):
hide from quick add, the balance list and account pickers; history and net worth keep it</label
>
<label class="field"
>Color<Input
name="color"
@@ -1144,9 +1185,13 @@
<label class="field"
>Account<NativeSelect name="accountId" required={mode === 'transaction'}
><option value="">{mode === 'forecast' ? 'Unassigned' : 'Select an account'}</option
>{#each data.accounts as account (account.id)}<option
>{#each pickerAccounts as account (account.id)}<option
value={account.id}
selected={active.accountId === account.id}>{account.name}</option
selected={active.accountId === account.id ||
(mode === 'transaction' &&
active.accountId == null &&
account.id === defaultAccountId)}
>{account.name}{account.archived ? ' (archived)' : ''}</option
>{/each}</NativeSelect
></label
>
@@ -1192,9 +1237,10 @@
<label class="field"
>Account<NativeSelect name="accountId"
><option value="">Any account</option
>{#each data.accounts as account (account.id)}<option
>{#each pickerAccounts as account (account.id)}<option
value={account.id}
selected={active.accountId === account.id}>{account.name}</option
selected={active.accountId === account.id}
>{account.name}{account.archived ? ' (archived)' : ''}</option
>{/each}</NativeSelect
></label
>

View File

@@ -653,3 +653,24 @@
font-size: 14px;
color: var(--site-muted);
}
.money-app .archived-accounts {
margin-top: 12px;
border-top: 1px solid var(--site-border);
padding-top: 10px;
}
.money-app .archived-accounts summary {
cursor: pointer;
font-size: 14px;
opacity: 0.8;
}
.money-app .archived-accounts .archived-note {
margin: 6px 0 8px;
font-size: 13px;
}
.money-app .archived-accounts .item-list {
opacity: 0.75;
}
.money-app .archived-chip {
opacity: 0.6;
}

View File

@@ -65,7 +65,8 @@ export const load: PageServerLoad = async (event) => {
id: moneyAccounts.id,
name: moneyAccounts.name,
color: moneyAccounts.color,
scope: moneyAccounts.scope
scope: moneyAccounts.scope,
archived: moneyAccounts.archived
})
.from(moneyAccounts)
.where(eq(moneyAccounts.userId, user.id))

View File

@@ -89,7 +89,7 @@
value={data.filters.accountIds.length === 1 ? String(data.filters.accountIds[0]) : ''}
onchange={autoSubmit}
><option value="">All accounts</option>{#each data.accounts as account (account.id)}<option
value={String(account.id)}>{account.name}</option
value={String(account.id)}>{account.name}{account.archived ? ' (archived)' : ''}</option
>{/each}</NativeSelect
></label
>