Skip to content
Merged
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
97 changes: 57 additions & 40 deletions app/components/Package/WeeklyDownloadStats.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,6 @@ const chartModal = useModal('chart-modal')
const hasChartModalTransitioned = shallowRef(false)
const isChartModalOpen = shallowRef(false)

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

function handleModalClose() {
isChartModalOpen.value = false
hasChartModalTransitioned.value = false
Expand Down Expand Up @@ -96,10 +87,24 @@ 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
hasChartModalTransitioned.value = false
// 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 @@ -109,6 +114,8 @@ async function loadWeeklyDownloads() {
weeklyDownloads.value = (result as WeeklyDownloadPoint[]) ?? []
} catch {
weeklyDownloads.value = []
} finally {
isLoadingWeeklyDownloads.value = false
}
}

Expand Down Expand Up @@ -212,6 +219,7 @@ const config = computed(() => {
<CollapsibleSection id="downloads" :title="$t('package.downloads.title')">
<template #actions>
<ButtonBase
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 @@ -223,44 +231,53 @@ 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">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this mean if we're loading the downloads but ultimately have none in the end, we'll flash the chart up then remove it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we're optimistically assuming it WILL load, as it does for 99,9% of cases

<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 @close="handleModalClose" @transitioned="handleModalTransitioned">
<PackageChartModal
v-if="isChartModalOpen && hasWeeklyDownloads"
@close="handleModalClose"
@transitioned="handleModalTransitioned"
>
<!-- The Chart is mounted after the dialog has transitioned -->
<!-- This avoids flaky behavior that hides the chart's minimap half of the time -->
<Transition name="opacity" mode="out-in">
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