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
133 changes: 87 additions & 46 deletions app/components/Package/DownloadAnalytics.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,38 @@ const props = defineProps<{
createdIso: string | null
}>()

const { locale } = useI18n()
const { accentColors, selectedAccentColor } = useAccentColor()
const colorMode = useColorMode()
const resolvedMode = shallowRef<'light' | 'dark'>('light')
const rootEl = shallowRef<HTMLElement | null>(null)

const { width } = useElementSize(rootEl)

const chartKey = ref(0)

let chartRemountTimeoutId: ReturnType<typeof setTimeout> | null = null

onMounted(() => {
rootEl.value = document.documentElement
resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light'

// If the chart is painted too early, built-in auto-sizing does not adapt to the final container size
chartRemountTimeoutId = setTimeout(() => {
chartKey.value += 1
chartRemountTimeoutId = null
}, 1)
})

onBeforeUnmount(() => {
if (chartRemountTimeoutId !== null) {
clearTimeout(chartRemountTimeoutId)
chartRemountTimeoutId = null
}
})

const { colors } = useCssVariables(
['--bg', '--bg-subtle', '--bg-elevated', '--fg-subtle', '--border', '--border-subtle'],
['--bg', '--fg', '--bg-subtle', '--bg-elevated', '--fg-subtle', '--border', '--border-subtle'],
{
element: rootEl,
watchHtmlAttributes: true,
Expand Down Expand Up @@ -121,7 +139,7 @@ function isYearlyDataset(data: unknown): data is YearlyDownloadPoint[] {
function formatXyDataset(
selectedGranularity: ChartTimeGranularity,
dataset: EvolutionData,
): { dataset: VueUiXyDatasetItem[] | null; dates: string[] } {
): { dataset: VueUiXyDatasetItem[] | null; dates: number[] } {
if (selectedGranularity === 'weekly' && isWeeklyDataset(dataset)) {
return {
dataset: [
Expand All @@ -132,12 +150,7 @@ function formatXyDataset(
color: accent.value,
},
],
dates: dataset.map(d =>
$t('package.downloads.date_range_multiline', {
start: d.weekStart,
end: d.weekEnd,
}),
),
dates: dataset.map(d => d.timestampEnd),
}
}
if (selectedGranularity === 'daily' && isDailyDataset(dataset)) {
Expand All @@ -150,7 +163,7 @@ function formatXyDataset(
color: accent.value,
},
],
dates: dataset.map(d => d.day),
dates: dataset.map(d => d.timestamp),
}
}
if (selectedGranularity === 'monthly' && isMonthlyDataset(dataset)) {
Expand All @@ -163,7 +176,7 @@ function formatXyDataset(
color: accent.value,
},
],
dates: dataset.map(d => d.month),
dates: dataset.map(d => d.timestamp),
}
}
if (selectedGranularity === 'yearly' && isYearlyDataset(dataset)) {
Expand All @@ -176,7 +189,7 @@ function formatXyDataset(
color: accent.value,
},
],
dates: dataset.map(d => d.year),
dates: dataset.map(d => d.timestamp),
}
}
return { dataset: null, dates: [] }
Expand All @@ -198,18 +211,6 @@ function safeMax(a: string, b: string): string {
return a.localeCompare(b) >= 0 ? a : b
}

function extractDates(dateLabel: string): [string, string] | null {
const matches = dateLabel.match(/\b(\d{4}(?:-\d{2}-\d{2})?)\b/g) // either yyyy or yyyy-mm-dd
if (!matches) return null

const first = matches.at(0)
const last = matches.at(-1)

if (!first || !last || first === last) return null

return [first, last]
}

