Skip to content

Commit 83eb55b

Browse files
committed
analdash: split the dashboard into Acquisition, Product, and Link codes pages
The single 1220-line `+page.svelte` had grown too dense to navigate or edit safely. Reorganize it into a multi-page app under a shared layout, with no behavior change — same charts, blurbs, tooltips, and range/day selection, just split and componentized. - Three routes under one `+layout.svelte` shell (sticky header: brand, page nav, range/day picker): - `/` Acquisition: daily funnel + channels, awareness, interest, download. - `/product` Product: active use, payment, retention, feedback & errors. - `/links` Link codes: a stub (heading + "coming soon") so the skeleton and nav entry exist; the `?r=` short-link CRUD lands here next. The picker is hidden here (no time window applies). - Nav marks the active page (`aria-current="page"` + accent), sentence case, reusing existing tokens. - Shared selection: `+layout.server.ts` resolves the `DashboardSelection` from `?range=`/`?day=` for the picker, and each page's `+page.server.ts` re-resolves it for its own fetch, so switching pages preserves the selection. The funnel stays a fixed last-30-days view. - Data loading splits per page: `fetch-all.ts` now exposes per-source loaders plus `fetchAcquisitionData` / `fetchProductData` composers, so each page fetches only the sources it renders. The Cloudflare source (downloads + heartbeat + update activity) is shared by both pages but cached per selection, so it's one fetch, not two. `fetchDashboardData` (all sources) still backs the report endpoint unchanged. - Componentized the giant page into `lib/components/sections/*` plus shared UI (FunnelTable, CountryTable, MetricRow/MetricTable, ErrorState/EmptyState/BetaEmptyState, SectionDescription, Methodology, ExternalLinks) and client-safe helpers (`format.ts`, `colors.ts`, `chart-helpers.ts`). Each route file is now a few dozen lines. - Tests: add `fetch-all.subset.test.ts` pinning that each page loads exactly its source subset (and the report loads the union). All 88 dashboard tests pass; svelte-check and `pnpm build` are clean. - Docs: rewrite the page map in `CLAUDE.md` and add `DETAILS.md` (structure, the loading split, the shared-Cloudflare-cache rationale, and the client/server import-boundary gotcha that only `pnpm build` catches, not svelte-check).
1 parent f2b2c46 commit 83eb55b

32 files changed

Lines changed: 2058 additions & 1291 deletions

apps/analytics-dashboard/CLAUDE.md

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
# Analytics dashboard
22

3-
Private SvelteKit dashboard consolidating Cmdr business metrics into a single view, organized by acquisition stage.
4-
Deployed to Cloudflare Pages at `analdash.getcmdr.com`. Auth via Cloudflare Access (zero-trust, no application code).
3+
Private SvelteKit dashboard consolidating Cmdr business metrics, organized by acquisition stage across a few pages under
4+
a shared top nav. Deployed to Cloudflare Pages at `analdash.getcmdr.com`. Auth via Cloudflare Access (zero-trust, no
5+
application code).
6+
7+
## Pages and where each section lives
8+
9+
Three routes share `+layout.svelte` (sticky header: brand, nav, and the range/day picker). The picker is hidden on
10+
`/links`. Full structure, the data-loading split, and the componentization are in `DETAILS.md` § "Multi-page structure".
11+
12+
- `/` (Acquisition, `routes/+page.svelte`): daily funnel + channels, awareness, interest, download.
13+
- `/product` (Product, `routes/product/+page.svelte`): active use, payment, retention, feedback & errors.
14+
- `/links` (Link codes, `routes/links/+page.svelte`): a stub for `?r=` short-link CRUD, filled in later. No data load.
15+
16+
Each section is a component under `src/lib/components/sections/`; shared bits (funnel table, country table, metric
17+
row/table, state panels, descriptions) are in `src/lib/components/`. Don't import `$lib/server/*` as a runtime value
18+
into any of these (browser-bundled) — type-only is fine. The build's `vite-plugin-sveltekit-guard` enforces this;
19+
svelte-check does NOT catch it, so run `pnpm build` before declaring a client/server-boundary change done.
520

