[READ-ONLY] a fast, modern browser for the npm registry

feat: add permalink to downloads modal (#1299)

Co-authored-by: Willow (GHOST) <ghostdevbusiness@gmail.com>

authored by

Matteo Gabriele
Willow (GHOST)
and committed by
GitHub
54e08d0a 3ff5d584

+170 -72
+48 -29
app/components/Package/TrendsChart.vue
··· 6 6 import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors' 7 7 import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' 8 8 import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' 9 + import type { 10 + ChartTimeGranularity, 11 + DailyDataPoint, 12 + DateRangeFields, 13 + EvolutionData, 14 + EvolutionOptions, 15 + MonthlyDataPoint, 16 + WeeklyDataPoint, 17 + YearlyDataPoint, 18 + } from '~/types/chart' 9 19 10 - const props = defineProps<{ 11 - // For single package downloads history 12 - weeklyDownloads?: WeeklyDataPoint[] 13 - inModal?: boolean 20 + const props = withDefaults( 21 + defineProps<{ 22 + // For single package downloads history 23 + weeklyDownloads?: WeeklyDataPoint[] 24 + inModal?: boolean 14 25 15 - /** 16 - * Backward compatible single package mode. 17 - * Used when `weeklyDownloads` is provided. 18 - */ 19 - packageName?: string 26 + /** 27 + * Backward compatible single package mode. 28 + * Used when `weeklyDownloads` is provided. 29 + */ 30 + packageName?: string 20 31 21 - /** 22 - * Multi-package mode. 23 - * Used when `weeklyDownloads` is not provided. 24 - */ 25 - packageNames?: string[] 26 - createdIso?: string | null 32 + /** 33 + * Multi-package mode. 34 + * Used when `weeklyDownloads` is not provided. 35 + */ 36 + packageNames?: string[] 37 + createdIso?: string | null 27 38 28 - /** When true, shows facet selector (e.g. Downloads / Likes). */ 29 - showFacetSelector?: boolean 30 - }>() 39 + /** When true, shows facet selector (e.g. Downloads / Likes). */ 40 + showFacetSelector?: boolean 41 + permalink?: boolean 42 + }>(), 43 + { 44 + permalink: false, 45 + }, 46 + ) 31 47 32 48 const { locale } = useI18n() 33 49 const { accentColors, selectedAccentColor } = useAccentColor() ··· 110 126 const mobileBreakpointWidth = 640 111 127 const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth) 112 128 113 - type ChartTimeGranularity = 'daily' | 'weekly' | 'monthly' | 'yearly' 114 129 const DEFAULT_GRANULARITY: ChartTimeGranularity = 'weekly' 115 - type EvolutionData = DailyDataPoint[] | WeeklyDataPoint[] | MonthlyDataPoint[] | YearlyDataPoint[] 116 - 117 - type DateRangeFields = { 118 - startDate?: string 119 - endDate?: string 120 - } 121 130 122 131 function isRecord(value: unknown): value is Record<string, unknown> { 123 132 return typeof value === 'object' && value !== null ··· 322 331 return single ? [single] : [] 323 332 }) 324 333 325 - const selectedGranularity = shallowRef<ChartTimeGranularity>(DEFAULT_GRANULARITY) 334 + const selectedGranularity = usePermalink<ChartTimeGranularity>('granularity', DEFAULT_GRANULARITY, { 335 + permanent: props.permalink, 336 + }) 337 + 326 338 const displayedGranularity = shallowRef<ChartTimeGranularity>(DEFAULT_GRANULARITY) 327 339 328 340 const isEndDateOnPeriodEnd = computed(() => { ··· 352 364 () => !pending.value && isEstimationGranularity.value, 353 365 ) 354 366 355 - const startDate = shallowRef<string>('') // YYYY-MM-DD 356 - const endDate = shallowRef<string>('') // YYYY-MM-DD 367 + const startDate = usePermalink<string>('start', '', { 368 + permanent: props.permalink, 369 + }) 370 + const endDate = usePermalink<string>('end', '', { 371 + permanent: props.permalink, 372 + }) 373 + 357 374 const hasUserEditedDates = shallowRef(false) 358 375 359 376 /** ··· 578 595 }, 579 596 ]) 580 597 581 - const selectedMetric = shallowRef<MetricId>(DEFAULT_METRIC_ID) 598 + const selectedMetric = usePermalink<MetricId>('facet', DEFAULT_METRIC_ID, { 599 + permanent: props.permalink, 600 + }) 582 601 583 602 // Per-metric state keyed by metric id 584 603 const metricStates = reactive<
+36 -3
app/components/Package/WeeklyDownloadStats.vue
··· 1 1 <script setup lang="ts"> 2 2 import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline' 3 3 import { useCssVariables } from '~/composables/useColors' 4 + import type { WeeklyDataPoint } from '~/types/chart' 4 5 import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors' 5 6 6 7 const props = defineProps<{ ··· 8 9 createdIso: string | null 9 10 }>() 10 11 12 + const router = useRouter() 13 + const route = useRoute() 14 + 11 15 const chartModal = useModal('chart-modal') 12 16 const hasChartModalTransitioned = shallowRef(false) 13 - const isChartModalOpen = shallowRef(false) 17 + 18 + const isChartModalOpen = shallowRef<boolean>(false) 14 19 15 20 function handleModalClose() { 16 21 isChartModalOpen.value = false 17 22 hasChartModalTransitioned.value = false 23 + 24 + router.replace({ 25 + query: { 26 + ...route.query, 27 + modal: undefined, 28 + granularity: undefined, 29 + end: undefined, 30 + start: undefined, 31 + facet: undefined, 32 + }, 33 + }) 18 34 } 19 35 20 36 function handleModalTransitioned() { ··· 95 111 96 112 isChartModalOpen.value = true 97 113 hasChartModalTransitioned.value = false 114 + 115 + await router.replace({ 116 + query: { 117 + ...route.query, 118 + modal: 'chart', 119 + }, 120 + }) 121 + 98 122 // ensure the component renders before opening the dialog 99 123 await nextTick() 100 124 await nextTick() ··· 119 143 } 120 144 } 121 145 122 - onMounted(() => { 123 - loadWeeklyDownloads() 146 + onMounted(async () => { 147 + await loadWeeklyDownloads() 148 + 149 + if (route.query.modal === 'chart') { 150 + isChartModalOpen.value = true 151 + } 152 + 153 + if (isChartModalOpen.value && hasWeeklyDownloads.value) { 154 + openChartModal() 155 + } 124 156 }) 125 157 126 158 watch( ··· 284 316 :inModal="true" 285 317 :packageName="props.packageName" 286 318 :createdIso="createdIso" 319 + permalink 287 320 show-facet-selector 288 321 /> 289 322 </Transition>
+8 -40
app/composables/useCharts.ts
··· 1 1 import type { MaybeRefOrGetter } from 'vue' 2 2 import { toValue } from 'vue' 3 + import type { 4 + DailyDataPoint, 5 + DailyRawPoint, 6 + EvolutionOptions, 7 + MonthlyDataPoint, 8 + WeeklyDataPoint, 9 + YearlyDataPoint, 10 + } from '~/types/chart' 3 11 import { fetchNpmDownloadsRange } from '~/utils/npm/api' 4 12 5 13 export type PackumentLikeForTime = { 6 14 time?: Record<string, string> 7 15 } 8 - 9 - export type DailyDataPoint = { value: number; day: string; timestamp: number } 10 - export type WeeklyDataPoint = { 11 - value: number 12 - weekKey: string 13 - weekStart: string 14 - weekEnd: string 15 - timestampStart: number 16 - timestampEnd: number 17 - } 18 - export type MonthlyDataPoint = { value: number; month: string; timestamp: number } 19 - export type YearlyDataPoint = { value: number; year: string; timestamp: number } 20 - 21 - type EvolutionOptionsBase = { 22 - startDate?: string 23 - endDate?: string 24 - } 25 - 26 - export type EvolutionOptionsDay = EvolutionOptionsBase & { 27 - granularity: 'day' 28 - } 29 - export type EvolutionOptionsWeek = EvolutionOptionsBase & { 30 - granularity: 'week' 31 - weeks?: number 32 - } 33 - export type EvolutionOptionsMonth = EvolutionOptionsBase & { 34 - granularity: 'month' 35 - months?: number 36 - } 37 - export type EvolutionOptionsYear = EvolutionOptionsBase & { 38 - granularity: 'year' 39 - } 40 - 41 - export type EvolutionOptions = 42 - | EvolutionOptionsDay 43 - | EvolutionOptionsWeek 44 - | EvolutionOptionsMonth 45 - | EvolutionOptionsYear 46 - 47 - type DailyRawPoint = { day: string; value: number } 48 16 49 17 function toIsoDateString(date: Date): string { 50 18 return date.toISOString().slice(0, 10)
+26
app/composables/usePermalink.ts
··· 1 + /** 2 + * Creates a computed property that uses route query parameters by default, 3 + * with an option to use local state instead. 4 + */ 5 + export function usePermalink<T extends string = string>( 6 + queryKey: string, 7 + defaultValue: T = '' as T, 8 + options: { permanent?: boolean } = {}, 9 + ): WritableComputedRef<T> { 10 + const { permanent = true } = options 11 + const localValue = shallowRef<T>(defaultValue) 12 + const routeValue = useRouteQuery<T>(queryKey, defaultValue) 13 + 14 + const permalinkValue = computed({ 15 + get: () => (permanent ? routeValue.value : localValue.value), 16 + set: (value: T) => { 17 + if (permanent) { 18 + routeValue.value = value 19 + } else { 20 + localValue.value = value 21 + } 22 + }, 23 + }) 24 + 25 + return permalinkValue 26 + }
+52
app/types/chart.ts
··· 1 + export type ChartTimeGranularity = 'daily' | 'weekly' | 'monthly' | 'yearly' 2 + 3 + export type DateRangeFields = { 4 + startDate?: string 5 + endDate?: string 6 + } 7 + 8 + export type DailyDataPoint = { value: number; day: string; timestamp: number } 9 + export type WeeklyDataPoint = { 10 + value: number 11 + weekKey: string 12 + weekStart: string 13 + weekEnd: string 14 + timestampStart: number 15 + timestampEnd: number 16 + } 17 + export type MonthlyDataPoint = { value: number; month: string; timestamp: number } 18 + export type YearlyDataPoint = { value: number; year: string; timestamp: number } 19 + 20 + export type EvolutionData = 21 + | DailyDataPoint[] 22 + | WeeklyDataPoint[] 23 + | MonthlyDataPoint[] 24 + | YearlyDataPoint[] 25 + 26 + type EvolutionOptionsBase = { 27 + startDate?: string 28 + endDate?: string 29 + } 30 + 31 + export type EvolutionOptionsDay = EvolutionOptionsBase & { 32 + granularity: 'day' 33 + } 34 + export type EvolutionOptionsWeek = EvolutionOptionsBase & { 35 + granularity: 'week' 36 + weeks?: number 37 + } 38 + export type EvolutionOptionsMonth = EvolutionOptionsBase & { 39 + granularity: 'month' 40 + months?: number 41 + } 42 + export type EvolutionOptionsYear = EvolutionOptionsBase & { 43 + granularity: 'year' 44 + } 45 + 46 + export type EvolutionOptions = 47 + | EvolutionOptionsDay 48 + | EvolutionOptionsWeek 49 + | EvolutionOptionsMonth 50 + | EvolutionOptionsYear 51 + 52 + export type DailyRawPoint = { day: string; value: number }