Skip to content

Latest commit

 

History

History
771 lines (648 loc) · 25.5 KB

File metadata and controls

771 lines (648 loc) · 25.5 KB

Evolith Tracker — Diseño Frontend React

Navegación Bilingüe: English · Español (este documento)

Estado del Documento: Borrador
Tipo: Diseño de Arquitectura Frontend
Satélite: Evolith Tracker
Upstream: Evolith Core
Fecha: 2026-06-07
Autor: Architect Agent (BMAD)


1. Visión General de Arquitectura

Topología: El frontend es una arquitectura de Microfrontend (Module Federation) desde la Fase 1, según T-002 y T-002. Un Shell Host orquesta remotes React desplegables de forma independiente, uno por Bounded Context. Los patrones de §2 en adelante (gestión de estado, componentes, UI basada en permisos) aplican dentro de cada remote; el código transversal (UI kit, auth, contracts) reside en packages/ federados compartidos.

1.1 Stack Tecnológico

Componente Tecnología Justificación
Runtime Node.js 20 LTS Requisito estándar de herramientas React
Framework React 18+ con TypeScript 5.x (strict) UI basada en componentes, tipado fuerte
Bundler Vite 5+ HMR rápido, nativo ESM
Microfrontends Module Federation (@module-federation/vite) Build y deploy independiente por remote (T-002)
Routing TanStack Router (v3) Routing type-safe, layouts anidados; routing global en Shell Host
Estado Servidor TanStack Query v5 Estado asíncrono, caché, refetch en segundo plano (singleton compartido)
Estado Cliente Zustand v4 Estado UI global mínimo (tema, sidebar, notificaciones)
Estado Cross-MFE Event bus del lado cliente + URL/storage Acoplamiento mínimo entre remotes (T-002 §4)
Formularios React Hook Form + Zod Rendimiento, validación por schema
Librería UI Design System compartido ui-kit (base shadcn/ui) Accesible, personalizable; previene divergencia visual entre remotes
Gráficos Recharts Visualización de métricas DORA/SPACE
Testing Vitest + React Testing Library + Playwright Unitarias + E2E
i18n react-i18next Soporte bilingüe inglés / español

1.2 Estructura Monorepo (Microfrontends)

apps/
├── shell-host/                       # Host: global routing, layout, session, MF orchestration
│   ├── src/
│   │   ├── app/                      # Providers (QueryClient, PermissionProvider, ThemeProvider)
│   │   ├── bootstrap/                # Remote registry, dynamic remote loading, error boundaries
│   │   └── layout/                   # Shell chrome (nav, header, global gate status bar)
│   └── vite.config.ts                # host federation config (remotes registry)
├── mfe-discovery/                    # Remote: Discovery module UI
│   ├── src/
│   │   ├── App.tsx                   # Remote entry exposed via Module Federation
│   │   ├── features/                # InitiativeCard, CanvasForm, ApprovalPanel
│   │   ├── hooks/                    # useInitiatives, useInitiativeDetail
│   │   ├── routes.tsx                # Remote-local routes
│   │   └── bootstrap.tsx             # Standalone bootstrap (remote can run isolated in dev)
│   └── vite.config.ts                # remote federation config (exposes ./App)
├── mfe-design/                       # Remote: Design module UI
├── mfe-construction/                 # Remote: Construction module UI
├── mfe-qa/                           # Remote: QA module UI
├── mfe-release/                      # Remote: Release module UI
├── mfe-governance/                   # Remote: Governance + agent orchestration UI
└── mfe-metrics/                      # Remote: Scorecards & analytics UI

packages/                             # Shared federated libraries (Module Federation `shared`)
├── ui-kit/                           # Shared Design System (Button, Modal, Badge, ...) — single source of visual truth
├── contracts/                        # Generated OpenAPI client types
├── auth/                             # usePermission, PermissionProvider, route guards
├── event-bus/                        # Client-side event bus for cross-MFE communication
├── shared-stores/                    # Zustand stores for cross-cutting UI state (theme, notifications)
└── utils/                            # Shared utilities (formatDate, cn(), ...)

