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

Configure Feed

Select the types of activity you want to include in your feed.

feat: sparkline chart for weekly downloads (#10)

authored by

Alec Lloyd Probert and committed by
GitHub
60b107b0 90163329

+190 -1
+88
app/components/PackageDownloadStats.vue
··· 1 + <script setup lang="ts"> 2 + import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline' 3 + 4 + const props = defineProps<{ 5 + downloads?: Array<{ 6 + downloads: number | null 7 + weekStart: string 8 + weekEnd: string 9 + }> 10 + }>() 11 + 12 + const dataset = computed(() => 13 + props.downloads?.map(d => ({ 14 + value: d?.downloads ?? 0, 15 + period: `${d.weekStart ?? '-'} to ${d.weekEnd ?? '-'}`, 16 + })), 17 + ) 18 + 19 + const lastDatapoint = computed(() => { 20 + return (dataset.value || []).at(-1)?.period ?? '' 21 + }) 22 + 23 + const config = computed(() => ({ 24 + theme: 'dark', // enforced dark mode for now 25 + style: { 26 + backgroundColor: 'transparent', 27 + area: { 28 + color: '#6A6A6A', 29 + useGradient: false, 30 + opacity: 10, 31 + }, 32 + dataLabel: { 33 + offsetX: -10, 34 + fontSize: 28, 35 + bold: false, 36 + color: '#FAFAFA', 37 + }, 38 + line: { 39 + color: '#6A6A6A', 40 + }, 41 + plot: { 42 + radius: 6, 43 + stroke: '#FAFAFA', 44 + }, 45 + title: { 46 + text: lastDatapoint.value, 47 + fontSize: 12, 48 + color: '#666666', 49 + bold: false, 50 + }, 51 + verticalIndicator: { 52 + strokeDasharray: 0, 53 + color: '#FAFAFA', 54 + }, 55 + }, 56 + })) 57 + </script> 58 + 59 + <template> 60 + <div class="space-y-8"> 61 + <!-- Download stats --> 62 + <section> 63 + <div class="flex items-center justify-between mb-3"> 64 + <h2 id="dependencies-heading" class="text-xs text-fg-subtle uppercase tracking-wider"> 65 + Weekly Downloads 66 + </h2> 67 + </div> 68 + <div class="w-full"> 69 + <ClientOnly> 70 + <VueUiSparkline :dataset :config /> 71 + </ClientOnly> 72 + </div> 73 + </section> 74 + </div> 75 + </template> 76 + 77 + <style> 78 + /** Overrides */ 79 + .vue-ui-sparkline-title span { 80 + padding: 0 !important; 81 + letter-spacing: 0.04rem; 82 + } 83 + .vue-ui-sparkline text { 84 + font-family: 85 + Geist Mono, 86 + monospace !important; 87 + } 88 + </style>
+3
app/composables/useCharts.ts
··· 1 + import { createSharedComposable } from '@vueuse/core' 2 + 3 + export const useCharts = createSharedComposable(function useCharts() {})
+41
app/composables/useNpmRegistry.ts
··· 141 141 ) 142 142 } 143 143 144 + type NpmDownloadsRangeResponse = { 145 + start: string 146 + end: string 147 + package: string 148 + downloads: Array<{ day: string; downloads: number }> 149 + } 150 + 151 + async function fetchNpmDownloadsRange( 152 + packageName: string, 153 + start: string, 154 + end: string, 155 + ): Promise<NpmDownloadsRangeResponse> { 156 + const encodedName = encodePackageName(packageName) 157 + return await $fetch<NpmDownloadsRangeResponse>( 158 + `${NPM_API}/downloads/range/${start}:${end}/${encodedName}`, 159 + ) 160 + } 161 + 162 + export function usePackageWeeklyDownloadEvolution( 163 + name: MaybeRefOrGetter<string>, 164 + options: MaybeRefOrGetter<{ 165 + weeks?: number 166 + endDate?: string 167 + }> = {}, 168 + ) { 169 + return useLazyAsyncData( 170 + () => `downloads-weekly-evolution:${toValue(name)}:${JSON.stringify(toValue(options))}`, 171 + async () => { 172 + const packageName = toValue(name) 173 + const { weeks = 12, endDate } = toValue(options) ?? {} 174 + const end = endDate ? new Date(`${endDate}T00:00:00.000Z`) : new Date() 175 + const start = addDays(end, -(weeks * 7) + 1) 176 + const startIso = toIsoDateString(start) 177 + const endIso = toIsoDateString(end) 178 + const range = await fetchNpmDownloadsRange(packageName, startIso, endIso) 179 + const sortedDaily = [...range.downloads].sort((a, b) => a.day.localeCompare(b.day)) 180 + return buildWeeklyEvolutionFromDaily(sortedDaily) 181 + }, 182 + ) 183 + } 184 + 144 185 const emptySearchResponse = { 145 186 objects: [], 146 187 total: 0,
+4
app/pages/package/[...name].vue
··· 42 42 const { data: pkg, status, error } = usePackage(packageName, requestedVersion) 43 43 44 44 const { data: downloads } = usePackageDownloads(packageName, 'last-week') 45 + const { data: weeklyDownloads } = usePackageWeeklyDownloadEvolution(packageName, { weeks: 52 }) 45 46 46 47 // Fetch README for specific version if requested, otherwise latest 47 48 const { data: readmeData } = useLazyFetch<{ html: string }>( ··· 565 566 </li> 566 567 </ul> 567 568 </section> 569 + 570 + <!-- Donwload stats --> 571 + <PackageDownloadStats :downloads="weeklyDownloads" /> 568 572 569 573 <section 570 574 v-if="
+29
app/utils/charts.ts
··· 1 + export function sum(numbers: number[]): number { 2 + return numbers.reduce((a, b) => a + b, 0) 3 + } 4 + 5 + export function chunkIntoWeeks<T>(items: T[], weekSize = 7): T[][] { 6 + const result: T[][] = [] 7 + for (let index = 0; index < items.length; index += weekSize) { 8 + result.push(items.slice(index, index + weekSize)) 9 + } 10 + return result 11 + } 12 + 13 + export function buildWeeklyEvolutionFromDaily( 14 + daily: Array<{ day: string; downloads: number }>, 15 + ): Array<{ weekStart: string; weekEnd: string; downloads: number }> { 16 + const weeks = chunkIntoWeeks(daily, 7) 17 + return weeks.map(weekDays => { 18 + const weekStart = weekDays[0]?.day ?? '' 19 + const weekEnd = weekDays[weekDays.length - 1]?.day ?? '' 20 + const downloads = sum(weekDays.map(d => d.downloads)) 21 + return { weekStart, weekEnd, downloads } 22 + }) 23 + } 24 + 25 + export function addDays(date: Date, days: number): Date { 26 + const d = new Date(date) 27 + d.setUTCDate(d.getUTCDate() + days) 28 + return d 29 + }
+7
app/utils/formatters.ts
··· 9 9 day: 'numeric', 10 10 }) 11 11 } 12 + 13 + export function toIsoDateString(date: Date): string { 14 + const year = date.getUTCFullYear() 15 + const month = String(date.getUTCMonth() + 1).padStart(2, '0') 16 + const day = String(date.getUTCDate()).padStart(2, '0') 17 + return `${year}-${month}-${day}` 18 + }
+2 -1
package.json
··· 42 42 "shiki": "^3.21.0", 43 43 "ufo": "^1.6.3", 44 44 "unplugin-vue-router": "^0.19.2", 45 - "vue": "3.5.27" 45 + "vue": "3.5.27", 46 + "vue-data-ui": "^3.13.0" 46 47 }, 47 48 "devDependencies": { 48 49 "@iconify-json/carbon": "^1.2.18",
+16
pnpm-lock.yaml
··· 64 64 vue: 65 65 specifier: 3.5.27 66 66 version: 3.5.27(typescript@5.9.3) 67 + vue-data-ui: 68 + specifier: ^3.13.0 69 + version: 3.13.0(vue@3.5.27(typescript@5.9.3)) 67 70 devDependencies: 68 71 '@iconify-json/carbon': 69 72 specifier: ^1.2.18 ··· 6713 6716 6714 6717 vue-component-type-helpers@2.2.12: 6715 6718 resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} 6719 + 6720 + vue-data-ui@3.13.0: 6721 + resolution: {integrity: sha512-N9IlA9knxsKEgyqZoWBA1pw3oIPJ+yBUYfndoygranAYmtW8YON7mQ9AzfYL12xEVPosLsM+EbRqEozgi4OQ3Q==} 6722 + peerDependencies: 6723 + jspdf: '>=3.0.1' 6724 + vue: '>=3.3.0' 6725 + peerDependenciesMeta: 6726 + jspdf: 6727 + optional: true 6716 6728 6717 6729 vue-devtools-stub@0.1.0: 6718 6730 resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} ··· 14447 14459 ufo: 1.6.3 14448 14460 14449 14461 vue-component-type-helpers@2.2.12: {} 14462 + 14463 + vue-data-ui@3.13.0(vue@3.5.27(typescript@5.9.3)): 14464 + dependencies: 14465 + vue: 3.5.27(typescript@5.9.3) 14450 14466 14451 14467 vue-devtools-stub@0.1.0: {} 14452 14468