# CONSTRUYE MI HUB DE CONTENIDO

> **Para ti, antes de empezar (lo lee la persona, no la IA):**
> 1. Instala Claude Code y crea una carpeta vacía, por ejemplo `mi-hub`.
> 2. Guarda este archivo dentro con el nombre `hub-de-contenido.md`.
> 3. Abre Claude Code en esa carpeta y escribe: **«Lee hub-de-contenido.md entero y empieza por la Fase 0.»**
> 4. Cuenta con varias sesiones. La primera, un par de horas: Claude te irá pidiendo que crees cuentas y le pases claves, y te dice exactamente dónde está cada botón.
> 5. Para seguir otro día, abre Claude Code en la misma carpeta y escribe: **«Lee hub-de-contenido.md y PLAN.md y sigue por la fase en la que nos quedamos.»**
> 6. Si en algún momento no entiendes algo, pregúntale. Está instruido para explicártelo sin tecnicismos.
>
> **Ten esto preparado antes de empezar** (lo que no tengas, Claude te guía para crearlo):
> - Claude Code con un plan de pago de Claude, y el ordenador donde vas a trabajar siempre.
> - Tu logo en PNG o SVG, mejor con fondo transparente, y el color de tu marca en hex (tipo `#7CBA5F`).
> - Una cuenta de correo para abrir Vercel y Supabase.
> - Instagram en modo **profesional** (Empresa o Creador) y **vinculado a una página de Facebook**, con acceso de administrador a esa página y a tu cartera comercial de Meta (antes Business Manager).
> - La cuenta de Google **dueña de tu canal de YouTube** (no una que solo lo gestione).
> - Acceso al panel de tu dominio si quieres el hub en `contenido.tudominio.com`.
> - La lista de tu equipo: nombre de cada persona, qué hace y qué debería ver.
> - Tus categorías de contenido (educativo, storytelling, caso de éxito, polémico, CTA…) y los canales que planificas cada semana (historias, emails, recursos…).
> - Un link de un reel y otro de un vídeo de YouTube ya publicados, para comprobar que las métricas llegan.

---

## 1. Tu papel

Eres el arquitecto y operador técnico de un **hub de contenido**: la herramienta interna donde un creador y su equipo llevan todo su contenido desde la idea hasta que está publicado, y donde ven con datos reales qué funciona.

Lo construyes de punta a punta: código, base de datos, integraciones, despliegue y verificación. La persona con la que hablas **no es programadora**. Tu trabajo es que acabe con un hub en producción, con su dominio, sincronizando sus cuentas solo cada noche, y que sepa usarlo.

Este documento describe un sistema que ya existe y funciona en producción. No es una idea: cada decisión y cada gotcha de abajo sale de haberlo construido. Respétalos salvo que la persona pida otra cosa.

### Reglas de trabajo

1. **Una fase cada vez.** No empieces una fase sin cerrar la anterior con su criterio de hecho.
2. **Nada se da por hecho sin verificar.** Build limpio, typecheck limpio y comprobación visual en navegador (escritorio y móvil 390 px) antes de decir que algo funciona. Cuando falle una prueba, mira la base de datos o una captura antes de tocar código: a menudo el fallo es de la prueba.
3. **Cada credencial se prueba con una llamada real** en cuanto la recibes. Si falla, se arregla ahí, no tres fases después.
4. **Secretos:** solo en `.env.local` (en `.gitignore`) y en las variables de entorno del hosting. Nunca los imprimas en pantalla, nunca los metas en el código, nunca en un commit.
5. **Las APIs de Meta y Google cambian.** Antes de implementar cada integración, comprueba la documentación oficial actual con búsqueda web. Si algo de este documento ya no es así, gana la documentación y me avisas.
6. **Explica en llano.** Cuando me pidas algo, dime qué es, para qué sirve y dónde está el botón. Una cosa por mensaje cuando sean pasos en una web.
7. **Git desde el principio.** Commit al cerrar cada fase con un mensaje que diga qué se hizo.
8. **Si algo que pido es mala idea, dímelo antes de hacerlo**, con el porqué y la alternativa.

---

## 2. Fase 0 · Entrevista y plan (sin escribir código)

Hazme estas preguntas **en un solo mensaje numerado**. Si alguna respuesta queda ambigua, repregunta solo esa.

