Skip to content

Commit 67efc4a

Browse files
committed
Dashboard: Add GitHub star history tracking
- Fetch stargazer timestamps for cmdr and mtp-rs repos (paginated) - Aggregate into daily counts with cumulative totals per repo + combined - Cache aggressively (1 hour) since star events are append-only - Show in Awareness section: per-repo totals + cumulative line chart - Include in /api/report: total, per-repo, and recent growth numbers
1 parent efbf367 commit 67efc4a

5 files changed

Lines changed: 156 additions & 5 deletions

File tree

apps/analytics-dashboard/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Each source gets its own module under `src/lib/server/sources/`:
4343
| `umami.ts` | JWT (username/password login) | Page views, visitors, referrers, countries, download events for veszelovszki.com + getcmdr.com |
4444
| `cloudflare.ts` | Bearer token (via `LICENSE_SERVER_ADMIN_TOKEN`) | Download counts, active users by version/arch/country — fetched from worker endpoints (`/admin/downloads`, `/admin/active-users`) |
4545
| `paddle.ts` | Bearer token, cursor pagination | Completed transactions, subscriptions by status |
46-
| `github.ts` | Optional Bearer token | Release download counts per asset |
46+
| `github.ts` | Optional Bearer token | Release download counts per asset; star history (daily + cumulative) for cmdr and mtp-rs via stargazers API with pagination |
4747
| `posthog.ts` | Bearer personal API key | Pageview trends via Trends API (EU endpoint) |
4848
| `license.ts` | Bearer admin token | Activation count + active devices from `/admin/stats` |
4949

apps/analytics-dashboard/src/lib/server/fetch-all.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import type { TimeRange, SourceResult } from './types.js'
22
import type { UmamiData } from './sources/umami.js'
33
import type { CloudflareData } from './sources/cloudflare.js'
44
import type { PaddleData } from './sources/paddle.js'
5-
import type { GitHubData } from './sources/github.js'
5+
import type { GitHubData, GitHubStarsData } from './sources/github.js'
66
import type { PostHogData } from './sources/posthog.js'
77
import type { LicenseData } from './sources/license.js'
88
import { fetchUmamiData } from './sources/umami.js'
99
import { fetchCloudflareData } from './sources/cloudflare.js'
1010
import { fetchPaddleData } from './sources/paddle.js'
11-
import { fetchGitHubData } from './sources/github.js'
11+
import { fetchGitHubData, fetchGitHubStarsData } from './sources/github.js'
1212
import { fetchPostHogData } from './sources/posthog.js'
1313
import { fetchLicenseData } from './sources/license.js'
1414

