diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 984acb04f..ef0944de4 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -6,6 +6,7 @@ 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 type { ChartTimeGranularity, DailyDataPoint, @@ -35,6 +36,7 @@ const props = withDefaults( * Used when `weeklyDownloads` is not provided. */ packageNames?: string[] + repoRef?: RepoRef | null | undefined createdIso?: string | null /** When true, shows facet selector (e.g. Downloads / Likes). */ @@ -332,6 +334,32 @@ const effectivePackageNames = computed(() => { return single ? [single] : [] }) +const { + fetchPackageDownloadEvolution, + fetchPackageLikesEvolution, + fetchRepoContributorsEvolution, + fetchRepoRefsForPackages, +} = useCharts() + +const repoRefsByPackage = shallowRef>({}) +const repoRefsRequestToken = shallowRef(0) + +watch( + () => effectivePackageNames.value, + async names => { + if (!import.meta.client) return + if (!isMultiPackageMode.value) { + repoRefsByPackage.value = {} + return + } + const currentToken = ++repoRefsRequestToken.value + const refs = await fetchRepoRefsForPackages(names) + if (currentToken !== repoRefsRequestToken.value) return + repoRefsByPackage.value = refs + }, + { immediate: true }, +) + const selectedGranularity = usePermalink('granularity', DEFAULT_GRANULARITY, { permanent: props.permalink, }) @@ -361,9 +389,10 @@ const isEndDateOnPeriodEnd = computed(() => { const isEstimationGranularity = computed( () => displayedGranularity.value === 'monthly' || displayedGranularity.value === 'yearly', ) -const shouldRenderEstimationOverlay = computed( - () => !pending.value && isEstimationGranularity.value, +const supportsEstimation = computed( + () => isEstimationGranularity.value && selectedMetric.value !== 'contributors', ) +const shouldRenderEstimationOverlay = computed(() => !pending.value && supportsEstimation.value) const startDate = usePermalink('start', '', { permanent: props.permalink, @@ -571,35 +600,112 @@ function applyDateRange>(base: T): T & DateRan return next } -const { fetchPackageDownloadEvolution, fetchPackageLikesEvolution } = 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: true, + }) + } + + 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 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'] + } + + 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 +730,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 +809,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 +876,7 @@ async function loadMetric(metricId: MetricId) { } } - const result = await fetchFn(pkg) + const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef }) if (currentToken !== state.requestToken) return state.evolution = (result ?? []) as EvolutionData @@ -778,9 +904,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 +930,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 +979,7 @@ const chartData = computed<{ } const state = activeMetricState.value - const names = effectivePackageNames.value + const names = effectivePackageNamesForMetric.value const granularity = displayedGranularity.value const timestampSet = new Set() @@ -877,9 +1019,15 @@ const chartData = computed<{ const normalisedDataset = computed(() => { return chartData.value.dataset?.map(d => { + const lastValue = d.series.at(-1) ?? 0 + + // Contributors is an absolute metric: keep the partial period value as-is. + const projectedLastValue = + selectedMetric.value === 'contributors' ? lastValue : extrapolateLastValue(lastValue) + return { ...d, - series: [...d.series.slice(0, -1), extrapolateLastValue(d.series.at(-1) ?? 0)], + series: [...d.series.slice(0, -1), projectedLastValue], } }) }) @@ -936,6 +1084,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 @@ -1052,6 +1207,8 @@ function getCompletionRatioForBucket(params: { * or the original `lastValue` when no extrapolation should be applied. */ function extrapolateLastValue(lastValue: number) { + if (selectedMetric.value === 'contributors') return lastValue + if (displayedGranularity.value !== 'monthly' && displayedGranularity.value !== 'yearly') return lastValue @@ -1114,20 +1271,20 @@ function drawEstimationLine(svg: Record) { lines.push(` - ) { }) // Inject the estimation legend item when necessary - if ( - ['monthly', 'yearly'].includes(displayedGranularity.value) && - !isEndDateOnPeriodEnd.value && - !isZoomed.value - ) { + if (supportsEstimation.value && !isEndDateOnPeriodEnd.value && !isZoomed.value) { seriesNames.push(` - { 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, @@ -1472,12 +1625,7 @@ watch(selectedMetric, value => { 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" />
@@ -1535,10 +1683,15 @@ watch(selectedMetric, value => {
+ +

+ {{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }} + {{ skippedPackagesWithoutGitHub.join(', ') }} +

@@ -1557,12 +1710,7 @@ watch(selectedMetric, value => { -
+
() const router = useRouter() @@ -315,6 +317,7 @@ const config = computed(() => { :weeklyDownloads="weeklyDownloads" :inModal="true" :packageName="props.packageName" + :repoRef="props.repoRef" :createdIso="createdIso" permalink show-facet-selector diff --git a/app/composables/useCharts.ts b/app/composables/useCharts.ts index 2fa34eedf..339d760e2 100644 --- a/app/composables/useCharts.ts +++ b/app/composables/useCharts.ts @@ -8,6 +8,10 @@ import type { WeeklyDataPoint, YearlyDataPoint, } from '~/types/chart' +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 { fetchNpmDownloadsRange } from '~/utils/npm/api' export type PackumentLikeForTime = { @@ -182,11 +186,151 @@ 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 +const repoMetaCache = 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() + repoMetaCache?.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 +521,105 @@ 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 [] + } + + async function fetchRepoRefsForPackages( + packageNames: MaybeRefOrGetter, + ): Promise> { + const names = (toValue(packageNames) ?? []).map(n => String(n).trim()).filter(Boolean) + if (!import.meta.client || !names.length) return {} + + const settled = await Promise.allSettled( + names.map(async name => { + const cacheKey = name + const cache = repoMetaCache + if (cache?.has(cacheKey)) { + const ref = await cache.get(cacheKey)! + return { name, ref } + } + + const promise = $fetch( + `/api/registry/package-meta/${encodePackageName(name)}`, + ) + .then(meta => { + const repoUrl = meta?.links?.repository + return repoUrl ? parseRepoUrl(repoUrl) : null + }) + .catch(error => { + cache?.delete(cacheKey) + throw error + }) + + cache?.set(cacheKey, promise) + const ref = await promise + return { name, ref } + }), + ) + + const next: Record = {} + for (const [index, entry] of settled.entries()) { + const name = names[index] + if (!name) continue + if (entry.status === 'fulfilled') { + next[name] = entry.value.ref ?? null + } else { + next[name] = null + } + } + + return next + } + return { fetchPackageDownloadEvolution, fetchPackageLikesEvolution, + fetchRepoContributorsEvolution, + fetchRepoRefsForPackages, getNpmPackageCreationDate, } } diff --git a/app/pages/compare.vue b/app/pages/compare.vue index 9f7d7e5a7..ac2fbcf60 100644 --- a/app/pages/compare.vue +++ b/app/pages/compare.vue @@ -198,7 +198,10 @@ useSeoMeta({
@@ -247,9 +250,12 @@ useSeoMeta({
-