1. ¿Cómo se llama tu marca y qué color principal usa? (Un hex si lo tienes; si no, descríbelo.)
2. ¿Me pasas tu logo? (Arrástralo a esta conversación o dime la ruta del archivo.) Se usa en el login, en el menú y como icono de la pestaña. ¿Tema oscuro o claro?
3. ¿En qué plataformas publicas? (Instagram, YouTube, TikTok, LinkedIn, X…) Con datos reales se miden Instagram y YouTube; el resto entra en los tableros y en la galería con su vídeo incrustado, sin métricas.
4. ¿Qué formatos produces? Por defecto hay tres tableros: **Short Form** (reels, shorts), **Long Form** (vídeos largos) y **Ads** (anuncios). ¿Quitas o añades alguno?
5. En YouTube, ¿subes Shorts o solo vídeos largos? (Si no subes Shorts, se excluyen de todas las métricas.)
6. ¿Quién trabaja contigo y qué hace cada uno? (Editor de vídeo, diseñador, setters que solo miran…)
7. ¿Qué tipos o ángulos de contenido usas para clasificar piezas? (Ejemplos: educativo, storytelling, caso de éxito, polémico, CTA…) Si no lo tienes claro, propongo una lista.
8. ¿Qué canales planificas en el calendario además de los vídeos? (Historias, emails, recursos gratuitos, directos…)
9. ¿Tienes dominio propio? ¿Quieres el hub en un subdominio tipo `contenido.tudominio.com`?
10. ¿En qué idioma quieres la interfaz? (Si hay alguien del equipo que no habla inglés, todo en su idioma, sin excepciones.)

Con las respuestas escribe **`PLAN.md`** con: secciones que tendrá el hub, roles y qué ve cada uno, catálogos (estados, tipos, plataformas, canales del calendario), integraciones que se activan y fases. Añade al final una sección **Estado** que actualizas al cerrar cada fase: qué está hecho, qué falta y qué credenciales ya están puestas (solo sus nombres). Enséñamelo resumido y espera mi OK antes de la Fase 1.

**Si te han pegado este documento en el chat en vez de dejarlo en la carpeta**, guárdalo tú como `hub-de-contenido.md` en la raíz del proyecto antes de seguir. Cada sesión nueva empieza releyendo este archivo y `PLAN.md`: el plan solo resume, las reglas y los detalles están aquí.

---

## 3. Qué vas a construir

### 3.1 Secciones (orden del menú)

1. **Short Form** · tablero kanban.
2. **Long Form** · tablero kanban.
3. **Ads** · tablero kanban con pipeline de testing propio.
4. **Diseño** · tablero de proyectos de diseño (miniaturas, carruseles, presentaciones).
5. **Calendario** · vista mensual de todo lo que sale cada día.
6. **Publicados** · siempre la última: galería de lo publicado + **Métricas**.
7. **Equipo** · solo admin: personas, roles y qué secciones ve cada una.

Arriba a la derecha: campana de actividad (quién movió o creó qué) y badges de novedades por sección.

### 3.2 Tableros de contenido (Short, Long, Ads)

**Columnas por defecto (orgánico), en el idioma elegido:** Idea → En guion → Por grabar → Por editar → Por revisar → Revisado → Programado → Publicado.

**Columnas de Ads:** Idea → En guion → Por grabar → Por editar → Por revisar → Por probar → En test → Reserva → Probado → Descartado | Ganador.
- Un anuncio no se "publica": se lanza y se juzga. Por eso Ads no usa Programado ni Publicado, y no aparece en la galería de Publicados.
- **Reserva** no es un descarte: son creatividades que no recibieron alcance y son inventario para refrescar campañas.
- **Ganador** no es terminal: subestado escalando / mantenimiento / fatigado / archivado.
- Métricas de test por anuncio (gasto, impresiones, hook rate, CTR, CPA, frecuencia) y un aviso automático con umbrales configurables. Por defecto: sin juicio hasta 30 € de gasto o 2.000 impresiones; kill si hook rate < 15 % con 3.000+ impresiones, CTR < 0,5 % con 2.000+, o frecuencia > 3; luz verde si hook rate > 30 %. **Sin ROAS por anuncio:** en high ticket la atribución por anuncio es ruido; el ROAS se mira por campaña.

**Tarjeta (vista en tablero):** título, chips de tipo, plataforma, avatar del editor, fecha de publicación, miniatura si ya está publicada. Arrastrar entre columnas y reordenar.

**Drawer de tarjeta (al abrirla):** título, estado, formato, plataforma, tipos (multi), funnel (en Ads), editor asignado, fecha de publicación, guion (texto largo), links de referencia / bruto / editado / publicado, comentarios del equipo con autor y fecha. Todo se guarda solo al editar, sin botón de guardar.

**Al pegar la URL publicada:** la tarjeta muestra el embed (YouTube, Instagram, TikTok), resuelve título y miniatura reales del vídeo y, si es de Instagram o YouTube, captura sus métricas al momento.

**Filtros por tipo** encima del tablero. Con un filtro activo, el arrastre se desactiva (si no, las posiciones se corrompen) y se avisa.

### 3.3 Diseño

Kanban de proyectos: Backlog → En curso → Revisión 1 → Revisión 2 (si hace falta) → Revisión 3 (si hace falta) → Entregado.
- Campos: título, briefing, tipo de pieza, destino (YouTube, reel, post, ads, webinar), responsable, fecha límite, link al guion.
- **Referencias** (inspiración) separadas de **Entrega** (el trabajo terminado). Añadir una entrega sella la fecha y mueve el proyecto a Revisión 1 solo.
- Un link de vídeo de feedback (Loom o similar) **por ronda**, para que el feedback de una ronda no pise el de la siguiente.
- Subida de imágenes a un bucket privado, validando que el archivo es realmente una imagen. La API las sirve con URLs firmadas de caducidad corta.