1.3 Configuración Vite — Shell Host & Remote

// apps/shell-host/vite.config.ts (HOST)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@module-federation/vite';

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'shell_host',
      remotes: {
        // Loaded dynamically from a versioned registry in production
        mfe_discovery: 'mfeDiscovery@/remotes/discovery/remoteEntry.js',
        mfe_design: 'mfeDesign@/remotes/design/remoteEntry.js',
        mfe_construction: 'mfeConstruction@/remotes/construction/remoteEntry.js',
        mfe_qa: 'mfeQa@/remotes/qa/remoteEntry.js',
        mfe_release: 'mfeRelease@/remotes/release/remoteEntry.js',
        mfe_governance: 'mfeGovernance@/remotes/governance/remoteEntry.js',
        mfe_metrics: 'mfeMetrics@/remotes/metrics/remoteEntry.js',
      },
      shared: ['react', 'react-dom', '@tanstack/react-query'], // singletons — no duplicate instances
    }),
  ],
  server: { port: 5173, proxy: { '/api': { target: process.env.VITE_API_URL ?? 'http://localhost:3000', changeOrigin: true } } },
});
// apps/mfe-discovery/vite.config.ts (REMOTE)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@module-federation/vite';

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'mfeDiscovery',
      filename: 'remoteEntry.js',
      exposes: { './App': './src/App.tsx' },
      shared: ['react', 'react-dom', '@tanstack/react-query'],
    }),
  ],
  server: { port: 5174 }, // each remote runs on its own port in dev
});

1.4 Reglas de Gobernanza de Microfrontends (T-002)

Aspecto Regla
Consistencia visual Todos los remotes consumen el ui-kit compartido — ningún remote envía sus propios primitivos
Dependencias compartidas react, react-dom, @tanstack/react-query son singletons shared de Module Federation
Estado Cross-MFE Mínimo; vía URL params, storage, o event-bus compartido. Sin importaciones directas remote-a-remote
Aislamiento de fallos Cada remote envuelto en un error boundary en el Shell Host — un remote que falle no debe tumbar la suite
Despliegue independiente Cada remote se compila y despliega independientemente; el Shell Host carga desde un registro versionado
Auth Contexto de permisos provisto por el Shell Host vía paquete auth compartido; los remotes nunca reimplementan autorización

2. Gestión de Estado — Estrategia Dual

2.1 Estado del Servidor (TanStack Query)

Todos los datos del backend se gestionan vía TanStack Query. Las queries y mutations se colocan junto a sus módulos de funcionalidad.

// features/discovery/api/initiative-api.ts
import { trackerApiClient } from '@/shared/contracts/api-client';

export const initiativeKeys = {
  all: ['initiatives'] as const,
  detail: (id: string) => ['initiatives', id] as const,
  backlog: (id: string) => ['initiatives', id, 'backlog'] as const,
};

export const useInitiatives = (tenantId: string) =>
  useQuery({
    queryKey: initiativeKeys.all,
    queryFn: () => trackerApiClient.initiative.list({ tenantId }),
    staleTime: 30_000,
  });

export const useInitiativeDetail = (id: string) =>
  useQuery({
    queryKey: initiativeKeys.detail(id),
    queryFn: () => trackerApiClient.initiative.get(id),
  });

export const useSubmitInitiative = () =>
  useMutation({
    mutationFn: (data: SubmitInitiativeInput) =>
      trackerApiClient.initiative.submit(data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: initiativeKeys.all });
    },
  });

2.2 Estado del Cliente (Zustand)

Solo el estado UI que es efímero, local a la sesión, y no respaldado por el servidor usa Zustand. Según ADR-0045, el estado del servidor nunca debe fluir a través de Zustand.

