Skip to content

Commit 6755779

Browse files
authored
feat: add GitHub contributors graph (#1445)
1 parent 9fbdb32 commit 6755779

File tree

12 files changed

+566
-57
lines changed

12 files changed

+566
-57
lines changed

app/components/Package/TrendsChart.vue

Lines changed: 196 additions & 51 deletions
Large diffs are not rendered by default.

app/components/Package/WeeklyDownloadStats.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline'
33
import { useCssVariables } from '~/composables/useColors'
44
import type { WeeklyDataPoint } from '~/types/chart'
55
import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors'
6+
import type { RepoRef } from '#shared/utils/git-providers'
67
78
const props = defineProps<{
89
packageName: string
910
createdIso: string | null
11+
repoRef?: RepoRef | null | undefined
1012
}>()
1113
1214
const router = useRouter()
@@ -315,6 +317,7 @@ const config = computed(() => {
315317
:weeklyDownloads="weeklyDownloads"
316318
:inModal="true"
317319
:packageName="props.packageName"
320+
:repoRef="props.repoRef"
318321
:createdIso="createdIso"
319322
permalink
320323
show-facet-selector

app/composables/useCharts.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import type {
88
WeeklyDataPoint,
99
YearlyDataPoint,
1010
} from '~/types/chart'
11+
import type { RepoRef } from '#shared/utils/git-providers'
12+
import { parseRepoUrl } from '#shared/utils/git-providers'
13+
import type { PackageMetaResponse } from '#shared/types'
14+
import { encodePackageName } from '#shared/utils/npm'
1115
import { fetchNpmDownloadsRange } from '~/utils/npm/api'
1216

1317
export type PackumentLikeForTime = {
@@ -182,11 +186,151 @@ export function buildYearlyEvolutionFromDaily(daily: DailyRawPoint[]): YearlyDat
182186

183187
const npmDailyRangeCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null
184188
const likesEvolutionCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null
189+
const contributorsEvolutionCache = import.meta.client
190+
? new Map<string, Promise<GitHubContributorStats[]>>()
191+
: null
192+
const repoMetaCache = import.meta.client ? new Map<string, Promise<RepoRef | null>>() : null
185193

186194
/** Clears client-side promise caches. Exported for use in tests. */
187195
export function clearClientCaches() {
188196
npmDailyRangeCache?.clear()
189197
likesEvolutionCache?.clear()
198+
contributorsEvolutionCache?.clear()
199+
repoMetaCache?.clear()
200+
}
201+
202+
type GitHubContributorWeek = {
203+
w: number
204+
a: number
205+
d: number
206+
c: number
207+
}
208+
209+
type GitHubContributorStats = {
210+
total: number
211+
weeks: GitHubContributorWeek[]
212+
}
213+
214+
function pad2(value: number): string {
215+
return value.toString().padStart(2, '0')
216+
}
217+
218+
function toIsoMonthKey(date: Date): string {
219+
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}`
220+
}
221+
222+
function isOverlappingRange(start: Date, end: Date, rangeStart: Date, rangeEnd: Date): boolean {
223+
return end.getTime() >= rangeStart.getTime() && start.getTime() <= rangeEnd.getTime()
224+
}
225+
226+
function buildWeeklyEvolutionFromContributorCounts(
227+
weeklyCounts: Map<number, number>,
228+
rangeStart: Date,
229+
rangeEnd: Date,
230+
): WeeklyDataPoint[] {
231+
return Array.from(weeklyCounts.entries())
232+
.sort(([a], [b]) => a - b)
233+
.map(([weekStartSeconds, value]) => {
234+
const weekStartDate = new Date(weekStartSeconds * 1000)
235+
const weekEndDate = addDays(weekStartDate, 6)
236+
237+
if (!isOverlappingRange(weekStartDate, weekEndDate, rangeStart, rangeEnd)) return null
238+
239+
const clampedWeekEndDate = weekEndDate.getTime() > rangeEnd.getTime() ? rangeEnd : weekEndDate
240+
241+
const weekStartIso = toIsoDateString(weekStartDate)
242+
const weekEndIso = toIsoDateString(clampedWeekEndDate)
243+
244+
return {
245+
value,
246+
weekKey: `${weekStartIso}_${weekEndIso}`,
247+
weekStart: weekStartIso,
248+
weekEnd: weekEndIso,
249+
timestampStart: weekStartDate.getTime(),
250+
timestampEnd: clampedWeekEndDate.getTime(),
251+
}
252+
})
253+
.filter((item): item is WeeklyDataPoint => Boolean(item))
254+
}
255+
256+
function buildMonthlyEvolutionFromContributorCounts(
257+
monthlyCounts: Map<string, number>,
258+
rangeStart: Date,
259+
rangeEnd: Date,
260+
): MonthlyDataPoint[] {
261+
return Array.from(monthlyCounts.entries())
262+
.sort(([a], [b]) => a.localeCompare(b))
263+
.map(([month, value]) => {
264+
const [year, monthNumber] = month.split('-').map(Number)
265+
if (!year || !monthNumber) return null
266+
267+
const monthStartDate = new Date(Date.UTC(year, monthNumber - 1, 1))
268+
const monthEndDate = new Date(Date.UTC(year, monthNumber, 0))
269+
270+
if (!isOverlappingRange(monthStartDate, monthEndDate, rangeStart, rangeEnd)) return null
271+
272+
return {
273+
month,
274+
value,
275+
timestamp: monthStartDate.getTime(),
276+
}
277+
})
278+
.filter((item): item is MonthlyDataPoint => Boolean(item))
279+
}
280+
281+
function buildYearlyEvolutionFromContributorCounts(
282+
yearlyCounts: Map<string, number>,
283+
rangeStart: Date,
284+
rangeEnd: Date,
285+
): YearlyDataPoint[] {
286+
return Array.from(yearlyCounts.entries())
287+
.sort(([a], [b]) => a.localeCompare(b))
288+
.map(([year, value]) => {
289+
const yearNumber = Number(year)
290+
if (!yearNumber) return null
291+
292+
const yearStartDate = new Date(Date.UTC(yearNumber, 0, 1))
293+
const yearEndDate = new Date(Date.UTC(yearNumber, 11, 31))
294+
295+
if (!isOverlappingRange(yearStartDate, yearEndDate, rangeStart, rangeEnd)) return null
296+
297+
return {
298+
year,
299+
value,
300+
timestamp: yearStartDate.getTime(),
301+
}
302+
})
303+
.filter((item): item is YearlyDataPoint => Boolean(item))
304+
}
305+
306+
function buildContributorCounts(stats: GitHubContributorStats[]) {
307+
const weeklyCounts = new Map<number, number>()
308+
const monthlyCounts = new Map<string, number>()
309+
const yearlyCounts = new Map<string, number>()
310+
311+
for (const contributor of stats ?? []) {
312+
const monthSet = new Set<string>()
313+
const yearSet = new Set<string>()
314+
315+
for (const week of contributor?.weeks ?? []) {
316+
if (!week || week.c <= 0) continue
317+
318+
weeklyCounts.set(week.w, (weeklyCounts.get(week.w) ?? 0) + 1)
319+
320+
const weekStartDate = new Date(week.w * 1000)
321+
monthSet.add(toIsoMonthKey(weekStartDate))
322+
yearSet.add(String(weekStartDate.getUTCFullYear()))
323+
}
324+
325+
for (const key of monthSet) {
326+
monthlyCounts.set(key, (monthlyCounts.get(key) ?? 0) + 1)
327+
}
328+
for (const key of yearSet) {
329+
yearlyCounts.set(key, (yearlyCounts.get(key) ?? 0) + 1)
330+
}
331+
}
332+
333+
return { weeklyCounts, monthlyCounts, yearlyCounts }
190334
}
191335

192336
async function fetchDailyRangeCached(packageName: string, startIso: string, endIso: string) {
@@ -377,9 +521,105 @@ export function useCharts() {
377521
return buildYearlyEvolutionFromDaily(filteredDaily)
378522
}
379523

524+
async function fetchRepoContributorsEvolution(
525+
repoRef: MaybeRefOrGetter<RepoRef | null | undefined>,
526+
evolutionOptions: MaybeRefOrGetter<EvolutionOptions>,
527+
): Promise<DailyDataPoint[] | WeeklyDataPoint[] | MonthlyDataPoint[] | YearlyDataPoint[]> {
528+
const resolvedRepoRef = toValue(repoRef)
529+
if (!resolvedRepoRef || resolvedRepoRef.provider !== 'github') return []
530+
531+
const resolvedOptions = toValue(evolutionOptions)
532+
533+
const cache = contributorsEvolutionCache
534+
const cacheKey = `${resolvedRepoRef.owner}/${resolvedRepoRef.repo}`
535+
536+
let statsPromise: Promise<GitHubContributorStats[]>
537+
538+
if (cache?.has(cacheKey)) {
539+
statsPromise = cache.get(cacheKey)!
540+
} else {
541+
statsPromise = $fetch<GitHubContributorStats[]>(
542+
`/api/github/contributors-evolution/${resolvedRepoRef.owner}/${resolvedRepoRef.repo}`,
543+
)
544+
.then(data => (Array.isArray(data) ? data : []))
545+
.catch(error => {
546+
cache?.delete(cacheKey)
547+
throw error
548+
})
549+
550+
cache?.set(cacheKey, statsPromise)
551+
}
552+
553+
const stats = await statsPromise
554+
const { start, end } = resolveDateRange(resolvedOptions, null)
555+
556+
const { weeklyCounts, monthlyCounts, yearlyCounts } = buildContributorCounts(stats)
557+
558+
if (resolvedOptions.granularity === 'week') {
559+
return buildWeeklyEvolutionFromContributorCounts(weeklyCounts, start, end)
560+
}
561+
if (resolvedOptions.granularity === 'month') {
562+
return buildMonthlyEvolutionFromContributorCounts(monthlyCounts, start, end)
563+
}
564+
if (resolvedOptions.granularity === 'year') {
565+
return buildYearlyEvolutionFromContributorCounts(yearlyCounts, start, end)
566+
}
567+
568+
return []
569+
}
570+
571+
async function fetchRepoRefsForPackages(
572+
packageNames: MaybeRefOrGetter<string[]>,
573+
): Promise<Record<string, RepoRef | null>> {
574+
const names = (toValue(packageNames) ?? []).map(n => String(n).trim()).filter(Boolean)
575+
if (!import.meta.client || !names.length) return {}
576+
577+
const settled = await Promise.allSettled(
578+
names.map(async name => {
579+
const cacheKey = name
580+
const cache = repoMetaCache
581+
if (cache?.has(cacheKey)) {
582+
const ref = await cache.get(cacheKey)!
583+
return { name, ref }
584+
}
585+
586+
const promise = $fetch<PackageMetaResponse>(
587+
`/api/registry/package-meta/${encodePackageName(name)}`,
588+
)
589+
.then(meta => {
590+
const repoUrl = meta?.links?.repository
591+
return repoUrl ? parseRepoUrl(repoUrl) : null
592+
})
593+
.catch(error => {
594+
cache?.delete(cacheKey)
595+
throw error
596+
})
597+
598+
cache?.set(cacheKey, promise)
599+
const ref = await promise
600+
return { name, ref }
601+
}),
602+
)
603+
604+
const next: Record<string, RepoRef | null> = {}
605+
for (const [index, entry] of settled.entries()) {
606+
const name = names[index]
607+
if (!name) continue
608+
if (entry.status === 'fulfilled') {
609+
next[name] = entry.value.ref ?? null
610+
} else {
611+
next[name] = null
612+
}
613+
}
614+
615+
return next
616+
}
617+
380618
return {
381619
fetchPackageDownloadEvolution,
382620
fetchPackageLikesEvolution,
621+
fetchRepoContributorsEvolution,
622+
fetchRepoRefsForPackages,
383623
getNpmPackageCreationDate,
384624
}
385625
}

app/pages/compare.vue

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,10 @@ useSeoMeta({
198198
</h2>
199199

200200
<div
201-
v-if="status === 'pending' && (!packagesData || packagesData.every(p => p === null))"
201+
v-if="
202+
(status === 'pending' || status === 'idle') &&
203+
(!packagesData || packagesData.every(p => p === null))
204+
"
202205
class="flex items-center justify-center py-12"
203206
>
204207
<LoadingSpinner :text="$t('compare.packages.loading')" />
@@ -247,9 +250,12 @@ useSeoMeta({
247250
<CompareLineChart :packages="packages.filter(p => p !== NO_DEPENDENCY_ID)" />
248251
</div>
249252

250-
<div v-else class="text-center py-12" role="alert">
253+
<div v-else-if="status === 'error'" class="text-center py-12" role="alert">
251254
<p class="text-fg-muted">{{ $t('compare.packages.error') }}</p>
252255
</div>
256+
<div v-else class="flex items-center justify-center py-12">
257+
<LoadingSpinner :text="$t('compare.packages.loading')" />
258+
</div>
253259
</section>
254260

255261
<!-- Empty state -->

app/pages/package/[[org]]/[name].vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1370,7 +1370,11 @@ const showSkeleton = shallowRef(false)
13701370
</ClientOnly>
13711371

13721372
<!-- Download stats -->
1373-
<PackageWeeklyDownloadStats :packageName :createdIso="pkg?.time?.created ?? null" />
1373+
<PackageWeeklyDownloadStats
1374+
:packageName
1375+
:createdIso="pkg?.time?.created ?? null"
1376+
:repoRef="repoRef"
1377+
/>
13741378

13751379
<!-- Playground links -->
13761380
<PackagePlaygrounds

i18n/locales/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,11 @@
362362
"y_axis_label": "{granularity} {facet}",
363363
"facet": "Facet",
364364
"title": "Trends",
365+
"contributors_skip": "Not shown in Contributors (no GitHub repo):",
365366
"items": {
366367
"downloads": "Downloads",
367-
"likes": "Likes"
368+
"likes": "Likes",
369+
"contributors": "Contributors"
368370
}
369371
},
370372
"downloads": {

i18n/schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,9 @@
10901090
"title": {
10911091
"type": "string"
10921092
},
1093+
"contributors_skip": {
1094+
"type": "string"
1095+
},
10931096
"items": {
10941097
"type": "object",
10951098
"properties": {
@@ -1098,6 +1101,9 @@
10981101
},
10991102
"likes": {
11001103
"type": "string"
1104+
},
1105+
"contributors": {
1106+
"type": "string"
11011107
}
11021108
},
11031109
"additionalProperties": false

lunaria/files/en-GB.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,9 +361,11 @@
361361
"y_axis_label": "{granularity} {facet}",
362362
"facet": "Facet",
363363
"title": "Trends",
364+
"contributors_skip": "Not shown in Contributors (no GitHub repo):",
364365
"items": {
365366
"downloads": "Downloads",
366-
"likes": "Likes"
367+
"likes": "Likes",
368+
"contributors": "Contributors"
367369
}
368370
},
369371
"downloads": {

lunaria/files/en-US.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,9 +361,11 @@
361361
"y_axis_label": "{granularity} {facet}",
362362
"facet": "Facet",
363363
"title": "Trends",
364+
"contributors_skip": "Not shown in Contributors (no GitHub repo):",
364365
"items": {
365366
"downloads": "Downloads",
366-
"likes": "Likes"
367+
"likes": "Likes",
368+
"contributors": "Contributors"
367369
}
368370
},
369371
"downloads": {

0 commit comments

Comments
 (0)