### 3.4 Calendario

Vista mensual, lunes primero, con el día de hoy marcado. Mezcla **tres fuentes**:
1. Tarjetas de los tableros con fecha de publicación (se pintan como "del pipeline", con miniatura si están publicadas).
2. Proyectos de Diseño por fecha límite.
3. Entradas manuales: historias, emails, recursos gratuitos, notas…

Cada canal manual tiene dos variantes: **nutrición** y **con CTA**. Mismo color por canal; la variante con CTA es el tono intenso. Cada entrada admite título, nota y hasta dos links (por ejemplo la carpeta de archivos y el recurso al que lleva el CTA).

Clic en un día abre un panel con todo lo de ese día para añadir o borrar. Leyenda clicable que filtra por tipo. Navegación por mes con `?m=AAAA-MM`. En móvil, puntos por celda y el panel del día.

### 3.5 Publicados

Dos pestañas: **Galería** y **Métricas**.

**Galería:** lo publicado del orgánico (sin Ads), con embed, cifras del último snapshot, filtros por plataforma y tipo, y fijar arriba. Para roles que no tienen acceso al tablero de origen, la galería es de solo lectura y **no expone guiones ni links internos**.

**Métricas:** la cuenta entera de un vistazo, no solo lo que está en los tableros. Selector de plataforma (una pestaña por plataforma medida) y de período (7 d, 30 d, 90 d, 12 m). Bloques, de arriba abajo:

1. **Canal:** avatar, nombre, seguidores, número de piezas. Cifra grande de views del período con variación frente al período anterior de la misma duración. Curva diaria real de la cuenta con marcas en los días en que se publicó. Cuatro KPIs en una rejilla:
   - YouTube: watch time, suscriptores (total y neto del período), vídeos publicados, retención mediana.
   - Instagram: alcance (cuentas únicas), seguidores, publicaciones, interacciones.
2. **Lo mejor:** las 6 piezas con más views, con miniatura grande. Conmutador "De siempre / Esta semana" (views ganadas en 7 días: lo que sigue vivo sin empujarlo).
3. **Listón:** qué es una pieza normal en la cuenta. Un punto por pieza con la mediana y el rango del 50 % central marcados. Al lado, dispersión views × retención (YouTube) o views × engagement (Instagram), con las medianas como líneas. En YouTube, arriba a la izquierda quedan los vídeos que retienen pero nadie ve (problema de miniatura y título).
4. **Ritmo:** mapa de calor de 52 semanas de publicación con meses, cadencia real ("1 cada N días"), días sin publicar y mediana de views por día de la semana.
5. **Equipo:** mediana de views por pieza de cada editor (solo con piezas enlazadas a una tarjeta con editor). Publicar más no premia por sí solo.
6. **Catálogo:** todas las piezas en una tabla ordenable por cualquier columna, con buscador, barra de views relativa, views de los últimos 7 días, mini curva de 28 días y retención con indicador.

### 3.6 Equipo, roles y permisos

| Rol | Qué puede hacer |
|---|---|
| admin | Todo, incluido Equipo. Ve todas las secciones siempre. |
| editor | Tableros de contenido y calendario en escritura. Diseño en modo consulta. |
| diseñador | Diseño en escritura. |
| lector | Solo lectura de sus secciones. Pensado para setters o asistentes. |

Además del rol, **cada persona tiene asignadas las secciones que ve** (chips en la pantalla de Equipo). Al entrar, cada uno aterriza en su primera sección permitida. Rol y secciones se releen de la base de datos en cada petición: desactivar a alguien o cambiarle el rol surte efecto al instante.

---

## 4. Proveedores y cuentas que necesitas

Guía a la persona por cada uno **cuando llegue su fase**, no todos de golpe. Verifica cada credencial con una llamada real.

| Proveedor | Para qué | Coste orientativo |
|---|---|---|
| **Claude Code** | Quien construye y mantiene el hub | Suscripción de Claude |
| **Node.js 20+** y **git** | Herramientas locales | Gratis |
| **Vercel** | Hosting, dominio, tarea programada nocturna | Hobby gratis pero sus condiciones son para uso personal no comercial; para un negocio, plan Pro |
| **Supabase** | Base de datos Postgres y almacenamiento de imágenes | Plan gratuito suficiente para empezar |
| **Meta** (cartera comercial + app de desarrollador) | Datos de Instagram | Gratis |
| **Google Cloud** | Datos de YouTube | Gratis dentro de cuota |
| **Dominio propio** (opcional) | `contenido.tudominio.com` | Lo que ya pagues |

Revisa los precios y condiciones actuales de cada uno antes de recomendar plan.

### 4.1 Supabase
1. Crear proyecto. **Región: la misma que usará la función de Vercel** (por ejemplo `us-west-2` con Vercel `pdx1`, o `eu-central-1` con `fra1`). Cada consulta que cruza un océano cuesta cientos de milisegundos.
2. Necesitas: **URL del proyecto** y **secret key / service role key** (Settings → API). La clave anon no se usa.
3. El SQL se ejecuta en el SQL Editor o con un token de gestión si la persona prefiere dártelo.
4. El plan gratuito pausa proyectos inactivos; el cron nocturno lo mantiene despierto.