// shared/stores/ui.store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface UiState {
  sidebarCollapsed: boolean;
  activeInitiativeId: string | null;
  notificationQueue: Notification[];
  theme: 'light' | 'dark' | 'system';

  toggleSidebar: () => void;
  setActiveInitiative: (id: string | null) => void;
  addNotification: (n: Notification) => void;
  dismissNotification: (id: string) => void;
}

export const useUiStore = create<UiState>()(
  persist(
    (set) => ({
      sidebarCollapsed: false,
      activeInitiativeId: null,
      notificationQueue: [],
      theme: 'system',

      toggleSidebar: () =>
        set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),

      setActiveInitiative: (id) =>
        set({ activeInitiativeId: id }),

      addNotification: (n) =>
        set((s) => ({ notificationQueue: [...s.notificationQueue, n] })),

      dismissNotification: (id) =>
        set((s) => ({
          notificationQueue: s.notificationQueue.filter((n) => n.id !== id),
        })),
    }),
    { name: 'evolith-ui-store', partialize: (s) => ({ theme: s.theme, sidebarCollapsed: s.sidebarCollapsed }) }
  )
);

3. UI Basada en Permisos

3.1 Contexto de Permisos

El backend resuelve permisos canónicos del grafo de autorización UMS. El frontend recibe estos como un array plano de strings TrackerPermission en el contexto de auth.

// shared/hooks/usePermission.ts
import { createContext } from 'react';
import { useAuthStore } from '@/shared/stores/auth.store';

export type TrackerPermission =
  | 'tracker:initiative:read'
  | 'tracker:initiative:create'
  | 'tracker:initiative:approve'
  | 'tracker:initiative:reject'
  | 'tracker:design:read'
  | 'tracker:design:contract:submit'
  | 'tracker:design:approve'
  | 'tracker:construction:read'
  | 'tracker:construction:task:complete'
  | 'tracker:qa:gate:evaluate'
  | 'tracker:release:authorize'
  | 'tracker:agent:assign'
  | 'tracker:scorecard:read'
  | 'tracker:settings:manage';

interface PermissionContextValue {
  permissions: TrackerPermission[];
  can: (permission: TrackerPermission) => boolean;
  canAny: (permissions: TrackerPermission[]) => boolean;
  canAll: (permissions: TrackerPermission[]) => boolean;
}

export const PermissionContext = createContext<PermissionContextValue>({
  permissions: [],
  can: () => false,
  canAny: () => false,
  canAll: () => false,
});

export const usePermission = () => {
  const permissions = useAuthStore((s) => s.permissions);
  return {
    permissions,
    can: (p: TrackerPermission) => permissions.includes(p),
    canAny: (ps: TrackerPermission[]) => ps.some((p) => permissions.includes(p)),
    canAll: (ps: TrackerPermission[]) => ps.every((p) => permissions.includes(p)),
  };
};

3.2 Componentes Protegidos

// shared/components/permission-guard.tsx
interface PermissionGuardProps {
  permission: TrackerPermission;
  fallback?: React.ReactNode;
  children: React.ReactNode;
}

export const PermissionGuard: FC<PermissionGuardProps> = ({
  permission,
  fallback = null,
  children,
}) => {
  const { can } = usePermission();
  return can(permission) ? <>{children}</> : <>{fallback}</>;
};

// Usage
<PermissionGuard permission="tracker:initiative:approve">
  <ApproveButton onClick={handleApprove} />
</PermissionGuard>

// Route-level protection
const initiativeRoute = {
  path: '/initiatives/:id',
  loader: async () => {
    const { can } = usePermission();
    if (!can('tracker:initiative:read')) throw redirect('/403');
    return {};
  },
  children: [...],
};

4. Arquitectura de Routing

4.1 Arbol de Rutas

// app/router.tsx
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router';
import { RootLayout } from './root';
import { PipelinePage } from '@/features/governance/routes/pipeline';
import { DiscoveryHubPage } from '@/features/discovery/routes/hub';
import { DesignReviewPage } from '@/features/design/routes/review';
import { ConstructionBoardPage } from '@/features/construction/routes/board';
import { QaCommandPage } from '@/features/qa/routes/command';
import { ReleasePlannerPage } from '@/features/release/routes/planner';
import { AgentConsolePage } from '@/features/governance/routes/agent-console';
import { ScorecardsPage } from '@/features/metrics/routes/scorecards';
import { InitiativeDetailPage } from '@/features/discovery/routes/initiative-detail';

