diff --git a/eslint.config.js b/eslint.config.js old mode 100644 new mode 100755 diff --git a/scripts/foundation-migration.sql b/scripts/foundation-migration.sql index 7dafbe6..01b5a9c 100644 --- a/scripts/foundation-migration.sql +++ b/scripts/foundation-migration.sql @@ -9,6 +9,7 @@ WHERE slug IS NULL; ALTER TABLE lists ALTER COLUMN slug SET NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS lists_slug_unique ON lists (slug); ALTER TABLE lists ADD COLUMN IF NOT EXISTS is_progress boolean NOT NULL DEFAULT false; +ALTER TABLE lists ADD COLUMN IF NOT EXISTS picture_url text; ALTER TABLE list_items ADD COLUMN IF NOT EXISTS picture_url text; ALTER TABLE list_items ADD COLUMN IF NOT EXISTS description text; diff --git a/scripts/list-picture-migration.sql b/scripts/list-picture-migration.sql new file mode 100644 index 0000000..169e957 --- /dev/null +++ b/scripts/list-picture-migration.sql @@ -0,0 +1 @@ +ALTER TABLE lists ADD COLUMN IF NOT EXISTS picture_url text; diff --git a/scripts/money-tracker-migration.sql b/scripts/money-tracker-migration.sql index f43992c..cbab45d 100644 --- a/scripts/money-tracker-migration.sql +++ b/scripts/money-tracker-migration.sql @@ -116,3 +116,5 @@ CREATE TABLE IF NOT EXISTS money_insurances ( updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS money_insurances_user_id_idx ON money_insurances (user_id); + +ALTER TABLE money_accounts ADD COLUMN IF NOT EXISTS color text; diff --git a/src/lib/money-tracker/MoneyChart.svelte b/src/lib/money-tracker/MoneyChart.svelte index 7861ac5..82c35fc 100644 --- a/src/lib/money-tracker/MoneyChart.svelte +++ b/src/lib/money-tracker/MoneyChart.svelte @@ -1,35 +1,74 @@
{#if values.length} -
+
(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} +
width / 2} + style={`left:${x(hoverDate)}px`} + role="status" + > +

+ {dateLabel(hoverDate, true)}{#if marker && hoverDate > marker.date} · projected{/if} +

+ {#each hoverRows as row (row.item.label)} +

+ + {row.item.label} + {format(row.point.value, true)} +

+ {/each} +
+ {/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 @@
+ @@ -41,6 +78,7 @@ {/if}
{#if form?.message}{/if} + {#if 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; + }} +/> +
    -
    -

    {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} +
    + + {#if data.list.pictureUrl}{/if} +
    +
    Delete this list @@ -148,8 +190,17 @@
    {/if} +
      - {#each data.list.items as item (item.id)} + {#each visibleItems as item (item.id)}
    • {#if item.pictureUrl} {/if} + {#if data.isAdmin && managing}{/if}
      - {#if data.isAdmin && managing} -
      - edit item -
      - - - - {#if item.pictureUrl}{/if} - - - -
      - - -
      -
      - {/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} + +

    Personal finance

    Money tracker

    -

    One place for every account, plan and household cost.

    - - {#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.

    - +
    + {#each timelineViews as item (item.id)}{/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.

    - +
    -
    - {#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)}
    • + {@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

    - -
    - {#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

    - -
    - {#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)} - /> + />
    + {#if data.accounts.length}
    + {#each data.accounts as account (account.id)}{/each} +
    {/if}
    {#each shown as row (row.id)} data.today} + >{/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. +

    +
    + +
    + {#if data.recurring.length}
      + {#each data.recurring as row (row.id)}
    • + {@render swatch(row.accountId)}{row.name}{row.cadence} · {row.accountId + ? accountName(row.accountId) + : 'Any account'} · {row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn + ? dateLabel(row.endsOn) + : 'ongoing'}{@render signed(row.amountCents)}{@render rowActions( + row.name, + () => open('recurring', row), + () => deletion(row.name, row.id, 'deleteRecurring') + )} +
    • {/each} +
    {:else}

    Rent, subscriptions, salary and other known repeats.

    {/if} +
    +
    +
    +
    +

    Future estimates

    +

    Dated income and costs you expect.

    +
    + +
    + {#if data.forecasts.length}
      + {#each data.forecasts as row (row.id)}
    • + {@render swatch(row.accountId)}{row.description}{dateLabel(row.date)} · {row.accountId + ? accountName(row.accountId) + : 'Unassigned'}{@render signed(row.amountCents)}{@render rowActions( + row.description, + () => open('forecast', row), + () => deletion(row.description, row.id, 'deleteForecast') + )} +
    • {/each} +
    {:else}

    Add expected costs or income to look ahead.

    {/if} +
    -
    -
    -
    -

    Recurring income and costs

    -

    Rent, subscriptions, salary and other known repeats.

    -
    - -
    -
    -
    {dateLabel(row.date)}{row.description}{accountName(row.accountId)}{amount(row.amountCents)}{#each shown as row (row.id)}
    {dateLabel(row.date)}{row.description}{@render signed(row.amountCents)} open('transaction', row)}>
    - {#each data.recurring as row (row.id)}{/each} -
    NameAccountFrequencyDatesAmountActions
    {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)}
    -
    - {#if !data.recurring.length}

    No recurring items yet.

    {/if} -
    -
    -
    -
    -

    Future estimates

    -

    Dated income and costs you expect.

    -
    - -
    -
    - - {#each data.forecasts as row (row.id)}{/each} -
    DateDescriptionAccountAmountActions
    {dateLabel(row.date)}{row.description}{row.accountId ? accountName(row.accountId) : 'Unassigned'}{amount(row.amountCents)}
    -
    - {#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.

    +
    {#each kinds as kind (kind.id)}{/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. -

    -
    - -
    + + {#if readings.length}
      + {#each [...readings].reverse() as row (row.id)}
    • + {dateLabel(row.date)}{Number(row.reading).toLocaleString('en-NL', { maximumFractionDigits: 3 })} + {utility.unit}{@render rowActions( + 'reading from ' + row.date, + () => open('reading', row), + () => + deletion( + utility.label + ' reading on ' + dateLabel(row.date), + row.id, + 'deleteReading' + ) + )} +
    • {/each} +
    {: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

    -
    -
    - - {#each [...readings].reverse() as row (row.id)}{/each} -
    DateMeter value ({utility.unit})Actions
    {dateLabel(row.date)}{Number(row.reading).toLocaleString('en-NL', { maximumFractionDigits: 3 })}
    -
    - {#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.

    +
    -
    - - {#each data.wishlist as row (row.id)}{/each} -
    ItemTierNotesEstimated costActions
    {row.name}{row.priority}{row.notes ?? '—'}{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}
    +
    + {#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}
      + {#each data.wishlist as row (row.id)}
    • + {row.priority}{row.name}{#if row.notes}{row.notes}{/if}{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}{@render rowActions( + row.name, + () => open('wish', row), + () => deletion(row.name, row.id, 'deleteWishlist') + )} +
    • {/each} +
    {: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.

    +
    -
    - - {#each data.insurances as row (row.id)}{/each} -
    PolicyProviderCoveragePremiumActions
    {row.name}{row.provider ?? '—'}{row.coverage ?? '—'}{row.premiumCents == null - ? '—' - : amount(row.premiumCents) + ' / ' + row.cadence}
    -
    - {#if !data.insurances.length}

    - Add policies to keep track of what is covered. -

    {/if} + {#if data.insurances.length}
      + {#each data.insurances as row (row.id)}
    • + {row.name}{[row.provider, row.coverage].filter(Boolean).join(' · ') || '—'}{row.premiumCents == null + ? '—' + : amount(row.premiumCents) + ' / ' + row.cadence}{@render rowActions( + row.name, + () => open('insurance', row), + () => deletion(row.name, row.id, 'deleteInsurance') + )} +
    • {/each} +
    {:else}

    Add policies to keep track of what is covered.

    {/if}
    - {:else if tab === 'accounts'} -
    -
    -
    -

    Accounts

    -

    Opening balances plus every signed transaction.

    -
    -
    -
    - - {#each data.accounts as row (row.id)}{/each} -
    AccountTypeOpening balanceCurrent balanceActions
    {row.name}{row.kind}{amount(row.openingBalanceCents)}{amount(balances.get(row.id) ?? 0)}
    -
    - {#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. + {:else if mode === 'transaction' || mode === 'forecast'}