### 4.2 Vercel
1. Cuenta y proyecto desde la carpeta local con la CLI (`npx vercel`). No hace falta GitHub.
2. Variables de entorno de producción desde el panel web. **Gotcha:** `echo "valor" | vercel env add` guarda el valor **vacío**; usa el panel o el modo interactivo.
3. Fija la región en `vercel.json` y declara el cron allí.
4. Dominio: añadirlo en el proyecto y crear el CNAME que indique Vercel en el proveedor del dominio.

### 4.3 Instagram (Meta)
Requisitos que la persona debe tener o crear:
1. Cuenta de Instagram **profesional** (Empresa o Creador), vinculada a una **página de Facebook**.
2. **Cartera comercial** de Meta (business.facebook.com, antes llamada Business Manager) con esa página y esa cuenta de Instagram dentro.
3. Una **app** en developers.facebook.com asociada a la cartera comercial.
4. Vía **"Instagram API con inicio de sesión de Facebook"** y un **usuario del sistema** en la cartera comercial, con token que no caduca. No uses la vía de inicio de sesión de Instagram: su token dura 60 días y las métricas mueren solas si nadie lo renueva. Permisos: `instagram_basic`, `instagram_manage_insights`, `pages_read_engagement` y `pages_show_list`. Confirma los nombres en la documentación actual antes de generar el token.
5. Aviso: Meta no da seguidores por día (`follower_count`) en cuentas de menos de 100 seguidores. El resto funciona igual.
6. Saca el **ID de usuario de Instagram** (el numérico de la cuenta de empresa, no el @) con una llamada a la API y verifícalo leyendo sus últimas publicaciones.

Variables: `META_TOKEN`, `IG_USER_ID`.

### 4.4 YouTube (Google Cloud)
1. Proyecto en console.cloud.google.com **con la cuenta dueña del canal** (o con acceso de propietario al proyecto).
2. Habilitar **YouTube Data API v3** y **YouTube Analytics API**.
3. Pantalla de consentimiento OAuth y un cliente OAuth. Scopes: lectura de YouTube y lectura de YouTube Analytics.
4. **Gotcha crítico:** si la app se queda en modo *Testing*, el refresh token caduca a los 7 días y las métricas mueren solas. Pásala a *Producción* aunque no esté verificada (Google mostrará un aviso al autorizar, se acepta).
5. Escribe un script local que abra el consentimiento, escuche en `localhost` y guarde el **refresh token**. Al autorizar, si la cuenta tiene varios canales, elegir el correcto.
6. Verifica con una llamada a la API de Analytics que devuelva views de los últimos 90 días del canal.

Variables: `YT_CLIENT_ID`, `YT_CLIENT_SECRET`, `YT_REFRESH_TOKEN`.

### 4.5 Resto de variables
`SUPABASE_URL`, `SUPABASE_SECRET_KEY`, `AUTH_SECRET` (aleatoria larga, firma las sesiones), `CRON_SECRET` (aleatoria, protege el endpoint del cron).

Todas las integraciones deben **degradar con elegancia**: si falta una variable, esa parte devuelve vacío y el hub sigue funcionando.

---

## 5. Arquitectura

- **Next.js** (App Router, última versión estable), **React**, **TypeScript**, **Tailwind**. Antes de escribir código, lee la documentación que trae el paquete instalado: las versiones nuevas cambian APIs y convenciones.
- **dnd-kit** para arrastrar. **Phosphor Icons** para iconos.
- **Supabase solo en servidor**, con la secret key. El navegador nunca habla con Supabase: todo pasa por rutas `/api/*` que validan sesión, rol y sección.
- **Sin librerías de gráficos.** Todas las curvas, barras y mapas de calor en SVG y CSS a mano. Pesan nada y se ven exactamente como quieres.
- **Autenticación propia**, sin proveedor externo:
  - Contraseñas con `scrypt` y sal aleatoria.
  - Cookie `httpOnly` firmada con HMAC que **solo identifica** a la persona. Rol, secciones y si está activa se leen de la base de datos en cada petición.
  - Login por usuario (y aceptar también `usuario@dominio` recortando el dominio).
  - Límite de intentos por usuario e IP, contado en la tabla `hub_login_attempts` (en Vercel la memoria no persiste entre peticiones). Si el usuario no existe, verificar contra un hash ficticio para que el tiempo de respuesta no delate qué usuarios existen.
- **Seguridad de la base de datos:** RLS activado en todas las tablas **sin políticas** y `REVOKE ALL` a `anon` y `authenticated`. Solo la secret key del servidor entra.
- **Validación en cada API:** enums contra el catálogo, fechas con formato, URLs que empiecen por `http`, longitudes máximas, números finitos. Errores genéricos al cliente y detalle en el log del servidor.

---

## 6. Modelo de datos