const rootRoute = createRootRoute({ component: RootLayout });

const pipelineRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: PipelinePage,
});

const initiativesRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/initiatives',
  children: {
    detail: createRoute({
      getParentRoute: () => initiativesRoute,
      path: '$initiativeId',
      component: InitiativeDetailPage,
    }),
  },
});

const discoveryHubRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/discovery',
  component: DiscoveryHubPage,
});

const designReviewRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/design',
  component: DesignReviewPage,
});

// ... construction, qa, release, agent-console, scorecards routes

export const router = createRouter({ routeTree: rootRoute.addChildren([
  pipelineRoute,
  initiativesRoute,
  discoveryHubRoute,
  designReviewRoute,
  constructionRoute,
  qaRoute,
  releaseRoute,
  agentConsoleRoute,
  scorecardsRoute,
  settingsRoute,
])});

4.2 Layout Raíz con Permission Provider

// app/root.tsx
export const RootLayout: FC = () => {
  return (
    <AuthProvider>
      <PermissionProvider>
        <QueryClientProvider>
          <ThemeProvider>
            <div className="flex h-screen">
              <Sidebar />
              <main className="flex-1 overflow-auto">
                <Outlet />
              </main>
            </div>
          </ThemeProvider>
        </QueryClientProvider>
      </PermissionProvider>
    </AuthProvider>
  );
};

5. Componentes de Funcionalidad (Pantallas Clave)

5.1 Vista Pipeline (Pantalla Principal)

// features/governance/routes/pipeline.tsx
export const PipelinePage: FC = () => {
  const { data: initiatives, isLoading } = useInitiatives();
  const { can } = usePermission();

  if (isLoading) return <PipelineSkeleton />;

  return (
    <div className="p-6 space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-semibold">Initiative Pipeline</h1>
        {can('tracker:initiative:create') && (
          <Button as={Link} to="/discovery/new">New Initiative</Button>
        )}
      </div>

      <div className="grid grid-cols-5 gap-4">
        {INITIATIVE_GATES.map((gate) => (
          <PipelineColumn key={gate} gate={gate}>
            {initiatives
              .filter((i) => i.currentGate === gate)
              .map((initiative) => (
                <InitiativeCard
                  key={initiative.id}
                  initiative={initiative}
                  onSelect={() => navigate({
                    to: '/initiatives/$initiativeId',
                    params: { initiativeId: initiative.id },
                  })}
                />
              ))}
          </PipelineColumn>
        ))}
      </div>
    </div>
  );
};

5.2 Tarjeta de Iniciativa

// features/governance/components/initiative-card.tsx
interface InitiativeCardProps {
  initiative: InitiativeSummary;
  onSelect: () => void;
}

export const InitiativeCard: FC<InitiativeCardProps> = ({ initiative, onSelect }) => {
  const { blockers, timeInGate, assignedAgent } = initiative;

  return (
    <Card
      className={cn(
        'cursor-pointer transition-shadow hover:shadow-md',
        initiative.status === 'blocked' && 'border-amber-300',
        initiative.status === 'rejected' && 'border-red-300',
      )}
      onClick={onSelect}
    >
      <CardHeader>
        <CardTitle className="text-sm">{initiative.name}</CardTitle>
        <GateProgressIndicator gates={5} current={initiative.currentGateIndex} />
      </CardHeader>
      <CardContent>
        {blockers.length > 0 ? (
          <p className="text-xs text-amber-600 truncate">
            Blocked: {blockers[0].description}
          </p>
        ) : (
          <p className="text-xs text-green-600">Flowing</p>
        )}
        <div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
          <span>{timeInGate}</span>
          {assignedAgent && <AgentBadge agent={assignedAgent} />}
        </div>
      </CardContent>
    </Card>
  );
};

