add icons to lists and solve money tracker bugs
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s
This commit is contained in:
0
eslint.config.js
Normal file → Executable file
0
eslint.config.js
Normal file → Executable file
@@ -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;
|
||||
|
||||
|
||||
1
scripts/list-picture-migration.sql
Normal file
1
scripts/list-picture-migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE lists ADD COLUMN IF NOT EXISTS picture_url text;
|
||||
@@ -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;
|
||||
|
||||
@@ -1,35 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { ChartSeries } from './charts';
|
||||
import type { ChartPoint, ChartSeries } from './charts';
|
||||
|
||||
let {
|
||||
series,
|
||||
title,
|
||||
unit = '€'
|
||||
}: { series: ChartSeries[]; title: string; unit?: string } = $props();
|
||||
const width = 800;
|
||||
const height = 260;
|
||||
const plot = { left: 72, right: 18, top: 14, bottom: 38 };
|
||||
unit = '€',
|
||||
marker
|
||||
}: {
|
||||
series: ChartSeries[];
|
||||
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 dates = $derived(
|
||||
series.flatMap((item) => item.points.map((point) => Date.parse(`${point.date}T12:00:00Z`)))
|
||||
);
|
||||
let min = $derived(Math.min(0, ...values));
|
||||
let max = $derived(Math.max(0, ...values));
|
||||
let times = $derived(series.flatMap((item) => item.points.map((point) => time(point.date))));
|
||||
let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values)));
|
||||
let min = $derived(scale.min);
|
||||
let max = $derived(scale.max);
|
||||
let span = $derived(max - min || 1);
|
||||
let from = $derived(Math.min(...dates));
|
||||
let until = $derived(Math.max(...dates));
|
||||
let from = $derived(Math.min(...times));
|
||||
let until = $derived(Math.max(...times));
|
||||
let dateSpan = $derived(until - from || 1);
|
||||
const money = new Intl.NumberFormat('en-NL', { maximumFractionDigits: 0 });
|
||||
function x(date: string) {
|
||||
return (
|
||||
plot.left +
|
||||
((Date.parse(`${date}T12:00:00Z`) - from) / dateSpan) * (width - plot.left - plot.right)
|
||||
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 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) {
|
||||
return plot.left + ((time(date) - from) / dateSpan) * (width - plot.left - plot.right);
|
||||
}
|
||||
function y(value: number) {
|
||||
return plot.top + (1 - (value - min) / span) * (height - plot.top - plot.bottom);
|
||||
}
|
||||
function path(points: ChartSeries['points']) {
|
||||
function path(points: ChartPoint[]) {
|
||||
return points
|
||||
.map(
|
||||
(point, index) =>
|
||||
@@ -37,26 +76,76 @@
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
function dateLabel(value: number) {
|
||||
return new Date(value).toLocaleDateString('en-NL', {
|
||||
/** Solid part through `dashedFrom` and dashed part from the last solid point onward. */
|
||||
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',
|
||||
year: 'numeric',
|
||||
year: withDay ? 'numeric' : '2-digit',
|
||||
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>
|
||||
|
||||
<figure class="chart">
|
||||
{#if values.length}
|
||||
<div class="chart-scroll" role="region" aria-label={title}>
|
||||
<div class="chart-area" bind:clientWidth>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
width={clientWidth ? undefined : width}
|
||||
role="img"
|
||||
aria-label={title}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
onpointermove={hover}
|
||||
onpointerdown={hover}
|
||||
onpointerleave={() => (hoverDate = null)}
|
||||
>
|
||||
{#each [0, 0.5, 1] as fraction (fraction)}
|
||||
{@const value = max - span * fraction}
|
||||
{#each scale.ticks as value (value)}
|
||||
<line
|
||||
x1={plot.left}
|
||||
x2={width - plot.right}
|
||||
@@ -66,53 +155,123 @@
|
||||
stroke-width="1"
|
||||
/>
|
||||
<text
|
||||
x={plot.left - 12}
|
||||
x={plot.left - 8}
|
||||
y={y(value) + 4}
|
||||
text-anchor="end"
|
||||
fill="var(--site-muted)"
|
||||
font-size="11">{unit}{money.format(value / (unit === '€' ? 100 : 1))}</text
|
||||
font-size="11">{format(value)}</text
|
||||
>
|
||||
{/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)}
|
||||
{#if item.points.length}
|
||||
{@const parts = segments(item)}
|
||||
{#each [parts.solid, parts.dashed] as points, index (index)}
|
||||
{#if points.length > 1}
|
||||
<path
|
||||
d={path(item.points)}
|
||||
d={path(points)}
|
||||
fill="none"
|
||||
stroke={item.color}
|
||||
stroke-width={item.emphasis ? 3.5 : 2}
|
||||
stroke-linecap="round"
|
||||
stroke-width={item.emphasis ? 3 : 1.75}
|
||||
stroke-dasharray={index ? '6 5' : undefined}
|
||||
stroke-linecap={index ? 'butt' : 'round'}
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if item.points.length === 1}
|
||||
<circle
|
||||
cx={x(item.points[0].date)}
|
||||
cy={y(item.points[0].value)}
|
||||
r={item.emphasis ? 4 : 3}
|
||||
r="4"
|
||||
fill={item.color}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
<text x={plot.left} y={height - 8} fill="var(--site-muted)" font-size="11"
|
||||
>{dateLabel(from)}</text
|
||||
>
|
||||
<text
|
||||
x={width - plot.right}
|
||||
y={height - 8}
|
||||
text-anchor="end"
|
||||
fill="var(--site-muted)"
|
||||
font-size="11">{dateLabel(until)}</text
|
||||
>
|
||||
{#if hoverDate}
|
||||
<line
|
||||
x1={x(hoverDate)}
|
||||
x2={x(hoverDate)}
|
||||
y1={plot.top}
|
||||
y2={height - plot.bottom}
|
||||
stroke="var(--site-muted)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
{#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>
|
||||
{#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} · 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 series.length > 1}
|
||||
{/if}
|
||||
</div>
|
||||
{#if series.length > 1 || series.some((item) => item.dashedFrom)}
|
||||
<figcaption class="legend">
|
||||
{#each series as item (item.label)}
|
||||
<span class:emphasis={item.emphasis}
|
||||
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
|
||||
>
|
||||
{/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>
|
||||
{/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;
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
|
||||
@@ -138,7 +138,17 @@
|
||||
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 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)]">
|
||||
{#if list.isProgress && data.user}
|
||||
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
@@ -14,7 +48,8 @@
|
||||
<form
|
||||
method="POST"
|
||||
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"
|
||||
>
|
||||
<label class="block"
|
||||
@@ -33,7 +68,9 @@
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<ListPictureDropzone label="List picture (optional)" />
|
||||
<button
|
||||
disabled={uploading}
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
|
||||
>create list</button
|
||||
>
|
||||
@@ -41,6 +78,7 @@
|
||||
</details>{/if}
|
||||
</div>
|
||||
{#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>
|
||||
{#each data.lists as list (list.id)}
|
||||
<li class="border-t border-[var(--site-border)]">
|
||||
@@ -49,7 +87,17 @@
|
||||
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 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)]">
|
||||
{#if list.isProgress && data.user}
|
||||
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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<void>;
|
||||
}) => {
|
||||
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;
|
||||
};
|
||||
</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">
|
||||
<a
|
||||
href={resolve('/lists')}
|
||||
@@ -54,6 +78,14 @@
|
||||
><ArrowLeft size={16} aria-hidden="true" /> lists</a
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if data.list.pictureUrl}<img
|
||||
src={data.list.pictureUrl}
|
||||
alt=""
|
||||
width="96"
|
||||
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)]">
|
||||
@@ -61,6 +93,7 @@
|
||||
{data.total === 1 ? 'item' : 'items'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if data.isAdmin}<button
|
||||
type="button"
|
||||
onclick={() => (managing = !managing)}
|
||||
@@ -96,7 +129,8 @@
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateList"
|
||||
use:enhance
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="grid gap-3 sm:grid-cols-2 sm:items-end"
|
||||
>
|
||||
<label
|
||||
@@ -124,7 +158,15 @@
|
||||
<option value="progress" selected={data.list.isProgress}>Progress</option>
|
||||
</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>
|
||||
<details class="border-t border-[var(--site-border)] pt-4">
|
||||
<summary class="cursor-pointer font-medium text-red-700">Delete this list</summary>
|
||||
@@ -148,8 +190,17 @@
|
||||
</div>
|
||||
</details>
|
||||
{/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)]">
|
||||
{#each data.list.items as item (item.id)}
|
||||
{#each visibleItems as item (item.id)}
|
||||
<li class="py-4">
|
||||
<div class="flex items-start gap-4">
|
||||
{#if item.pictureUrl}<img
|
||||
@@ -180,29 +231,51 @@
|
||||
</button>
|
||||
</form>
|
||||
{/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>
|
||||
</li>
|
||||
{:else}<li class="py-5 text-[var(--site-muted)]">
|
||||
{search ? 'No matching items' : 'no items yet'}
|
||||
</li>{/each}
|
||||
</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>
|
||||
{#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"
|
||||
class="grid gap-3"
|
||||
>
|
||||
<input type="hidden" name="itemId" value={item.id} />
|
||||
<input type="hidden" name="itemId" value={editingItem.id} />
|
||||
<label
|
||||
>Name<input
|
||||
name="name"
|
||||
value={item.name}
|
||||
value={editingItem.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"
|
||||
{#if editingItem.pictureUrl}<label class="text-sm"
|
||||
><input type="checkbox" name="removePicture" /> Remove current picture</label
|
||||
>{/if}
|
||||
<label
|
||||
@@ -210,7 +283,7 @@
|
||||
name="description"
|
||||
maxlength="5000"
|
||||
rows="3"
|
||||
class="block w-full rounded border p-2">{item.description ?? ''}</textarea
|
||||
class="block w-full rounded border p-2">{editingItem.description ?? ''}</textarea
|
||||
></label
|
||||
>
|
||||
<button
|
||||
@@ -219,17 +292,24 @@
|
||||
>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}`}
|
||||
<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>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{:else}<li class="py-5 text-[var(--site-muted)]">no items yet</li>{/each}
|
||||
</ul>
|
||||
{#if data.isAdmin && managing}
|
||||
<details class="border-t border-[var(--site-border)] pt-5">
|
||||
<summary class="cursor-pointer font-medium">add item</summary>
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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<Tab>('overview');
|
||||
let mode = $state<Mode>('transaction');
|
||||
let active = $state<Record<string, string | number | null>>({});
|
||||
let deleteAction = $state('');
|
||||
let showActionError = $state(false);
|
||||
let search = $state('');
|
||||
let accountFilter = $state('all');
|
||||
let accountFilter = $state<number[]>([]);
|
||||
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<TimelineView>('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 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="money-app mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
|
||||
{#snippet signed(value: number)}
|
||||
<span class:positive={value > 0} class:negative={value < 0}>{amount(value)}</span>
|
||||
{/snippet}
|
||||
{#snippet swatch(id: number | null)}
|
||||
<span class="swatch" style:background={id === null ? 'transparent' : colors.get(id)}></span>
|
||||
{/snippet}
|
||||
{#snippet rowActions(label: string, edit: () => void, remove: () => void)}
|
||||
<span class="actions"
|
||||
><button aria-label={'Edit ' + label} onclick={edit}><Pencil size={14} /></button><button
|
||||
aria-label={'Delete ' + label}
|
||||
onclick={remove}><Trash2 size={14} /></button
|
||||
></span
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
<div class="money-app mx-auto max-w-screen-2xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<header class="app-header">
|
||||
<div>
|
||||
<p class="muted">Personal finance</p>
|
||||
<h1>Money tracker</h1>
|
||||
<p class="muted">One place for every account, plan and household cost.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="button secondary" onclick={() => open('import')}
|
||||
><Upload size={16} /> Import CSV</button
|
||||
><button class="button primary" onclick={createForTab}
|
||||
><Plus size={16} /> Add {tab === 'overview' || tab === 'transactions'
|
||||
? 'transaction'
|
||||
: tab === 'planning'
|
||||
? 'recurring item'
|
||||
: tab === 'utilities'
|
||||
? 'reading'
|
||||
: tab === 'wishlist'
|
||||
? 'wish'
|
||||
: tab === 'insurance'
|
||||
? 'policy'
|
||||
: 'account'}</button
|
||||
><button class="button primary" onclick={() => open('transaction')}
|
||||
><Plus size={16} /> Add transaction</button
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="tabs" aria-label="Money tracker sections">
|
||||
{#each tabs as item (item.id)}<button
|
||||
class:current={tab === item.id}
|
||||
aria-current={tab === item.id ? 'page' : undefined}
|
||||
onclick={() => {
|
||||
tab = item.id;
|
||||
pageNumber = 1;
|
||||
}}>{item.label}</button
|
||||
>{/each}
|
||||
</nav>
|
||||
|
||||
{#if tab === 'overview'}
|
||||
<section class="overview-grid" aria-label="Account overview">
|
||||
<div class="net-worth">
|
||||
<p class="eyebrow">Across {data.accounts.length} accounts</p>
|
||||
<p class="net-value">{amount(total)}</p>
|
||||
<p class="muted">Current combined balance through {dateLabel(data.today)}</p>
|
||||
<section class="stat-strip" aria-label="Summary">
|
||||
<div class="stat net">
|
||||
<p class="eyebrow">Net worth · {data.accounts.length} accounts</p>
|
||||
<p class="stat-value">{@render signed(total)}</p>
|
||||
</div>
|
||||
<div class="overview-side">
|
||||
<div>
|
||||
<p class="eyebrow">Transactions</p>
|
||||
<p class="side-value">{data.transactions.length.toLocaleString('en-NL')}</p>
|
||||
<div class="stat">
|
||||
<p class="eyebrow">Spendable</p>
|
||||
<p class="stat-value">{@render signed(total - invested)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="eyebrow">Monthly recurring net</p>
|
||||
<p class="side-value">{amount(Math.round(monthlyRecurring))}</p>
|
||||
<div class="stat">
|
||||
<p class="eyebrow">Invested</p>
|
||||
<p class="stat-value">{@render signed(invested)}</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<p class="eyebrow">Net this month</p>
|
||||
<p class="stat-value">{@render signed(monthNet)}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Account history</h2>
|
||||
<p>
|
||||
End-of-day balances through today. Future dated transactions appear in the projection.
|
||||
<div class="stat">
|
||||
<p class="eyebrow">Recurring / month</p>
|
||||
<p class="stat-value">{@render signed(Math.round(monthlyRecurring))}</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<p class="eyebrow">
|
||||
Projected {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}
|
||||
</p>
|
||||
<p class="stat-value">{@render signed(projectionEnd[0])}</p>
|
||||
</div>
|
||||
</div>
|
||||
<MoneyChart {series} title="Individual and combined account balances over time" />
|
||||
</section>
|
||||
<section class="panel">
|
||||
|
||||
<div class="dashboard">
|
||||
<section class="panel span-8">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Expected wealth</h2>
|
||||
<p>From today through {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}.</p>
|
||||
<h2>Wealth over time</h2>
|
||||
<p>Actual balances through today, then the projection.</p>
|
||||
</div>
|
||||
<button
|
||||
class="button secondary"
|
||||
@@ -265,71 +283,85 @@
|
||||
>Edit projection</button
|
||||
>
|
||||
</div>
|
||||
<MoneyChart series={projection} title="Expected total, spendable and invested wealth" />
|
||||
<div class="subtabs" role="group" aria-label="Chart view">
|
||||
{#each timelineViews as item (item.id)}<button
|
||||
type="button"
|
||||
class:current={timelineView === item.id}
|
||||
aria-pressed={timelineView === item.id}
|
||||
onclick={() => (timelineView = item.id)}>{item.label}</button
|
||||
>{/each}
|
||||
</div>
|
||||
<MoneyChart
|
||||
series={timeline}
|
||||
title="Actual and projected wealth over time"
|
||||
marker={{ date: data.today, label: 'Today' }}
|
||||
/>
|
||||
<div class="projection-totals" aria-label="Projected balances at end of period">
|
||||
<div>
|
||||
<span class="eyebrow">Total at end</span><strong
|
||||
>{@render signed(projectionEnd[0])}</strong
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="eyebrow">Spendable at end</span><strong
|
||||
>{@render signed(projectionEnd[1])}</strong
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="eyebrow">Invested at end</span><strong
|
||||
>{@render signed(projectionEnd[2])}</strong
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted projection-note">
|
||||
Includes future dated transactions and estimates{data.projectionSettings
|
||||
?.includeRecurring !== 0
|
||||
? ', plus recurring monthly equivalents from next month'
|
||||
: ''}. {data.projectionSettings?.investmentAccountId
|
||||
? `The selected investment account has a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return; contributions move money between accounts.`
|
||||
: 'Choose an investment account to model contributions and growth.'} Enter an item only once
|
||||
across transactions, estimates, and recurring items to avoid counting it twice.
|
||||
? `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.
|
||||
</p>
|
||||
</section>
|
||||
<section class="panel">
|
||||
|
||||
<section class="panel span-4" id="accounts">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Wish list total</h2>
|
||||
<p>Cumulative estimated cost by priority, from tier 5 down.</p>
|
||||
</div>
|
||||
<button class="text-button" onclick={() => (tab = 'wishlist')}>View wish list</button>
|
||||
</div>
|
||||
<div class="wish-tier-grid">
|
||||
{#each wishTiers as row (row.tier)}<div>
|
||||
<span class="eyebrow">{row.tier === 5 ? 'Tier 5' : `Tiers 5–${row.tier}`}</span><strong
|
||||
>{amount(row.value)}</strong
|
||||
><small>{row.count} {row.count === 1 ? 'item' : 'items'}</small>
|
||||
</div>{/each}
|
||||
</div>
|
||||
{#if unpricedWishes}<p class="muted projection-note">
|
||||
{unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these
|
||||
totals.
|
||||
</p>{/if}
|
||||
</section>
|
||||
<div class="two-columns">
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Accounts</h2>
|
||||
<button class="text-button" onclick={() => (tab = 'accounts')}>View all</button>
|
||||
<p>Select accounts to filter transactions.</p>
|
||||
</div>
|
||||
{#if data.accounts.length}<ul class="summary-list">
|
||||
{#each data.accounts as account (account.id)}<li>
|
||||
<span>{account.name}</span><strong>{amount(balances.get(account.id) ?? 0)}</strong>
|
||||
<button class="button secondary" onclick={() => open('account')}
|
||||
><Plus size={16} /> Add</button
|
||||
>
|
||||
</div>
|
||||
{#if data.accounts.length}<ul class="item-list">
|
||||
{#each data.accounts as account (account.id)}<li
|
||||
class:selected={accountFilter.includes(account.id)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="item-main"
|
||||
aria-pressed={accountFilter.includes(account.id)}
|
||||
onclick={() => toggleAccount(account.id)}
|
||||
>{@render swatch(account.id)}<span
|
||||
><strong>{account.name}</strong><small class="capitalize"
|
||||
>{account.kind} · opening {amount(account.openingBalanceCents)}</small
|
||||
></span
|
||||
></button
|
||||
><strong class="item-amount">{@render signed(balances.get(account.id) ?? 0)}</strong
|
||||
>{@render rowActions(
|
||||
account.name,
|
||||
() => open('account', account),
|
||||
() => deletion(account.name, account.id, 'deleteAccount')
|
||||
)}
|
||||
</li>{/each}
|
||||
</ul>{:else}<p class="empty">
|
||||
Add an account or import your transactions to get started.
|
||||
</p>{/if}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Recent transactions</h2>
|
||||
<button class="text-button" onclick={() => (tab = 'transactions')}>View all</button>
|
||||
</div>
|
||||
{#if recent.length}<ul class="summary-list">
|
||||
{#each recent as row (row.id)}<li>
|
||||
<span
|
||||
><strong>{row.description}</strong><small
|
||||
>{accountName(row.accountId)} · {dateLabel(row.date)}</small
|
||||
></span
|
||||
><strong class:negative={row.amountCents < 0}>{amount(row.amountCents)}</strong>
|
||||
</li>{/each}
|
||||
</ul>{:else}<p class="empty">
|
||||
Transactions will appear here after you add or import them.
|
||||
</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
{:else if tab === 'transactions'}
|
||||
<section class="panel">
|
||||
|
||||
<section class="panel span-8" id="transactions">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Transactions</h2>
|
||||
@@ -346,15 +378,7 @@
|
||||
placeholder="Search descriptions"
|
||||
bind:value={search}
|
||||
oninput={() => (pageNumber = 1)}
|
||||
/><label class="sr-only" for="account-filter">Filter account</label><select
|
||||
id="account-filter"
|
||||
bind:value={accountFilter}
|
||||
onchange={() => (pageNumber = 1)}
|
||||
><option value="all">All accounts</option
|
||||
>{#each data.accounts as account (account.id)}<option value={account.id}
|
||||
>{account.name}</option
|
||||
>{/each}</select
|
||||
>
|
||||
/>
|
||||
<label class="sr-only" for="transaction-order">Transaction order</label><select
|
||||
id="transaction-order"
|
||||
bind:value={sortOrder}
|
||||
@@ -363,6 +387,23 @@
|
||||
></select
|
||||
>
|
||||
</div>
|
||||
{#if data.accounts.length}<div class="chips" role="group" aria-label="Filter by account">
|
||||
<button
|
||||
type="button"
|
||||
class:current={!accountFilter.length}
|
||||
aria-pressed={!accountFilter.length}
|
||||
onclick={() => {
|
||||
accountFilter = [];
|
||||
pageNumber = 1;
|
||||
}}>All accounts</button
|
||||
>{#each data.accounts as account (account.id)}<button
|
||||
type="button"
|
||||
class:current={accountFilter.includes(account.id)}
|
||||
aria-pressed={accountFilter.includes(account.id)}
|
||||
onclick={() => toggleAccount(account.id)}
|
||||
>{@render swatch(account.id)}{account.name}</button
|
||||
>{/each}
|
||||
</div>{/if}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
@@ -372,19 +413,21 @@
|
||||
></tr
|
||||
></thead
|
||||
><tbody
|
||||
>{#each shown as row (row.id)}<tr
|
||||
><td>{dateLabel(row.date)}</td><td class="strong">{row.description}</td><td
|
||||
>{accountName(row.accountId)}</td
|
||||
><td class="numeric" class:negative={row.amountCents < 0}
|
||||
>{amount(row.amountCents)}</td
|
||||
><td class="actions"
|
||||
>{#each shown as row (row.id)}<tr class:future={row.date > data.today}
|
||||
><td class="nowrap">{dateLabel(row.date)}</td><td class="strong"
|
||||
>{row.description}</td
|
||||
><td class="nowrap"
|
||||
><span class="account-cell"
|
||||
>{@render swatch(row.accountId)}{accountName(row.accountId)}</span
|
||||
></td
|
||||
><td class="numeric">{@render signed(row.amountCents)}</td><td class="actions"
|
||||
><button
|
||||
aria-label={'Edit ' + row.description}
|
||||
onclick={() => open('transaction', row)}><Pencil size={15} /></button
|
||||
onclick={() => open('transaction', row)}><Pencil size={14} /></button
|
||||
><button
|
||||
aria-label={'Delete ' + row.description}
|
||||
onclick={() => deletion(row.description, row.id, 'deleteTransaction')}
|
||||
><Trash2 size={15} /></button
|
||||
><Trash2 size={14} /></button
|
||||
></td
|
||||
></tr
|
||||
>{/each}</tbody
|
||||
@@ -408,110 +451,82 @@
|
||||
</div>
|
||||
</div>{/if}
|
||||
</section>
|
||||
{:else if tab === 'planning'}
|
||||
<div class="planning-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Monthly equivalent</p>
|
||||
<p class="large-value">{amount(Math.round(monthlyRecurring))}</p>
|
||||
<p class="muted">Signed total of recurring income and costs</p>
|
||||
</div>
|
||||
<p class="muted">
|
||||
Recurring items feed the Overview projection when enabled. Dated forecasts do not change
|
||||
actual balances.
|
||||
</p>
|
||||
</div>
|
||||
<section class="panel">
|
||||
|
||||
<div class="span-4 stack">
|
||||
<section class="panel" id="recurring">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Recurring income and costs</h2>
|
||||
<p>Rent, subscriptions, salary and other known repeats.</p>
|
||||
<h2>Recurring</h2>
|
||||
<p>
|
||||
{@render signed(Math.round(monthlyRecurring))} per month now. Feeds the projection when
|
||||
enabled.
|
||||
</p>
|
||||
</div>
|
||||
<button class="button secondary" onclick={() => open('recurring')}
|
||||
><Plus size={16} /> Add item</button
|
||||
><Plus size={16} /> Add</button
|
||||
>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Name</th><th>Account</th><th>Frequency</th><th>Dates</th><th class="numeric"
|
||||
>Amount</th
|
||||
><th class="actions-heading">Actions</th></tr
|
||||
></thead
|
||||
><tbody
|
||||
>{#each data.recurring as row (row.id)}<tr
|
||||
><td class="strong">{row.name}</td><td
|
||||
>{row.accountId ? accountName(row.accountId) : 'Any'}</td
|
||||
><td class="capitalize">{row.cadence}</td><td
|
||||
>{row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
|
||||
{#if data.recurring.length}<ul class="item-list scroll">
|
||||
{#each data.recurring as row (row.id)}<li>
|
||||
<span class="item-main"
|
||||
>{@render swatch(row.accountId)}<span
|
||||
><strong>{row.name}</strong><small
|
||||
><span class="capitalize">{row.cadence}</span> · {row.accountId
|
||||
? accountName(row.accountId)
|
||||
: 'Any account'} · {row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
|
||||
? dateLabel(row.endsOn)
|
||||
: 'ongoing'}</td
|
||||
><td class="numeric" class:negative={row.amountCents < 0}
|
||||
>{amount(row.amountCents)}</td
|
||||
><td class="actions"
|
||||
><button aria-label={'Edit ' + row.name} onclick={() => open('recurring', row)}
|
||||
><Pencil size={15} /></button
|
||||
><button
|
||||
aria-label={'Delete ' + row.name}
|
||||
onclick={() => deletion(row.name, row.id, 'deleteRecurring')}
|
||||
><Trash2 size={15} /></button
|
||||
></td
|
||||
></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{#if !data.recurring.length}<p class="empty">No recurring items yet.</p>{/if}
|
||||
: 'ongoing'}</small
|
||||
></span
|
||||
></span
|
||||
><strong class="item-amount">{@render signed(row.amountCents)}</strong
|
||||
>{@render rowActions(
|
||||
row.name,
|
||||
() => open('recurring', row),
|
||||
() => deletion(row.name, row.id, 'deleteRecurring')
|
||||
)}
|
||||
</li>{/each}
|
||||
</ul>{:else}<p class="empty">Rent, subscriptions, salary and other known repeats.</p>{/if}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<section class="panel" id="estimates">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Future estimates</h2>
|
||||
<p>Dated income and costs you expect.</p>
|
||||
</div>
|
||||
<button class="button secondary" onclick={() => open('forecast')}
|
||||
><Plus size={16} /> Add estimate</button
|
||||
><Plus size={16} /> Add</button
|
||||
>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Date</th><th>Description</th><th>Account</th><th class="numeric">Amount</th><th
|
||||
class="actions-heading">Actions</th
|
||||
></tr
|
||||
></thead
|
||||
><tbody
|
||||
>{#each data.forecasts as row (row.id)}<tr
|
||||
><td>{dateLabel(row.date)}</td><td class="strong">{row.description}</td><td
|
||||
>{row.accountId ? accountName(row.accountId) : 'Unassigned'}</td
|
||||
><td class="numeric" class:negative={row.amountCents < 0}
|
||||
>{amount(row.amountCents)}</td
|
||||
><td class="actions"
|
||||
><button
|
||||
aria-label={'Edit ' + row.description}
|
||||
onclick={() => open('forecast', row)}><Pencil size={15} /></button
|
||||
><button
|
||||
aria-label={'Delete ' + row.description}
|
||||
onclick={() => deletion(row.description, row.id, 'deleteForecast')}
|
||||
><Trash2 size={15} /></button
|
||||
></td
|
||||
></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{#if !data.forecasts.length}<p class="empty">
|
||||
Add expected costs or income to look ahead.
|
||||
</p>{/if}
|
||||
{#if data.forecasts.length}<ul class="item-list scroll">
|
||||
{#each data.forecasts as row (row.id)}<li>
|
||||
<span class="item-main"
|
||||
>{@render swatch(row.accountId)}<span
|
||||
><strong>{row.description}</strong><small
|
||||
>{dateLabel(row.date)} · {row.accountId
|
||||
? accountName(row.accountId)
|
||||
: 'Unassigned'}</small
|
||||
></span
|
||||
></span
|
||||
><strong class="item-amount">{@render signed(row.amountCents)}</strong
|
||||
>{@render rowActions(
|
||||
row.description,
|
||||
() => open('forecast', row),
|
||||
() => deletion(row.description, row.id, 'deleteForecast')
|
||||
)}
|
||||
</li>{/each}
|
||||
</ul>{:else}<p class="empty">Add expected costs or income to look ahead.</p>{/if}
|
||||
</section>
|
||||
{:else if tab === 'utilities'}
|
||||
<section class="panel">
|
||||
</div>
|
||||
|
||||
<section class="panel span-4" id="utilities">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Utilities</h2>
|
||||
<p>Meter readings, estimated variable cost and monthly advances.</p>
|
||||
<p>Meter readings and estimated variable cost.</p>
|
||||
</div>
|
||||
<button class="button secondary" onclick={() => open('reading', { kind: selectedUtility })}
|
||||
><Plus size={16} /> Reading</button
|
||||
>
|
||||
</div>
|
||||
<div class="subtabs">
|
||||
{#each kinds as kind (kind.id)}<button
|
||||
@@ -519,36 +534,31 @@
|
||||
onclick={() => (selectedUtility = kind.id)}>{kind.label}</button
|
||||
>{/each}
|
||||
</div>
|
||||
<div class="utility-top">
|
||||
<div class="mini-stats">
|
||||
<div>
|
||||
<p class="eyebrow">Usage in billing period</p>
|
||||
<p class="large-value">
|
||||
{estimate ? estimate.usage.toLocaleString('en-NL', { maximumFractionDigits: 3 }) : '—'}
|
||||
<span>{utility.unit}</span>
|
||||
</p>
|
||||
<span class="eyebrow">Usage</span><strong
|
||||
>{estimate ? estimate.usage.toLocaleString('en-NL', { maximumFractionDigits: 3 }) : '—'}
|
||||
<small>{utility.unit}</small></strong
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<p class="eyebrow">Estimated variable cost</p>
|
||||
<p class="large-value">{estimate ? amount(estimate.estimatedCents) : '—'}</p>
|
||||
<span class="eyebrow">Est. cost</span><strong
|
||||
>{estimate ? amount(estimate.estimatedCents) : '—'}</strong
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<p class="eyebrow">Advances less estimated cost</p>
|
||||
<p class="large-value">{estimate ? amount(estimate.differenceCents) : '—'}</p>
|
||||
<span class="eyebrow">Advances − cost</span><strong
|
||||
>{#if estimate}{@render signed(estimate.differenceCents)}{:else}—{/if}</strong
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted utility-note">
|
||||
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.
|
||||
</p>
|
||||
<MoneyChart
|
||||
series={utilitySeries}
|
||||
title={utility.label + ' meter readings over time'}
|
||||
unit=""
|
||||
/>
|
||||
<div class="utility-actions">
|
||||
<button
|
||||
class="button secondary"
|
||||
class="text-button utility-settings"
|
||||
onclick={() =>
|
||||
open('settings', {
|
||||
kind: selectedUtility,
|
||||
@@ -556,165 +566,100 @@
|
||||
monthlyAdvanceCents: settings?.monthlyAdvanceCents ?? 0,
|
||||
billingStart: settings?.billingStart ?? ''
|
||||
})}>Edit rate and advance</button
|
||||
><button class="button primary" onclick={() => open('reading', { kind: selectedUtility })}
|
||||
><Plus size={16} /> Add reading</button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>{utility.label} readings</h2>
|
||||
<p>{readings.length} recorded</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Date</th><th class="numeric">Meter value ({utility.unit})</th><th
|
||||
class="actions-heading">Actions</th
|
||||
></tr
|
||||
></thead
|
||||
><tbody
|
||||
>{#each [...readings].reverse() as row (row.id)}<tr
|
||||
><td>{dateLabel(row.date)}</td><td class="numeric strong"
|
||||
>{Number(row.reading).toLocaleString('en-NL', { maximumFractionDigits: 3 })}</td
|
||||
><td class="actions"
|
||||
><button
|
||||
aria-label={'Edit reading from ' + row.date}
|
||||
onclick={() => open('reading', row)}><Pencil size={15} /></button
|
||||
><button
|
||||
aria-label={'Delete reading from ' + row.date}
|
||||
onclick={() =>
|
||||
{#if readings.length}<ul class="item-list scroll">
|
||||
{#each [...readings].reverse() as row (row.id)}<li>
|
||||
<span class="item-main"><span><strong>{dateLabel(row.date)}</strong></span></span
|
||||
><span class="item-amount"
|
||||
>{Number(row.reading).toLocaleString('en-NL', { maximumFractionDigits: 3 })}
|
||||
{utility.unit}</span
|
||||
>{@render rowActions(
|
||||
'reading from ' + row.date,
|
||||
() => open('reading', row),
|
||||
() =>
|
||||
deletion(
|
||||
utility.label + ' reading on ' + dateLabel(row.date),
|
||||
row.id,
|
||||
'deleteReading'
|
||||
)}><Trash2 size={15} /></button
|
||||
></td
|
||||
></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{#if !readings.length}<p class="empty">Add at least two readings to calculate usage.</p>{/if}
|
||||
)
|
||||
)}
|
||||
</li>{/each}
|
||||
</ul>{:else}<p class="empty">Add at least two readings to calculate usage.</p>{/if}
|
||||
<p class="muted utility-note">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
{:else if tab === 'wishlist'}
|
||||
<section class="panel">
|
||||
|
||||
<section class="panel span-4" id="wishlist">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Wish list</h2>
|
||||
<p>Ideas for later, kept outside your forecasts.</p>
|
||||
<p>Cumulative cost by priority tier.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Item</th><th>Tier</th><th>Notes</th><th class="numeric">Estimated cost</th><th
|
||||
class="actions-heading">Actions</th
|
||||
></tr
|
||||
></thead
|
||||
><tbody
|
||||
>{#each data.wishlist as row (row.id)}<tr
|
||||
><td class="strong">{row.name}</td><td>{row.priority}</td><td>{row.notes ?? '—'}</td
|
||||
><td class="numeric"
|
||||
>{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}</td
|
||||
><td class="actions"
|
||||
><button aria-label={'Edit ' + row.name} onclick={() => open('wish', row)}
|
||||
><Pencil size={15} /></button
|
||||
><button
|
||||
aria-label={'Delete ' + row.name}
|
||||
onclick={() => deletion(row.name, row.id, 'deleteWishlist')}
|
||||
><Trash2 size={15} /></button
|
||||
></td
|
||||
></tr
|
||||
>{/each}</tbody
|
||||
<button class="button secondary" onclick={() => open('wish')}><Plus size={16} /> Add</button
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{#if !data.wishlist.length}<p class="empty">Nothing on your wish list yet.</p>{/if}
|
||||
<div class="wish-tier-grid">
|
||||
{#each wishTiers as row (row.tier)}<div>
|
||||
<span class="eyebrow">{row.tier === 5 ? 'Tier 5' : `5–${row.tier}`}</span><strong
|
||||
>{amount(row.value)}</strong
|
||||
><small>{row.count} {row.count === 1 ? 'item' : 'items'}</small>
|
||||
</div>{/each}
|
||||
</div>
|
||||
{#if unpricedWishes}<p class="muted projection-note">
|
||||
{unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these
|
||||
totals.
|
||||
</p>{/if}
|
||||
{#if data.wishlist.length}<ul class="item-list scroll">
|
||||
{#each data.wishlist as row (row.id)}<li>
|
||||
<span class="item-main"
|
||||
><span class="tier">{row.priority}</span><span
|
||||
><strong>{row.name}</strong>{#if row.notes}<small>{row.notes}</small>{/if}</span
|
||||
></span
|
||||
><strong class="item-amount"
|
||||
>{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}</strong
|
||||
>{@render rowActions(
|
||||
row.name,
|
||||
() => open('wish', row),
|
||||
() => deletion(row.name, row.id, 'deleteWishlist')
|
||||
)}
|
||||
</li>{/each}
|
||||
</ul>{:else}<p class="empty">Nothing on your wish list yet.</p>{/if}
|
||||
</section>
|
||||
{:else if tab === 'insurance'}
|
||||
<section class="panel">
|
||||
|
||||
<section class="panel span-4" id="insurance">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Insurance</h2>
|
||||
<p>Policies, providers, coverage and premiums in one place.</p>
|
||||
<p>{amount(Math.round(monthlyInsurance))} per month in premiums.</p>
|
||||
</div>
|
||||
<button class="button secondary" onclick={() => open('insurance')}
|
||||
><Plus size={16} /> Add</button
|
||||
>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Policy</th><th>Provider</th><th>Coverage</th><th class="numeric">Premium</th><th
|
||||
class="actions-heading">Actions</th
|
||||
></tr
|
||||
></thead
|
||||
><tbody
|
||||
>{#each data.insurances as row (row.id)}<tr
|
||||
><td class="strong">{row.name}</td><td>{row.provider ?? '—'}</td><td
|
||||
>{row.coverage ?? '—'}</td
|
||||
><td class="numeric"
|
||||
{#if data.insurances.length}<ul class="item-list scroll">
|
||||
{#each data.insurances as row (row.id)}<li>
|
||||
<span class="item-main"
|
||||
><span
|
||||
><strong>{row.name}</strong><small
|
||||
>{[row.provider, row.coverage].filter(Boolean).join(' · ') || '—'}</small
|
||||
></span
|
||||
></span
|
||||
><strong class="item-amount"
|
||||
>{row.premiumCents == null
|
||||
? '—'
|
||||
: amount(row.premiumCents) + ' / ' + row.cadence}</td
|
||||
><td class="actions"
|
||||
><button aria-label={'Edit ' + row.name} onclick={() => open('insurance', row)}
|
||||
><Pencil size={15} /></button
|
||||
><button
|
||||
aria-label={'Delete ' + row.name}
|
||||
onclick={() => deletion(row.name, row.id, 'deleteInsurance')}
|
||||
><Trash2 size={15} /></button
|
||||
></td
|
||||
></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{#if !data.insurances.length}<p class="empty">
|
||||
Add policies to keep track of what is covered.
|
||||
</p>{/if}
|
||||
: amount(row.premiumCents) + ' / ' + row.cadence}</strong
|
||||
>{@render rowActions(
|
||||
row.name,
|
||||
() => open('insurance', row),
|
||||
() => deletion(row.name, row.id, 'deleteInsurance')
|
||||
)}
|
||||
</li>{/each}
|
||||
</ul>{:else}<p class="empty">Add policies to keep track of what is covered.</p>{/if}
|
||||
</section>
|
||||
{:else if tab === 'accounts'}
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Accounts</h2>
|
||||
<p>Opening balances plus every signed transaction.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Account</th><th>Type</th><th class="numeric">Opening balance</th><th
|
||||
class="numeric">Current balance</th
|
||||
><th class="actions-heading">Actions</th></tr
|
||||
></thead
|
||||
><tbody
|
||||
>{#each data.accounts as row (row.id)}<tr
|
||||
><td class="strong">{row.name}</td><td class="capitalize">{row.kind}</td><td
|
||||
class="numeric">{amount(row.openingBalanceCents)}</td
|
||||
><td class="numeric strong">{amount(balances.get(row.id) ?? 0)}</td><td
|
||||
class="actions"
|
||||
><button aria-label={'Edit ' + row.name} onclick={() => open('account', row)}
|
||||
><Pencil size={15} /></button
|
||||
><button
|
||||
aria-label={'Delete ' + row.name}
|
||||
onclick={() => deletion(row.name, row.id, 'deleteAccount')}
|
||||
><Trash2 size={15} /></button
|
||||
></td
|
||||
></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{#if !data.accounts.length}<p class="empty">
|
||||
Add your first account or import a CSV to create them automatically.
|
||||
</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<dialog bind:this={dialog} class="money-dialog" onclose={() => (active = {})}>
|
||||
{#if showActionError && form?.message}<p class="form-error" role="alert">{form.message}</p>{/if}
|
||||
@@ -842,6 +787,20 @@
|
||||
>Investment accounts are included in total wealth but excluded from the cash reserve.</small
|
||||
></label
|
||||
>
|
||||
<label class="field"
|
||||
>Color<input
|
||||
name="color"
|
||||
type="color"
|
||||
list="account-colors"
|
||||
value={active.color ??
|
||||
(typeof active.id === 'number'
|
||||
? colors.get(active.id)
|
||||
: accountColor({}, data.accounts.length))}
|
||||
/><datalist id="account-colors"
|
||||
>{#each presetColors as color (color)}<option value={color}></option>{/each}</datalist
|
||||
><small>Used for the account's chart line, filter chip and transaction rows.</small
|
||||
></label
|
||||
>
|
||||
{:else if mode === 'transaction' || mode === 'forecast'}
|
||||
<label class="field"
|
||||
>Date<input
|
||||
@@ -927,16 +886,19 @@
|
||||
{:else if mode === 'projectionSettings'}
|
||||
<p class="dialog-copy">
|
||||
A planning scenario. Returns are hypothetical and calculated monthly. Contributions
|
||||
transfer money from spendable accounts into the selected investment account.
|
||||
transfer money from spendable accounts into a projected investment balance.
|
||||
</p>
|
||||
<label class="field"
|
||||
>Investment account<select name="investmentAccountId"
|
||||
><option value="">No investment model</option
|
||||
>Existing investment account (optional)<select name="investmentAccountId"
|
||||
><option value="">Start projected investments at €0</option
|
||||
>{#each data.accounts.filter((account) => account.kind === 'investment') as account (account.id)}<option
|
||||
value={account.id}
|
||||
selected={active.investmentAccountId === account.id}>{account.name}</option
|
||||
>{/each}</select
|
||||
><small>Mark an account as Investment in Accounts to select it here.</small></label
|
||||
><small
|
||||
>Select an existing investment account to include its current balance in the growth
|
||||
model.</small
|
||||
></label
|
||||
>
|
||||
<label class="field"
|
||||
>Spendable reserve (€)<input
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
}
|
||||
.money-app .button.secondary:hover,
|
||||
.money-dialog .button.secondary:hover,
|
||||
.money-app .tabs button:hover,
|
||||
.money-app .subtabs button:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
@@ -55,8 +54,7 @@
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding-bottom: 30px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.money-app h1 {
|
||||
margin: 2px 0 8px;
|
||||
@@ -70,84 +68,43 @@
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
margin-top: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.money-app .projection-totals {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.money-app .projection-totals > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 7px;
|
||||
}
|
||||
.money-app .projection-totals strong {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.money-app .wish-tier-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.money-app .wish-tier-grid > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 15px;
|
||||
gap: 3px;
|
||||
padding: 9px 11px;
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .wish-tier-grid > div + div {
|
||||
border-left: 1px solid var(--site-border);
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 7px;
|
||||
}
|
||||
.money-app .wish-tier-grid strong {
|
||||
font-size: 17px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -167,21 +124,20 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
.money-app .panel {
|
||||
margin-bottom: 28px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
padding: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .panel-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 22px;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.money-app .panel-heading h2 {
|
||||
font-size: 20px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
@@ -196,32 +152,12 @@
|
||||
text-underline-offset: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app .two-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 28px;
|
||||
}
|
||||
.money-app .summary-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.money-app .summary-list strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.money-app .summary-list small {
|
||||
display: block;
|
||||
color: var(--site-muted);
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.money-app .negative {
|
||||
color: #e5484d;
|
||||
}
|
||||
.money-app .positive {
|
||||
color: #46a758;
|
||||
}
|
||||
.money-app .empty {
|
||||
color: var(--site-muted);
|
||||
font-size: 13px;
|
||||
@@ -262,12 +198,12 @@
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app td {
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding: 11px 12px;
|
||||
padding: 8px 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.money-app tbody tr:hover {
|
||||
@@ -314,35 +250,11 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.money-app .planning-intro {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding: 8px 0 30px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.money-app .planning-intro > p {
|
||||
max-width: 370px;
|
||||
}
|
||||
.money-app .large-value {
|
||||
font-size: clamp(1.6rem, 4vw, 2.5rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.05em;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.money-app .large-value span {
|
||||
font-size: 16px;
|
||||
color: var(--site-muted);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.money-app .subtabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.money-app .subtabs button {
|
||||
border: 1px solid var(--site-border);
|
||||
@@ -356,20 +268,223 @@
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.money-app .utility-top {
|
||||
.money-app .stat-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.money-app .utility-note {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.money-app .utility-actions {
|
||||
.money-app .stat {
|
||||
padding: 16px 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .stat + .stat {
|
||||
border-left: 1px solid var(--site-border);
|
||||
}
|
||||
.money-app .stat-value {
|
||||
margin-top: 4px;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.money-app .stat.net .stat-value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.money-app .dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.money-app .span-8 {
|
||||
grid-column: span 8;
|
||||
}
|
||||
.money-app .span-4 {
|
||||
grid-column: span 4;
|
||||
}
|
||||
.money-app .stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .swatch {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px var(--site-border);
|
||||
}
|
||||
.money-app .account-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.money-app .nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app tr.future {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.money-app .chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.money-app .chips button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 5px 11px;
|
||||
color: var(--site-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.money-app .chips button:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.money-app .chips button.current {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.money-app .item-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.money-app .item-list.scroll {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.money-app .item-list li.selected {
|
||||
background: var(--site-surface);
|
||||
box-shadow: inset 2px 0 0 var(--site-text);
|
||||
}
|
||||
.money-app .item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.money-app .item-main > span:last-child {
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .item-main strong {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app .item-main small {
|
||||
display: block;
|
||||
color: var(--site-muted);
|
||||
font-size: 11px;
|
||||
margin-top: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app .item-amount {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app .item-list .actions button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.money-app .tier {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 5px;
|
||||
color: var(--site-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.money-app .mini-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.money-app .mini-stats > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .mini-stats strong {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.money-app .mini-stats small {
|
||||
color: var(--site-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.money-app .utility-settings {
|
||||
display: block;
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
.money-app .utility-note {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.money-app .span-8,
|
||||
.money-app .span-4 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
.money-app .dashboard > .span-4:nth-last-child(-n + 3) {
|
||||
grid-column: span 6;
|
||||
}
|
||||
.money-app .dashboard > .span-4:last-child {
|
||||
grid-column: span 12;
|
||||
}
|
||||
.money-app .stat-strip {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.money-app .stat:nth-child(4) {
|
||||
border-left: 0;
|
||||
}
|
||||
.money-app .stat:nth-child(n + 4) {
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.money-app .dashboard > .span-4:nth-last-child(-n + 3) {
|
||||
grid-column: span 12;
|
||||
}
|
||||
.money-app .stat-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.money-app .stat.net {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.money-app .stat + .stat {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
.money-app .stat:nth-child(odd):not(.net) {
|
||||
border-left: 1px solid var(--site-border);
|
||||
}
|
||||
}
|
||||
.money-dialog {
|
||||
width: min(440px, calc(100% - 28px));
|
||||
@@ -452,49 +567,13 @@
|
||||
color: #e5484d;
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.money-app .overview-grid,
|
||||
.money-app .two-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.money-app .overview-side {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--site-border);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
.money-app .overview-side > div + div {
|
||||
border-top: 0;
|
||||
border-left: 1px solid var(--site-border);
|
||||
}
|
||||
.money-app .utility-top {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.money-app .wish-tier-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.money-app .wish-tier-grid > div {
|
||||
border-left: 0 !important;
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
.money-app .planning-intro {
|
||||
display: block;
|
||||
}
|
||||
.money-app .planning-intro > p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.money-app .projection-totals {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.money-app .panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.money-app .net-worth {
|
||||
padding: 22px;
|
||||
}
|
||||
.money-app .overview-side > div {
|
||||
padding: 16px;
|
||||
}
|
||||
.money-app .filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user