Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 55 additions & 38 deletions app/components/Package/WeeklyDownloadStats.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,6 @@ const props = defineProps<{
const chartModal = useModal('chart-modal')

const isChartModalOpen = shallowRef(false)
async function openChartModal() {
isChartModalOpen.value = true
// ensure the component renders before opening the dialog
await nextTick()
await nextTick()
chartModal.open()
}

const { fetchPackageDownloadEvolution } = useCharts()

Expand Down Expand Up @@ -85,10 +78,23 @@ const pulseColor = computed(() => {
})

const weeklyDownloads = shallowRef<WeeklyDownloadPoint[]>([])
const isLoadingWeeklyDownloads = shallowRef(true)
const hasWeeklyDownloads = computed(() => weeklyDownloads.value.length > 0)

async function openChartModal() {
if (!hasWeeklyDownloads.value) return

isChartModalOpen.value = true
// ensure the component renders before opening the dialog
await nextTick()
await nextTick()
chartModal.open()
}

async function loadWeeklyDownloads() {
if (!import.meta.client) return

isLoadingWeeklyDownloads.value = true
try {
const result = await fetchPackageDownloadEvolution(
() => props.packageName,
Expand All @@ -98,6 +104,8 @@ async function loadWeeklyDownloads() {
weeklyDownloads.value = (result as WeeklyDownloadPoint[]) ?? []
} catch {
weeklyDownloads.value = []
} finally {
isLoadingWeeklyDownloads.value = false
}
}

Expand Down Expand Up @@ -201,6 +209,7 @@ const config = computed(() => {
<CollapsibleSection id="downloads" :title="$t('package.downloads.title')">
<template #actions>
<button
v-if="hasWeeklyDownloads"
type="button"
@click="openChartModal"
class="text-fg-subtle hover:text-fg transition-colors duration-200 inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 focus-visible:outline-accent/70 rounded"
Expand All @@ -212,44 +221,52 @@ const config = computed(() => {
</template>

<div class="w-full overflow-hidden">
<ClientOnly>
<VueUiSparkline class="w-full max-w-xs" :dataset :config>
<template #skeleton>
<!-- This empty div overrides the default built-in scanning animation on load -->
<div />
</template>
</VueUiSparkline>
<template #fallback>
<!-- Skeleton matching sparkline layout: title row + chart with data label -->
<div class="min-h-[75.195px]">
<!-- Title row: date range (24px height) -->
<div class="h-6 flex items-center ps-3">
<SkeletonInline class="h-3 w-36" />
</div>
<!-- Chart area: data label left, sparkline right -->
<div class="aspect-[500/80] flex items-center">
<!-- Data label (covers ~42% width) -->
<div class="w-[42%] flex items-center ps-0.5">
<SkeletonInline class="h-7 w-24" />
<template v-if="isLoadingWeeklyDownloads || hasWeeklyDownloads">
<ClientOnly>
<VueUiSparkline class="w-full max-w-xs" :dataset :config>
<template #skeleton>
<!-- This empty div overrides the default built-in scanning animation on load -->
<div />
</template>
</VueUiSparkline>
<template #fallback>
<!-- Skeleton matching sparkline layout: title row + chart with data label -->
<div class="min-h-[75.195px]">
<!-- Title row: date range (24px height) -->
<div class="h-6 flex items-center ps-3">
<SkeletonInline class="h-3 w-36" />
</div>
<!-- Sparkline area (~58% width) -->
<div class="flex-1 flex items-end gap-0.5 h-4/5 pe-3">
<SkeletonInline
v-for="i in 16"
:key="i"
class="flex-1 rounded-sm"
:style="{ height: `${25 + ((i * 7) % 50)}%` }"
/>
<!-- Chart area: data label left, sparkline right -->
<div class="aspect-[500/80] flex items-center">
<!-- Data label (covers ~42% width) -->
<div class="w-[42%] flex items-center ps-0.5">
<SkeletonInline class="h-7 w-24" />
</div>
<!-- Sparkline area (~58% width) -->
<div class="flex-1 flex items-end gap-0.5 h-4/5 pe-3">
<SkeletonInline
v-for="i in 16"
:key="i"
class="flex-1 rounded-sm"
:style="{ height: `${25 + ((i * 7) % 50)}%` }"
/>
</div>
</div>
</div>
</div>
</template>
</ClientOnly>
</template>
</ClientOnly>
</template>
<p v-else class="py-2 text-sm font-mono text-fg-subtle">
{{ $t('package.downloads.no_data') }}
</p>
</div>
</CollapsibleSection>
</div>

<PackageChartModal v-if="isChartModalOpen" @close="isChartModalOpen = false">
<PackageChartModal
v-if="isChartModalOpen && hasWeeklyDownloads"
@close="isChartModalOpen = false"
>
<PackageDownloadAnalytics
:weeklyDownloads="weeklyDownloads"
:inModal="true"
Expand Down
65 changes: 65 additions & 0 deletions test/nuxt/components/PackageWeeklyDownloadStats.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
import { defineComponent, h } from 'vue'
import { describe, expect, it, vi } from 'vitest'

const { mockFetchPackageDownloadEvolution } = vi.hoisted(() => ({
mockFetchPackageDownloadEvolution: vi.fn(),
}))

mockNuxtImport('useCharts', () => {
return () => ({
fetchPackageDownloadEvolution: (...args: unknown[]) =>
mockFetchPackageDownloadEvolution(...args),
})
})

vi.mock('vue-data-ui/vue-ui-sparkline', () => ({
VueUiSparkline: defineComponent({
name: 'VueUiSparkline',
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h('div', { class: attrs.class }, slots.default?.() ?? [])
},
}),
}))

import PackageWeeklyDownloadStats from '~/components/Package/WeeklyDownloadStats.vue'

describe('PackageWeeklyDownloadStats', () => {
const baseProps = {
packageName: 'test-package',
createdIso: '2026-02-05T00:00:00.000Z',
}

it('hides the section when weekly downloads are empty', async () => {
mockFetchPackageDownloadEvolution.mockReset()
mockFetchPackageDownloadEvolution.mockResolvedValue([])

const component = await mountSuspended(PackageWeeklyDownloadStats, {
props: baseProps,
})

expect(component.text()).toContain('Weekly Downloads')
expect(component.text()).toContain('No download data available')
})

it('shows the section when weekly downloads exist', async () => {
mockFetchPackageDownloadEvolution.mockReset()
mockFetchPackageDownloadEvolution.mockResolvedValue([
{
weekStart: '2026-01-01',
weekEnd: '2026-01-07',
timestampStart: 1767225600000,
timestampEnd: 1767744000000,
downloads: 42,
},
])

const component = await mountSuspended(PackageWeeklyDownloadStats, {
props: baseProps,
})

expect(component.text()).toContain('Weekly Downloads')
expect(component.text()).not.toContain('No download data available')
})
})
Loading