5.3 Hub de Discovery (Conversación Primero)

// features/discovery/routes/hub.tsx
export const DiscoveryHubPage: FC = () => {
  const [conversation, setConversation] = useState<ChatMessage[]>([]);
  const submitMutation = useSubmitInitiative();

  const handleCanvasSubmit = async (canvas: DiscoveryCanvasData) => {
    const result = await submitMutation.mutateAsync(canvas);
    if (result.isSuccess) {
      setConversation((prev) => [
        ...prev,
        { role: 'assistant', content: 'Canvas submitted successfully. Awaiting approval.' },
      ]);
    }
  };

  return (
    <div className="flex h-full">
      <div className="w-2/3 p-6">
        <h1 className="text-2xl font-semibold mb-4">Discovery Hub</h1>
        <ConversationFlow messages={conversation} onComplete={handleCanvasSubmit} />
      </div>
      <aside className="w-1/3 border-l p-6">
        <InitiativeList />
      </aside>
    </div>
  );
};

5.4 Panel de Quality Gate

// features/qa/components/quality-gate-panel.tsx
interface QualityGatePanelProps {
  initiativeId: string;
}

export const QualityGatePanel: FC<QualityGatePanelProps> = ({ initiativeId }) => {
  const { data: gateStatus } = useQualityGate(initiativeId);

  return (
    <div className="space-y-4">
      <h2 className="text-lg font-semibold">QA Gate Status</h2>
      <GateChecklist checks={gateStatus.checks} />
      <div className="flex items-center gap-4">
        <GateVerdictBadge verdict={gateStatus.verdict} />
        {gateStatus.verdict === 'PASS' && (
          <Button onClick={() => navigate({ to: '/release', params: { initiativeId } })}>
            Proceed to Release
          </Button>
        )}
        {gateStatus.verdict === 'FAIL' && (
          <Button variant="destructive" onClick={() => navigate({ to: '/qa/defects' })}>
            Log Defects
          </Button>
        )}
      </div>
    </div>
  );
};

6. Formularios y Validación

6.1 Formulario de Envío de Iniciativa

// features/discovery/components/submit-initiative-form.tsx
const initiativeSchema = z.object({
  name: z.string().min(3).max(200),
  description: z.string().max(5000),
  canvasData: z.object({
    problem: z.string().min(10),
    valueProposition: z.string().min(10),
    roiEstimate: z.enum(['LOW', 'MEDIUM', 'HIGH']),
    kpis: z.array(z.string()).min(1),
    risks: z.array(z.object({
      description: z.string(),
      likelihood: z.enum(['LOW', 'MEDIUM', 'HIGH']),
      impact: z.enum(['LOW', 'MEDIUM', 'HIGH']),
    })),
  }),
});

type InitiativeFormData = z.infer<typeof initiativeSchema>;

export const SubmitInitiativeForm: FC = () => {
  const form = useForm<InitiativeFormData>({
    resolver: zodResolver(initiativeSchema),
    defaultValues: { name: '', description: '', canvasData: { problem: '', valueProposition: '', roiEstimate: 'MEDIUM', kpis: [], risks: [] } },
  });

  const submitMutation = useSubmitInitiative();

  return (
    <FormProvider {...form}>
      <form onSubmit={form.handleSubmit((data) => submitMutation.mutate(data))}>
        <FormField control={form.control} name="name" render={({ field }) => (
          <FormItem>
            <FormLabel>Initiative Name</FormLabel>
            <FormControl><Input {...field} /></FormControl>
            <FormMessage />
          </FormItem>
        )} />
        {/* ... remaining fields */}
        <Button type="submit" loading={submitMutation.isPending}>Submit Initiative</Button>
      </form>
    </FormProvider>
  );
};

7. Cliente API (Generado desde OpenAPI)

7.1 Configuración del Cliente API

// shared/contracts/api-client.ts
import { Client } from '@hey-api/client';

