Skip to content

Commit 4b7c9e1

Browse files
committed
Dashboard: Color-coded charts, 3-col layout
- Three-column responsive grid (3 → 2 → 1), wider max width (1800px) - Sticky slim header; range buttons reset chart zoom on click - Per-repo star charts: cmdr in gold, mtp-rs in purple - Color dots on metrics: gold for getcmdr.com, purple for mtp-rs, autumn green for veszelovszki.com - `Chart.svelte`: added `colors`, `xMin`/`xMax` props and scroll-to-zoom
1 parent 67efc4a commit 4b7c9e1

4 files changed

Lines changed: 131 additions & 35 deletions

File tree

apps/analytics-dashboard/CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ aggregate numbers. A true funnel would require cross-site user identity tracking
8989

9090
**Decision**: Dark mode only. **Why**: Internal tool, always viewed on a laptop. Saves effort.
9191

92+
**Decision**: Consistent color coding across the dashboard. **Why**: Visual clarity when scanning metrics.
93+
- **Gold (`#ffc206`)**: getcmdr.com / vdavid/cmdr — the primary product
94+
- **Purple (`#a78bfa`)**: vdavid/mtp-rs — the library repo
95+
- **Autumn green (`#8faa3b`)**: veszelovszki.com — David's personal site
96+
These colors are used in metric dots, chart strokes, and chart fills. Keep them consistent when adding new UI.
97+
9298
**Decision**: Single page, not multi-page. **Why**: Only six sections. Scroll is simpler than navigation.
9399

94100
**Decision**: uPlot for charts. **Why**: ~45 KB, fast canvas rendering, simple API. No wrapper needed.

