diff --git a/.agents/comunidad.yaml b/.agents/comunidad.yaml new file mode 100644 index 0000000..5c34a83 --- /dev/null +++ b/.agents/comunidad.yaml @@ -0,0 +1,28 @@ +id: "ai-radar-comunidad" +display_name: "Comunidad" +agent_type: "default" +reasoning_effort: "medium" +source_type: "community" +scope: + include: + - "Hacker News, Reddit tecnico, foros de Hugging Face y GitHub discussions." + - "Blogs personales tecnicos, posts publicos y reportes de usuarios con reproduccion." + - "Incidentes, adopcion temprana, fricciones reales y feedback operativo." + exclude: + - "Rumores sin enlace o sin evidencia reproducible." + - "Opiniones que no indiquen impacto practico para builders." +instructions: >- + Busca senales recientes de IA nacidas en comunidad. Distingue hechos + verificables de testimonios u opiniones. Cuando una senal sea autoinformada, + marcala claramente y busca una segunda fuente tecnica u oficial si existe. +output: + language: "es" + fields: + - "titulo" + - "url" + - "fecha" + - "fuente" + - "evidencia" + - "impacto" + - "accion" + - "estado" diff --git a/.agents/fuentes-oficiales.yaml b/.agents/fuentes-oficiales.yaml new file mode 100644 index 0000000..28ecf61 --- /dev/null +++ b/.agents/fuentes-oficiales.yaml @@ -0,0 +1,29 @@ +id: "ai-radar-fuentes-oficiales" +display_name: "Fuentes oficiales" +agent_type: "default" +reasoning_effort: "high" +source_type: "official" +scope: + include: + - "Anuncios, blogs, changelogs y documentacion oficial de companias de IA." + - "Comunicados de organismos regulatorios, gobiernos, estandares y laboratorios." + - "Paginas primarias de producto, API, seguridad, compliance o investigacion." + exclude: + - "Cobertura secundaria sin enlace a fuente primaria." + - "Opinion, rumor o resumen no verificable." +instructions: >- + Busca senales recientes de IA en fuentes primarias. Prioriza lanzamientos de + modelos, cambios de API, regulacion, seguridad, agentes, compute, + herramientas developer y adopcion enterprise. Verifica fecha exacta, + deduplica anuncios repetidos y evita hype. +output: + language: "es" + fields: + - "titulo" + - "url" + - "fecha" + - "fuente" + - "evidencia" + - "impacto" + - "accion" + - "estado" diff --git a/.agents/medios-secundarios.yaml b/.agents/medios-secundarios.yaml new file mode 100644 index 0000000..5b8b276 --- /dev/null +++ b/.agents/medios-secundarios.yaml @@ -0,0 +1,28 @@ +id: "ai-radar-medios-secundarios" +display_name: "Medios secundarios" +agent_type: "default" +reasoning_effort: "medium" +source_type: "secondary_media" +scope: + include: + - "Medios tecnologicos, financieros y de negocio confiables." + - "Reportes sobre regulacion, chips, data centers, adopcion enterprise y mercado." + - "Contexto que complemente fuentes primarias o revele impacto estrategico." + exclude: + - "Notas sin fecha clara." + - "Contenido puramente promocional o sin evidencia enlazable." +instructions: >- + Busca reportes recientes de IA en medios secundarios. Prioriza informacion + con impacto para estrategia, producto o infraestructura. Marca siempre que la + fuente sea secundaria y, cuando sea posible, enlaza tambien la fuente primaria. +output: + language: "es" + fields: + - "titulo" + - "url" + - "fecha" + - "fuente" + - "evidencia" + - "impacto" + - "accion" + - "estado" diff --git a/.agents/repo-tecnico.yaml b/.agents/repo-tecnico.yaml new file mode 100644 index 0000000..28328d4 --- /dev/null +++ b/.agents/repo-tecnico.yaml @@ -0,0 +1,29 @@ +id: "ai-radar-repo-tecnico" +display_name: "Repo tecnico" +agent_type: "default" +reasoning_effort: "xhigh" +source_type: "technical_repository" +scope: + include: + - "Releases, changelogs, issues y discussions de repositorios tecnicos." + - "Model cards, datasets, papers, arXiv, Hugging Face y benchmarks." + - "Frameworks de inferencia, entrenamiento, agentes, evals y tooling developer." + exclude: + - "Repos sin evidencia tecnica reciente." + - "Papers sin artefacto, benchmark o consecuencia practica clara." +instructions: >- + Busca senales recientes de IA con evidencia tecnica verificable. Prioriza + cambios que afecten builders: compatibilidad, rendimiento, licencias, + migraciones, nuevos modelos, bugs bloqueantes y patrones emergentes. Revisa + fechas, estado del artefacto y riesgos de adopcion. +output: + language: "es" + fields: + - "titulo" + - "url" + - "fecha" + - "fuente" + - "evidencia" + - "impacto" + - "accion" + - "estado" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4052bbc --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= +AI_RADAR_API_TOKEN= +AI_RADAR_API_BASE_URL=http://localhost:3000 +NOTION_API_KEY= +NOTION_DATA_SOURCE_ID= +NOTION_DATABASE_ID= +NOTION_DATABASE_URL= +NOTION_DATA_SOURCE_URL= +NEXT_PUBLIC_APP_ENV=development diff --git a/.gitignore b/.gitignore index e8fd391..aec75c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,18 @@ .DS_Store node_modules/ .pnpm-store/ +.next/ dist/ coverage/ .env .env.* +!.env.example .airadar/ data/searches/ data/reports/ snapshots/weekly/ config/sources.json +supabase/.temp/ frameio/ recordings/ *.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..155745d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# Guia del Repositorio + +## Estructura del Proyecto y Organizacion + +Este workspace contiene AI Radar en `platzi-codex-clase-02-agents-md/`. El proyecto sigue siendo pequeno: `README.md` define la direccion del producto, `AGENTS.md` define reglas para agentes y `.gitignore` excluye caches locales, secretos, datos generados y salidas de build. La implementacion actual agrega un runtime minimo Next.js con API routes protegidas, dashboard visual en `app/page.js`, helpers server-side en `lib/`, migraciones en `supabase/migrations/`, pruebas en `tests/`, fixtures en `fixtures/` y scripts locales en `scripts/`. + +## Comandos de Build, Prueba y Desarrollo + +Usa estos comandos mientras trabajas: + +```powershell +cd platzi-codex-clase-02-agents-md +git status --short +git log --oneline -5 +npm test +npm run build +npm run sources:refresh +npm run supabase:check +``` + +Para desarrollo local de la API usa `npm run dev`. No ejecutes ni documentes comandos nuevos hasta que existan en `package.json`. + +## Estilo de Codigo y Convenciones de Nombres + +Manten encabezados claros, parrafos breves y nombres descriptivos para archivos nuevos, por ejemplo `fixtures/signals.json` o `scripts/normalize-sources.js`. La API actual usa JavaScript ESM, validacion con esquemas y funciones pequenas en `lib/`. No incluyas en control de versiones salidas generadas, snapshots temporales, grabaciones, credenciales ni bases de datos locales. + +## Guia de Pruebas + +Las pruebas usan `node:test` y viven en `tests/`. Mantén pruebas enfocadas en validacion de contratos, normalizacion, endpoints y adaptadores server-side. Para cambios de interfaz visual, agrega o ejecuta verificaciones con Playwright para flujos de usuario cuando el alcance lo justifique. + +## Guia de Commits y Pull Requests + +El historial existente usa prefijos convencionales cortos como `docs:` y `chore:`. Manten ese estilo, por ejemplo `docs: aclarar objetivos de AI Radar` o `chore: actualizar reglas de ignore`. Los pull requests deben describir que cambio, como se verifico y que queda intencionalmente pendiente. Incluye capturas solo cuando exista una interfaz. + +## Instrucciones Especificas para Agentes + +Inspecciona el repositorio antes de editar. Trata el README como direccion de producto, no como prueba de funcionalidades implementadas. Manten los cambios acotados a la leccion actual y evita inventar servicios, scripts, bases de datos o automatizaciones que no esten presentes. + +La integracion Supabase actual es server-side: los endpoints requieren `Authorization: Bearer $AI_RADAR_API_TOKEN` y usan `SUPABASE_SERVICE_ROLE_KEY` solo en el servidor. El dashboard puede leer Supabase desde server components mediante helpers de `lib/`; si faltan variables o tablas, debe caer a fixture declarado sin exponer secretos al navegador. No uses secretos con prefijo `NEXT_PUBLIC_`. No apliques migraciones DDL ni crees proyectos Supabase remotos sin aprobacion explicita. + +Cuando una tarea requiera buscar senales recientes de IA con subagentes, usa las configuraciones en `.agents/` y genera el plan de llamadas con: + +```powershell +python scripts\llamar_subagentes.py "senales recientes de IA" +``` + +Antes de generar el plan, consulta Notion primero usando la tabla `AI radar Sources`. Refresca `config/sources.json` como cache local con las fuentes activas agrupadas por subagente. Usa `npm run sources:refresh` cuando existan `NOTION_API_KEY` y `NOTION_DATA_SOURCE_ID`; si el conector Notion no permite consultas SQL, usa `search` + `fetch` como fallback de lectura y reporta el motivo. Ese archivo esta ignorado por git y no debe tratarse como artefacto versionado salvo instruccion explicita. + +Ese script valida los YAML, lee `config/sources.json` si existe y produce payloads para `multi_agent_v1.spawn_agent`; ejecuta esos payloads en paralelo desde Codex, deduplica resultados y normaliza las senales antes de responder o guardar snapshots. + +Si Notion no responde, la tabla no esta indexada, una fuente falla o un subagente no devuelve resultado, continua con el fallback indicado por el script y reporta el motivo en la respuesta final. + +Trata frases naturales como "busca las noticias de esta semana", "busca noticias recientes de IA" o "dame las senales de IA de la semana" como solicitudes para activar ese flujo. Para "esta semana", calcula la ventana de los ultimos 7 dias con fechas exactas y pasala al script con `--desde` y `--hasta`. diff --git a/README.md b/README.md index 9478e4b..f468234 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ AI Radar es el proyecto del curso avanzado de Codex. El objetivo del producto es organizar noticias, herramientas, papers, repos y lanzamientos de IA para convertirlos en senales accionables para builders: que paso, por que importa, que tan confiable es y que vale la pena probar. -Estado inicial: definicion de producto, stack objetivo y reglas iniciales. La implementacion se construye por capas durante el curso con Codex. +Estado actual: definicion de producto, contrato local de senales, scripts de subagentes, una API minima para persistir runs y senales en Supabase y un dashboard visual inicial. El dashboard puede leer datos reales server-side desde Supabase cuando el entorno esta configurado; si faltan credenciales, cae al fixture declarado en `fixtures/dashboard.json`. ## Problema @@ -32,14 +32,66 @@ Al final del curso, AI Radar debe poder: - guardar trazas de decisiones y validaciones, - desplegarse con infraestructura controlada. -## Estado Inicial +## Estado Actual -El starter contiene: +El repo contiene: - `README.md` - `.gitignore` +- `AGENTS.md` +- `.agents/` con configuraciones de subagentes +- `contracts/ai-radar-daily-signals.schema.json` +- `data/daily/` con snapshots diarios +- `scripts/` con utilidades locales +- `app/api/` con endpoints Next.js protegidos +- `app/page.js` con dashboard visual basado en API server-side o fixture declarado +- `lib/` con validacion y acceso server-side a Supabase +- `supabase/migrations/` con el esquema core +- `tests/` con pruebas `node:test` -La primera clase usa este estado para mostrar como `AGENTS.md` cambia la forma en que Codex entiende un proyecto antes de escribir codigo. +El dashboard visual existe en modo operacional inicial. Aun no hay ranking persistido con scores propios; cuando usa Supabase, la capa visual adapta `signals` y `sources` al contrato de UI y declara ese mapeo en los datos entregados a la pagina. + +## Desarrollo Local + +```powershell +npm install +npm test +npm run build +npm run dev +npm run sources:refresh +npm run supabase:check +``` + +Configura `.env.local` a partir de `.env.example`. No guardes claves reales en git. + +## Fuentes Notion + +La tabla de fuentes vive en Notion como `AI radar Sources`. Para refrescar el cache local ignorado por git: + +```powershell +npm run sources:refresh +``` + +El script usa la API publica de Notion y evita depender de consultas SQL del conector Notion. Configura `NOTION_API_KEY` y preferentemente `NOTION_DATA_SOURCE_ID`; `NOTION_DATABASE_ID` queda como fallback legacy. El cache resultante se escribe en `config/sources.json`, agrupado por subagente y solo con filas `Status = activa`. + +## API Supabase + +Los endpoints requieren `Authorization: Bearer $AI_RADAR_API_TOKEN`: + +- `POST /api/runs`: guarda un run completo con senales normalizadas. +- `GET /api/runs/:id`: consulta un run y sus senales. +- `GET /api/signals?fecha=&source_type=&limit=`: lista senales persistidas. +- `POST /api/sources/sync`: sincroniza fuentes activas desde el cache de Notion. + +Supabase se usa solo server-side con `SUPABASE_SERVICE_ROLE_KEY`. La migracion local habilita RLS y no crea politicas publicas. + +Para validar que el entorno apunta al proyecto correcto y que existen las tablas esperadas: + +```powershell +npm run supabase:check +``` + +El diagnostico no imprime secretos. Si faltan variables o el proyecto no tiene `public.sources`, `public.runs` y `public.signals`, reporta el problema antes de intentar sincronizar fuentes o persistir snapshots. ## Stack Objetivo diff --git a/app/api/runs/[id]/route.js b/app/api/runs/[id]/route.js new file mode 100644 index 0000000..9fb794c --- /dev/null +++ b/app/api/runs/[id]/route.js @@ -0,0 +1,27 @@ +import { jsonError, requireBearerToken } from "../../../../lib/api/auth.js"; +import { getSupabaseAdmin } from "../../../../lib/supabase/client.js"; +import { getRunWithSignals } from "../../../../lib/supabase/runs.js"; +import { validationIssues, uuidSchema } from "../../../../lib/validation.js"; + +export const runtime = "nodejs"; + +export async function GET(request, context) { + const auth = requireBearerToken(request); + if (!auth.ok) { + return auth.response; + } + + const params = await context.params; + const result = uuidSchema.safeParse(params.id); + if (!result.success) { + return jsonError(400, "invalid_run_id", "id de run invalido", validationIssues(result.error)); + } + + try { + const supabase = getSupabaseAdmin(); + const payload = await getRunWithSignals(supabase, result.data); + return Response.json(payload); + } catch (error) { + return jsonError(500, "get_run_failed", error.message); + } +} diff --git a/app/api/runs/route.js b/app/api/runs/route.js new file mode 100644 index 0000000..7f77f30 --- /dev/null +++ b/app/api/runs/route.js @@ -0,0 +1,38 @@ +import { jsonError, requireBearerToken } from "../../../lib/api/auth.js"; +import { getSupabaseAdmin } from "../../../lib/supabase/client.js"; +import { saveRun } from "../../../lib/supabase/runs.js"; +import { PayloadValidationError, normalizeRunPayload } from "../../../lib/validation.js"; + +export const runtime = "nodejs"; + +export async function POST(request) { + const auth = requireBearerToken(request); + if (!auth.ok) { + return auth.response; + } + + let body; + try { + body = await request.json(); + } catch { + return jsonError(400, "invalid_json", "el body debe ser JSON valido"); + } + + let run; + try { + run = normalizeRunPayload(body); + } catch (error) { + if (error instanceof PayloadValidationError) { + return jsonError(400, "invalid_payload", error.message, error.issues); + } + throw error; + } + + try { + const supabase = getSupabaseAdmin(); + const result = await saveRun(supabase, run); + return Response.json(result, { status: 201 }); + } catch (error) { + return jsonError(500, "save_run_failed", error.message); + } +} diff --git a/app/api/signals/route.js b/app/api/signals/route.js new file mode 100644 index 0000000..3b4f153 --- /dev/null +++ b/app/api/signals/route.js @@ -0,0 +1,31 @@ +import { jsonError, requireBearerToken } from "../../../lib/api/auth.js"; +import { getSupabaseAdmin } from "../../../lib/supabase/client.js"; +import { listSignals } from "../../../lib/supabase/signals.js"; +import { PayloadValidationError, normalizeSignalQuery } from "../../../lib/validation.js"; + +export const runtime = "nodejs"; + +export async function GET(request) { + const auth = requireBearerToken(request); + if (!auth.ok) { + return auth.response; + } + + let query; + try { + query = normalizeSignalQuery(new URL(request.url).searchParams); + } catch (error) { + if (error instanceof PayloadValidationError) { + return jsonError(400, "invalid_query", error.message, error.issues); + } + throw error; + } + + try { + const supabase = getSupabaseAdmin(); + const signals = await listSignals(supabase, query); + return Response.json({ signals, count: signals.length }); + } catch (error) { + return jsonError(500, "list_signals_failed", error.message); + } +} diff --git a/app/api/sources/sync/route.js b/app/api/sources/sync/route.js new file mode 100644 index 0000000..59ee06c --- /dev/null +++ b/app/api/sources/sync/route.js @@ -0,0 +1,38 @@ +import { jsonError, requireBearerToken } from "../../../../lib/api/auth.js"; +import { getSupabaseAdmin } from "../../../../lib/supabase/client.js"; +import { syncSources } from "../../../../lib/supabase/sources.js"; +import { PayloadValidationError, normalizeSourcesPayload } from "../../../../lib/validation.js"; + +export const runtime = "nodejs"; + +export async function POST(request) { + const auth = requireBearerToken(request); + if (!auth.ok) { + return auth.response; + } + + let body; + try { + body = await request.json(); + } catch { + return jsonError(400, "invalid_json", "el body debe ser JSON valido"); + } + + let sources; + try { + sources = normalizeSourcesPayload(body); + } catch (error) { + if (error instanceof PayloadValidationError) { + return jsonError(400, "invalid_payload", error.message, error.issues); + } + throw error; + } + + try { + const supabase = getSupabaseAdmin(); + const synced = await syncSources(supabase, sources); + return Response.json({ synced_count: synced.length }); + } catch (error) { + return jsonError(500, "sync_sources_failed", error.message); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..fc1f61a --- /dev/null +++ b/app/globals.css @@ -0,0 +1,1461 @@ +:root { + --bg: #f6f8fb; + --surface: #ffffff; + --surface-muted: #f8fafc; + --line: #dce3ee; + --line-strong: #c7d1df; + --text: #0d172b; + --muted: #50617c; + --muted-strong: #33415c; + --accent: #1d6ff2; + --accent-strong: #0f54c8; + --ink: #070d1d; + --green: #049669; + --teal: #12a8a1; + --orange: #f58b17; + --red: #df2e38; + --shadow: 0 12px 28px rgb(30 45 70 / 8%); + --button-bg: #ffffff; + --button-hover: #f2f6fb; + --button-active: #e9f1ff; + color-scheme: light; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + background: var(--bg); +} + +body { + margin: 0; + min-width: 320px; + color: var(--text); + background: var(--bg); +} + +button, +input, +select { + font: inherit; +} + +button, +select { + cursor: pointer; +} + +button:disabled, +input:disabled, +select:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 70%, white); + outline-offset: 2px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.app-header { + position: sticky; + top: 0; + z-index: 10; + display: grid; + grid-template-columns: 180px minmax(220px, 1fr) auto minmax(320px, auto); + align-items: center; + gap: 28px; + min-height: 92px; + padding: 0 28px 0 0; + background: color-mix(in srgb, var(--surface) 94%, transparent); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(16px); +} + +.brand { + display: flex; + align-items: center; + align-self: stretch; + padding: 0 28px; + color: var(--text); + font-size: 28px; + font-weight: 800; + white-space: nowrap; + text-decoration: none; + border-right: 1px solid var(--line); +} + +.title-block h1, +.title-block p, +.updated-at, +.sources-heading h2, +.sources-heading p, +.source-card h3, +.source-card p { + margin: 0; +} + +.title-block h1 { + font-size: clamp(22px, 2vw, 30px); + line-height: 1.1; + letter-spacing: 0; +} + +.title-block p, +.updated-at, +.sources-heading p { + margin-top: 6px; + color: var(--muted); + font-size: 14px; +} + +.mode-switch { + display: inline-grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + min-width: 360px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; + background: var(--surface); + box-shadow: 0 10px 24px rgb(20 32 54 / 7%); +} + +.mode-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 42px; + padding: 0 22px; + color: var(--text); + font-weight: 750; + white-space: nowrap; + background: transparent; + border: 0; + border-radius: 7px; + transition: + background-color 140ms ease, + color 140ms ease, + box-shadow 140ms ease; +} + +.mode-button + .mode-button { + border-left: 0; +} + +.mode-button.is-active { + color: #ffffff; + background: var(--ink); + box-shadow: 0 8px 18px rgb(7 13 29 / 22%); +} + +.mode-button:not(.is-active):hover { + background: var(--button-hover); +} + +.header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; +} + +.updated-at::before { + content: ""; + display: inline-block; + width: 12px; + height: 12px; + margin-right: 8px; + vertical-align: -1px; + border: 2px solid var(--muted); + border-radius: 999px; +} + +.secondary-button, +.icon-button, +.profile-button, +.page-button, +.page-arrow { + border: 1px solid var(--line); + background: var(--button-bg); +} + +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 34px; + padding: 0 16px; + color: var(--text); + font-weight: 700; + border-radius: 6px; + white-space: nowrap; + box-shadow: 0 1px 0 rgb(20 40 70 / 4%); + transition: + background-color 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +.secondary-button:hover:not(:disabled), +.icon-button:hover:not(:disabled), +.page-button:hover:not(:disabled), +.page-arrow:hover:not(:disabled) { + background: var(--button-hover); + border-color: var(--line-strong); +} + +.secondary-button:active:not(:disabled), +.icon-button:active:not(:disabled), +.page-button:active:not(:disabled), +.page-arrow:active:not(:disabled) { + transform: translateY(1px); +} + +.snapshot-button { + min-height: 46px; + padding: 0 20px; +} + +.icon-button, +.profile-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 10px; + color: var(--text); + font-weight: 800; +} + +.button-icon { + position: relative; + display: inline-block; + flex: 0 0 auto; + width: 16px; + height: 16px; + color: currentColor; +} + +.reader-icon { + border: 2px solid currentColor; + border-radius: 3px; +} + +.reader-icon::before { + content: ""; + position: absolute; + inset: 3px auto 3px 6px; + border-left: 2px solid currentColor; +} + +.operator-icon::before, +.operator-icon::after { + content: ""; + position: absolute; + left: 1px; + width: 14px; + height: 2px; + background: currentColor; + border-radius: 999px; +} + +.operator-icon::before { + top: 4px; + box-shadow: 4px 5px 0 currentColor; +} + +.operator-icon::after { + top: 2px; + width: 2px; + height: 6px; + transform: translateX(8px); + box-shadow: -5px 7px 0 currentColor; +} + +.snapshot-icon { + border: 2px solid currentColor; + border-radius: 4px; +} + +.snapshot-icon::before { + content: ""; + position: absolute; + inset: 3px; + border: 2px solid currentColor; + border-radius: 999px; +} + +.snapshot-icon::after { + content: ""; + position: absolute; + top: -4px; + left: 4px; + width: 6px; + height: 3px; + background: currentColor; + border-radius: 2px 2px 0 0; +} + +.alert-icon::before { + content: ""; + position: absolute; + left: 7px; + top: 2px; + width: 2px; + height: 9px; + background: currentColor; + border-radius: 999px; +} + +.alert-icon::after { + content: ""; + position: absolute; + left: 7px; + bottom: 2px; + width: 2px; + height: 2px; + background: currentColor; + border-radius: 999px; +} + +.evidence-icon { + width: 17px; + border: 2px solid currentColor; + border-radius: 999px 999px 70% 70%; + transform: rotate(-8deg); +} + +.evidence-icon::before { + content: ""; + position: absolute; + left: 5px; + top: 4px; + width: 4px; + height: 4px; + background: currentColor; + border-radius: 999px; +} + +.filter-icon { + background: currentColor; + clip-path: polygon(1px 2px, 15px 2px, 10px 8px, 10px 14px, 6px 14px, 6px 8px); +} + +.profile-button { + color: #ffffff; + background: var(--ink); + border-color: var(--ink); + border-radius: 999px; +} + +.alert-button { + position: relative; +} + +.alert-count { + position: absolute; + top: -9px; + right: -7px; + display: inline-grid; + min-width: 22px; + height: 22px; + place-items: center; + padding: 0 5px; + color: #ffffff; + font-size: 12px; + font-weight: 800; + background: #ff524d; + border-radius: 999px; +} + +.dashboard-shell { + padding: 28px; +} + +.filters-panel { + display: grid; + grid-template-columns: minmax(260px, 1.25fr) minmax(190px, 0.9fr) minmax(250px, 1fr) minmax(210px, 0.9fr) minmax(170px, 0.75fr) minmax(170px, 0.75fr) minmax(150px, 0.65fr) auto auto; + gap: 14px; + align-items: center; + margin-bottom: 24px; +} + +.search-field, +.select-field { + display: grid; + align-items: center; + min-height: 46px; + padding: 0 14px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + box-shadow: 0 1px 0 rgb(20 40 70 / 4%); + transition: + background-color 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease; +} + +.search-field { + position: relative; + display: flex; +} + +.search-field::after { + content: ""; + position: absolute; + right: 16px; + width: 14px; + height: 14px; + border: 2px solid var(--muted-strong); + border-radius: 999px; + box-shadow: 7px 7px 0 -5px var(--muted-strong); +} + +.search-field input { + width: 100%; + min-width: 0; + padding: 0 32px 0 0; + color: var(--text); + border: 0; + outline: 0; +} + +.select-field { + position: relative; + grid-template-columns: auto auto minmax(0, 1fr); + gap: 9px; + padding: 0 34px 0 12px; +} + +.search-field:hover, +.select-field:hover, +.toggle-field:hover { + border-color: var(--line-strong); + background: #fbfdff; +} + +.search-field:focus-within, +.select-field:focus-within, +.toggle-field:focus-within { + border-color: color-mix(in srgb, var(--accent) 70%, var(--line)); + box-shadow: 0 0 0 3px rgb(29 111 242 / 12%); +} + +.select-field::after, +.page-size::after { + content: ""; + position: absolute; + top: 50%; + right: 14px; + width: 7px; + height: 7px; + border-right: 2px solid var(--muted-strong); + border-bottom: 2px solid var(--muted-strong); + pointer-events: none; + transform: translateY(-65%) rotate(45deg); +} + +.control-label { + flex: 0 0 auto; + color: var(--muted-strong); + font-weight: 750; + white-space: nowrap; +} + +.control-icon { + position: relative; + display: inline-block; + width: 16px; + height: 16px; + color: var(--muted-strong); +} + +.select-field select, +.page-size select { + width: 100%; + min-width: 0; + height: 44px; + padding: 0; + color: var(--text); + font-weight: 700; + text-overflow: ellipsis; + background: transparent; + border: 0; + outline: 0; + appearance: none; +} + +.compact-filter .control-label { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.calendar-icon { + border: 2px solid currentColor; + border-radius: 3px; +} + +.calendar-icon::before { + content: ""; + position: absolute; + left: 2px; + right: 2px; + top: 4px; + border-top: 2px solid currentColor; +} + +.source-icon { + border: 2px solid currentColor; + border-radius: 999px; +} + +.source-icon::before, +.source-icon::after { + content: ""; + position: absolute; + left: 2px; + right: 2px; + border-top: 2px solid currentColor; +} + +.source-icon::before { + top: 4px; +} + +.source-icon::after { + top: 9px; +} + +.topic-icon { + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-radius: 3px; + transform: rotate(45deg); +} + +.topic-icon::before { + content: ""; + position: absolute; + inset: 4px; + background: currentColor; + border-radius: 999px; +} + +.type-icon::before, +.type-icon::after { + content: ""; + position: absolute; + left: 1px; + width: 14px; + height: 7px; + border: 2px solid currentColor; + border-radius: 3px; +} + +.type-icon::before { + top: 1px; +} + +.type-icon::after { + bottom: 1px; +} + +.confidence-icon::before { + content: ""; + position: absolute; + left: 1px; + right: 1px; + bottom: 3px; + height: 8px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: skewX(-24deg); +} + +.confidence-icon::after { + content: ""; + position: absolute; + right: 1px; + top: 2px; + width: 5px; + height: 5px; + background: currentColor; + border-radius: 999px; +} + +.impact-icon::before { + content: ""; + position: absolute; + left: 1px; + right: 1px; + bottom: 3px; + height: 9px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; +} + +.impact-icon::after { + content: ""; + position: absolute; + left: 4px; + bottom: 5px; + width: 10px; + height: 8px; + border-top: 2px solid currentColor; + border-right: 2px solid currentColor; + transform: rotate(-38deg); +} + +.toggle-field { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 46px; + padding: 0 12px; + color: var(--text); + font-weight: 750; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + box-shadow: 0 1px 0 rgb(20 40 70 / 4%); + transition: + background-color 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease; +} + +.toggle-field input { + appearance: none; + position: relative; + width: 46px; + height: 24px; + margin: 0; + background: #c8d0dc; + border-radius: 999px; + transition: background-color 140ms ease; +} + +.toggle-field input::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + background: #ffffff; + border-radius: 999px; + transition: transform 160ms ease; +} + +.toggle-field input:checked { + background: var(--accent); +} + +.toggle-field input:checked::after { + transform: translateX(22px); +} + +.ranking-section, +.sources-section { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 7px; + box-shadow: 0 1px 0 rgb(20 40 70 / 4%); +} + +.section-heading { + min-height: 0; +} + +.section-heading p { + margin: 0; + padding: 0 16px; + color: var(--muted); + font-size: 0; +} + +.table-shell { + width: 100%; + overflow-x: auto; +} + +table { + width: 100%; + min-width: 1040px; + border-collapse: collapse; +} + +th, +td { + padding: 12px 14px; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} + +th { + color: var(--text); + font-size: 13px; + font-weight: 800; + background: var(--surface-muted); +} + +td { + font-size: 14px; +} + +.rank-cell { + width: 56px; + color: var(--text); + font-weight: 750; + text-align: center; +} + +.signal-cell { + min-width: 340px; +} + +.signal-cell strong { + display: block; + max-width: 720px; + line-height: 1.25; +} + +.signal-cell span, +.impact-cell span, +.recency-cell span { + display: block; + margin-top: 5px; + color: var(--muted); + font-size: 13px; +} + +.impact-cell { + min-width: 92px; + text-align: center; +} + +.impact-score { + display: block; + font-size: 27px; + line-height: 1; +} + +.impact-strong, +.impact-medium { + color: var(--green); +} + +.impact-low { + color: #157a68; +} + +.confidence-cell { + min-width: 150px; +} + +.confidence-value { + display: block; + margin-bottom: 8px; + font-weight: 800; +} + +.confidence-high { + color: var(--text); +} + +.confidence-medium { + color: #8f5a05; +} + +.confidence-low { + color: var(--red); +} + +.meter { + display: block; + width: 132px; + height: 8px; + overflow: hidden; + background: #eef2f7; + border-radius: 999px; +} + +.meter span { + display: block; + height: 100%; + background: var(--teal); + border-radius: inherit; +} + +.confidence-medium + .meter span { + background: var(--orange); +} + +.confidence-low + .meter span { + background: var(--red); +} + +.recency-cell { + min-width: 100px; + text-align: center; +} + +.sources-cell { + min-width: 150px; +} + +.source-stack { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.source-badge, +.source-more { + display: inline-grid; + min-width: 24px; + height: 24px; + place-items: center; + padding: 0 5px; + color: var(--text); + font-size: 12px; + font-weight: 900; + line-height: 1; + background: #ffffff; + border: 1px solid var(--line); + border-radius: 5px; +} + +.source-red { + color: #ff362f; +} + +.source-dark { + color: #ffffff; + background: #05070d; + border-color: #05070d; +} + +.source-blue { + color: #ffffff; + background: #2587e8; + border-color: #2587e8; +} + +.source-blue-muted { + color: #274268; + background: #e8eef9; +} + +.source-orange { + color: #ffffff; + background: #fb9b2c; + border-color: #fb9b2c; +} + +.source-green, +.source-green-dark { + color: #137a1d; + background: #ebf9ed; +} + +.source-green-dark { + color: #ffffff; + background: #5b9d18; + border-color: #5b9d18; +} + +.source-purple { + color: #ffffff; + background: #635bff; + border-color: #635bff; +} + +.source-navy { + color: #ffffff; + background: #164174; + border-color: #164174; +} + +.source-light { + color: var(--text); + background: #ffffff; +} + +.source-more { + color: var(--muted-strong); + background: #f1f5f9; +} + +.duplicate-cell { + min-width: 115px; +} + +.duplicate { + font-weight: 850; +} + +.duplicate-unique { + color: var(--green); +} + +.duplicate-possible { + color: #d36b00; +} + +.duplicate-possible::before { + content: ""; + display: inline-block; + width: 7px; + height: 12px; + margin-right: 6px; + vertical-align: -1px; + background: linear-gradient(#ffd6a0 0 45%, #ff8f1f 45% 100%); + border-radius: 999px; +} + +.actions-cell { + display: flex; + align-items: center; + gap: 6px; + min-width: 160px; +} + +.dots-button { + width: 28px; + height: 32px; + border-color: transparent; + background: transparent; +} + +.filters-button { + min-height: 46px; +} + +.evidence-button { + gap: 6px; + min-width: 128px; + padding: 0 10px; +} + +.dots-icon, +.dots-icon::before, +.dots-icon::after { + display: block; + width: 4px; + height: 4px; + background: currentColor; + border-radius: 999px; +} + +.dots-icon { + position: relative; +} + +.dots-icon::before, +.dots-icon::after { + content: ""; + position: absolute; + left: 0; +} + +.dots-icon::before { + top: -7px; +} + +.dots-icon::after { + top: 7px; +} + +.table-footer { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; + padding: 13px 18px; +} + +.table-footer p { + margin: 0; + color: var(--muted-strong); + font-weight: 650; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.page-button, +.page-arrow { + min-width: 34px; + height: 34px; + color: var(--text); + font-weight: 750; + border-radius: 6px; +} + +.page-button { + border-color: transparent; + background: transparent; +} + +.page-button.is-active { + color: var(--accent-strong); + background: #eef5ff; + border-color: var(--accent); +} + +.page-gap { + color: var(--muted); +} + +.page-size { + position: relative; + justify-self: end; + min-width: 150px; + padding: 9px 34px 9px 12px; + border: 1px solid var(--line); + border-radius: 6px; +} + +.page-size select { + height: auto; +} + +.sources-section { + margin-top: 20px; + padding: 16px; +} + +.sources-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.sources-heading h2 { + font-size: 18px; +} + +.sources-heading a { + color: var(--accent-strong); + font-size: 14px; + font-weight: 700; + text-decoration: none; +} + +.sources-grid { + display: grid; + grid-template-columns: repeat(9, minmax(130px, 1fr)); + gap: 10px; +} + +.source-card { + position: relative; + min-height: 102px; + padding: 12px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 6px; +} + +.source-card-header { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.source-card h3 { + min-width: 0; + overflow: hidden; + font-size: 13px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-metric { + display: flex; + align-items: baseline; + gap: 4px; + margin-top: 12px; +} + +.source-metric strong, +.source-card-summary strong { + font-size: 16px; +} + +.source-metric span, +.source-card-summary p { + color: var(--muted); + font-size: 13px; +} + +.sparkline { + position: absolute; + right: 10px; + bottom: 8px; + width: 118px; + height: 28px; +} + +.sparkline polyline { + fill: none; + stroke: #31b896; + stroke-width: 2.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.source-lag { + position: absolute; + right: 12px; + top: 58px; + color: var(--muted); + font-size: 12px; +} + +.source-card-summary { + display: grid; + align-content: start; + gap: 8px; +} + +.state-cell { + height: 280px; + text-align: center; +} + +.state-cell strong, +.state-cell span { + display: block; +} + +.state-cell span { + margin-top: 8px; + color: var(--muted); +} + +.state-cell-error strong { + color: var(--red); +} + +.skeleton-row td { + padding: 20px; +} + +.skeleton-line { + display: block; + width: 100%; + height: 42px; + background: linear-gradient(90deg, #eef2f6, #f8fafc, #eef2f6); + background-size: 240% 100%; + border-radius: 6px; + animation: shimmer 1.2s linear infinite; +} + +.no-results-row { + display: none; +} + +.no-results-row.is-visible { + display: table-row; +} + +.evidence-dialog { + width: min(640px, calc(100vw - 32px)); + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 28px 70px rgb(10 20 40 / 26%); +} + +.evidence-dialog::backdrop { + background: rgb(9 14 28 / 45%); +} + +.evidence-dialog form { + padding: 22px; +} + +.dialog-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.dialog-heading h2 { + margin: 0; + font-size: 20px; +} + +.evidence-dialog dl { + display: grid; + gap: 8px; + margin: 0; +} + +.evidence-dialog dt { + color: var(--muted); + font-size: 13px; + font-weight: 800; + text-transform: uppercase; +} + +.evidence-dialog dd { + margin: 0 0 12px; + line-height: 1.5; +} + +@keyframes shimmer { + from { + background-position: 120% 0; + } + to { + background-position: -120% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + +@media (max-width: 1560px) { + .filters-panel { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 1320px) { + .app-header { + grid-template-columns: 180px 1fr; + gap: 18px; + padding: 0 18px 16px 0; + } + + .brand { + padding: 0 22px; + font-size: 27px; + } + + .mode-switch, + .header-actions { + grid-column: 2; + justify-self: start; + } + + .sources-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 820px) { + .app-header { + position: static; + grid-template-columns: 1fr; + gap: 14px; + min-height: auto; + padding: 18px; + } + + .brand { + align-self: auto; + padding: 0; + border-right: 0; + } + + .mode-switch, + .header-actions { + grid-column: auto; + width: 100%; + } + + .mode-switch { + min-width: 0; + } + + .mode-button { + padding: 0 12px; + } + + .header-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .dashboard-shell { + padding: 16px; + } + + .filters-panel { + grid-template-columns: 1fr; + } + + .ranking-section { + border: 0; + background: transparent; + box-shadow: none; + } + + .table-shell { + overflow: visible; + } + + table { + min-width: 0; + } + + thead { + display: none; + } + + tbody, + tr, + td { + display: block; + width: 100%; + } + + tr { + margin-bottom: 12px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 7px; + box-shadow: 0 1px 0 rgb(20 40 70 / 4%); + } + + td { + display: grid; + grid-template-columns: minmax(88px, 35%) 1fr; + gap: 12px; + padding: 11px 14px; + border-bottom: 1px solid #edf1f6; + } + + td::before { + content: attr(data-label); + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + } + + td:last-child { + border-bottom: 0; + } + + .rank-cell, + .impact-cell, + .recency-cell { + text-align: left; + } + + .signal-cell, + .confidence-cell, + .sources-cell, + .duplicate-cell, + .actions-cell { + min-width: 0; + } + + .actions-cell { + align-items: center; + } + + .meter { + width: 100%; + } + + .table-footer { + grid-template-columns: 1fr; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 7px; + } + + .pagination { + justify-content: flex-start; + overflow-x: auto; + padding-bottom: 4px; + } + + .page-size { + justify-self: stretch; + } + + .sources-heading { + align-items: flex-start; + flex-direction: column; + } + + .sources-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .title-block h1 { + font-size: 24px; + } + + .updated-at { + width: 100%; + } + + .secondary-button { + padding: 0 12px; + } + + td { + grid-template-columns: 1fr; + gap: 6px; + } + + .source-stack { + flex-wrap: wrap; + } +} diff --git a/app/layout.js b/app/layout.js new file mode 100644 index 0000000..9dc2fad --- /dev/null +++ b/app/layout.js @@ -0,0 +1,17 @@ +import "./globals.css"; + +export const metadata = { + title: "AI Radar", + description: "Ranking operativo de senales de AI Radar", + icons: { + icon: "/favicon.svg", + }, +}; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} diff --git a/app/page.js b/app/page.js new file mode 100644 index 0000000..2d7a068 --- /dev/null +++ b/app/page.js @@ -0,0 +1,431 @@ +import Script from "next/script"; +import { loadDashboardData } from "../lib/dashboard.js"; + +export const dynamic = "force-dynamic"; + +const stateLabels = { + loading: "Cargando senales", + empty: "No hay senales para la ventana seleccionada", + error: "No se pudo cargar el ranking de senales", + success: "Ranking de senales cargado", +}; + +function scoreTone(score) { + if (score >= 80) { + return "strong"; + } + if (score >= 55) { + return "medium"; + } + return "low"; +} + +function duplicateLabel(status) { + return status === "possible" ? "Posible" : "Unico"; +} + +function sourceTone(tone) { + return `source-badge source-${tone}`; +} + +function sparklinePoints(values) { + const width = 118; + const height = 28; + const min = Math.min(...values); + const max = Math.max(...values); + const spread = max - min || 1; + + return values + .map((value, index) => { + const x = (index / (values.length - 1)) * width; + const y = height - ((value - min) / spread) * 18 - 5; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); +} + +function renderNoResultsRow() { + return ( + + + No hay resultados para esos filtros + Ajusta busqueda, impacto minimo, confianza o duplicados. + + + ); +} + +function renderTableBody(viewState, dashboard) { + if (viewState === "loading") { + return Array.from({ length: 5 }, (_, index) => ( + + + + + + )); + } + + if (viewState === "empty") { + return ( + + + No hay senales todavia + Conecta una API real o cambia a un fixture con senales declaradas. + + + ); + } + + if (viewState === "error") { + return ( + + + Error al cargar el ranking + El estado de error esta declarado para QA visual. Reintenta o revisa la fuente de datos. + + + ); + } + + return ( + <> + {dashboard.signals.map((signal) => ( + source.name.toLowerCase()).join(" ")} + > + + {signal.rank} + + + {signal.title} + {signal.category} + + + + {signal.impact_score} + + {signal.impact_label} + + + + {signal.confidence_score}% + +