From dc34dc7c3e4a0eb0b68c3329db517caa35adf131 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 12 Feb 2026 14:19:07 -0600 Subject: [PATCH 1/8] feat: add GitHub contributors graph --- app/components/Package/TrendsChart.vue | 250 +++++++++++++++--- .../Package/WeeklyDownloadStats.vue | 3 + app/composables/useCharts.ts | 187 +++++++++++++ app/pages/package/[[org]]/[name].vue | 6 +- i18n/locales/en.json | 3 +- i18n/schema.json | 3 + lunaria/files/en-GB.json | 3 +- lunaria/files/en-US.json | 3 +- modules/runtime/server/cache.ts | 13 + .../[owner]/[repo].get.ts | 62 +++++ test/fixtures/github/contributors-stats.json | 18 ++ 11 files changed, 505 insertions(+), 46 deletions(-) create mode 100644 server/api/github/contributors-evolution/[owner]/[repo].get.ts create mode 100644 test/fixtures/github/contributors-stats.json diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 9339abf76..66ddd7598 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -6,6 +6,10 @@ import { useCssVariables } from '~/composables/useColors' import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors' import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' +import type { RepoRef } from '#shared/utils/git-providers' +import { parseRepoUrl } from '#shared/utils/git-providers' +import type { PackageMetaResponse } from '#shared/types' +import { encodePackageName } from '#shared/utils/npm' import type { ChartTimeGranularity, DailyDataPoint, @@ -35,6 +39,7 @@ const props = withDefaults( * Used when `weeklyDownloads` is not provided. */ packageNames?: string[] + repoRef?: RepoRef | null createdIso?: string | null /** When true, shows facet selector (e.g. Downloads / Likes). */ @@ -332,6 +337,56 @@ const effectivePackageNames = computed(() => { return single ? [single] : [] }) +const repoRefsByPackage = shallowRef>({}) +const repoRefsRequestToken = shallowRef(0) + +async function loadRepoRefsForPackages(packages: string[]) { + if (!import.meta.client) return + if (!packages.length) { + repoRefsByPackage.value = {} + return + } + + const currentToken = ++repoRefsRequestToken.value + + const settled = await Promise.allSettled( + packages.map(async name => { + const encoded = encodePackageName(name) + const meta = await $fetch(`/api/registry/package-meta/${encoded}`) + const repoUrl = meta?.links?.repository + const ref = repoUrl ? parseRepoUrl(repoUrl) : null + return { name, ref } + }), + ) + + if (currentToken !== repoRefsRequestToken.value) return + + const next: Record = {} + for (const [index, entry] of settled.entries()) { + const name = packages[index] + if (!name) continue + if (entry.status === 'fulfilled') { + next[name] = entry.value.ref ?? null + } else { + next[name] = null + } + } + repoRefsByPackage.value = next +} + +watch( + () => effectivePackageNames.value, + names => { + if (!import.meta.client) return + if (!isMultiPackageMode.value) { + repoRefsByPackage.value = {} + return + } + loadRepoRefsForPackages(names) + }, + { immediate: true }, +) + const selectedGranularity = usePermalink('granularity', DEFAULT_GRANULARITY, { permanent: props.permalink, }) @@ -571,35 +626,108 @@ function applyDateRange>(base: T): T & DateRan return next } -const { fetchPackageDownloadEvolution, fetchPackageLikesEvolution } = useCharts() +const { + fetchPackageDownloadEvolution, + fetchPackageLikesEvolution, + fetchRepoContributorsEvolution, +} = useCharts() -type MetricId = 'downloads' | 'likes' +type MetricId = 'downloads' | 'likes' | 'contributors' const DEFAULT_METRIC_ID: MetricId = 'downloads' +type MetricContext = { + packageName: string + repoRef: RepoRef | null +} + type MetricDef = { id: MetricId label: string - fetch: (pkg: string, options: EvolutionOptions) => Promise + fetch: (context: MetricContext, options: EvolutionOptions) => Promise + supportsMulti?: boolean } -const METRICS = computed(() => [ - { - id: 'downloads', - label: $t('package.trends.items.downloads'), - fetch: (pkg, opts) => - fetchPackageDownloadEvolution(pkg, props.createdIso ?? null, opts) as Promise, - }, - { - id: 'likes', - label: $t('package.trends.items.likes'), - fetch: (pkg, opts) => fetchPackageLikesEvolution(pkg, opts) as Promise, - }, -]) +const hasContributorsFacet = computed(() => { + if (isMultiPackageMode.value) { + return Object.values(repoRefsByPackage.value).some(ref => ref?.provider === 'github') + } + const ref = props.repoRef + return ref?.provider === 'github' && ref.owner && ref.repo +}) + +const METRICS = computed(() => { + const metrics: MetricDef[] = [ + { + id: 'downloads', + label: $t('package.trends.items.downloads'), + fetch: ({ packageName }, opts) => + fetchPackageDownloadEvolution( + packageName, + props.createdIso ?? null, + opts, + ) as Promise, + supportsMulti: true, + }, + { + id: 'likes', + label: $t('package.trends.items.likes'), + fetch: ({ packageName }, opts) => fetchPackageLikesEvolution(packageName, opts), + supportsMulti: true, + }, + ] + + if (hasContributorsFacet.value) { + metrics.push({ + id: 'contributors', + label: $t('package.trends.items.contributors'), + fetch: ({ repoRef }, opts) => fetchRepoContributorsEvolution(repoRef, opts), + supportsMulti: false, + }) + } + + return metrics +}) const selectedMetric = usePermalink('facet', DEFAULT_METRIC_ID, { permanent: props.permalink, }) +const effectivePackageNamesForMetric = computed(() => { + if (!isMultiPackageMode.value) return effectivePackageNames.value + if (selectedMetric.value !== 'contributors') return effectivePackageNames.value + return effectivePackageNames.value.filter( + name => repoRefsByPackage.value[name]?.provider === 'github', + ) +}) + +const availableGranularities = computed(() => { + if (selectedMetric.value === 'contributors') { + return ['weekly', 'monthly', 'yearly'] + } + + return ['daily', 'weekly', 'monthly', 'yearly'] +}) + +watch( + () => [selectedMetric.value, availableGranularities.value] as const, + () => { + if (!availableGranularities.value.includes(selectedGranularity.value)) { + selectedGranularity.value = 'weekly' + } + }, + { immediate: true }, +) + +watch( + () => METRICS.value, + metrics => { + if (!metrics.some(m => m.id === selectedMetric.value)) { + selectedMetric.value = DEFAULT_METRIC_ID + } + }, + { immediate: true }, +) + // Per-metric state keyed by metric id const metricStates = reactive< Record< @@ -624,10 +752,18 @@ const metricStates = reactive< evolutionsByPackage: {}, requestToken: 0, }, + contributors: { + pending: false, + evolution: [], + evolutionsByPackage: {}, + requestToken: 0, + }, }) const activeMetricState = computed(() => metricStates[selectedMetric.value]) -const activeMetricDef = computed(() => METRICS.value.find(m => m.id === selectedMetric.value)!) +const activeMetricDef = computed( + () => METRICS.value.find(m => m.id === selectedMetric.value) ?? METRICS.value[0], +) const pending = computed(() => activeMetricState.value.pending) const isMounted = shallowRef(false) @@ -695,21 +831,33 @@ watch( async function loadMetric(metricId: MetricId) { if (!import.meta.client) return - const packageNames = effectivePackageNames.value - if (!packageNames.length) return - const state = metricStates[metricId] const metric = METRICS.value.find(m => m.id === metricId)! const currentToken = ++state.requestToken state.pending = true - const fetchFn = (pkg: string) => metric.fetch(pkg, options.value) + const fetchFn = (context: MetricContext) => metric.fetch(context, options.value) try { + const packageNames = effectivePackageNamesForMetric.value + if (!packageNames.length) { + if (isMultiPackageMode.value) state.evolutionsByPackage = {} + else state.evolution = [] + displayedGranularity.value = selectedGranularity.value + return + } + if (isMultiPackageMode.value) { + if (metric.supportsMulti === false) { + state.evolutionsByPackage = {} + displayedGranularity.value = selectedGranularity.value + return + } + const settled = await Promise.allSettled( packageNames.map(async pkg => { - const result = await fetchFn(pkg) + const repoRef = metricId === 'contributors' ? repoRefsByPackage.value[pkg] : null + const result = await fetchFn({ packageName: pkg, repoRef }) return { pkg, result: (result ?? []) as EvolutionData } }), ) @@ -750,7 +898,7 @@ async function loadMetric(metricId: MetricId) { } } - const result = await fetchFn(pkg) + const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef ?? null }) if (currentToken !== state.requestToken) return state.evolution = (result ?? []) as EvolutionData @@ -778,9 +926,13 @@ const debouncedLoadNow = useDebounceFn(() => { const fetchTriggerKey = computed(() => { const names = effectivePackageNames.value.join(',') const o = options.value + const repoKey = props.repoRef + ? `${props.repoRef.provider}:${props.repoRef.owner}/${props.repoRef.repo}` + : '' return [ isMultiPackageMode.value ? 'M' : 'S', names, + repoKey, String(props.createdIso ?? ''), String(o.granularity ?? ''), String('weeks' in o ? (o.weeks ?? '') : ''), @@ -800,6 +952,18 @@ watch( { flush: 'post' }, ) +watch( + () => repoRefsByPackage.value, + () => { + if (!import.meta.client) return + if (!isMounted.value) return + if (!isMultiPackageMode.value) return + if (selectedMetric.value !== 'contributors') return + debouncedLoadNow() + }, + { deep: true }, +) + const effectiveDataSingle = computed(() => { const state = activeMetricState.value if ( @@ -837,7 +1001,7 @@ const chartData = computed<{ } const state = activeMetricState.value - const names = effectivePackageNames.value + const names = effectivePackageNamesForMetric.value const granularity = displayedGranularity.value const timestampSet = new Set() @@ -936,6 +1100,13 @@ function getGranularityLabel(granularity: ChartTimeGranularity) { return granularityLabels.value[granularity] } +const granularityItems = computed(() => + availableGranularities.value.map(granularity => ({ + label: granularityLabels.value[granularity], + value: granularity, + })), +) + function clampRatio(value: number): number { if (value < 0) return 0 if (value > 1) return 1 @@ -1114,20 +1285,20 @@ function drawEstimationLine(svg: Record) { lines.push(` - ) { !isZoomed.value ) { seriesNames.push(` - { id="granularity" v-model="selectedGranularity" :disabled="activeMetricState.pending" - :items="[ - { label: $t('package.trends.granularity_daily'), value: 'daily' }, - { label: $t('package.trends.granularity_weekly'), value: 'weekly' }, - { label: $t('package.trends.granularity_monthly'), value: 'monthly' }, - { label: $t('package.trends.granularity_yearly'), value: 'yearly' }, - ]" + :items="granularityItems" />
diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue index dfe7da4f6..c5b8a236e 100644 --- a/app/components/Package/WeeklyDownloadStats.vue +++ b/app/components/Package/WeeklyDownloadStats.vue @@ -3,10 +3,12 @@ import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline' import { useCssVariables } from '~/composables/useColors' import type { WeeklyDataPoint } from '~/types/chart' import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors' +import type { RepoRef } from '#shared/utils/git-providers' const props = defineProps<{ packageName: string createdIso: string | null + repoRef?: RepoRef | null }>() const router = useRouter() @@ -315,6 +317,7 @@ const config = computed(() => { :weeklyDownloads="weeklyDownloads" :inModal="true" :packageName="props.packageName" + :repoRef="props.repoRef ?? null" :createdIso="createdIso" permalink show-facet-selector diff --git a/app/composables/useCharts.ts b/app/composables/useCharts.ts index 2fa34eedf..0552fff24 100644 --- a/app/composables/useCharts.ts +++ b/app/composables/useCharts.ts @@ -8,6 +8,7 @@ import type { WeeklyDataPoint, YearlyDataPoint, } from '~/types/chart' +import type { RepoRef } from '#shared/utils/git-providers' import { fetchNpmDownloadsRange } from '~/utils/npm/api' export type PackumentLikeForTime = { @@ -182,11 +183,149 @@ export function buildYearlyEvolutionFromDaily(daily: DailyRawPoint[]): YearlyDat const npmDailyRangeCache = import.meta.client ? new Map>() : null const likesEvolutionCache = import.meta.client ? new Map>() : null +const contributorsEvolutionCache = import.meta.client + ? new Map>() + : null /** Clears client-side promise caches. Exported for use in tests. */ export function clearClientCaches() { npmDailyRangeCache?.clear() likesEvolutionCache?.clear() + contributorsEvolutionCache?.clear() +} + +type GitHubContributorWeek = { + w: number + a: number + d: number + c: number +} + +type GitHubContributorStats = { + total: number + weeks: GitHubContributorWeek[] +} + +function pad2(value: number): string { + return value.toString().padStart(2, '0') +} + +function toIsoMonthKey(date: Date): string { + return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}` +} + +function isOverlappingRange(start: Date, end: Date, rangeStart: Date, rangeEnd: Date): boolean { + return end.getTime() >= rangeStart.getTime() && start.getTime() <= rangeEnd.getTime() +} + +function buildWeeklyEvolutionFromContributorCounts( + weeklyCounts: Map, + rangeStart: Date, + rangeEnd: Date, +): WeeklyDataPoint[] { + return Array.from(weeklyCounts.entries()) + .sort(([a], [b]) => a - b) + .map(([weekStartSeconds, value]) => { + const weekStartDate = new Date(weekStartSeconds * 1000) + const weekEndDate = addDays(weekStartDate, 6) + + if (!isOverlappingRange(weekStartDate, weekEndDate, rangeStart, rangeEnd)) return null + + const clampedWeekEndDate = weekEndDate.getTime() > rangeEnd.getTime() ? rangeEnd : weekEndDate + + const weekStartIso = toIsoDateString(weekStartDate) + const weekEndIso = toIsoDateString(clampedWeekEndDate) + + return { + value, + weekKey: `${weekStartIso}_${weekEndIso}`, + weekStart: weekStartIso, + weekEnd: weekEndIso, + timestampStart: weekStartDate.getTime(), + timestampEnd: clampedWeekEndDate.getTime(), + } + }) + .filter((item): item is WeeklyDataPoint => Boolean(item)) +} + +function buildMonthlyEvolutionFromContributorCounts( + monthlyCounts: Map, + rangeStart: Date, + rangeEnd: Date, +): MonthlyDataPoint[] { + return Array.from(monthlyCounts.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, value]) => { + const [year, monthNumber] = month.split('-').map(Number) + if (!year || !monthNumber) return null + + const monthStartDate = new Date(Date.UTC(year, monthNumber - 1, 1)) + const monthEndDate = new Date(Date.UTC(year, monthNumber, 0)) + + if (!isOverlappingRange(monthStartDate, monthEndDate, rangeStart, rangeEnd)) return null + + return { + month, + value, + timestamp: monthStartDate.getTime(), + } + }) + .filter((item): item is MonthlyDataPoint => Boolean(item)) +} + +function buildYearlyEvolutionFromContributorCounts( + yearlyCounts: Map, + rangeStart: Date, + rangeEnd: Date, +): YearlyDataPoint[] { + return Array.from(yearlyCounts.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([year, value]) => { + const yearNumber = Number(year) + if (!yearNumber) return null + + const yearStartDate = new Date(Date.UTC(yearNumber, 0, 1)) + const yearEndDate = new Date(Date.UTC(yearNumber, 11, 31)) + + if (!isOverlappingRange(yearStartDate, yearEndDate, rangeStart, rangeEnd)) return null + + return { + year, + value, + timestamp: yearStartDate.getTime(), + } + }) + .filter((item): item is YearlyDataPoint => Boolean(item)) +} + +function buildContributorCounts(stats: GitHubContributorStats[]) { + const weeklyCounts = new Map() + const monthlyCounts = new Map() + const yearlyCounts = new Map() + + for (const contributor of stats ?? []) { + const monthSet = new Set() + const yearSet = new Set() + + for (const week of contributor?.weeks ?? []) { + if (!week || week.c <= 0) continue + + weeklyCounts.set(week.w, (weeklyCounts.get(week.w) ?? 0) + 1) + + const weekStartDate = new Date(week.w * 1000) + monthSet.add(toIsoMonthKey(weekStartDate)) + yearSet.add(String(weekStartDate.getUTCFullYear())) + } + + for (const key of monthSet) { + monthlyCounts.set(key, (monthlyCounts.get(key) ?? 0) + 1) + } + for (const key of yearSet) { + yearlyCounts.set(key, (yearlyCounts.get(key) ?? 0) + 1) + } + } + + return { weeklyCounts, monthlyCounts, yearlyCounts } } async function fetchDailyRangeCached(packageName: string, startIso: string, endIso: string) { @@ -377,9 +516,57 @@ export function useCharts() { return buildYearlyEvolutionFromDaily(filteredDaily) } + async function fetchRepoContributorsEvolution( + repoRef: MaybeRefOrGetter, + evolutionOptions: MaybeRefOrGetter, + ): Promise { + const resolvedRepoRef = toValue(repoRef) + if (!resolvedRepoRef || resolvedRepoRef.provider !== 'github') return [] + + const resolvedOptions = toValue(evolutionOptions) + + const cache = contributorsEvolutionCache + const cacheKey = `${resolvedRepoRef.owner}/${resolvedRepoRef.repo}` + + let statsPromise: Promise + + if (cache?.has(cacheKey)) { + statsPromise = cache.get(cacheKey)! + } else { + statsPromise = $fetch( + `/api/github/contributors-evolution/${resolvedRepoRef.owner}/${resolvedRepoRef.repo}`, + ) + .then(data => (Array.isArray(data) ? data : [])) + .catch(error => { + cache?.delete(cacheKey) + throw error + }) + + cache?.set(cacheKey, statsPromise) + } + + const stats = await statsPromise + const { start, end } = resolveDateRange(resolvedOptions, null) + + const { weeklyCounts, monthlyCounts, yearlyCounts } = buildContributorCounts(stats) + + if (resolvedOptions.granularity === 'week') { + return buildWeeklyEvolutionFromContributorCounts(weeklyCounts, start, end) + } + if (resolvedOptions.granularity === 'month') { + return buildMonthlyEvolutionFromContributorCounts(monthlyCounts, start, end) + } + if (resolvedOptions.granularity === 'year') { + return buildYearlyEvolutionFromContributorCounts(yearlyCounts, start, end) + } + + return [] + } + return { fetchPackageDownloadEvolution, fetchPackageLikesEvolution, + fetchRepoContributorsEvolution, getNpmPackageCreationDate, } } diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 6990e3046..350353855 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -1365,7 +1365,11 @@ const showSkeleton = shallowRef(false) - + ( + FIXTURE_PATHS.githubContributorsStats, + ) + if (contributorsStats) { + return { data: contributorsStats } + } + return { data: [] } + } + // Contributors endpoint: /repos/{owner}/{repo}/contributors const contributorsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/contributors$/) if (contributorsMatch) { diff --git a/server/api/github/contributors-evolution/[owner]/[repo].get.ts b/server/api/github/contributors-evolution/[owner]/[repo].get.ts new file mode 100644 index 000000000..6ab333c67 --- /dev/null +++ b/server/api/github/contributors-evolution/[owner]/[repo].get.ts @@ -0,0 +1,62 @@ +import type { CachedFetchFunction, CachedFetchResult } from '#shared/utils/fetch-cache-config' +import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' + +type GitHubContributorWeek = { + w: number + a: number + d: number + c: number +} + +type GitHubContributorStats = { + total: number + weeks: GitHubContributorWeek[] +} + +export default defineCachedEventHandler( + async event => { + const owner = getRouterParam(event, 'owner') + const repo = getRouterParam(event, 'repo') + + if (!owner || !repo) { + throw createError({ + status: 400, + message: 'repository not provided', + }) + } + + let cachedFetch: CachedFetchFunction + if (event.context.cachedFetch) { + cachedFetch = event.context.cachedFetch + } else { + cachedFetch = async ( + url: string, + options: Parameters[1] = {}, + _ttl?: number, + ): Promise> => { + const data = (await $fetch(url, options)) as T + return { data, isStale: false, cachedAt: null } + } + } + + try { + const { data } = await cachedFetch( + `https://api.github.com/repos/${owner}/${repo}/stats/contributors`, + { + headers: { + 'User-Agent': 'npmx', + 'Accept': 'application/vnd.github+json', + }, + }, + CACHE_MAX_AGE_ONE_DAY, + ) + + return Array.isArray(data) ? data : [] + } catch { + return [] + } + }, + { + maxAge: CACHE_MAX_AGE_ONE_DAY, + }, +) diff --git a/test/fixtures/github/contributors-stats.json b/test/fixtures/github/contributors-stats.json new file mode 100644 index 000000000..6b4af0779 --- /dev/null +++ b/test/fixtures/github/contributors-stats.json @@ -0,0 +1,18 @@ +[ + { + "total": 5, + "weeks": [ + { "w": 1700438400, "a": 12, "d": 3, "c": 2 }, + { "w": 1701043200, "a": 0, "d": 0, "c": 0 }, + { "w": 1701648000, "a": 7, "d": 1, "c": 1 } + ] + }, + { + "total": 9, + "weeks": [ + { "w": 1700438400, "a": 20, "d": 5, "c": 4 }, + { "w": 1701043200, "a": 2, "d": 0, "c": 1 }, + { "w": 1702252800, "a": 4, "d": 1, "c": 1 } + ] + } +] From f0f4fd53847380fd1b4e301ad7e5c5a71293a70b Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 12 Feb 2026 14:36:30 -0600 Subject: [PATCH 2/8] Fix type issues --- app/components/Package/TrendsChart.vue | 22 +++++++++---------- .../Package/WeeklyDownloadStats.vue | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 66ddd7598..23da122e5 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -39,7 +39,7 @@ const props = withDefaults( * Used when `weeklyDownloads` is not provided. */ packageNames?: string[] - repoRef?: RepoRef | null + repoRef?: RepoRef createdIso?: string | null /** When true, shows facet selector (e.g. Downloads / Likes). */ @@ -337,7 +337,7 @@ const effectivePackageNames = computed(() => { return single ? [single] : [] }) -const repoRefsByPackage = shallowRef>({}) +const repoRefsByPackage = shallowRef>({}) const repoRefsRequestToken = shallowRef(0) async function loadRepoRefsForPackages(packages: string[]) { @@ -354,21 +354,21 @@ async function loadRepoRefsForPackages(packages: string[]) { const encoded = encodePackageName(name) const meta = await $fetch(`/api/registry/package-meta/${encoded}`) const repoUrl = meta?.links?.repository - const ref = repoUrl ? parseRepoUrl(repoUrl) : null + const ref = repoUrl ? parseRepoUrl(repoUrl) : undefined return { name, ref } }), ) if (currentToken !== repoRefsRequestToken.value) return - const next: Record = {} + const next: Record = {} for (const [index, entry] of settled.entries()) { const name = packages[index] if (!name) continue if (entry.status === 'fulfilled') { - next[name] = entry.value.ref ?? null + next[name] = entry.value.ref ?? undefined } else { - next[name] = null + next[name] = undefined } } repoRefsByPackage.value = next @@ -637,7 +637,7 @@ const DEFAULT_METRIC_ID: MetricId = 'downloads' type MetricContext = { packageName: string - repoRef: RepoRef | null + repoRef: RepoRef | undefined } type MetricDef = { @@ -856,7 +856,7 @@ async function loadMetric(metricId: MetricId) { const settled = await Promise.allSettled( packageNames.map(async pkg => { - const repoRef = metricId === 'contributors' ? repoRefsByPackage.value[pkg] : null + const repoRef = metricId === 'contributors' ? repoRefsByPackage.value[pkg] : undefined const result = await fetchFn({ packageName: pkg, repoRef }) return { pkg, result: (result ?? []) as EvolutionData } }), @@ -898,7 +898,7 @@ async function loadMetric(metricId: MetricId) { } } - const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef ?? null }) + const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef }) if (currentToken !== state.requestToken) return state.evolution = (result ?? []) as EvolutionData @@ -1498,7 +1498,7 @@ const chartConfig = computed(() => { axis: { yLabel: $t('package.trends.y_axis_label', { granularity: getGranularityLabel(selectedGranularity.value), - facet: activeMetricDef.value.label, + facet: activeMetricDef.value?.label, }), yLabelOffsetX: 12, fontSize: isMobile.value ? 32 : 24, @@ -1695,7 +1695,7 @@ watch(selectedMetric, value => {
diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue index c5b8a236e..957d5fe03 100644 --- a/app/components/Package/WeeklyDownloadStats.vue +++ b/app/components/Package/WeeklyDownloadStats.vue @@ -8,7 +8,7 @@ import type { RepoRef } from '#shared/utils/git-providers' const props = defineProps<{ packageName: string createdIso: string | null - repoRef?: RepoRef | null + repoRef?: RepoRef }>() const router = useRouter() @@ -317,7 +317,7 @@ const config = computed(() => { :weeklyDownloads="weeklyDownloads" :inModal="true" :packageName="props.packageName" - :repoRef="props.repoRef ?? null" + :repoRef="props.repoRef" :createdIso="createdIso" permalink show-facet-selector From 85778ac197debbf4f4408e36d32e7bf36efaef04 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 12 Feb 2026 15:24:17 -0600 Subject: [PATCH 3/8] retry on 202 --- .../[owner]/[repo].get.ts | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/server/api/github/contributors-evolution/[owner]/[repo].get.ts b/server/api/github/contributors-evolution/[owner]/[repo].get.ts index 6ab333c67..3f87d1b73 100644 --- a/server/api/github/contributors-evolution/[owner]/[repo].get.ts +++ b/server/api/github/contributors-evolution/[owner]/[repo].get.ts @@ -1,4 +1,3 @@ -import type { CachedFetchFunction, CachedFetchResult } from '#shared/utils/fetch-cache-config' import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' type GitHubContributorWeek = { @@ -25,33 +24,40 @@ export default defineCachedEventHandler( }) } - let cachedFetch: CachedFetchFunction - if (event.context.cachedFetch) { - cachedFetch = event.context.cachedFetch - } else { - cachedFetch = async ( - url: string, - options: Parameters[1] = {}, - _ttl?: number, - ): Promise> => { - const data = (await $fetch(url, options)) as T - return { data, isStale: false, cachedAt: null } - } + const url = `https://api.github.com/repos/${owner}/${repo}/stats/contributors` + const headers = { + 'User-Agent': 'npmx', + 'Accept': 'application/vnd.github+json', } + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + const maxAttempts = 6 + let delayMs = 1000 + try { - const { data } = await cachedFetch( - `https://api.github.com/repos/${owner}/${repo}/stats/contributors`, - { - headers: { - 'User-Agent': 'npmx', - 'Accept': 'application/vnd.github+json', - }, - }, - CACHE_MAX_AGE_ONE_DAY, - ) - - return Array.isArray(data) ? data : [] + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const response = await $fetch.raw(url, { headers }) + const status = response.status + + if (status === 200) { + return Array.isArray(response._data) ? response._data : [] + } + + if (status === 204) { + return [] + } + + if (status === 202) { + if (attempt === maxAttempts - 1) return [] + await sleep(delayMs) + delayMs = Math.min(delayMs * 2, 16_000) + continue + } + + return [] + } + + return [] } catch { return [] } From b11e4a01f24ba8ce6da9264ca4731d4d3262bdcf Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 12 Feb 2026 15:33:14 -0600 Subject: [PATCH 4/8] Go back to handling both null and undefined --- app/components/Package/TrendsChart.vue | 4 ++-- app/components/Package/WeeklyDownloadStats.vue | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 23da122e5..8a3ebe025 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -39,7 +39,7 @@ const props = withDefaults( * Used when `weeklyDownloads` is not provided. */ packageNames?: string[] - repoRef?: RepoRef + repoRef?: RepoRef | null | undefined createdIso?: string | null /** When true, shows facet selector (e.g. Downloads / Likes). */ @@ -637,7 +637,7 @@ const DEFAULT_METRIC_ID: MetricId = 'downloads' type MetricContext = { packageName: string - repoRef: RepoRef | undefined + repoRef?: RepoRef | undefined | null } type MetricDef = { diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue index 957d5fe03..0e11bdc74 100644 --- a/app/components/Package/WeeklyDownloadStats.vue +++ b/app/components/Package/WeeklyDownloadStats.vue @@ -8,7 +8,7 @@ import type { RepoRef } from '#shared/utils/git-providers' const props = defineProps<{ packageName: string createdIso: string | null - repoRef?: RepoRef + repoRef?: RepoRef | null | undefined }>() const router = useRouter() From 22ce764968aac205a199257f054d829b6e8d3973 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 12 Feb 2026 16:46:39 -0600 Subject: [PATCH 5/8] Fix review comments --- app/components/Package/TrendsChart.vue | 80 ++++++++++---------------- app/composables/useCharts.ts | 58 +++++++++++++++++++ i18n/locales/en.json | 1 + i18n/schema.json | 3 + lunaria/files/en-GB.json | 1 + lunaria/files/en-US.json | 1 + 6 files changed, 95 insertions(+), 49 deletions(-) diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 8a3ebe025..492a1fa23 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -7,9 +7,6 @@ import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors' import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' import type { RepoRef } from '#shared/utils/git-providers' -import { parseRepoUrl } from '#shared/utils/git-providers' -import type { PackageMetaResponse } from '#shared/types' -import { encodePackageName } from '#shared/utils/npm' import type { ChartTimeGranularity, DailyDataPoint, @@ -337,52 +334,28 @@ const effectivePackageNames = computed(() => { return single ? [single] : [] }) -const repoRefsByPackage = shallowRef>({}) -const repoRefsRequestToken = shallowRef(0) - -async function loadRepoRefsForPackages(packages: string[]) { - if (!import.meta.client) return - if (!packages.length) { - repoRefsByPackage.value = {} - return - } - - const currentToken = ++repoRefsRequestToken.value - - const settled = await Promise.allSettled( - packages.map(async name => { - const encoded = encodePackageName(name) - const meta = await $fetch(`/api/registry/package-meta/${encoded}`) - const repoUrl = meta?.links?.repository - const ref = repoUrl ? parseRepoUrl(repoUrl) : undefined - return { name, ref } - }), - ) - - if (currentToken !== repoRefsRequestToken.value) return +const { + fetchPackageDownloadEvolution, + fetchPackageLikesEvolution, + fetchRepoContributorsEvolution, + fetchRepoRefsForPackages, +} = useCharts() - const next: Record = {} - for (const [index, entry] of settled.entries()) { - const name = packages[index] - if (!name) continue - if (entry.status === 'fulfilled') { - next[name] = entry.value.ref ?? undefined - } else { - next[name] = undefined - } - } - repoRefsByPackage.value = next -} +const repoRefsByPackage = shallowRef>({}) +const repoRefsRequestToken = shallowRef(0) watch( () => effectivePackageNames.value, - names => { + async names => { if (!import.meta.client) return if (!isMultiPackageMode.value) { repoRefsByPackage.value = {} return } - loadRepoRefsForPackages(names) + const currentToken = ++repoRefsRequestToken.value + const refs = await fetchRepoRefsForPackages(names) + if (currentToken !== repoRefsRequestToken.value) return + repoRefsByPackage.value = refs }, { immediate: true }, ) @@ -626,18 +599,12 @@ function applyDateRange>(base: T): T & DateRan return next } -const { - fetchPackageDownloadEvolution, - fetchPackageLikesEvolution, - fetchRepoContributorsEvolution, -} = useCharts() - type MetricId = 'downloads' | 'likes' | 'contributors' const DEFAULT_METRIC_ID: MetricId = 'downloads' type MetricContext = { packageName: string - repoRef?: RepoRef | undefined | null + repoRef?: RepoRef | null } type MetricDef = { @@ -681,7 +648,7 @@ const METRICS = computed(() => { id: 'contributors', label: $t('package.trends.items.contributors'), fetch: ({ repoRef }, opts) => fetchRepoContributorsEvolution(repoRef, opts), - supportsMulti: false, + supportsMulti: true, }) } @@ -700,6 +667,16 @@ const effectivePackageNamesForMetric = computed(() => { ) }) +const skippedPackagesWithoutGitHub = computed(() => { + if (!isMultiPackageMode.value) return [] + if (selectedMetric.value !== 'contributors') return [] + if (!effectivePackageNames.value.length) return [] + + return effectivePackageNames.value.filter( + name => repoRefsByPackage.value[name]?.provider !== 'github', + ) +}) + const availableGranularities = computed(() => { if (selectedMetric.value === 'contributors') { return ['weekly', 'monthly', 'yearly'] @@ -856,7 +833,7 @@ async function loadMetric(metricId: MetricId) { const settled = await Promise.allSettled( packageNames.map(async pkg => { - const repoRef = metricId === 'contributors' ? repoRefsByPackage.value[pkg] : undefined + const repoRef = metricId === 'contributors' ? repoRefsByPackage.value[pkg] : null const result = await fetchFn({ packageName: pkg, repoRef }) return { pkg, result: (result ?? []) as EvolutionData } }), @@ -1692,6 +1669,11 @@ watch(selectedMetric, value => {