621
## Stack
722

@@ -12,21 +27,27 @@ Deployed to Cloudflare Pages at `analdash.getcmdr.com`. Auth via Cloudflare Acce
1227

1328
## Key files
1429

15-
| File | Purpose |
16-
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
17-
| `src/app.css` | Tailwind v4 theme (dark palette matching getcmdr.com) |
18-
| `src/app.d.ts` | Platform env type declarations for CF Pages |
19-
| `src/routes/+page.svelte` | Single-page dashboard: a top "Daily funnel" table, then 6 acquisition stages plus feedback & errors |
20-
| `src/routes/+page.server.ts` | Server load: reads `?range=` and `?day=` params, delegates to `fetch-all.ts` |
21-
| `src/routes/api/report/+server.ts` | Agent-readable plain-text report (includes the daily funnel) with all breakdowns |
22-
| `src/lib/server/fetch-all.ts` | Shared data-fetching logic used by both the page and report API |
23-
| `src/lib/components/Chart.svelte` | Reusable uPlot chart with ResizeObserver and dark theme |
24-
| `src/lib/components/StackedBarChart.svelte` | Discrete per-day stacked bars (plain elements, not uPlot) with an exact-numbers hover/focus tooltip. Used for the by-source new-installs chart and the by-version update chart |
25-
| `src/lib/server/types.ts` | Shared types: `TimeRange`, `DashboardSelection`, `SourceResult`, time window + selection helpers |
26-
| `src/lib/server/cache.ts` | CF Cache API wrapper with in-memory Map fallback for local dev |
27-
| `src/lib/server/sources/` | Data source modules (one per external API) |
28-
| `svelte.config.js` | Adapter-cloudflare config |
29-
| `vitest.config.ts` | Vitest config for unit tests |
30+
- `src/app.css`: Tailwind v4 theme (dark palette matching getcmdr.com).
31+
- `src/app.d.ts`: Platform env type declarations for CF Pages.
32+
- `src/routes/+layout.svelte`: shared shell — sticky header, page nav, and the range/day picker (hidden on `/links`).
33+
- `src/routes/+layout.server.ts`: resolves the shared `DashboardSelection` from `?range=` / `?day=` once for the layout.
34+
- `src/routes/+page.{svelte,server.ts}`: Acquisition page; loads the funnel/Umami/Cloudflare/GitHub/PostHog subset.
35+
- `src/routes/product/+page.{svelte,server.ts}`: Product page; loads the Cloudflare/Paddle/license/feedback subset.
36+
- `src/routes/links/+page.svelte`: Link codes stub (no data load).
37+
- `src/routes/api/report/+server.ts`: agent-readable plain-text report (all sections, via `fetchDashboardData`).
38+
- `src/lib/server/fetch-all.ts`: per-source loaders plus the per-page composers (`fetchAcquisitionData`,
39+
`fetchProductData`) and the all-sources `fetchDashboardData` for the report.
40+
- `src/lib/components/sections/`: one component per dashboard section.
41+
- `src/lib/components/`: shared UI (FunnelTable, CountryTable, MetricRow/MetricTable, ErrorState/EmptyState/
42+
BetaEmptyState, SectionDescription, Methodology, ExternalLinks, Chart, StackedBarChart, MiniTimeline, PieChart).
43+
- `src/lib/{format,colors,chart-helpers}.ts`: client-safe formatters, color tokens, and chart data-shaping helpers.
44+
- `StackedBarChart.svelte`: discrete per-day stacked bars (plain elements, not uPlot) with an exact-numbers hover/focus
45+
tooltip; used for the by-source new-installs chart and the by-version update chart.
46+
- `src/lib/server/types.ts`: shared types: `TimeRange`, `DashboardSelection`, `SourceResult`, time window + selection
47+
helpers.
48+
- `src/lib/server/cache.ts`: CF Cache API wrapper with in-memory Map fallback for local dev.
49+
- `src/lib/server/sources/`: data source modules (one per external API).
50+
- `svelte.config.js`: adapter-cloudflare config. `vitest.config.ts`: Vitest config.
3051