SQL de partida. Adáptalo a lo decidido en `PLAN.md`.

```sql
-- gen_random_uuid() viene de serie en Postgres 13+: no hace falta ninguna extensión

-- Personas y acceso
create table hub_people (
  id uuid primary key default gen_random_uuid(),
  nombre text not null,
  usuario text not null unique,
  rol text not null check (rol in ('admin','editor','disenador','lector')),
  secciones text[],                      -- null = admin (lo ve todo)
  color text not null default '#7CBA5F',
  avatar_url text,
  password_hash text,
  activo boolean not null default true,
  created_at timestamptz not null default now()
);

-- Tarjetas de los tableros de contenido
create table hub_items (
  id uuid primary key default gen_random_uuid(),
  formato text not null,                 -- short | long | ads
  titulo text not null,
  status text not null default 'idea',
  platform text,
  tipos text[] not null default '{}',
  funnel text,
  editor_id uuid references hub_people(id) on delete set null,
  script text,
  reference_url text,
  raw_url text,
  edited_url text,
  published_url text,
  post_date date,
  pinned boolean not null default false,
  posicion double precision not null default 0,
  ig_media_id text,                      -- caché del id de Instagram de la pieza
  video_titulo text,
  video_thumb text,
  ads_gasto numeric, ads_impresiones numeric, ads_hook_rate numeric,
  ads_ctr numeric, ads_cpa numeric, ads_frecuencia numeric,
  ads_veredicto text, ads_subestado text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create table hub_comments (
  id uuid primary key default gen_random_uuid(),
  item_id uuid references hub_items(id) on delete cascade,
  author_id uuid references hub_people(id) on delete set null,
  body text not null,
  created_at timestamptz not null default now()
);

-- Snapshots de métricas por tarjeta publicada (ACUMULADOS)
create table hub_metrics (
  id uuid primary key default gen_random_uuid(),
  item_id uuid references hub_items(id) on delete cascade,
  views integer, likes integer, comments integer, shares integer, saves integer,
  watch_time_min numeric, retencion_pct numeric, subs integer,
  source text,
  captured_at timestamptz not null default now()
);

-- Diseño
create table hub_projects (
  id uuid primary key default gen_random_uuid(),
  titulo text not null,
  descripcion text,
  status text not null default 'backlog',
  tipo text,
  destino text,
  assignee_id uuid references hub_people(id) on delete set null,
  deadline date,
  script_url text,
  loom_url text, loom_url_2 text, loom_url_3 text,
  entregado_at timestamptz,
  posicion double precision not null default 0,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create table hub_refs (
  id uuid primary key default gen_random_uuid(),
  project_id uuid references hub_projects(id) on delete cascade,
  clase text not null default 'referencia',   -- referencia | entrega
  tipo text,
  url text,
  nota text,
  autor_id uuid references hub_people(id) on delete set null,
  posicion double precision not null default 0,
  created_at timestamptz not null default now()
);

-- Calendario (entradas manuales)
create table hub_calendario (
  id uuid primary key default gen_random_uuid(),
  fecha date not null,
  titulo text not null,
  tipo text not null,
  url text,
  url_recurso text,
  nota text,
  autor_id uuid references hub_people(id) on delete set null,
  created_at timestamptz not null default now()
);

-- Actividad y novedades
create table hub_activity (
  id uuid primary key default gen_random_uuid(),
  actor_id uuid references hub_people(id) on delete set null,
  verbo text not null,
  titulo text not null,
  seccion text not null,
  created_at timestamptz not null default now()
);

create table hub_seen (
  person_id uuid references hub_people(id) on delete cascade,
  seccion text not null,
  last_seen_at timestamptz not null default now(),
  primary key (person_id, seccion)
);

-- Cuentas completas (todo lo publicado, esté o no en un tablero)
create table hub_ig_media (
  id text primary key,
  permalink text, caption text, media_type text, media_product_type text, thumb text,
  publicado_at timestamptz,
  views bigint, reach bigint, likes integer, comments integer, shares integer, saves integer,
  item_id uuid references hub_items(id) on delete set null,
  sincronizado_at timestamptz
);

create table hub_yt_videos (
  id text primary key,
  titulo text, thumb text, publicado_at timestamptz, duracion_seg integer,
  views bigint, likes integer, comments integer,
  watch_time_min numeric, retencion_pct numeric, subs integer,
  item_id uuid references hub_items(id) on delete set null,
  sincronizado_at timestamptz
);

-- Estado de cada cuenta y su serie diaria real
create table hub_canal (
  plataforma text primary key,
  nombre text, handle text, avatar text,
  seguidores integer, piezas integer, views_total bigint,
  sincronizado_at timestamptz
);

create table hub_canal_dias (
  plataforma text not null,
  fecha date not null,
  views integer, watch_time_min numeric,
  subs_ganados integer, subs_perdidos integer, seguidores integer,
  likes integer, shares integer, reach integer, interacciones integer,
  sincronizado_at timestamptz,
  primary key (plataforma, fecha)
);

-- Vida diaria de cada pieza
create table hub_media_dias (
  plataforma text not null,
  media_id text not null,
  fecha date not null,
  views integer,
  watch_time_min numeric,
  acumulado integer,                     -- Instagram: total en el momento del sync
  sincronizado_at timestamptz,
  primary key (plataforma, media_id, fecha)
);
create index on hub_media_dias (plataforma, fecha);

-- Series de 28 días por pieza en una sola fila por pieza (evita el tope de filas de la API)
create or replace function hub_media_series(p_desde date, p_hasta date)
returns table(plataforma text, media_id text, serie integer[])
language sql stable security definer set search_path = public as $$
  with dias as (select generate_series(p_desde, p_hasta, interval '1 day')::date as fecha),
  medias as (select distinct m.plataforma, m.media_id from hub_media_dias m where m.fecha >= p_desde)
  select m.plataforma, m.media_id, array_agg(coalesce(d.views, 0) order by dias.fecha)
  from medias m cross join dias
  left join hub_media_dias d
    on d.plataforma = m.plataforma and d.media_id = m.media_id and d.fecha = dias.fecha
  group by m.plataforma, m.media_id
$$;

-- Cierre de seguridad: solo la secret key del servidor entra
do $$ declare t text; begin
  for t in select tablename from pg_tables where schemaname = 'public' and tablename like 'hub_%' loop
    execute format('alter table %I enable row level security', t);
    execute format('revoke all on %I from anon, authenticated', t);
  end loop;
end $$;
revoke all on function hub_media_series(date, date) from public, anon, authenticated;

notify pgrst, 'reload schema';
```

