+
(hoverDate = null)}
>
- {#each [0, 0.5, 1] as fraction (fraction)}
- {@const value = max - span * fraction}
+ {#each scale.ticks as value (value)}
{unit}{money.format(value / (unit === '€' ? 100 : 1))} {format(value)}
{/each}
+ {#each ticks() as tick (tick)}
+ {dateLabel(tick)}
+ {/each}
+ {#if marker && time(marker.date) >= from && time(marker.date) <= until}
+
+ {marker.label}
+ {/if}
{#each series as item (item.label)}
- {#if item.points.length}
-
- {#if item.points.length === 1}
- 1}
+
{/if}
+ {/each}
+ {#if item.points.length === 1}
+
{/if}
{/each}
- {dateLabel(from)}
- {dateLabel(until)}
+ {#if hoverDate}
+
+ {#each hoverRows as row (row.item.label)}
+
+ {/each}
+ {/if}
+
+ {#if hoverDate && hoverRows.length}
+
+ {/if}
- {#if series.length > 1}
+ {#if series.length > 1 || series.some((item) => item.dashedFrom)}
{#each series as item (item.label)}
{item.label}
{/each}
+ {#if series.some((item) => item.dashedFrom)}
+ Actual
+ Projected
+ {/if}
{/if}
{:else}
@@ -124,14 +283,54 @@
.chart {
min-width: 0;
}
- .chart-scroll {
- overflow-x: auto;
+ .chart-area {
+ position: relative;
+ touch-action: pan-y;
}
svg {
display: block;
width: 100%;
- min-width: 540px;
height: auto;
+ overflow: visible;
+ user-select: none;
+ }
+ .tooltip {
+ position: absolute;
+ top: 4px;
+ z-index: 2;
+ min-width: 170px;
+ max-width: 240px;
+ margin-left: 12px;
+ padding: 8px 10px;
+ border: 1px solid var(--site-border);
+ border-radius: 7px;
+ background: var(--site-bg, #000);
+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.25);
+ font-size: 12px;
+ pointer-events: none;
+ }
+ .tooltip.flip {
+ transform: translateX(calc(-100% - 24px));
+ }
+ .tooltip p {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ margin: 0;
+ color: var(--site-muted);
+ line-height: 1.6;
+ }
+ .tooltip p.emphasis {
+ color: var(--site-text);
+ font-weight: 600;
+ }
+ .tooltip .tooltip-date {
+ color: var(--site-text);
+ }
+ .tooltip-value {
+ margin-left: auto;
+ color: var(--site-text);
+ font-variant-numeric: tabular-nums;
}
.legend {
display: flex;
@@ -147,16 +346,27 @@
gap: 7px;
}
.legend > span.emphasis {
- order: -1;
- flex-basis: 100%;
color: var(--site-text);
font-weight: 600;
}
+ .legend > .line-key.first {
+ margin-left: auto;
+ }
+ .key {
+ display: inline-block;
+ width: 18px;
+ border-top: 2px solid var(--site-muted);
+ }
+ .key.dashed {
+ border-top-style: dashed;
+ }
.swatch {
display: inline-block;
+ flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
+ box-shadow: 0 0 0 1px var(--site-border);
}
.empty {
padding: 70px 0;
diff --git a/src/lib/money-tracker/charts.test.ts b/src/lib/money-tracker/charts.test.ts
index 656b357..60d9110 100644
--- a/src/lib/money-tracker/charts.test.ts
+++ b/src/lib/money-tracker/charts.test.ts
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest';
import {
accountBalances,
+ accountColor,
balanceSeries,
forecastSeries,
utilityEstimate,
- wealthProjection
+ wealthProjection,
+ wealthTimeline
} from './charts';
describe('money tracker calculations', () => {
@@ -31,6 +33,18 @@ describe('money tracker calculations', () => {
);
});
+ it("uses an account's chosen color and falls back to the palette", () => {
+ const series = balanceSeries(
+ [
+ { id: 1, name: 'Revolut', openingBalanceCents: 0, color: '#000000' },
+ { id: 2, name: 'Knab', openingBalanceCents: 0, color: null }
+ ],
+ [{ accountId: 1, date: '2026-01-01', amountCents: 100 }]
+ );
+ expect(series[0].color).toBe('#000000');
+ expect(series[1].color).toBe(accountColor({ color: null }, 1));
+ });
+
it('plots combined transactions in date order even if the input is unordered', () => {
const combined = balanceSeries(
[{ id: 1, name: 'Cash', openingBalanceCents: 1000 }],
@@ -113,6 +127,108 @@ describe('money tracker calculations', () => {
expect(series[0].points.at(-1)?.value).toBe(15000);
});
+ it('invests excess each month without requiring an existing investment account', () => {
+ const projection = wealthProjection(
+ [{ id: 1, kind: 'spendable', openingBalanceCents: 40054 }],
+ new Map([[1, 40054]]),
+ [],
+ [],
+ [{ amountCents: 127137, cadence: 'monthly', accountId: 1 }],
+ {
+ investmentAccountId: null,
+ reserveCents: 100000,
+ annualReturnPercent: '5.00',
+ contributionMode: 'excess',
+ fixedMonthlyCents: 0,
+ includeRecurring: 1,
+ endDate: '2026-11-30'
+ },
+ '2026-09-23'
+ );
+ expect(projection[0].points.map((point) => point.value)).toEqual([
+ 40054, 40054, 167191, 294602
+ ]);
+ expect(projection[1].points.at(-1)?.value).toBe(100000);
+ expect(projection[2].points.at(-1)?.value).toBe(194602);
+ });
+
+ it('continues actual history into the projection as one dashed line', () => {
+ const accounts = [
+ { id: 1, name: 'Bank', kind: 'spendable', openingBalanceCents: 0 },
+ { id: 2, name: 'Broker', kind: 'investment', openingBalanceCents: 0 }
+ ];
+ const actual = [
+ { accountId: 1, date: '2026-09-01', amountCents: 50000 },
+ { accountId: 2, date: '2026-09-10', amountCents: 20000 }
+ ];
+ const projection = [
+ {
+ label: 'Total wealth',
+ color: '',
+ points: [
+ { date: '2026-09-23', value: 70000 },
+ { date: '2026-10-31', value: 90000 }
+ ]
+ },
+ {
+ label: 'Spendable',
+ color: '',
+ points: [
+ { date: '2026-09-23', value: 50000 },
+ { date: '2026-10-31', value: 60000 }
+ ]
+ },
+ {
+ label: 'Invested',
+ color: '',
+ points: [
+ { date: '2026-09-23', value: 20000 },
+ { date: '2026-10-31', value: 30000 }
+ ]
+ }
+ ];
+ const [total] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'total');
+ expect(total.dashedFrom).toBe('2026-09-23');
+ expect(total.points).toEqual([
+ { date: '2026-09-01', value: 50000 },
+ { date: '2026-09-10', value: 70000 },
+ { date: '2026-09-23', value: 70000 },
+ { date: '2026-10-31', value: 90000 }
+ ]);
+ const [, spendable, invested] = wealthTimeline(
+ accounts,
+ actual,
+ projection,
+ '2026-09-23',
+ 'split'
+ );
+ expect(spendable.points.map((point) => point.value)).toEqual([50000, 50000, 50000, 60000]);
+ expect(invested.points.map((point) => point.value)).toEqual([0, 20000, 20000, 30000]);
+ const byAccount = wealthTimeline(accounts, actual, projection, '2026-09-23', 'accounts');
+ expect(byAccount.map((line) => line.label)).toEqual(['Bank', 'Broker', 'Total']);
+ expect(byAccount[0].points.at(-1)).toEqual({ date: '2026-09-23', value: 50000 });
+ });
+
+ it('starts the timeline at today when there is no history', () => {
+ const [total] = wealthTimeline(
+ [],
+ [],
+ [
+ {
+ label: 'Total wealth',
+ color: '',
+ points: [
+ { date: '2026-09-23', value: 0 },
+ { date: '2026-10-31', value: 100 }
+ ]
+ }
+ ],
+ '2026-09-23',
+ 'total'
+ );
+ expect(total.points.map((point) => point.date)).toEqual(['2026-09-23', '2026-10-31']);
+ });
+
it('compares estimated usage cost with prorated advances', () => {
const result = utilityEstimate(
[
diff --git a/src/lib/money-tracker/charts.ts b/src/lib/money-tracker/charts.ts
index bfe902a..b668c20 100644
--- a/src/lib/money-tracker/charts.ts
+++ b/src/lib/money-tracker/charts.ts
@@ -4,13 +4,21 @@ export type ChartSeries = {
color: string;
points: ChartPoint[];
emphasis?: boolean;
+ /** Points after this date are drawn dashed (projected). */
+ dashedFrom?: string;
};
+const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
+
+/** The account's chosen color, or a palette color based on its position in the list. */
+export function accountColor(account: { color?: string | null }, index: number): string {
+ return account.color || palette[index % palette.length];
+}
+
export function balanceSeries(
- accounts: { id: number; name: string; openingBalanceCents: number }[],
+ accounts: { id: number; name: string; openingBalanceCents: number; color?: string | null }[],
transactions: { accountId: number; date: string; amountCents: number }[]
): ChartSeries[] {
- const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date));
const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]]));
@@ -34,7 +42,7 @@ export function balanceSeries(
return [
...accounts.map((account, index) => ({
label: account.name,
- color: palette[index % palette.length],
+ color: accountColor(account, index),
points: accountPoints.get(account.id)!
})),
{ label: 'All accounts', color: 'var(--site-muted)', points: combinedPoints }
@@ -163,7 +171,7 @@ export function wealthProjection(
} else spendable += value;
}
}
- if (validInvestmentId !== null && index > 0) {
+ if (index > 0) {
const growth = Math.round(indexBalance * monthlyGrowth);
invested += growth;
indexBalance += growth;
@@ -194,6 +202,82 @@ export function wealthProjection(
];
}
+export type TimelineView = 'total' | 'split' | 'accounts';
+
+/**
+ * Joins actual history (solid, through today) with the wealth projection (dashed, after
+ * today) so both read as one continuous line per measure.
+ */
+export function wealthTimeline(
+ accounts: {
+ id: number;
+ name: string;
+ kind: string;
+ openingBalanceCents: number;
+ color?: string | null;
+ }[],
+ actualTransactions: { accountId: number; date: string; amountCents: number }[],
+ projection: ChartSeries[],
+ today: string,
+ view: TimelineView
+): ChartSeries[] {
+ const history = balanceSeries(accounts, actualTransactions);
+ const accountLines = history.slice(0, accounts.length);
+ const dates = history.at(-1)!.points.map((point) => point.date);
+ const sumKind = (investment: boolean) =>
+ dates.map((date, index) => ({
+ date,
+ value: accounts.reduce(
+ (sum, account, accountIndex) =>
+ (account.kind === 'investment') === investment
+ ? sum + accountLines[accountIndex].points[index].value
+ : sum,
+ 0
+ )
+ }));
+ const untilToday = (points: ChartPoint[]) => {
+ const last = points.at(-1);
+ return last && last.date < today ? [...points, { date: today, value: last.value }] : points;
+ };
+ const join = (actual: ChartPoint[], projected: ChartPoint[]) => {
+ const solid = untilToday(actual);
+ const cutoff = solid.at(-1)?.date ?? '';
+ return [...solid, ...projected.filter((point) => point.date > cutoff)];
+ };
+ const [totalProjection, spendableProjection, investedProjection] = projection;
+ const total: ChartSeries = {
+ label: 'Total',
+ color: 'var(--site-text)',
+ emphasis: true,
+ dashedFrom: today,
+ points: join(history.at(-1)!.points, totalProjection?.points ?? [])
+ };
+ if (view === 'accounts')
+ return [
+ ...accountLines
+ .filter((line) => line.points.some((point) => point.value !== 0))
+ .map((line) => ({ ...line, points: untilToday(line.points) })),
+ total
+ ];
+ if (view === 'split')
+ return [
+ total,
+ {
+ label: 'Spendable',
+ color: '#46a758',
+ dashedFrom: today,
+ points: join(sumKind(false), spendableProjection?.points ?? [])
+ },
+ {
+ label: 'Invested',
+ color: '#8e4ec6',
+ dashedFrom: today,
+ points: join(sumKind(true), investedProjection?.points ?? [])
+ }
+ ];
+ return [total];
+}
+
export function utilityEstimate(
readings: { date: string; reading: string; kind: string }[],
kind: string,
diff --git a/src/lib/server/db/schema/lists.ts b/src/lib/server/db/schema/lists.ts
index a3c76b3..d46b39e 100644
--- a/src/lib/server/db/schema/lists.ts
+++ b/src/lib/server/db/schema/lists.ts
@@ -6,6 +6,7 @@ export const lists = pgTable('lists', {
id: id(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
+ pictureUrl: text('picture_url'),
isProgress: boolean('is_progress').notNull().default(false),
...timestamps
});
diff --git a/src/lib/server/db/schema/money-tracker.ts b/src/lib/server/db/schema/money-tracker.ts
index eacdc5b..35b64f5 100644
--- a/src/lib/server/db/schema/money-tracker.ts
+++ b/src/lib/server/db/schema/money-tracker.ts
@@ -12,6 +12,7 @@ export const moneyAccounts = pgTable(
name: text('name').notNull(),
openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
kind: text('kind').notNull().default('spendable'),
+ color: text('color'),
...timestamps
},
(table) => [unique().on(table.userId, table.name), index().on(table.userId)]
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
index 2f922ff..beae7e8 100755
--- a/src/routes/+page.server.ts
+++ b/src/routes/+page.server.ts
@@ -6,7 +6,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const lists = await db.query.lists.findMany({
- columns: { id: true, name: true, slug: true, isProgress: true },
+ columns: { id: true, name: true, slug: true, isProgress: true, pictureUrl: true },
with: { items: { columns: { id: true } } },
orderBy: { id: 'desc' }
});
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 724efcb..b8bede8 100755
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -138,7 +138,17 @@
class="block py-4 hover:text-[var(--site-muted)]"
>
- {list.name}
+
+ {#if list.pictureUrl} {/if}
+ {list.name}
+
{#if list.isProgress && data.user}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}
diff --git a/src/routes/lists/+page.server.ts b/src/routes/lists/+page.server.ts
index 2cd15c5..971c13b 100644
--- a/src/routes/lists/+page.server.ts
+++ b/src/routes/lists/+page.server.ts
@@ -2,6 +2,7 @@ import { db } from '$lib/server/db';
import { listItemCompletions, lists } from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization';
import { uniqueListSlug } from '$lib/server/slugify';
+import { listImageUrl } from '$lib/server/r2';
import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -36,12 +37,18 @@ export const actions: Actions = {
const form = await event.request.formData();
const name = String(form.get('name') ?? '').trim();
const type = String(form.get('type') ?? '');
+ const pictureKey = String(form.get('pictureKey') ?? '').trim();
+ const picture = form.get('picture');
if (!name || name.length > 200)
return fail(400, { message: 'Enter a list name of 1–200 characters.' });
if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' });
+ if (picture instanceof File && picture.size > 0)
+ return fail(400, { message: 'Enable JavaScript to upload an image.' });
+ const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
+ if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
const slug = await uniqueListSlug(name);
- await db.insert(lists).values({ name, slug, isProgress: type === 'progress' });
+ await db.insert(lists).values({ name, slug, isProgress: type === 'progress', pictureUrl });
redirect(303, `/lists/${slug}`);
}
};
diff --git a/src/routes/lists/+page.svelte b/src/routes/lists/+page.svelte
index 1276b7f..b038a1d 100644
--- a/src/routes/lists/+page.svelte
+++ b/src/routes/lists/+page.svelte
@@ -2,8 +2,42 @@
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte';
+ import ListPictureDropzone from '$lib/components/ListPictureDropzone.svelte';
+ import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
+ let uploadError = $state('');
+ let uploading = $state(false);
+ const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
+ uploadError = '';
+ const picture = formData.get('picture');
+ formData.delete('picture');
+ if (!(picture instanceof File) || !picture.size) return;
+ if (!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)) {
+ uploadError = 'Choose a JPEG, PNG, or WebP image.';
+ cancel();
+ return;
+ }
+ if (picture.size > 5 * 1024 * 1024) {
+ uploadError = 'Images must be 5 MB or smaller.';
+ cancel();
+ return;
+ }
+ uploading = true;
+ try {
+ const body = new FormData();
+ body.set('picture', picture);
+ const response = await fetch(resolve('/lists/api/upload'), { method: 'POST', body });
+ if (!response.ok) throw new Error('Could not upload the image. Please try again.');
+ const { key } = await response.json();
+ formData.set('pictureKey', key);
+ } catch (cause) {
+ uploadError = cause instanceof Error ? cause.message : 'Image upload failed.';
+ cancel();
+ } finally {
+ uploading = false;
+ }
+ };
@@ -14,7 +48,8 @@
{#if form?.message}{form.message}
{/if}
+ {#if uploadError}{uploadError}
{/if}
{#each data.lists as list (list.id)}
@@ -49,7 +87,17 @@
class="block py-5 hover:text-[var(--site-muted)]"
>
- {list.name}
+
+ {#if list.pictureUrl} {/if}
+ {list.name}
+
{#if list.isProgress && data.user}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}
diff --git a/src/routes/lists/[slug]/+page.server.ts b/src/routes/lists/[slug]/+page.server.ts
index f0d4893..4b545ae 100644
--- a/src/routes/lists/[slug]/+page.server.ts
+++ b/src/routes/lists/[slug]/+page.server.ts
@@ -1,4 +1,5 @@
import { db } from '$lib/server/db';
+import { env } from '$env/dynamic/private';
import { lists, listItems, listItemCompletions } from '$lib/server/db/schema';
import { requireAdmin, requireUser } from '$lib/server/authorization';
import { deleteObject, listImageKey, listImageUrl } from '$lib/server/r2';
@@ -13,6 +14,8 @@ async function currentList(slug: string) {
return list;
}
async function removeStoredPicture(url: string | null) {
+ // Development and staging may reference pictures copied from production.
+ if (env.ORIGIN !== 'https://stanrunge.dev') return;
const key = url ? listImageKey(url) : null;
if (!key) return;
try {
@@ -79,7 +82,9 @@ export const actions: Actions = {
.from(listItems)
.where(eq(listItems.listId, list.id));
await db.delete(lists).where(eq(lists.id, list.id));
- await Promise.all(pictures.map((item) => removeStoredPicture(item.pictureUrl)));
+ await Promise.all(
+ [...pictures.map((item) => item.pictureUrl), list.pictureUrl].map(removeStoredPicture)
+ );
redirect(303, '/lists');
},
updateList: async (event) => {
@@ -90,12 +95,21 @@ export const actions: Actions = {
.trim()
.toLowerCase();
const type = String(form.get('type') ?? '');
+ const pictureKey = String(form.get('pictureKey') ?? '').trim();
+ const removePicture = form.get('removePicture') === 'on';
+ const picture = form.get('picture');
if (!name || name.length > 200)
return fail(400, { message: 'Enter a name of 1–200 characters' });
if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' });
if (!validSlug(slug))
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
+ if (picture instanceof File && picture.size > 0)
+ return fail(400, { message: 'Enable JavaScript to upload an image.' });
+ if (pictureKey && removePicture)
+ return fail(400, { message: 'Choose a new picture or remove the old one.' });
+ const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
+ if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
const current = await currentList(event.params.slug);
const duplicate = await db
.select({ id: lists.id })
@@ -104,10 +118,16 @@ export const actions: Actions = {
if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
const [list] = await db
.update(lists)
- .set({ name, slug, isProgress: type === 'progress' })
+ .set({
+ name,
+ slug,
+ isProgress: type === 'progress',
+ ...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
+ })
.where(eq(lists.id, current.id))
.returning();
if (!list) error(404, 'List not found');
+ if (list.pictureUrl !== current.pictureUrl) await removeStoredPicture(current.pictureUrl);
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
return { saved: true };
},
diff --git a/src/routes/lists/[slug]/+page.svelte b/src/routes/lists/[slug]/+page.svelte
index 94b23fd..f995923 100644
--- a/src/routes/lists/[slug]/+page.svelte
+++ b/src/routes/lists/[slug]/+page.svelte
@@ -12,11 +12,28 @@
let uploadError = $state('');
let uploading = $state(false);
let managing = $state(false);
+ let search = $state('');
+ let editingItem = $state<(typeof data.list.items)[number] | null>(null);
+ let visibleItems = $derived(
+ data.list.items.filter((item) =>
+ `${item.name} ${item.description ?? ''}`.toLowerCase().includes(search.trim().toLowerCase())
+ )
+ );
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
+ const finish = async ({
+ result,
+ update
+ }: {
+ result: { type: string };
+ update: () => Promise;
+ }) => {
+ await update();
+ if (result.type === 'success') editingItem = null;
+ };
uploadError = '';
const picture = formData.get('picture');
formData.delete('picture');
- if (!(picture instanceof File) || picture.size === 0) return;
+ if (!(picture instanceof File) || picture.size === 0) return finish;
if (!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)) {
uploadError = 'Choose a JPEG, PNG, or WebP image.';
cancel();
@@ -44,9 +61,16 @@
} finally {
uploading = false;
}
+ return finish;
};
+ {
+ if (event.key === 'Escape') editingItem = null;
+ }}
+/>
+
lists
-
-
{data.list.name}
-
- {data.total}
- {data.total === 1 ? 'item' : 'items'}
-
+
+ {#if data.list.pictureUrl}
{/if}
+
+
{data.list.name}
+
+ {data.total}
+ {data.total === 1 ? 'item' : 'items'}
+
+
{#if data.isAdmin}
Progress
- Save list
+
+
+ {#if data.list.pictureUrl} Remove current picture {/if}
+
+ Save list
Delete this list
@@ -148,8 +190,17 @@
{/if}
+
+ Search list items
+
+
- {#each data.list.items as item (item.id)}
+ {#each visibleItems as item (item.id)}
{#if item.pictureUrl}
{/if}
+ {#if data.isAdmin && managing}
(editingItem = item)}
+ class="shrink-0 rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-surface)]"
+ aria-label={`Edit ${item.name}`}>Edit {/if}
- {#if data.isAdmin && managing}
-
- edit item
-
-
-
- Remove item
-
-
- {/if}
- {:else}no items yet {/each}
+ {:else}
+ {search ? 'No matching items' : 'no items yet'}
+ {/each}
+ {#if data.isAdmin && managing && editingItem}
+
+
+ {/if}
{#if data.isAdmin && managing}
add item
diff --git a/src/routes/projects/money-tracker/+page.server.ts b/src/routes/projects/money-tracker/+page.server.ts
index 5088a80..6907484 100644
--- a/src/routes/projects/money-tracker/+page.server.ts
+++ b/src/routes/projects/money-tracker/+page.server.ts
@@ -117,19 +117,24 @@ export const actions: Actions = {
const name = textField(form, 'name', 80);
const openingBalanceCents = cents(form.get('openingBalance'));
const kind = String(form.get('kind') ?? 'spendable');
+ const colorRaw = String(form.get('color') ?? '').trim();
+ const color = colorRaw ? colorRaw.toLowerCase() : null;
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
return invalid('Enter an account name and a valid opening balance.');
+ if (color && !/^#[0-9a-f]{6}$/.test(color)) return invalid('Choose a valid account color.');
const id = recordId(form);
try {
if (id) {
const [row] = await db
.update(moneyAccounts)
- .set({ name, openingBalanceCents, kind })
+ .set({ name, openingBalanceCents, kind, 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 });
+ await db
+ .insert(moneyAccounts)
+ .values({ userId: user.id, name, openingBalanceCents, kind, color });
} catch (cause) {
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
return invalid('An account with that name already exists.');
diff --git a/src/routes/projects/money-tracker/+page.svelte b/src/routes/projects/money-tracker/+page.svelte
index 1960c99..4ab9e3e 100644
--- a/src/routes/projects/money-tracker/+page.svelte
+++ b/src/routes/projects/money-tracker/+page.svelte
@@ -10,16 +10,16 @@
import MoneyChart from '$lib/money-tracker/MoneyChart.svelte';
import {
accountBalances,
- balanceSeries,
+ accountColor,
monthlyCents,
+ type TimelineView,
utilityEstimate,
- wealthProjection
+ wealthProjection,
+ wealthTimeline
} from '$lib/money-tracker/charts';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
- type Tab =
- 'overview' | 'transactions' | 'planning' | 'utilities' | 'wishlist' | 'insurance' | 'accounts';
type Mode =
| 'account'
| 'transaction'
@@ -32,28 +32,27 @@
| 'insurance'
| 'import'
| 'delete';
- const tabs: { id: Tab; label: string }[] = [
- { id: 'overview', label: 'Overview' },
- { id: 'transactions', label: 'Transactions' },
- { id: 'planning', label: 'Planning' },
- { id: 'utilities', label: 'Utilities' },
- { id: 'wishlist', label: 'Wish list' },
- { id: 'insurance', label: 'Insurance' },
- { id: 'accounts', label: 'Accounts' }
- ];
const kinds = [
{ id: 'electricity', label: 'Electricity', unit: 'kWh' },
{ id: 'gas', label: 'Gas', unit: 'm³' },
{ id: 'water', label: 'Water', unit: 'm³' }
] as const;
+ const presetColors = [
+ '#000000',
+ '#ffd100',
+ '#009286',
+ '#0070f3',
+ '#e5484d',
+ '#8e4ec6',
+ '#e56910'
+ ];
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
- let tab = $state('overview');
let mode = $state('transaction');
let active = $state>({});
let deleteAction = $state('');
let showActionError = $state(false);
let search = $state('');
- let accountFilter = $state('all');
+ let accountFilter = $state([]);
let sortOrder = $state<'newest' | 'oldest'>('newest');
let pageNumber = $state(1);
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
@@ -62,12 +61,29 @@
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, actualTransactions));
- let recent = $derived([...actualTransactions].reverse().slice(0, 8));
+ let invested = $derived(
+ data.accounts
+ .filter((account) => account.kind === 'investment')
+ .reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
+ );
+ let colors = $derived(
+ new Map(data.accounts.map((account, index) => [account.id, accountColor(account, index)]))
+ );
+ let monthNet = $derived(
+ actualTransactions
+ .filter((item) => item.date.slice(0, 7) === data.today.slice(0, 7))
+ .reduce((sum, item) => sum + item.amountCents, 0)
+ );
+ let monthlyInsurance = $derived(
+ data.insurances.reduce(
+ (sum, item) => sum + monthlyCents(item.premiumCents ?? 0, item.cadence),
+ 0
+ )
+ );
let filtered = $derived(
(sortOrder === 'newest' ? [...data.transactions].reverse() : data.transactions).filter(
(row) =>
- (accountFilter === 'all' || String(row.accountId) === accountFilter) &&
+ (!accountFilter.length || accountFilter.includes(row.accountId)) &&
(!search ||
row.description.toLowerCase().includes(search.toLowerCase()) ||
accountName(row.accountId).toLowerCase().includes(search.toLowerCase()))
@@ -75,7 +91,13 @@
);
let shown = $derived(filtered.slice((pageNumber - 1) * 25, pageNumber * 25));
let monthlyRecurring = $derived(
- data.recurring.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0)
+ data.recurring
+ .filter(
+ (item) =>
+ (!item.startsOn || item.startsOn <= data.today) &&
+ (!item.endsOn || item.endsOn >= data.today)
+ )
+ .reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0)
);
let projection = $derived(
wealthProjection(
@@ -88,6 +110,16 @@
data.today
)
);
+ const timelineViews: { id: TimelineView; label: string }[] = [
+ { id: 'total', label: 'Total' },
+ { id: 'split', label: 'Spendable vs invested' },
+ { id: 'accounts', label: 'By account' }
+ ];
+ let timelineView = $state('total');
+ let timeline = $derived(
+ wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView)
+ );
+ let projectionEnd = $derived(projection.map((line) => line.points.at(-1)?.value ?? 0));
let wishTiers = $derived(
[5, 4, 3, 2, 1].map((tier) => ({
tier,
@@ -144,20 +176,11 @@
showActionError = false;
dialog.showModal();
}
- function createForTab() {
- open(
- tab === 'accounts'
- ? 'account'
- : tab === 'transactions' || tab === 'overview'
- ? 'transaction'
- : tab === 'planning'
- ? 'recurring'
- : tab === 'utilities'
- ? 'reading'
- : tab === 'wishlist'
- ? 'wish'
- : 'insurance'
- );
+ function toggleAccount(id: number) {
+ accountFilter = accountFilter.includes(id)
+ ? accountFilter.filter((item) => item !== id)
+ : [...accountFilter, id];
+ pageNumber = 1;
}
function deletion(name: string, id: number, action: string) {
open('delete', { id, name }, action);
@@ -188,76 +211,71 @@
}
-
+{#snippet signed(value: number)}
+
0} class:negative={value < 0}>{amount(value)}
+{/snippet}
+{#snippet swatch(id: number | null)}
+
+{/snippet}
+{#snippet rowActions(label: string, edit: () => void, remove: () => void)}
+
+{/snippet}
+
+
-
- {#each tabs as item (item.id)} {
- tab = item.id;
- pageNumber = 1;
- }}>{item.label} {/each}
-
- {#if tab === 'overview'}
-
-
-
Across {data.accounts.length} accounts
-
{amount(total)}
-
Current combined balance through {dateLabel(data.today)}
-
-
-
-
Transactions
-
{data.transactions.length.toLocaleString('en-NL')}
-
-
-
Monthly recurring net
-
{amount(Math.round(monthlyRecurring))}
-
-
-
-
+
+
+
Net worth · {data.accounts.length} accounts
+
{@render signed(total)}
+
+
+
Spendable
+
{@render signed(total - invested)}
+
+
+
Invested
+
{@render signed(invested)}
+
+
+
Net this month
+
{@render signed(monthNet)}
+
+
+
Recurring / month
+
{@render signed(Math.round(monthlyRecurring))}
+
+
+
+ Projected {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}
+
+
{@render signed(projectionEnd[0])}
+
+
+
+
+
-
Account history
-
- End-of-day balances through today. Future dated transactions appear in the projection.
-
-
-
-
-
-
-
-
-
Expected wealth
-
From today through {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}.
+
Wealth over time
+
Actual balances through today, then the projection.
Edit projection
-
+
+ {#each timelineViews as item (item.id)} (timelineView = item.id)}>{item.label} {/each}
+
+
+
+
+ Total at end {@render signed(projectionEnd[0])}
+
+
+ Spendable at end {@render signed(projectionEnd[1])}
+
+
+ Invested at end {@render signed(projectionEnd[2])}
+
+
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.
+ ? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
+ : `Excess above ${amount(data.projectionSettings?.reserveCents ?? 100000)} moves into a projected investment balance with a hypothetical ${data.projectionSettings?.annualReturnPercent ?? '5.00'}% annual return.`}
+ Contributions move money between spendable and invested balances. Enter an item only once across
+ transactions, estimates, and recurring items to avoid counting it twice.
-
+
+
-
Wish list total
-
Cumulative estimated cost by priority, from tier 5 down.
+
Accounts
+
Select accounts to filter transactions.
-
(tab = 'wishlist')}>View wish list
+
open('account')}
+ > Add
-
- {#each wishTiers as row (row.tier)}
- {row.tier === 5 ? 'Tier 5' : `Tiers 5–${row.tier}`} {amount(row.value)} {row.count} {row.count === 1 ? 'item' : 'items'}
-
{/each}
-
- {#if unpricedWishes}
- {unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these
- totals.
+ {#if data.accounts.length}
+ {#each data.accounts as account (account.id)}
+ toggleAccount(account.id)}
+ >{@render swatch(account.id)}{account.name} {account.kind} · opening {amount(account.openingBalanceCents)} {@render signed(balances.get(account.id) ?? 0)} {@render rowActions(
+ account.name,
+ () => open('account', account),
+ () => deletion(account.name, account.id, 'deleteAccount')
+ )}
+ {/each}
+ {:else}
+ Add an account or import your transactions to get started.
{/if}
-
-
-
-
Accounts
- (tab = 'accounts')}>View all
-
- {#if data.accounts.length}
- {#each data.accounts as account (account.id)}
- {account.name} {amount(balances.get(account.id) ?? 0)}
- {/each}
- {:else}
- Add an account or import your transactions to get started.
-
{/if}
-
-
-
-
Recent transactions
- (tab = 'transactions')}>View all
-
- {#if recent.length}
- {#each recent as row (row.id)}
- {row.description} {accountName(row.accountId)} · {dateLabel(row.date)} {amount(row.amountCents)}
- {/each}
- {:else}
- Transactions will appear here after you add or import them.
-
{/if}
-
-
- {:else if tab === 'transactions'}
-
+
+
Transactions
@@ -346,15 +378,7 @@
placeholder="Search descriptions"
bind:value={search}
oninput={() => (pageNumber = 1)}
- />Filter account (pageNumber = 1)}
- >All accounts {#each data.accounts as account (account.id)}{account.name} {/each}
+ />
Transaction order
+ {#if data.accounts.length}
+ {
+ accountFilter = [];
+ pageNumber = 1;
+ }}>All accounts {#each data.accounts as account (account.id)} toggleAccount(account.id)}
+ >{@render swatch(account.id)}{account.name} {/each}
+
{/if}
{#each shown as row (row.id)}{dateLabel(row.date)} {row.description} {accountName(row.accountId)} {amount(row.amountCents)} {#each shown as row (row.id)} data.today}
+ >{dateLabel(row.date)} {row.description} {@render swatch(row.accountId)}{accountName(row.accountId)} {@render signed(row.amountCents)} open('transaction', row)}> open('transaction', row)}> deletion(row.description, row.id, 'deleteTransaction')}
- > {/each}
{/if}
- {:else if tab === 'planning'}
-
-
-
Monthly equivalent
-
{amount(Math.round(monthlyRecurring))}
-
Signed total of recurring income and costs
-
-
- Recurring items feed the Overview projection when enabled. Dated forecasts do not change
- actual balances.
-
+
+
+
+
+
+
Recurring
+
+ {@render signed(Math.round(monthlyRecurring))} per month now. Feeds the projection when
+ enabled.
+
+
+
open('recurring')}
+ > Add
+
+ {#if data.recurring.length}{:else}Rent, subscriptions, salary and other known repeats.
{/if}
+
+
+
+
+
Future estimates
+
Dated income and costs you expect.
+
+
open('forecast')}
+ > Add
+
+ {#if data.forecasts.length}{:else}Add expected costs or income to look ahead.
{/if}
+
-
-
-
-
Recurring income and costs
-
Rent, subscriptions, salary and other known repeats.
-
-
open('recurring')}
- > Add item
-
-
-
- Name Account Frequency Dates Amount Actions {#each data.recurring as row (row.id)}{row.name} {row.accountId ? accountName(row.accountId) : 'Any'} {row.cadence} {row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
- ? dateLabel(row.endsOn)
- : 'ongoing'} {amount(row.amountCents)} open('recurring', row)}
- > deletion(row.name, row.id, 'deleteRecurring')}
- > {/each}
-
-
- {#if !data.recurring.length}No recurring items yet.
{/if}
-
-
-
-
-
Future estimates
-
Dated income and costs you expect.
-
-
open('forecast')}
- > Add estimate
-
-
-
- Date Description Account Amount Actions {#each data.forecasts as row (row.id)}{dateLabel(row.date)} {row.description} {row.accountId ? accountName(row.accountId) : 'Unassigned'} {amount(row.amountCents)} open('forecast', row)}> deletion(row.description, row.id, 'deleteForecast')}
- > {/each}
-
-
- {#if !data.forecasts.length}
- Add expected costs or income to look ahead.
-
{/if}
-
- {:else if tab === 'utilities'}
-
+
+
Utilities
-
Meter readings, estimated variable cost and monthly advances.
+
Meter readings and estimated variable cost.
+
open('reading', { kind: selectedUtility })}
+ > Reading
{#each kinds as kind (kind.id)} (selectedUtility = kind.id)}>{kind.label} {/each}
-
+
-
Usage in billing period
-
- {estimate ? estimate.usage.toLocaleString('en-NL', { maximumFractionDigits: 3 }) : '—'}
- {utility.unit}
-
+
Usage {estimate ? estimate.usage.toLocaleString('en-NL', { maximumFractionDigits: 3 }) : '—'}
+ {utility.unit}
-
Estimated variable cost
-
{estimate ? amount(estimate.estimatedCents) : '—'}
+
Est. cost {estimate ? amount(estimate.estimatedCents) : '—'}
-
Advances less estimated cost
-
{estimate ? amount(estimate.differenceCents) : '—'}
+
Advances − cost {#if estimate}{@render signed(estimate.differenceCents)}{:else}—{/if}
-
- Estimate uses the first and last readings in the billing period, the current unit rate and
- prorated monthly advances. Fixed charges, taxes, rate changes and supplier settlements are
- not included.
-
-
-
- open('settings', {
- kind: selectedUtility,
- ratePerUnit: settings?.ratePerUnit ?? '0',
- monthlyAdvanceCents: settings?.monthlyAdvanceCents ?? 0,
- billingStart: settings?.billingStart ?? ''
- })}>Edit rate and advance open('reading', { kind: selectedUtility })}
- > Add reading
-
+
+ open('settings', {
+ kind: selectedUtility,
+ ratePerUnit: settings?.ratePerUnit ?? '0',
+ monthlyAdvanceCents: settings?.monthlyAdvanceCents ?? 0,
+ billingStart: settings?.billingStart ?? ''
+ })}>Edit rate and advance
+ {#if readings.length}
{:else}
Add at least two readings to calculate usage.
{/if}
+
+ Uses the first and last readings in the billing period, the current unit rate and prorated
+ monthly advances. Fixed charges, taxes and settlements are not included.
+
-
-
-
{utility.label} readings
-
{readings.length} recorded
-
-
-
- Date Meter value ({utility.unit}) Actions {#each [...readings].reverse() as row (row.id)}{dateLabel(row.date)} {Number(row.reading).toLocaleString('en-NL', { maximumFractionDigits: 3 })} open('reading', row)}>
- deletion(
- utility.label + ' reading on ' + dateLabel(row.date),
- row.id,
- 'deleteReading'
- )}> {/each}
-
-
- {#if !readings.length}Add at least two readings to calculate usage.
{/if}
-
- {:else if tab === 'wishlist'}
-
+
+
Wish list
-
Ideas for later, kept outside your forecasts.
+
Cumulative cost by priority tier.
+
open('wish')}> Add
-
-
- Item Tier Notes Estimated cost Actions {#each data.wishlist as row (row.id)}{row.name} {row.priority} {row.notes ?? '—'} {row.estimatedCents == null ? '—' : amount(row.estimatedCents)} open('wish', row)}
- > deletion(row.name, row.id, 'deleteWishlist')}
- > {/each}
-
+
+ {#each wishTiers as row (row.tier)}
+ {row.tier === 5 ? 'Tier 5' : `5–${row.tier}`} {amount(row.value)} {row.count} {row.count === 1 ? 'item' : 'items'}
+
{/each}
- {#if !data.wishlist.length}
Nothing on your wish list yet.
{/if}
+ {#if unpricedWishes}
+ {unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these
+ totals.
+
{/if}
+ {#if data.wishlist.length}
{:else}
Nothing on your wish list yet.
{/if}
- {:else if tab === 'insurance'}
-
+
+
Insurance
-
Policies, providers, coverage and premiums in one place.
+
{amount(Math.round(monthlyInsurance))} per month in premiums.
+
open('insurance')}
+ > Add
-
-
- Policy Provider Coverage Premium Actions {#each data.insurances as row (row.id)}{row.name} {row.provider ?? '—'} {row.coverage ?? '—'} {row.premiumCents == null
- ? '—'
- : amount(row.premiumCents) + ' / ' + row.cadence} open('insurance', row)}
- > deletion(row.name, row.id, 'deleteInsurance')}
- > {/each}
-
-
- {#if !data.insurances.length}
- Add policies to keep track of what is covered.
-
{/if}
+ {#if data.insurances.length}{:else}Add policies to keep track of what is covered.
{/if}
- {:else if tab === 'accounts'}
-
-
-
-
Accounts
-
Opening balances plus every signed transaction.
-
-
-
-
- Account Type Opening balance Current balance Actions {#each data.accounts as row (row.id)}{row.name} {row.kind} {amount(row.openingBalanceCents)} {amount(balances.get(row.id) ?? 0)} open('account', row)}
- > deletion(row.name, row.id, 'deleteAccount')}
- > {/each}
-
-
- {#if !data.accounts.length}
- Add your first account or import a CSV to create them automatically.
-
{/if}
-
- {/if}
+
(active = {})}>
@@ -842,6 +787,20 @@
>Investment accounts are included in total wealth but excluded from the cash reserve.
+ Color{#each presetColors as color (color)} {/each} Used for the account's chart line, filter chip and transaction rows.
{:else if mode === 'transaction' || mode === 'forecast'}
Date
A planning scenario. Returns are hypothetical and calculated monthly. Contributions
- transfer money from spendable accounts into the selected investment account.
+ transfer money from spendable accounts into a projected investment balance.
Investment accountNo investment model Existing investment account (optional)Start projected investments at €0 {#each data.accounts.filter((account) => account.kind === 'investment') as account (account.id)}{account.name} {/each}Mark an account as Investment in Accounts to select it here. Select an existing investment account to include its current balance in the growth
+ model.
Spendable reserve (€) div {
- padding: 22px 26px;
-}
-.money-app .overview-side > div + div {
- border-top: 1px solid var(--site-border);
-}
-.money-app .side-value {
- font-size: 1.5rem;
- font-weight: 600;
- letter-spacing: -0.04em;
- margin-top: 7px;
-}
.money-app .projection-note {
margin-top: 14px;
line-height: 1.5;
}
+.money-app .projection-totals {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin-top: 18px;
+}
+.money-app .projection-totals > div {
+ display: grid;
+ gap: 4px;
+ padding: 12px 14px;
+ border: 1px solid var(--site-border);
+ border-radius: 7px;
+}
+.money-app .projection-totals strong {
+ font-size: 16px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
.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;
+ grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
+ gap: 6px;
}
.money-app .wish-tier-grid > div {
display: grid;
- gap: 5px;
- padding: 15px;
+ gap: 3px;
+ padding: 9px 11px;
min-width: 0;
-}
-.money-app .wish-tier-grid > div + div {
- border-left: 1px solid var(--site-border);
+ border: 1px solid var(--site-border);
+ border-radius: 7px;
}
.money-app .wish-tier-grid strong {
- font-size: 17px;
+ font-size: 14px;
font-weight: 600;
overflow-wrap: anywhere;
}
@@ -167,21 +124,20 @@
font-size: 13px;
}
.money-app .panel {
- margin-bottom: 28px;
border: 1px solid var(--site-border);
border-radius: 10px;
- padding: 24px;
+ padding: 20px;
min-width: 0;
}
.money-app .panel-heading {
display: flex;
align-items: start;
justify-content: space-between;
- gap: 20px;
- margin-bottom: 22px;
+ gap: 16px;
+ margin-bottom: 16px;
}
.money-app .panel-heading h2 {
- font-size: 20px;
+ font-size: 17px;
font-weight: 600;
letter-spacing: -0.035em;
}
@@ -196,32 +152,12 @@
text-underline-offset: 3px;
white-space: nowrap;
}
-.money-app .two-columns {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 28px;
-}
-.money-app .summary-list li {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 16px;
- border-top: 1px solid var(--site-border);
- padding: 12px 0;
- font-size: 13px;
-}
-.money-app .summary-list strong {
- font-weight: 500;
-}
-.money-app .summary-list small {
- display: block;
- color: var(--site-muted);
- font-size: 11px;
- margin-top: 2px;
-}
.money-app .negative {
color: #e5484d;
}
+.money-app .positive {
+ color: #46a758;
+}
.money-app .empty {
color: var(--site-muted);
font-size: 13px;
@@ -262,12 +198,12 @@
text-align: left;
font-weight: 500;
border-bottom: 1px solid var(--site-border);
- padding: 10px 12px;
+ padding: 8px 10px;
white-space: nowrap;
}
.money-app td {
border-bottom: 1px solid var(--site-border);
- padding: 11px 12px;
+ padding: 8px 10px;
vertical-align: top;
}
.money-app tbody tr:hover {
@@ -314,35 +250,11 @@
display: flex;
gap: 8px;
}
-.money-app .planning-intro {
- display: flex;
- align-items: end;
- justify-content: space-between;
- gap: 32px;
- border-bottom: 1px solid var(--site-border);
- padding: 8px 0 30px;
- margin-bottom: 28px;
-}
-.money-app .planning-intro > p {
- max-width: 370px;
-}
-.money-app .large-value {
- font-size: clamp(1.6rem, 4vw, 2.5rem);
- font-weight: 600;
- letter-spacing: -0.05em;
- margin: 6px 0;
-}
-.money-app .large-value span {
- font-size: 16px;
- color: var(--site-muted);
- font-weight: 400;
- letter-spacing: 0;
-}
.money-app .subtabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
- margin-bottom: 24px;
+ margin-bottom: 16px;
}
.money-app .subtabs button {
border: 1px solid var(--site-border);
@@ -356,20 +268,223 @@
color: var(--site-bg);
border-color: var(--site-text);
}
-.money-app .utility-top {
+.money-app .stat-strip {
display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 20px;
- padding-bottom: 16px;
-}
-.money-app .utility-note {
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ border: 1px solid var(--site-border);
+ border-radius: 10px;
+ overflow: hidden;
margin-bottom: 16px;
}
-.money-app .utility-actions {
+.money-app .stat {
+ padding: 16px 18px;
+ min-width: 0;
+}
+.money-app .stat + .stat {
+ border-left: 1px solid var(--site-border);
+}
+.money-app .stat-value {
+ margin-top: 4px;
+ font-size: 1.35rem;
+ font-weight: 600;
+ letter-spacing: -0.04em;
+ font-variant-numeric: tabular-nums;
+ overflow-wrap: anywhere;
+}
+.money-app .stat.net .stat-value {
+ font-size: 1.75rem;
+}
+.money-app .dashboard {
+ display: grid;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ gap: 16px;
+ align-items: start;
+}
+.money-app .span-8 {
+ grid-column: span 8;
+}
+.money-app .span-4 {
+ grid-column: span 4;
+}
+.money-app .stack {
+ display: grid;
+ gap: 16px;
+ min-width: 0;
+}
+.money-app .swatch {
+ display: inline-block;
+ flex: none;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px var(--site-border);
+}
+.money-app .account-cell {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+.money-app .nowrap {
+ white-space: nowrap;
+}
+.money-app tr.future {
+ color: var(--site-muted);
+}
+.money-app .chips {
display: flex;
flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 12px;
+}
+.money-app .chips button {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ border: 1px solid var(--site-border);
+ border-radius: 999px;
+ padding: 5px 11px;
+ color: var(--site-muted);
+ font-size: 12px;
+}
+.money-app .chips button:hover {
+ background: var(--site-hover);
+}
+.money-app .chips button.current {
+ color: var(--site-text);
+ border-color: var(--site-text);
+ background: var(--site-surface);
+}
+.money-app .item-list li {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border-top: 1px solid var(--site-border);
+ padding: 8px 0;
+ font-size: 13px;
+}
+.money-app .item-list.scroll {
+ max-height: 360px;
+ overflow-y: auto;
+}
+.money-app .item-list li.selected {
+ background: var(--site-surface);
+ box-shadow: inset 2px 0 0 var(--site-text);
+}
+.money-app .item-main {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ flex: 1;
+ min-width: 0;
+ text-align: left;
+ padding-left: 4px;
+}
+.money-app .item-main > span:last-child {
+ min-width: 0;
+}
+.money-app .item-main strong {
+ display: block;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.money-app .item-main small {
+ display: block;
+ color: var(--site-muted);
+ font-size: 11px;
+ margin-top: 1px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.money-app .item-amount {
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.money-app .item-list .actions button {
+ width: 26px;
+ height: 26px;
+}
+.money-app .tier {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: 20px;
+ height: 20px;
+ border: 1px solid var(--site-border);
+ border-radius: 5px;
+ color: var(--site-muted);
+ font-size: 11px;
+}
+.money-app .mini-stats {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
- padding-top: 16px;
+ margin-bottom: 12px;
+}
+.money-app .mini-stats > div {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+}
+.money-app .mini-stats strong {
+ font-size: 15px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+.money-app .mini-stats small {
+ color: var(--site-muted);
+ font-weight: 400;
+}
+.money-app .utility-settings {
+ display: block;
+ margin: 10px 0 6px;
+}
+.money-app .utility-note {
+ margin-top: 12px;
+ font-size: 12px;
+}
+@media (max-width: 1180px) {
+ .money-app .span-8,
+ .money-app .span-4 {
+ grid-column: span 12;
+ }
+ .money-app .dashboard > .span-4:nth-last-child(-n + 3) {
+ grid-column: span 6;
+ }
+ .money-app .dashboard > .span-4:last-child {
+ grid-column: span 12;
+ }
+ .money-app .stat-strip {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ .money-app .stat:nth-child(4) {
+ border-left: 0;
+ }
+ .money-app .stat:nth-child(n + 4) {
+ border-top: 1px solid var(--site-border);
+ }
+}
+@media (max-width: 760px) {
+ .money-app .dashboard > .span-4:nth-last-child(-n + 3) {
+ grid-column: span 12;
+ }
+ .money-app .stat-strip {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .money-app .stat.net {
+ grid-column: span 2;
+ }
+ .money-app .stat + .stat {
+ border-left: 0;
+ border-top: 1px solid var(--site-border);
+ }
+ .money-app .stat:nth-child(odd):not(.net) {
+ border-left: 1px solid var(--site-border);
+ }
}
.money-dialog {
width: min(440px, calc(100% - 28px));
@@ -452,49 +567,13 @@
color: #e5484d;
font-size: 13px;
}
-@media (max-width: 760px) {
- .money-app .overview-grid,
- .money-app .two-columns {
- grid-template-columns: 1fr;
- }
- .money-app .overview-side {
- border-left: 0;
- border-top: 1px solid var(--site-border);
- grid-template-columns: 1fr 1fr;
- grid-template-rows: auto;
- }
- .money-app .overview-side > div + div {
- border-top: 0;
- border-left: 1px solid var(--site-border);
- }
- .money-app .utility-top {
- 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;
- }
- .money-app .planning-intro > p {
- margin-top: 16px;
- }
-}
@media (max-width: 520px) {
+ .money-app .projection-totals {
+ grid-template-columns: 1fr;
+ }
.money-app .panel {
padding: 16px;
}
- .money-app .net-worth {
- padding: 22px;
- }
- .money-app .overview-side > div {
- padding: 16px;
- }
.money-app .filters {
flex-direction: column;
}