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;
}