3152
## Running locally
3253

@@ -157,7 +178,10 @@ so no Listmonk secret reaches the dashboard.
157178

158179
These colors are used in metric dots, chart strokes, and chart fills. Keep them consistent when adding new UI.
159180

160-
**Decision**: Single page, not multi-page. **Why**: A handful of sections. Scroll is simpler than navigation.
181+
**Decision**: Split across pages (Acquisition, Product, Link codes) under a shared layout, not one long scroll. **Why**:
182+
the single page grew too dense. Grouping by stage keeps each route readable and lets each page fetch only the sources it
183+
renders. The range/day selection stays shared (resolved in the layout, carried in the URL), so switching pages preserves
184+
it. See `DETAILS.md` § "Multi-page structure".
161185

162186
**Decision**: A "Feedback & errors" section reads the app's own stores via two worker admin endpoints (`/admin/feedback`
163187
from D1, `/admin/error-reports` from the R2 bucket's `list` with `customMetadata`), not Discord. **Why**: the
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Analytics dashboard — details
2+
3+
Read this before structural changes. `CLAUDE.md` is the always-loaded summary; this is the depth.
4+
5+
## Multi-page structure
6+
7+
The dashboard is three routes under one shared layout. Each section is its own component; the route files just compose
8+
sections and pass their loaded data down.
9+
10+
### Routes and sections
11+
12+
- `/` — Acquisition (`routes/+page.svelte`), in render order:
13+
- Daily funnel + Channels (`components/FunnelTable.svelte`) — always the last 30 UTC days, independent of the picker.
14+
- Awareness (`components/sections/AwarenessSection.svelte`).
15+
- Interest (`components/sections/InterestSection.svelte`).
16+
- Download (`components/sections/DownloadSection.svelte`), with `CountryTable.svelte` and the per-day pie tooltip.
17+
- `/product` — Product (`routes/product/+page.svelte`):
18+
- Active use (`ActiveUseSection.svelte`), Payment (`PaymentSection.svelte`), Retention (`RetentionSection.svelte`),
19+
Feedback & errors (`FeedbackErrorsSection.svelte`).
20+
- `/links` — Link codes (`routes/links/+page.svelte`): a stub. The `?r=` short-link CRUD lands here later; for now it's
21+
a heading + a "coming soon" note, no data load. The layout hides the range/day picker here.
22+
23+
### Shared layout
24+
25+
`routes/+layout.svelte` is the only shell: a sticky header with the brand, the page nav (Acquisition / Product / Link
26+
codes, active page marked with `aria-current="page"` and the accent background), and the range/day picker. The picker
27+
writes `?range=` / `?day=` and keeps the current pathname (`${pathname}?range=...`), so switching range on `/product`
28+
stays on `/product`. It's hidden on `/links`. The "Updated HH:MM" stamp reads `page.data.updatedAt` (set by whichever
29+
data page is active; absent on `/links`).
30+
31+
`rangeButtons` (the picker's `today/24h/7d/30d` button list) lives as a local const in the layout, NOT in
32+
`$lib/server/types.ts` — see the boundary gotcha below.
33+
34+
## Data-loading split
35+
36+
`fetch-all.ts` is structured as:
37+
38+
1. **Per-source loaders** (`fetchFunnelSource`, `fetchUmamiSource`, `fetchCloudflareSource`, …): each wraps one source
39+
with its env-var guard (`guardedFetch`) and the 20s `withTimeout` cap. One place per source.
40+
2. **Per-page composers**: `fetchAcquisitionData` runs funnel + Umami + Cloudflare + GitHub + GitHub-stars + PostHog in
41+
parallel; `fetchProductData` runs Cloudflare + Paddle + license + feedback-and-errors. Each page's `+page.server.ts`
42+
re-resolves the selection from the URL and calls its composer, so a page fetches only what it renders.
43+
3. **`fetchDashboardData`**: all nine sources, used by the report endpoint (`api/report/+server.ts`) which dumps every
44+
section at once. Unchanged contract.
45+
46+
The funnel is always 30 days (`fetchFunnelData` ignores the selection); it's still gated on the worker admin token, with
47+
Umami and Paddle degrading to dashes inside. Each source returns `SourceResult<T>` (ok+data, or an error string the UI
48+
shows as "Couldn't load this data").
49+
50+
### The Cloudflare source is shared by both pages
51+
52+
`fetchCloudflareData` returns `downloads` (Download section, on `/`) AND `heartbeatDau` + `updateActivity` (Active use,
53+
on `/product`) in one fetch. Both pages call `fetchCloudflareSource`. That's intentional, not duplication: the source is
54+
cached per selection in `cache.ts`, so the two page loads share one cache entry rather than re-fetching. Don't split the
55+
worker endpoints apart to "load less" — it would fragment the cache key and the 401/timeout degradation, for no gain.
56+
57+
## Selection state
58+
59+
The shared `DashboardSelection` (`{ range, day }`) is resolved from `?range=` / `?day=` in `+layout.server.ts` (for the
60+
picker UI) and again in each `+page.server.ts` (for that page's fetch); both call the same `resolveSelection`, so they
61+
agree. A valid `?day=YYYY-MM-DD` forces `range: 'day'`. The funnel row click on `/` navigates to `/?day=<date>`, which
62+
filters the Acquisition sections to that day and highlights the row. Cache keys include the day (`selectionCacheKey`) so
63+
two picked days never collide. The whole rationale (why a `DashboardSelection` not a bare `TimeRange`, the coarse-window
64+
mapping for worker/PostHog sources) is in `CLAUDE.md` § "Key decisions".
65+
66+
## Componentization
67+
68+
- Section components take their `SourceResult<…>` props plus `selection` (so `ErrorState` can build the "Try again"
69+
link). They own their own local interaction state — e.g. `DownloadSection` owns the timeline zoom window and the
70+
per-day pie tooltip; `CountryTable` owns its hover tooltip and shares the parent's zoom window via props.
71+
- Shared presentational pieces: `MetricRow`, `MetricTable`, `SectionDescription` (the "what + how reliable" blurb),
72+
`Methodology` (the "how this is measured" note), `ErrorState` / `EmptyState` / `BetaEmptyState`, `ExternalLinks`.
73+
- Pure data shaping lives in `$lib/chart-helpers.ts` (stacking, `aggregateBy`, `buildTimeline`, semver compare, etc.),
74+
formatting in `$lib/format.ts`, and color tokens in `$lib/colors.ts`. These are client-safe modules.
75+
76+
## Client/server boundary gotcha
77+
78+
SvelteKit forbids importing `$lib/server/*` as a runtime value into browser-bundled code (components, route `.svelte`
79+
files). Type-only imports (`import type { DashboardSelection, SourceResult } from '$lib/server/types.js'`) are fine. A
80+
runtime value import (e.g. a `const`) trips `vite-plugin-sveltekit-guard` at BUILD time — and `svelte-check` does NOT
81+
catch it. So always run `pnpm build`, not just `pnpm check`, after touching imports across that boundary. Client-shared
82+
runtime values live outside `$lib/server`: `$lib/funnel.ts`, `$lib/feedback-and-errors.ts`, `$lib/format.ts`,
83+
`$lib/colors.ts`, `$lib/chart-helpers.ts`.
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Pure data-shaping helpers for the Download and Active use charts: stacking download/update rows by
3+
* day, aggregating by a field, and building per-day timelines. Kept out of the Svelte components so
4+
* the section components stay focused on markup. `DownloadRow`/`UpdateActivityRow` are server types
5+
* but the shapes are plain data, safe to import into client code.
6+
*/
7+
import type { DownloadRow, UpdateActivityRow } from '$lib/server/sources/cloudflare.js'
8+
9+
export interface StackSeries {
10+
key: string
11+
label: string
12+
color: string
13+
values: number[]
14+
}
15+
16+
// Download source colors: website is the product gold, Homebrew an amber, everything else grey.
17+
export const SOURCE_STACK = [
18+
{ key: 'website', label: 'Website', color: '#ffc206' },
19+
{ key: 'homebrew', label: 'Homebrew', color: '#f0883e' },
20+
{ key: 'other', label: 'Direct / other', color: '#71717a' },
21+
]
22+
// Newest release gets the brightest color; the rest cycle, with anything older bucketed as grey.
23+
export const VERSION_PALETTE = ['#ffc206', '#a78bfa', '#22d3ee', '#8faa3b', '#f0883e', '#f472b6']
24+
export const COLOR_OLDER = '#71717a'
25+
26+
/** Sorted unique day strings (YYYY-MM-DD, ascending) from a list of rows carrying a `day` field. */
27+
export function uniqueDays(rows: Array<{ day: string }>): string[] {
28+
return [...new Set(rows.map((r) => r.day))].sort()
29+
}
30+
31+
/** Aligns rows into a per-key map of per-day value arrays (one slot per entry in `days`). */
32+
function stackByDay<T>(
33+
rows: T[],
34+
days: string[],
35+
getDay: (r: T) => string,
36+
getKey: (r: T) => string,
37+
getValue: (r: T) => number,
38+
): Map<string, number[]> {
39+
const dayIndex = new Map(days.map((d, i) => [d, i]))
40+
const byKey = new Map<string, number[]>()
41+
for (const row of rows) {
42+
const di = dayIndex.get(getDay(row))
43+
if (di === undefined) continue
44+
const key = getKey(row)
45+
let arr = byKey.get(key)
46+
if (!arr) {
47+
arr = new Array(days.length).fill(0)
48+
byKey.set(key, arr)
49+
}
50+
arr[di] += getValue(row)
51+
}
52+
return byKey
53+
}
54+
55+
/** Downloads stacked by source, using the deduped same-day-distinct count. */
56+
export function downloadSourceSeries(rows: DownloadRow[], days: string[]): StackSeries[] {
57+
const byKey = stackByDay(
58+
rows,
59+
days,
60+
(r) => r.day,
61+
(r) => r.source,
62+
(r) => r.uniqueDownloads,
63+
)
64+
return SOURCE_STACK.map((s) => ({ ...s, values: byKey.get(s.key) ?? new Array(days.length).fill(0) })).filter(
65+
(s) => s.values.some((v) => v > 0),
66+
)
67+
}
68+
69+
/** Update activity stacked by the version each install was running when it checked. */
70+
export function updateVersionSeries(rows: UpdateActivityRow[], days: string[]): StackSeries[] {
71+
const byKey = stackByDay(
72+
rows,
73+
days,
74+
(r) => r.day,
75+
(r) => r.version,
76+
(r) => r.updaters,
77+
)
78+
const versions = [...byKey.keys()].sort(compareSemverDesc)
79+
const top = versions.slice(0, VERSION_PALETTE.length)
80+
const rest = versions.slice(VERSION_PALETTE.length)
81+
const series: StackSeries[] = top.map((v, i) => ({
82+
key: v,
83+
label: `v${v}`,
84+
color: VERSION_PALETTE[i],
85+
values: byKey.get(v) ?? new Array(days.length).fill(0),
86+
}))
87+
if (rest.length > 0) {
88+
const olderValues = new Array(days.length).fill(0)
89+
for (const v of rest) {
90+
const arr = byKey.get(v) ?? []
91+
for (let i = 0; i < days.length; i++) olderValues[i] += arr[i] ?? 0
92+
}
93+
series.push({ key: 'older', label: 'Older', color: COLOR_OLDER, values: olderValues })
94+
}
95+
return series
96+
}
97+
98+
/** Aggregates rows by a string field, summing a numeric field. */
99+
export function aggregateBy(
100+
rows: DownloadRow[],
101+
groupField: keyof DownloadRow,
102+
sumField: keyof DownloadRow,
103+
): Array<{ x: string; y: number }> {
104+
const map = new Map<string, number>()
105+
for (const row of rows) {
106+
const key = String(row[groupField])
107+
map.set(key, (map.get(key) ?? 0) + Number(row[sumField]))
108+
}
109+
return [...map.entries()].map(([x, y]) => ({ x, y })).sort((a, b) => b.y - a.y)
110+
}
111+
112+
/** Returns sorted unique day strings and their unix timestamps from download rows. */
113+
export function getDayAxis(rows: DownloadRow[]): { days: string[]; timestamps: number[] } {
114+
const days = [...new Set(rows.map((r) => r.day))].sort()
115+
const timestamps = days.map((d) => new Date(d).getTime() / 1000)
116+
return { days, timestamps }
117+
}
118+
119+
/** Builds uPlot [timestamps[], values[]] for a filtered subset, aligned to the full day axis. */
120+
export function buildTimeline(
121+
rows: DownloadRow[],
122+
allDays: string[],
123+
allTimestamps: number[],
124+
): [number[], number[]] {
125+
const byDay = new Map<string, number>()
126+
for (const row of rows) {
127+
byDay.set(row.day, (byDay.get(row.day) ?? 0) + row.downloads)
128+
}
129+
return [allTimestamps, allDays.map((d) => byDay.get(d) ?? 0)]
130+
}
131+
132+
/** Compares two semver strings, descending (higher version first). */
133+
export function compareSemverDesc(a: string, b: string): number {
134+
const pa = a.split('.').map(Number)
135+
const pb = b.split('.').map(Number)
136+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
137+
const diff = (pb[i] ?? 0) - (pa[i] ?? 0)
138+
if (diff !== 0) return diff
139+
}
140+
return 0
141+
}
142+
143+
/** Finds the max daily download value across a set of groups. */
144+
export function maxDailyAcrossGroups(
145+
rows: DownloadRow[],
146+
groupField: keyof DownloadRow,
147+
groupKeys: string[],
148+
allDays: string[],
149+
): number {
150+
let max = 1
151+
for (const key of groupKeys) {
152+
const byDay = new Map<string, number>()
153+
for (const row of rows) {
154+
if (String(row[groupField]) === key) {
155+
byDay.set(row.day, (byDay.get(row.day) ?? 0) + row.downloads)
156+
}
157+
}
158+
for (const v of byDay.values()) {
159+
if (v > max) max = v
160+
}
161+
}
162+
return max
163+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* The dashboard's shared color tokens. Kept consistent across metric dots, chart strokes, and fills.
3+
* See the "Consistent color coding" decision in CLAUDE.md.
4+
*/
5+
6+
/** getcmdr.com / vdavid/cmdr (the primary product). */
7+
export const COLOR_GOLD = '#ffc206'
8+
/** vdavid/mtp-rs (the library repo). */
9+
export const COLOR_PURPLE = '#a78bfa'
10+
/** veszelovszki.com (David's personal site). */
11+
export const COLOR_GREEN = '#8faa3b'
12+
/** getprvw.com (Prvw product site). */
13+
export const COLOR_CYAN = '#22d3ee'

0 commit comments

Comments
 (0)