[READ-ONLY] a fast, modern browser for the npm registry

feat(ui): generalize downloads chart and add social likes option (#1310)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by philippeserhal.com

autofix-ci[bot] and committed by
GitHub
c1f33ac9 28012358

+964 -397
+2 -2
app/components/Compare/LineChart.vue
··· 1 1 <script setup lang="ts"> 2 - import DownloadAnalytics from '../Package/DownloadAnalytics.vue' 2 + import TrendsChart from '../Package/TrendsChart.vue' 3 3 4 4 const { packages } = defineProps<{ 5 5 packages: string[] ··· 8 8 9 9 <template> 10 10 <div class="font-mono"> 11 - <DownloadAnalytics :package-names="packages" :in-modal="false" /> 11 + <TrendsChart :package-names="packages" :in-modal="false" show-facet-selector /> 12 12 </div> 13 13 </template>
+6 -2
app/components/Package/ChartModal.vue
··· 1 - <script setup lang="ts"></script> 1 + <script setup lang="ts"> 2 + defineProps<{ 3 + modalTitle?: string 4 + }>() 5 + </script> 2 6 3 7 <template> 4 8 <Modal 5 - :modalTitle="$t('package.downloads.modal_title')" 9 + :modalTitle="modalTitle ?? $t('package.trends.title')" 6 10 id="chart-modal" 7 11 class="h-full sm:h-min sm:border sm:border-border sm:rounded-lg shadow-xl sm:max-h-[90vh] sm:max-w-3xl" 8 12 >
+211 -128
app/components/Package/DownloadAnalytics.vue app/components/Package/TrendsChart.vue
··· 8 8 9 9 const props = defineProps<{ 10 10 // For single package downloads history 11 - weeklyDownloads?: WeeklyDownloadPoint[] 11 + weeklyDownloads?: WeeklyDataPoint[] 12 12 inModal?: boolean 13 13 14 14 /** ··· 23 23 */ 24 24 packageNames?: string[] 25 25 createdIso?: string | null 26 + 27 + /** When true, shows facet selector (e.g. Downloads / Likes). */ 28 + showFacetSelector?: boolean 26 29 }>() 27 30 28 31 const { locale } = useI18n() ··· 51 54 await nextTick() 52 55 isMounted.value = true 53 56 54 - loadNow() 57 + loadMetric(selectedMetric.value) 55 58 }) 56 59 57 60 const { colors } = useCssVariables( ··· 101 104 const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth) 102 105 103 106 type ChartTimeGranularity = 'daily' | 'weekly' | 'monthly' | 'yearly' 104 - type EvolutionData = 105 - | DailyDownloadPoint[] 106 - | WeeklyDownloadPoint[] 107 - | MonthlyDownloadPoint[] 108 - | YearlyDownloadPoint[] 107 + const DEFAULT_GRANULARITY: ChartTimeGranularity = 'weekly' 108 + type EvolutionData = DailyDataPoint[] | WeeklyDataPoint[] | MonthlyDataPoint[] | YearlyDataPoint[] 109 109 110 110 type DateRangeFields = { 111 111 startDate?: string ··· 116 116 return typeof value === 'object' && value !== null 117 117 } 118 118 119 - function isWeeklyDataset(data: unknown): data is WeeklyDownloadPoint[] { 119 + function isWeeklyDataset(data: unknown): data is WeeklyDataPoint[] { 120 120 return ( 121 121 Array.isArray(data) && 122 122 data.length > 0 && 123 123 isRecord(data[0]) && 124 124 'weekStart' in data[0] && 125 125 'weekEnd' in data[0] && 126 - 'downloads' in data[0] 126 + 'value' in data[0] 127 127 ) 128 128 } 129 - function isDailyDataset(data: unknown): data is DailyDownloadPoint[] { 129 + function isDailyDataset(data: unknown): data is DailyDataPoint[] { 130 130 return ( 131 131 Array.isArray(data) && 132 132 data.length > 0 && 133 133 isRecord(data[0]) && 134 134 'day' in data[0] && 135 - 'downloads' in data[0] 135 + 'value' in data[0] 136 136 ) 137 137 } 138 - function isMonthlyDataset(data: unknown): data is MonthlyDownloadPoint[] { 138 + function isMonthlyDataset(data: unknown): data is MonthlyDataPoint[] { 139 139 return ( 140 140 Array.isArray(data) && 141 141 data.length > 0 && 142 142 isRecord(data[0]) && 143 143 'month' in data[0] && 144 - 'downloads' in data[0] 144 + 'value' in data[0] 145 145 ) 146 146 } 147 - function isYearlyDataset(data: unknown): data is YearlyDownloadPoint[] { 147 + function isYearlyDataset(data: unknown): data is YearlyDataPoint[] { 148 148 return ( 149 149 Array.isArray(data) && 150 150 data.length > 0 && 151 151 isRecord(data[0]) && 152 152 'year' in data[0] && 153 - 'downloads' in data[0] 153 + 'value' in data[0] 154 154 ) 155 155 } 156 156 ··· 188 188 { 189 189 name: seriesName, 190 190 type: 'line', 191 - series: dataset.map(d => d.downloads), 191 + series: dataset.map(d => d.value), 192 192 color: accent.value, 193 193 useArea: true, 194 194 }, ··· 202 202 { 203 203 name: seriesName, 204 204 type: 'line', 205 - series: dataset.map(d => d.downloads), 205 + series: dataset.map(d => d.value), 206 206 color: accent.value, 207 207 useArea: true, 208 208 }, ··· 216 216 { 217 217 name: seriesName, 218 218 type: 'line', 219 - series: dataset.map(d => d.downloads), 219 + series: dataset.map(d => d.value), 220 220 color: accent.value, 221 221 useArea: true, 222 222 }, ··· 230 230 { 231 231 name: seriesName, 232 232 type: 'line', 233 - series: dataset.map(d => d.downloads), 233 + series: dataset.map(d => d.value), 234 234 color: accent.value, 235 235 useArea: true, 236 236 }, ··· 247 247 * 248 248 * Each returned point contains: 249 249 * - `timestamp`: the numeric time value used for x-axis alignment 250 - * - `downloads`: the corresponding value at that time 250 + * - `value`: the corresponding value at that time 251 251 * 252 252 * The timestamp field is selected according to granularity: 253 253 * - **daily** → `timestamp` ··· 263 263 * 264 264 * @param selectedGranularity - Active chart time granularity 265 265 * @param dataset - Raw evolution dataset to extract points from 266 - * @returns An array of normalized `{ timestamp, downloads }` points 266 + * @returns An array of normalized `{ timestamp, value }` points 267 267 */ 268 268 function extractSeriesPoints( 269 269 selectedGranularity: ChartTimeGranularity, 270 270 dataset: EvolutionData, 271 - ): Array<{ timestamp: number; downloads: number }> { 271 + ): Array<{ timestamp: number; value: number }> { 272 272 if (selectedGranularity === 'weekly' && isWeeklyDataset(dataset)) { 273 - return dataset.map(d => ({ timestamp: d.timestampEnd, downloads: d.downloads })) 274 - } 275 - if (selectedGranularity === 'daily' && isDailyDataset(dataset)) { 276 - return dataset.map(d => ({ timestamp: d.timestamp, downloads: d.downloads })) 273 + return dataset.map(d => ({ timestamp: d.timestampEnd, value: d.value })) 277 274 } 278 - if (selectedGranularity === 'monthly' && isMonthlyDataset(dataset)) { 279 - return dataset.map(d => ({ timestamp: d.timestamp, downloads: d.downloads })) 280 - } 281 - if (selectedGranularity === 'yearly' && isYearlyDataset(dataset)) { 282 - return dataset.map(d => ({ timestamp: d.timestamp, downloads: d.downloads })) 275 + if ( 276 + (selectedGranularity === 'daily' && isDailyDataset(dataset)) || 277 + (selectedGranularity === 'monthly' && isMonthlyDataset(dataset)) || 278 + (selectedGranularity === 'yearly' && isYearlyDataset(dataset)) 279 + ) { 280 + return (dataset as Array<{ timestamp: number; value: number }>).map(d => ({ 281 + timestamp: d.timestamp, 282 + value: d.value, 283 + })) 283 284 } 284 285 return [] 285 286 } ··· 314 315 return single ? [single] : [] 315 316 }) 316 317 317 - const selectedGranularity = shallowRef<ChartTimeGranularity>('weekly') 318 - const displayedGranularity = shallowRef<ChartTimeGranularity>('weekly') 318 + const selectedGranularity = shallowRef<ChartTimeGranularity>(DEFAULT_GRANULARITY) 319 + const displayedGranularity = shallowRef<ChartTimeGranularity>(DEFAULT_GRANULARITY) 319 320 320 321 const isEndDateOnPeriodEnd = computed(() => { 321 322 const g = selectedGranularity.value ··· 495 496 const options = shallowRef< 496 497 | { granularity: 'day'; startDate?: string; endDate?: string } 497 498 | { granularity: 'week'; weeks: number; startDate?: string; endDate?: string } 498 - | { granularity: 'month'; months: number; startDate?: string; endDate?: string } 499 + | { 500 + granularity: 'month' 501 + months: number 502 + startDate?: string 503 + endDate?: string 504 + } 499 505 | { granularity: 'year'; startDate?: string; endDate?: string } 500 506 >({ granularity: 'week', weeks: 52 }) 501 507 ··· 540 546 return next 541 547 } 542 548 543 - const { fetchPackageDownloadEvolution } = useCharts() 549 + const { fetchPackageDownloadEvolution, fetchPackageLikesEvolution } = useCharts() 544 550 545 - const evolution = shallowRef<EvolutionData>(props.weeklyDownloads ?? []) 546 - const evolutionsByPackage = shallowRef<Record<string, EvolutionData>>({}) 547 - const pending = shallowRef(false) 551 + type MetricId = 'downloads' | 'likes' 552 + const DEFAULT_METRIC_ID: MetricId = 'downloads' 553 + 554 + type MetricDef = { 555 + id: MetricId 556 + label: string 557 + fetch: (pkg: string, options: EvolutionOptions) => Promise<EvolutionData> 558 + } 559 + 560 + const METRICS = computed<MetricDef[]>(() => [ 561 + { 562 + id: 'downloads', 563 + label: $t('package.trends.items.downloads'), 564 + fetch: (pkg, opts) => 565 + fetchPackageDownloadEvolution(pkg, props.createdIso ?? null, opts) as Promise<EvolutionData>, 566 + }, 567 + { 568 + id: 'likes', 569 + label: $t('package.trends.items.likes'), 570 + fetch: (pkg, opts) => fetchPackageLikesEvolution(pkg, opts) as Promise<EvolutionData>, 571 + }, 572 + ]) 573 + 574 + const selectedMetric = shallowRef<MetricId>(DEFAULT_METRIC_ID) 575 + 576 + // Per-metric state keyed by metric id 577 + const metricStates = reactive< 578 + Record< 579 + MetricId, 580 + { 581 + pending: boolean 582 + evolution: EvolutionData 583 + evolutionsByPackage: Record<string, EvolutionData> 584 + requestToken: number 585 + } 586 + > 587 + >({ 588 + downloads: { 589 + pending: false, 590 + evolution: props.weeklyDownloads ?? [], 591 + evolutionsByPackage: {}, 592 + requestToken: 0, 593 + }, 594 + likes: { 595 + pending: false, 596 + evolution: [], 597 + evolutionsByPackage: {}, 598 + requestToken: 0, 599 + }, 600 + }) 601 + 602 + const activeMetricState = computed(() => metricStates[selectedMetric.value]) 603 + const activeMetricDef = computed(() => METRICS.value.find(m => m.id === selectedMetric.value)!) 604 + const pending = computed(() => activeMetricState.value.pending) 548 605 549 606 const isMounted = shallowRef(false) 550 - let requestToken = 0 551 607 552 608 // Watches granularity and date inputs to keep request options in sync and 553 609 // manage the loading state. ··· 574 630 575 631 const packageNames = effectivePackageNames.value 576 632 if (!import.meta.client || !packageNames.length) { 577 - pending.value = false 633 + activeMetricState.value.pending = false 578 634 return 579 635 } 580 636 581 - const o = options.value as any 582 - const hasExplicitRange = Boolean(o.startDate || o.endDate) 637 + const o = options.value 638 + const hasExplicitRange = ('startDate' in o && o.startDate) || ('endDate' in o && o.endDate) 583 639 584 640 // Do not show loading when weeklyDownloads is already provided 585 641 if ( 642 + selectedMetric.value === DEFAULT_METRIC_ID && 586 643 !isMultiPackageMode.value && 587 - o.granularity === 'week' && 644 + granularityValue === DEFAULT_GRANULARITY && 588 645 props.weeklyDownloads?.length && 589 646 !hasExplicitRange 590 647 ) { 591 - pending.value = false 648 + activeMetricState.value.pending = false 592 649 return 593 650 } 594 651 595 - pending.value = true 652 + activeMetricState.value.pending = true 596 653 }, 597 654 { immediate: true }, 598 655 ) 599 656 600 657 /** 601 - * Fetches download evolution data based on the current granularity, 658 + * Fetches evolution data for a given metric based on the current granularity, 602 659 * date range, and package selection. 603 660 * 604 661 * This function: ··· 606 663 * - supports both single-package and multi-package modes 607 664 * - applies request de-duplication via a request token to avoid race conditions 608 665 * - updates the appropriate reactive stores with fetched data 609 - * - manages the `pending` loading state 610 - * 611 - * Behavior details: 612 - * - In multi-package mode, all packages are fetched in parallel and partial 613 - * failures are tolerated using `Promise.allSettled` 614 - * - In single-package mode, weekly data is reused from `weeklyDownloads` 615 - * when available and no explicit date range is requested 616 - * - Outdated responses are discarded when a newer request supersedes them 617 - * 666 + * - manages the metric's `pending` loading state 618 667 */ 619 - async function loadNow() { 668 + async function loadMetric(metricId: MetricId) { 620 669 if (!import.meta.client) return 621 670 622 671 const packageNames = effectivePackageNames.value 623 672 if (!packageNames.length) return 624 673 625 - const currentToken = ++requestToken 626 - pending.value = true 674 + const state = metricStates[metricId] 675 + const metric = METRICS.value.find(m => m.id === metricId)! 676 + const currentToken = ++state.requestToken 677 + state.pending = true 678 + 679 + const fetchFn = (pkg: string) => metric.fetch(pkg, options.value) 627 680 628 681 try { 629 682 if (isMultiPackageMode.value) { 630 683 const settled = await Promise.allSettled( 631 684 packageNames.map(async pkg => { 632 - const result = await fetchPackageDownloadEvolution( 633 - pkg, 634 - props.createdIso ?? null, 635 - options.value, 636 - ) 685 + const result = await fetchFn(pkg) 637 686 return { pkg, result: (result ?? []) as EvolutionData } 638 687 }), 639 688 ) 640 689 641 - if (currentToken !== requestToken) return 690 + if (currentToken !== state.requestToken) return 642 691 643 692 const next: Record<string, EvolutionData> = {} 644 693 for (const entry of settled) { 645 694 if (entry.status === 'fulfilled') next[entry.value.pkg] = entry.value.result 646 695 } 647 696 648 - evolutionsByPackage.value = next 697 + state.evolutionsByPackage = next 649 698 displayedGranularity.value = selectedGranularity.value 650 699 return 651 700 } 652 701 653 702 const pkg = packageNames[0] ?? '' 654 703 if (!pkg) { 655 - evolution.value = [] 704 + state.evolution = [] 656 705 displayedGranularity.value = selectedGranularity.value 657 706 return 658 707 } 659 708 660 - const o = options.value 661 - const hasExplicitRange = Boolean((o as any).startDate || (o as any).endDate) 662 - if (o.granularity === 'week' && props.weeklyDownloads?.length && !hasExplicitRange) { 663 - evolution.value = props.weeklyDownloads 664 - displayedGranularity.value = 'weekly' 665 - return 709 + // In single-package mode the parent already fetches weekly downloads for the 710 + // sparkline (WeeklyDownloadStats). When the user hasn't customised the date 711 + // range we can reuse that prop directly and skip a redundant API call. 712 + if (metricId === DEFAULT_METRIC_ID) { 713 + const o = options.value 714 + const hasExplicitRange = ('startDate' in o && o.startDate) || ('endDate' in o && o.endDate) 715 + if ( 716 + selectedGranularity.value === DEFAULT_GRANULARITY && 717 + props.weeklyDownloads?.length && 718 + !hasExplicitRange 719 + ) { 720 + state.evolution = props.weeklyDownloads 721 + displayedGranularity.value = DEFAULT_GRANULARITY 722 + return 723 + } 666 724 } 667 725 668 - const result = await fetchPackageDownloadEvolution(pkg, props.createdIso ?? null, options.value) 669 - if (currentToken !== requestToken) return 726 + const result = await fetchFn(pkg) 727 + if (currentToken !== state.requestToken) return 670 728 671 - evolution.value = (result ?? []) as EvolutionData 729 + state.evolution = (result ?? []) as EvolutionData 672 730 displayedGranularity.value = selectedGranularity.value 673 731 } catch { 674 - if (currentToken !== requestToken) return 675 - if (isMultiPackageMode.value) evolutionsByPackage.value = {} 676 - else evolution.value = [] 732 + if (currentToken !== state.requestToken) return 733 + if (isMultiPackageMode.value) state.evolutionsByPackage = {} 734 + else state.evolution = [] 677 735 } finally { 678 - if (currentToken === requestToken) pending.value = false 736 + if (currentToken === state.requestToken) state.pending = false 679 737 } 680 738 } 681 739 ··· 687 745 // - prevents unnecessary API load and visual flicker of the loading state 688 746 // 689 747 const debouncedLoadNow = useDebounceFn(() => { 690 - loadNow() 748 + loadMetric(selectedMetric.value) 691 749 }, 1000) 692 750 693 751 const fetchTriggerKey = computed(() => { 694 752 const names = effectivePackageNames.value.join(',') 695 - const o = options.value as any 753 + const o = options.value 696 754 return [ 697 755 isMultiPackageMode.value ? 'M' : 'S', 698 756 names, 699 757 String(props.createdIso ?? ''), 700 758 String(o.granularity ?? ''), 701 - String(o.weeks ?? ''), 702 - String(o.months ?? ''), 703 - String(o.startDate ?? ''), 704 - String(o.endDate ?? ''), 759 + String('weeks' in o ? (o.weeks ?? '') : ''), 760 + String('months' in o ? (o.months ?? '') : ''), 761 + String('startDate' in o ? (o.startDate ?? '') : ''), 762 + String('endDate' in o ? (o.endDate ?? '') : ''), 705 763 ].join('|') 706 764 }) 707 765 ··· 716 774 ) 717 775 718 776 const effectiveDataSingle = computed<EvolutionData>(() => { 719 - if (displayedGranularity.value === 'weekly' && props.weeklyDownloads?.length) { 720 - if (isWeeklyDataset(evolution.value) && evolution.value.length) return evolution.value 777 + const state = activeMetricState.value 778 + if ( 779 + selectedMetric.value === DEFAULT_METRIC_ID && 780 + displayedGranularity.value === DEFAULT_GRANULARITY && 781 + props.weeklyDownloads?.length 782 + ) { 783 + if (isWeeklyDataset(state.evolution) && state.evolution.length) return state.evolution 721 784 return props.weeklyDownloads 722 785 } 723 - return evolution.value 786 + return state.evolution 724 787 }) 725 788 726 789 /** 727 - * Normalized chart data derived from the fetched evolution datasets. 790 + * Normalized chart data derived from the active metric's evolution datasets. 728 791 * 729 - * This computed value adapts its behavior based on the current mode: 730 - * 731 - * - **Single-package mode** 732 - * - Delegates formatting to `formatXyDataset` 733 - * - Produces a single series with its corresponding timestamps 734 - * 735 - * - **Multi-package mode** 736 - * - Merges multiple package datasets into a shared time axis 737 - * - Aligns all series on the same sorted list of timestamps 738 - * - Fills missing datapoints with `0` to keep series lengths consistent 739 - * - Assigns framework-specific colors when applicable 740 - * 792 + * Adapts its behavior based on the current mode: 793 + * - **Single-package mode**: formats via `formatXyDataset` 794 + * - **Multi-package mode**: merges datasets into a shared time axis 795 + 741 796 * The returned structure matches the expectations of `VueUiXy`: 742 797 * - `dataset`: array of series definitions, or `null` when no data is available 743 798 * - `dates`: sorted list of timestamps used as the x-axis reference ··· 745 800 * Returning `dataset: null` explicitly signals the absence of data and allows 746 801 * the template to handle empty states without ambiguity. 747 802 */ 748 - const chartData = computed<{ dataset: VueUiXyDatasetItem[] | null; dates: number[] }>(() => { 803 + const chartData = computed<{ 804 + dataset: VueUiXyDatasetItem[] | null 805 + dates: number[] 806 + }>(() => { 749 807 if (!isMultiPackageMode.value) { 750 808 const pkg = effectivePackageNames.value[0] ?? props.packageName ?? '' 751 809 return formatXyDataset(displayedGranularity.value, effectiveDataSingle.value, pkg) 752 810 } 753 811 812 + const state = activeMetricState.value 754 813 const names = effectivePackageNames.value 755 814 const granularity = displayedGranularity.value 756 815 757 816 const timestampSet = new Set<number>() 758 - const pointsByPackage = new Map<string, Array<{ timestamp: number; downloads: number }>>() 817 + const pointsByPackage = new Map<string, Array<{ timestamp: number; value: number }>>() 759 818 760 819 for (const pkg of names) { 761 - const data = evolutionsByPackage.value[pkg] ?? [] 820 + const data = state.evolutionsByPackage[pkg] ?? [] 762 821 const points = extractSeriesPoints(granularity, data) 763 822 pointsByPackage.set(pkg, points) 764 823 for (const p of points) timestampSet.add(p.timestamp) ··· 770 829 const dataset: VueUiXyDatasetItem[] = names.map(pkg => { 771 830 const points = pointsByPackage.get(pkg) ?? [] 772 831 const map = new Map<number, number>() 773 - for (const p of points) map.set(p.timestamp, p.downloads) 832 + for (const p of points) map.set(p.timestamp, p.value) 774 833 775 834 const series = dates.map(t => map.get(t) ?? 0) 776 835 777 - const item: VueUiXyDatasetItem = { name: pkg, type: 'line', series } as VueUiXyDatasetItem 836 + const item: VueUiXyDatasetItem = { 837 + name: pkg, 838 + type: 'line', 839 + series, 840 + } as VueUiXyDatasetItem 778 841 779 842 if (isListedFramework(pkg)) { 780 843 item.color = getFrameworkColor(pkg) 781 844 } 782 - // Other packages default to built-in palette 783 845 return item 784 846 }) 785 847 ··· 1423 1485 backgroundColor: colors.value.bg, 1424 1486 padding: { bottom: displayedGranularity.value === 'yearly' ? 84 : 64, right: 100 }, // padding right is set to leave space of last datapoint label(s) 1425 1487 userOptions: { 1426 - buttons: { pdf: false, labels: false, fullscreen: false, table: false, tooltip: false }, 1488 + buttons: { 1489 + pdf: false, 1490 + labels: false, 1491 + fullscreen: false, 1492 + table: false, 1493 + tooltip: false, 1494 + }, 1427 1495 buttonTitles: { 1428 1496 csv: $t('package.trends.download_file', { fileType: 'CSV' }), 1429 1497 img: $t('package.trends.download_file', { fileType: 'PNG' }), ··· 1467 1535 axis: { 1468 1536 yLabel: $t('package.trends.y_axis_label', { 1469 1537 granularity: getGranularityLabel(selectedGranularity.value), 1470 - facet: $t('package.trends.items.downloads'), 1538 + facet: activeMetricDef.value.label, 1471 1539 }), 1472 1540 yLabelOffsetX: 12, 1473 1541 fontSize: isMobile.value ? 32 : 24, ··· 1574 1642 }, 1575 1643 } 1576 1644 }) 1645 + 1646 + // Trigger data loading when the metric is switched 1647 + watch(selectedMetric, value => { 1648 + if (!isMounted.value) return 1649 + loadMetric(value) 1650 + }) 1577 1651 </script> 1578 1652 1579 1653 <template> 1580 - <div class="w-full relative" id="download-analytics" :aria-busy="pending ? 'true' : 'false'"> 1654 + <div 1655 + class="w-full relative" 1656 + id="trends-chart" 1657 + :aria-busy="activeMetricState.pending ? 'true' : 'false'" 1658 + > 1581 1659 <div class="w-full mb-4 flex flex-col gap-3"> 1582 1660 <div class="flex flex-col sm:flex-row gap-3 sm:gap-2 sm:items-end"> 1583 1661 <SelectField 1662 + v-if="showFacetSelector" 1663 + id="trends-metric-select" 1664 + v-model="selectedMetric" 1665 + :items="METRICS.map(m => ({ label: m.label, value: m.id }))" 1666 + :label="$t('package.trends.facet')" 1667 + /> 1668 + 1669 + <SelectField 1584 1670 :label="$t('package.trends.granularity')" 1585 1671 id="granularity" 1586 1672 v-model="selectedGranularity" 1587 - :disabled="pending" 1673 + :disabled="activeMetricState.pending" 1588 1674 :items="[ 1589 1675 { label: $t('package.trends.granularity_daily'), value: 'daily' }, 1590 1676 { label: $t('package.trends.granularity_weekly'), value: 'weekly' }, ··· 1609 1695 <InputBase 1610 1696 id="startDate" 1611 1697 v-model="startDate" 1612 - :disabled="pending" 1698 + :disabled="activeMetricState.pending" 1613 1699 type="date" 1614 1700 class="w-full min-w-0 bg-transparent ps-7" 1615 1701 size="medium" ··· 1629 1715 <InputBase 1630 1716 id="endDate" 1631 1717 v-model="endDate" 1632 - :disabled="pending" 1718 + :disabled="activeMetricState.pending" 1633 1719 type="date" 1634 1720 class="w-full min-w-0 bg-transparent ps-7" 1635 1721 size="medium" ··· 1650 1736 </div> 1651 1737 </div> 1652 1738 1653 - <h2 id="download-analytics-title" class="sr-only"> 1654 - {{ $t('package.downloads.title') }} 1739 + <h2 id="trends-chart-title" class="sr-only"> 1740 + {{ $t('package.trends.title') }} — {{ activeMetricDef.label }} 1655 1741 </h2> 1656 1742 1657 - <div role="region" aria-labelledby="download-analytics-title"> 1743 + <!-- Chart panel (active metric) --> 1744 + <div role="region" aria-labelledby="trends-chart-title" class="min-h-[260px]"> 1658 1745 <ClientOnly v-if="chartData.dataset"> 1659 1746 <div :data-pending="pending" :data-minimap-visible="maxDatapoints > 6"> 1660 1747 <VueUiXy ··· 1858 1945 <div class="min-h-[260px]" /> 1859 1946 </template> 1860 1947 </ClientOnly> 1861 - </div> 1862 1948 1863 - <div 1864 - v-if="!chartData.dataset && !pending" 1865 - class="min-h-[260px] flex items-center justify-center text-fg-subtle font-mono text-sm" 1866 - > 1867 - {{ 1868 - $t('package.trends.no_data', { 1869 - facet: $t('package.trends.items.downloads'), 1870 - }) 1871 - }} 1949 + <div 1950 + v-if="!chartData.dataset && !activeMetricState.pending" 1951 + class="min-h-[260px] flex items-center justify-center text-fg-subtle font-mono text-sm" 1952 + > 1953 + {{ $t('package.trends.no_data') }} 1954 + </div> 1872 1955 </div> 1873 1956 1874 1957 <div 1875 - v-if="pending" 1958 + v-if="activeMetricState.pending" 1876 1959 role="status" 1877 1960 aria-live="polite" 1878 1961 class="absolute top-1/2 inset-is-1/2 -translate-x-1/2 -translate-y-1/2 text-xs text-fg-subtle font-mono bg-bg/70 backdrop-blur px-3 py-2 rounded-md border border-border" ··· 1899 1982 1900 1983 /* Override default placement of the refresh button to have it to the minimap's side */ 1901 1984 @media screen and (min-width: 767px) { 1902 - #download-analytics .vue-data-ui-refresh-button { 1985 + #trends-chart .vue-data-ui-refresh-button { 1903 1986 top: -0.6rem !important; 1904 1987 left: calc(100% + 2rem) !important; 1905 1988 }
+6 -5
app/components/Package/WeeklyDownloadStats.vue
··· 86 86 return isDarkMode.value ? accent.value : lightenOklch(accent.value, 0.5) 87 87 }) 88 88 89 - const weeklyDownloads = shallowRef<WeeklyDownloadPoint[]>([]) 89 + const weeklyDownloads = shallowRef<WeeklyDataPoint[]>([]) 90 90 const isLoadingWeeklyDownloads = shallowRef(true) 91 91 const hasWeeklyDownloads = computed(() => weeklyDownloads.value.length > 0) 92 92 ··· 111 111 () => props.createdIso, 112 112 () => ({ granularity: 'week' as const, weeks: 52 }), 113 113 ) 114 - weeklyDownloads.value = (result as WeeklyDownloadPoint[]) ?? [] 114 + weeklyDownloads.value = (result as WeeklyDataPoint[]) ?? [] 115 115 } catch { 116 116 weeklyDownloads.value = [] 117 117 } finally { ··· 130 130 131 131 const dataset = computed(() => 132 132 weeklyDownloads.value.map(d => ({ 133 - value: d?.downloads ?? 0, 133 + value: d?.value ?? 0, 134 134 period: $t('package.trends.date_range', { 135 135 start: d.weekStart ?? '-', 136 136 end: d.weekEnd ?? '-', ··· 277 277 <!-- The Chart is mounted after the dialog has transitioned --> 278 278 <!-- This avoids flaky behavior that hides the chart's minimap half of the time --> 279 279 <Transition name="opacity" mode="out-in"> 280 - <PackageDownloadAnalytics 280 + <PackageTrendsChart 281 281 v-if="hasChartModalTransitioned" 282 282 :weeklyDownloads="weeklyDownloads" 283 283 :inModal="true" 284 284 :packageName="props.packageName" 285 285 :createdIso="createdIso" 286 + show-facet-selector 286 287 /> 287 288 </Transition> 288 289 289 - <!-- This placeholder bears the same dimensions as the PackageDownloadAnalytics component --> 290 + <!-- This placeholder bears the same dimensions as the PackageTrendsChart component --> 290 291 <!-- Avoids CLS when the dialog has transitioned --> 291 292 <div 292 293 v-if="!hasChartModalTransitioned"
+113 -79
app/composables/useCharts.ts
··· 6 6 time?: Record<string, string> 7 7 } 8 8 9 - export type DailyDownloadPoint = { downloads: number; day: string; timestamp: number } 10 - export type WeeklyDownloadPoint = { 11 - downloads: number 9 + export type DailyDataPoint = { value: number; day: string; timestamp: number } 10 + export type WeeklyDataPoint = { 11 + value: number 12 12 weekKey: string 13 13 weekStart: string 14 14 weekEnd: string 15 15 timestampStart: number 16 16 timestampEnd: number 17 17 } 18 - export type MonthlyDownloadPoint = { downloads: number; month: string; timestamp: number } 19 - export type YearlyDownloadPoint = { downloads: number; year: string; timestamp: number } 18 + export type MonthlyDataPoint = { value: number; month: string; timestamp: number } 19 + export type YearlyDataPoint = { value: number; year: string; timestamp: number } 20 20 21 - type PackageDownloadEvolutionOptionsBase = { 21 + type EvolutionOptionsBase = { 22 22 startDate?: string 23 23 endDate?: string 24 24 } 25 25 26 - export type PackageDownloadEvolutionOptionsDay = PackageDownloadEvolutionOptionsBase & { 26 + export type EvolutionOptionsDay = EvolutionOptionsBase & { 27 27 granularity: 'day' 28 28 } 29 - export type PackageDownloadEvolutionOptionsWeek = PackageDownloadEvolutionOptionsBase & { 29 + export type EvolutionOptionsWeek = EvolutionOptionsBase & { 30 30 granularity: 'week' 31 31 weeks?: number 32 32 } 33 - export type PackageDownloadEvolutionOptionsMonth = PackageDownloadEvolutionOptionsBase & { 33 + export type EvolutionOptionsMonth = EvolutionOptionsBase & { 34 34 granularity: 'month' 35 35 months?: number 36 36 } 37 - export type PackageDownloadEvolutionOptionsYear = PackageDownloadEvolutionOptionsBase & { 37 + export type EvolutionOptionsYear = EvolutionOptionsBase & { 38 38 granularity: 'year' 39 39 } 40 40 41 - export type PackageDownloadEvolutionOptions = 42 - | PackageDownloadEvolutionOptionsDay 43 - | PackageDownloadEvolutionOptionsWeek 44 - | PackageDownloadEvolutionOptionsMonth 45 - | PackageDownloadEvolutionOptionsYear 41 + export type EvolutionOptions = 42 + | EvolutionOptionsDay 43 + | EvolutionOptionsWeek 44 + | EvolutionOptionsMonth 45 + | EvolutionOptionsYear 46 46 47 - type DailyDownloadsResponse = { downloads: Array<{ day: string; downloads: number }> } 47 + type DailyRawPoint = { day: string; value: number } 48 48 49 49 function toIsoDateString(date: Date): string { 50 50 return date.toISOString().slice(0, 10) ··· 105 105 return chunks 106 106 } 107 107 108 - function mergeDailyPoints( 109 - points: Array<{ day: string; downloads: number }>, 110 - ): Array<{ day: string; downloads: number }> { 111 - const downloadsByDay = new Map<string, number>() 108 + function mergeDailyPoints(points: DailyRawPoint[]): DailyRawPoint[] { 109 + const valuesByDay = new Map<string, number>() 112 110 113 111 for (const point of points) { 114 - downloadsByDay.set(point.day, (downloadsByDay.get(point.day) ?? 0) + point.downloads) 112 + valuesByDay.set(point.day, (valuesByDay.get(point.day) ?? 0) + point.value) 115 113 } 116 114 117 - return Array.from(downloadsByDay.entries()) 115 + return Array.from(valuesByDay.entries()) 118 116 .sort(([a], [b]) => a.localeCompare(b)) 119 - .map(([day, downloads]) => ({ day, downloads })) 117 + .map(([day, value]) => ({ day, value })) 120 118 } 121 119 122 - function buildDailyEvolutionFromDaily( 123 - daily: Array<{ day: string; downloads: number }>, 124 - ): Array<{ day: string; downloads: number; timestamp: number }> { 120 + export function buildDailyEvolutionFromDaily(daily: DailyRawPoint[]): DailyDataPoint[] { 125 121 return daily 126 122 .slice() 127 123 .sort((a, b) => a.day.localeCompare(b.day)) ··· 129 125 const dayDate = parseIsoDateOnly(item.day) 130 126 const timestamp = dayDate.getTime() 131 127 132 - return { day: item.day, downloads: item.downloads, timestamp } 128 + return { day: item.day, value: item.value, timestamp } 133 129 }) 134 130 } 135 131 136 - function buildRollingWeeklyEvolutionFromDaily( 137 - daily: Array<{ day: string; downloads: number }>, 132 + export function buildRollingWeeklyEvolutionFromDaily( 133 + daily: DailyRawPoint[], 138 134 rangeStartIso: string, 139 135 rangeEndIso: string, 140 - ): WeeklyDownloadPoint[] { 136 + ): WeeklyDataPoint[] { 141 137 const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day)) 142 138 const rangeStartDate = parseIsoDateOnly(rangeStartIso) 143 139 const rangeEndDate = parseIsoDateOnly(rangeEndIso) ··· 150 146 if (dayOffset < 0) continue 151 147 152 148 const weekIndex = Math.floor(dayOffset / 7) 153 - groupedByIndex.set(weekIndex, (groupedByIndex.get(weekIndex) ?? 0) + item.downloads) 149 + groupedByIndex.set(weekIndex, (groupedByIndex.get(weekIndex) ?? 0) + item.value) 154 150 } 155 151 156 152 return Array.from(groupedByIndex.entries()) 157 153 .sort(([a], [b]) => a - b) 158 - .map(([weekIndex, downloads]) => { 154 + .map(([weekIndex, value]) => { 159 155 const weekStartDate = addDays(rangeStartDate, weekIndex * 7) 160 156 const weekEndDate = addDays(weekStartDate, 6) 161 157 ··· 170 166 const timestampEnd = clampedWeekEndDate.getTime() 171 167 172 168 return { 173 - downloads, 169 + value, 174 170 weekKey: `${weekStartIso}_${weekEndIso}`, 175 171 weekStart: weekStartIso, 176 172 weekEnd: weekEndIso, ··· 180 176 }) 181 177 } 182 178 183 - function buildMonthlyEvolutionFromDaily( 184 - daily: Array<{ day: string; downloads: number }>, 185 - ): Array<{ month: string; downloads: number; timestamp: number }> { 179 + export function buildMonthlyEvolutionFromDaily(daily: DailyRawPoint[]): MonthlyDataPoint[] { 186 180 const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day)) 187 - const downloadsByMonth = new Map<string, number>() 181 + const valuesByMonth = new Map<string, number>() 188 182 189 183 for (const item of sorted) { 190 184 const month = item.day.slice(0, 7) 191 - downloadsByMonth.set(month, (downloadsByMonth.get(month) ?? 0) + item.downloads) 185 + valuesByMonth.set(month, (valuesByMonth.get(month) ?? 0) + item.value) 192 186 } 193 187 194 - return Array.from(downloadsByMonth.entries()) 188 + return Array.from(valuesByMonth.entries()) 195 189 .sort(([a], [b]) => a.localeCompare(b)) 196 - .map(([month, downloads]) => { 190 + .map(([month, value]) => { 197 191 const monthStartDate = parseIsoDateOnly(`${month}-01`) 198 192 const timestamp = monthStartDate.getTime() 199 - return { month, downloads, timestamp } 193 + return { month, value, timestamp } 200 194 }) 201 195 } 202 196 203 - function buildYearlyEvolutionFromDaily( 204 - daily: Array<{ day: string; downloads: number }>, 205 - ): Array<{ year: string; downloads: number; timestamp: number }> { 197 + export function buildYearlyEvolutionFromDaily(daily: DailyRawPoint[]): YearlyDataPoint[] { 206 198 const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day)) 207 - const downloadsByYear = new Map<string, number>() 199 + const valuesByYear = new Map<string, number>() 208 200 209 201 for (const item of sorted) { 210 202 const year = item.day.slice(0, 4) 211 - downloadsByYear.set(year, (downloadsByYear.get(year) ?? 0) + item.downloads) 203 + valuesByYear.set(year, (valuesByYear.get(year) ?? 0) + item.value) 212 204 } 213 205 214 - return Array.from(downloadsByYear.entries()) 206 + return Array.from(valuesByYear.entries()) 215 207 .sort(([a], [b]) => a.localeCompare(b)) 216 - .map(([year, downloads]) => { 208 + .map(([year, value]) => { 217 209 const yearStartDate = parseIsoDateOnly(`${year}-01-01`) 218 210 const timestamp = yearStartDate.getTime() 219 - return { year, downloads, timestamp } 211 + return { year, value, timestamp } 220 212 }) 221 213 } 222 214 223 - function getClientDailyRangePromiseCache() { 224 - if (!import.meta.client) return null 225 - 226 - const globalScope = globalThis as unknown as { 227 - __npmDailyRangePromiseCache?: Map<string, Promise<Array<{ day: string; downloads: number }>>> 228 - } 229 - 230 - if (!globalScope.__npmDailyRangePromiseCache) { 231 - globalScope.__npmDailyRangePromiseCache = new Map() 232 - } 215 + const npmDailyRangeCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null 216 + const likesEvolutionCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null 233 217 234 - return globalScope.__npmDailyRangePromiseCache 218 + /** Clears client-side promise caches. Exported for use in tests. */ 219 + export function clearClientCaches() { 220 + npmDailyRangeCache?.clear() 221 + likesEvolutionCache?.clear() 235 222 } 236 223 237 224 async function fetchDailyRangeCached(packageName: string, startIso: string, endIso: string) { 238 - const cache = getClientDailyRangePromiseCache() 225 + const cache = npmDailyRangeCache 239 226 240 227 if (!cache) { 241 228 const response = await fetchNpmDownloadsRange(packageName, startIso, endIso) 242 - return [...response.downloads].sort((a, b) => a.day.localeCompare(b.day)) 229 + return [...response.downloads] 230 + .sort((a, b) => a.day.localeCompare(b.day)) 231 + .map(d => ({ day: d.day, value: d.downloads })) 243 232 } 244 233 245 234 const cacheKey = `${packageName}:${startIso}:${endIso}` ··· 247 236 if (cachedPromise) return cachedPromise 248 237 249 238 const promise = fetchNpmDownloadsRange(packageName, startIso, endIso) 250 - .then((response: DailyDownloadsResponse) => 251 - [...response.downloads].sort((a, b) => a.day.localeCompare(b.day)), 239 + .then(response => 240 + [...response.downloads] 241 + .sort((a, b) => a.day.localeCompare(b.day)) 242 + .map(d => ({ day: d.day, value: d.downloads })), 252 243 ) 253 244 .catch(error => { 254 245 cache.delete(cacheKey) ··· 272 263 return fetchDailyRangeCached(packageName, startIso, endIso) 273 264 } 274 265 275 - const all: Array<{ day: string; downloads: number }> = [] 266 + const all: DailyRawPoint[] = [] 276 267 277 268 for (const range of ranges) { 278 269 const part = await fetchDailyRangeCached(packageName, range.startIso, range.endIso) ··· 288 279 return /^\d{4}-\d{2}-\d{2}$/.test(dateOnly) ? dateOnly : null 289 280 } 290 281 291 - function getNpmPackageCreationDate(packument: PackumentLikeForTime): string | null { 282 + export function getNpmPackageCreationDate(packument: PackumentLikeForTime): string | null { 292 283 const time = packument.time 293 284 if (!time) return null 294 285 if (time.created) return time.created ··· 303 294 304 295 export function useCharts() { 305 296 function resolveDateRange( 306 - downloadEvolutionOptions: PackageDownloadEvolutionOptions, 297 + evolutionOptions: EvolutionOptions, 307 298 packageCreatedIso: string | null, 308 299 ): { start: Date; end: Date } { 309 300 const today = new Date() ··· 311 302 Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1), 312 303 ) 313 304 314 - const endDateOnly = toDateOnly(downloadEvolutionOptions.endDate) 305 + const endDateOnly = toDateOnly(evolutionOptions.endDate) 315 306 const end = endDateOnly ? parseIsoDateOnly(endDateOnly) : yesterday 316 307 317 - const startDateOnly = toDateOnly(downloadEvolutionOptions.startDate) 308 + const startDateOnly = toDateOnly(evolutionOptions.startDate) 318 309 if (startDateOnly) { 319 310 const start = parseIsoDateOnly(startDateOnly) 320 311 return { start, end } ··· 322 313 323 314 let start: Date 324 315 325 - if (downloadEvolutionOptions.granularity === 'year') { 316 + if (evolutionOptions.granularity === 'year') { 326 317 if (packageCreatedIso) { 327 318 start = startOfUtcYear(new Date(packageCreatedIso)) 328 319 } else { 329 320 start = addDays(end, -(5 * 365) + 1) 330 321 } 331 - } else if (downloadEvolutionOptions.granularity === 'month') { 332 - const monthCount = downloadEvolutionOptions.months ?? 12 322 + } else if (evolutionOptions.granularity === 'month') { 323 + const monthCount = evolutionOptions.months ?? 12 333 324 const firstOfThisMonth = startOfUtcMonth(end) 334 325 start = new Date( 335 326 Date.UTC( ··· 338 329 1, 339 330 ), 340 331 ) 341 - } else if (downloadEvolutionOptions.granularity === 'week') { 342 - const weekCount = downloadEvolutionOptions.weeks ?? 52 332 + } else if (evolutionOptions.granularity === 'week') { 333 + const weekCount = evolutionOptions.weeks ?? 52 343 334 344 335 // Full rolling weeks ending on `end` (yesterday by default) 345 336 // Range length is exactly weekCount * 7 days (inclusive) ··· 354 345 async function fetchPackageDownloadEvolution( 355 346 packageName: MaybeRefOrGetter<string>, 356 347 createdIso: MaybeRefOrGetter<string | null | undefined>, 357 - downloadEvolutionOptions: MaybeRefOrGetter<PackageDownloadEvolutionOptions>, 358 - ): Promise< 359 - DailyDownloadPoint[] | WeeklyDownloadPoint[] | MonthlyDownloadPoint[] | YearlyDownloadPoint[] 360 - > { 348 + evolutionOptions: MaybeRefOrGetter<EvolutionOptions>, 349 + ): Promise<DailyDataPoint[] | WeeklyDataPoint[] | MonthlyDataPoint[] | YearlyDataPoint[]> { 361 350 const resolvedPackageName = toValue(packageName) 362 351 const resolvedCreatedIso = toValue(createdIso) ?? null 363 - const resolvedOptions = toValue(downloadEvolutionOptions) 352 + const resolvedOptions = toValue(evolutionOptions) 364 353 365 354 const { start, end } = resolveDateRange(resolvedOptions, resolvedCreatedIso) 366 355 ··· 376 365 return buildYearlyEvolutionFromDaily(sortedDaily) 377 366 } 378 367 368 + async function fetchPackageLikesEvolution( 369 + packageName: MaybeRefOrGetter<string>, 370 + evolutionOptions: MaybeRefOrGetter<EvolutionOptions>, 371 + ): Promise<DailyDataPoint[] | WeeklyDataPoint[] | MonthlyDataPoint[] | YearlyDataPoint[]> { 372 + const resolvedPackageName = toValue(packageName) 373 + const resolvedOptions = toValue(evolutionOptions) 374 + 375 + // Fetch daily likes data (with client-side promise caching) 376 + const cache = likesEvolutionCache 377 + const cacheKey = resolvedPackageName 378 + 379 + let dailyLikesPromise: Promise<DailyRawPoint[]> 380 + 381 + if (cache?.has(cacheKey)) { 382 + dailyLikesPromise = cache.get(cacheKey)! 383 + } else { 384 + dailyLikesPromise = $fetch<Array<{ day: string; likes: number }>>( 385 + `/api/social/likes-evolution/${resolvedPackageName}`, 386 + ) 387 + .then(data => (data ?? []).map(d => ({ day: d.day, value: d.likes }))) 388 + .catch(error => { 389 + cache?.delete(cacheKey) 390 + throw error 391 + }) 392 + 393 + cache?.set(cacheKey, dailyLikesPromise) 394 + } 395 + 396 + const sortedDaily = await dailyLikesPromise 397 + 398 + const { start, end } = resolveDateRange(resolvedOptions, null) 399 + const startIso = toIsoDateString(start) 400 + const endIso = toIsoDateString(end) 401 + 402 + const filteredDaily = sortedDaily.filter(d => d.day >= startIso && d.day <= endIso) 403 + 404 + if (resolvedOptions.granularity === 'day') return buildDailyEvolutionFromDaily(filteredDaily) 405 + if (resolvedOptions.granularity === 'week') 406 + return buildRollingWeeklyEvolutionFromDaily(filteredDaily, startIso, endIso) 407 + if (resolvedOptions.granularity === 'month') 408 + return buildMonthlyEvolutionFromDaily(filteredDaily) 409 + return buildYearlyEvolutionFromDaily(filteredDaily) 410 + } 411 + 379 412 return { 380 413 fetchPackageDownloadEvolution, 414 + fetchPackageLikesEvolution, 381 415 getNpmPackageCreationDate, 382 416 } 383 417 }
+1 -2
i18n/locales/ar.json
··· 306 306 "downloads": { 307 307 "title": "التنزيلات الأسبوعية", 308 308 "analyze": "تحليل التنزيلات", 309 - "community_distribution": "عرض توزيع تبنّي المجتمع", 310 - "modal_title": "التنزيلات" 309 + "community_distribution": "عرض توزيع تبنّي المجتمع" 311 310 }, 312 311 "install_scripts": { 313 312 "title": "سكربتات التثبيت",
+1 -2
i18n/locales/az-AZ.json
··· 243 243 }, 244 244 "downloads": { 245 245 "title": "Həftəlik Endirmələr", 246 - "analyze": "Endirmələri təhlil et", 247 - "modal_title": "Endirmələr" 246 + "analyze": "Endirmələri təhlil et" 248 247 }, 249 248 "install_scripts": { 250 249 "title": "Quraşdırma Skriptləri",
+1 -2
i18n/locales/bn-IN.json
··· 272 272 }, 273 273 "downloads": { 274 274 "title": "সাপ্তাহিক ডাউনলোড", 275 - "analyze": "ডাউনলোড বিশ্লেষণ করুন", 276 - "modal_title": "ডাউনলোড" 275 + "analyze": "ডাউনলোড বিশ্লেষণ করুন" 277 276 }, 278 277 "install_scripts": { 279 278 "title": "ইনস্টল স্ক্রিপ্ট",
+1 -2
i18n/locales/cs-CZ.json
··· 278 278 }, 279 279 "downloads": { 280 280 "title": "Týdenní stažení", 281 - "analyze": "Analyzovat stažení", 282 - "modal_title": "Stažení" 281 + "analyze": "Analyzovat stažení" 283 282 }, 284 283 "install_scripts": { 285 284 "title": "Instalační skripty",
+1 -5
i18n/locales/de-DE.json
··· 329 329 "downloads": { 330 330 "title": "Wöchentliche Downloads", 331 331 "analyze": "Downloads analysieren", 332 - "community_distribution": "Community-Adoptionsverteilung ansehen", 333 - "modal_title": "Downloads" 332 + "community_distribution": "Community-Adoptionsverteilung ansehen" 334 333 }, 335 334 "install_scripts": { 336 335 "title": "Installationsskripte", ··· 960 959 "types_none": "Keine", 961 960 "vulnerabilities_summary": "{count} ({critical}C/{high}H)", 962 961 "up_to_you": "Deine Entscheidung!" 963 - }, 964 - "trends": { 965 - "title": "Wöchentliche Downloads" 966 962 } 967 963 } 968 964 },
+6 -4
i18n/locales/en.json
··· 339 339 "legend_estimation": "Estimation", 340 340 "no_data": "No data available", 341 341 "y_axis_label": "{granularity} {facet}", 342 + "facet": "Facet", 343 + "title": "Trends", 342 344 "items": { 343 - "downloads": "Downloads" 345 + "downloads": "Downloads", 346 + "likes": "Likes" 344 347 } 345 348 }, 346 349 "downloads": { 347 350 "title": "Weekly Downloads", 348 351 "analyze": "Analyze downloads", 349 - "community_distribution": "View community adoption distribution", 350 - "modal_title": "Downloads" 352 + "community_distribution": "View community adoption distribution" 351 353 }, 352 354 "install_scripts": { 353 355 "title": "Install Scripts", ··· 980 982 "up_to_you": "Up to you!" 981 983 }, 982 984 "trends": { 983 - "title": "Weekly Downloads" 985 + "title": "Compare Trends" 984 986 } 985 987 } 986 988 },
+1 -2
i18n/locales/es.json
··· 307 307 "downloads": { 308 308 "title": "Descargas Semanales", 309 309 "analyze": "Analizar descargas", 310 - "community_distribution": "Ver distribución de adopción comunitaria", 311 - "modal_title": "Descargas" 310 + "community_distribution": "Ver distribución de adopción comunitaria" 312 311 }, 313 312 "install_scripts": { 314 313 "title": "Scripts de Instalación",
+25 -23
i18n/locales/fr-FR.json
··· 314 314 "show_more": "(afficher {count} de plus)", 315 315 "show_less": "(afficher moins)" 316 316 }, 317 - "trends": { 318 - "granularity": "Granularité", 319 - "granularity_daily": "Quotidien", 320 - "granularity_weekly": "Hebdomadaire", 321 - "granularity_monthly": "Mensuel", 322 - "granularity_yearly": "Annuel", 323 - "start_date": "Début", 324 - "end_date": "Fin", 325 - "loading": "Chargement...", 326 - "date_range": "{start} au {end}", 327 - "date_range_multiline": "{start}\nau {end}", 328 - "download_file": "Télécharger {fileType}", 329 - "toggle_annotator": "Afficher/Masquer l'annotateur", 330 - "legend_estimation": "Estimation", 331 - "no_data": "Données non disponibles", 332 - "y_axis_label": "{facet} {granularity}", 333 - "items": { 334 - "downloads": "Téléchargements" 335 - } 336 - }, 337 317 "downloads": { 338 318 "title": "Téléchargements hebdomadaires", 339 319 "analyze": "Analyser les téléchargements", 340 - "community_distribution": "Voir la distribution des versions téléchargées par la communauté", 341 - "modal_title": "Téléchargements" 320 + "community_distribution": "Voir la distribution des versions téléchargées par la communauté" 342 321 }, 343 322 "install_scripts": { 344 323 "title": "Scripts d'installation", ··· 421 400 "published": "Récemment publié", 422 401 "name_asc": "Nom (A-Z)", 423 402 "name_desc": "Nom (Z-A)" 403 + }, 404 + "trends": { 405 + "granularity": "Granularité", 406 + "granularity_daily": "Quotidien", 407 + "granularity_weekly": "Hebdomadaire", 408 + "granularity_monthly": "Mensuel", 409 + "granularity_yearly": "Annuel", 410 + "start_date": "Début", 411 + "end_date": "Fin", 412 + "loading": "Chargement...", 413 + "date_range": "{start} au {end}", 414 + "date_range_multiline": "{start}\nau {end}", 415 + "download_file": "Télécharger {fileType}", 416 + "toggle_annotator": "Afficher/Masquer l'annotateur", 417 + "legend_estimation": "Estimation", 418 + "no_data": "Données non disponibles", 419 + "y_axis_label": "{facet} {granularity}", 420 + "facet": "Facette", 421 + "title": "Tendances", 422 + "items": { 423 + "downloads": "Téléchargements", 424 + "likes": "J'aime" 425 + } 424 426 }, 425 427 "size": { 426 428 "b": "{size} o", ··· 970 972 "up_to_you": "À vous de décider !" 971 973 }, 972 974 "trends": { 973 - "title": "Téléchargements hebdomadaires" 975 + "title": "Comparer les tendances" 974 976 } 975 977 } 976 978 },
+1 -2
i18n/locales/hi-IN.json
··· 273 273 }, 274 274 "downloads": { 275 275 "title": "साप्ताहिक डाउनलोड्स", 276 - "analyze": "डाउनलोड्स का विश्लेषण करें", 277 - "modal_title": "डाउनलोड्स" 276 + "analyze": "डाउनलोड्स का विश्लेषण करें" 278 277 }, 279 278 "install_scripts": { 280 279 "title": "इंस्टॉल स्क्रिप्ट्स",
+1 -2
i18n/locales/hu-HU.json
··· 242 242 }, 243 243 "downloads": { 244 244 "title": "Heti letöltések", 245 - "analyze": "Letöltések elemzése", 246 - "modal_title": "Letöltések" 245 + "analyze": "Letöltések elemzése" 247 246 }, 248 247 "install_scripts": { 249 248 "title": "Telepítő scriptek",
+1 -2
i18n/locales/id-ID.json
··· 259 259 }, 260 260 "downloads": { 261 261 "title": "Unduhan Mingguan", 262 - "analyze": "Analisis unduhan", 263 - "modal_title": "Unduhan" 262 + "analyze": "Analisis unduhan" 264 263 }, 265 264 "install_scripts": { 266 265 "title": "Skrip Instalasi",
+1 -2
i18n/locales/it-IT.json
··· 313 313 "downloads": { 314 314 "title": "Download settimanali", 315 315 "analyze": "Analizza download", 316 - "community_distribution": "Visualizza distribuzione adozione della comunità", 317 - "modal_title": "Download" 316 + "community_distribution": "Visualizza distribuzione adozione della comunità" 318 317 }, 319 318 "install_scripts": { 320 319 "title": "Script di installazione",
+1 -2
i18n/locales/ja-JP.json
··· 307 307 "downloads": { 308 308 "title": "週間ダウンロード数", 309 309 "analyze": "ダウンロード数を分析", 310 - "community_distribution": "コミュニティの採用分布を表示", 311 - "modal_title": "ダウンロード数" 310 + "community_distribution": "コミュニティの採用分布を表示" 312 311 }, 313 312 "install_scripts": { 314 313 "title": "インストールスクリプト",
+1 -2
i18n/locales/ne-NP.json
··· 259 259 }, 260 260 "downloads": { 261 261 "title": "साप्ताहिक डाउनलोड", 262 - "analyze": "डाउनलोड विश्लेषण", 263 - "modal_title": "डाउनलोडहरू" 262 + "analyze": "डाउनलोड विश्लेषण" 264 263 }, 265 264 "install_scripts": { 266 265 "title": "इन्स्टल स्क्रिप्टहरू",
+1 -2
i18n/locales/no-NO.json
··· 285 285 "downloads": { 286 286 "title": "Ukentlige nedlastinger", 287 287 "analyze": "Analyser nedlastinger", 288 - "community_distribution": "Vis distribusjon av bruk i fellesskapet", 289 - "modal_title": "Nedlastinger" 288 + "community_distribution": "Vis distribusjon av bruk i fellesskapet" 290 289 }, 291 290 "install_scripts": { 292 291 "title": "Installasjonsskript",
+1 -2
i18n/locales/pl-PL.json
··· 286 286 }, 287 287 "downloads": { 288 288 "title": "Pobrania tygodniowe", 289 - "analyze": "Analizuj pobrania", 290 - "modal_title": "Pobrania" 289 + "analyze": "Analizuj pobrania" 291 290 }, 292 291 "install_scripts": { 293 292 "title": "Skrypty instalacji",
+1 -2
i18n/locales/pt-BR.json
··· 274 274 }, 275 275 "downloads": { 276 276 "title": "Downloads Semanais", 277 - "analyze": "Analisar downloads", 278 - "modal_title": "Downloads" 277 + "analyze": "Analisar downloads" 279 278 }, 280 279 "install_scripts": { 281 280 "title": "Scripts de Instalação",
+1 -2
i18n/locales/ru-RU.json
··· 240 240 }, 241 241 "downloads": { 242 242 "title": "Загрузки за неделю", 243 - "analyze": "Анализировать загрузки", 244 - "modal_title": "Загрузки" 243 + "analyze": "Анализировать загрузки" 245 244 }, 246 245 "install_scripts": { 247 246 "title": "Скрипты установки",
+1 -2
i18n/locales/te-IN.json
··· 273 273 }, 274 274 "downloads": { 275 275 "title": "వారపు డౌన్‌లోడ్‌లు", 276 - "analyze": "డౌన్‌లోడ్‌లను విశ్లేషించండి", 277 - "modal_title": "డౌన్‌లోడ్‌లు" 276 + "analyze": "డౌన్‌లోడ్‌లను విశ్లేషించండి" 278 277 }, 279 278 "install_scripts": { 280 279 "title": "ఇన్‌స్టాల్ స్క్రిప్ట్‌లు",
+1 -2
i18n/locales/uk-UA.json
··· 243 243 }, 244 244 "downloads": { 245 245 "title": "Завантажень на тиждень", 246 - "analyze": "Проаналізувати завантаження", 247 - "modal_title": "Завантаження" 246 + "analyze": "Проаналізувати завантаження" 248 247 }, 249 248 "install_scripts": { 250 249 "title": "Скрипти встановлення",
+1 -5
i18n/locales/zh-CN.json
··· 337 337 "downloads": { 338 338 "title": "每周下载量", 339 339 "analyze": "分析下载量", 340 - "community_distribution": "查看社区采用分布", 341 - "modal_title": "下载量" 340 + "community_distribution": "查看社区采用分布" 342 341 }, 343 342 "install_scripts": { 344 343 "title": "安装脚本", ··· 968 967 "types_none": "无", 969 968 "vulnerabilities_summary": "{count}({critical} 严重/{high} 高)", 970 969 "up_to_you": "由你决定!" 971 - }, 972 - "trends": { 973 - "title": "每周下载量" 974 970 } 975 971 } 976 972 },
+1 -2
i18n/locales/zh-TW.json
··· 346 346 "downloads": { 347 347 "title": "每週下載量", 348 348 "analyze": "分析下載量", 349 - "community_distribution": "檢視社群採用分布", 350 - "modal_title": "下載量" 349 + "community_distribution": "檢視社群採用分布" 351 350 }, 352 351 "install_scripts": { 353 352 "title": "安裝腳本",
+9 -3
i18n/schema.json
··· 1021 1021 "y_axis_label": { 1022 1022 "type": "string" 1023 1023 }, 1024 + "facet": { 1025 + "type": "string" 1026 + }, 1027 + "title": { 1028 + "type": "string" 1029 + }, 1024 1030 "items": { 1025 1031 "type": "object", 1026 1032 "properties": { 1027 1033 "downloads": { 1034 + "type": "string" 1035 + }, 1036 + "likes": { 1028 1037 "type": "string" 1029 1038 } 1030 1039 }, ··· 1043 1052 "type": "string" 1044 1053 }, 1045 1054 "community_distribution": { 1046 - "type": "string" 1047 - }, 1048 - "modal_title": { 1049 1055 "type": "string" 1050 1056 } 1051 1057 },
+1 -2
lunaria/files/ar-EG.json
··· 305 305 "downloads": { 306 306 "title": "التنزيلات الأسبوعية", 307 307 "analyze": "تحليل التنزيلات", 308 - "community_distribution": "عرض توزيع تبنّي المجتمع", 309 - "modal_title": "التنزيلات" 308 + "community_distribution": "عرض توزيع تبنّي المجتمع" 310 309 }, 311 310 "install_scripts": { 312 311 "title": "سكربتات التثبيت",
+1 -2
lunaria/files/az-AZ.json
··· 242 242 }, 243 243 "downloads": { 244 244 "title": "Həftəlik Endirmələr", 245 - "analyze": "Endirmələri təhlil et", 246 - "modal_title": "Endirmələr" 245 + "analyze": "Endirmələri təhlil et" 247 246 }, 248 247 "install_scripts": { 249 248 "title": "Quraşdırma Skriptləri",
+1 -2
lunaria/files/bn-IN.json
··· 272 272 }, 273 273 "downloads": { 274 274 "title": "সাপ্তাহিক ডাউনলোড", 275 - "analyze": "ডাউনলোড বিশ্লেষণ করুন", 276 - "modal_title": "ডাউনলোড" 275 + "analyze": "ডাউনলোড বিশ্লেষণ করুন" 277 276 }, 278 277 "install_scripts": { 279 278 "title": "ইনস্টল স্ক্রিপ্ট",
+1 -2
lunaria/files/cs-CZ.json
··· 277 277 }, 278 278 "downloads": { 279 279 "title": "Týdenní stažení", 280 - "analyze": "Analyzovat stažení", 281 - "modal_title": "Stažení" 280 + "analyze": "Analyzovat stažení" 282 281 }, 283 282 "install_scripts": { 284 283 "title": "Instalační skripty",
+1 -5
lunaria/files/de-DE.json
··· 328 328 "downloads": { 329 329 "title": "Wöchentliche Downloads", 330 330 "analyze": "Downloads analysieren", 331 - "community_distribution": "Community-Adoptionsverteilung ansehen", 332 - "modal_title": "Downloads" 331 + "community_distribution": "Community-Adoptionsverteilung ansehen" 333 332 }, 334 333 "install_scripts": { 335 334 "title": "Installationsskripte", ··· 959 958 "types_none": "Keine", 960 959 "vulnerabilities_summary": "{count} ({critical}C/{high}H)", 961 960 "up_to_you": "Deine Entscheidung!" 962 - }, 963 - "trends": { 964 - "title": "Wöchentliche Downloads" 965 961 } 966 962 } 967 963 },
+6 -4
lunaria/files/en-GB.json
··· 338 338 "legend_estimation": "Estimation", 339 339 "no_data": "No data available", 340 340 "y_axis_label": "{granularity} {facet}", 341 + "facet": "Facet", 342 + "title": "Trends", 341 343 "items": { 342 - "downloads": "Downloads" 344 + "downloads": "Downloads", 345 + "likes": "Likes" 343 346 } 344 347 }, 345 348 "downloads": { 346 349 "title": "Weekly Downloads", 347 350 "analyze": "Analyze downloads", 348 - "community_distribution": "View community adoption distribution", 349 - "modal_title": "Downloads" 351 + "community_distribution": "View community adoption distribution" 350 352 }, 351 353 "install_scripts": { 352 354 "title": "Install Scripts", ··· 979 981 "up_to_you": "Up to you!" 980 982 }, 981 983 "trends": { 982 - "title": "Weekly Downloads" 984 + "title": "Compare Trends" 983 985 } 984 986 } 985 987 },
+6 -4
lunaria/files/en-US.json
··· 338 338 "legend_estimation": "Estimation", 339 339 "no_data": "No data available", 340 340 "y_axis_label": "{granularity} {facet}", 341 + "facet": "Facet", 342 + "title": "Trends", 341 343 "items": { 342 - "downloads": "Downloads" 344 + "downloads": "Downloads", 345 + "likes": "Likes" 343 346 } 344 347 }, 345 348 "downloads": { 346 349 "title": "Weekly Downloads", 347 350 "analyze": "Analyze downloads", 348 - "community_distribution": "View community adoption distribution", 349 - "modal_title": "Downloads" 351 + "community_distribution": "View community adoption distribution" 350 352 }, 351 353 "install_scripts": { 352 354 "title": "Install Scripts", ··· 979 981 "up_to_you": "Up to you!" 980 982 }, 981 983 "trends": { 982 - "title": "Weekly Downloads" 984 + "title": "Compare Trends" 983 985 } 984 986 } 985 987 },
+1 -2
lunaria/files/es-419.json
··· 306 306 "downloads": { 307 307 "title": "Descargas Semanales", 308 308 "analyze": "Analizar descargas", 309 - "community_distribution": "Ver distribución de adopción comunitaria", 310 - "modal_title": "Descargas" 309 + "community_distribution": "Ver distribución de adopción comunitaria" 311 310 }, 312 311 "install_scripts": { 313 312 "title": "Scripts de Instalación",
+1 -2
lunaria/files/es-ES.json
··· 306 306 "downloads": { 307 307 "title": "Descargas Semanales", 308 308 "analyze": "Analizar descargas", 309 - "community_distribution": "Ver distribución de adopción comunitaria", 310 - "modal_title": "Descargas" 309 + "community_distribution": "Ver distribución de adopción comunitaria" 311 310 }, 312 311 "install_scripts": { 313 312 "title": "Scripts de Instalación",
+25 -23
lunaria/files/fr-FR.json
··· 313 313 "show_more": "(afficher {count} de plus)", 314 314 "show_less": "(afficher moins)" 315 315 }, 316 - "trends": { 317 - "granularity": "Granularité", 318 - "granularity_daily": "Quotidien", 319 - "granularity_weekly": "Hebdomadaire", 320 - "granularity_monthly": "Mensuel", 321 - "granularity_yearly": "Annuel", 322 - "start_date": "Début", 323 - "end_date": "Fin", 324 - "loading": "Chargement...", 325 - "date_range": "{start} au {end}", 326 - "date_range_multiline": "{start}\nau {end}", 327 - "download_file": "Télécharger {fileType}", 328 - "toggle_annotator": "Afficher/Masquer l'annotateur", 329 - "legend_estimation": "Estimation", 330 - "no_data": "Données non disponibles", 331 - "y_axis_label": "{facet} {granularity}", 332 - "items": { 333 - "downloads": "Téléchargements" 334 - } 335 - }, 336 316 "downloads": { 337 317 "title": "Téléchargements hebdomadaires", 338 318 "analyze": "Analyser les téléchargements", 339 - "community_distribution": "Voir la distribution des versions téléchargées par la communauté", 340 - "modal_title": "Téléchargements" 319 + "community_distribution": "Voir la distribution des versions téléchargées par la communauté" 341 320 }, 342 321 "install_scripts": { 343 322 "title": "Scripts d'installation", ··· 420 399 "published": "Récemment publié", 421 400 "name_asc": "Nom (A-Z)", 422 401 "name_desc": "Nom (Z-A)" 402 + }, 403 + "trends": { 404 + "granularity": "Granularité", 405 + "granularity_daily": "Quotidien", 406 + "granularity_weekly": "Hebdomadaire", 407 + "granularity_monthly": "Mensuel", 408 + "granularity_yearly": "Annuel", 409 + "start_date": "Début", 410 + "end_date": "Fin", 411 + "loading": "Chargement...", 412 + "date_range": "{start} au {end}", 413 + "date_range_multiline": "{start}\nau {end}", 414 + "download_file": "Télécharger {fileType}", 415 + "toggle_annotator": "Afficher/Masquer l'annotateur", 416 + "legend_estimation": "Estimation", 417 + "no_data": "Données non disponibles", 418 + "y_axis_label": "{facet} {granularity}", 419 + "facet": "Facette", 420 + "title": "Tendances", 421 + "items": { 422 + "downloads": "Téléchargements", 423 + "likes": "J'aime" 424 + } 423 425 }, 424 426 "size": { 425 427 "b": "{size} o", ··· 969 971 "up_to_you": "À vous de décider !" 970 972 }, 971 973 "trends": { 972 - "title": "Téléchargements hebdomadaires" 974 + "title": "Comparer les tendances" 973 975 } 974 976 } 975 977 },
+1 -2
lunaria/files/hi-IN.json
··· 272 272 }, 273 273 "downloads": { 274 274 "title": "साप्ताहिक डाउनलोड्स", 275 - "analyze": "डाउनलोड्स का विश्लेषण करें", 276 - "modal_title": "डाउनलोड्स" 275 + "analyze": "डाउनलोड्स का विश्लेषण करें" 277 276 }, 278 277 "install_scripts": { 279 278 "title": "इंस्टॉल स्क्रिप्ट्स",
+1 -2
lunaria/files/hu-HU.json
··· 241 241 }, 242 242 "downloads": { 243 243 "title": "Heti letöltések", 244 - "analyze": "Letöltések elemzése", 245 - "modal_title": "Letöltések" 244 + "analyze": "Letöltések elemzése" 246 245 }, 247 246 "install_scripts": { 248 247 "title": "Telepítő scriptek",
+1 -2
lunaria/files/id-ID.json
··· 258 258 }, 259 259 "downloads": { 260 260 "title": "Unduhan Mingguan", 261 - "analyze": "Analisis unduhan", 262 - "modal_title": "Unduhan" 261 + "analyze": "Analisis unduhan" 263 262 }, 264 263 "install_scripts": { 265 264 "title": "Skrip Instalasi",
+1 -2
lunaria/files/it-IT.json
··· 312 312 "downloads": { 313 313 "title": "Download settimanali", 314 314 "analyze": "Analizza download", 315 - "community_distribution": "Visualizza distribuzione adozione della comunità", 316 - "modal_title": "Download" 315 + "community_distribution": "Visualizza distribuzione adozione della comunità" 317 316 }, 318 317 "install_scripts": { 319 318 "title": "Script di installazione",
+1 -2
lunaria/files/ja-JP.json
··· 306 306 "downloads": { 307 307 "title": "週間ダウンロード数", 308 308 "analyze": "ダウンロード数を分析", 309 - "community_distribution": "コミュニティの採用分布を表示", 310 - "modal_title": "ダウンロード数" 309 + "community_distribution": "コミュニティの採用分布を表示" 311 310 }, 312 311 "install_scripts": { 313 312 "title": "インストールスクリプト",
+1 -2
lunaria/files/ne-NP.json
··· 258 258 }, 259 259 "downloads": { 260 260 "title": "साप्ताहिक डाउनलोड", 261 - "analyze": "डाउनलोड विश्लेषण", 262 - "modal_title": "डाउनलोडहरू" 261 + "analyze": "डाउनलोड विश्लेषण" 263 262 }, 264 263 "install_scripts": { 265 264 "title": "इन्स्टल स्क्रिप्टहरू",
+1 -2
lunaria/files/no-NO.json
··· 284 284 "downloads": { 285 285 "title": "Ukentlige nedlastinger", 286 286 "analyze": "Analyser nedlastinger", 287 - "community_distribution": "Vis distribusjon av bruk i fellesskapet", 288 - "modal_title": "Nedlastinger" 287 + "community_distribution": "Vis distribusjon av bruk i fellesskapet" 289 288 }, 290 289 "install_scripts": { 291 290 "title": "Installasjonsskript",
+1 -2
lunaria/files/pl-PL.json
··· 285 285 }, 286 286 "downloads": { 287 287 "title": "Pobrania tygodniowe", 288 - "analyze": "Analizuj pobrania", 289 - "modal_title": "Pobrania" 288 + "analyze": "Analizuj pobrania" 290 289 }, 291 290 "install_scripts": { 292 291 "title": "Skrypty instalacji",
+1 -2
lunaria/files/pt-BR.json
··· 273 273 }, 274 274 "downloads": { 275 275 "title": "Downloads Semanais", 276 - "analyze": "Analisar downloads", 277 - "modal_title": "Downloads" 276 + "analyze": "Analisar downloads" 278 277 }, 279 278 "install_scripts": { 280 279 "title": "Scripts de Instalação",
+1 -2
lunaria/files/ru-RU.json
··· 239 239 }, 240 240 "downloads": { 241 241 "title": "Загрузки за неделю", 242 - "analyze": "Анализировать загрузки", 243 - "modal_title": "Загрузки" 242 + "analyze": "Анализировать загрузки" 244 243 }, 245 244 "install_scripts": { 246 245 "title": "Скрипты установки",
+1 -2
lunaria/files/te-IN.json
··· 272 272 }, 273 273 "downloads": { 274 274 "title": "వారపు డౌన్‌లోడ్‌లు", 275 - "analyze": "డౌన్‌లోడ్‌లను విశ్లేషించండి", 276 - "modal_title": "డౌన్‌లోడ్‌లు" 275 + "analyze": "డౌన్‌లోడ్‌లను విశ్లేషించండి" 277 276 }, 278 277 "install_scripts": { 279 278 "title": "ఇన్‌స్టాల్ స్క్రిప్ట్‌లు",
+1 -2
lunaria/files/uk-UA.json
··· 242 242 }, 243 243 "downloads": { 244 244 "title": "Завантажень на тиждень", 245 - "analyze": "Проаналізувати завантаження", 246 - "modal_title": "Завантаження" 245 + "analyze": "Проаналізувати завантаження" 247 246 }, 248 247 "install_scripts": { 249 248 "title": "Скрипти встановлення",
+1 -5
lunaria/files/zh-CN.json
··· 336 336 "downloads": { 337 337 "title": "每周下载量", 338 338 "analyze": "分析下载量", 339 - "community_distribution": "查看社区采用分布", 340 - "modal_title": "下载量" 339 + "community_distribution": "查看社区采用分布" 341 340 }, 342 341 "install_scripts": { 343 342 "title": "安装脚本", ··· 967 966 "types_none": "无", 968 967 "vulnerabilities_summary": "{count}({critical} 严重/{high} 高)", 969 968 "up_to_you": "由你决定!" 970 - }, 971 - "trends": { 972 - "title": "每周下载量" 973 969 } 974 970 } 975 971 },
+1 -2
lunaria/files/zh-TW.json
··· 345 345 "downloads": { 346 346 "title": "每週下載量", 347 347 "analyze": "分析下載量", 348 - "community_distribution": "檢視社群採用分布", 349 - "modal_title": "下載量" 348 + "community_distribution": "檢視社群採用分布" 350 349 }, 351 350 "install_scripts": { 352 351 "title": "安裝腳本",
+12
server/api/social/likes-evolution/[...pkg].get.ts
··· 1 + export default defineEventHandler(async event => { 2 + const packageName = getRouterParam(event, 'pkg') 3 + if (!packageName) { 4 + throw createError({ 5 + status: 400, 6 + message: 'package name not provided', 7 + }) 8 + } 9 + 10 + const likesUtil = new PackageLikesUtils() 11 + return await likesUtil.getLikesEvolution(packageName) 12 + })
+82 -14
server/utils/atproto/utils/likes.ts
··· 1 1 import { $nsid as likeNsid } from '#shared/types/lexicons/dev/npmx/feed/like.defs' 2 2 import type { Backlink } from '#shared/utils/constellation' 3 + import { TID } from '@atproto/common' 3 4 4 5 //Cache keys and helpers 5 6 const CACHE_PREFIX = 'atproto-likes:' ··· 8 9 `${CACHE_PREFIX}${packageName}:users:${did}:liked` 9 10 const CACHE_USERS_BACK_LINK = (packageName: string, did: string) => 10 11 `${CACHE_PREFIX}${packageName}:users:${did}:backlink` 12 + const CACHE_EVOLUTION_KEY = (packageName: string) => `${CACHE_PREFIX}${packageName}:evolution` 11 13 12 14 const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5 13 15 14 16 /** 17 + * Decodes TID timestamps from backlink rkeys and groups by day. 18 + * Pure function — no I/O, no side effects. 19 + */ 20 + export function aggregateBacklinksByDay( 21 + backlinks: Backlink[], 22 + ): Array<{ day: string; likes: number }> { 23 + const countsByDay = new Map<string, number>() 24 + for (const backlink of backlinks) { 25 + try { 26 + const tid = TID.fromStr(backlink.rkey) 27 + const timestampMs = tid.timestamp() / 1000 28 + const date = new Date(timestampMs) 29 + const day = date.toISOString().slice(0, 10) 30 + countsByDay.set(day, (countsByDay.get(day) ?? 0) + 1) 31 + } catch { 32 + console.warn(`Skipping non-TID rkey: ${backlink.rkey}`) 33 + } 34 + } 35 + return Array.from(countsByDay.entries()) 36 + .sort(([a], [b]) => a.localeCompare(b)) 37 + .map(([day, likes]) => ({ day, likes })) 38 + } 39 + 40 + /** The subset of Constellation that PackageLikesUtils actually needs. */ 41 + export type ConstellationLike = Pick<Constellation, 'getBackLinks' | 'getLinksDistinctDids'> 42 + 43 + /** 15 44 * Logic to handle liking, unliking, and seeing if a user has liked a package on npmx 16 45 */ 17 46 export class PackageLikesUtils { 18 - private readonly constellation: Constellation 47 + private readonly constellation: ConstellationLike 19 48 private readonly cache: CacheAdapter 20 49 21 - constructor() { 22 - this.constellation = new Constellation( 23 - // Passes in a fetch wrapped as cachedfetch since are already doing some heavy caching here 24 - async <T = unknown>( 25 - url: string, 26 - options: Parameters<typeof $fetch>[1] = {}, 27 - _ttl?: number, 28 - ): Promise<CachedFetchResult<T>> => { 29 - const data = (await $fetch<T>(url, options)) as T 30 - return { data, isStale: false, cachedAt: null } 31 - }, 32 - ) 33 - this.cache = getCacheAdapter('generic') 50 + constructor(deps?: { constellation?: ConstellationLike; cache?: CacheAdapter }) { 51 + this.constellation = 52 + deps?.constellation ?? 53 + new Constellation( 54 + // Passes in a fetch wrapped as cachedfetch since are already doing some heavy caching here 55 + async <T = unknown>( 56 + url: string, 57 + options: Parameters<typeof $fetch>[1] = {}, 58 + _ttl?: number, 59 + ): Promise<CachedFetchResult<T>> => { 60 + const data = (await $fetch<T>(url, options)) as T 61 + return { data, isStale: false, cachedAt: null } 62 + }, 63 + ) 64 + this.cache = deps?.cache ?? getCacheAdapter('generic') 34 65 } 35 66 36 67 /** ··· 247 278 totalLikes: totalLikes, 248 279 userHasLiked: false, 249 280 } 281 + } 282 + 283 + /** 284 + * Gets the likes evolution for a package as daily {day, likes} points. 285 + * Fetches ALL backlinks via paginated constellation calls, decodes TID 286 + * timestamps from each rkey, and groups by day. 287 + * Results are cached for 5 minutes. 288 + */ 289 + async getLikesEvolution(packageName: string): Promise<Array<{ day: string; likes: number }>> { 290 + const cacheKey = CACHE_EVOLUTION_KEY(packageName) 291 + const cached = await this.cache.get<Array<{ day: string; likes: number }>>(cacheKey) 292 + if (cached) return cached 293 + 294 + const subjectRef = PACKAGE_SUBJECT_REF(packageName) 295 + const allBacklinks: Backlink[] = [] 296 + let cursor: string | undefined 297 + 298 + // Paginate through all backlinks 299 + do { 300 + const { data } = await this.constellation.getBackLinks( 301 + subjectRef, 302 + likeNsid, 303 + 'subjectRef', 304 + 100, 305 + cursor, 306 + false, 307 + [], 308 + 0, 309 + ) 310 + allBacklinks.push(...data.records) 311 + cursor = data.cursor 312 + } while (cursor) 313 + 314 + const result = aggregateBacklinksByDay(allBacklinks) 315 + 316 + await this.cache.set(cacheKey, result, CACHE_MAX_AGE) 317 + return result 250 318 } 251 319 }
+11 -11
test/nuxt/a11y.spec.ts
··· 197 197 // The #components import automatically provides the client variant 198 198 import HeaderAccountMenuServer from '~/components/Header/AccountMenu.server.vue' 199 199 import ToggleServer from '~/components/Settings/Toggle.server.vue' 200 - import PackageDownloadAnalytics from '~/components/Package/DownloadAnalytics.vue' 201 200 import SearchProviderToggleServer from '~/components/SearchProviderToggle.server.vue' 201 + import PackageTrendsChart from '~/components/Package/TrendsChart.vue' 202 202 203 203 describe('component accessibility audits', () => { 204 204 describe('DateTime', () => { ··· 607 607 // inherently provided by the native <dialog> element with aria-labelledby. 608 608 }) 609 609 610 - describe('PackageDownloadAnalytics', () => { 610 + describe('PackageTrendsChart', () => { 611 611 const mockWeeklyDownloads = [ 612 612 { 613 - downloads: 1000, 613 + value: 1000, 614 614 weekKey: '2024-W01', 615 615 weekStart: '2024-01-01', 616 616 weekEnd: '2024-01-07', ··· 618 618 timestampEnd: 1704585600, 619 619 }, 620 620 { 621 - downloads: 1200, 621 + value: 1200, 622 622 weekKey: '2024-W02', 623 623 weekStart: '2024-01-08', 624 624 weekEnd: '2024-01-14', ··· 626 626 timestampEnd: 1705190400, 627 627 }, 628 628 { 629 - downloads: 1500, 629 + value: 1500, 630 630 weekKey: '2024-W03', 631 631 weekStart: '2024-01-15', 632 632 weekEnd: '2024-01-21', ··· 636 636 ] 637 637 638 638 it('should have no accessibility violations (non-modal)', async () => { 639 - const wrapper = await mountSuspended(PackageDownloadAnalytics, { 639 + const wrapper = await mountSuspended(PackageTrendsChart, { 640 640 props: { 641 641 weeklyDownloads: mockWeeklyDownloads, 642 642 packageName: 'vue', ··· 650 650 }) 651 651 652 652 it('should have no accessibility violations with empty data', async () => { 653 - const wrapper = await mountSuspended(PackageDownloadAnalytics, { 653 + const wrapper = await mountSuspended(PackageTrendsChart, { 654 654 props: { 655 655 weeklyDownloads: [], 656 656 packageName: 'vue', ··· 1609 1609 props: { packages: [] }, 1610 1610 global: { 1611 1611 stubs: { 1612 - DownloadAnalytics: { 1613 - template: '<div data-test-id="download-analytics-stub"></div>', 1612 + TrendsChart: { 1613 + template: '<div data-test-id="trends-chart-stub"></div>', 1614 1614 }, 1615 1615 }, 1616 1616 }, ··· 1624 1624 props: { packages: ['vue', 'react'] }, 1625 1625 global: { 1626 1626 stubs: { 1627 - DownloadAnalytics: { 1628 - template: '<div data-test-id="download-analytics-stub"></div>', 1627 + TrendsChart: { 1628 + template: '<div data-test-id="trends-chart-stub"></div>', 1629 1629 }, 1630 1630 }, 1631 1631 },
+1 -1
test/nuxt/components/PackageWeeklyDownloadStats.spec.ts
··· 51 51 weekEnd: '2026-01-07', 52 52 timestampStart: 1767225600000, 53 53 timestampEnd: 1767744000000, 54 - downloads: 42, 54 + value: 42, 55 55 }, 56 56 ]) 57 57
+74
test/nuxt/composables/use-charts.spec.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 2 + import { clearClientCaches } from '~/composables/useCharts' 3 + 4 + const mockLikesResponse = [ 5 + { day: '2025-03-01', likes: 5 }, 6 + { day: '2025-03-02', likes: 3 }, 7 + { day: '2025-03-03', likes: 8 }, 8 + { day: '2025-03-04', likes: 2 }, 9 + { day: '2025-03-05', likes: 10 }, 10 + { day: '2025-03-06', likes: 1 }, 11 + { day: '2025-03-07', likes: 4 }, 12 + { day: '2025-03-08', likes: 6 }, 13 + { day: '2025-03-09', likes: 7 }, 14 + { day: '2025-03-10', likes: 3 }, 15 + ] 16 + 17 + describe('useCharts – fetchPackageLikesEvolution', () => { 18 + let fetchSpy: ReturnType<typeof vi.fn> 19 + 20 + beforeEach(() => { 21 + fetchSpy = vi.fn().mockResolvedValue(mockLikesResponse) 22 + vi.stubGlobal('$fetch', fetchSpy) 23 + 24 + // Clear any client-side caches between tests 25 + clearClientCaches() 26 + }) 27 + 28 + afterEach(() => { 29 + vi.unstubAllGlobals() 30 + }) 31 + 32 + it('propagates API errors', async () => { 33 + fetchSpy.mockRejectedValueOnce(new Error('Network error')) 34 + 35 + const { fetchPackageLikesEvolution } = useCharts() 36 + 37 + await expect( 38 + fetchPackageLikesEvolution('fail-pkg', { 39 + granularity: 'day', 40 + startDate: '2025-03-01', 41 + endDate: '2025-03-10', 42 + }), 43 + ).rejects.toThrow('Network error') 44 + }) 45 + 46 + it('returns daily points with value field mapped from likes', async () => { 47 + const { fetchPackageLikesEvolution } = useCharts() 48 + 49 + const result = await fetchPackageLikesEvolution('vue', { 50 + granularity: 'day', 51 + startDate: '2025-03-01', 52 + endDate: '2025-03-10', 53 + }) 54 + 55 + expect(result.length).toBeGreaterThan(0) 56 + expect(result[0]).toHaveProperty('value') 57 + expect(result[0]).toHaveProperty('day') 58 + expect(result[0]).toHaveProperty('timestamp') 59 + expect(result[0]).not.toHaveProperty('downloads') 60 + expect(result[0]).not.toHaveProperty('likes') 61 + }) 62 + 63 + it('uses client-side promise caching for repeated calls', async () => { 64 + const { fetchPackageLikesEvolution } = useCharts() 65 + 66 + const options = { granularity: 'day' as const, startDate: '2025-03-01', endDate: '2025-03-10' } 67 + 68 + await fetchPackageLikesEvolution('vue', options) 69 + await fetchPackageLikesEvolution('vue', options) 70 + 71 + // Should only fetch once thanks to client-side promise caching 72 + expect(fetchSpy).toHaveBeenCalledTimes(1) 73 + }) 74 + })
+156
test/unit/app/composables/use-charts.spec.ts
··· 1 + import { describe, expect, it, vi } from 'vitest' 2 + 3 + vi.mock('~/utils/npm/api', () => ({ 4 + fetchNpmDownloadsRange: vi.fn(), 5 + })) 6 + 7 + import { 8 + buildDailyEvolutionFromDaily, 9 + buildRollingWeeklyEvolutionFromDaily, 10 + buildMonthlyEvolutionFromDaily, 11 + buildYearlyEvolutionFromDaily, 12 + getNpmPackageCreationDate, 13 + } from '../../../../app/composables/useCharts' 14 + 15 + describe('buildDailyEvolutionFromDaily', () => { 16 + it('adds timestamps and preserves values', () => { 17 + const result = buildDailyEvolutionFromDaily([ 18 + { day: '2025-03-01', value: 10 }, 19 + { day: '2025-03-02', value: 20 }, 20 + ]) 21 + 22 + expect(result).toEqual([ 23 + { day: '2025-03-01', value: 10, timestamp: Date.UTC(2025, 2, 1) }, 24 + { day: '2025-03-02', value: 20, timestamp: Date.UTC(2025, 2, 2) }, 25 + ]) 26 + }) 27 + 28 + it('sorts by day', () => { 29 + const result = buildDailyEvolutionFromDaily([ 30 + { day: '2025-03-03', value: 3 }, 31 + { day: '2025-03-01', value: 1 }, 32 + { day: '2025-03-02', value: 2 }, 33 + ]) 34 + 35 + expect(result.map(r => r.day)).toEqual(['2025-03-01', '2025-03-02', '2025-03-03']) 36 + }) 37 + 38 + it('returns empty array for empty input', () => { 39 + expect(buildDailyEvolutionFromDaily([])).toEqual([]) 40 + }) 41 + }) 42 + 43 + describe('buildRollingWeeklyEvolutionFromDaily', () => { 44 + it('groups daily data into rolling 7-day buckets', () => { 45 + const daily = Array.from({ length: 14 }, (_, i) => ({ 46 + day: `2025-03-${String(i + 1).padStart(2, '0')}`, 47 + value: 10, 48 + })) 49 + 50 + const result = buildRollingWeeklyEvolutionFromDaily(daily, '2025-03-01', '2025-03-14') 51 + 52 + expect(result).toHaveLength(2) 53 + expect(result[0]!.value).toBe(70) 54 + expect(result[0]!.weekStart).toBe('2025-03-01') 55 + expect(result[0]!.weekEnd).toBe('2025-03-07') 56 + expect(result[1]!.value).toBe(70) 57 + expect(result[1]!.weekStart).toBe('2025-03-08') 58 + expect(result[1]!.weekEnd).toBe('2025-03-14') 59 + }) 60 + 61 + it('clamps last week to range end date', () => { 62 + const daily = [ 63 + { day: '2025-03-01', value: 5 }, 64 + { day: '2025-03-02', value: 5 }, 65 + { day: '2025-03-08', value: 10 }, 66 + ] 67 + 68 + const result = buildRollingWeeklyEvolutionFromDaily(daily, '2025-03-01', '2025-03-09') 69 + 70 + // Last week bucket should be clamped to end date 71 + const lastWeek = result[result.length - 1]! 72 + expect(lastWeek.weekEnd).toBe('2025-03-09') 73 + }) 74 + 75 + it('returns empty array for empty input', () => { 76 + expect(buildRollingWeeklyEvolutionFromDaily([], '2025-03-01', '2025-03-14')).toEqual([]) 77 + }) 78 + }) 79 + 80 + describe('buildMonthlyEvolutionFromDaily', () => { 81 + it('aggregates daily data by month', () => { 82 + const result = buildMonthlyEvolutionFromDaily([ 83 + { day: '2025-01-15', value: 10 }, 84 + { day: '2025-01-20', value: 5 }, 85 + { day: '2025-02-10', value: 20 }, 86 + ]) 87 + 88 + expect(result).toEqual([ 89 + { month: '2025-01', value: 15, timestamp: Date.UTC(2025, 0, 1) }, 90 + { month: '2025-02', value: 20, timestamp: Date.UTC(2025, 1, 1) }, 91 + ]) 92 + }) 93 + 94 + it('sorts by month', () => { 95 + const result = buildMonthlyEvolutionFromDaily([ 96 + { day: '2025-03-01', value: 1 }, 97 + { day: '2025-01-01', value: 1 }, 98 + { day: '2025-02-01', value: 1 }, 99 + ]) 100 + 101 + expect(result.map(r => r.month)).toEqual(['2025-01', '2025-02', '2025-03']) 102 + }) 103 + 104 + it('returns empty array for empty input', () => { 105 + expect(buildMonthlyEvolutionFromDaily([])).toEqual([]) 106 + }) 107 + }) 108 + 109 + describe('buildYearlyEvolutionFromDaily', () => { 110 + it('aggregates daily data by year', () => { 111 + const result = buildYearlyEvolutionFromDaily([ 112 + { day: '2024-06-15', value: 100 }, 113 + { day: '2024-12-01', value: 50 }, 114 + { day: '2025-03-01', value: 200 }, 115 + ]) 116 + 117 + expect(result).toEqual([ 118 + { year: '2024', value: 150, timestamp: Date.UTC(2024, 0, 1) }, 119 + { year: '2025', value: 200, timestamp: Date.UTC(2025, 0, 1) }, 120 + ]) 121 + }) 122 + 123 + it('returns empty array for empty input', () => { 124 + expect(buildYearlyEvolutionFromDaily([])).toEqual([]) 125 + }) 126 + }) 127 + 128 + describe('getNpmPackageCreationDate', () => { 129 + it('returns created date from packument time', () => { 130 + const result = getNpmPackageCreationDate({ 131 + time: { 132 + 'created': '2020-01-15T10:00:00.000Z', 133 + 'modified': '2025-01-01T00:00:00.000Z', 134 + '1.0.0': '2020-01-15T10:00:00.000Z', 135 + }, 136 + }) 137 + 138 + expect(result).toBe('2020-01-15T10:00:00.000Z') 139 + }) 140 + 141 + it('returns earliest version date when created is missing', () => { 142 + const result = getNpmPackageCreationDate({ 143 + time: { 144 + 'modified': '2025-01-01T00:00:00.000Z', 145 + '1.0.0': '2020-06-01T00:00:00.000Z', 146 + '2.0.0': '2021-01-01T00:00:00.000Z', 147 + }, 148 + }) 149 + 150 + expect(result).toBe('2020-06-01T00:00:00.000Z') 151 + }) 152 + 153 + it('returns null when time is missing', () => { 154 + expect(getNpmPackageCreationDate({})).toBeNull() 155 + }) 156 + })
+172
test/unit/server/utils/likes-evolution.spec.ts
··· 1 + import { describe, expect, it, vi, beforeEach, type Mocked } from 'vitest' 2 + import { TID } from '@atproto/common' 3 + import type { ConstellationLike } from '../../../../server/utils/atproto/utils/likes' 4 + import type { CacheAdapter } from '../../../../server/utils/cache/shared' 5 + 6 + vi.stubGlobal('CACHE_MAX_AGE_ONE_MINUTE', 60) 7 + vi.stubGlobal('PACKAGE_SUBJECT_REF', (pkg: string) => `https://npmx.dev/package/${pkg}`) 8 + vi.stubGlobal('$fetch', vi.fn()) 9 + vi.stubGlobal('Constellation', vi.fn()) 10 + vi.stubGlobal('getCacheAdapter', vi.fn()) 11 + 12 + vi.mock('#shared/types/lexicons/dev/npmx/feed/like.defs', () => ({ 13 + $nsid: 'dev.npmx.feed.like', 14 + })) 15 + 16 + const { aggregateBacklinksByDay, PackageLikesUtils } = 17 + await import('../../../../server/utils/atproto/utils/likes') 18 + 19 + function tidFromDate(date: Date): string { 20 + const microseconds = date.getTime() * 1000 21 + return TID.fromTime(microseconds, 0).toString() 22 + } 23 + 24 + function backlink(date: Date): { did: string; collection: string; rkey: string } { 25 + return { did: 'did:plc:test', collection: 'dev.npmx.feed.like', rkey: tidFromDate(date) } 26 + } 27 + 28 + describe('aggregateBacklinksByDay', () => { 29 + it('groups backlinks by day from TID rkeys', () => { 30 + const result = aggregateBacklinksByDay([ 31 + backlink(new Date('2025-03-10T12:00:00.000Z')), 32 + backlink(new Date('2025-03-10T18:00:00.000Z')), 33 + backlink(new Date('2025-03-11T09:00:00.000Z')), 34 + ]) 35 + 36 + expect(result).toEqual([ 37 + { day: '2025-03-10', likes: 2 }, 38 + { day: '2025-03-11', likes: 1 }, 39 + ]) 40 + }) 41 + 42 + it('sorts results chronologically', () => { 43 + const result = aggregateBacklinksByDay([ 44 + backlink(new Date('2025-05-03T10:00:00.000Z')), 45 + backlink(new Date('2025-05-01T10:00:00.000Z')), 46 + backlink(new Date('2025-05-02T10:00:00.000Z')), 47 + ]) 48 + 49 + expect(result.map(r => r.day)).toEqual(['2025-05-01', '2025-05-02', '2025-05-03']) 50 + }) 51 + 52 + it('skips non-TID rkeys with warning', () => { 53 + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) 54 + 55 + const result = aggregateBacklinksByDay([ 56 + { did: 'did:plc:user1', collection: 'dev.npmx.feed.like', rkey: 'not-a-valid-tid' }, 57 + backlink(new Date('2025-04-20T10:00:00.000Z')), 58 + ]) 59 + 60 + expect(result).toEqual([{ day: '2025-04-20', likes: 1 }]) 61 + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('non-TID rkey')) 62 + 63 + warnSpy.mockRestore() 64 + }) 65 + 66 + it('returns empty array for empty input', () => { 67 + expect(aggregateBacklinksByDay([])).toEqual([]) 68 + }) 69 + 70 + it('aggregates multiple likes on same day', () => { 71 + const result = aggregateBacklinksByDay([ 72 + backlink(new Date('2025-07-04T08:00:00.000Z')), 73 + backlink(new Date('2025-07-04T12:00:00.000Z')), 74 + backlink(new Date('2025-07-04T20:00:00.000Z')), 75 + ]) 76 + 77 + expect(result).toEqual([{ day: '2025-07-04', likes: 3 }]) 78 + }) 79 + }) 80 + 81 + function makeBacklinksPage( 82 + records: Array<{ did: string; collection: string; rkey: string }>, 83 + cursor?: string, 84 + ) { 85 + return { 86 + data: { 87 + records, 88 + total: records.length, 89 + cursor, 90 + }, 91 + isStale: false, 92 + cachedAt: null, 93 + } 94 + } 95 + 96 + describe('PackageLikesUtils.getLikesEvolution', () => { 97 + const mockConstellation: Mocked<ConstellationLike> = { 98 + getBackLinks: vi.fn(), 99 + getLinksDistinctDids: vi.fn(), 100 + } 101 + // vi.fn() can't represent CacheAdapter's generic get<T>/set<T> signatures, 102 + // so we assert once here and get full type-safety everywhere else. 103 + const mockCache = { 104 + get: vi.fn(), 105 + set: vi.fn(), 106 + delete: vi.fn(), 107 + } as unknown as Mocked<CacheAdapter> 108 + 109 + beforeEach(() => { 110 + vi.clearAllMocks() 111 + mockCache.get.mockResolvedValue(undefined) 112 + mockCache.set.mockResolvedValue(undefined) 113 + }) 114 + 115 + it('paginates through backlinks and caches the result', async () => { 116 + const day1 = new Date('2025-01-15T10:00:00.000Z') 117 + const day2 = new Date('2025-01-16T10:00:00.000Z') 118 + 119 + // Page 1 returns a cursor 120 + mockConstellation.getBackLinks.mockResolvedValueOnce( 121 + makeBacklinksPage([backlink(day1)], 'cursor-page-2'), 122 + ) 123 + // Page 2 returns no cursor (end) 124 + mockConstellation.getBackLinks.mockResolvedValueOnce(makeBacklinksPage([backlink(day2)])) 125 + 126 + const utils = new PackageLikesUtils({ 127 + constellation: mockConstellation, 128 + cache: mockCache, 129 + }) 130 + 131 + const result = await utils.getLikesEvolution('react') 132 + 133 + expect(mockConstellation.getBackLinks).toHaveBeenCalledTimes(2) 134 + expect(result).toEqual([ 135 + { day: '2025-01-15', likes: 1 }, 136 + { day: '2025-01-16', likes: 1 }, 137 + ]) 138 + expect(mockCache.set).toHaveBeenCalledWith( 139 + expect.stringContaining('evolution'), 140 + result, 141 + expect.any(Number), 142 + ) 143 + }) 144 + 145 + it('returns cached result without calling constellation', async () => { 146 + const cachedData = [{ day: '2025-06-01', likes: 5 }] 147 + mockCache.get.mockResolvedValueOnce(cachedData) 148 + 149 + const utils = new PackageLikesUtils({ 150 + constellation: mockConstellation, 151 + cache: mockCache, 152 + }) 153 + 154 + const result = await utils.getLikesEvolution('lodash') 155 + 156 + expect(mockConstellation.getBackLinks).not.toHaveBeenCalled() 157 + expect(result).toEqual(cachedData) 158 + }) 159 + 160 + it('returns empty array when no backlinks exist', async () => { 161 + mockConstellation.getBackLinks.mockResolvedValueOnce(makeBacklinksPage([])) 162 + 163 + const utils = new PackageLikesUtils({ 164 + constellation: mockConstellation, 165 + cache: mockCache, 166 + }) 167 + 168 + const result = await utils.getLikesEvolution('empty-pkg') 169 + 170 + expect(result).toEqual([]) 171 + }) 172 + })