<script setup lang="ts">
import { computed, useId } from 'vue'
export interface Stat {
label: string
/** Display value, already formatted (e.g. "$48,210" or "3.4%"). */
value: string
/** Change versus the previous period, in percent. Positive or negative. */
delta: number
/** Data points for the sparkline, oldest first. Two or more points. */
trend: number[]
/** Set to 'down' for metrics where a decrease is good (churn, response time). */
goodWhen?: 'up' | 'down'
}
const props = withDefaults(defineProps<{
stats?: Stat[]
heading?: string
/** Shown under the heading and used in the delta's accessible label. */
period?: string
}>(), {
heading: 'Overview',
period: 'Last 30 days',
stats: () => [
{ label: 'Monthly recurring revenue', value: '$48,210', delta: 12.4, trend: [31, 33, 32, 35, 36, 38, 37, 40, 42, 41, 45, 48] },
{ label: 'Active workspaces', value: '2,914', delta: 4.1, trend: [2410, 2480, 2520, 2500, 2610, 2650, 2700, 2690, 2760, 2810, 2870, 2914] },
{ label: 'Churn rate', value: '1.9%', delta: -0.6, goodWhen: 'down', trend: [2.8, 2.7, 2.6, 2.6, 2.4, 2.5, 2.3, 2.2, 2.1, 2.1, 2.0, 1.9] },
{ label: 'Avg. first response', value: '2h 14m', delta: 8.3, goodWhen: 'down', trend: [118, 122, 120, 126, 125, 131, 128, 129, 134, 131, 136, 134] },
],
})
const uid = useId()
const W = 120
const H = 36
function paths(points: number[]) {
const min = Math.min(...points)
const max = Math.max(...points)
const span = max - min || 1
const step = points.length > 1 ? W / (points.length - 1) : 0
// Leave 3px of room at the top and bottom so the stroke is never clipped.
const xy = points.map((p, i) => [i * step, H - 3 - ((p - min) / span) * (H - 6)] as const)
const line = xy.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ')
return { line, area: `${line} L${W} ${H} L0 ${H} Z` }
}
const cards = computed(() => props.stats.map((stat, i) => {
const up = stat.delta >= 0
const good = stat.delta === 0 ? null : (stat.goodWhen ?? 'up') === 'up' ? up : !up
return { stat, up, good, id: `${uid}-g${i}`, ...paths(stat.trend) }
}))
const fmtDelta = (d: number) => `${d > 0 ? '+' : d < 0 ? 'โ' : ''}${Math.abs(d).toFixed(1)}%`
</script>
<template>
<section>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ heading }}</h2>
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{{ period }}</p>
</div>
<slot name="actions" />
</div>
<dl class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div
v-for="c in cards"
:key="c.stat.label"
class="relative flex flex-col overflow-hidden rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:shadow-none dark:ring-white/10"
>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ c.stat.label }}</dt>
<dd class="mt-2 flex items-baseline justify-between gap-3">
<span class="text-3xl font-semibold tracking-tight text-gray-900 tabular-nums dark:text-white">{{ c.stat.value }}</span>
<span
class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"
:class="c.good === null
? 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300'
: c.good
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300'
: 'bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-300'"
>
<svg v-if="c.stat.delta !== 0" viewBox="0 0 12 12" aria-hidden="true" class="size-3 fill-current" :class="!c.up && 'rotate-180'">
<path d="M6 2.5 10 8H2l4-5.5Z" />
</svg>
<span class="sr-only">{{ c.up ? 'Increased' : 'Decreased' }} by</span>
{{ fmtDelta(c.stat.delta) }}
<span class="sr-only">compared to the previous period ({{ c.good === null ? 'no change' : c.good ? 'good' : 'needs attention' }})</span>
</span>
</dd>
<dd class="mt-4">
<svg
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
class="h-10 w-full overflow-visible text-blue-600 dark:text-blue-400"
role="img"
:aria-label="`Trend for ${c.stat.label}, ${c.stat.trend.length} data points, from ${c.stat.trend[0]} to ${c.stat.trend[c.stat.trend.length - 1]}`"
>
<defs>
<linearGradient :id="c.id" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.25" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
<g>
<path :d="c.area" :fill="`url(#${c.id})`" />
<path :d="c.line" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke" />
</g>
</svg>
</dd>
</div>
</dl>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import StatsCards, { type Stat } from './Component.vue'
type Range = '7d' | '30d' | '90d'
const range = ref<Range>('30d')
const labels: Record<Range, string> = { '7d': 'Last 7 days', '30d': 'Last 30 days', '90d': 'Last 90 days' }
const data: Record<Range, Stat[] | undefined> = {
'7d': [
{ label: 'Monthly recurring revenue', value: '$48,210', delta: 2.1, trend: [46.9, 47.1, 47.0, 47.4, 47.8, 48.0, 48.2] },
{ label: 'Active workspaces', value: '2,914', delta: -0.4, trend: [2926, 2931, 2920, 2918, 2909, 2911, 2914] },
{ label: 'Churn rate', value: '1.9%', delta: 0, goodWhen: 'down', trend: [1.9, 1.9, 2.0, 1.9, 1.9, 1.9, 1.9] },
{ label: 'Avg. first response', value: '1h 52m', delta: -11.5, goodWhen: 'down', trend: [131, 128, 124, 120, 117, 115, 112] },
],
'30d': undefined, // use the component's defaults
'90d': [
{ label: 'Monthly recurring revenue', value: '$48,210', delta: 31.8, trend: [36, 37, 37, 39, 40, 41, 42, 43, 44, 45, 47, 48] },
{ label: 'Active workspaces', value: '2,914', delta: 18.6, trend: [2450, 2500, 2540, 2600, 2650, 2690, 2740, 2780, 2820, 2860, 2890, 2914] },
{ label: 'Churn rate', value: '1.9%', delta: -1.4, goodWhen: 'down', trend: [3.3, 3.1, 3.0, 2.8, 2.7, 2.5, 2.4, 2.2, 2.1, 2.0, 2.0, 1.9] },
{ label: 'Avg. first response', value: '2h 14m', delta: 4.9, goodWhen: 'down', trend: [127, 125, 129, 128, 130, 127, 131, 132, 130, 133, 135, 134] },
],
}
const stats = computed(() => data[range.value])
const acquisition: Stat[] = [
{ label: 'Site visitors', value: '84,390', delta: 6.2, trend: [62, 64, 70, 66, 71, 75, 73, 78, 80, 79, 82, 84] },
{ label: 'Trial signups', value: '1,284', delta: 9.8, trend: [88, 92, 95, 90, 101, 104, 99, 108, 112, 110, 118, 121] },
{ label: 'Trial to paid', value: '18.2%', delta: -1.3, trend: [19.8, 19.5, 19.9, 19.2, 19.0, 18.8, 19.1, 18.6, 18.4, 18.5, 18.1, 18.2] },
{ label: 'Cost per acquisition', value: '$41.60', delta: -3.7, goodWhen: 'down', trend: [46, 45, 45.5, 44, 44.2, 43.5, 43, 42.8, 42.1, 42.4, 41.9, 41.6] },
]
</script>
<template>
<main class="min-h-screen bg-gray-50 px-4 py-12 sm:px-6 lg:px-8 dark:bg-gray-950">
<div class="mx-auto max-w-6xl">
<StatsCards :stats="stats" :period="labels[range]">
<template #actions>
<div role="radiogroup" aria-label="Date range" class="inline-flex rounded-lg bg-white p-1 text-sm font-medium ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<button
v-for="r in (['7d', '30d', '90d'] as const)"
:key="r"
type="button"
role="radio"
:aria-checked="range === r"
class="rounded-md px-3 py-1.5 transition focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-600"
:class="range === r ? 'bg-blue-600 text-white dark:bg-blue-500' : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'"
@click="range = r"
>{{ r }}</button>
</div>
</template>
</StatsCards>
<StatsCards class="mt-12" heading="Acquisition" period="Last 12 weeks" :stats="acquisition" />
</div>
</main>
</template>