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:
59
src/lib/components/CompletionSparkline.svelte
Normal file
59
src/lib/components/CompletionSparkline.svelte
Normal 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}
|
||||||
Reference in New Issue
Block a user