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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user