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;
|
||||
}
|
||||
@@ -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 {
|
||||
TASK_STATUSES,
|
||||
todoActivity,
|
||||
@@ -30,12 +31,35 @@ export const load: PageServerLoad = async (event) => {
|
||||
return {
|
||||
today: todayIn(),
|
||||
areas,
|
||||
goals,
|
||||
goals: await withLiveMetrics(goals),
|
||||
metricKeys: GOAL_METRIC_KEYS.map((key) => ({ key, label: GOAL_METRICS[key].label })),
|
||||
tasks,
|
||||
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 n = Number(v);
|
||||
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})`;
|
||||
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 =>
|
||||
typeof v === 'string' && (TASK_STATUSES as readonly string[]).includes(v);
|
||||
|
||||
@@ -211,12 +237,15 @@ export const actions: Actions = {
|
||||
metricUnit: g.metricUnit ?? null,
|
||||
metricTarget: g.metricTarget ?? null,
|
||||
metricCurrent: g.metricCurrent ?? null,
|
||||
metricKey: metricKeyOf(form.get('metricKey')),
|
||||
flagged: g.flagged
|
||||
};
|
||||
try {
|
||||
if (goalId) {
|
||||
const [prev] = await db.select().from(todoGoals).where(eq(todoGoals.id, goalId));
|
||||
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;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
|
||||
@@ -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