From 0121071af2d8928a97000e29ff19a952c990ae94 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Mon, 26 Oct 2026 07:35:09 +0000 Subject: [PATCH] =?UTF-8?q?Tasks=20goals:=20investment=20income=20in=20the?= =?UTF-8?q?=20=E2=82=AC10k/month=20bar=20(trailing-12m=20money.transaction?= =?UTF-8?q?s=20amount=5Fcents>0,=20category=20dividend|interest,=20any=20a?= =?UTF-8?q?ccount=20except=20Joint,=20=C3=B712);=20own=20segment=20colour?= =?UTF-8?q?=20+=20breakdown=20line,=20breakdown=20amounts=20with=20cents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/goal-metrics/core.ts | 24 +++++++++++++++++++++++- src/routes/trackers/tasks/+page.svelte | 15 ++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/lib/server/goal-metrics/core.ts b/src/lib/server/goal-metrics/core.ts index 8015af5..d606b03 100644 --- a/src/lib/server/goal-metrics/core.ts +++ b/src/lib/server/goal-metrics/core.ts @@ -23,6 +23,10 @@ export type GoalMetricDetail = { export type GoalMetricResult = { value: number; detail: GoalMetricDetail }; export const SIDE_INCOME_SOURCE = 'moneybird-side-income-t12m'; +/** money.transactions.category values that count as investment income (agreed with MoneyMan). + * Dividends, interest (incl. Revolut savings interest) and distributions are recorded as one + * of these; deposits, transfers, investment sales and unrealised gains are NOT. */ +export const INVESTMENT_INCOME_CATEGORIES = ['dividend', 'interest']; const TZ = 'Europe/Amsterdam'; type SourceRow = { @@ -54,6 +58,18 @@ async function ownNetIncomeMonthly(sql: Sql): Promise { select value, detail, computed_at, error from tasks.metric_sources where key = ${SIDE_INCOME_SOURCE}`; + // Investment income: trailing 12 months of money.transactions with amount_cents > 0 and + // category in INVESTMENT_INCOME_CATEGORIES, on any account except Joint (shared), ÷ 12. + const [inv] = await sql<{ cents: string; n: number }[]>` + select coalesce(sum(t.amount_cents), 0) as cents, count(*)::int as n + from money.transactions t + join money.accounts a on a.id = t.account_id + where t.amount_cents > 0 + and t.category = any(${INVESTMENT_INCOME_CATEGORIES}) + and a.scope <> 'joint' + and t.date > ((now() at time zone ${TZ})::date - interval '12 months') + and t.date <= (now() at time zone ${TZ})::date`; + const parts: GoalMetricPart[] = rows.map((r) => ({ key: `recurring-${r.id}`, label: r.name, @@ -71,6 +87,12 @@ async function ownNetIncomeMonthly(sql: Sql): Promise { : 'not fetched yet', asOf: src?.computed_at ? new Date(src.computed_at).toISOString() : null }); + parts.push({ + key: 'investment-income', + label: 'Investment income', + value: round2(Number(inv?.cents ?? 0) / 100 / 12), + note: `${inv?.n ?? 0} dividend/interest payments, last 12 months ÷ 12` + }); return { value: round2(parts.reduce((s, p) => s + p.value, 0)), detail: { @@ -87,7 +109,7 @@ export const GOAL_METRICS: Record< { label: string; compute: (sql: Sql) => Promise } > = { 'own-net-income-monthly': { - label: 'Own net income / month (salary + side income)', + label: 'Own net income / month (salary + side + investment income)', compute: ownNetIncomeMonthly } }; diff --git a/src/routes/trackers/tasks/+page.svelte b/src/routes/trackers/tasks/+page.svelte index cc2fc7f..e0aaa29 100644 --- a/src/routes/trackers/tasks/+page.svelte +++ b/src/routes/trackers/tasks/+page.svelte @@ -20,6 +20,7 @@ import Sparkles from 'lucide-svelte/icons/sparkles'; import RefreshCw from 'lucide-svelte/icons/refresh-cw'; import TriangleAlert from 'lucide-svelte/icons/triangle-alert'; + import TrendingUp from 'lucide-svelte/icons/trending-up'; import type { SubmitFunction } from '@sveltejs/kit'; import type { PageProps } from './$types'; @@ -202,6 +203,8 @@ : `${n.toLocaleString('en-GB')}${unit ? ` ${unit}` : ''}`; // Auto metrics (goal.metricKey): stacked bar per part, colourful on the black/white base. const PART_COLORS = ['rgb(14 165 233)', 'rgb(245 158 11)', 'rgb(168 85 247)', 'rgb(16 185 129)']; + const money2 = (n: number) => + new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' }).format(n); const partPct = (v: number, target: number) => Math.max(0, Math.min(100, (v / target) * 100)); const pctLabel = (v: number, target: number) => { const p = (v / target) * 100; @@ -521,9 +524,9 @@ aria-valuemax={target} aria-valuenow={goal.metricCurrent} > - {#each d.parts as p, i (p.key)} p.value > 0) as p (p.key)}{/each} @@ -531,12 +534,14 @@ {#each d.parts as p, i (p.key)}
  • {#if p.key === 'side-income'}{:else}{#if p.key === 'side-income'}{/if}{:else if p.key === 'investment-income'}{:else}{/if} {p.label} - {money(p.value, goal.metricUnit)} + {money2(p.value)}
  • {/each}