export const trackerApiClient = new Client({
  baseUrl: import.meta.env.VITE_API_URL ?? '/api/v1',
  headers: {
    'Content-Type': 'application/json',
  },
}).withAuth(({ token }) => ({
  headers: { Authorization: `Bearer ${token}` },
}));

// Generated from OpenAPI spec at build time via @hey-api/openapi-ts
import * as InitiativeEndpoints from './generated/initiative';
import * as DesignEndpoints from './generated/design';
import * as ConstructionEndpoints from './generated/construction';
import * as QaEndpoints from './generated/qa';
import * as ReleaseEndpoints from './generated/release';

trackerApiClient.addEndpoints(InitiativeEndpoints);
trackerApiClient.addEndpoints(DesignEndpoints);
trackerApiClient.addEndpoints(ConstructionEndpoints);
trackerApiClient.addEndpoints(QaEndpoints);
trackerApiClient.addEndpoints(ReleaseEndpoints);

8. Observabilidad (Frontend)

8.1 Integración de Tracing

// app/providers.tsx
import { TraceProvider } from '@tanstack/react-tracing';

export const providers = [
  QueryClientProvider,
  PermissionProvider,
  TraceProvider.configure({
    endpoint: import.meta.env.VITE_OTEL_EXPORTER_OTLP_ENDPOINT,
    serviceName: 'evolith-tracker-web',
  }),
];

8.2 Error Boundary

// shared/components/error-boundary.tsx
export class ErrorBoundary extends React.Component<Props, State> {
  componentDidCatch(error: Error, info: ErrorInfo) {
    logger.error({ error: error.message, componentStack: info.componentStack });
    Sentry.captureException(error);
  }

  render() {
    return this.state.hasError ? (
      <ErrorFallback onReset={() => this.setState({ hasError: false })} />
    ) : (
      this.props.children
    );
  }
}

9. Estrategia de Librería de Componentes

Los componentes UI se construyen sobre una librería de componentes headless (primitivas Radix UI) con una capa de Design System personalizado aplicada. Esto asegura:

  • Cumplimiento completo de accesibilidad (WCAG 2.1 AA)
  • Personalización total de estilos vía variables CSS
  • Sin vendor lock-in en una librería de componentes específica
// shared/components/ui/button.tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'destructive' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = 'primary', size = 'md', ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={cn(
          'inline-flex items-center justify-center rounded-md font-medium transition-colors',
          'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
          'disabled:pointer-events-none disabled:opacity-50',
          buttonVariants[variant],
          buttonSizes[size],
          className,
        )}
        {...props}
      />
    );
  },
);

10. Diseño Responsivo

Breakpoint Comportamiento
sm (640px) Pipeline colapsa a kanban de 2 columnas
md (768px) Pipeline completo de 5 columnas, sidebar visible
lg (1024px) Sidebar fijo, paneles de detalle completos
xl (1280px) Ancho máximo de contenido 1400px, centrado

11. Estrategia de Testing

// tests/unit/features/discovery/components/initiative-card.test.tsx
import { render, screen } from '@testing-library/react';
import { InitiativeCard } from '@/features/discovery/components/initiative-card';

describe('InitiativeCard', () => {
  it('shows blocked status correctly', () => {
    render(<InitiativeCard initiative={mockInitiative('blocked')} onSelect={fn} />);
    expect(screen.getByText(/Blocked:/)).toBeInTheDocument();
  });

  it('hides action button when user lacks permission', () => {
    const { container } = render(
      <PermissionProvider permissions={[]}>
        <InitiativeCard initiative={mockInitiative()} onSelect={fn} />
      </PermissionProvider>,
    );
    expect(container.querySelector('button')).toBeNull();
  });
});

// tests/e2e/pipeline.spec.ts
import { test, expect } from '@playwright/test';

test('pipeline shows all active initiatives', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByText('Initiative Pipeline')).toBeVisible();
  await expect(page.locator('[data-testid="initiative-card"]')).toHaveCount(5);
});

Referencias