feat(frontend): add ProgressRing component — SVG progress arc with percentage label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-19 23:34:30 +02:00
parent 6e888d9958
commit ae0e3b271d
2 changed files with 70 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
<script lang="ts">
let { percentage }: { percentage: number } = $props();
</script>
<div class="flex flex-col items-center">
<svg width="36" height="36" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="7" fill="none" stroke="var(--c-line)" stroke-width="2" />
<circle
class="fill-arc"
cx="10"
cy="10"
r="7"
fill="none"
stroke="var(--c-accent)"
stroke-width="2"
stroke-linecap="round"
transform="rotate(-90 10 10)"
stroke-dasharray="{(percentage / 100) * 43.98} 43.98"
/>
</svg>
<span
class="block text-center font-sans text-[10px] font-bold {percentage > 0 ? 'text-accent' : 'text-gray-400'}"
>
{percentage}%
</span>
</div>

View File

@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ProgressRing from './ProgressRing.svelte';
afterEach(cleanup);
describe('ProgressRing', () => {
it('renders the correct stroke-dasharray for 75%', async () => {
render(ProgressRing, { percentage: 75 });
const arc = document.querySelector('circle.fill-arc') as SVGCircleElement | null;
expect(arc).not.toBeNull();
// circumference = 2 * π * 7 ≈ 43.98; 75% of that ≈ 32.99
const dasharray = arc!.getAttribute('stroke-dasharray') ?? '';
const filled = parseFloat(dasharray.split(' ')[0]);
expect(filled).toBeCloseTo(32.99, 1);
});
it('renders a gray label when percentage is 0', async () => {
render(ProgressRing, { percentage: 0 });
const label = page.getByText('0%');
await expect.element(label).toBeInTheDocument();
// Label should carry the gray class, not the mint class
const el = (await label.element()) as HTMLElement;
expect(el.className).toContain('text-gray-400');
});
it('renders a mint-colored label when percentage is > 0', async () => {
render(ProgressRing, { percentage: 75 });
const label = page.getByText('75%');
await expect.element(label).toBeInTheDocument();
const el = (await label.element()) as HTMLElement;
expect(el.className).toContain('text-accent');
});
it('renders a fully filled arc for 100%', async () => {
render(ProgressRing, { percentage: 100 });
const arc = document.querySelector('circle.fill-arc') as SVGCircleElement | null;
expect(arc).not.toBeNull();
const dasharray = arc!.getAttribute('stroke-dasharray') ?? '';
const filled = parseFloat(dasharray.split(' ')[0]);
expect(filled).toBeCloseTo(43.98, 1);
});
});