SKILL OUTPUT
Frontend Senior React / Next.js
Proyecto: FlowMetrics Dashboard — SaaS B2B Analytics
CULTIVA IA vite-spa Bundle analizando…
FlowMetrics
Dashboard auth-walled · Equipo 4 devs · Desktop/corporate primary · LCP ≤ 2500ms
React 18 TypeScript 5 Vite 5 Tailwind CSS shadcn/ui TanStack Query
40
/100
F

Bundle Health Score Crítico

17 dependencias de producción analizadas. 5 paquetes pesados detectados.
Potencial de reducción estimado: ~400 KB reemplazando alternativas ligeras.

Paquete Tamaño Problema Alternativa Ahorro
moment 290 KB Locale files bundleados por defecto dayjs (2 KB) −288 KB
@mui/material ~300 KB Runtime pesado, emotion SSR overhead shadcn/ui + Radix −270 KB
chart.js 180 KB Todos los tipos de gráfica incluidos recharts (lazy) ya instalado
lodash 71 KB Full library, sin tree-shaking lodash-es + imports individuales −60 KB
axios 14 KB fetch nativo cubre todos los casos ky (3 KB) −11 KB
vite-spa
Fit 100%
Auth-walled dashboard. Login → app shell una vez → routing client-side. Sin SEO. Bundle más pesado aceptable porque los usuarios vuelven.
next-app-router
Fit 40%
SaaS customer-facing con SEO + dinámico. RSC-first. Overhead de infra sin beneficio para app auth-walled.
remix-or-sveltekit
Fit 25%
Mobile-4G primario, low-JS, progressive enhancement. No aplica aquí.
astro-or-static
Fit 5%
Marketing/blog/docs. Near-zero write. No aplica para dashboard interactivo.
Stack Seleccionado — vite-spa
Build
vite 5
Framework
react 18vuesolid
Lenguaje
typescript strict
Estilos
tailwindcss-modules
Componentes
shadcn/uiradix
Router
react-router v6tanstack-router
Estado
tanstack-queryzustand
Formularios
rhf + zod
Testing
vitestrtlplaywright
Code Split
route-level lazy
Anti-patrones a evitar
KILL
no-code-splitting
Bundle único en SPA multi-ruta = inutilizable en redes lentas
KILL
context-as-global-state
Re-renders en cascada; usar Zustand/Jotai para estado global UI
WARN
redux-without-justification
TanStack Query gestiona server state; Zustand el estado UI
WARN
next-or-remix-for-pure-spa
Overkill; Vite es más ligero para app auth-walled
Criterios de Éxito Verificables (Karpathy #4)
2500ms
LCP p75 desktop
<200ms
INP p75
<0.1
CLS p75
200 KB
Bundle init gzip
80 KB
Por ruta (lazy)
≥80
Lighthouse Perf
≥90
Lighthouse a11y
≥50%
Test coverage
Proyecto scaffoldeado: flowmetrics-dashboard
Template: React + Vite · 29 archivos generados · Features: api, forms, testing
$ cd flowmetrics-dashboard
$ npm install
$ npm run dev
Estructura generada
flowmetrics-dashboard/
src/
components/
ui/ Button, Input, Card
layout/ Header, Sidebar
pages/
Dashboard.tsx
Accounts.tsx
Settings.tsx
hooks/
useDebounce.ts
useLocalStorage.ts
lib/
utils.ts cn() helper
queryClient.ts
api/
client.ts ky wrapper
metrics.ts
types/
index.ts
main.tsx
App.tsx
vite.config.ts
tailwind.config.ts
tsconfig.json
vitest.config.ts
package.json
Features incluidas
🔌
API Client
TanStack Query + ky wrapper con interceptors de auth
INCLUIDO
📝
Forms
React Hook Form + Zod validation schema
INCLUIDO
🧪
Testing
Vitest + React Testing Library + Playwright e2e
INCLUIDO
🎨
Design System
shadcn/ui + Tailwind CSS v3 + CSS variables
INCLUIDO
Code Splitting
Route-level lazy loading (React.lazy + Suspense)
INCLUIDO
MetricCard.tsx TSX
// MetricCard — client component
'use client';

import { useState } from 'react';
import { cn } from '@/lib/utils';

interface MetricCardProps {
  label: string;
  value: string | number;
  change?: string;
  trend?: 'up' | 'down' | 'flat';
  className?: string;
}

export function MetricCard({
  label, value, change, trend, className
}: MetricCardProps) {
  const [hovered, setHovered] =
    useState(false);

  return (
    <div
      className={cn(
        'rounded-lg border p-4',
        hovered && 'shadow-md',
        className
      )}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
    >
      <p className="text-xs text-muted">
        {label}
      </p>
      <p className="text-2xl font-bold">
        {value}
      </p>
      {change && (
        <span className={cn(
          trend === 'up' && 'text-green-500',
          trend === 'down' && 'text-red-500'
        )}>{change}</span>
      )}
    </div>
  );
}
MetricCard.test.tsx VITEST
import { render, screen }
  from '@testing-library/react';
import userEvent
  from '@testing-library/user-event';
import { MetricCard }
  from './MetricCard';

describe('MetricCard', () => {
  it('renders label and value', () => {
    render(
      <MetricCard
        label="MRR"
        value="€42,800"
      />
    );
    expect(
      screen.getByText('MRR')
    ).toBeInTheDocument();
    expect(
      screen.getByText('€42,800')
    ).toBeInTheDocument();
  });

  it('shows change with trend', () => {
    render(
      <MetricCard
        label="MRR" value="€42,800"
        change="+12.4%" trend="up"
      />
    );
    const change =
      screen.getByText('+12.4%');
    expect(change).toHaveClass(
      'text-green-500'
    );
  });

  it('applies custom className', () => {
    render(
      <MetricCard
        label="LTV" value="€1,240"
        className="test-class"
      />
    );
    expect(
      screen.getByText('LTV')
        .closest('div')
    ).toHaveClass('test-class');
  });
});
MetricCard.stories.tsx STORYBOOK
import type { Meta, StoryObj }
  from '@storybook/react';
import { MetricCard }
  from './MetricCard';

const meta: Meta<typeof MetricCard> = {
  title: 'UI/MetricCard',
  component: MetricCard,
  tags: ['autodocs'],
  args: {
    label: 'MRR',
    value: '€42,800',
  },
};
export default meta;

type Story =
  StoryObj<typeof MetricCard>;

export const Default: Story = {};

export const Trending: Story = {
  args: {
    change: '+12.4%',
    trend: 'up',
  },
};

export const Dropping: Story = {
  args: {
    label: 'Churn',
    value: '3.2%',
    change: '+0.8%',
    trend: 'down',
  },
};

export const CAC: Story = {
  args: {
    label: 'CAC',
    value: '€184',
    change: '−€12',
    trend: 'up',
  },
};
Preview — MetricCard stories en FlowMetrics Dashboard
MRR
€42,800
▲ +12.4% vs mes anterior
CAC
€184
▼ −€12 mejorando
LTV
€1,240
▲ +8.1% vs mes anterior
Churn
3.2%
▲ +0.8pp atención
bundlewatch-initial-and-per-route
Bloquea PR si init >200KB-gzip o ruta >80KB-gzip
a11y-axe-checks
axe-core en CI, Lighthouse a11y ≥90, WCAG AA
playwright-smoke-on-key-flows
Login → dashboard → tabla cuentas → logout
typecheck-strict
tsc --strict --noEmit sin warnings