update money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 30s
All checks were successful
Test and deploy staging / staging (push) Successful in 30s
This commit is contained in:
@@ -7,7 +7,8 @@ import {
|
||||
moneyUtilitySettings,
|
||||
moneyUtilityReadings,
|
||||
moneyWishlist,
|
||||
moneyInsurances
|
||||
moneyInsurances,
|
||||
moneyProjectionSettings
|
||||
} from '$lib/server/db/schema';
|
||||
import { requireAdmin } from '$lib/server/authorization';
|
||||
import {
|
||||
@@ -38,7 +39,8 @@ export const load: PageServerLoad = async (event) => {
|
||||
utilitySettings,
|
||||
utilityReadings,
|
||||
wishlist,
|
||||
insurances
|
||||
insurances,
|
||||
projectionSettings
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@@ -75,7 +77,8 @@ export const load: PageServerLoad = async (event) => {
|
||||
.select()
|
||||
.from(moneyInsurances)
|
||||
.where(eq(moneyInsurances.userId, userId))
|
||||
.orderBy(asc(moneyInsurances.name))
|
||||
.orderBy(asc(moneyInsurances.name)),
|
||||
db.select().from(moneyProjectionSettings).where(eq(moneyProjectionSettings.userId, userId))
|
||||
]);
|
||||
return {
|
||||
accounts,
|
||||
@@ -85,7 +88,9 @@ export const load: PageServerLoad = async (event) => {
|
||||
utilitySettings,
|
||||
utilityReadings,
|
||||
wishlist,
|
||||
insurances
|
||||
insurances,
|
||||
projectionSettings: projectionSettings[0] ?? null,
|
||||
today: new Date().toISOString().slice(0, 10)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,18 +116,20 @@ export const actions: Actions = {
|
||||
const form = await event.request.formData();
|
||||
const name = textField(form, 'name', 80);
|
||||
const openingBalanceCents = cents(form.get('openingBalance'));
|
||||
if (!name || openingBalanceCents == null)
|
||||
const kind = String(form.get('kind') ?? 'spendable');
|
||||
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
|
||||
return invalid('Enter an account name and a valid opening balance.');
|
||||
const id = recordId(form);
|
||||
try {
|
||||
if (id) {
|
||||
const [row] = await db
|
||||
.update(moneyAccounts)
|
||||
.set({ name, openingBalanceCents })
|
||||
.set({ name, openingBalanceCents, kind })
|
||||
.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 });
|
||||
} else
|
||||
await db.insert(moneyAccounts).values({ userId: user.id, name, openingBalanceCents, kind });
|
||||
} catch (cause) {
|
||||
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
|
||||
return invalid('An account with that name already exists.');
|
||||
@@ -182,10 +189,21 @@ export const actions: Actions = {
|
||||
const name = textField(form, 'name', 200);
|
||||
const amountCents = cents(form.get('amount'));
|
||||
const period = cadence(form.get('cadence'));
|
||||
if (!name || amountCents == null || !period)
|
||||
const startsOnRaw = String(form.get('startsOn') ?? '').trim();
|
||||
const endsOnRaw = String(form.get('endsOn') ?? '').trim();
|
||||
const startsOn = startsOnRaw ? validDate(startsOnRaw) : null;
|
||||
const endsOn = endsOnRaw ? validDate(endsOnRaw) : null;
|
||||
if (
|
||||
!name ||
|
||||
amountCents == null ||
|
||||
!period ||
|
||||
(startsOnRaw && !startsOn) ||
|
||||
(endsOnRaw && !endsOn) ||
|
||||
(startsOn && endsOn && endsOn < startsOn)
|
||||
)
|
||||
return invalid('Enter a name, valid signed amount and frequency.');
|
||||
const accountId = await ownedAccount(user.id, form.get('accountId'), true);
|
||||
const values = { name, amountCents, cadence: period, accountId };
|
||||
const values = { name, amountCents, cadence: period, accountId, startsOn, endsOn };
|
||||
const id = recordId(form);
|
||||
if (id) {
|
||||
const [row] = await db
|
||||
@@ -236,6 +254,59 @@ export const actions: Actions = {
|
||||
.where(and(eq(moneyForecasts.id, id), eq(moneyForecasts.userId, user.id)));
|
||||
return { saved: true };
|
||||
},
|
||||
saveProjectionSettings: async (event) => {
|
||||
const user = requireAdmin(event);
|
||||
const form = await event.request.formData();
|
||||
const investmentAccountId = await ownedAccount(user.id, form.get('investmentAccountId'), true);
|
||||
if (investmentAccountId !== null) {
|
||||
const [account] = await db
|
||||
.select({ kind: moneyAccounts.kind })
|
||||
.from(moneyAccounts)
|
||||
.where(and(eq(moneyAccounts.id, investmentAccountId), eq(moneyAccounts.userId, user.id)));
|
||||
if (account?.kind !== 'investment') return invalid('Choose an investment account.');
|
||||
}
|
||||
const reserveCents = cents(form.get('reserve'));
|
||||
const fixedMonthlyCents = cents(form.get('fixedMonthly'));
|
||||
const annualReturnPercent = String(form.get('annualReturnPercent') ?? '')
|
||||
.trim()
|
||||
.replace(',', '.');
|
||||
const contributionMode = String(form.get('contributionMode') ?? 'excess');
|
||||
const endDateRaw = String(form.get('endDate') ?? '').trim();
|
||||
const endDate = endDateRaw ? validDate(endDateRaw) : null;
|
||||
const maxEndDate = new Date();
|
||||
maxEndDate.setUTCFullYear(maxEndDate.getUTCFullYear() + 20);
|
||||
if (
|
||||
reserveCents == null ||
|
||||
reserveCents < 0 ||
|
||||
fixedMonthlyCents == null ||
|
||||
fixedMonthlyCents < 0 ||
|
||||
!/^(?:\d{1,2}|100)(?:\.\d{1,2})?$/.test(annualReturnPercent) ||
|
||||
Number(annualReturnPercent) > 100 ||
|
||||
!['excess', 'fixed'].includes(contributionMode) ||
|
||||
(endDateRaw && !endDate) ||
|
||||
(endDate &&
|
||||
(endDate < new Date().toISOString().slice(0, 10) ||
|
||||
endDate > maxEndDate.toISOString().slice(0, 10)))
|
||||
)
|
||||
return invalid('Enter valid projection settings.');
|
||||
const values = {
|
||||
investmentAccountId,
|
||||
reserveCents,
|
||||
fixedMonthlyCents,
|
||||
annualReturnPercent,
|
||||
contributionMode,
|
||||
includeRecurring: form.get('includeRecurring') === 'on' ? 1 : 0,
|
||||
endDate
|
||||
};
|
||||
await db
|
||||
.insert(moneyProjectionSettings)
|
||||
.values({ userId: user.id, ...values })
|
||||
.onConflictDoUpdate({
|
||||
target: moneyProjectionSettings.userId,
|
||||
set: values
|
||||
});
|
||||
return { saved: true };
|
||||
},
|
||||
saveUtilitySettings: async (event) => {
|
||||
const user = requireAdmin(event);
|
||||
const form = await event.request.formData();
|
||||
@@ -300,14 +371,18 @@ export const actions: Actions = {
|
||||
const name = textField(form, 'name', 200);
|
||||
const estimatedCents = cents(form.get('estimated'), true);
|
||||
const notes = optionalText(form, 'notes');
|
||||
const priority = Number(form.get('priority') ?? 3);
|
||||
if (
|
||||
!name ||
|
||||
estimatedCents === undefined ||
|
||||
notes === undefined ||
|
||||
(estimatedCents !== null && estimatedCents < 0)
|
||||
(estimatedCents !== null && estimatedCents < 0) ||
|
||||
!Number.isInteger(priority) ||
|
||||
priority < 1 ||
|
||||
priority > 5
|
||||
)
|
||||
return invalid('Enter a name and a valid estimated cost.');
|
||||
const values = { name, estimatedCents, notes };
|
||||
const values = { name, estimatedCents, notes, priority };
|
||||
const id = recordId(form);
|
||||
if (id) {
|
||||
const [row] = await db
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
import {
|
||||
accountBalances,
|
||||
balanceSeries,
|
||||
forecastSeries,
|
||||
monthlyCents,
|
||||
utilityEstimate
|
||||
utilityEstimate,
|
||||
wealthProjection
|
||||
} from '$lib/money-tracker/charts';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
| 'recurring'
|
||||
| 'forecast'
|
||||
| 'settings'
|
||||
| 'projectionSettings'
|
||||
| 'reading'
|
||||
| 'wish'
|
||||
| 'insurance'
|
||||
@@ -57,10 +58,12 @@
|
||||
let pageNumber = $state(1);
|
||||
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
|
||||
let dialog: HTMLDialogElement;
|
||||
let balances = $derived(accountBalances(data.accounts, data.transactions));
|
||||
let actualTransactions = $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 total = $derived([...balances.values()].reduce((sum, value) => sum + value, 0));
|
||||
let series = $derived(balanceSeries(data.accounts, data.transactions));
|
||||
let recent = $derived([...data.transactions].reverse().slice(0, 8));
|
||||
let series = $derived(balanceSeries(data.accounts, actualTransactions));
|
||||
let recent = $derived([...actualTransactions].reverse().slice(0, 8));
|
||||
let filtered = $derived(
|
||||
(sortOrder === 'newest' ? [...data.transactions].reverse() : data.transactions).filter(
|
||||
(row) =>
|
||||
@@ -75,7 +78,27 @@
|
||||
data.recurring.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0)
|
||||
);
|
||||
let projection = $derived(
|
||||
forecastSeries(total, data.forecasts, new Date().toISOString().slice(0, 10))
|
||||
wealthProjection(
|
||||
data.accounts,
|
||||
balances,
|
||||
futureTransactions,
|
||||
data.forecasts,
|
||||
data.recurring,
|
||||
data.projectionSettings,
|
||||
data.today
|
||||
)
|
||||
);
|
||||
let wishTiers = $derived(
|
||||
[5, 4, 3, 2, 1].map((tier) => ({
|
||||
tier,
|
||||
value: data.wishlist
|
||||
.filter((item) => item.priority >= tier)
|
||||
.reduce((sum, item) => sum + (item.estimatedCents ?? 0), 0),
|
||||
count: data.wishlist.filter((item) => item.priority >= tier).length
|
||||
}))
|
||||
);
|
||||
let unpricedWishes = $derived(
|
||||
data.wishlist.filter((item) => item.estimatedCents === null).length
|
||||
);
|
||||
let utility = $derived(kinds.find((item) => item.id === selectedUtility)!);
|
||||
let settings = $derived(data.utilitySettings.find((item) => item.kind === selectedUtility));
|
||||
@@ -206,7 +229,7 @@
|
||||
<div class="net-worth">
|
||||
<p class="eyebrow">Across {data.accounts.length} accounts</p>
|
||||
<p class="net-value">{amount(total)}</p>
|
||||
<p class="muted">Current combined balance</p>
|
||||
<p class="muted">Current combined balance through {dateLabel(data.today)}</p>
|
||||
</div>
|
||||
<div class="overview-side">
|
||||
<div>
|
||||
@@ -223,10 +246,55 @@
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Account history</h2>
|
||||
<p>Balances calculated from opening balances and dated transactions.</p>
|
||||
<p>
|
||||
End-of-day balances through today. Future dated transactions appear in the projection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<MoneyChart {series} title="Account balances over time" />
|
||||
<MoneyChart {series} title="Individual and combined account balances over time" />
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Expected wealth</h2>
|
||||
<p>From today through {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}.</p>
|
||||
</div>
|
||||
<button
|
||||
class="button secondary"
|
||||
onclick={() => open('projectionSettings', data.projectionSettings ?? {})}
|
||||
>Edit projection</button
|
||||
>
|
||||
</div>
|
||||
<MoneyChart series={projection} title="Expected total, spendable and invested wealth" />
|
||||
<p class="muted projection-note">
|
||||
Includes future dated transactions and estimates{data.projectionSettings
|
||||
?.includeRecurring !== 0
|
||||
? ', plus recurring monthly equivalents from next month'
|
||||
: ''}. {data.projectionSettings?.investmentAccountId
|
||||
? `The selected investment account has a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return; contributions move money between accounts.`
|
||||
: 'Choose an investment account to model contributions and growth.'} Enter an item only once
|
||||
across transactions, estimates, and recurring items to avoid counting it twice.
|
||||
</p>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Wish list total</h2>
|
||||
<p>Cumulative estimated cost by priority, from tier 5 down.</p>
|
||||
</div>
|
||||
<button class="text-button" onclick={() => (tab = 'wishlist')}>View wish list</button>
|
||||
</div>
|
||||
<div class="wish-tier-grid">
|
||||
{#each wishTiers as row (row.tier)}<div>
|
||||
<span class="eyebrow">{row.tier === 5 ? 'Tier 5' : `Tiers 5–${row.tier}`}</span><strong
|
||||
>{amount(row.value)}</strong
|
||||
><small>{row.count} {row.count === 1 ? 'item' : 'items'}</small>
|
||||
</div>{/each}
|
||||
</div>
|
||||
{#if unpricedWishes}<p class="muted projection-note">
|
||||
{unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these
|
||||
totals.
|
||||
</p>{/if}
|
||||
</section>
|
||||
<div class="two-columns">
|
||||
<section class="panel">
|
||||
@@ -348,23 +416,10 @@
|
||||
<p class="muted">Signed total of recurring income and costs</p>
|
||||
</div>
|
||||
<p class="muted">
|
||||
Recurring items are a reference list. Dated forecasts do not change actual balances.
|
||||
Recurring items feed the Overview projection when enabled. Dated forecasts do not change
|
||||
actual balances.
|
||||
</p>
|
||||
</div>
|
||||
{#if data.forecasts.some((item) => item.date >= new Date().toISOString().slice(0, 10))}
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Projected total</h2>
|
||||
<p>
|
||||
Current combined balance plus dated future estimates. Recurring items are not
|
||||
automatically included.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<MoneyChart series={projection} title="Projected combined account balance" />
|
||||
</section>
|
||||
{/if}
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
@@ -379,17 +434,20 @@
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Name</th><th>Account</th><th>Frequency</th><th class="numeric">Amount</th><th
|
||||
class="actions-heading">Actions</th
|
||||
></tr
|
||||
><th>Name</th><th>Account</th><th>Frequency</th><th>Dates</th><th class="numeric"
|
||||
>Amount</th
|
||||
><th class="actions-heading">Actions</th></tr
|
||||
></thead
|
||||
><tbody
|
||||
>{#each data.recurring as row (row.id)}<tr
|
||||
><td class="strong">{row.name}</td><td
|
||||
>{row.accountId ? accountName(row.accountId) : 'Any'}</td
|
||||
><td class="capitalize">{row.cadence}</td><td
|
||||
class="numeric"
|
||||
class:negative={row.amountCents < 0}>{amount(row.amountCents)}</td
|
||||
>{row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
|
||||
? dateLabel(row.endsOn)
|
||||
: 'ongoing'}</td
|
||||
><td class="numeric" class:negative={row.amountCents < 0}
|
||||
>{amount(row.amountCents)}</td
|
||||
><td class="actions"
|
||||
><button aria-label={'Edit ' + row.name} onclick={() => open('recurring', row)}
|
||||
><Pencil size={15} /></button
|
||||
@@ -552,13 +610,14 @@
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Item</th><th>Notes</th><th class="numeric">Estimated cost</th><th
|
||||
><th>Item</th><th>Tier</th><th>Notes</th><th class="numeric">Estimated cost</th><th
|
||||
class="actions-heading">Actions</th
|
||||
></tr
|
||||
></thead
|
||||
><tbody
|
||||
>{#each data.wishlist as row (row.id)}<tr
|
||||
><td class="strong">{row.name}</td><td>{row.notes ?? '—'}</td><td class="numeric"
|
||||
><td class="strong">{row.name}</td><td>{row.priority}</td><td>{row.notes ?? '—'}</td
|
||||
><td class="numeric"
|
||||
>{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}</td
|
||||
><td class="actions"
|
||||
><button aria-label={'Edit ' + row.name} onclick={() => open('wish', row)}
|
||||
@@ -628,14 +687,14 @@
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Account</th><th class="numeric">Opening balance</th><th class="numeric"
|
||||
>Current balance</th
|
||||
><th>Account</th><th>Type</th><th class="numeric">Opening balance</th><th
|
||||
class="numeric">Current balance</th
|
||||
><th class="actions-heading">Actions</th></tr
|
||||
></thead
|
||||
><tbody
|
||||
>{#each data.accounts as row (row.id)}<tr
|
||||
><td class="strong">{row.name}</td><td class="numeric"
|
||||
>{amount(row.openingBalanceCents)}</td
|
||||
><td class="strong">{row.name}</td><td class="capitalize">{row.kind}</td><td
|
||||
class="numeric">{amount(row.openingBalanceCents)}</td
|
||||
><td class="numeric strong">{amount(balances.get(row.id) ?? 0)}</td><td
|
||||
class="actions"
|
||||
><button aria-label={'Edit ' + row.name} onclick={() => open('account', row)}
|
||||
@@ -667,8 +726,10 @@
|
||||
? 'Import transactions'
|
||||
: mode === 'settings'
|
||||
? 'Utility settings'
|
||||
: (active.id ? 'Edit ' : 'Add ') +
|
||||
(mode === 'wish' ? 'wish list item' : mode === 'reading' ? 'meter reading' : mode)}
|
||||
: mode === 'projectionSettings'
|
||||
? 'Wealth projection'
|
||||
: (active.id ? 'Edit ' : 'Add ') +
|
||||
(mode === 'wish' ? 'wish list item' : mode === 'reading' ? 'meter reading' : mode)}
|
||||
</h2>
|
||||
<button class="icon-button" aria-label="Close dialog" onclick={() => dialog.close()}
|
||||
><X size={18} /></button
|
||||
@@ -738,11 +799,13 @@
|
||||
? 'saveForecast'
|
||||
: mode === 'settings'
|
||||
? 'saveUtilitySettings'
|
||||
: mode === 'reading'
|
||||
? 'saveReading'
|
||||
: mode === 'wish'
|
||||
? 'saveWishlist'
|
||||
: 'saveInsurance')}
|
||||
: mode === 'projectionSettings'
|
||||
? 'saveProjectionSettings'
|
||||
: mode === 'reading'
|
||||
? 'saveReading'
|
||||
: mode === 'wish'
|
||||
? 'saveWishlist'
|
||||
: 'saveInsurance')}
|
||||
use:enhance={() =>
|
||||
async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
@@ -770,6 +833,15 @@
|
||||
value={inputAmount(active.openingBalanceCents)}
|
||||
/><small>Balance before the first transaction you record.</small></label
|
||||
>
|
||||
<label class="field"
|
||||
>Account type<select name="kind"
|
||||
><option value="spendable" selected={active.kind !== 'investment'}>Spendable</option
|
||||
><option value="investment" selected={active.kind === 'investment'}>Investment</option
|
||||
></select
|
||||
><small
|
||||
>Investment accounts are included in total wealth but excluded from the cash reserve.</small
|
||||
></label
|
||||
>
|
||||
{:else if mode === 'transaction' || mode === 'forecast'}
|
||||
<label class="field"
|
||||
>Date<input
|
||||
@@ -844,6 +916,78 @@
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label class="field"
|
||||
>Starts on<input name="startsOn" type="date" value={active.startsOn ?? ''} /><small
|
||||
>Optional. Leave blank for an ongoing item.</small
|
||||
></label
|
||||
>
|
||||
<label class="field"
|
||||
>Ends on<input name="endsOn" type="date" value={active.endsOn ?? ''} /></label
|
||||
>
|
||||
{:else if mode === 'projectionSettings'}
|
||||
<p class="dialog-copy">
|
||||
A planning scenario. Returns are hypothetical and calculated monthly. Contributions
|
||||
transfer money from spendable accounts into the selected investment account.
|
||||
</p>
|
||||
<label class="field"
|
||||
>Investment account<select name="investmentAccountId"
|
||||
><option value="">No investment model</option
|
||||
>{#each data.accounts.filter((account) => account.kind === 'investment') as account (account.id)}<option
|
||||
value={account.id}
|
||||
selected={active.investmentAccountId === account.id}>{account.name}</option
|
||||
>{/each}</select
|
||||
><small>Mark an account as Investment in Accounts to select it here.</small></label
|
||||
>
|
||||
<label class="field"
|
||||
>Spendable reserve (€)<input
|
||||
name="reserve"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
value={inputAmount(active.reserveCents ?? 100000)}
|
||||
/></label
|
||||
>
|
||||
<label class="field"
|
||||
>Hypothetical annual return (%)<input
|
||||
name="annualReturnPercent"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
required
|
||||
value={active.annualReturnPercent ?? '5.00'}
|
||||
/></label
|
||||
>
|
||||
<label class="field"
|
||||
>Contribution rule<select name="contributionMode"
|
||||
><option value="excess" selected={active.contributionMode !== 'fixed'}
|
||||
>Invest all above reserve</option
|
||||
><option value="fixed" selected={active.contributionMode === 'fixed'}
|
||||
>Fixed monthly amount, up to excess</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<label class="field"
|
||||
>Fixed monthly amount (€)<input
|
||||
name="fixedMonthly"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
value={inputAmount(active.fixedMonthlyCents ?? 0)}
|
||||
/></label
|
||||
>
|
||||
<label class="field"
|
||||
>Project through<input name="endDate" type="date" value={active.endDate ?? ''} /><small
|
||||
>Optional, up to 20 years ahead. By default, ends at the latest future entry, or one
|
||||
year from today.</small
|
||||
></label
|
||||
>
|
||||
<label class="field check-field"
|
||||
><input name="includeRecurring" type="checkbox" checked={active.includeRecurring !== 0} /> Include
|
||||
recurring items from next month</label
|
||||
>
|
||||
{:else if mode === 'settings'}
|
||||
<input type="hidden" name="kind" value={active.kind ?? selectedUtility} />
|
||||
<label class="field"
|
||||
@@ -915,6 +1059,15 @@
|
||||
value={inputAmount(active.estimatedCents)}
|
||||
/></label
|
||||
>
|
||||
<label class="field"
|
||||
>Priority tier<select name="priority"
|
||||
>{#each [5, 4, 3, 2, 1] as tier (tier)}<option
|
||||
value={tier}
|
||||
selected={(active.priority ?? 3) === tier}
|
||||
>Tier {tier}{tier === 5 ? ' · highest' : tier === 1 ? ' · lowest' : ''}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label class="field"
|
||||
>Notes<textarea name="notes" maxlength="2000" rows="3">{active.notes ?? ''}</textarea
|
||||
></label
|
||||
|
||||
@@ -126,6 +126,38 @@
|
||||
letter-spacing: -0.04em;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.money-app .projection-note {
|
||||
margin-top: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.money-app .wish-tier-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.money-app .wish-tier-grid > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 15px;
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .wish-tier-grid > div + div {
|
||||
border-left: 1px solid var(--site-border);
|
||||
}
|
||||
.money-app .wish-tier-grid strong {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.money-app .wish-tier-grid small {
|
||||
color: var(--site-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.money-app .capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.money-app .eyebrow {
|
||||
font-size: 12px;
|
||||
color: var(--site-muted);
|
||||
@@ -401,6 +433,15 @@
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.money-dialog .field.check-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
.money-dialog .field.check-field input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
.money-dialog .dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -430,6 +471,13 @@
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.money-app .wish-tier-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.money-app .wish-tier-grid > div {
|
||||
border-left: 0 !important;
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
.money-app .planning-intro {
|
||||
display: block;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user