Tasks goals: auto metrics (tasks.goals.metric_key/metric_detail/metric_computed_at, tasks.metric_sources cache). €10k/month goal gets a live progress bar: Revolut recurring income (excl. Zorgtoeslag/Joint) computed on page load + trailing-12m paid Moneybird invoices / 12 cached by a daily goal-metrics user timer; stacked bar with %, amount vs target, salary/side-income breakdown, last-updated
Some checks failed
Deploy production / deploy (push) Failing after 1m14s

This commit is contained in:
2026-10-26 01:17:29 +00:00
parent 5c6ce75409
commit 5d732fc750
10 changed files with 40489 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ import {
date,
index,
integer,
jsonb,
numeric,
smallint,
text,
@@ -14,6 +15,7 @@ import {
} from 'drizzle-orm/pg-core';
import { tasksSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
import type { GoalMetricDetail } from '../../goal-metrics/core';
// Private task + goal tracker (owner only, see requireOwner); meant to replace TickTick.
// No user_id: there is exactly one owner, and the schema is granted to personal_app only.
@@ -60,6 +62,10 @@ export const todoGoals = tasksSchema.table(
metricUnit: text('metric_unit'), // e.g. "EUR"
metricTarget: numeric('metric_target', { precision: 14, scale: 2, mode: 'number' }),
metricCurrent: numeric('metric_current', { precision: 14, scale: 2, mode: 'number' }),
// Auto metric (src/lib/server/goal-metrics): when set, metric_current is computed, not typed.
metricKey: text('metric_key'),
metricDetail: jsonb('metric_detail').$type<GoalMetricDetail>(), // breakdown of the last computation
metricComputedAt: timestamptz('metric_computed_at'),
flagged: boolean('flagged').notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
@@ -73,6 +79,17 @@ export const todoGoals = tasksSchema.table(
]
);
/** Cached inputs for auto goal metrics that come from slow/external APIs (e.g. Moneybird),
* refreshed daily by scripts/goal-metrics.ts so page loads never call them. */
export const todoMetricSources = tasksSchema.table('metric_sources', {
key: text('key').primaryKey(),
value: numeric('value', { precision: 14, scale: 2, mode: 'number' }),
detail: jsonb('detail'),
computedAt: timestamptz('computed_at').notNull().defaultNow(), // last successful refresh
attemptedAt: timestamptz('attempted_at').notNull().defaultNow(),
error: text('error') // last refresh error (value stays the last good one)
});
export const todoTasks = tasksSchema.table(
'tasks',
{

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { fetchMoneybirdSideIncome } from './core';
describe('fetchMoneybirdSideIncome', () => {
it('paginates, sums paid totals over the trailing 12 months and divides by 12', async () => {
const urls: string[] = [];
const page = (n: number, amount: string) =>
Array.from({ length: n }, () => ({ total_price_incl_tax: amount, currency: 'EUR' }));
const fake = (async (url: string) => {
urls.push(url);
const body = urls.length === 1 ? page(100, '10.00') : page(2, '70.50');
return new Response(JSON.stringify(body));
}) as unknown as typeof fetch;
const r = await fetchMoneybirdSideIncome('t', '1', new Date('2026-10-26T12:00:00Z'), fake);
expect(urls).toHaveLength(2);
expect(decodeURIComponent(urls[0])).toContain('state:paid,period:20251027..20261026');
expect(urls[1]).toContain('page=2');
expect(r).toMatchObject({ invoices: 102, total: 1141, monthly: 95.08 });
});
it('throws on API errors (the refresh keeps the cached value)', async () => {
const fake = (async () => new Response('no', { status: 429 })) as unknown as typeof fetch;
await expect(fetchMoneybirdSideIncome('t', '1', new Date(), fake)).rejects.toThrow('429');
});
});

View File

@@ -0,0 +1,177 @@
import type { Sql } from 'postgres';
// Auto-updating goal metrics (tasks.goals.metric_key). Pure: no $app imports, so the
// daily refresh script (scripts/goal-metrics.ts) and the page load share it.
// - Cheap parts (money.recurring) are computed live on every page load, so a raise
// in the money tracker moves the bar immediately.
// - Slow/external parts (Moneybird) are cached in tasks.metric_sources by the daily
// refresh; page loads never call an external API.
export type GoalMetricPart = {
key: string;
label: string;
value: number; // in the goal's unit (EUR per month)
note?: string | null;
asOf?: string | null; // ISO moment the part was computed (cached parts)
};
export type GoalMetricDetail = {
parts: GoalMetricPart[];
liveAt: string; // ISO moment of this computation
sourcesAt?: string | null; // oldest cached source used
error?: string | null; // last refresh error of a cached source, if any
};
export type GoalMetricResult = { value: number; detail: GoalMetricDetail };
export const SIDE_INCOME_SOURCE = 'moneybird-side-income-t12m';
const TZ = 'Europe/Amsterdam';
type SourceRow = {
value: string | null;
detail: { invoices?: number; total?: number; from?: string; to?: string } | null;
computed_at: Date;
error: string | null;
};
/** Stan's own net monthly income into Revolut: recurring income + trailing-12m side income / 12. */
async function ownNetIncomeMonthly(sql: Sql): Promise<GoalMetricResult> {
// Personal Revolut only (Joint is shared with Juhee); Zorgtoeslag is a benefit, not income.
const rows = await sql<{ id: number; name: string; monthly_cents: string }[]>`
select r.id, r.name,
(case r.cadence
when 'monthly' then r.amount_cents::numeric
when 'yearly' then r.amount_cents / 12.0
when 'weekly' then r.amount_cents * 52 / 12.0
end) as monthly_cents
from money.recurring r
join money.accounts a on a.id = r.account_id
where r.amount_cents > 0
and a.name = 'Revolut' and a.scope = 'personal'
and r.name !~* 'zorgtoeslag'
and (r.starts_on is null or r.starts_on <= (now() at time zone ${TZ})::date)
and (r.ends_on is null or r.ends_on >= (now() at time zone ${TZ})::date)
order by monthly_cents desc, r.id`;
const [src] = await sql<SourceRow[]>`
select value, detail, computed_at, error from tasks.metric_sources
where key = ${SIDE_INCOME_SOURCE}`;
const parts: GoalMetricPart[] = rows.map((r) => ({
key: `recurring-${r.id}`,
label: r.name,
value: round2(Number(r.monthly_cents) / 100),
note: 'recurring'
}));
const side = src?.value != null ? round2(Number(src.value)) : null;
parts.push({
key: 'side-income',
label: 'Side income',
value: side ?? 0,
note:
src?.detail?.invoices != null
? `${src.detail.invoices} paid invoices, last 12 months ÷ 12`
: 'not fetched yet',
asOf: src?.computed_at ? new Date(src.computed_at).toISOString() : null
});
return {
value: round2(parts.reduce((s, p) => s + p.value, 0)),
detail: {
parts,
liveAt: new Date().toISOString(),
sourcesAt: src?.computed_at ? new Date(src.computed_at).toISOString() : null,
error: src?.error ?? null
}
};
}
export const GOAL_METRICS: Record<
string,
{ label: string; compute: (sql: Sql) => Promise<GoalMetricResult> }
> = {
'own-net-income-monthly': {
label: 'Own net income / month (salary + side income)',
compute: ownNetIncomeMonthly
}
};
export const GOAL_METRIC_KEYS = Object.keys(GOAL_METRICS);
const round2 = (n: number) => Math.round(n * 100) / 100;
const ymd = (d: Date) => d.toLocaleDateString('sv-SE', { timeZone: TZ }).replaceAll('-', '');
/** Trailing 12 months of paid Moneybird sales invoices (KOR, 0% VAT: totals = gross income). */
export async function fetchMoneybirdSideIncome(
token: string,
administrationId: string,
now = new Date(),
fetchImpl: typeof fetch = fetch
) {
const fromDate = new Date(now);
fromDate.setFullYear(fromDate.getFullYear() - 1);
fromDate.setDate(fromDate.getDate() + 1);
const from = ymd(fromDate);
const to = ymd(now);
let total = 0;
let invoices = 0;
for (let page = 1; page <= 50; page++) {
const url =
`https://moneybird.com/api/v2/${administrationId}/sales_invoices.json` +
`?filter=${encodeURIComponent(`state:paid,period:${from}..${to}`)}&per_page=100&page=${page}`;
const res = await fetchImpl(url, {
headers: { authorization: `Bearer ${token}`, accept: 'application/json' }
});
if (!res.ok) throw new Error(`Moneybird ${res.status} on page ${page}`);
const rows = (await res.json()) as { total_price_incl_tax: string; currency: string }[];
for (const r of rows) {
if (r.currency && r.currency !== 'EUR') throw new Error(`Unexpected currency ${r.currency}`);
total += Number(r.total_price_incl_tax);
invoices++;
}
if (rows.length < 100) break;
}
total = round2(total);
return { total, monthly: round2(total / 12), invoices, from, to };
}
export async function saveSource(
sql: Sql,
key: string,
value: number | null,
detail: unknown,
error: string | null
) {
if (value === null) {
// Keep the last good value; just record the error.
await sql`
insert into tasks.metric_sources (key, value, detail, computed_at, error)
values (${key}, null, null, now(), ${error})
on conflict (key) do update set error = excluded.error, attempted_at = now()`;
return;
}
await sql`
insert into tasks.metric_sources (key, value, detail, computed_at, attempted_at, error)
values (${key}, ${value}, ${sql.json(detail as never)}, now(), now(), null)
on conflict (key) do update set value = excluded.value, detail = excluded.detail,
computed_at = now(), attempted_at = now(), error = null`;
}
/** Recompute every goal with a metric_key and store it (metric_current + a progress snapshot). */
export async function persistGoalMetrics(sql: Sql) {
const goals = await sql<{ id: number; metric_key: string; metric_current: string | null }[]>`
select id, metric_key, metric_current from tasks.goals where metric_key is not null`;
const out: { id: number; value: number; target: number | null }[] = [];
for (const g of goals) {
const m = GOAL_METRICS[g.metric_key];
if (!m) continue;
const r = await m.compute(sql);
const [row] = await sql<{ metric_target: string | null }[]>`
update tasks.goals set metric_current = ${r.value}, metric_detail = ${sql.json(r.detail as never)},
metric_computed_at = now()
where id = ${g.id} returning metric_target`;
if (g.metric_current === null || Number(g.metric_current) !== r.value)
await sql`insert into tasks.activity (goal_id, kind, value) values (${g.id}, 'progress', ${r.value})`;
out.push({
id: g.id,
value: r.value,
target: row?.metric_target ? Number(row.metric_target) : null
});
}
return out;
}

View File

@@ -1,4 +1,5 @@
import { db } from '#lib/server/db/index.js';
import { client, db } from '#lib/server/db/index.js';
import { GOAL_METRICS, GOAL_METRIC_KEYS } from '#lib/server/goal-metrics/core.js';
import {
TASK_STATUSES,
todoActivity,
@@ -30,12 +31,35 @@ export const load: PageServerLoad = async (event) => {
return {
today: todayIn(),
areas,
goals,
goals: await withLiveMetrics(goals),
metricKeys: GOAL_METRIC_KEYS.map((key) => ({ key, label: GOAL_METRICS[key].label })),
tasks,
seo: { title: 'Tasks', description: 'Private task and goal tracker.', index: false }
};
};
/** Auto goals: recompute live (cheap SQL + cached Moneybird), fall back to the stored value. */
async function withLiveMetrics<G extends typeof todoGoals.$inferSelect>(goals: G[]) {
return Promise.all(
goals.map(async (g) => {
const m = g.metricKey ? GOAL_METRICS[g.metricKey] : undefined;
if (!m || g.status !== 'active') return g;
try {
const r = await m.compute(client);
return {
...g,
metricCurrent: r.value,
metricDetail: r.detail,
metricComputedAt: new Date()
};
} catch (e) {
console.error(`[goal-metrics] ${g.metricKey}: ${(e as Error).message}`);
return g;
}
})
);
}
const id = (v: FormDataEntryValue | null) => {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
@@ -57,6 +81,8 @@ function dbError(e: unknown): string {
if (cause?.constraint_name) return `Not allowed (${cause.constraint_name})`;
return cause?.message || 'Could not save';
}
const metricKeyOf = (v: FormDataEntryValue | null) =>
typeof v === 'string' && GOAL_METRIC_KEYS.includes(v) ? v : null;
const isStatus = (v: unknown): v is TaskStatus =>
typeof v === 'string' && (TASK_STATUSES as readonly string[]).includes(v);
@@ -211,12 +237,15 @@ export const actions: Actions = {
metricUnit: g.metricUnit ?? null,
metricTarget: g.metricTarget ?? null,
metricCurrent: g.metricCurrent ?? null,
metricKey: metricKeyOf(form.get('metricKey')),
flagged: g.flagged
};
try {
if (goalId) {
const [prev] = await db.select().from(todoGoals).where(eq(todoGoals.id, goalId));
if (!prev) return fail(404, { message: 'Goal not found' });
// Auto goals: the computed value wins over whatever the form sent.
if (values.metricKey) values.metricCurrent = prev.metricKey ? prev.metricCurrent : null;
const achievedAt = g.status === 'achieved' ? (prev.achievedAt ?? new Date()) : null;
await db.transaction(async (tx) => {
await tx

View File

@@ -16,6 +16,10 @@
import CircleDot from 'lucide-svelte/icons/circle-dot';
import CircleCheck from 'lucide-svelte/icons/circle-check';
import CircleX from 'lucide-svelte/icons/circle-x';
import Briefcase from 'lucide-svelte/icons/briefcase';
import Sparkles from 'lucide-svelte/icons/sparkles';
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
@@ -196,6 +200,23 @@
maximumFractionDigits: 0
}).format(n)
: `${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 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;
return p < 10 ? p.toFixed(1) : Math.round(p).toString();
};
const updatedLabel = (iso: string | null | undefined) =>
iso
? new Date(iso).toLocaleString('en-GB', {
timeZone: 'Europe/Amsterdam',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})
: 'never';
function targetLabel(day: string) {
const n = daysUntil(day);
const date = new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
@@ -214,6 +235,7 @@
let goalDialog = $state(false);
let editing = $state<Task | null>(null);
let editingGoal = $state<Goal | null>(null);
let goalMetricKey = $state('');
let confirmDelete = $state(false);
let error = $state<string | null>(null);
let quickError = $state<string | null>(null);
@@ -227,6 +249,7 @@
}
function openGoal(g: Goal | null = null) {
editingGoal = g;
goalMetricKey = g?.metricKey ?? '';
confirmDelete = false;
error = null;
goalDialog = true;
@@ -479,7 +502,54 @@
</p>
{#if goal.notes}<p class="notes mb-2">{goal.notes}</p>{/if}
{#if goal.targetDate}<small class="block">🎯 {targetLabel(goal.targetDate)}</small>{/if}
{#if goal.metricTarget !== null}
{#if goal.metricKey && goal.metricDetail && goal.metricTarget && goal.metricCurrent !== null}
{@const d = goal.metricDetail}
{@const target = goal.metricTarget}
<div class="auto mt-3">
<p class="flex items-baseline justify-between gap-2">
<span class="pct">{pctLabel(goal.metricCurrent, target)}<i>%</i></span>
<small
>{money(goal.metricCurrent, goal.metricUnit)} / {money(target, goal.metricUnit)}
<span class="opacity-70">per month</span></small
>
</p>
<div
class="stack mt-1.5"
role="progressbar"
aria-label={goal.metric ?? 'Progress'}
aria-valuemin={0}
aria-valuemax={target}
aria-valuenow={goal.metricCurrent}
>
{#each d.parts as p, i (p.key)}<span
style:width={`${partPct(p.value, target)}%`}
style:background={PART_COLORS[i % PART_COLORS.length]}
title={`${p.label}: ${money(p.value, goal.metricUnit)}`}
></span>{/each}
</div>
<ul class="legend mt-2">
{#each d.parts as p, i (p.key)}
<li>
<span class="dot" style:color={PART_COLORS[i % PART_COLORS.length]}
>{#if p.key === 'side-income'}<Sparkles size={12} />{:else}<Briefcase
size={12}
/>{/if}</span
>
<span class="truncate" title={p.note ?? ''}>{p.label}</span>
<span class="amt">{money(p.value, goal.metricUnit)}</span>
</li>
{/each}
</ul>
<small class="updated mt-1.5 flex items-center gap-1"
><RefreshCw size={11} /> salary live · side income {updatedLabel(
d.sourcesAt
)}</small
>
{#if d.error}<small class="mt-0.5 flex items-center gap-1 text-amber-500"
><TriangleAlert size={11} /> last refresh failed, showing cached value</small
>{/if}
</div>
{:else if goal.metricTarget !== null}
<small class="block"
>{goal.metric ?? 'Target'}: {goal.metricCurrent !== null
? `${money(goal.metricCurrent, goal.metricUnit)} / `
@@ -704,10 +774,18 @@
/></label
>
<label class="grid gap-1 text-sm"
><span>Current</span>
><span>Auto metric</span>
<NativeSelect name="metricKey" bind:value={goalMetricKey}>
<option value="">None (type it in)</option>
{#each data.metricKeys as m (m.key)}<option value={m.key}>{m.label}</option>{/each}
</NativeSelect></label
>
<label class="grid gap-1 text-sm"
><span>Current{goalMetricKey ? ' (auto)' : ''}</span>
<Input
name="metricCurrent"
inputmode="decimal"
disabled={!!goalMetricKey}
value={editingGoal?.metricCurrent ?? ''}
/></label
>
@@ -920,6 +998,57 @@
.goal:hover {
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
}
.auto .pct {
font-size: 1.75rem;
font-weight: 650;
letter-spacing: -0.03em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.auto .pct i {
font-style: normal;
font-size: 0.9rem;
margin-left: 0.1rem;
color: var(--site-muted);
}
.auto small {
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.stack {
display: flex;
gap: 2px;
height: 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--site-muted) 18%, transparent);
overflow: hidden;
}
.stack span {
display: block;
height: 100%;
transition: width 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.stack span:last-child {
border-radius: 0 999px 999px 0;
}
.legend {
display: grid;
gap: 0.2rem;
font-size: 0.8rem;
}
.legend li {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.45rem;
}
.legend .dot {
display: inline-flex;
}
.legend .amt {
font-variant-numeric: tabular-nums;
color: var(--site-muted);
}
.goal.muted {
opacity: 0.6;
}