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

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