Skip to content

Commit 3785a31

Browse files
committed
Feature - Add sparkline chart for weekly downloads
1 parent 71e4343 commit 3785a31

8 files changed

Lines changed: 196 additions & 1 deletion

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from "vue";
3+
import { VueUiSparkline } from "vue-data-ui/vue-ui-sparkline"
4+
5+
const props = defineProps<{
6+
downloads?: Array<{
7+
downloads: number | null,
8+
weekStart: string,
9+
weekEnd: string
10+
}>
11+
}>()
12+
13+
const dataset = computed(() => props.downloads?.map(d => ({
14+
value: d?.downloads ?? 0,
15+
period: `${d.weekStart ?? '-'} to ${d.weekEnd ?? '-'}`
16+
})));
17+
18+
const lastDatapoint = computed(() => {
19+
return (dataset.value || []).at(-1)?.period ?? ''
20+
})
21+
22+
const config = computed(() => ({
23+
theme: 'dark', // enforced dark mode for now
24+
style: {
25+
backgroundColor: 'transparent',
26+
area: {
27+
color: '#6A6A6A',
28+
useGradient: false,
29+
opacity: 10
30+
},
31+
dataLabel: {
32+
offsetX: -10,
33+
fontSize: 28,
34+
bold: false,
35+
color: '#FAFAFA'
36+
},
37+
line: {
38+
color: '#6A6A6A'
39+
},
40+
plot: {
41+
radius: 6,
42+
stroke: '#FAFAFA'
43+
},
44+
title: {
45+
text: lastDatapoint.value,
46+
fontSize: 12,
47+
color: '#666666',
48+
bold: false,
49+
},
50+
verticalIndicator: {
51+
strokeDasharray: 0,
52+
color: '#FAFAFA'
53+
}
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
65+
id="dependencies-heading"
66+
class="text-xs text-fg-subtle uppercase tracking-wider"
67+
>
68+
Weekly Downloads
69+
</h2>
70+
</div>
71+
<div class="w-full">
72+
<ClientOnly>
73+
<VueUiSparkline
74+
:dataset
75+
:config
76+
/>
77+
</ClientOnly>
78+
</div>
79+
</section>
80+
</div>
81+
</template>
82+
83+
<style>
84+
/** Overrides */
85+
.vue-ui-sparkline-title span {
86+
padding: 0 !important;
87+
letter-spacing: 0.04rem;
88+
}
89+
.vue-ui-sparkline text {
90+
font-family: Geist Mono, monospace !important;
91+
}
92+
</style>

app/composables/useCharts.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createSharedComposable } from '@vueuse/core'
2+
3+
export const useCharts = createSharedComposable(function useCharts() {
4+
5+
})

app/composables/useNpmRegistry.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,45 @@ export function usePackageDownloads(
137137
)
138138
}
139139

140+
type NpmDownloadsRangeResponse = {
141+
start: string
142+
end: string
143+
package: string
144+
downloads: Array<{ day: string, downloads: number }>
145+
}
146+
147+
async function fetchNpmDownloadsRange(
148+
packageName: string,
149+
start: string,
150+
end: string,
151+
): Promise<NpmDownloadsRangeResponse> {
152+
const encodedName = encodePackageName(packageName)
153+
return await $fetch<NpmDownloadsRangeResponse>(`${NPM_API}/downloads/range/${start}:${end}/${encodedName}`)
154+
}
155+
156+
export function usePackageWeeklyDownloadEvolution(
157+
name: MaybeRefOrGetter<string>,
158+
options: MaybeRefOrGetter<{
159+
weeks?: number
160+
endDate?: string
161+
}> = {},
162+
) {
163+
return useLazyAsyncData(
164+
() => `downloads-weekly-evolution:${toValue(name)}:${JSON.stringify(toValue(options))}`,
165+
async () => {
166+
const packageName = toValue(name)
167+
const { weeks = 12, endDate } = toValue(options) ?? {}
168+
const end = endDate ? new Date(`${endDate}T00:00:00.000Z`) : new Date()
169+
const start = addDays(end, -(weeks * 7) + 1)
170+
const startIso = toIsoDateString(start)
171+
const endIso = toIsoDateString(end)
172+
const range = await fetchNpmDownloadsRange(packageName, startIso, endIso)
173+
const sortedDaily = [...range.downloads].sort((a, b) => a.day.localeCompare(b.day))
174+
return buildWeeklyEvolutionFromDaily(sortedDaily)
175+
},
176+
)
177+
}
178+
140179
const emptySearchResponse = { objects: [], total: 0, time: new Date().toISOString() } satisfies NpmSearchResponse
141180

142181
export function useNpmSearch(

app/pages/package/[...name].vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const orgName = computed(() => {
4444
const { data: pkg, status, error } = usePackage(packageName, requestedVersion)
4545
4646
const { data: downloads } = usePackageDownloads(packageName, 'last-week')
47+
const { data: weeklyDownloads } = usePackageWeeklyDownloadEvolution(packageName, { weeks: 52 })
4748
4849
// Fetch README for specific version if requested, otherwise latest
4950
const { data: readmeData } = useLazyFetch<{ html: string }>(() => {
@@ -607,6 +608,11 @@ defineOgImageComponent('Package', {
607608
</ul>
608609
</section>
609610

611+
<!-- Donwload stats -->
612+
<PackageDownloadStats
613+
:downloads="weeklyDownloads"
614+
/>
615+
610616
<section
611617
v-if="displayVersion?.engines && (displayVersion.engines.node || displayVersion.engines.npm)"
612618
aria-labelledby="compatibility-heading"

app/utils/charts.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
}

app/utils/formatters.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,10 @@ export function formatDate(dateStr: string): string {
99
day: 'numeric',
1010
})
1111
}
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+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"shiki": "^3.21.0",
4343
"ufo": "^1.6.3",
4444
"unplugin-vue-router": "^0.19.2",
45-
"vue": "3.5.27"
45+
"vue": "3.5.27",
46+
"vue-data-ui": "^3.13.0"
4647
},
4748
"devDependencies": {
4849
"@iconify-json/carbon": "^1.2.18",

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)