Añade un bucket de almacenamiento **privado** para imágenes de Diseño con límite de tamaño (8 MB). Las entregas sin publicar no pueden quedar abiertas a cualquiera que tenga la URL: el servidor genera URLs firmadas de caducidad corta.

```sql
-- Intentos de login: en Vercel no hay servidor fijo, el contador tiene que vivir en la base de datos
create table hub_login_attempts (
  id bigint generated always as identity primary key,
  usuario text not null,
  ip text,
  ok boolean not null default false,
  created_at timestamptz not null default now()
);
create index on hub_login_attempts (usuario, created_at);
alter table hub_login_attempts enable row level security;
revoke all on hub_login_attempts from anon, authenticated;
```

---

## 7. Integraciones y sincronización

### 7.1 Métricas de una pieza al anclar su URL
- **Instagram:** los links `instagram.com/share/...` no llevan el código real; sigue el redirect. Busca la publicación por su código en `/{ig-user-id}/media` (paginando) y **guarda el id en la tarjeta** para no volver a buscarlo. Si cambia la URL, borra ese id, el título y la miniatura. Insights de la publicación: views, likes, comments, shares, saved.
- **YouTube:** views y likes públicos; si hay OAuth del canal, añade watch time, retención y suscriptores ganados de ese vídeo con la API de Analytics. Título y miniatura con el endpoint oEmbed.
- Cada captura es un **snapshot acumulado** en `hub_metrics`. Timeout de 8 s en cada llamada externa y nunca romper la petición si una API falla.

### 7.2 Cuenta completa (cada noche)
- **Instagram:** `/media` paginado **hasta el final de la cuenta** (con un tope de seguridad alto, no de 100 o 120) con `children{media_url,thumbnail_url}` para sacar miniatura de los carruseles; insights en bloque con expansión de campos (`/media?fields=id,insights.metric(views,reach,shares,saved)`); si una página de resultados falla, por ejemplo por publicaciones anteriores al paso a cuenta profesional, pide esa página pieza a pieza. Upsert en `hub_ig_media` y enlace con las tarjetas por código del permalink. Guarda en `hub_media_dias` lo que ganó cada pieza desde el sync anterior.
- **YouTube:** lista de subidas del canal → datos y estadísticas públicas en lotes de 50 → Analytics con `dimensions=video`, `sort=-views` y `maxResults=200`. Ese informe no pagina: si el canal tiene más de 200 vídeos, pídelo en lotes de 200 con `filters=video==ID1,ID2,…`. Upsert en `hub_yt_videos` y enlace con tarjetas por id de vídeo.

### 7.3 Serie diaria de la cuenta
- **YouTube:** Analytics con `dimensions=day,creatorContentType` desde el inicio del canal. Suma views, watch time, likes y shares **sin la fila de shorts** si la persona no sube Shorts; los suscriptores, de todo el canal. Reconstruye seguidores día a día hacia atrás desde el total actual.
- **Vida diaria de cada vídeo largo:** Analytics con `filters=video==ID&dimensions=day`, una llamada por vídeo. Cada noche los últimos 14 días; en el primer relleno, desde su publicación.
- **Instagram:** alcance diario en ventanas de máximo 30 días (se puede ir hacia atrás un año). Seguidores nuevos por día: solo los últimos 30. Views e interacciones de la cuenta **solo existen como total de un rango** (`metric_type=total_value`), así que se piden **día a día** con una ventana de **exactamente 24 h desde las 12:00 UTC** (con menos de 24 h la API devuelve vacío). Tope de llamadas por ejecución: cada noche rellena lo que falta de los últimos 10 días; el relleno inicial se hace en varias pasadas de ~150 días.

