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
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;
|
||||
27026
drizzle/20261025154013_money_accounts_archived/snapshot.json
Normal file
27026
drizzle/20261025154013_money_accounts_archived/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,9 @@
|
||||
const remembered =
|
||||
typeof localStorage === 'undefined' ? null : localStorage.getItem(storageKey);
|
||||
accountId = String(
|
||||
accounts.find((account) => String(account.id) === remembered)?.id ?? accounts[0].id
|
||||
accounts.find((account) => String(account.id) === remembered)?.id ??
|
||||
accounts.find((account) => account.name.toLowerCase() === 'revolut')?.id ??
|
||||
accounts[0].id
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,6 +27,10 @@ export const moneyAccounts = moneySchema.table(
|
||||
// False for money tracked but not owned (e.g. a savings account parents controlled):
|
||||
// still listed with its own balance, left out of net worth and the total wealth chart.
|
||||
includeInTotal: boolean('include_in_total').notNull().default(true),
|
||||
// Closed or no longer used accounts (old banks, Trading 212 once emptied): kept for history,
|
||||
// transactions and the wealth chart, but left out of the quick balances and account pickers.
|
||||
// Their balance still counts in net worth (include_in_total) until it reaches zero.
|
||||
archived: boolean('archived').notNull().default(false),
|
||||
color: text('color'),
|
||||
// Household scope: Stan's own money vs. the joint account with Juhee.
|
||||
scope: text('scope').notNull().default('personal'),
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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} · archived{/if}{#if account.includeInTotal === false} ·
|
||||
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} · 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} ·
|
||||
{@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
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user