···2525 createdIso?: string | null
2626}>()
27272828-const shouldFetch = computed(() => true)
2929-3028const { locale } = useI18n()
3129const { accentColors, selectedAccentColor } = useAccentColor()
3230const colorMode = useColorMode()
···140138 )
141139}
142140141141+/**
142142+ * Formats a single evolution dataset into the structure expected by `VueUiXy`
143143+ * for single-series charts.
144144+ *
145145+ * The dataset is interpreted based on the selected time granularity:
146146+ * - **daily** → uses `timestamp`
147147+ * - **weekly** → uses `timestampEnd`
148148+ * - **monthly** → uses `timestamp`
149149+ * - **yearly** → uses `timestamp`
150150+ *
151151+ * Only datasets matching the expected shape for the given granularity are
152152+ * accepted. If the dataset does not match, an empty result is returned.
153153+ *
154154+ * The returned structure includes:
155155+ * - a single line-series dataset with a consistent color
156156+ * - a list of timestamps used as the x-axis values
157157+ *
158158+ * @param selectedGranularity - Active chart time granularity
159159+ * @param dataset - Raw evolution dataset to format
160160+ * @param seriesName - Display name for the resulting series
161161+ * @returns An object containing a formatted dataset and its associated dates,
162162+ * or `{ dataset: null, dates: [] }` when the input is incompatible
163163+ */
143164function formatXyDataset(
144165 selectedGranularity: ChartTimeGranularity,
145166 dataset: EvolutionData,
···200221 return { dataset: null, dates: [] }
201222}
202223224224+/**
225225+ * Extracts normalized time-series points from an evolution dataset based on
226226+ * the selected time granularity.
227227+ *
228228+ * Each returned point contains:
229229+ * - `timestamp`: the numeric time value used for x-axis alignment
230230+ * - `downloads`: the corresponding value at that time
231231+ *
232232+ * The timestamp field is selected according to granularity:
233233+ * - **daily** → `timestamp`
234234+ * - **weekly** → `timestampEnd`
235235+ * - **monthly** → `timestamp`
236236+ * - **yearly** → `timestamp`
237237+ *
238238+ * If the dataset does not match the expected shape for the given granularity,
239239+ * an empty array is returned.
240240+ *
241241+ * This helper is primarily used in multi-package mode to align multiple
242242+ * datasets on a shared time axis.
243243+ *
244244+ * @param selectedGranularity - Active chart time granularity
245245+ * @param dataset - Raw evolution dataset to extract points from
246246+ * @returns An array of normalized `{ timestamp, downloads }` points
247247+ */
203248function extractSeriesPoints(
204249 selectedGranularity: ChartTimeGranularity,
205250 dataset: EvolutionData,
···263308const endDate = shallowRef<string>('') // YYYY-MM-DD
264309const hasUserEditedDates = shallowRef(false)
265310311311+/**
312312+ * Initializes the date range from the provided weeklyDownloads dataset.
313313+ *
314314+ * The range is inferred directly from the dataset boundaries:
315315+ * - `startDate` is set from the `weekStart` of the first entry
316316+ * - `endDate` is set from the `weekEnd` of the last entry
317317+ *
318318+ * Dates are normalized to `YYYY-MM-DD` and validated before assignment.
319319+ *
320320+ * This function is a no-op when:
321321+ * - the user has already edited the date range
322322+ * - no weekly download data is available
323323+ *
324324+ * The inferred range takes precedence over client-side fallbacks but does not
325325+ * override user-defined dates.
326326+ */
266327function initDateRangeFromWeekly() {
267328 if (hasUserEditedDates.value) return
268329 if (!props.weeklyDownloads?.length) return
···275336 if (isValidIsoDateOnly(end)) endDate.value = end
276337}
277338339339+/**
340340+ * Initializes a default date range on the client when no explicit dates
341341+ * have been provided and the user has not manually edited the range, typically
342342+ * when weeklyDownloads is not provided.
343343+ *
344344+ * The range is computed in UTC to avoid timezone-related off-by-one errors:
345345+ * - `endDate` is set to yesterday (UTC)
346346+ * - `startDate` is set to 29 days before yesterday (UTC), yielding a 30-day range
347347+ *
348348+ * This function is a no-op when:
349349+ * - the user has already edited the date range
350350+ * - the code is running on the server
351351+ * - both `startDate` and `endDate` are already defined
352352+ */
278353function initDateRangeFallbackClient() {
279354 if (hasUserEditedDates.value) return
280355 if (!import.meta.client) return
···297372function toUtcDateOnly(date: Date): string {
298373 return date.toISOString().slice(0, 10)
299374}
375375+300376function addUtcDays(date: Date, days: number): Date {
301377 const next = new Date(date)
302378 next.setUTCDate(next.getUTCDate() + days)
303379 return next
304380}
381381+382382+/**
383383+ * Initializes a default date range for multi-package mode using a fixed
384384+ * 52-week rolling window.
385385+ *
386386+ * The range is computed in UTC to ensure consistent boundaries across
387387+ * timezones:
388388+ * - `endDate` is set to yesterday (UTC)
389389+ * - `startDate` is set to the first day of the 52-week window ending yesterday
390390+ *
391391+ * This function is intended for multi-package comparisons where no explicit
392392+ * date range or dataset-derived range is available.
393393+ *
394394+ * This function is a no-op when:
395395+ * - the user has already edited the date range
396396+ * - the code is running on the server
397397+ * - the component is not in multi-package mode
398398+ * - both `startDate` and `endDate` are already defined
399399+ */
305400function initDateRangeForMultiPackageWeekly52() {
306401 if (hasUserEditedDates.value) return
307402 if (!import.meta.client) return
···364459 | { granularity: 'year'; startDate?: string; endDate?: string }
365460>({ granularity: 'week', weeks: 52 })
366461462462+/**
463463+ * Applies the current date range (`startDate` / `endDate`) to a base options
464464+ * object, returning a new object augmented with validated date fields.
465465+ *
466466+ * Dates are normalized to `YYYY-MM-DD`, validated, and ordered to ensure
467467+ * logical consistency:
468468+ * - When both dates are valid, the earliest is assigned to `startDate` and
469469+ * the latest to `endDate`
470470+ * - When only one valid date is present, only that boundary is applied
471471+ * - Invalid or empty dates are omitted from the result
472472+ *
473473+ * The input object is not mutated.
474474+ *
475475+ * @typeParam T - Base options type to extend with date range fields
476476+ * @param base - Base options object to which the date range should be applied
477477+ * @returns A new options object including the applicable `startDate` and/or
478478+ * `endDate` fields
479479+ */
367480function applyDateRange<T extends Record<string, unknown>>(base: T): T & DateRangeFields {
368481 const next: T & DateRangeFields = { ...base }
369482···396509const isMounted = shallowRef(false)
397510let requestToken = 0
398511512512+// Watches granularity and date inputs to keep request options in sync and
513513+// manage the loading state.
514514+//
515515+// This watcher does NOT perform the fetch itself. Its responsibilities are:
516516+// - derive the correct API options from the selected granularity
517517+// - apply the current validated date range to those options
518518+// - determine whether a loading indicator should be shown
519519+//
520520+// Fetching is debounced separately to avoid excessive
521521+// network requests while the user is interacting with controls.
399522watch(
400523 [selectedGranularity, startDate, endDate],
401524 ([granularityValue]) => {
···410533 if (!isMounted.value) return
411534412535 const packageNames = effectivePackageNames.value
413413- if (!import.meta.client || !shouldFetch.value || !packageNames.length) {
536536+ if (!import.meta.client || !packageNames.length) {
414537 pending.value = false
415538 return
416539 }
···434557 { immediate: true },
435558)
436559560560+/**
561561+ * Fetches download evolution data based on the current granularity,
562562+ * date range, and package selection.
563563+ *
564564+ * This function:
565565+ * - runs only on the client
566566+ * - supports both single-package and multi-package modes
567567+ * - applies request de-duplication via a request token to avoid race conditions
568568+ * - updates the appropriate reactive stores with fetched data
569569+ * - manages the `pending` loading state
570570+ *
571571+ * Behavior details:
572572+ * - In multi-package mode, all packages are fetched in parallel and partial
573573+ * failures are tolerated using `Promise.allSettled`
574574+ * - In single-package mode, weekly data is reused from `weeklyDownloads`
575575+ * when available and no explicit date range is requested
576576+ * - Outdated responses are discarded when a newer request supersedes them
577577+ *
578578+ */
437579async function loadNow() {
438580 if (!import.meta.client) return
439439- if (!shouldFetch.value) return
440581441582 const packageNames = effectivePackageNames.value
442583 if (!packageNames.length) return
···498639 }
499640}
500641642642+// Debounced wrapper around `loadNow` to avoid triggering a network request
643643+// on every intermediate state change while the user is interacting with inputs
644644+//
645645+// This 'arbitrary' 1000 ms delay:
646646+// - gives enough time for the user to finish changing granularity or dates
647647+// - prevents unnecessary API load and visual flicker of the loading state
648648+//
501649const debouncedLoadNow = useDebounceFn(() => {
502650 loadNow()
503651}, 1000)
···506654 const names = effectivePackageNames.value.join(',')
507655 const o = options.value as any
508656 return [
509509- shouldFetch.value ? '1' : '0',
510657 isMultiPackageMode.value ? 'M' : 'S',
511658 names,
512659 String(props.createdIso ?? ''),
···536683 return evolution.value
537684})
538685686686+/**
687687+ * Normalized chart data derived from the fetched evolution datasets.
688688+ *
689689+ * This computed value adapts its behavior based on the current mode:
690690+ *
691691+ * - **Single-package mode**
692692+ * - Delegates formatting to `formatXyDataset`
693693+ * - Produces a single series with its corresponding timestamps
694694+ *
695695+ * - **Multi-package mode**
696696+ * - Merges multiple package datasets into a shared time axis
697697+ * - Aligns all series on the same sorted list of timestamps
698698+ * - Fills missing datapoints with `0` to keep series lengths consistent
699699+ * - Assigns framework-specific colors when applicable
700700+ *
701701+ * The returned structure matches the expectations of `VueUiXy`:
702702+ * - `dataset`: array of series definitions, or `null` when no data is available
703703+ * - `dates`: sorted list of timestamps used as the x-axis reference
704704+ *
705705+ * Returning `dataset: null` explicitly signals the absence of data and allows
706706+ * the template to handle empty states without ambiguity.
707707+ */
539708const chartData = computed<{ dataset: VueUiXyDatasetItem[] | null; dates: number[] }>(() => {
540709 if (!isMultiPackageMode.value) {
541710 const pkg = effectivePackageNames.value[0] ?? props.packageName ?? ''
···558727 const dates = Array.from(timestampSet).sort((a, b) => a - b)
559728 if (!dates.length) return { dataset: null, dates: [] }
560729561561- const dataset: VueUiXyDatasetItem[] = names.map((pkg, index) => {
730730+ const dataset: VueUiXyDatasetItem[] = names.map(pkg => {
562731 const points = pointsByPackage.get(pkg) ?? []
563732 const map = new Map<number, number>()
564733 for (const p of points) map.set(p.timestamp, p.downloads)
···616785 return `${sanitise(label ?? '')}-${g}_${range}.${extension}`
617786}
618787619619-const config = computed(() => {
788788+// VueUiXy chart component configuration
789789+const chartConfig = computed(() => {
620790 return {
621791 theme: isDarkMode.value ? 'dark' : 'default',
622792 chart: {
···8601030 <div role="region" aria-labelledby="download-analytics-title">
8611031 <ClientOnly v-if="chartData.dataset">
8621032 <div>
863863- <VueUiXy :dataset="chartData.dataset" :config="config" class="[direction:ltr]">
10331033+ <VueUiXy :dataset="chartData.dataset" :config="chartConfig" class="[direction:ltr]">
8641034 <!-- Custom legend for multiple series -->
8651035 <template v-if="isMultiPackageMode" #legend="{ legend }">
8661036 <div class="flex gap-4 flex-wrap justify-center">
···9701140 </div>
97111419721142 <div
973973- v-if="shouldFetch && !chartData.dataset && !pending"
11431143+ v-if="!chartData.dataset && !pending"
9741144 class="min-h-[260px] flex items-center justify-center text-fg-subtle font-mono text-sm"
9751145 >
9761146 {{ $t('package.downloads.no_data') }}