/**
* Two-phase state:
* - selectedGranularity: immediate UI
Expand Down Expand Up @@ -439,7 +440,7 @@ const effectiveData = computed<EvolutionData>(() => {
return evolution.value
})

const chartData = computed<{ dataset: VueUiXyDatasetItem[] | null; dates: string[] }>(() => {
const chartData = computed<{ dataset: VueUiXyDatasetItem[] | null; dates: number[] }>(() => {
return formatXyDataset(displayedGranularity.value, effectiveData.value)
})

Expand All @@ -453,11 +454,39 @@ const loadFile = (link: string, filename: string) => {
a.remove()
}

const datetimeFormatterOptions = computed(() => {
return {
daily: {
year: 'yyyy-MM-dd',
month: 'yyyy-MM-dd',
day: 'yyyy-MM-dd',
},
weekly: {
year: 'yyyy-MM-dd',
month: 'yyyy-MM-dd',
day: 'yyyy-MM-dd',
},
monthly: {
year: 'MMM yyyy',
month: 'MMM yyyy',
day: 'MMM yyyy',
},
yearly: {
year: 'yyyy',
month: 'yyyy',
day: 'yyyy',
},
}[selectedGranularity.value]
})

const config = computed(() => {
return {
theme: isDarkMode.value ? 'dark' : 'default',
chart: {
height: isMobile.value ? 950 : 600,
padding: {
bottom: 36,
},
userOptions: {
buttons: {
pdf: false,
Expand Down Expand Up @@ -525,17 +554,33 @@ const config = computed(() => {
fontSize: isMobile.value ? 32 : 24,
},
xAxisLabels: {
show: !isMobile.value,
show: false,
values: chartData.value?.dates,
showOnlyAtModulo: true,
modulo: 12,
datetimeFormatter: {
enable: true,
locale: locale.value,
useUTC: true,
options: datetimeFormatterOptions.value,
},
},
yAxis: {
formatter,
useNiceScale: true,
},
},
},
timeTag: {
show: true,
backgroundColor: colors.value.bgElevated,
color: colors.value.fg,
fontSize: 16,
circleMarker: {
radius: 3,
color: colors.value.border,
},
useDefaultFormat: true,
timeFormat: 'yyyy-MM-dd HH:mm:ss',
},
highlighter: {
useLine: true,
},
Expand All @@ -547,32 +592,17 @@ const config = computed(() => {
borderColor: 'transparent',
backdropFilter: false,
backgroundColor: 'transparent',
customFormat: ({
absoluteIndex,
datapoint,
}: {
absoluteIndex: number
datapoint: Record<string, any>
}) => {
customFormat: ({ datapoint }: { datapoint: Record<string, any> }) => {
if (!datapoint) return ''
const displayValue = formatter({ value: datapoint[0]?.value ?? 0 })
return `<div class="flex flex-col font-mono text-xs p-3 border border-border rounded-md bg-[var(--bg)]/10 backdrop-blur-md">
<span class="text-fg-subtle">${chartData.value?.dates[absoluteIndex]}</span>
<span class="text-xl">${displayValue}</span>
<span class="text-xl text-[var(--fg)]">${displayValue}</span>
</div>
`
},
},
zoom: {
maxWidth: isMobile.value ? 350 : 500,
customFormat:
displayedGranularity.value !== 'weekly'
? undefined
: ({ absoluteIndex, side }: { absoluteIndex: number; side: 'left' | 'right' }) => {
const parts = extractDates(chartData.value.dates[absoluteIndex] ?? '')
if (!parts) return ''
return side === 'left' ? parts[0] : parts[1]
},
highlightColor: colors.value.bgElevated,
minimap: {
show: true,
Expand All @@ -594,7 +624,7 @@ const config = computed(() => {
</script>

<template>
<div class="w-full relative">
<div class="w-full relative" id="download-analytics">
<div class="w-full mb-4 flex flex-col gap-3">
<!-- Mobile: stack vertically, Desktop: horizontal -->
<div class="flex flex-col sm:flex-row gap-3 sm:gap-2 sm:items-end">
Expand Down Expand Up @@ -688,7 +718,12 @@ const config = computed(() => {
</div>

<ClientOnly v-if="inModal && chartData.dataset">
<VueUiXy :dataset="chartData.dataset" :config="config" class="[direction:ltr]">
<VueUiXy
:dataset="chartData.dataset"
:config="config"
class="[direction:ltr]"
:key="chartKey"
>
<template #menuIcon="{ isOpen }">
<span v-if="isOpen" class="i-carbon:close w-6 h-6" aria-hidden="true" />
<span v-else class="i-carbon:overflow-menu-vertical w-6 h-6" aria-hidden="true" />
Expand Down Expand Up @@ -799,4 +834,10 @@ const config = computed(() => {
background: var(--bg-elevated) !important;
box-shadow: none !important;
}

/* Override default placement of the refresh button to have it to the minimap's side */
#download-analytics .vue-data-ui-refresh-button {
top: -0.6rem !important;
left: calc(100% + 2rem) !important;
}
</style>
38 changes: 29 additions & 9 deletions app/composables/useCharts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ export type PackumentLikeForTime = {
time?: Record<string, string>
}

