[READ-ONLY] a fast, modern browser for the npm registry
at main 625 lines 20 kB view raw
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}