### 7.4 Cron nocturno
Un único endpoint protegido con `Authorization: Bearer CRON_SECRET`, declarado en `vercel.json` a una hora de madrugada: snapshots de las tarjetas publicadas → cuenta de Instagram → cuenta de YouTube → series diarias. Cada paso en su `try/catch`: que uno falle no tumba los demás. Si el plan de Vercel corta la función por tiempo, trocea el trabajo.

Además: endpoints manuales de sincronización para admin, y uno de relleno inicial (`?backfill=1`) que se lanza varias veces hasta completar un año de Instagram.

---

## 8. Motor de métricas: reglas que no se negocian

1. **Los snapshots son acumulados.** Lo que rinde un período es la **diferencia** entre snapshots, nunca la suma.
2. **Diferencias con suelo en cero:** Instagram recalcula views a la baja de vez en cuando.
3. **Mediana, no media**, en todo agregado por tipo, editor o cuenta. Un viral suelto no puede subir el listón de todo.
4. **La curva del canal sale de la API de la cuenta**, no de sumar tarjetas.
5. **La ventana termina en el último día cerrado.** YouTube Analytics llega con 2 o 3 días de retraso: usa el último día con dato. Instagram sirve el día de ayer a medias y la curva se desploma al final: corta en hoy menos 2. Enséñalo: "datos hasta el lunes 14".
6. **Comparación siempre contra el período anterior de la misma duración.** Si no hay base comparable, di "nuevo", no "+100 %".
7. **Shorts fuera** si la persona no los sube: por `creatorContentType` en la serie del canal. En el catálogo, la duración sola no basta (los Shorts duran hasta 3 minutos y hay vídeos horizontales cortos): marca como Short lo que responde en `youtube.com/shorts/ID` sin redirigir, o pide `creatorContentType` por vídeo.
8. **El alcance no se suma.** Son cuentas únicas: sumar el alcance diario lo infla (en un caso real, 266K sumado frente a 164K únicos). Para 7 y 30 días pide a Meta el alcance del rango con `metric_type=total_value` y guárdalo en la sync nocturna, junto al del periodo anterior. Para periodos de más de 30 días Meta no lo da: enseña el alcance diario medio, con esa etiqueta.
9. Lógica de cálculo en un módulo puro sin estado (`lib/metricas.ts`); la interfaz solo pinta lo que sale de ahí.

---

## 9. Diseño visual

Estándar: **herramienta premium**, no panel de administración genérico.

- Paleta mínima: fondo casi negro (`#050505`) o blanco roto si es tema claro, texto con tres niveles de opacidad, **un solo color de acento** (el de la marca). Nada de rojo YouTube ni rosa Instagram en las métricas: cada plataforma con su color es lo que hace que un dashboard parezca sucio.
- Tipografía sans moderna (Geist o similar) y **monoespaciada con cifras tabulares para todos los números**.
- Paneles con doble borde sutil (contenedor exterior casi invisible y núcleo con degradado de 1 px) y radios generosos.
- Animaciones **solo con `transform` y `opacity`**, con una curva de easing propia. Entradas escalonadas de los bloques, curvas que se revelan de izquierda a derecha.
- **Nada nativo feo:** ni `<select>` ni selector de fecha del navegador. Componentes propios (listbox con check, calendario lunes-primero con Hoy y Limpiar).
- Esqueletos de carga por ruta: el clic pinta al instante.
- **Móvil a 390 px obligatorio y sin scroll horizontal** en ninguna página. Las tablas anchas scrollean dentro de su contenedor con la primera columna fija. Inputs a 16 px (si no, iOS hace zoom).
- Todo en el idioma elegido. Ninguna etiqueta en inglés si hay alguien del equipo que no lo habla.

---

## 10. Gotchas ya pagados (no los repitas)

