@@ -25,8 +25,6 @@ const props = defineProps<{
2525 createdIso? : string | null
2626}>()
2727
28- const shouldFetch = computed (() => true )
29-
3028const { locale } = useI18n ()
3129const { accentColors, selectedAccentColor } = useAccentColor ()
3230const colorMode = useColorMode ()
@@ -140,6 +138,29 @@ function isYearlyDataset(data: unknown): data is YearlyDownloadPoint[] {
140138 )
141139}
142140
141+ /**
142+ * Formats a single evolution dataset into the structure expected by `VueUiXy`
143+ * for single-series charts.
144+ *
145+ * The dataset is interpreted based on the selected time granularity:
146+ * - **daily** → uses `timestamp`
147+ * - **weekly** → uses `timestampEnd`
148+ * - **monthly** → uses `timestamp`
149+ * - **yearly** → uses `timestamp`
150+ *
151+ * Only datasets matching the expected shape for the given granularity are
152+ * accepted. If the dataset does not match, an empty result is returned.
153+ *
154+ * The returned structure includes:
155+ * - a single line-series dataset with a consistent color
156+ * - a list of timestamps used as the x-axis values
157+ *
158+ * @param selectedGranularity - Active chart time granularity
159+ * @param dataset - Raw evolution dataset to format
160+ * @param seriesName - Display name for the resulting series
161+ * @returns An object containing a formatted dataset and its associated dates,
162+ * or `{ dataset: null, dates: [] }` when the input is incompatible
163+ */
143164function formatXyDataset(
144165 selectedGranularity : ChartTimeGranularity ,
145166 dataset : EvolutionData ,
@@ -200,6 +221,30 @@ function formatXyDataset(
200221 return { dataset: null , dates: [] }
201222}
202223
224+ /**
225+ * Extracts normalized time-series points from an evolution dataset based on
226+ * the selected time granularity.
227+ *
228+ * Each returned point contains:
229+ * - `timestamp`: the numeric time value used for x-axis alignment
230+ * - `downloads`: the corresponding value at that time
231+ *
232+ * The timestamp field is selected according to granularity:
233+ * - **daily** → `timestamp`
234+ * - **weekly** → `timestampEnd`
235+ * - **monthly** → `timestamp`
236+ * - **yearly** → `timestamp`
237+ *
238+ * If the dataset does not match the expected shape for the given granularity,
239+ * an empty array is returned.
240+ *
241+ * This helper is primarily used in multi-package mode to align multiple
242+ * datasets on a shared time axis.
243+ *
244+ * @param selectedGranularity - Active chart time granularity
245+ * @param dataset - Raw evolution dataset to extract points from
246+ * @returns An array of normalized `{ timestamp, downloads }` points
247+ */
203248function extractSeriesPoints(
204249 selectedGranularity : ChartTimeGranularity ,
205250 dataset : EvolutionData ,
@@ -263,6 +308,22 @@ const startDate = shallowRef<string>('') // YYYY-MM-DD
263308const endDate = shallowRef <string >(' ' ) // YYYY-MM-DD
264309const hasUserEditedDates = shallowRef (false )
265310
311+ /**
312+ * Initializes the date range from the provided weeklyDownloads dataset.
313+ *
314+ * The range is inferred directly from the dataset boundaries:
315+ * - `startDate` is set from the `weekStart` of the first entry
316+ * - `endDate` is set from the `weekEnd` of the last entry
317+ *
318+ * Dates are normalized to `YYYY-MM-DD` and validated before assignment.
319+ *
320+ * This function is a no-op when:
321+ * - the user has already edited the date range
322+ * - no weekly download data is available
323+ *
324+ * The inferred range takes precedence over client-side fallbacks but does not
325+ * override user-defined dates.
326+ */
266327function initDateRangeFromWeekly() {
267328 if (hasUserEditedDates .value ) return
268329 if (! props .weeklyDownloads ?.length ) return
@@ -275,6 +336,20 @@ function initDateRangeFromWeekly() {
275336 if (isValidIsoDateOnly (end )) endDate .value = end
276337}
277338
339+ /**
340+ * Initializes a default date range on the client when no explicit dates
341+ * have been provided and the user has not manually edited the range, typically
342+ * when weeklyDownloads is not provided.
343+ *
344+ * The range is computed in UTC to avoid timezone-related off-by-one errors:
345+ * - `endDate` is set to yesterday (UTC)
346+ * - `startDate` is set to 29 days before yesterday (UTC), yielding a 30-day range
347+ *
348+ * This function is a no-op when:
349+ * - the user has already edited the date range
350+ * - the code is running on the server
351+ * - both `startDate` and `endDate` are already defined
352+ */
278353function initDateRangeFallbackClient() {
279354 if (hasUserEditedDates .value ) return
280355 if (! import .meta .client ) return
@@ -297,11 +372,31 @@ function initDateRangeFallbackClient() {
297372function toUtcDateOnly(date : Date ): string {
298373 return date .toISOString ().slice (0 , 10 )
299374}
375+
300376function addUtcDays(date : Date , days : number ): Date {
301377 const next = new Date (date )
302378 next .setUTCDate (next .getUTCDate () + days )
303379 return next
304380}
381+
382+ /**
383+ * Initializes a default date range for multi-package mode using a fixed
384+ * 52-week rolling window.
385+ *
386+ * The range is computed in UTC to ensure consistent boundaries across
387+ * timezones:
388+ * - `endDate` is set to yesterday (UTC)
389+ * - `startDate` is set to the first day of the 52-week window ending yesterday
390+ *
391+ * This function is intended for multi-package comparisons where no explicit
392+ * date range or dataset-derived range is available.
393+ *
394+ * This function is a no-op when:
395+ * - the user has already edited the date range
396+ * - the code is running on the server
397+ * - the component is not in multi-package mode
398+ * - both `startDate` and `endDate` are already defined
399+ */
305400function initDateRangeForMultiPackageWeekly52() {
306401 if (hasUserEditedDates .value ) return
307402 if (! import .meta .client ) return
@@ -364,6 +459,24 @@ const options = shallowRef<
364459 | { granularity : ' year' ; startDate ?: string ; endDate ?: string }
365460> ({ granularity: ' week' , weeks: 52 })
366461
462+ /**
463+ * Applies the current date range (`startDate` / `endDate`) to a base options
464+ * object, returning a new object augmented with validated date fields.
465+ *
466+ * Dates are normalized to `YYYY-MM-DD`, validated, and ordered to ensure
467+ * logical consistency:
468+ * - When both dates are valid, the earliest is assigned to `startDate` and
469+ * the latest to `endDate`
470+ * - When only one valid date is present, only that boundary is applied
471+ * - Invalid or empty dates are omitted from the result
472+ *
473+ * The input object is not mutated.
474+ *
475+ * @typeParam T - Base options type to extend with date range fields
476+ * @param base - Base options object to which the date range should be applied
477+ * @returns A new options object including the applicable `startDate` and/or
478+ * `endDate` fields
479+ */
367480function applyDateRange<T extends Record <string , unknown >>(base : T ): T & DateRangeFields {
368481 const next: T & DateRangeFields = { ... base }
369482
@@ -396,6 +509,16 @@ const pending = shallowRef(false)
396509const isMounted = shallowRef (false )
397510let requestToken = 0
398511
512+ // Watches granularity and date inputs to keep request options in sync and
513+ // manage the loading state.
514+ //
515+ // This watcher does NOT perform the fetch itself. Its responsibilities are:
516+ // - derive the correct API options from the selected granularity
517+ // - apply the current validated date range to those options
518+ // - determine whether a loading indicator should be shown
519+ //
520+ // Fetching is debounced separately to avoid excessive
521+ // network requests while the user is interacting with controls.
399522watch (
400523 [selectedGranularity , startDate , endDate ],
401524 ([granularityValue ]) => {
@@ -410,7 +533,7 @@ watch(
410533 if (! isMounted .value ) return
411534
412535 const packageNames = effectivePackageNames .value
413- if (! import .meta .client || ! shouldFetch . value || ! packageNames .length ) {
536+ if (! import .meta .client || ! packageNames .length ) {
414537 pending .value = false
415538 return
416539 }
@@ -434,9 +557,27 @@ watch(
434557 { immediate: true },
435558)
436559
560+ /**
561+ * Fetches download evolution data based on the current granularity,
562+ * date range, and package selection.
563+ *
564+ * This function:
565+ * - runs only on the client
566+ * - supports both single-package and multi-package modes
567+ * - applies request de-duplication via a request token to avoid race conditions
568+ * - updates the appropriate reactive stores with fetched data
569+ * - manages the `pending` loading state
570+ *
571+ * Behavior details:
572+ * - In multi-package mode, all packages are fetched in parallel and partial
573+ * failures are tolerated using `Promise.allSettled`
574+ * - In single-package mode, weekly data is reused from `weeklyDownloads`
575+ * when available and no explicit date range is requested
576+ * - Outdated responses are discarded when a newer request supersedes them
577+ *
578+ */
437579async function loadNow() {
438580 if (! import .meta .client ) return
439- if (! shouldFetch .value ) return
440581
441582 const packageNames = effectivePackageNames .value
442583 if (! packageNames .length ) return
@@ -498,6 +639,13 @@ async function loadNow() {
498639 }
499640}
500641
642+ // Debounced wrapper around `loadNow` to avoid triggering a network request
643+ // on every intermediate state change while the user is interacting with inputs
644+ //
645+ // This 'arbitrary' 1000 ms delay:
646+ // - gives enough time for the user to finish changing granularity or dates
647+ // - prevents unnecessary API load and visual flicker of the loading state
648+ //
501649const debouncedLoadNow = useDebounceFn (() => {
502650 loadNow ()
503651}, 1000 )
@@ -506,7 +654,6 @@ const fetchTriggerKey = computed(() => {
506654 const names = effectivePackageNames .value .join (' ,' )
507655 const o = options .value as any
508656 return [
509- shouldFetch .value ? ' 1' : ' 0' ,
510657 isMultiPackageMode .value ? ' M' : ' S' ,
511658 names ,
512659 String (props .createdIso ?? ' ' ),
@@ -536,6 +683,28 @@ const effectiveDataSingle = computed<EvolutionData>(() => {
536683 return evolution .value
537684})
538685
686+ /**
687+ * Normalized chart data derived from the fetched evolution datasets.
688+ *
689+ * This computed value adapts its behavior based on the current mode:
690+ *
691+ * - **Single-package mode**
692+ * - Delegates formatting to `formatXyDataset`
693+ * - Produces a single series with its corresponding timestamps
694+ *
695+ * - **Multi-package mode**
696+ * - Merges multiple package datasets into a shared time axis
697+ * - Aligns all series on the same sorted list of timestamps
698+ * - Fills missing datapoints with `0` to keep series lengths consistent
699+ * - Assigns framework-specific colors when applicable
700+ *
701+ * The returned structure matches the expectations of `VueUiXy`:
702+ * - `dataset`: array of series definitions, or `null` when no data is available
703+ * - `dates`: sorted list of timestamps used as the x-axis reference
704+ *
705+ * Returning `dataset: null` explicitly signals the absence of data and allows
706+ * the template to handle empty states without ambiguity.
707+ */
539708const chartData = computed <{ dataset: VueUiXyDatasetItem [] | null ; dates: number [] }>(() => {
540709 if (! isMultiPackageMode .value ) {
541710 const pkg = effectivePackageNames .value [0 ] ?? props .packageName ?? ' '
@@ -558,7 +727,7 @@ const chartData = computed<{ dataset: VueUiXyDatasetItem[] | null; dates: number
558727 const dates = Array .from (timestampSet ).sort ((a , b ) => a - b )
559728 if (! dates .length ) return { dataset: null , dates: [] }
560729
561- const dataset: VueUiXyDatasetItem [] = names .map (( pkg , index ) => {
730+ 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 )
@@ -616,7 +785,8 @@ function buildExportFilename(extension: string): string {
616785 return ` ${sanitise (label ?? ' ' )}-${g }_${range }.${extension } `
617786}
618787
619- const config = computed (() => {
788+ // VueUiXy chart component configuration
789+ const chartConfig = computed (() => {
620790 return {
621791 theme: isDarkMode .value ? ' dark' : ' default' ,
622792 chart: {
@@ -860,7 +1030,7 @@ const config = computed(() => {
8601030 <div role =" region" aria-labelledby =" download-analytics-title" >
8611031 <ClientOnly v-if =" chartData.dataset" >
8621032 <div >
863- <VueUiXy :dataset =" chartData.dataset" :config =" config " class =" [direction:ltr]" >
1033+ <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" >
@@ -970,7 +1140,7 @@ const config = computed(() => {
9701140 </div >
9711141
9721142 <div
973- v-if =" shouldFetch && !chartData.dataset && !pending"
1143+ 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') }}
0 commit comments