Arquitectura Composition API • Pinia • Nuxt 3 • TypeScript
label, value, trend y period vía props tipadas. No hace fetch. El container DashboardPage.vue usa el composable useDashboardMetrics() para hidratar los datos.
// stores/useDashboardStore.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { type MetricSnapshot, type DateRange } from '@/types/metrics' import { fetchMetrics } from '@/api/metricsClient' export const useDashboardStore = defineStore('dashboard', () => { // ─── State ─────────────────────────────────────── const metrics = ref<MetricSnapshot[]>([]) const isLoading = ref(false) const error = ref<string | null>(null) const dateRange = ref<DateRange>({ from: '2026-06-11', to: '2026-06-18' }) // ─── Computed ───────────────────────────────────── const conversionRate = computed(() => metrics.value.find(m => m.key === 'conversion_rate') ) const hasData = computed(() => metrics.value.length > 0) // ─── Actions ────────────────────────────────────── async function loadMetrics(range: DateRange) { isLoading.value = true error.value = null try { metrics.value = await fetchMetrics(range) dateRange.value = range } catch (err) { error.value = (err as Error).message } finally { isLoading.value = false } } function reset() { metrics.value = [] error.value = null } return { // Expose ONLY what consumers need metrics, isLoading, error, dateRange, conversionRate, hasData, loadMetrics, reset, } })
script setup —
todo lo que se return queda reactivo y devtools-friendly.
Cada acción async maneja los tres estados: isLoading, éxito y error.
import { ref, watch, readonly, onUnmounted, toValue, type MaybeRef } from 'vue' export function useDebounce<T>( value: MaybeRef<T>, delay: number ): Ref<T> { const debounced = ref(toValue(value)) as Ref<T> let timer: ReturnType<typeof setTimeout> watch( () => toValue(value), (newVal) => { clearTimeout(timer) timer = setTimeout(() => { debounced.value = newVal }, delay) } ) onUnmounted(() => clearTimeout(timer)) return readonly(debounced) }
import { watch, type Ref } from 'vue' import { useDebounce } from './useDebounce' import { useDashboardStore } from '@/stores' import { type DateRange } from '@/types/metrics' export function useDashboardMetrics( dateRange: Ref<DateRange> ) { const store = useDashboardStore() const debouncedRange = useDebounce(dateRange, 400) watch( debouncedRange, (range) => store.loadMetrics(range), { immediate: true } ) return { metrics: store.metrics, isLoading: store.isLoading, error: store.error, } }
<script setup lang="ts"> // 1. Imports: vue → ecosystem → absolutos → relativos import { computed } from 'vue' // 2. Props & Emits interface Props { label: string value: string | number trend?: number // positivo = sube, negativo = baja period?: string variant?: 'default' | 'highlight' } const props = withDefaults(defineProps<Props>(), { variant: 'default', period: 'vs. periodo anterior', }) const emit = defineEmits<{ focus: [label: string] }>() // 3. Computed const trendClass = computed(() => props.trend === undefined ? '' : props.trend >= 0 ? 'trend-up' : 'trend-down' ) const trendSign = computed(() => props.trend !== undefined && props.trend >= 0 ? '↑' : '↓' ) const trendAbs = computed(() => Math.abs(props.trend ?? 0).toFixed(2) ) </script> <template> <div :class=["metric-card", `mc-${variant}`] @click="emit('focus', label)" role="button" :aria-label=`Métrica: ${label}` > <p class="metric-label">{{ label }}</p> <p class="metric-value">{{ value }}</p> <!-- Slot condicional de tendencia --> <slot name="trend"> <div v-if="trend !== undefined" :class=['metric-trend', trendClass]"> <span class="trend-arrow">{{ trendSign }}</span> {{ trendAbs }}% </div> </slot> <p class="metric-period">{{ period }}</p> </div> </template> <style scoped> /* Scoped: estilos solo para este componente */ .metric-card { cursor: pointer; transition: transform .15s; } .metric-card:hover { transform: translateY(-2px); } </style>
// Protege rutas con meta.requiresAuth export function setupAuthGuard(router) { router.beforeEach((to) => { const auth = useAuthStore() if (to.meta.requiresAuth && !auth.isLoggedIn) { return { name: 'login', query: { redirect: to.fullPath }, } } }) } // pages/dashboard/index.vue route meta definePageMeta({ middleware: ['auth'], })
<script setup lang="ts"> // Nuxt auto-importa ref, computed, useFetch… const dateRange = ref({ from: '2026-06-11', to: '2026-06-18', }) // Composable encapsula fetch + debounce const { metrics, isLoading, error } = useDashboardMetrics(dateRange) // Parámetro reactivo de ruta const route = useRoute() const shopId = computed( () => route.params.shopId as string ) </script> <template> <main> <div v-if="isLoading">Cargando…</div> <div v-else-if="error">{{ error }}</div> <MetricCard v-else v-for="m in metrics" :key="m.id" v-bind="m" /> </main> </template>
import { describe, it, expect, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import MetricCard from './MetricCard.vue' beforeEach(() => { setActivePinia(createPinia()) }) describe('MetricCard', () => { it('renderiza label y value', () => { const w = mount(MetricCard, { props: { label: 'Conversión', value: '4.82%', trend: 0.34 } }) expect(w.text()).toContain('Conversión') expect(w.text()).toContain('4.82%') }) it('emite focus al hacer click', async () => { const w = mount(MetricCard, { props: { label: 'MRR', value: '€23K' } }) await w.trigger('click') expect(w.emitted('focus')![0]).toEqual(['MRR']) }) it('trend negativo aplica clase trend-down', () => { const w = mount(MetricCard, { props: { label: 'Abandono', value: '68%', trend: -4.2 } }) expect(w.find('.trend-down').exists()).toBe(true) }) })
// Antes (frágil, nombre debe coincidir) const inputRef = ref<HTMLInputElement>() // Ahora (Vue 3.5) — explícito y seguro import { useTemplateRef } from 'vue' const searchEl = useTemplateRef<HTMLInputElement>('search') // En template: // <input ref="search" />
import { watch, onWatcherCleanup } from 'vue' watch(shopId, async (id) => { const ctrl = new AbortController() // Cancela el fetch anterior automáticamente onWatcherCleanup( () => ctrl.abort() ) const data = await fetchShop(id, { signal: ctrl.signal }) })
// Vue < 3.5: ROMPE reactividad const { label, value } = defineProps<...>() // Vue 3.5+: destructure ES seguro const { label = '–', value = 0 } = defineProps<{ label?: string value?: number }>() // Limitación: watch siempre con getter watch(() => label, (v) => { /* ok */ })
import { defineAsyncComponent, hydrateOnVisible, } from 'vue' // FunnelChart solo hidrata al ser visible // Mejora LCP en dashboards pesados const FunnelChart = defineAsyncComponent({ loader: () => import('./FunnelChart.vue'), hydrate: hydrateOnVisible(), })