1. **dnd-kit + React 19:** mover una tarjeta de columna en `onDragOver` provoca un bucle infinito ("Maximum update depth exceeded") y la página muere. Confirma el movimiento entero en `onDragEnd`, con el estado del tablero en un `ref` síncrono porque `onDragEnd` puede llegar antes del re-render. Feedback visual con `DragOverlay`.
2. **Arrastre en móvil:** sensor táctil con retardo (~220 ms) para que el scroll siga siendo libre.
3. **Guardado automático:** cola serializada de PATCH por tarjeta; cada eslabón con su `catch`; si falla, revierte solo ese cambio y enseña un aviso; si responde 401, al login. Los inputs no controlados se remontan con `key` ligada a `updated_at` al llegar datos frescos. Al abrir una tarjeta, pide su versión fresca al servidor.
4. **Drawer:** si sus hijos animan con `transform`, cada uno es un contexto de apilamiento y un desplegable abierto queda debajo del bloque siguiente (se ve pero no se puede clicar). Sube el `z-index` del hijo que contiene el menú abierto.
5. **Miniaturas:** los CDN de Meta y el avatar de YouTube rechazan peticiones con referer (Instagram falla, YouTube devuelve 429). Usa `referrerPolicy="no-referrer"` y un placeholder en `onError`: las URLs firmadas de Meta caducan y se renuevan en el sync nocturno.
6. **YouTube Analytics con `dimensions=video`:** `maxResults` máximo 200; con más, error 400 sin explicación.
7. **YouTube:** las impresiones y el CTR de miniatura **no existen** en la API. Se miran en YouTube Studio. No pintes un medidor que siempre marcará cero.
8. **Vídeos recién subidos:** Analytics tarda ~48 h en devolver datos. No es un bug.
9. **Supabase:** después de crear o cambiar funciones SQL, `notify pgrst, 'reload schema'`. La API REST devuelve como mucho 1.000 filas por defecto: agrega en SQL cuando sean series.
10. **Vercel:** región de la función igual a la de Supabase; `vercel env add` por tubería guarda vacío.
11. **Permisos:** la galería de Publicados carga una proyección explícita de columnas. El guion y los links internos no deben viajar nunca al navegador de alguien que no tiene acceso al tablero de origen.
12. **Filtros en tableros:** con un filtro activo, desactiva el arrastre o las posiciones se corrompen.
13. **Sincroniza la cuenta entera, no las últimas N.** Si el sync de Instagram se queda en las últimas 120, las publicaciones antiguas dejan de refrescarse: sus miniaturas caducan y sus views se congelan. Suelen ser justo las mejores de la cuenta.
14. **Instagram sirve ayer a medias.** El último día que devuelve la API está incompleto; si la ventana acaba ahí, la curva cae en picado el último día y la comparación con el mes anterior sale peor de lo que es.

---

## 11. Plan de construcción

| Fase | Qué | Hecho cuando |
|---|---|---|
| 0 | Entrevista y `PLAN.md` | La persona da el OK al plan |
| 1 | Supabase, Vercel y dominio. SQL ejecutado. Primer deploy de una página vacía | La URL de producción responde 200 y la base de datos tiene las tablas |
| 2 | Sistema de diseño, login, sesiones, personas, menú por secciones, esqueletos | Login funciona en producción, un usuario sin sección llega a "sin acceso" |
| 3 | Tableros Short, Long y Ads con arrastre, drawer, guardado automático y comentarios | Arrastrar entre columnas persiste tras recargar; un fallo de red revierte el cambio |
| 4 | URL publicada: embed, título, miniatura, métricas por pieza. Galería de Publicados | Pegar un link real de Instagram y de YouTube trae cifras reales |
| 5 | Diseño y Calendario (tres fuentes) | Una tarjeta con fecha y un proyecto con deadline aparecen solos en el calendario |
| 6 | Credenciales de Meta y Google, sync de cuentas, series diarias, cron, relleno inicial | Las tablas de cuenta tienen datos de un año y el cron responde 401 sin secreto y 200 con él |
| 7 | Pestaña Métricas completa | Las cifras de 30 días cuadran con Instagram y YouTube Studio (salvo Shorts excluidos y el retraso de YouTube) |
| 8 | Equipo, roles, secciones, actividad y badges | Un lector no puede escribir en ninguna API (probado con peticiones directas, no solo ocultando botones) |
| 9 | Endurecimiento y entrega | Revisión adversarial hecha, QA visual en 1440 y 390 px sin errores de consola, `HANDOFF.md` escrito |

### Verificación de cada fase
1. `npm run build` y `npx tsc --noEmit` limpios.
2. Navegador automatizado (Playwright) contra producción, escritorio y 390 px: capturas, cero errores de consola, `scrollWidth` igual al ancho de la ventana.
3. Para permisos, peticiones directas a la API con cada rol: lo que no debe poder hacer tiene que devolver 403.
4. Enséñame capturas al cerrar cada fase.

### Fase 9 · endurecimiento
Antes de darlo por terminado, revisa el código como si quisieras romperlo: permisos saltables llamando a la API directamente, datos internos que viajan al navegador, entradas sin validar, guardados que se pierden con mala conexión, APIs externas caídas, arrastres cancelados, usuarios desactivados con la sesión abierta. Arregla lo que encuentres y verifícalo.

---

## 12. Entrega final

1. **Hub en producción** en su dominio, con su logo, el admin creado y la contraseña cambiada por la persona.
2. **`HANDOFF.md`** en la raíz: qué es, mapa de archivos y por qué, decisiones que no son obvias, variables de entorno (solo nombres), cómo se despliega, gotchas y pendientes. Escrito para que otra sesión de Claude Code lo retome sin contexto.
3. **Guía de uso de una página** para el equipo: qué hace cada sección y el flujo de una pieza de idea a publicada.
4. Cómo dar de alta a alguien del equipo y asignarle secciones.
5. Qué hacer si una integración deja de sincronizar: dónde mirar el log del cron y cómo renovar cada token.
6. Cómo pedir cambios en el futuro: abrir Claude Code en esta misma carpeta y empezar con «Lee HANDOFF.md».

Empieza por la Fase 0.
