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 =
|
const remembered =
|
||||||
typeof localStorage === 'undefined' ? null : localStorage.getItem(storageKey);
|
typeof localStorage === 'undefined' ? null : localStorage.getItem(storageKey);
|
||||||
accountId = String(
|
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):
|
// 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.
|
// still listed with its own balance, left out of net worth and the total wealth chart.
|
||||||
includeInTotal: boolean('include_in_total').notNull().default(true),
|
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'),
|
color: text('color'),
|
||||||
// Household scope: Stan's own money vs. the joint account with Juhee.
|
// Household scope: Stan's own money vs. the joint account with Juhee.
|
||||||
scope: text('scope').notNull().default('personal'),
|
scope: text('scope').notNull().default('personal'),
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const actions: Actions = {
|
|||||||
const openingBalanceCents = cents(form.get('openingBalance'));
|
const openingBalanceCents = cents(form.get('openingBalance'));
|
||||||
const kind = String(form.get('kind') ?? 'spendable');
|
const kind = String(form.get('kind') ?? 'spendable');
|
||||||
const includeInTotal = form.get('includeInTotal') === 'on';
|
const includeInTotal = form.get('includeInTotal') === 'on';
|
||||||
|
const archived = form.get('archived') === 'on';
|
||||||
const colorRaw = String(form.get('color') ?? '').trim();
|
const colorRaw = String(form.get('color') ?? '').trim();
|
||||||
const color = colorRaw ? colorRaw.toLowerCase() : null;
|
const color = colorRaw ? colorRaw.toLowerCase() : null;
|
||||||
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
|
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
|
||||||
@@ -138,14 +139,20 @@ export const actions: Actions = {
|
|||||||
if (id) {
|
if (id) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(moneyAccounts)
|
.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)))
|
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id)))
|
||||||
.returning({ id: moneyAccounts.id });
|
.returning({ id: moneyAccounts.id });
|
||||||
if (!row) error(404, 'Account not found');
|
if (!row) error(404, 'Account not found');
|
||||||
} else
|
} else
|
||||||
await db
|
await db.insert(moneyAccounts).values({
|
||||||
.insert(moneyAccounts)
|
userId: user.id,
|
||||||
.values({ userId: user.id, name, openingBalanceCents, kind, includeInTotal, color });
|
name,
|
||||||
|
openingBalanceCents,
|
||||||
|
kind,
|
||||||
|
includeInTotal,
|
||||||
|
archived,
|
||||||
|
color
|
||||||
|
});
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
|
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
|
||||||
return invalid('An account with that name already exists.');
|
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 futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
|
||||||
let balances = $derived(accountBalances(data.accounts, actualTransactions));
|
let balances = $derived(accountBalances(data.accounts, actualTransactions));
|
||||||
let totalAccounts = $derived(data.accounts.filter((account) => account.includeInTotal !== false));
|
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 totalIds = $derived(new Set(totalAccounts.map((account) => account.id)));
|
||||||
let total = $derived(
|
let total = $derived(
|
||||||
totalAccounts.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
|
totalAccounts.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
|
||||||
@@ -399,6 +416,41 @@
|
|||||||
>
|
>
|
||||||
{/snippet}
|
{/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">
|
<div class="money-app mx-auto max-w-screen-2xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -449,7 +501,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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}
|
{#if importNotice}<p class="mb-4 text-sm" role="status">{importNotice}</p>{/if}
|
||||||
|
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
@@ -576,43 +628,24 @@
|
|||||||
onclick={() => open('account')}><Plus size={16} /> Add</Button
|
onclick={() => open('account')}><Plus size={16} /> Add</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if data.accounts.length}<ul class="item-list">
|
{#if openAccounts.length}<ul class="item-list">
|
||||||
{#each data.accounts as account (account.id)}<li
|
{#each openAccounts as account (account.id)}{@render accountRow(account)}{/each}
|
||||||
class:selected={accountFilter.includes(account.id)}
|
</ul>{:else if !archivedAccounts.length}<p class="empty">
|
||||||
>
|
|
||||||
<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">
|
|
||||||
Add an account or import your transactions to get started.
|
Add an account or import your transactions to get started.
|
||||||
</p>{/if}
|
</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>
|
||||||
|
|
||||||
<section class="panel span-8" id="transactions">
|
<section class="panel span-8" id="transactions">
|
||||||
@@ -658,7 +691,11 @@
|
|||||||
>{#each data.accounts as account (account.id)}<Button
|
>{#each data.accounts as account (account.id)}<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
type="button"
|
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)}
|
aria-pressed={accountFilter.includes(account.id)}
|
||||||
onclick={() => toggleAccount(account.id)}
|
onclick={() => toggleAccount(account.id)}
|
||||||
>{@render swatch(account.id)}{account.name}</Button
|
>{@render swatch(account.id)}{account.name}</Button
|
||||||
@@ -1038,7 +1075,7 @@
|
|||||||
accounts)<NativeSelect name="accountId" bind:value={importAccountId}
|
accounts)<NativeSelect name="accountId" bind:value={importAccountId}
|
||||||
><option value="">From the file</option
|
><option value="">From the file</option
|
||||||
>{#each data.accounts as account (account.id)}<option value={String(account.id)}
|
>{#each data.accounts as account (account.id)}<option value={String(account.id)}
|
||||||
>{account.name}</option
|
>{account.name}{account.archived ? ' (archived)' : ''}</option
|
||||||
>{/each}</NativeSelect
|
>{/each}</NativeSelect
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
@@ -1109,6 +1146,10 @@
|
|||||||
><Checkbox name="includeInTotal" value="on" checked={active.includeInTotal !== false} /> Count
|
><Checkbox name="includeInTotal" value="on" checked={active.includeInTotal !== false} /> Count
|
||||||
toward total wealth</label
|
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"
|
<label class="field"
|
||||||
>Color<Input
|
>Color<Input
|
||||||
name="color"
|
name="color"
|
||||||
@@ -1144,9 +1185,13 @@
|
|||||||
<label class="field"
|
<label class="field"
|
||||||
>Account<NativeSelect name="accountId" required={mode === 'transaction'}
|
>Account<NativeSelect name="accountId" required={mode === 'transaction'}
|
||||||
><option value="">{mode === 'forecast' ? 'Unassigned' : 'Select an account'}</option
|
><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}
|
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
|
>{/each}</NativeSelect
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
@@ -1192,9 +1237,10 @@
|
|||||||
<label class="field"
|
<label class="field"
|
||||||
>Account<NativeSelect name="accountId"
|
>Account<NativeSelect name="accountId"
|
||||||
><option value="">Any account</option
|
><option value="">Any account</option
|
||||||
>{#each data.accounts as account (account.id)}<option
|
>{#each pickerAccounts as account (account.id)}<option
|
||||||
value={account.id}
|
value={account.id}
|
||||||
selected={active.accountId === account.id}>{account.name}</option
|
selected={active.accountId === account.id}
|
||||||
|
>{account.name}{account.archived ? ' (archived)' : ''}</option
|
||||||
>{/each}</NativeSelect
|
>{/each}</NativeSelect
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -653,3 +653,24 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--site-muted);
|
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,
|
id: moneyAccounts.id,
|
||||||
name: moneyAccounts.name,
|
name: moneyAccounts.name,
|
||||||
color: moneyAccounts.color,
|
color: moneyAccounts.color,
|
||||||
scope: moneyAccounts.scope
|
scope: moneyAccounts.scope,
|
||||||
|
archived: moneyAccounts.archived
|
||||||
})
|
})
|
||||||
.from(moneyAccounts)
|
.from(moneyAccounts)
|
||||||
.where(eq(moneyAccounts.userId, user.id))
|
.where(eq(moneyAccounts.userId, user.id))
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
value={data.filters.accountIds.length === 1 ? String(data.filters.accountIds[0]) : ''}
|
value={data.filters.accountIds.length === 1 ? String(data.filters.accountIds[0]) : ''}
|
||||||
onchange={autoSubmit}
|
onchange={autoSubmit}
|
||||||
><option value="">All accounts</option>{#each data.accounts as account (account.id)}<option
|
><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
|
>{/each}</NativeSelect
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user