export type DailyDownloadPoint = { downloads: number; day: string }
export type DailyDownloadPoint = { downloads: number; day: string; timestamp: number }
export type WeeklyDownloadPoint = {
downloads: number
weekKey: string
weekStart: string
weekEnd: string
timestampStart: number
timestampEnd: number
}
export type MonthlyDownloadPoint = { downloads: number; month: string }
export type YearlyDownloadPoint = { downloads: number; year: string }
export type MonthlyDownloadPoint = { downloads: number; month: string; timestamp: number }
export type YearlyDownloadPoint = { downloads: number; year: string; timestamp: number }

type PackageDownloadEvolutionOptionsBase = {
startDate?: string
Expand Down Expand Up @@ -124,11 +126,16 @@ function mergeDailyPoints(

function buildDailyEvolutionFromDaily(
daily: Array<{ day: string; downloads: number }>,
): DailyDownloadPoint[] {
): Array<{ day: string; downloads: number; timestamp: number }> {
return daily
.slice()
.sort((a, b) => a.day.localeCompare(b.day))
.map(item => ({ day: item.day, downloads: item.downloads }))
.map(item => {
const dayDate = parseIsoDateOnly(item.day)
const timestamp = dayDate.getTime()

return { day: item.day, downloads: item.downloads, timestamp }
})
}

function buildRollingWeeklyEvolutionFromDaily(
Expand Down Expand Up @@ -164,18 +171,23 @@ function buildRollingWeeklyEvolutionFromDaily(
const weekStartIso = toIsoDateString(weekStartDate)
const weekEndIso = toIsoDateString(clampedWeekEndDate)

const timestampStart = weekStartDate.getTime()
const timestampEnd = clampedWeekEndDate.getTime()

return {
downloads,
weekKey: `${weekStartIso}_${weekEndIso}`,
weekStart: weekStartIso,
weekEnd: weekEndIso,
timestampStart,
timestampEnd,
}
})
}

function buildMonthlyEvolutionFromDaily(
daily: Array<{ day: string; downloads: number }>,
): MonthlyDownloadPoint[] {
): Array<{ month: string; downloads: number; timestamp: number }> {
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
const downloadsByMonth = new Map<string, number>()

Expand All @@ -186,12 +198,16 @@ function buildMonthlyEvolutionFromDaily(

return Array.from(downloadsByMonth.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, downloads]) => ({ month, downloads }))
.map(([month, downloads]) => {
const monthStartDate = parseIsoDateOnly(`${month}-01`)
const timestamp = monthStartDate.getTime()
return { month, downloads, timestamp }
})
}

function buildYearlyEvolutionFromDaily(
daily: Array<{ day: string; downloads: number }>,
): YearlyDownloadPoint[] {
): Array<{ year: string; downloads: number; timestamp: number }> {
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
const downloadsByYear = new Map<string, number>()

Expand All @@ -202,7 +218,11 @@ function buildYearlyEvolutionFromDaily(

return Array.from(downloadsByYear.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([year, downloads]) => ({ year, downloads }))
.map(([year, downloads]) => {
const yearStartDate = parseIsoDateOnly(`${year}-01-01`)
const timestamp = yearStartDate.getTime()
return { year, downloads, timestamp }
})
}

function getClientDailyRangePromiseCache() {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
"vite-plugin-pwa": "1.2.0",
"vite-plus": "0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab",
"vue": "3.5.27",
"vue-data-ui": "3.14.0"
"vue-data-ui": "3.14.1"
},
"devDependencies": {
"@npm/types": "2.1.0",
Expand Down
Loading
Loading