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:
13
deploy/systemd/goal-metrics.service
Normal file
13
deploy/systemd/goal-metrics.service
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Goal metrics refresh (Moneybird side income -> tasks.metric_sources, auto goals -> tasks.goals)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# A checkout of stan/website on main (needs node_modules for postgres.js).
|
||||||
|
WorkingDirectory=%h/dev/website-goalbar
|
||||||
|
# DB_URL (app role), MONEYBIRD_API_TOKEN, MONEYBIRD_ADMINISTRATION_ID; mode 600, never committed.
|
||||||
|
EnvironmentFile=%h/.config/goal-metrics.env
|
||||||
|
ExecStart=/usr/bin/bun scripts/goal-metrics.ts
|
||||||
|
TimeoutStartSec=300
|
||||||
10
deploy/systemd/goal-metrics.timer
Normal file
10
deploy/systemd/goal-metrics.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Goal metrics refresh, daily (salary part is live on page load anyway)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 06:40:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=300
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
23
drizzle/20261026011600_goal_metrics/migration.sql
Normal file
23
drizzle/20261026011600_goal_metrics/migration.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE "tasks"."metric_sources" (
|
||||||
|
"key" text PRIMARY KEY,
|
||||||
|
"value" numeric(14,2),
|
||||||
|
"detail" jsonb,
|
||||||
|
"computed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"attempted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"error" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."goals" ADD COLUMN "metric_key" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."goals" ADD COLUMN "metric_detail" jsonb;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."goals" ADD COLUMN "metric_computed_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
-- Grants (default privileges already cover new tasks tables; explicit for safety). Prod only.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON tasks.metric_sources TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Stan's income goal gets the auto metric (salary from money.recurring + Moneybird side income).
|
||||||
|
UPDATE tasks.goals SET metric_key = 'own-net-income-monthly'
|
||||||
|
WHERE metric_key IS NULL AND title ILIKE '€10k net/month into own Revolut%';
|
||||||
40016
drizzle/20261026011600_goal_metrics/snapshot.json
Normal file
40016
drizzle/20261026011600_goal_metrics/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
46
scripts/goal-metrics.ts
Normal file
46
scripts/goal-metrics.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Daily refresh of auto goal metrics (tasks.goals.metric_key):
|
||||||
|
// 1. trailing-12-month paid Moneybird sales invoices -> tasks.metric_sources
|
||||||
|
// 2. recompute every auto goal -> tasks.goals.metric_current/metric_detail (+ progress snapshot)
|
||||||
|
//
|
||||||
|
// bun --env-file=$HOME/.config/goal-metrics.env scripts/goal-metrics.ts [--dry-run]
|
||||||
|
// Env: DB_URL (app role), MONEYBIRD_API_TOKEN, MONEYBIRD_ADMINISTRATION_ID. Mode 600, never committed.
|
||||||
|
// Run by the goal-metrics.timer user unit (deploy/systemd). Page loads never call Moneybird.
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import {
|
||||||
|
SIDE_INCOME_SOURCE,
|
||||||
|
fetchMoneybirdSideIncome,
|
||||||
|
persistGoalMetrics,
|
||||||
|
saveSource
|
||||||
|
} from '../src/lib/server/goal-metrics/core';
|
||||||
|
|
||||||
|
const dry = process.argv.includes('--dry-run');
|
||||||
|
const { DB_URL, MONEYBIRD_API_TOKEN, MONEYBIRD_ADMINISTRATION_ID } = process.env;
|
||||||
|
if (!DB_URL) throw new Error('Set DB_URL');
|
||||||
|
const sql = postgres(DB_URL, { max: 1, connect_timeout: 10, onnotice: () => {} });
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
if (!MONEYBIRD_API_TOKEN || !MONEYBIRD_ADMINISTRATION_ID) {
|
||||||
|
failed = true;
|
||||||
|
console.error('[goal-metrics] Moneybird env missing; keeping the cached side income');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const mb = await fetchMoneybirdSideIncome(MONEYBIRD_API_TOKEN, MONEYBIRD_ADMINISTRATION_ID);
|
||||||
|
console.log(`[goal-metrics] Moneybird: ${mb.invoices} paid invoices ${mb.from}..${mb.to}`);
|
||||||
|
if (!dry) await saveSource(sql, SIDE_INCOME_SOURCE, mb.monthly, mb, null);
|
||||||
|
} catch (e) {
|
||||||
|
failed = true;
|
||||||
|
const msg = (e as Error).message.slice(0, 300);
|
||||||
|
console.error(`[goal-metrics] Moneybird failed: ${msg}`);
|
||||||
|
if (!dry) await saveSource(sql, SIDE_INCOME_SOURCE, null, null, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dry) {
|
||||||
|
for (const g of await persistGoalMetrics(sql))
|
||||||
|
console.log(
|
||||||
|
`[goal-metrics] goal ${g.id}: ${g.target ? ((g.value / g.target) * 100).toFixed(1) + '%' : 'no target'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
if (failed) process.exit(1);
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
date,
|
date,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
|
jsonb,
|
||||||
numeric,
|
numeric,
|
||||||
smallint,
|
smallint,
|
||||||
text,
|
text,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
import { tasksSchema } from './schemas';
|
import { tasksSchema } from './schemas';
|
||||||
import { id, timestamps, timestamptz } from './helpers';
|
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.
|
// 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.
|
// 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"
|
metricUnit: text('metric_unit'), // e.g. "EUR"
|
||||||
metricTarget: numeric('metric_target', { precision: 14, scale: 2, mode: 'number' }),
|
metricTarget: numeric('metric_target', { precision: 14, scale: 2, mode: 'number' }),
|
||||||
metricCurrent: numeric('metric_current', { 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),
|
flagged: boolean('flagged').notNull().default(false),
|
||||||
sortOrder: integer('sort_order').notNull().default(0),
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
|
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(
|
export const todoTasks = tasksSchema.table(
|
||||||
'tasks',
|
'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;
|
||||||
|
}
|
||||||
@@ -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 {
|
import {
|
||||||
TASK_STATUSES,
|
TASK_STATUSES,
|
||||||
todoActivity,
|
todoActivity,
|
||||||
@@ -30,12 +31,35 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
return {
|
return {
|
||||||
today: todayIn(),
|
today: todayIn(),
|
||||||
areas,
|
areas,
|
||||||
goals,
|
goals: await withLiveMetrics(goals),
|
||||||
|
metricKeys: GOAL_METRIC_KEYS.map((key) => ({ key, label: GOAL_METRICS[key].label })),
|
||||||
tasks,
|
tasks,
|
||||||
seo: { title: 'Tasks', description: 'Private task and goal tracker.', index: false }
|
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 id = (v: FormDataEntryValue | null) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isInteger(n) && n > 0 ? n : null;
|
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})`;
|
if (cause?.constraint_name) return `Not allowed (${cause.constraint_name})`;
|
||||||
return cause?.message || 'Could not save';
|
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 =>
|
const isStatus = (v: unknown): v is TaskStatus =>
|
||||||
typeof v === 'string' && (TASK_STATUSES as readonly string[]).includes(v);
|
typeof v === 'string' && (TASK_STATUSES as readonly string[]).includes(v);
|
||||||
|
|
||||||
@@ -211,12 +237,15 @@ export const actions: Actions = {
|
|||||||
metricUnit: g.metricUnit ?? null,
|
metricUnit: g.metricUnit ?? null,
|
||||||
metricTarget: g.metricTarget ?? null,
|
metricTarget: g.metricTarget ?? null,
|
||||||
metricCurrent: g.metricCurrent ?? null,
|
metricCurrent: g.metricCurrent ?? null,
|
||||||
|
metricKey: metricKeyOf(form.get('metricKey')),
|
||||||
flagged: g.flagged
|
flagged: g.flagged
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
if (goalId) {
|
if (goalId) {
|
||||||
const [prev] = await db.select().from(todoGoals).where(eq(todoGoals.id, goalId));
|
const [prev] = await db.select().from(todoGoals).where(eq(todoGoals.id, goalId));
|
||||||
if (!prev) return fail(404, { message: 'Goal not found' });
|
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;
|
const achievedAt = g.status === 'achieved' ? (prev.achievedAt ?? new Date()) : null;
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
import CircleDot from 'lucide-svelte/icons/circle-dot';
|
import CircleDot from 'lucide-svelte/icons/circle-dot';
|
||||||
import CircleCheck from 'lucide-svelte/icons/circle-check';
|
import CircleCheck from 'lucide-svelte/icons/circle-check';
|
||||||
import CircleX from 'lucide-svelte/icons/circle-x';
|
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 { SubmitFunction } from '@sveltejs/kit';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
@@ -196,6 +200,23 @@
|
|||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
}).format(n)
|
}).format(n)
|
||||||
: `${n.toLocaleString('en-GB')}${unit ? ` ${unit}` : ''}`;
|
: `${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) {
|
function targetLabel(day: string) {
|
||||||
const n = daysUntil(day);
|
const n = daysUntil(day);
|
||||||
const date = new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
|
const date = new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
|
||||||
@@ -214,6 +235,7 @@
|
|||||||
let goalDialog = $state(false);
|
let goalDialog = $state(false);
|
||||||
let editing = $state<Task | null>(null);
|
let editing = $state<Task | null>(null);
|
||||||
let editingGoal = $state<Goal | null>(null);
|
let editingGoal = $state<Goal | null>(null);
|
||||||
|
let goalMetricKey = $state('');
|
||||||
let confirmDelete = $state(false);
|
let confirmDelete = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let quickError = $state<string | null>(null);
|
let quickError = $state<string | null>(null);
|
||||||
@@ -227,6 +249,7 @@
|
|||||||
}
|
}
|
||||||
function openGoal(g: Goal | null = null) {
|
function openGoal(g: Goal | null = null) {
|
||||||
editingGoal = g;
|
editingGoal = g;
|
||||||
|
goalMetricKey = g?.metricKey ?? '';
|
||||||
confirmDelete = false;
|
confirmDelete = false;
|
||||||
error = null;
|
error = null;
|
||||||
goalDialog = true;
|
goalDialog = true;
|
||||||
@@ -479,7 +502,54 @@
|
|||||||
</p>
|
</p>
|
||||||
{#if goal.notes}<p class="notes mb-2">{goal.notes}</p>{/if}
|
{#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.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"
|
<small class="block"
|
||||||
>{goal.metric ?? 'Target'}: {goal.metricCurrent !== null
|
>{goal.metric ?? 'Target'}: {goal.metricCurrent !== null
|
||||||
? `${money(goal.metricCurrent, goal.metricUnit)} / `
|
? `${money(goal.metricCurrent, goal.metricUnit)} / `
|
||||||
@@ -704,10 +774,18 @@
|
|||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
<label class="grid gap-1 text-sm"
|
<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
|
<Input
|
||||||
name="metricCurrent"
|
name="metricCurrent"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
|
disabled={!!goalMetricKey}
|
||||||
value={editingGoal?.metricCurrent ?? ''}
|
value={editingGoal?.metricCurrent ?? ''}
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
@@ -920,6 +998,57 @@
|
|||||||
.goal:hover {
|
.goal:hover {
|
||||||
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
|
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 {
|
.goal.muted {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user