From 3785a3191c2b7ca8b3a7f468e7f50461e2a4ccea Mon Sep 17 00:00:00 2001 From: graphieros Date: Sat, 24 Jan 2026 12:50:46 +0100 Subject: [PATCH 1/2] Feature - Add sparkline chart for weekly downloads --- app/components/PackageDownloadStats.vue | 92 +++++++++++++++++++++++++ app/composables/useCharts.ts | 5 ++ app/composables/useNpmRegistry.ts | 39 +++++++++++ app/pages/package/[...name].vue | 6 ++ app/utils/charts.ts | 29 ++++++++ app/utils/formatters.ts | 7 ++ package.json | 3 +- pnpm-lock.yaml | 16 +++++ 8 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 app/components/PackageDownloadStats.vue create mode 100644 app/composables/useCharts.ts create mode 100644 app/utils/charts.ts diff --git a/app/components/PackageDownloadStats.vue b/app/components/PackageDownloadStats.vue new file mode 100644 index 0000000000..a67ac76b51 --- /dev/null +++ b/app/components/PackageDownloadStats.vue @@ -0,0 +1,92 @@ + + + + + \ No newline at end of file diff --git a/app/composables/useCharts.ts b/app/composables/useCharts.ts new file mode 100644 index 0000000000..b1f45a88f1 --- /dev/null +++ b/app/composables/useCharts.ts @@ -0,0 +1,5 @@ +import { createSharedComposable } from '@vueuse/core' + +export const useCharts = createSharedComposable(function useCharts() { + +}) diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index 95afaedf4b..238c63bc9f 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -137,6 +137,45 @@ export function usePackageDownloads( ) } +type NpmDownloadsRangeResponse = { + start: string + end: string + package: string + downloads: Array<{ day: string, downloads: number }> +} + +async function fetchNpmDownloadsRange( + packageName: string, + start: string, + end: string, +): Promise { + const encodedName = encodePackageName(packageName) + return await $fetch(`${NPM_API}/downloads/range/${start}:${end}/${encodedName}`) +} + +export function usePackageWeeklyDownloadEvolution( + name: MaybeRefOrGetter, + options: MaybeRefOrGetter<{ + weeks?: number + endDate?: string + }> = {}, +) { + return useLazyAsyncData( + () => `downloads-weekly-evolution:${toValue(name)}:${JSON.stringify(toValue(options))}`, + async () => { + const packageName = toValue(name) + const { weeks = 12, endDate } = toValue(options) ?? {} + const end = endDate ? new Date(`${endDate}T00:00:00.000Z`) : new Date() + const start = addDays(end, -(weeks * 7) + 1) + const startIso = toIsoDateString(start) + const endIso = toIsoDateString(end) + const range = await fetchNpmDownloadsRange(packageName, startIso, endIso) + const sortedDaily = [...range.downloads].sort((a, b) => a.day.localeCompare(b.day)) + return buildWeeklyEvolutionFromDaily(sortedDaily) + }, + ) +} + const emptySearchResponse = { objects: [], total: 0, time: new Date().toISOString() } satisfies NpmSearchResponse export function useNpmSearch( diff --git a/app/pages/package/[...name].vue b/app/pages/package/[...name].vue index 92cfe9d1f3..987f5c80f7 100644 --- a/app/pages/package/[...name].vue +++ b/app/pages/package/[...name].vue @@ -44,6 +44,7 @@ const orgName = computed(() => { const { data: pkg, status, error } = usePackage(packageName, requestedVersion) const { data: downloads } = usePackageDownloads(packageName, 'last-week') +const { data: weeklyDownloads } = usePackageWeeklyDownloadEvolution(packageName, { weeks: 52 }) // Fetch README for specific version if requested, otherwise latest const { data: readmeData } = useLazyFetch<{ html: string }>(() => { @@ -607,6 +608,11 @@ defineOgImageComponent('Package', { + + +
a + b, 0) +} + +export function chunkIntoWeeks(items: T[], weekSize = 7): T[][] { + const result: T[][] = [] + for (let index = 0; index < items.length; index += weekSize) { + result.push(items.slice(index, index + weekSize)) + } + return result +} + +export function buildWeeklyEvolutionFromDaily( + daily: Array<{ day: string, downloads: number }>, +): Array<{ weekStart: string, weekEnd: string, downloads: number }> { + const weeks = chunkIntoWeeks(daily, 7) + return weeks.map((weekDays) => { + const weekStart = weekDays[0]?.day ?? '' + const weekEnd = weekDays[weekDays.length - 1]?.day ?? '' + const downloads = sum(weekDays.map(d => d.downloads)) + return { weekStart, weekEnd, downloads } + }) +} + +export function addDays(date: Date, days: number): Date { + const d = new Date(date) + d.setUTCDate(d.getUTCDate() + days) + return d +} diff --git a/app/utils/formatters.ts b/app/utils/formatters.ts index 2b6eaf527f..cbd05cffc1 100644 --- a/app/utils/formatters.ts +++ b/app/utils/formatters.ts @@ -9,3 +9,10 @@ export function formatDate(dateStr: string): string { day: 'numeric', }) } + +export function toIsoDateString(date: Date): string { + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const day = String(date.getUTCDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} diff --git a/package.json b/package.json index f78c51a52b..86e795ec24 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "shiki": "^3.21.0", "ufo": "^1.6.3", "unplugin-vue-router": "^0.19.2", - "vue": "3.5.27" + "vue": "3.5.27", + "vue-data-ui": "^3.13.0" }, "devDependencies": { "@iconify-json/carbon": "^1.2.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf283e8434..8cde7e8db6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: vue: specifier: 3.5.27 version: 3.5.27(typescript@5.9.3) + vue-data-ui: + specifier: ^3.13.0 + version: 3.13.0(vue@3.5.27(typescript@5.9.3)) devDependencies: '@iconify-json/carbon': specifier: ^1.2.18 @@ -7090,6 +7093,15 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} + vue-data-ui@3.13.0: + resolution: {integrity: sha512-N9IlA9knxsKEgyqZoWBA1pw3oIPJ+yBUYfndoygranAYmtW8YON7mQ9AzfYL12xEVPosLsM+EbRqEozgi4OQ3Q==} + peerDependencies: + jspdf: '>=3.0.1' + vue: '>=3.3.0' + peerDependenciesMeta: + jspdf: + optional: true + vue-devtools-stub@0.1.0: resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} @@ -15255,6 +15267,10 @@ snapshots: vue-component-type-helpers@2.2.12: {} + vue-data-ui@3.13.0(vue@3.5.27(typescript@5.9.3)): + dependencies: + vue: 3.5.27(typescript@5.9.3) + vue-devtools-stub@0.1.0: {} vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1)): From ecb5e5b694efc6b1c865cea6d773f3f83e137300 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 24 Jan 2026 12:16:11 +0000 Subject: [PATCH 2/2] chore: lint --- app/components/PackageDownloadStats.vue | 134 ++++++++++++------------ app/composables/useCharts.ts | 4 +- app/composables/useNpmRegistry.ts | 12 ++- app/pages/package/[...name].vue | 4 +- app/utils/charts.ts | 6 +- 5 files changed, 79 insertions(+), 81 deletions(-) diff --git a/app/components/PackageDownloadStats.vue b/app/components/PackageDownloadStats.vue index a67ac76b51..639a63c816 100644 --- a/app/components/PackageDownloadStats.vue +++ b/app/components/PackageDownloadStats.vue @@ -1,92 +1,88 @@ \ No newline at end of file + diff --git a/app/composables/useCharts.ts b/app/composables/useCharts.ts index b1f45a88f1..98ccdf127e 100644 --- a/app/composables/useCharts.ts +++ b/app/composables/useCharts.ts @@ -1,5 +1,3 @@ import { createSharedComposable } from '@vueuse/core' -export const useCharts = createSharedComposable(function useCharts() { - -}) +export const useCharts = createSharedComposable(function useCharts() {}) diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index da97d56918..07d5be42ef 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -145,7 +145,7 @@ type NpmDownloadsRangeResponse = { start: string end: string package: string - downloads: Array<{ day: string, downloads: number }> + downloads: Array<{ day: string; downloads: number }> } async function fetchNpmDownloadsRange( @@ -154,7 +154,9 @@ async function fetchNpmDownloadsRange( end: string, ): Promise { const encodedName = encodePackageName(packageName) - return await $fetch(`${NPM_API}/downloads/range/${start}:${end}/${encodedName}`) + return await $fetch( + `${NPM_API}/downloads/range/${start}:${end}/${encodedName}`, + ) } export function usePackageWeeklyDownloadEvolution( @@ -180,7 +182,11 @@ export function usePackageWeeklyDownloadEvolution( ) } -const emptySearchResponse = { objects: [], total: 0, time: new Date().toISOString() } satisfies NpmSearchResponse +const emptySearchResponse = { + objects: [], + total: 0, + time: new Date().toISOString(), +} satisfies NpmSearchResponse export function useNpmSearch( query: MaybeRefOrGetter, diff --git a/app/pages/package/[...name].vue b/app/pages/package/[...name].vue index 3592dde3dc..0704deda7e 100644 --- a/app/pages/package/[...name].vue +++ b/app/pages/package/[...name].vue @@ -568,9 +568,7 @@ defineOgImageComponent('Package', {
- +