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
Some checks failed
Deploy production / deploy (push) Failing after 1m14s
This commit is contained in:
@@ -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',
|
||||
{
|
||||
|
||||
25
src/lib/server/goal-metrics/core.test.ts
Normal file
25
src/lib/server/goal-metrics/core.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
177
src/lib/server/goal-metrics/core.ts
Normal file
177
src/lib/server/goal-metrics/core.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user