add icons to lists and solve money tracker bugs
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s

This commit is contained in:
2026-09-24 19:24:09 +00:00
parent a10585a4d2
commit c757d0882a
18 changed files with 1358 additions and 731 deletions

0
eslint.config.js Normal file → Executable file
View File

View File

@@ -9,6 +9,7 @@ WHERE slug IS NULL;
ALTER TABLE lists ALTER COLUMN slug SET NOT NULL; ALTER TABLE lists ALTER COLUMN slug SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS lists_slug_unique ON lists (slug); 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 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 picture_url text;
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS description text; ALTER TABLE list_items ADD COLUMN IF NOT EXISTS description text;

View File

@@ -0,0 +1 @@
ALTER TABLE lists ADD COLUMN IF NOT EXISTS picture_url text;

View File

@@ -116,3 +116,5 @@ CREATE TABLE IF NOT EXISTS money_insurances (
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now()
); );
CREATE INDEX IF NOT EXISTS money_insurances_user_id_idx ON money_insurances (user_id); 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;

View File

@@ -1,35 +1,74 @@
<script lang="ts"> <script lang="ts">
import type { ChartSeries } from './charts'; import type { ChartPoint, ChartSeries } from './charts';
let { let {
series, series,
title, title,
unit = '€' unit = '€',
}: { series: ChartSeries[]; title: string; unit?: string } = $props(); marker
const width = 800; }: {
const height = 260; series: ChartSeries[];
const plot = { left: 72, right: 18, top: 14, bottom: 38 }; title: string;
unit?: string;
marker?: { date: string; label: string };
} = $props();
let clientWidth = $state(800);
let width = $derived(Math.max(280, clientWidth));
let compact = $derived(width < 520);
let height = $derived(compact ? 240 : 280);
let plot = $derived({ left: compact ? 52 : 72, right: 14, top: 18, bottom: 30 });
let values = $derived(series.flatMap((item) => item.points.map((point) => point.value))); let values = $derived(series.flatMap((item) => item.points.map((point) => point.value)));
let dates = $derived( let times = $derived(series.flatMap((item) => item.points.map((point) => time(point.date))));
series.flatMap((item) => item.points.map((point) => Date.parse(`${point.date}T12:00:00Z`))) let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values)));
); let min = $derived(scale.min);
let min = $derived(Math.min(0, ...values)); let max = $derived(scale.max);
let max = $derived(Math.max(0, ...values));
let span = $derived(max - min || 1); let span = $derived(max - min || 1);
let from = $derived(Math.min(...dates)); let from = $derived(Math.min(...times));
let until = $derived(Math.max(...dates)); let until = $derived(Math.max(...times));
let dateSpan = $derived(until - from || 1); let dateSpan = $derived(until - from || 1);
let snapDates = $derived(
[...new Set(series.flatMap((item) => item.points.map((point) => point.date)))].sort()
);
let hoverDate = $state<string | null>(null);
let hoverRows = $derived(
hoverDate === null
? []
: series.flatMap((item) => {
const point = pointAt(item.points, hoverDate!);
return point ? [{ item, point }] : [];
})
);
const money = new Intl.NumberFormat('en-NL', { maximumFractionDigits: 0 }); const money = new Intl.NumberFormat('en-NL', { maximumFractionDigits: 0 });
const euros = new Intl.NumberFormat('en-NL', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
});
const exact = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
/** Rounds the value range out to 1/2/5 × 10ⁿ steps so gridlines land on round amounts. */
function niceScale(low: number, high: number) {
const raw = (high - low || 1) / 3;
const magnitude = 10 ** Math.floor(Math.log10(raw));
const step = ([1, 2, 2.5, 5, 10].find((factor) => factor * magnitude >= raw) ?? 10) * magnitude;
// A dip of a few euros below zero should not add a whole empty gridline band.
const min = low > -step * 0.05 ? 0 : Math.floor(low / step) * step;
const max = Math.ceil(high / step) * step;
const ticks: number[] = [];
for (let value = max; value >= min - step / 2; value -= step) ticks.push(value);
return { min, max, ticks };
}
function time(date: string) {
return Date.parse(`${date}T12:00:00Z`);
}
function x(date: string) { function x(date: string) {
return ( return plot.left + ((time(date) - from) / dateSpan) * (width - plot.left - plot.right);
plot.left +
((Date.parse(`${date}T12:00:00Z`) - from) / dateSpan) * (width - plot.left - plot.right)
);
} }
function y(value: number) { function y(value: number) {
return plot.top + (1 - (value - min) / span) * (height - plot.top - plot.bottom); return plot.top + (1 - (value - min) / span) * (height - plot.top - plot.bottom);
} }
function path(points: ChartSeries['points']) { function path(points: ChartPoint[]) {
return points return points
.map( .map(
(point, index) => (point, index) =>
@@ -37,26 +76,76 @@
) )
.join(' '); .join(' ');
} }
function dateLabel(value: number) { /** Solid part through `dashedFrom` and dashed part from the last solid point onward. */
return new Date(value).toLocaleDateString('en-NL', { function segments(item: ChartSeries) {
if (!item.dashedFrom) return { solid: item.points, dashed: [] };
const split = item.points.findLastIndex((point) => point.date <= item.dashedFrom!);
return split < 0
? { solid: [], dashed: item.points }
: { solid: item.points.slice(0, split + 1), dashed: item.points.slice(split) };
}
function pointAt(points: ChartPoint[], date: string) {
if (!points.length || date < points[0].date || date > points.at(-1)!.date) return null;
return points.findLast((point) => point.date <= date) ?? null;
}
function ticks() {
const months = (until - from) / (30.44 * 86_400_000);
const step = [1, 2, 3, 6, 12, 24].find((size) => months / size <= (compact ? 3 : 6)) ?? 48;
const start = new Date(from);
const result: string[] = [];
let year = start.getUTCFullYear();
let month = start.getUTCMonth() + 1;
month = Math.ceil(month / step) * step;
for (let guard = 0; guard < 60; guard++) {
while (month >= 12) {
month -= 12;
year++;
}
const date = `${year}-${String(month + 1).padStart(2, '0')}-01`;
if (time(date) > until) break;
if (time(date) >= from) result.push(date);
month += step;
}
return result;
}
function dateLabel(date: string, withDay = false) {
return new Date(time(date)).toLocaleDateString('en-NL', {
day: withDay ? 'numeric' : undefined,
month: 'short', month: 'short',
year: 'numeric', year: withDay ? 'numeric' : '2-digit',
timeZone: 'UTC' timeZone: 'UTC'
}); });
} }
function format(value: number, precise = false) {
if (unit !== '€') return `${money.format(value)} ${unit}`;
return (precise ? exact : euros).format(value / 100);
}
function hover(event: PointerEvent) {
const svg = event.currentTarget as SVGSVGElement;
const box = svg.getBoundingClientRect();
const position = ((event.clientX - box.left) / box.width) * width;
const target = from + ((position - plot.left) / (width - plot.left - plot.right)) * dateSpan;
let best: string | null = null;
for (const date of snapDates)
if (best === null || Math.abs(time(date) - target) < Math.abs(time(best) - target))
best = date;
hoverDate = best;
}
</script> </script>
<figure class="chart"> <figure class="chart">
{#if values.length} {#if values.length}
<div class="chart-scroll" role="region" aria-label={title}> <div class="chart-area" bind:clientWidth>
<svg <svg
viewBox={`0 0 ${width} ${height}`} viewBox={`0 0 ${width} ${height}`}
width={clientWidth ? undefined : width}
role="img" role="img"
aria-label={title} aria-label={title}
preserveAspectRatio="xMidYMid meet" onpointermove={hover}
onpointerdown={hover}
onpointerleave={() => (hoverDate = null)}
> >
{#each [0, 0.5, 1] as fraction (fraction)} {#each scale.ticks as value (value)}
{@const value = max - span * fraction}
<line <line
x1={plot.left} x1={plot.left}
x2={width - plot.right} x2={width - plot.right}
@@ -66,53 +155,123 @@
stroke-width="1" stroke-width="1"
/> />
<text <text
x={plot.left - 12} x={plot.left - 8}
y={y(value) + 4} y={y(value) + 4}
text-anchor="end" text-anchor="end"
fill="var(--site-muted)" fill="var(--site-muted)"
font-size="11">{unit}{money.format(value / (unit === '€' ? 100 : 1))}</text font-size="11">{format(value)}</text
> >
{/each} {/each}
{#each ticks() as tick (tick)}
<text
x={x(tick)}
y={height - 8}
text-anchor="middle"
fill="var(--site-muted)"
font-size="11">{dateLabel(tick)}</text
>
{/each}
{#if marker && time(marker.date) >= from && time(marker.date) <= until}
<line
x1={x(marker.date)}
x2={x(marker.date)}
y1={plot.top - 6}
y2={height - plot.bottom}
stroke="var(--site-muted)"
stroke-width="1"
stroke-dasharray="2 3"
/>
<text
x={x(marker.date)}
y={plot.top - 8}
text-anchor="middle"
fill="var(--site-muted)"
font-size="11">{marker.label}</text
>
{/if}
{#each series as item (item.label)} {#each series as item (item.label)}
{#if item.points.length} {@const parts = segments(item)}
<path {#each [parts.solid, parts.dashed] as points, index (index)}
d={path(item.points)} {#if points.length > 1}
fill="none" <path
stroke={item.color} d={path(points)}
stroke-width={item.emphasis ? 3.5 : 2} fill="none"
stroke-linecap="round" stroke={item.color}
stroke-linejoin="round" stroke-width={item.emphasis ? 3 : 1.75}
vector-effect="non-scaling-stroke" stroke-dasharray={index ? '6 5' : undefined}
/> stroke-linecap={index ? 'butt' : 'round'}
{#if item.points.length === 1} stroke-linejoin="round"
<circle
cx={x(item.points[0].date)}
cy={y(item.points[0].value)}
r={item.emphasis ? 4 : 3}
fill={item.color}
/> />
{/if} {/if}
{/each}
{#if item.points.length === 1}
<circle
cx={x(item.points[0].date)}
cy={y(item.points[0].value)}
r="4"
fill={item.color}
/>
{/if} {/if}
{/each} {/each}
<text x={plot.left} y={height - 8} fill="var(--site-muted)" font-size="11" {#if hoverDate}
>{dateLabel(from)}</text <line
> x1={x(hoverDate)}
<text x2={x(hoverDate)}
x={width - plot.right} y1={plot.top}
y={height - 8} y2={height - plot.bottom}
text-anchor="end" stroke="var(--site-muted)"
fill="var(--site-muted)" stroke-width="1"
font-size="11">{dateLabel(until)}</text />
> {#each hoverRows as row (row.item.label)}
<circle
cx={x(hoverDate)}
cy={y(row.point.value)}
r="4"
fill={row.item.color}
stroke="var(--site-bg, #000)"
stroke-width="2"
/>
{/each}
{/if}
<rect
x={plot.left}
y={plot.top}
width={width - plot.left - plot.right}
height={height - plot.top - plot.bottom}
fill="transparent"
/>
</svg> </svg>
{#if hoverDate && hoverRows.length}
<div
class="tooltip"
class:flip={x(hoverDate) > width / 2}
style={`left:${x(hoverDate)}px`}
role="status"
>
<p class="tooltip-date">
{dateLabel(hoverDate, true)}{#if marker && hoverDate > marker.date}&nbsp;· projected{/if}
</p>
{#each hoverRows as row (row.item.label)}
<p class:emphasis={row.item.emphasis}>
<span class="swatch" style={`background:${row.item.color}`}></span>
<span class="tooltip-label">{row.item.label}</span>
<span class="tooltip-value">{format(row.point.value, true)}</span>
</p>
{/each}
</div>
{/if}
</div> </div>
{#if series.length > 1} {#if series.length > 1 || series.some((item) => item.dashedFrom)}
<figcaption class="legend"> <figcaption class="legend">
{#each series as item (item.label)} {#each series as item (item.label)}
<span class:emphasis={item.emphasis} <span class:emphasis={item.emphasis}
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span ><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
> >
{/each} {/each}
{#if series.some((item) => item.dashedFrom)}
<span class="line-key first"><span class="key solid"></span>Actual</span>
<span class="line-key"><span class="key dashed"></span>Projected</span>
{/if}
</figcaption> </figcaption>
{/if} {/if}
{:else} {:else}
@@ -124,14 +283,54 @@
.chart { .chart {
min-width: 0; min-width: 0;
} }
.chart-scroll { .chart-area {
overflow-x: auto; position: relative;
touch-action: pan-y;
} }
svg { svg {
display: block; display: block;
width: 100%; width: 100%;
min-width: 540px;
height: auto; 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 { .legend {
display: flex; display: flex;
@@ -147,16 +346,27 @@
gap: 7px; gap: 7px;
} }
.legend > span.emphasis { .legend > span.emphasis {
order: -1;
flex-basis: 100%;
color: var(--site-text); color: var(--site-text);
font-weight: 600; 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 { .swatch {
display: inline-block; display: inline-block;
flex: none;
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 1px var(--site-border);
} }
.empty { .empty {
padding: 70px 0; padding: 70px 0;

View File

@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
accountBalances, accountBalances,
accountColor,
balanceSeries, balanceSeries,
forecastSeries, forecastSeries,
utilityEstimate, utilityEstimate,
wealthProjection wealthProjection,
wealthTimeline
} from './charts'; } from './charts';
describe('money tracker calculations', () => { 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', () => { it('plots combined transactions in date order even if the input is unordered', () => {
const combined = balanceSeries( const combined = balanceSeries(
[{ id: 1, name: 'Cash', openingBalanceCents: 1000 }], [{ id: 1, name: 'Cash', openingBalanceCents: 1000 }],
@@ -113,6 +127,108 @@ describe('money tracker calculations', () => {
expect(series[0].points.at(-1)?.value).toBe(15000); 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', () => { it('compares estimated usage cost with prorated advances', () => {
const result = utilityEstimate( const result = utilityEstimate(
[ [

View File

@@ -4,13 +4,21 @@ export type ChartSeries = {
color: string; color: string;
points: ChartPoint[]; points: ChartPoint[];
emphasis?: boolean; 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( 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 }[] transactions: { accountId: number; date: string; amountCents: number }[]
): ChartSeries[] { ): ChartSeries[] {
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date)); const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date));
const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents])); const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]])); const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]]));
@@ -34,7 +42,7 @@ export function balanceSeries(
return [ return [
...accounts.map((account, index) => ({ ...accounts.map((account, index) => ({
label: account.name, label: account.name,
color: palette[index % palette.length], color: accountColor(account, index),
points: accountPoints.get(account.id)! points: accountPoints.get(account.id)!
})), })),
{ label: 'All accounts', color: 'var(--site-muted)', points: combinedPoints } { label: 'All accounts', color: 'var(--site-muted)', points: combinedPoints }
@@ -163,7 +171,7 @@ export function wealthProjection(
} else spendable += value; } else spendable += value;
} }
} }
if (validInvestmentId !== null && index > 0) { if (index > 0) {
const growth = Math.round(indexBalance * monthlyGrowth); const growth = Math.round(indexBalance * monthlyGrowth);
invested += growth; invested += growth;
indexBalance += 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( export function utilityEstimate(
readings: { date: string; reading: string; kind: string }[], readings: { date: string; reading: string; kind: string }[],
kind: string, kind: string,

View File

@@ -6,6 +6,7 @@ export const lists = pgTable('lists', {
id: id(), id: id(),
name: text('name').notNull(), name: text('name').notNull(),
slug: text('slug').notNull().unique(), slug: text('slug').notNull().unique(),
pictureUrl: text('picture_url'),
isProgress: boolean('is_progress').notNull().default(false), isProgress: boolean('is_progress').notNull().default(false),
...timestamps ...timestamps
}); });

View File

@@ -12,6 +12,7 @@ export const moneyAccounts = pgTable(
name: text('name').notNull(), name: text('name').notNull(),
openingBalanceCents: integer('opening_balance_cents').notNull().default(0), openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
kind: text('kind').notNull().default('spendable'), kind: text('kind').notNull().default('spendable'),
color: text('color'),
...timestamps ...timestamps
}, },
(table) => [unique().on(table.userId, table.name), index().on(table.userId)] (table) => [unique().on(table.userId, table.name), index().on(table.userId)]

View File

@@ -6,7 +6,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
const lists = await db.query.lists.findMany({ 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 } } }, with: { items: { columns: { id: true } } },
orderBy: { id: 'desc' } orderBy: { id: 'desc' }
}); });

View File

@@ -138,7 +138,17 @@
class="block py-4 hover:text-[var(--site-muted)]" class="block py-4 hover:text-[var(--site-muted)]"
> >
<span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1"> <span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1">
<span>{list.name}</span> <span class="flex items-center gap-3">
{#if list.pictureUrl}<img
src={list.pictureUrl}
alt=""
loading="lazy"
width="40"
height="40"
class="h-10 w-10 rounded bg-[var(--site-surface)] object-cover"
/>{/if}
<span>{list.name}</span>
</span>
<span class="inline-flex items-center gap-1 text-base text-[var(--site-muted)]"> <span class="inline-flex items-center gap-1 text-base text-[var(--site-muted)]">
{#if list.isProgress && data.user} {#if list.isProgress && data.user}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total} {list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}

View File

@@ -2,6 +2,7 @@ import { db } from '$lib/server/db';
import { listItemCompletions, lists } from '$lib/server/db/schema'; import { listItemCompletions, lists } from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization'; import { requireAdmin } from '$lib/server/authorization';
import { uniqueListSlug } from '$lib/server/slugify'; import { uniqueListSlug } from '$lib/server/slugify';
import { listImageUrl } from '$lib/server/r2';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@@ -36,12 +37,18 @@ export const actions: Actions = {
const form = await event.request.formData(); const form = await event.request.formData();
const name = String(form.get('name') ?? '').trim(); const name = String(form.get('name') ?? '').trim();
const type = String(form.get('type') ?? ''); const type = String(form.get('type') ?? '');
const pictureKey = String(form.get('pictureKey') ?? '').trim();
const picture = form.get('picture');
if (!name || name.length > 200) if (!name || name.length > 200)
return fail(400, { message: 'Enter a list name of 1–200 characters.' }); return fail(400, { message: 'Enter a list name of 1–200 characters.' });
if (type !== 'normal' && type !== 'progress') if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' }); 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); 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}`); redirect(303, `/lists/${slug}`);
} }
}; };

View File

@@ -2,8 +2,42 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte'; 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'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); 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;
}
};
</script> </script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16"> <div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -14,7 +48,8 @@
<form <form
method="POST" method="POST"
action="?/newList" action="?/newList"
use:enhance enctype="multipart/form-data"
use:enhance={uploadPicture}
class="absolute top-full right-0 z-10 mt-3 w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg" class="absolute top-full right-0 z-10 mt-3 w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
> >
<label class="block" <label class="block"
@@ -33,7 +68,9 @@
></select ></select
></label ></label
> >
<ListPictureDropzone label="List picture (optional)" />
<button <button
disabled={uploading}
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]" class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
>create list</button >create list</button
> >
@@ -41,6 +78,7 @@
</details>{/if} </details>{/if}
</div> </div>
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if} {#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="mb-5 text-red-500">{uploadError}</p>{/if}
<ul> <ul>
{#each data.lists as list (list.id)} {#each data.lists as list (list.id)}
<li class="border-t border-[var(--site-border)]"> <li class="border-t border-[var(--site-border)]">
@@ -49,7 +87,17 @@
class="block py-5 hover:text-[var(--site-muted)]" class="block py-5 hover:text-[var(--site-muted)]"
> >
<span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1"> <span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1">
<span class="text-lg font-medium">{list.name}</span> <span class="flex items-center gap-3">
{#if list.pictureUrl}<img
src={list.pictureUrl}
alt=""
loading="lazy"
width="48"
height="48"
class="h-12 w-12 rounded bg-[var(--site-surface)] object-cover"
/>{/if}
<span class="text-lg font-medium">{list.name}</span>
</span>
<span class="text-base text-[var(--site-muted)]"> <span class="text-base text-[var(--site-muted)]">
{#if list.isProgress && data.user} {#if list.isProgress && data.user}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total} {list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}

View File

@@ -1,4 +1,5 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { env } from '$env/dynamic/private';
import { lists, listItems, listItemCompletions } from '$lib/server/db/schema'; import { lists, listItems, listItemCompletions } from '$lib/server/db/schema';
import { requireAdmin, requireUser } from '$lib/server/authorization'; import { requireAdmin, requireUser } from '$lib/server/authorization';
import { deleteObject, listImageKey, listImageUrl } from '$lib/server/r2'; import { deleteObject, listImageKey, listImageUrl } from '$lib/server/r2';
@@ -13,6 +14,8 @@ async function currentList(slug: string) {
return list; return list;
} }
async function removeStoredPicture(url: string | null) { 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; const key = url ? listImageKey(url) : null;
if (!key) return; if (!key) return;
try { try {
@@ -79,7 +82,9 @@ export const actions: Actions = {
.from(listItems) .from(listItems)
.where(eq(listItems.listId, list.id)); .where(eq(listItems.listId, list.id));
await db.delete(lists).where(eq(lists.id, 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'); redirect(303, '/lists');
}, },
updateList: async (event) => { updateList: async (event) => {
@@ -90,12 +95,21 @@ export const actions: Actions = {
.trim() .trim()
.toLowerCase(); .toLowerCase();
const type = String(form.get('type') ?? ''); 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) if (!name || name.length > 200)
return fail(400, { message: 'Enter a name of 1–200 characters' }); return fail(400, { message: 'Enter a name of 1–200 characters' });
if (type !== 'normal' && type !== 'progress') if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' }); return fail(400, { message: 'Choose a list type.' });
if (!validSlug(slug)) if (!validSlug(slug))
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' }); 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 current = await currentList(event.params.slug);
const duplicate = await db const duplicate = await db
.select({ id: lists.id }) .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.' }); if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
const [list] = await db const [list] = await db
.update(lists) .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)) .where(eq(lists.id, current.id))
.returning(); .returning();
if (!list) error(404, 'List not found'); 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}`); if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
return { saved: true }; return { saved: true };
}, },

View File

@@ -12,11 +12,28 @@
let uploadError = $state(''); let uploadError = $state('');
let uploading = $state(false); let uploading = $state(false);
let managing = $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 uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
const finish = async ({
result,
update
}: {
result: { type: string };
update: () => Promise<void>;
}) => {
await update();
if (result.type === 'success') editingItem = null;
};
uploadError = ''; uploadError = '';
const picture = formData.get('picture'); const picture = formData.get('picture');
formData.delete('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)) { if (!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)) {
uploadError = 'Choose a JPEG, PNG, or WebP image.'; uploadError = 'Choose a JPEG, PNG, or WebP image.';
cancel(); cancel();
@@ -44,9 +61,16 @@
} finally { } finally {
uploading = false; uploading = false;
} }
return finish;
}; };
</script> </script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape') editingItem = null;
}}
/>
<section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14"> <section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14">
<a <a
href={resolve('/lists')} href={resolve('/lists')}
@@ -54,12 +78,21 @@
><ArrowLeft size={16} aria-hidden="true" /> lists</a ><ArrowLeft size={16} aria-hidden="true" /> lists</a
> >
<div class="flex flex-wrap items-end justify-between gap-4"> <div class="flex flex-wrap items-end justify-between gap-4">
<div> <div class="flex items-center gap-4">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{data.list.name}</h1> {#if data.list.pictureUrl}<img
<p class="mt-2 text-base text-[var(--site-muted)]"> src={data.list.pictureUrl}
{data.total} alt=""
{data.total === 1 ? 'item' : 'items'} width="96"
</p> height="96"
class="h-20 w-20 rounded-lg bg-[var(--site-surface)] object-cover sm:h-24 sm:w-24"
/>{/if}
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{data.list.name}</h1>
<p class="mt-2 text-base text-[var(--site-muted)]">
{data.total}
{data.total === 1 ? 'item' : 'items'}
</p>
</div>
</div> </div>
{#if data.isAdmin}<button {#if data.isAdmin}<button
type="button" type="button"
@@ -96,7 +129,8 @@
<form <form
method="POST" method="POST"
action="?/updateList" action="?/updateList"
use:enhance enctype="multipart/form-data"
use:enhance={uploadPicture}
class="grid gap-3 sm:grid-cols-2 sm:items-end" class="grid gap-3 sm:grid-cols-2 sm:items-end"
> >
<label <label
@@ -124,7 +158,15 @@
<option value="progress" selected={data.list.isProgress}>Progress</option> <option value="progress" selected={data.list.isProgress}>Progress</option>
</select></label </select></label
> >
<button class="rounded border p-2">Save list</button> <div class="sm:col-span-2">
<ListPictureDropzone label="List picture (optional)" />
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label
>{/if}
</div>
<button disabled={uploading} class="rounded border p-2 disabled:opacity-50"
>Save list</button
>
</form> </form>
<details class="border-t border-[var(--site-border)] pt-4"> <details class="border-t border-[var(--site-border)] pt-4">
<summary class="cursor-pointer font-medium text-red-700">Delete this list</summary> <summary class="cursor-pointer font-medium text-red-700">Delete this list</summary>
@@ -148,8 +190,17 @@
</div> </div>
</details> </details>
{/if} {/if}
<label class="block">
<span class="sr-only">Search list items</span>
<input
type="search"
bind:value={search}
placeholder="Search list items"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/>
</label>
<ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]"> <ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
{#each data.list.items as item (item.id)} {#each visibleItems as item (item.id)}
<li class="py-4"> <li class="py-4">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
{#if item.pictureUrl}<img {#if item.pictureUrl}<img
@@ -180,56 +231,85 @@
</button> </button>
</form> </form>
{/if} {/if}
{#if data.isAdmin && managing}<button
type="button"
onclick={() => (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</button
>{/if}
</div> </div>
{#if data.isAdmin && managing}
<details class="mt-3 border-t border-[var(--site-border)] pt-3">
<summary class="cursor-pointer text-base underline">edit item</summary>
<form
method="POST"
action="?/updateItem"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="mt-3 grid gap-3"
>
<input type="hidden" name="itemId" value={item.id} />
<label
>Name<input
name="name"
value={item.name}
required
maxlength="500"
class="block w-full rounded border p-2"
/></label
>
<ListPictureDropzone label="Replace picture (optional)" />
{#if item.pictureUrl}<label class="text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label
>{/if}
<label
>Description (optional)<textarea
name="description"
maxlength="5000"
rows="3"
class="block w-full rounded border p-2">{item.description ?? ''}</textarea
></label
>
<button
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Save item</button
>
</form>
<form method="POST" action="?/removeItem" use:enhance class="mt-3">
<input type="hidden" name="itemId" value={item.id} />
<button class="text-sm text-red-700 underline" aria-label={`Remove ${item.name}`}
>Remove item</button
>
</form>
</details>
{/if}
</li> </li>
{:else}<li class="py-5 text-[var(--site-muted)]">no items yet</li>{/each} {:else}<li class="py-5 text-[var(--site-muted)]">
{search ? 'No matching items' : 'no items yet'}
</li>{/each}
</ul> </ul>
{#if data.isAdmin && managing && editingItem}
<div class="fixed inset-0 z-40 bg-black/60"></div>
<div class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-8">
<div
role="dialog"
aria-modal="true"
aria-label={`Edit ${editingItem.name}`}
class="mx-auto max-w-xl rounded-lg bg-[var(--site-bg)] p-5 shadow-xl"
>
<div class="mb-4 flex items-center justify-between gap-4">
<h2 class="text-xl font-semibold">Edit item</h2>
<button type="button" onclick={() => (editingItem = null)} class="underline">Close</button
>
</div>
<form
method="POST"
action="?/updateItem"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="grid gap-3"
>
<input type="hidden" name="itemId" value={editingItem.id} />
<label
>Name<input
name="name"
value={editingItem.name}
required
maxlength="500"
class="block w-full rounded border p-2"
/></label
>
<ListPictureDropzone label="Replace picture (optional)" />
{#if editingItem.pictureUrl}<label class="text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label
>{/if}
<label
>Description (optional)<textarea
name="description"
maxlength="5000"
rows="3"
class="block w-full rounded border p-2">{editingItem.description ?? ''}</textarea
></label
>
<button
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Save item</button
>
</form>
<form
method="POST"
action="?/removeItem"
use:enhance={() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') editingItem = null;
}}
class="mt-5 border-t border-[var(--site-border)] pt-4"
>
<input type="hidden" name="itemId" value={editingItem.id} />
<button class="text-sm text-red-700 underline" aria-label={`Remove ${editingItem.name}`}
>Remove item</button
>
</form>
</div>
</div>
{/if}
{#if data.isAdmin && managing} {#if data.isAdmin && managing}
<details class="border-t border-[var(--site-border)] pt-5"> <details class="border-t border-[var(--site-border)] pt-5">
<summary class="cursor-pointer font-medium">add item</summary> <summary class="cursor-pointer font-medium">add item</summary>

View File

@@ -117,19 +117,24 @@ export const actions: Actions = {
const name = textField(form, 'name', 80); const name = textField(form, 'name', 80);
const openingBalanceCents = cents(form.get('openingBalance')); const openingBalanceCents = cents(form.get('openingBalance'));
const kind = String(form.get('kind') ?? 'spendable'); const kind = String(form.get('kind') ?? 'spendable');
const colorRaw = String(form.get('color') ?? '').trim();
const color = colorRaw ? colorRaw.toLowerCase() : null;
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind)) if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
return invalid('Enter an account name and a valid opening balance.'); 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); const id = recordId(form);
try { try {
if (id) { if (id) {
const [row] = await db const [row] = await db
.update(moneyAccounts) .update(moneyAccounts)
.set({ name, openingBalanceCents, kind }) .set({ name, openingBalanceCents, kind, color })
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id))) .where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id)))
.returning({ id: moneyAccounts.id }); .returning({ id: moneyAccounts.id });
if (!row) error(404, 'Account not found'); if (!row) error(404, 'Account not found');
} else } else
await db.insert(moneyAccounts).values({ userId: user.id, name, openingBalanceCents, kind }); await db
.insert(moneyAccounts)
.values({ userId: user.id, name, openingBalanceCents, kind, color });
} catch (cause) { } catch (cause) {
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505') if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
return invalid('An account with that name already exists.'); return invalid('An account with that name already exists.');

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,6 @@
} }
.money-app .button.secondary:hover, .money-app .button.secondary:hover,
.money-dialog .button.secondary:hover, .money-dialog .button.secondary:hover,
.money-app .tabs button:hover,
.money-app .subtabs button:hover { .money-app .subtabs button:hover {
background: var(--site-hover); background: var(--site-hover);
} }
@@ -55,8 +54,7 @@
align-items: end; align-items: end;
justify-content: space-between; justify-content: space-between;
gap: 24px; gap: 24px;
border-bottom: 1px solid var(--site-border); margin-bottom: 20px;
padding-bottom: 30px;
} }
.money-app h1 { .money-app h1 {
margin: 2px 0 8px; margin: 2px 0 8px;
@@ -70,84 +68,43 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.money-app .tabs {
display: flex;
gap: 2px;
overflow-x: auto;
border-bottom: 1px solid var(--site-border);
margin-bottom: 32px;
}
.money-app .tabs button {
border-bottom: 2px solid transparent;
color: var(--site-muted);
white-space: nowrap;
padding: 14px 16px 12px;
font-size: 13px;
}
.money-app .tabs button.current {
color: var(--site-text);
border-color: var(--site-text);
font-weight: 600;
}
.money-app .overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(250px, 0.8fr);
border: 1px solid var(--site-border);
border-radius: 10px;
overflow: hidden;
margin-bottom: 28px;
}
.money-app .net-worth {
padding: 32px;
min-width: 0;
}
.money-app .net-value {
font-size: clamp(2.5rem, 6vw, 4rem);
line-height: 1.2;
font-weight: 600;
letter-spacing: -0.055em;
overflow-wrap: anywhere;
margin: 8px 0;
}
.money-app .overview-side {
border-left: 1px solid var(--site-border);
display: grid;
grid-template-rows: 1fr 1fr;
}
.money-app .overview-side > 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 { .money-app .projection-note {
margin-top: 14px; margin-top: 14px;
line-height: 1.5; 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 { .money-app .wish-tier-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
border: 1px solid var(--site-border); gap: 6px;
border-radius: 8px;
overflow: hidden;
} }
.money-app .wish-tier-grid > div { .money-app .wish-tier-grid > div {
display: grid; display: grid;
gap: 5px; gap: 3px;
padding: 15px; padding: 9px 11px;
min-width: 0; min-width: 0;
} border: 1px solid var(--site-border);
.money-app .wish-tier-grid > div + div { border-radius: 7px;
border-left: 1px solid var(--site-border);
} }
.money-app .wish-tier-grid strong { .money-app .wish-tier-grid strong {
font-size: 17px; font-size: 14px;
font-weight: 600; font-weight: 600;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -167,21 +124,20 @@
font-size: 13px; font-size: 13px;
} }
.money-app .panel { .money-app .panel {
margin-bottom: 28px;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
border-radius: 10px; border-radius: 10px;
padding: 24px; padding: 20px;
min-width: 0; min-width: 0;
} }
.money-app .panel-heading { .money-app .panel-heading {
display: flex; display: flex;
align-items: start; align-items: start;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 16px;
margin-bottom: 22px; margin-bottom: 16px;
} }
.money-app .panel-heading h2 { .money-app .panel-heading h2 {
font-size: 20px; font-size: 17px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.035em; letter-spacing: -0.035em;
} }
@@ -196,32 +152,12 @@
text-underline-offset: 3px; text-underline-offset: 3px;
white-space: nowrap; 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 { .money-app .negative {
color: #e5484d; color: #e5484d;
} }
.money-app .positive {
color: #46a758;
}
.money-app .empty { .money-app .empty {
color: var(--site-muted); color: var(--site-muted);
font-size: 13px; font-size: 13px;
@@ -262,12 +198,12 @@
text-align: left; text-align: left;
font-weight: 500; font-weight: 500;
border-bottom: 1px solid var(--site-border); border-bottom: 1px solid var(--site-border);
padding: 10px 12px; padding: 8px 10px;
white-space: nowrap; white-space: nowrap;
} }
.money-app td { .money-app td {
border-bottom: 1px solid var(--site-border); border-bottom: 1px solid var(--site-border);
padding: 11px 12px; padding: 8px 10px;
vertical-align: top; vertical-align: top;
} }
.money-app tbody tr:hover { .money-app tbody tr:hover {
@@ -314,35 +250,11 @@
display: flex; display: flex;
gap: 8px; 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 { .money-app .subtabs {
display: flex; display: flex;
gap: 6px; gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 24px; margin-bottom: 16px;
} }
.money-app .subtabs button { .money-app .subtabs button {
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
@@ -356,20 +268,223 @@
color: var(--site-bg); color: var(--site-bg);
border-color: var(--site-text); border-color: var(--site-text);
} }
.money-app .utility-top { .money-app .stat-strip {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 20px; border: 1px solid var(--site-border);
padding-bottom: 16px; border-radius: 10px;
} overflow: hidden;
.money-app .utility-note {
margin-bottom: 16px; 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; display: flex;
flex-wrap: wrap; 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; 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 { .money-dialog {
width: min(440px, calc(100% - 28px)); width: min(440px, calc(100% - 28px));
@@ -452,49 +567,13 @@
color: #e5484d; color: #e5484d;
font-size: 13px; 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) { @media (max-width: 520px) {
.money-app .projection-totals {
grid-template-columns: 1fr;
}
.money-app .panel { .money-app .panel {
padding: 16px; padding: 16px;
} }
.money-app .net-worth {
padding: 22px;
}
.money-app .overview-side > div {
padding: 16px;
}
.money-app .filters { .money-app .filters {
flex-direction: column; flex-direction: column;
} }