forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { MaybeRefOrGetter } from 'vue'
2import { toValue } from 'vue'
3import type {
4 DailyDataPoint,
5 DailyRawPoint,
6 EvolutionOptions,
7 MonthlyDataPoint,
8 WeeklyDataPoint,
9 YearlyDataPoint,
10} from '~/types/chart'
11import type { RepoRef } from '#shared/utils/git-providers'
12import { parseRepoUrl } from '#shared/utils/git-providers'
13import type { PackageMetaResponse } from '#shared/types'
14import { encodePackageName } from '#shared/utils/npm'
15import { fetchNpmDownloadsRange } from '~/utils/npm/api'
16
17export type PackumentLikeForTime = {
18 time?: Record<string, string>
19}
20
21function toIsoDateString(date: Date): string {
22 return date.toISOString().slice(0, 10)
23}
24
25function addDays(date: Date, days: number): Date {
26 const updatedDate = new Date(date)
27 updatedDate.setUTCDate(updatedDate.getUTCDate() + days)
28 return updatedDate
29}
30
31function startOfUtcMonth(date: Date): Date {
32 return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1))
33}
34
35function startOfUtcYear(date: Date): Date {
36 return new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
37}
38
39function parseIsoDateOnly(value: string): Date {
40 return new Date(`${value}T00:00:00.000Z`)
41}
42
43function formatIsoDateOnly(date: Date): string {
44 return date.toISOString().slice(0, 10)
45}
46
47function differenceInUtcDaysInclusive(startIso: string, endIso: string): number {
48 const start = parseIsoDateOnly(startIso)
49 const end = parseIsoDateOnly(endIso)
50 return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
51}
52
53function splitIsoRangeIntoChunksInclusive(
54 startIso: string,
55 endIso: string,
56 maximumDaysPerRequest: number,
57): Array<{ startIso: string; endIso: string }> {
58 const totalDays = differenceInUtcDaysInclusive(startIso, endIso)
59 if (totalDays <= maximumDaysPerRequest) return [{ startIso, endIso }]
60
61 const chunks: Array<{ startIso: string; endIso: string }> = []
62 let cursorStart = parseIsoDateOnly(startIso)
63 const finalEnd = parseIsoDateOnly(endIso)
64
65 while (cursorStart.getTime() <= finalEnd.getTime()) {
66 const cursorEnd = addDays(cursorStart, maximumDaysPerRequest - 1)
67 const actualEnd = cursorEnd.getTime() < finalEnd.getTime() ? cursorEnd : finalEnd
68
69 chunks.push({
70 startIso: formatIsoDateOnly(cursorStart),
71 endIso: formatIsoDateOnly(actualEnd),
72 })
73
74 cursorStart = addDays(actualEnd, 1)
75 }
76
77 return chunks
78}
79
80function mergeDailyPoints(points: DailyRawPoint[]): DailyRawPoint[] {
81 const valuesByDay = new Map<string, number>()
82
83 for (const point of points) {
84 valuesByDay.set(point.day, (valuesByDay.get(point.day) ?? 0) + point.value)
85 }
86
87 return Array.from(valuesByDay.entries())
88 .sort(([a], [b]) => a.localeCompare(b))
89 .map(([day, value]) => ({ day, value }))
90}
91
92export function buildDailyEvolutionFromDaily(daily: DailyRawPoint[]): DailyDataPoint[] {
93 return daily
94 .slice()
95 .sort((a, b) => a.day.localeCompare(b.day))
96 .map(item => {
97 const dayDate = parseIsoDateOnly(item.day)
98 const timestamp = dayDate.getTime()
99
100 return { day: item.day, value: item.value, timestamp }
101 })
102}
103
104export function buildRollingWeeklyEvolutionFromDaily(
105 daily: DailyRawPoint[],
106 rangeStartIso: string,
107 rangeEndIso: string,
108): WeeklyDataPoint[] {
109 const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
110 const rangeStartDate = parseIsoDateOnly(rangeStartIso)
111 const rangeEndDate = parseIsoDateOnly(rangeEndIso)
112
113 const groupedByIndex = new Map<number, number>()
114
115 for (const item of sorted) {
116 const itemDate = parseIsoDateOnly(item.day)
117 const dayOffset = Math.floor((itemDate.getTime() - rangeStartDate.getTime()) / 86400000)
118 if (dayOffset < 0) continue
119
120 const weekIndex = Math.floor(dayOffset / 7)
121 groupedByIndex.set(weekIndex, (groupedByIndex.get(weekIndex) ?? 0) + item.value)
122 }
123
124 return Array.from(groupedByIndex.entries())
125 .sort(([a], [b]) => a - b)
126 .map(([weekIndex, value]) => {
127 const weekStartDate = addDays(rangeStartDate, weekIndex * 7)
128 const weekEndDate = addDays(weekStartDate, 6)
129
130 // Clamp weekEnd to the actual data range end date
131 const clampedWeekEndDate =
132 weekEndDate.getTime() > rangeEndDate.getTime() ? rangeEndDate : weekEndDate
133
134 const weekStartIso = toIsoDateString(weekStartDate)
135 const weekEndIso = toIsoDateString(clampedWeekEndDate)
136
137 const timestampStart = weekStartDate.getTime()
138 const timestampEnd = clampedWeekEndDate.getTime()
139
140 return {
141 value,
142 weekKey: `${weekStartIso}_${weekEndIso}`,
143 weekStart: weekStartIso,
144 weekEnd: weekEndIso,
145 timestampStart,
146 timestampEnd,
147 }
148 })
149}
150
151export function buildMonthlyEvolutionFromDaily(daily: DailyRawPoint[]): MonthlyDataPoint[] {
152 const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
153 const valuesByMonth = new Map<string, number>()
154
155 for (const item of sorted) {
156 const month = item.day.slice(0, 7)
157 valuesByMonth.set(month, (valuesByMonth.get(month) ?? 0) + item.value)
158 }
159
160 return Array.from(valuesByMonth.entries())
161 .sort(([a], [b]) => a.localeCompare(b))
162 .map(([month, value]) => {
163 const monthStartDate = parseIsoDateOnly(`${month}-01`)
164 const timestamp = monthStartDate.getTime()
165 return { month, value, timestamp }
166 })
167}
168
169export function buildYearlyEvolutionFromDaily(daily: DailyRawPoint[]): YearlyDataPoint[] {
170 const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
171 const valuesByYear = new Map<string, number>()
172
173 for (const item of sorted) {
174 const year = item.day.slice(0, 4)
175 valuesByYear.set(year, (valuesByYear.get(year) ?? 0) + item.value)
176 }
177
178 return Array.from(valuesByYear.entries())
179 .sort(([a], [b]) => a.localeCompare(b))
180 .map(([year, value]) => {
181 const yearStartDate = parseIsoDateOnly(`${year}-01-01`)
182 const timestamp = yearStartDate.getTime()
183 return { year, value, timestamp }
184 })
185}
186
187const npmDailyRangeCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null
188const likesEvolutionCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null
189const contributorsEvolutionCache = import.meta.client
190 ? new Map<string, Promise<GitHubContributorStats[]>>()
191 : null
192const repoMetaCache = import.meta.client ? new Map<string, Promise<RepoRef | null>>() : null
193
194/** Clears client-side promise caches. Exported for use in tests. */
195export function clearClientCaches() {
196 npmDailyRangeCache?.clear()
197 likesEvolutionCache?.clear()
198 contributorsEvolutionCache?.clear()
199 repoMetaCache?.clear()
200}
201
202type GitHubContributorWeek = {
203 w: number
204 a: number
205 d: number
206 c: number
207}
208
209type GitHubContributorStats = {
210 total: number
211 weeks: GitHubContributorWeek[]
212}
213
214function pad2(value: number): string {
215 return value.toString().padStart(2, '0')
216}
217
218function toIsoMonthKey(date: Date): string {
219 return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}`
220}
221
222function isOverlappingRange(start: Date, end: Date, rangeStart: Date, rangeEnd: Date): boolean {
223 return end.getTime() >= rangeStart.getTime() && start.getTime() <= rangeEnd.getTime()
224}
225
226function 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
256function 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
281function 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
306function 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 }
334}
335
336async function fetchDailyRangeCached(packageName: string, startIso: string, endIso: string) {
337 const cache = npmDailyRangeCache
338
339 if (!cache) {
340 const response = await fetchNpmDownloadsRange(packageName, startIso, endIso)
341 return [...response.downloads]
342 .sort((a, b) => a.day.localeCompare(b.day))
343 .map(d => ({ day: d.day, value: d.downloads }))
344 }
345
346 const cacheKey = `${packageName}:${startIso}:${endIso}`
347 const cachedPromise = cache.get(cacheKey)
348 if (cachedPromise) return cachedPromise
349
350 const promise = fetchNpmDownloadsRange(packageName, startIso, endIso)
351 .then(response =>
352 [...response.downloads]
353 .sort((a, b) => a.day.localeCompare(b.day))
354 .map(d => ({ day: d.day, value: d.downloads })),
355 )
356 .catch(error => {
357 cache.delete(cacheKey)
358 throw error
359 })
360
361 cache.set(cacheKey, promise)
362 return promise
363}
364
365/**
366 * API limit workaround:
367 * If the requested range is larger than the API allows (≈18 months),
368 * split into multiple requests, then merge/sum by day.
369 */
370async function fetchDailyRangeChunked(packageName: string, startIso: string, endIso: string) {
371 const maximumDaysPerRequest = 540
372 const ranges = splitIsoRangeIntoChunksInclusive(startIso, endIso, maximumDaysPerRequest)
373
374 if (ranges.length === 1) {
375 return fetchDailyRangeCached(packageName, startIso, endIso)
376 }
377
378 const all: DailyRawPoint[] = []
379
380 for (const range of ranges) {
381 const part = await fetchDailyRangeCached(packageName, range.startIso, range.endIso)
382 all.push(...part)
383 }
384
385 return mergeDailyPoints(all)
386}
387
388function toDateOnly(value?: string): string | null {
389 if (!value) return null
390 const dateOnly = value.slice(0, 10)
391 return /^\d{4}-\d{2}-\d{2}$/.test(dateOnly) ? dateOnly : null
392}
393
394export function getNpmPackageCreationDate(packument: PackumentLikeForTime): string | null {
395 const time = packument.time
396 if (!time) return null
397 if (time.created) return time.created
398
399 const versionDates = Object.entries(time)
400 .filter(([key, value]) => key !== 'modified' && key !== 'created' && Boolean(value))
401 .map(([, value]) => value)
402 .sort((a, b) => a.localeCompare(b))
403
404 return versionDates[0] ?? null
405}
406
407export function useCharts() {
408 function resolveDateRange(
409 evolutionOptions: EvolutionOptions,
410 packageCreatedIso: string | null,
411 ): { start: Date; end: Date } {
412 const today = new Date()
413 const yesterday = new Date(
414 Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1),
415 )
416
417 const endDateOnly = toDateOnly(evolutionOptions.endDate)
418 const end = endDateOnly ? parseIsoDateOnly(endDateOnly) : yesterday
419
420 const startDateOnly = toDateOnly(evolutionOptions.startDate)
421 if (startDateOnly) {
422 const start = parseIsoDateOnly(startDateOnly)
423 return { start, end }
424 }
425
426 let start: Date
427
428 if (evolutionOptions.granularity === 'year') {
429 if (packageCreatedIso) {
430 start = startOfUtcYear(new Date(packageCreatedIso))
431 } else {
432 start = addDays(end, -(5 * 365) + 1)
433 }
434 } else if (evolutionOptions.granularity === 'month') {
435 const monthCount = evolutionOptions.months ?? 12
436 const firstOfThisMonth = startOfUtcMonth(end)
437 start = new Date(
438 Date.UTC(
439 firstOfThisMonth.getUTCFullYear(),
440 firstOfThisMonth.getUTCMonth() - (monthCount - 1),
441 1,
442 ),
443 )
444 } else if (evolutionOptions.granularity === 'week') {
445 const weekCount = evolutionOptions.weeks ?? 52
446
447 // Full rolling weeks ending on `end` (yesterday by default)
448 // Range length is exactly weekCount * 7 days (inclusive)
449 start = addDays(end, -(weekCount * 7) + 1)
450 } else {
451 start = addDays(end, -30 + 1)
452 }
453
454 return { start, end }
455 }
456
457 async function fetchPackageDownloadEvolution(
458 packageName: MaybeRefOrGetter<string>,
459 createdIso: MaybeRefOrGetter<string | null | undefined>,
460 evolutionOptions: MaybeRefOrGetter<EvolutionOptions>,
461 ): Promise<DailyDataPoint[] | WeeklyDataPoint[] | MonthlyDataPoint[] | YearlyDataPoint[]> {
462 const resolvedPackageName = toValue(packageName)
463 const resolvedCreatedIso = toValue(createdIso) ?? null
464 const resolvedOptions = toValue(evolutionOptions)
465
466 const { start, end } = resolveDateRange(resolvedOptions, resolvedCreatedIso)
467
468 const startIso = toIsoDateString(start)
469 const endIso = toIsoDateString(end)
470
471 const sortedDaily = await fetchDailyRangeChunked(resolvedPackageName, startIso, endIso)
472
473 if (resolvedOptions.granularity === 'day') return buildDailyEvolutionFromDaily(sortedDaily)
474 if (resolvedOptions.granularity === 'week')
475 return buildRollingWeeklyEvolutionFromDaily(sortedDaily, startIso, endIso)
476 if (resolvedOptions.granularity === 'month') return buildMonthlyEvolutionFromDaily(sortedDaily)
477 return buildYearlyEvolutionFromDaily(sortedDaily)
478 }
479
480 async function fetchPackageLikesEvolution(
481 packageName: MaybeRefOrGetter<string>,
482 evolutionOptions: MaybeRefOrGetter<EvolutionOptions>,
483 ): Promise<DailyDataPoint[] | WeeklyDataPoint[] | MonthlyDataPoint[] | YearlyDataPoint[]> {
484 const resolvedPackageName = toValue(packageName)
485 const resolvedOptions = toValue(evolutionOptions)
486
487 // Fetch daily likes data (with client-side promise caching)
488 const cache = likesEvolutionCache
489 const cacheKey = resolvedPackageName
490
491 let dailyLikesPromise: Promise<DailyRawPoint[]>
492
493 if (cache?.has(cacheKey)) {
494 dailyLikesPromise = cache.get(cacheKey)!
495 } else {
496 dailyLikesPromise = $fetch<Array<{ day: string; likes: number }>>(
497 `/api/social/likes-evolution/${resolvedPackageName}`,
498 )
499 .then(data => (data ?? []).map(d => ({ day: d.day, value: d.likes })))
500 .catch(error => {
501 cache?.delete(cacheKey)
502 throw error
503 })
504
505 cache?.set(cacheKey, dailyLikesPromise)
506 }
507
508 const sortedDaily = await dailyLikesPromise
509
510 const { start, end } = resolveDateRange(resolvedOptions, null)
511 const startIso = toIsoDateString(start)
512 const endIso = toIsoDateString(end)
513
514 const filteredDaily = sortedDaily.filter(d => d.day >= startIso && d.day <= endIso)
515
516 if (resolvedOptions.granularity === 'day') return buildDailyEvolutionFromDaily(filteredDaily)
517 if (resolvedOptions.granularity === 'week')
518 return buildRollingWeeklyEvolutionFromDaily(filteredDaily, startIso, endIso)
519 if (resolvedOptions.granularity === 'month')
520 return buildMonthlyEvolutionFromDaily(filteredDaily)
521 return buildYearlyEvolutionFromDaily(filteredDaily)
522 }
523
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
618 return {
619 fetchPackageDownloadEvolution,
620 fetchPackageLikesEvolution,
621 fetchRepoContributorsEvolution,
622 fetchRepoRefsForPackages,
623 getNpmPackageCreationDate,
624 }
625}