@@ -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'
1115import { fetchNpmDownloadsRange } from '~/utils/npm/api'
1216
1317export type PackumentLikeForTime = {
@@ -182,11 +186,151 @@ export function buildYearlyEvolutionFromDaily(daily: DailyRawPoint[]): YearlyDat
182186
183187const npmDailyRangeCache = import . meta. client ? new Map < string , Promise < DailyRawPoint [ ] > > ( ) : null
184188const 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. */
187195export 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
192336async 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}
0 commit comments