apps/analytics-dashboard/src/lib/components/Chart.svelte

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,81 @@
88
data: uPlot.AlignedData
99
/** Series labels (excluding the timestamp series). */
1010
labels?: string[]
11+
/** Custom stroke colors per series. Falls back to gold/grey. */
12+
colors?: string[]
1113
/** Chart height in pixels. */
1214
height?: number
15+
/** Default X-axis min (unix seconds). Scroll to zoom overrides this. */
16+
xMin?: number | null
17+
/** Default X-axis max (unix seconds). Scroll to zoom overrides this. */
18+
xMax?: number | null
1319
}
1420
15-
let { data, labels = [], height = 200 }: Props = $props()
21+
let { data, labels = [], colors = [], height = 200, xMin = null, xMax = null }: Props = $props()
22+
23+
let zoomedXMin: number | null = $state(null)
24+
let zoomedXMax: number | null = $state(null)
25+
26+
// Reset zoom when external xMin/xMax change (e.g. range switch)
27+
$effect(() => {
28+
void xMin
29+
void xMax
30+
zoomedXMin = null
31+
zoomedXMax = null
32+
})
1633
1734
let container: HTMLDivElement
1835
let chart: uPlot | null = null
1936
37+
const defaultColors = ['#ffc206', '#a1a1aa', '#8b5cf6', '#10b981']
38+
39+
function colorAt(i: number): string {
40+
return colors[i] ?? defaultColors[i] ?? '#a1a1aa'
41+
}
42+
43+
function handleWheel(e: WheelEvent) {
44+
e.preventDefault()
45+
if (data[0].length < 2) return
46+
const dataXMin = data[0][0] as number
47+
const dataXMax = data[0][data[0].length - 1] as number
48+
const zoomFactor = e.deltaY > 0 ? 1.3 : 1 / 1.3
49+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
50+
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
51+
52+
const curMin = zoomedXMin ?? xMin ?? dataXMin
53+
const curMax = zoomedXMax ?? xMax ?? dataXMax
54+
const range = curMax - curMin
55+
const center = curMin + range * fraction
56+
const newRange = Math.min(range * zoomFactor, dataXMax - dataXMin)
57+
58+
if (newRange >= dataXMax - dataXMin) {
59+
zoomedXMin = null
60+
zoomedXMax = null
61+
} else {
62+
zoomedXMin = Math.max(dataXMin, center - newRange * fraction)
63+
zoomedXMax = Math.min(dataXMax, zoomedXMin + newRange)
64+
}
65+
}
66+
2067
function buildOpts(width: number): uPlot.Options {
2168
const series: uPlot.Series[] = [
2269
{}, // timestamp series (x-axis)
2370
...labels.map((label, i) => ({
2471
label,
25-
stroke: i === 0 ? '#ffc206' : '#a1a1aa',
72+
stroke: colorAt(i),
2673
width: 2,
27-
fill: i === 0 ? 'rgba(255, 194, 6, 0.08)' : undefined,
74+
fill: colorAt(i) + '14', // ~8% opacity
2875
})),
2976
]
3077
3178
// If no labels provided, add a default series
3279
if (labels.length === 0 && data.length > 1) {
3380
for (let i = 1; i < data.length; i++) {
81+
const c = colorAt(i - 1)
3482
series.push({
35-
stroke: i === 1 ? '#ffc206' : '#a1a1aa',
83+
stroke: c,
3684
width: 2,
37-
fill: i === 1 ? 'rgba(255, 194, 6, 0.08)' : undefined,
85+
fill: c + '14',
3886
})
3987
}
4088
}
@@ -68,11 +116,23 @@
68116
}
69117
}
70118
119+
function applyXScale() {
120+
if (!chart) return
121+
const effMin = zoomedXMin ?? xMin
122+
const effMax = zoomedXMax ?? xMax
123+
if (effMin != null && effMax != null) {
124+
chart.setScale('x', { min: effMin, max: effMax })
125+
} else if (data[0].length > 0) {
126+
chart.setScale('x', { min: data[0][0] as number, max: data[0][data[0].length - 1] as number })
127+
}
128+
}
129+
71130
function createChart() {
72131
if (!container || data[0].length === 0) return
73132
chart?.destroy()
74133
const opts = buildOpts(container.clientWidth)
75134
chart = new uPlot(opts, data, container)
135+
applyXScale()
76136
}
77137
78138
onMount(() => {
@@ -94,10 +154,17 @@
94154
95155
// Recreate chart when data changes
96156
$effect(() => {
97-
// Touch data to subscribe to it
98157
void data
99158
if (container) createChart()
100159
})
160+
161+
// Apply zoom when it changes
162+
$effect(() => {
163+
void zoomedXMin
164+
void zoomedXMax
165+
applyXScale()
166+
})
101167
</script>
102168

103-
<div bind:this={container} class="w-full"></div>
169+
<!-- svelte-ignore a11y_no_static_element_interactions -->
170+
<div bind:this={container} class="w-full" onwheel={handleWheel}></div>

apps/analytics-dashboard/src/lib/server/sources/umami.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export function parseUmamiMetrics(raw: unknown): UmamiMetricItem[] {
120120
}
121121

122122
export async function fetchUmamiData(env: UmamiEnv, range: TimeRange): Promise<SourceResult<UmamiData>> {
123-
const cached = await cacheGet<UmamiData>('umami', range)
123+
const cached = await cacheGet<UmamiData>('umami-v2', range)
124124
if (cached) return { ok: true, data: cached }
125125

126126
try {
@@ -137,7 +137,7 @@ export async function fetchUmamiData(env: UmamiEnv, range: TimeRange): Promise<S
137137
])
138138

139139
const data: UmamiData = { personalSite, website, websiteReferrers, websitePages, websiteCountries, downloadEvents }
140-
await cacheSet('umami', range, data)
140+
await cacheSet('umami-v2', range, data)
141141
return { ok: true, data }
142142
} catch (e) {
143143
return { ok: false, error: `Umami: ${e instanceof Error ? e.message : String(e)}` }

apps/analytics-dashboard/src/routes/+page.svelte

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@
8181
const ranges = ['24h', '7d', '30d'] as const
8282
const downloadSyncKey = 'dl-timelines'
8383
84+
// Color palette
85+
const COLOR_GOLD = '#ffc206'
86+
const COLOR_PURPLE = '#a78bfa'
87+
const COLOR_GREEN = '#8faa3b' // autumn-y green for veszelovszki.com
88+
89+
/** Time range in seconds for the selected range. Used as default zoom for star charts. */
90+
const rangeSeconds: Record<string, number> = { '24h': 86400, '7d': 7 * 86400, '30d': 30 * 86400 }
91+
function starChartXMin(): number {
92+
return Date.now() / 1000 - (rangeSeconds[data.range] ?? 7 * 86400)
93+
}
94+
8495
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
8596
function formatCountry(code: string): string {
8697
const name = regionNames.of(code.toUpperCase())
@@ -153,23 +164,23 @@
153164
}
154165
</script>
155166

156-
<div class="mx-auto max-w-6xl px-6 py-8">
157-
<!-- Header -->
158-
<header class="mb-8 flex flex-wrap items-center justify-between gap-4">
159-
<h1 class="text-2xl font-bold text-text-primary">Cmdr analytics</h1>
167+
<div class="mx-auto max-w-[1800px] px-6 pb-8 pt-14">
168+
<!-- Header (sticky) -->
169+
<header class="fixed inset-x-0 top-0 z-40 flex items-center justify-between gap-4 border-b border-border bg-surface/90 px-6 py-2 backdrop-blur-sm">
170+
<h1 class="text-lg font-bold text-text-primary">Cmdr analytics</h1>
160171

161172
<div class="flex items-center gap-3">
162173
<div class="flex rounded-lg border border-border bg-surface p-0.5">
163174
{#each ranges as r}
164-
<a
165-
href="?range={r}"
166-
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors
175+
<button
176+
onclick={() => { zoomXMin = null; zoomXMax = null; if (r !== data.range) window.location.href = `?range=${r}` }}
177+
class="rounded-md px-3 py-1 text-sm font-medium transition-colors
167178
{data.range === r
168179
? 'bg-accent text-accent-contrast'
169180
: 'text-text-secondary hover:text-text-primary'}"
170181
>
171182
{r}
172-
</a>
183+
</button>
173184
{/each}
174185
</div>
175186
<span class="text-xs text-text-tertiary">
@@ -179,7 +190,7 @@
179190
</header>
180191

181192
<!-- Sections -->
182-
<div class="space-y-8">
193+
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
183194
<!-- 1. Awareness -->
184195
<section class="rounded-xl border border-border bg-surface p-6">
185196
<div class="mb-4">
@@ -197,8 +208,8 @@
197208

198209
{@render metricRow([
199210
{ label: 'Total page views', value: formatNumber(totalPageviews), delta },
200-
{ label: 'veszelovszki.com views', value: formatNumber(umami.personalSite.pageviews.value) },
201-
{ label: 'getcmdr.com views', value: formatNumber(umami.website.pageviews.value) },
211+
{ label: 'veszelovszki.com views', value: formatNumber(umami.personalSite.pageviews.value), color: COLOR_GREEN },
212+
{ label: 'getcmdr.com views', value: formatNumber(umami.website.pageviews.value), color: COLOR_GOLD },
202213
])}
203214

204215
{#if umami.websiteReferrers.length > 0}
@@ -210,23 +221,30 @@
210221

211222
{#if data.githubStars.ok}
212223
{@const stars = data.githubStars.data}
224+
{@const repoColors: Record<string, string> = { 'vdavid/cmdr': COLOR_GOLD, 'vdavid/mtp-rs': COLOR_PURPLE }}
213225
<div class="mt-4">
214226
<h3 class="mb-2 text-sm font-medium text-text-secondary">GitHub stars</h3>
215227
{@render metricRow(
216-
stars.repos.map((r) => ({ label: r.repo, value: formatNumber(r.totalStars) }))
228+
stars.repos.map((r) => ({ label: r.repo, value: formatNumber(r.totalStars), color: repoColors[r.repo] }))
217229
)}
218-
{#if stars.combinedDaily.length > 1}
219-
<div class="mt-2">
220-
<Chart
221-
data={[
222-
stars.combinedDaily.map((d) => new Date(d.day).getTime() / 1000),
223-
stars.combinedDaily.map((d) => d.cumulative),
224-
]}
225-
labels={['Total stars']}
226-
height={160}
227-
/>
228-
</div>
229-
{/if}
230+
{#each stars.repos as repo}
231+
{@const c = repoColors[repo.repo] ?? COLOR_GOLD}
232+
{#if repo.daily.length > 1}
233+
<div class="mt-2">
234+
<Chart
235+
data={[
236+
repo.daily.map((d) => new Date(d.day).getTime() / 1000),
237+
repo.daily.map((d) => d.cumulative),
238+
]}
239+
labels={[repo.repo]}
240+
colors={[c]}
241+
height={120}
242+
xMin={starChartXMin()}
243+
xMax={Date.now() / 1000}
244+
/>
245+
</div>
246+
{/if}
247+
{/each}
230248
</div>
231249
{/if}
232250

@@ -605,11 +623,16 @@
605623
</div>
606624
{/snippet}
607625

608-
{#snippet metricRow(metrics: Array<{ label: string; value: string; delta?: { text: string; positive: boolean } }>)}
626+
{#snippet metricRow(metrics: Array<{ label: string; value: string; delta?: { text: string; positive: boolean }; color?: string }>)}
609627
<div class="flex flex-wrap gap-6">
610628
{#each metrics as metric}
611629
<div>
612-
<p class="text-xs text-text-tertiary">{metric.label}</p>
630+
<p class="flex items-center gap-1.5 text-xs text-text-tertiary">
631+
{#if metric.color}
632+
<span class="inline-block h-2 w-2 rounded-full" style="background: {metric.color}"></span>
633+
{/if}
634+
{metric.label}
635+
</p>
613636
<div class="flex items-baseline gap-2">
614637
<p class="text-2xl font-bold tabular-nums text-text-primary">{metric.value}</p>
615638
{#if metric.delta}

0 commit comments

Comments
 (0)