feat(gamification): CompletionSparkline component

Tiny inline SVG. Seven bars, zero-days render as 1 px baseline stubs.
fill=currentColor so the parent's text colour (tertiary ink on the
Tasks header) drives it. Self-hides if all 7 days are zero.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 20:41:51 +01:00
parent 4ffdae9838
commit 54ddd41265

View File

@@ -0,0 +1,59 @@
<script lang="ts">
// Phase 8. Tiny inline SVG sparkline. 7 bars, oldest to newest.
// Zero-days render as a 1 px baseline stub (never a gap, never zero
// height). The component self-hides when all bars are zero. Caller
// still guards on the user's settings toggle.
import type { DailyCompletionCount } from "$lib/types/app";
interface Props {
data: DailyCompletionCount[];
/** Total width in px. Default 80. */
width?: number;
/** Total height in px. Default 16. */
height?: number;
}
let { data, width = 80, height = 16 }: Props = $props();
const BAR_GAP = 2;
let maxCount = $derived(Math.max(1, ...data.map((d) => d.count)));
let barWidth = $derived(
(width - BAR_GAP * Math.max(0, data.length - 1)) / Math.max(1, data.length),
);
let ariaLabel = $derived.by(() => {
if (data.length === 0) return "";
const nums = data.map((d) => d.count).join(", ");
return `Tasks completed over the last ${data.length} days: ${nums}`;
});
let hasAnyCompletion = $derived(data.some((d) => d.count > 0));
</script>
{#if hasAnyCompletion}
<svg
{width}
{height}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={ariaLabel}
class="text-text-tertiary"
>
{#each data as d, i}
{@const x = i * (barWidth + BAR_GAP)}
{@const proportion = d.count / maxCount}
{@const barHeight = Math.max(1, Math.round(proportion * height))}
{@const y = height - barHeight}
<rect
{x}
{y}
width={barWidth}
height={barHeight}
fill="currentColor"
opacity={d.count === 0 ? 0.35 : 0.85}
rx="1"
/>
{/each}
</svg>
{/if}