Skip to content

Commit 752f464

Browse files
committed
feat: Add GitHub contributors graph
1 parent 4db33e9 commit 752f464

File tree

11 files changed

+505
-46
lines changed

11 files changed

+505
-46
lines changed

app/components/Package/TrendsChart.vue

Lines changed: 208 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { useCssVariables } from '~/composables/useColors'
66
import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors'
77
import { getFrameworkColor, isListedFramework } from '~/utils/frameworks'
88
import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark'
9+
import type { RepoRef } from '#shared/utils/git-providers'
10+
import { parseRepoUrl } from '#shared/utils/git-providers'
11+
import type { PackageMetaResponse } from '#shared/types'
12+
import { encodePackageName } from '#shared/utils/npm'
913
import type {
1014
ChartTimeGranularity,
1115
DailyDataPoint,
@@ -35,6 +39,7 @@ const props = withDefaults(
3539
* Used when `weeklyDownloads` is not provided.
3640
*/
3741
packageNames?: string[]
42+
repoRef?: RepoRef | null
3843
createdIso?: string | null
3944
4045
/** When true, shows facet selector (e.g. Downloads / Likes). */
@@ -332,6 +337,56 @@ const effectivePackageNames = computed<string[]>(() => {
332337
return single ? [single] : []
333338
})
334339
340+
const repoRefsByPackage = shallowRef<Record<string, RepoRef | null>>({})
341+
const repoRefsRequestToken = shallowRef(0)
342+
343+
async function loadRepoRefsForPackages(packages: string[]) {
344+
if (!import.meta.client) return
345+
if (!packages.length) {
346+
repoRefsByPackage.value = {}
347+
return
348+
}
349+
350+
const currentToken = ++repoRefsRequestToken.value
351+
352+
const settled = await Promise.allSettled(
353+
packages.map(async name => {
354+
const encoded = encodePackageName(name)
355+
const meta = await $fetch<PackageMetaResponse>(`/api/registry/package-meta/${encoded}`)
356+
const repoUrl = meta?.links?.repository
357+
const ref = repoUrl ? parseRepoUrl(repoUrl) : null
358+
return { name, ref }
359+
}),
360+
)
361+
362+
if (currentToken !== repoRefsRequestToken.value) return
363+
364+
const next: Record<string, RepoRef | null> = {}
365+
for (const [index, entry] of settled.entries()) {
366+
const name = packages[index]
367+
if (!name) continue
368+
if (entry.status === 'fulfilled') {
369+
next[name] = entry.value.ref ?? null
370+
} else {
371+
next[name] = null
372+
}
373+
}
374+
repoRefsByPackage.value = next
375+
}
376+
377+
watch(
378+
() => effectivePackageNames.value,
379+
names => {
380+
if (!import.meta.client) return
381+
if (!isMultiPackageMode.value) {
382+
repoRefsByPackage.value = {}
383+
return
384+
}
385+
loadRepoRefsForPackages(names)
386+
},
387+
{ immediate: true },
388+
)
389+
335390
const selectedGranularity = usePermalink<ChartTimeGranularity>('granularity', DEFAULT_GRANULARITY, {
336391
permanent: props.permalink,
337392
})
@@ -571,35 +626,108 @@ function applyDateRange<T extends Record<string, unknown>>(base: T): T & DateRan
571626
return next
572627
}
573628
574-
const { fetchPackageDownloadEvolution, fetchPackageLikesEvolution } = useCharts()
629+
const {
630+
fetchPackageDownloadEvolution,
631+
fetchPackageLikesEvolution,
632+
fetchRepoContributorsEvolution,
633+
} = useCharts()
575634
576-
type MetricId = 'downloads' | 'likes'
635+
type MetricId = 'downloads' | 'likes' | 'contributors'
577636
const DEFAULT_METRIC_ID: MetricId = 'downloads'
578637
638+
type MetricContext = {
639+
packageName: string
640+
repoRef: RepoRef | null
641+
}
642+
579643
type MetricDef = {
580644
id: MetricId
581645
label: string
582-
fetch: (pkg: string, options: EvolutionOptions) => Promise<EvolutionData>
646+
fetch: (context: MetricContext, options: EvolutionOptions) => Promise<EvolutionData>
647+
supportsMulti?: boolean
583648
}
584649
585-
const METRICS = computed<MetricDef[]>(() => [
586-
{
587-
id: 'downloads',
588-
label: $t('package.trends.items.downloads'),
589-
fetch: (pkg, opts) =>
590-
fetchPackageDownloadEvolution(pkg, props.createdIso ?? null, opts) as Promise<EvolutionData>,
591-
},
592-
{
593-
id: 'likes',
594-
label: $t('package.trends.items.likes'),
595-
fetch: (pkg, opts) => fetchPackageLikesEvolution(pkg, opts) as Promise<EvolutionData>,
596-
},
597-
])
650+
const hasContributorsFacet = computed(() => {
651+
if (isMultiPackageMode.value) {
652+
return Object.values(repoRefsByPackage.value).some(ref => ref?.provider === 'github')
653+
}
654+
const ref = props.repoRef
655+
return ref?.provider === 'github' && ref.owner && ref.repo
656+
})
657+
658+
const METRICS = computed<MetricDef[]>(() => {
659+
const metrics: MetricDef[] = [
660+
{
661+
id: 'downloads',
662+
label: $t('package.trends.items.downloads'),
663+
fetch: ({ packageName }, opts) =>
664+
fetchPackageDownloadEvolution(
665+
packageName,
666+
props.createdIso ?? null,
667+
opts,
668+
) as Promise<EvolutionData>,
669+
supportsMulti: true,
670+
},
671+
{
672+
id: 'likes',
673+
label: $t('package.trends.items.likes'),
674+
fetch: ({ packageName }, opts) => fetchPackageLikesEvolution(packageName, opts),
675+
supportsMulti: true,
676+
},
677+
]
678+
679+
if (hasContributorsFacet.value) {
680+
metrics.push({
681+
id: 'contributors',
682+
label: $t('package.trends.items.contributors'),
683+
fetch: ({ repoRef }, opts) => fetchRepoContributorsEvolution(repoRef, opts),
684+
supportsMulti: false,
685+
})
686+
}
687+
688+
return metrics
689+
})
598690
599691
const selectedMetric = usePermalink<MetricId>('facet', DEFAULT_METRIC_ID, {
600692
permanent: props.permalink,
601693
})
602694
695+
const effectivePackageNamesForMetric = computed<string[]>(() => {
696+
if (!isMultiPackageMode.value) return effectivePackageNames.value
697+
if (selectedMetric.value !== 'contributors') return effectivePackageNames.value
698+
return effectivePackageNames.value.filter(
699+
name => repoRefsByPackage.value[name]?.provider === 'github',
700+
)
701+
})
702+
703+
const availableGranularities = computed<ChartTimeGranularity[]>(() => {
704+
if (selectedMetric.value === 'contributors') {
705+
return ['weekly', 'monthly', 'yearly']
706+
}
707+
708+
return ['daily', 'weekly', 'monthly', 'yearly']
709+
})
710+
711+
watch(
712+
() => [selectedMetric.value, availableGranularities.value] as const,
713+
() => {
714+
if (!availableGranularities.value.includes(selectedGranularity.value)) {
715+
selectedGranularity.value = 'weekly'
716+
}
717+
},
718+
{ immediate: true },
719+
)
720+
721+
watch(
722+
() => METRICS.value,
723+
metrics => {
724+
if (!metrics.some(m => m.id === selectedMetric.value)) {
725+
selectedMetric.value = DEFAULT_METRIC_ID
726+
}
727+
},
728+
{ immediate: true },
729+
)
730+
603731
// Per-metric state keyed by metric id
604732
const metricStates = reactive<
605733
Record<
@@ -624,10 +752,18 @@ const metricStates = reactive<
624752
evolutionsByPackage: {},
625753
requestToken: 0,
626754
},
755+
contributors: {
756+
pending: false,
757+
evolution: [],
758+
evolutionsByPackage: {},
759+
requestToken: 0,
760+
},
627761
})
628762
629763
const activeMetricState = computed(() => metricStates[selectedMetric.value])
630-
const activeMetricDef = computed(() => METRICS.value.find(m => m.id === selectedMetric.value)!)
764+
const activeMetricDef = computed(
765+
() => METRICS.value.find(m => m.id === selectedMetric.value) ?? METRICS.value[0],
766+
)
631767
const pending = computed(() => activeMetricState.value.pending)
632768
633769
const isMounted = shallowRef(false)
@@ -695,21 +831,33 @@ watch(
695831
async function loadMetric(metricId: MetricId) {
696832
if (!import.meta.client) return
697833
698-
const packageNames = effectivePackageNames.value
699-
if (!packageNames.length) return
700-
701834
const state = metricStates[metricId]
702835
const metric = METRICS.value.find(m => m.id === metricId)!
703836
const currentToken = ++state.requestToken
704837
state.pending = true
705838
706-
const fetchFn = (pkg: string) => metric.fetch(pkg, options.value)
839+
const fetchFn = (context: MetricContext) => metric.fetch(context, options.value)
707840
708841
try {
842+
const packageNames = effectivePackageNamesForMetric.value
843+
if (!packageNames.length) {
844+
if (isMultiPackageMode.value) state.evolutionsByPackage = {}
845+
else state.evolution = []
846+
displayedGranularity.value = selectedGranularity.value
847+
return
848+
}
849+
709850
if (isMultiPackageMode.value) {
851+
if (metric.supportsMulti === false) {
852+
state.evolutionsByPackage = {}
853+
displayedGranularity.value = selectedGranularity.value
854+
return
855+
}
856+
710857
const settled = await Promise.allSettled(
711858
packageNames.map(async pkg => {
712-
const result = await fetchFn(pkg)
859+
const repoRef = metricId === 'contributors' ? repoRefsByPackage.value[pkg] : null
860+
const result = await fetchFn({ packageName: pkg, repoRef })
713861
return { pkg, result: (result ?? []) as EvolutionData }
714862
}),
715863
)
@@ -750,7 +898,7 @@ async function loadMetric(metricId: MetricId) {
750898
}
751899
}
752900
753-
const result = await fetchFn(pkg)
901+
const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef ?? null })
754902
if (currentToken !== state.requestToken) return
755903
756904
state.evolution = (result ?? []) as EvolutionData
@@ -778,9 +926,13 @@ const debouncedLoadNow = useDebounceFn(() => {
778926
const fetchTriggerKey = computed(() => {
779927
const names = effectivePackageNames.value.join(',')
780928
const o = options.value
929+
const repoKey = props.repoRef
930+
? `${props.repoRef.provider}:${props.repoRef.owner}/${props.repoRef.repo}`
931+
: ''
781932
return [
782933
isMultiPackageMode.value ? 'M' : 'S',
783934
names,
935+
repoKey,
784936
String(props.createdIso ?? ''),
785937
String(o.granularity ?? ''),
786938
String('weeks' in o ? (o.weeks ?? '') : ''),
@@ -800,6 +952,18 @@ watch(
800952
{ flush: 'post' },
801953
)
802954
955+
watch(
956+
() => repoRefsByPackage.value,
957+
() => {
958+
if (!import.meta.client) return
959+
if (!isMounted.value) return
960+
if (!isMultiPackageMode.value) return
961+
if (selectedMetric.value !== 'contributors') return
962+
debouncedLoadNow()
963+
},
964+
{ deep: true },
965+
)
966+
803967
const effectiveDataSingle = computed<EvolutionData>(() => {
804968
const state = activeMetricState.value
805969
if (
@@ -837,7 +1001,7 @@ const chartData = computed<{
8371001
}
8381002
8391003
const state = activeMetricState.value
840-
const names = effectivePackageNames.value
1004+
const names = effectivePackageNamesForMetric.value
8411005
const granularity = displayedGranularity.value
8421006
8431007
const timestampSet = new Set<number>()
@@ -936,6 +1100,13 @@ function getGranularityLabel(granularity: ChartTimeGranularity) {
9361100
return granularityLabels.value[granularity]
9371101
}
9381102
1103+
const granularityItems = computed(() =>
1104+
availableGranularities.value.map(granularity => ({
1105+
label: granularityLabels.value[granularity],
1106+
value: granularity,
1107+
})),
1108+
)
1109+
9391110
function clampRatio(value: number): number {
9401111
if (value < 0) return 0
9411112
if (value > 1) return 1
@@ -1114,20 +1285,20 @@ function drawEstimationLine(svg: Record<string, any>) {
11141285
11151286
lines.push(`
11161287
<line
1117-
x1="${previousPoint.x}"
1118-
y1="${previousPoint.y}"
1119-
x2="${lastPoint.x}"
1120-
y2="${lastPoint.y}"
1121-
stroke="${colors.value.bg}"
1288+
x1="${previousPoint.x}"
1289+
y1="${previousPoint.y}"
1290+
x2="${lastPoint.x}"
1291+
y2="${lastPoint.y}"
1292+
stroke="${colors.value.bg}"
11221293
stroke-width="3"
11231294
opacity="1"
11241295
/>
1125-
<line
1126-
x1="${previousPoint.x}"
1127-
y1="${previousPoint.y}"
1128-
x2="${lastPoint.x}"
1129-
y2="${lastPoint.y}"
1130-
stroke="${stroke}"
1296+
<line
1297+
x1="${previousPoint.x}"
1298+
y1="${previousPoint.y}"
1299+
x2="${lastPoint.x}"
1300+
y2="${lastPoint.y}"
1301+
stroke="${stroke}"
11311302
stroke-width="3"
11321303
stroke-dasharray="4 8"
11331304
stroke-linecap="round"
@@ -1240,7 +1411,7 @@ function drawSvgPrintLegend(svg: Record<string, any>) {
12401411
!isZoomed.value
12411412
) {
12421413
seriesNames.push(`
1243-
<line
1414+
<line
12441415
x1="${svg.drawingArea.left + 12}"
12451416
y1="${svg.drawingArea.top + 24 * data.length}"
12461417
x2="${svg.drawingArea.left + 24}"
@@ -1463,12 +1634,7 @@ watch(selectedMetric, value => {
14631634
id="granularity"
14641635
v-model="selectedGranularity"
14651636
:disabled="activeMetricState.pending"
1466-
:items="[
1467-
{ label: $t('package.trends.granularity_daily'), value: 'daily' },
1468-
{ label: $t('package.trends.granularity_weekly'), value: 'weekly' },
1469-
{ label: $t('package.trends.granularity_monthly'), value: 'monthly' },
1470-
{ label: $t('package.trends.granularity_yearly'), value: 'yearly' },
1471-
]"
1637+
:items="granularityItems"
14721638
/>
14731639

14741640
<div class="grid grid-cols-2 gap-2 flex-1">

0 commit comments

Comments
 (0)