@@ -19,6 +19,7 @@ export interface DashboardData {
1919
cloudflare: SourceResult<CloudflareData>
2020
paddle: SourceResult<PaddleData>
2121
github: SourceResult<GitHubData>
22+
githubStars: SourceResult<GitHubStarsData>
2223
posthog: SourceResult<PostHogData>
2324
license: SourceResult<LicenseData>
2425
}
@@ -61,7 +62,7 @@ export async function fetchDashboardData(
6162
const range: TimeRange = validRanges.has(rangeParam as TimeRange) ? (rangeParam as TimeRange) : '7d'
6263
const env = await resolveEnv(platform)
6364

64-
const [umami, cloudflare, paddle, github, posthog, license] = await Promise.all([
65+
const [umami, cloudflare, paddle, github, githubStars, posthog, license] = await Promise.all([
6566
guardedFetch(env?.UMAMI_API_URL, 'Umami', () =>
6667
fetchUmamiData(
6768
{
@@ -81,6 +82,7 @@ export async function fetchDashboardData(
8182
fetchPaddleData({ PADDLE_API_KEY_LIVE: env.PADDLE_API_KEY_LIVE }, range),
8283
),
8384
fetchGitHubData({ GITHUB_TOKEN: env?.GITHUB_TOKEN }),
85+
fetchGitHubStarsData({ GITHUB_TOKEN: env?.GITHUB_TOKEN }),
8486
guardedFetch(env?.POSTHOG_API_KEY, 'PostHog', () =>
8587
fetchPostHogData(
8688
{
@@ -96,5 +98,5 @@ export async function fetchDashboardData(
9698
),
9799
])
98100

99-
return { range, updatedAt: new Date().toISOString(), umami, cloudflare, paddle, github, posthog, license }
101+
return { range, updatedAt: new Date().toISOString(), umami, cloudflare, paddle, github, githubStars, posthog, license }
100102
}

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

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,120 @@
11
import type { SourceResult } from '../types.js'
22
import { cacheGet, cacheSet } from '../cache.js'
33

4+
// ── Star history ──────────────────────────────────────────────────────
5+
6+
export interface DailyStarCount {
7+
day: string
8+
newStars: number
9+
cumulative: number
10+
}
11+
12+
export interface RepoStarSummary {
13+
repo: string
14+
totalStars: number
15+
daily: DailyStarCount[]
16+
}
17+
18+
export interface GitHubStarsData {
19+
repos: RepoStarSummary[]
20+
totalStars: number
21+
/** Merged daily counts across all repos. */
22+
combinedDaily: DailyStarCount[]
23+
}
24+
25+
const TRACKED_REPOS = ['vdavid/cmdr', 'vdavid/mtp-rs']
26+
27+
interface RawStargazer {
28+
starred_at: string
29+
}
30+
31+
/** Fetches all stargazers for a repo, paginating via Link header. */
32+
async function fetchAllStargazers(repo: string, headers: Record<string, string>): Promise<string[]> {
33+
const dates: string[] = []
34+
let url: string | null = `https://api.github.com/repos/${repo}/stargazers?per_page=100`
35+
36+
while (url) {
37+
const response = await fetch(url, {
38+
headers: { ...headers, Accept: 'application/vnd.github.star+json' },
39+
})
40+
if (!response.ok) throw new Error(`GitHub stargazers API returned ${response.status} for ${repo}`)
41+
42+
const items = (await response.json()) as RawStargazer[]
43+
for (const item of items) dates.push(item.starred_at)
44+
45+
// Follow pagination
46+
const linkHeader = response.headers.get('Link')
47+
const nextMatch = linkHeader?.match(/<([^>]+)>;\s*rel="next"/)
48+
url = nextMatch?.[1] ?? null
49+
}
50+
51+
return dates
52+
}
53+
54+
/** Groups star dates into daily counts with cumulative totals. */
55+
function toDailyCounts(dates: string[]): DailyStarCount[] {
56+
const byDay = new Map<string, number>()
57+
for (const date of dates) {
58+
const day = date.split('T')[0]
59+
byDay.set(day, (byDay.get(day) ?? 0) + 1)
60+
}
61+
62+
const sorted = [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b))
63+
let cumulative = 0
64+
return sorted.map(([day, newStars]) => {
65+
cumulative += newStars
66+
return { day, newStars, cumulative }
67+
})
68+
}
69+
70+
/** Merges daily counts from multiple repos into a combined timeline. */
71+
function mergeDailyCounts(repoSummaries: RepoStarSummary[]): DailyStarCount[] {
72+
const byDay = new Map<string, number>()
73+
for (const repo of repoSummaries) {
74+
for (const entry of repo.daily) {
75+
byDay.set(entry.day, (byDay.get(entry.day) ?? 0) + entry.newStars)
76+
}
77+
}
78+
79+
const sorted = [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b))
80+
let cumulative = 0
81+
return sorted.map(([day, newStars]) => {
82+
cumulative += newStars
83+
return { day, newStars, cumulative }
84+
})
85+
}
86+
87+
/**
88+
* Fetches star history for tracked repos. Cached aggressively (1 hour)
89+
* since star events are append-only.
90+
*/
91+
export async function fetchGitHubStarsData(env: GitHubEnv): Promise<SourceResult<GitHubStarsData>> {
92+
const cached = await cacheGet<GitHubStarsData>('github-stars', '30d')
93+
if (cached) return { ok: true, data: cached }
94+
95+
try {
96+
const headers: Record<string, string> = { 'User-Agent': 'cmdr-analytics-dashboard' }
97+
if (env.GITHUB_TOKEN) headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`
98+
99+
const allDates = await Promise.all(TRACKED_REPOS.map((repo) => fetchAllStargazers(repo, headers)))
100+
101+
const repos: RepoStarSummary[] = TRACKED_REPOS.map((repo, i) => ({
102+
repo,
103+
totalStars: allDates[i].length,
104+
daily: toDailyCounts(allDates[i]),
105+
}))
106+
107+
const totalStars = repos.reduce((sum, r) => sum + r.totalStars, 0)
108+
const combinedDaily = mergeDailyCounts(repos)
109+
110+
const data: GitHubStarsData = { repos, totalStars, combinedDaily }
111+
await cacheSet('github-stars', '30d', data)
112+
return { ok: true, data }
113+
} catch (e) {
114+
return { ok: false, error: `GitHub stars: ${e instanceof Error ? e.message : String(e)}` }
115+
}
116+
}
117+
4118
export interface GitHubAsset {
5119
name: string
6120
downloadCount: number

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,33 @@
208208
</div>
209209
{/if}
210210

211+
{#if data.githubStars.ok}
212+
{@const stars = data.githubStars.data}
213+
<div class="mt-4">
214+
<h3 class="mb-2 text-sm font-medium text-text-secondary">GitHub stars</h3>
215+
{@render metricRow(
216+
stars.repos.map((r) => ({ label: r.repo, value: formatNumber(r.totalStars) }))
217+
)}
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+
</div>
231+
{/if}
232+
211233
{@render externalLinks([
212234
{ label: 'View veszelovszki.com in Umami', href: 'https://anal.veszelovszki.com' },
213235
{ label: 'View getcmdr.com in Umami', href: 'https://anal.veszelovszki.com' },
236+
{ label: 'cmdr on GitHub', href: 'https://github.com/vdavid/cmdr' },
237+
{ label: 'mtp-rs on GitHub', href: 'https://github.com/vdavid/mtp-rs' },
214238
])}
215239
{/if}
216240
</section>

apps/analytics-dashboard/src/routes/api/report/+server.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ function formatReport(data: DashboardData): string {
8181
`- getcmdr.com visitors: ${num(u.website.visitors.value)}${delta(u.website.visitors.value, u.website.visitors.prev)}`,
8282
)
8383

84+
if (data.githubStars.ok) {
85+
const s = data.githubStars.data
86+
blank()
87+
line(`GitHub stars: ${num(s.totalStars)} total`)
88+
for (const repo of s.repos) {
89+
const recent7 = repo.daily.filter((d) => new Date(d.day) >= new Date(Date.now() - 7 * 86_400_000)).reduce((sum, d) => sum + d.newStars, 0)
90+
const recent30 = repo.daily.filter((d) => new Date(d.day) >= new Date(Date.now() - 30 * 86_400_000)).reduce((sum, d) => sum + d.newStars, 0)
91+
line(` ${repo.repo}: ${num(repo.totalStars)} (last 7d: +${recent7}, last 30d: +${recent30})`)
92+
}
93+
}
94+
8495
if (u.websiteReferrers.length > 0) {
8596
blank()
8697
line('Top referrers:')

0 commit comments

Comments
 (0)