~/pcn $ iniciando_

programaConNosotros

Iniciar sesiónCrear cuenta
  • programaConNosotrosprogramaConNosotrosComunidad · desde 2020
  • Inicio
  • Feed
Actividades
  • Eventos
  • Conversaciones
  • Foro
  • Consejos
  • Charlas
  • Podcast
  • Desarrolloaquí
  • Proyectos
Recursos
  • Cursos
  • Lectura
  • Videos
  • Especialidades
  • Herramientas
  • Entrevistas
Comunidad
  • Historia
  • Miembros
  • Logros
  • Galería
  • Setups
  • Partners
  • Changelog
  • Métricas
SoporteFeedback
  • iniciarSesion();Crear cuenta

~/desarrollo

open-source · cualquier persona puede contribuir

$ tree ~/desarrollo

01/54
    1. 01Arquitectura
    2. 02Diagramas de arquitectura
  1. ▸diagramas/5

    1. 03Arquitectura lógica
    2. 04Arquitectura física
    3. 05Arquitectura de despliegue
    4. 06Entorno de desarrollo local
    5. 07Flujo de una petición
    1. 08Diseño UX/UI
    2. 09Tecnologías
    3. 10Cómo contribuir
    4. 11Decisiones (ADRs)
    5. 12Base de datos
    6. 13Notas del stack
  2. ▸notas/framework/5

    1. 14Next.js · App Router
    2. 15React Server Components
    3. 16Streaming y Suspense
    4. 17Server Actions
    5. 18Route Handlers y Proxy
  3. ▸notas/frontend/10

    1. 19React 19
    2. 20TypeScript
    3. 21Tailwind CSS
    4. 22shadcn/ui + Radix
    5. 23Zod + React Hook Form
    6. 24TanStack Query
    7. 25TanStack Table
    8. 26Fechas: Intl y date-fns
    9. 27Motion + Embla Carousel
    10. 28next/font · next-themes · Sonner
  4. ▸notas/datos/7

    1. 29Prisma
    2. 30Cache de datos
    3. 31PostgreSQL
    4. 32Autenticación propia
    5. 33AWS S3 + CloudFront
    6. 34Resend + React Email + MailHog
    7. 35GitHub REST API
  5. ▸notas/calidad/3

    1. 36Jest
    2. 37Playwright
    3. 38ESLint · Prettier · Husky
  6. ▸notas/infra/3

    1. 39Docker y Docker Compose
    2. 40Kamal + GitHub Actions
    3. 41pnpm · portless · worktrees
  7. ▸notas/modulos/7

    1. 42Galería · fotos y videos optimizados
    2. 43Eventos · variantes, permisos y calendario
    3. 44Eventos · inscripciones
    4. 45PCN OS · un escritorio hecho de iframes
    5. 46Rendimiento · primer paint de la home
    6. 47Cursor hacker
    7. 48PWA · pull to refresh
    1. 49Herramientas
    2. 50Convenciones
    3. 51Testing y calidad
    4. 52Estadísticas
    5. 53Team de desarrollo
    6. 54Por qué contribuir
LEYENDOíndice0%

## Arquitectura del proyecto

App Router & páginas
src/app — App Router de Next.js: el grupo (platform) con las secciones de la comunidad, autenticacion para login y registro, api para los route handlers, y archivos especiales como sitemap.ts, robots.ts, feed.xml y [shortcut] (atajos como /cowork que llevan al próximo evento)
Server actions
src/actions — lógica de servidor agrupada por dominio: auth, events, gallery, talks, talk-proposals, articles, comments, notifications, badges, users y más
Base de datos
Prisma ORM sobre PostgreSQL; esquema en prisma/schema.prisma con más de 70 migraciones versionadas
Validación
Zod — schemas en src/schemas, reutilizados tanto en forms del cliente como en server actions
Componentes & UI
src/components — componentes React agrupados por feature; src/components/ui contiene la librería shadcn/ui
Utilidades
src/lib — funciones compartidas: Prisma client, S3 y firma de CloudFront, procesamiento de fotos, email, calendario (ICS y Google Calendar), permisos de eventos, rate limiting, índice de búsqueda e imágenes de Open Graph
Hooks y contenido
src/hooks — custom hooks de React; src/data — contenido estático versionado: changelog, preguntas frecuentes, partners y conversaciones
Deploy
Kamal vía GitHub Actions — cada push a main aplica las migraciones pendientes y despliega automáticamente a producción

## Diagramas de arquitectura

El mismo sistema visto desde 5 ángulos: cómo se reparte la lógica, dónde corre cada pieza, cómo llega un cambio a producción, cómo se levanta en tu máquina y cómo colaboran las piezas en una petición real. Debajo de cada diagrama está explicado cada componente.

### Arquitectura lógica

Cómo se reparte la responsabilidad dentro de la aplicación, sin importar dónde corre. Es un monolito de Next.js en capas: la interfaz nunca habla directo con la base, siempre pasa por server actions o route handlers, que validan, chequean permisos y recién ahí usan Prisma.

arquitectura lógica100%

$ dibujando el diagrama…

Páginas (Server Components)
La mayoría de las páginas se renderizan en el servidor: consultan los datos ahí mismo y mandan HTML al navegador. No exponen ninguna API ni envían JavaScript para la parte que no es interactiva.
Client Components
Las partes interactivas (formularios, filtros, el diagrama de la base, PCN OS) llevan "use client". Usan shadcn/ui sobre Radix para la UI, react-hook-form + Zod para formularios y React Query para cachear datos que se piden desde el cliente.
Service worker (PWA)
public/sw.js, registrado por pwa-provider.tsx. Permite instalar PCN como app y muestra /offline cuando no hay conexión.
App Router
src/app define las rutas por carpetas: el grupo (platform) con las secciones de la comunidad, autenticacion, api y archivos especiales (sitemap, robots, opengraph-image, [shortcut]). Los layouts comparten la barra lateral, el tema y los providers.
Server actions
src/actions, agrupadas por dominio (auth, events, gallery, talks, comments…). Son la única puerta de escritura: cada una aplica el rate limit, lee la sesión de la cookie, chequea el rol o el dueño del recurso, valida la entrada y después escribe. Al terminar llaman a revalidatePath para refrescar las páginas afectadas.
Route handlers
Endpoints HTTP para lo que no es un formulario: /api/search (búsqueda global), /api/galeria (fotos aleatorias y detalle), los embeds de /lectura y /proyectos, /feed.xml (RSS) y /up, el healthcheck que usa el deploy.
Validación Zod
Los schemas de src/schemas se comparten entre el formulario del cliente y la server action: el usuario ve el error antes de enviar, y el servidor vuelve a validar porque al cliente no se le cree nada.
Dominio y utilidades
src/lib concentra la lógica reutilizable: sesiones (session.ts), permisos de eventos, lista de espera, rate limiting en memoria, procesamiento de fotos con sharp, S3 y firma de CloudFront, emails, calendario (ICS y Google Calendar), índice de búsqueda y logros.
Contenido versionado
Lo que cambia poco vive en el repo y no en la base: changelog, preguntas frecuentes, partners, conversaciones y las estadísticas de GitHub (src/data/github-stats.json). Se edita con una PR y se publica con el deploy. Las recomendaciones (artículos, libros, cursos, videos y charlas externas) pasaron a la base porque las proponen los miembros.
Prisma Client
Un único cliente compartido (singleton) que por defecto omite datos sensibles, como el hash de la contraseña y el teléfono de los oradores, para que no lleguen nunca a un componente por accidente.
PostgreSQL
La fuente de verdad de todo lo que crea la comunidad: usuarios, sesiones, eventos, inscripciones, charlas, galería, comentarios, notificaciones, recomendaciones (artículos, libros, cursos y videos, con revisión de admins), visitas y logs de errores. Más abajo está el diagrama completo de entidades.
S3 y CloudFront
S3 guarda los archivos subidos (flyers, fotos de perfil, logos, galería) y CloudFront los sirve desde la CDN. La galería es privada: solo se ve con URLs que el servidor firma al renderizar.
Email
Los emails transaccionales (código de verificación, recuperar clave, avisos de eventos) se arman con React Email y se envían por la API de Resend en producción y por SMTP a MailHog en local (nodemailer).

### Arquitectura física

Dónde corre cada pieza en producción. Todo vive en AWS: el tráfico de la app entra por un único servidor; los archivos pesados no pasan por él, van directo entre el navegador y S3/CloudFront.

arquitectura física100%

$ dibujando el diagrama…

Navegador / PWA
El sitio funciona en cualquier navegador y se puede instalar como app. Pide el HTML al servidor y las imágenes a CloudFront.
DNS
programaconnosotros.com apunta a la IP pública del servidor EC2.
Servidor EC2
Una única máquina Ubuntu en AWS (us-east-2) con Docker, configurada en config/deploy.yml. Como hay un solo proceso, cosas como el rate limiting se guardan en memoria sin necesidad de Redis.
kamal-proxy
El reverse proxy que instala Kamal delante de la app. Termina HTTPS con certificados de Let’s Encrypt que renueva solo y reenvía el tráfico al puerto 3000 del contenedor activo.
Contenedor pcn-website
La imagen construida con Dockerfile.prod (Node 24, pnpm build) que corre next start. Recibe la configuración (base, AWS, Resend) como variables de entorno secretas.
PostgreSQL
La base de producción es un PostgreSQL propio en una instancia EC2 de AWS, fuera del contenedor de la app: el contenedor se puede reemplazar en cada deploy sin tocar los datos. Prisma se conecta con una URL que llega como secreto, y el pipeline de deploy aplica las migraciones con su propia URL (DIRECT_URL).
Bucket S3
Guarda los archivos subidos. El navegador sube directo con una URL o un formulario prefirmados de vida corta, así un video grande no ocupa memoria ni ancho de banda del servidor.
CloudFront
La CDN delante del bucket: entrega flyers, avatares y galería desde un servidor cercano al visitante. Las fotos de la galería se guardan como inmutables, así que se cachean un año sin invalidar nada.
ECR
Amazon Elastic Container Registry: donde el pipeline sube cada imagen nueva y de donde el servidor la descarga al desplegar.
Resend
El servicio que manda los emails de producción por API, desde el dominio verificado del sitio (RESEND_API_KEY).
GitHub
Aloja el código y corre el pipeline de deploy. Además, pnpm github:stats consulta su API para regenerar el snapshot de estadísticas; el sitio nunca llama a GitHub en tiempo de render.

### Arquitectura de despliegue

El camino de un cambio desde tu branch hasta producción. Nada se despliega a mano: mergear a main dispara todo, y la versión vieja sigue atendiendo hasta que la nueva responde bien.

arquitectura de despliegue100%

$ dibujando el diagrama…

Tu branch
Cada cambio arranca en una branch propia. Husky corre lint, format check, tests y build antes de cada push: si algo falla, no sale de tu máquina.
PR hacia testing
La PR se revisa contra testing, nunca contra main. Si cambia la UI, lleva capturas generadas con pnpm screenshot.
branch testing
Junta los cambios aprobados. Cuando el equipo decide publicar, mergea testing a main.
GitHub Actions
El workflow deployment.yml corre en cada push a main (o a mano con workflow_dispatch). Arma el entorno con Node 24, pnpm, Ruby y Kamal, y carga los secretos del repo como variables.
prisma migrate deploy
Aplica a la base de producción las migraciones de prisma/migrations que todavía no corrieron, antes de que la versión nueva arranque. Por eso cada migración tiene que ser compatible con el código que está corriendo en ese momento.
kamal build
Construye la imagen amd64 con Docker Buildx a partir de Dockerfile.prod, reutilizando la caché de GitHub Actions, y la sube a ECR.
kamal deploy
Se conecta por SSH al servidor, descarga la imagen y arranca un contenedor nuevo al lado del viejo.
Healthcheck /up
kamal-proxy le pega a /up (src/app/up/route.ts) hasta que responde 200. Recién ahí manda el tráfico al contenedor nuevo y apaga el viejo: deploy sin downtime. Si nunca responde, el deploy falla y producción sigue con la versión anterior.

### Entorno de desarrollo local

Lo mismo que producción, pero en tu máquina y con reemplazos locales: Postgres en Docker en vez de la base de producción y MailHog en vez de Resend. Hay dos formas de levantarlo.

entorno de desarrollo local100%

$ dibujando el diagrama…

web
Contenedor con next dev y el código montado como volumen: los cambios se ven al instante. Aplica las migraciones al arrancar y espera a que la base esté sana.
database
Postgres 13 con healthcheck (pg_isready). Con pnpm populate-database se carga con datos de prueba.
mailhog
Un servidor SMTP falso que atrapa todos los emails. Se leen en http://localhost:18025, así podés probar la verificación de cuenta sin mandar nada real.
pnpm dev + portless
La alternativa sin el contenedor web: corre next dev en tu máquina detrás de portless, que le da una URL estable por HTTPS. Cada worktree tiene su propia URL (<branch>.pcn-website.localhost) y su propia base, así podés tener varias branches levantadas sin choques de puertos.
S3 de desarrollo
Subir archivos necesita credenciales de AWS en el .env. Sin ellas, el resto del sitio funciona igual.

### Flujo de una petición

Cómo colaboran las piezas en dos casos reales: abrir una página y subir una foto a la galería. El segundo muestra por qué los archivos van directo a S3 y no a través del servidor.

flujo de una petición100%

$ dibujando el diagrama…

Sesión
La cookie sessionId lleva un token aleatorio; la base guarda solo su hash SHA-256. Cada request la resuelve con findSession, que además descarta las sesiones vencidas.
Render en el servidor
La página consulta la base con Prisma y devuelve HTML listo. Las URLs de la galería se firman en ese momento y valen hasta el final de la hora siguiente, así la misma foto conserva la URL y se cachea.
CloudFront como caché
La primera vez trae el archivo de S3; las siguientes lo entrega desde la CDN sin tocar ni el servidor ni el bucket.
Subida directa a S3
El servidor solo firma un permiso de subida de vida corta para una clave y un tipo de archivo concretos. El archivo viaja del navegador a S3 sin pasar por Next.js. Los videos, además, se optimizan en el propio navegador con mediabunny antes de subirse.
Procesamiento con sharp
createPhoto descarga el original, lo rota según el EXIF y genera una versión de hasta 2560px y una miniatura de 640px en WebP. El original se borra: lo que queda guardado ya está optimizado.
Revalidación
Después de escribir, la server action llama a revalidatePath para que la próxima visita a la galería vea la foto nueva.

## Diseño UX/UI y design system

El sitio está hecho para gente súper nerd, apasionada por el software: estética de terminal, densidad antes que aire, un solo acento verde y el teclado como ciudadano de primera. Contamos cómo llegamos a esta estética, los principios que seguimos, los tokens y cada componente renderizado en vivo con todos sus estados.

~/desarrollo/diseno →

## Tecnologías que usamos

  • Next.js
  • React
  • TypeScript
  • Tailwind CSS
  • Prisma
  • PostgreSQL
  • Docker
  • Kamal
  • AWS
  • Git
  • GitHub

También usamos shadcn/ui para los componentes de interfaz. Leé cómo usamos cada una ↓

## Cómo contribuir

  1. 01Instalar Docker y Docker Compose
  2. 02Clonar el repositorio desde GitHub
  3. 03Crear el archivo .env usando .env.template como base
  4. 04Levantar todo con docker-compose up -d: Postgres, MailHog (localhost:18025) y la web en localhost:3000, que aplica las migraciones al arrancar
  5. 05Alternativa sin el contenedor web (Node 24+): pnpm install, docker-compose up -d database mailhog y pnpm dev, que sirve el sitio en https://pcn-website.localhost vía portless
  6. 06Opcional: si usás VS Code, abrí el proyecto con Dev Containers para desarrollar dentro del contenedor
  7. 07Cuando bajes migraciones nuevas, aplicalas con make apply-migrations (o pnpm apply-migrations)
  8. 08Opcional: poblar la base de datos con datos de prueba ejecutando pnpm populate-database
  9. 09Crear una branch, hacer los cambios y enviar una PR hacia testing; si cambia la UI, sumá capturas con pnpm screenshot

Charlamos el desarrollo del sitio en un grupo de WhatsApp ↗. No hace falta que vayas a programar: podés sumarte a leer lo que hablamos si te sirve, o preguntar lo que quieras.

## Decisiones de arquitectura (9 ADRs)

Por qué el sitio está hecho como está: cada ADR cuenta el problema, lo que se decidió, lo que cuesta y las alternativas que se descartaron. Si querés cambiar una de estas decisiones, abrí un issue proponiendo un ADR nuevo que la reemplace.

▸ADR-001Next.js App Router con Server Components y Server Actions, sin API aparteaceptada2025-03-01

contexto

El sitio lo mantiene una comunidad de voluntarios con distinta experiencia. Separar frontend y backend en dos proyectos duplica el deploy, los tipos y la autenticación, y frena a quien quiere hacer su primer PR de punta a punta.

decisión

Una sola app Next.js con App Router. Las páginas son Server Components que leen la base directamente (a través de cached()), y las escrituras son Server Actions en src/actions. Solo hay route handlers donde hace falta una URL pública: búsqueda, feed RSS, imágenes de OpenGraph, notificaciones y embeds.

consecuencias

  • ›Un PR puede tocar la base, la lógica y la UI en el mismo cambio, con los tipos de Prisma de punta a punta.
  • ›Cada Server Action es un endpoint público: tiene que chequear sesión o permisos ella misma y validar su entrada con zod. Lo exige src/lib/server-action-auth.test.ts.
  • ›El sitio queda atado a las convenciones de Next.js: cada versión mayor obliga a revisar caché, params asíncronos y metadata.

descartado

  • ›SPA + API REST: más piezas para desplegar y versionar, y la API terminaría siendo usada por un solo cliente.
  • ›tRPC: buena experiencia de tipos, pero agrega una capa que las Server Actions ya resuelven.

ver

src/actionsdocs/seguridad-owasp.md
▸ADR-002PostgreSQL propio en EC2, accedido solo con Prismaaceptada2026-06-01

contexto

La base empezó en Supabase. El plan gratuito pausaba el proyecto, el pooler sumaba latencia y no usábamos nada más de la plataforma (ni auth ni storage). La app ya corría en EC2.

decisión

PostgreSQL autoadministrado en una instancia EC2. La app habla con la base solo a través de Prisma 7 con @prisma/adapter-pg (o $queryRaw con template tags), nunca con SQL armado a mano. Las migraciones son SQL versionado en prisma/migrations y el deploy aplica las pendientes.

consecuencias

  • ›Control total de la versión, extensiones, backups e índices, y sin latencia de un pooler externo.
  • ›Los backups y las actualizaciones de seguridad del servidor son responsabilidad del equipo.
  • ›ESLint y src/lib/sql-safety.test.ts impiden SQL concatenado (OWASP A03).

descartado

  • ›Seguir en Supabase: más caro para lo poco que usábamos.
  • ›RDS: menos mantenimiento, pero varias veces más caro para el tamaño de la comunidad.

ver

prisma/schema.prismadocs/migracion-prisma-7.md
▸ADR-003Deploy con Kamal en EC2 detrás de CloudFront, no en Vercelaceptada2026-07-01

contexto

Vercel es lo más simple para Next.js, pero factura por uso y por integrante del equipo, y la base y los archivos ya están en AWS. Con pocos ingresos, el costo fijo y previsible pesa más que la comodidad.

decisión

Cada merge a main construye una imagen Docker (Dockerfile.prod) en GitHub Actions y Kamal la despliega en un EC2 sin downtime. CloudFront queda delante como CDN: cachea /_next/static y las imágenes, y llega a la app como origin.programaconnosotros.com.

consecuencias

  • ›Costo fijo y bajo; todo en la misma región que la base y S3.
  • ›Las funciones de Vercel (previews por PR, edge, ISR distribuido) no están: los previews se reemplazan con screenshots en el PR.
  • ›Si la comunidad crece en contribuidores, la decisión se revisa (plan: evaluar Vercel hacia fines de 2026).

ver

config/deploy.yml.github/workflows/deployment.yml
▸ADR-004Las ventanas de PCN OS son iframes de páginas reales (revisada: seguimos con iframes)aceptada2026-10-07

contexto

PCN OS muestra el sitio como un escritorio con ventanas. Cada ventana es un iframe que carga la URL real, y cada iframe es una copia entera de la app (React, Next.js, providers), lo más caro del escritorio. Se evaluó reemplazarlos por componentes renderizados directamente en el árbol del escritorio para gastar menos memoria y CPU.

decisión

Seguimos con iframes. Renderizar páginas como componentes no es viable sin reescribir el sitio: el costo se ataca con el modo liviano (máximo 3 ventanas vivas, el resto en pausa), el modo clásico y ventanas minimizadas que dejan de pintarse.

consecuencias

  • ›Cada ventana sigue siendo idéntica a la página en mobile o en el layout clásico, con su propio scroll, diálogos y layout responsive al ancho de la ventana.
  • ›El consumo crece con cada ventana abierta: el modo liviano y la medición automática de frames siguen siendo necesarios.
  • ›Las ventanas minimizadas quedan con visibility: hidden cuando termina la animación: el navegador deja de pintarlas y componerlas.

descartado

  • ›Componentes en el mismo documento: Next.js renderiza un solo árbol de rutas por documento. Las páginas son Server Components que leen datos por ruta, y no hay forma soportada de renderizar N rutas arbitrarias a la vez (las parallel routes tienen slots fijos). Habría que pasar cada página a un componente cliente con su propio fetch.
  • ›El diseño responsive de cada página usa media queries del viewport (md:, lg:): dentro de una ventana angosta en una pantalla ancha se verían rotas. Habría que migrar todo a container queries.
  • ›Diálogos, sheets, scroll lock, sticky headers, atajos de teclado y usePathname son globales al documento: varias páginas a la vez se pisarían. Habría que aislar router, portales y scroll por ventana.
  • ›Conclusión: es reescribir el sitio entero y perder la paridad con mobile, para ahorrar memoria que el modo liviano ya ahorra.

ver

docs/pcn-os-y-rendimiento.mdsrc/components/os
▸ADR-005Cache de lecturas con invalidación automática en cada escrituraaceptada2026-09-15

contexto

Casi todo lo que muestra el sitio es igual para todos y cambia poco, pero cada página vista volvía a consultar Postgres. Invalidar a mano en cada Server Action es fácil de olvidar y deja datos viejos.

decisión

Las lecturas compartidas pasan por cached() (src/lib/cache.ts), que declara qué tablas lee. Una extensión de Prisma vence esos tags en cada escritura a esas tablas, así nadie invalida a mano. No se cachean URLs firmadas ni lo que depende de la hora actual.

consecuencias

  • ›La mayoría de las páginas se sirven sin tocar la base.
  • ›Si una lectura usa una tabla que no declaró, en desarrollo se loguea [cache] <name> reads <Model>.
  • ›Las escrituras con SQL crudo no invalidan solas: hay que evitarlas o vencer el tag a mano.

ver

src/lib/cache.tssrc/lib/prisma.tsdocs/cache-de-datos.md
▸ADR-006El contenido curado vive en el repo, salvo las recomendacionesaceptada2025-06-01

contexto

Las conversaciones del grupo, el changelog y otros textos curados los escriben pocas personas y cambian por PR. Guardarlos en la base exige paneles de admin y migraciones de datos.

decisión

Ese contenido son archivos TypeScript y JSON versionados (src/data, conversaciones…). La base guarda lo que generan los usuarios y referencia el contenido del repo por id, sin clave foránea (ContentMark). Desde octubre de 2026 las recomendaciones (artículos, libros, cursos y videos) son la excepción: cualquier miembro las propone, así que viven en la tabla Recommendation con revisión de admins en /admin/recomendaciones, y las listas del repo se cargaron con una migración de datos que mantuvo sus ids.

consecuencias

  • ›Agregar una conversación o un cambio del changelog es un PR revisable, con historia en git y sin panel de admin.
  • ›Los ids del repo no se pueden reutilizar: la base podría tener marcas que apunten a ellos. Lo mismo vale para el slug de una recomendación.
  • ›Cambiar el contenido del repo requiere un deploy; una recomendación aprobada aparece al instante.
▸ADR-007Fotos en S3 con versión completa y miniatura, servidas por CloudFront firmadoaceptada2026-04-01

contexto

La galería y los setups tienen miles de fotos de celular de varios MB. Servirlas desde la app satura el servidor y algunas no son públicas.

decisión

El navegador sube el original directo a S3 con un POST prefirmado. El servidor lo optimiza con sharp a WebP en dos tamaños (completo y miniatura), borra el original y guarda ambas URLs. Las grillas usan la miniatura y el detalle la completa; las privadas se sirven con URLs firmadas de CloudFront.

consecuencias

  • ›Las subidas no pasan por el servidor de la app y las grillas pesan una fracción.
  • ›Las URLs firmadas vencen: nunca se cachean dentro de cached().

ver

src/lib/s3.tssrc/lib/gallery-signing.ts
▸ADR-008Los tests corren en la máquina de quien contribuye, no en CIaceptada2026-05-01

contexto

Las corridas de CI cuestan minutos de GitHub Actions y la suite completa (unitarios, build, integración con base y e2e) tarda. La mayoría de los PRs son chicos.

decisión

Husky corre lint, formato, tests y build antes de cada push. pnpm test:db corre la integración contra un Postgres local descartable y pnpm test:e2e (Playwright) se corre a mano. GitHub Actions solo construye y despliega.

consecuencias

  • ›El feedback llega antes de abrir el PR y CI es barato.
  • ›Saltear el hook (--no-verify) deja pasar código roto: la revisión del PR lo tiene que notar.

ver

.husky/pre-pushjest.db.config.mjsplaywright.config.ts
▸ADR-009Emails transaccionales con Resendaceptada2026-08-01

contexto

Los emails salían por SMTP de una casilla de Gmail: límites diarios bajos, caídas en spam y una cuenta que ya no es del equipo.

decisión

Los emails se mandan con la API de Resend desde un dominio propio verificado. Las plantillas son componentes de React Email con la estética del sitio.

consecuencias

  • ›Mejor entregabilidad y métricas de envío.
  • ›Una dependencia externa más con su API key. En desarrollo los emails van por SMTP a MailHog.

## Diseño de la base de datos

PostgreSQL con Prisma: 46 modelos y 68 relaciones, definidos en prisma/schema.prisma. Estas son las decisiones que dan forma al esquema, y abajo el diagrama completo de entidades y relaciones.

Identificadores
Todas las tablas usan un id de texto generado con cuid(): no revela cuántas filas hay ni en qué orden se crearon, y se puede generar sin consultar la base.
User en el centro
Casi todo cuelga de un usuario: 42 de las 68 relaciones apuntan a User (autor de un consejo, inscripto a un evento, orador de una charla, quien subió una foto…).
Tablas intermedias
Las relaciones muchos a muchos tienen su propia tabla con una restricción única compuesta, así no se puede, por ejemplo, inscribir dos veces a la misma persona: EventOrganizer, GalleryItemTag, SetupLike, Like, EventRegistration, EventWaitlistEntry, UserBadge, ForumPostLike.
Qué pasa al borrar
Cascade cuando la fila no tiene sentido sin su padre (likes, inscripciones, sesiones); SetNull cuando la historia tiene que sobrevivir aunque se borre el usuario (fotos subidas, oradores de charlas, logs de errores).
Gente sin cuenta
Los oradores de charlas y propuestas y los miembros de proyectos tienen userId opcional y guardan su nombre aparte: una charla puede tener un orador que no tiene cuenta en el sitio.
Recomendaciones
Artículos, libros, cursos y videos (las charlas externas son videos con isTalk) comparten la tabla Recommendation, con un kind y un status (PENDING, APPROVED, REJECTED) para la revisión de admins. El slug es único por kind y conserva los ids que tenían en el repo, así ArticleAuthor y ContentMark los siguen referenciando por id (articleId, contentType + contentId) sin clave foránea.
Enums
Role (REGULAR, ADMIN) · GalleryItemKind (PHOTO, VIDEO) · GalleryItemStatus (PENDING, APPROVED) · TalkProposalStatus (PENDING, ACCEPTED, REJECTED) · RecommendationKind (ARTICLE, BOOK, COURSE, VIDEO) · RecommendationStatus (PENDING, APPROVED, REJECTED)
Migraciones
Cada cambio al esquema es una migración SQL versionada en prisma/migrations; el deploy aplica las pendientes antes de levantar la versión nueva.
46 modelos · 68 relaciones100%

$ dibujando el diagrama…

$ pnpm db:diagram # regenera el diagrama desde el schema · última actualización: 9 de octubre de 2026

## Notas teóricas del stack (35)

Una guía para aprender con este proyecto: qué es cada tecnología, los conceptos que tenés que conocer y cómo la usamos acá, con fragmentos reales del código. Al final, cómo funcionan por dentro módulos del sitio como la galería y los eventos. Tocá el nombre del archivo de cada ejemplo para leerlo completo en GitHub.

$ ls notas/framework # framework

›Next.js · App Routerel sistema de archivos es el router2 ejemplos

# qué es

Next.js es un framework de React que suma lo que React solo no trae: ruteo, renderizado en el servidor, caché, optimización de imágenes y fuentes, y un build listo para producción. El App Router (la carpeta app/) arma las rutas a partir de carpetas: cada carpeta es un segmento de la URL y ciertos archivos con nombre especial definen qué se renderiza en ese segmento.

# conceptos clave

page.tsx
Hace que la carpeta sea una ruta pública. Sin page.tsx la carpeta existe pero no se puede visitar.
layout.tsx
UI compartida que envuelve a todas las páginas de abajo y no se vuelve a montar al navegar entre ellas (por eso el sidebar no parpadea).
loading.tsx
Skeleton que Next muestra al instante mientras la página se resuelve en el servidor. Por dentro es un <Suspense> automático.
(grupo)
Una carpeta entre paréntesis agrupa rutas para compartir un layout sin agregar nada a la URL: (platform)/desarrollo responde en /desarrollo.
[param]
Segmento dinámico: consejos/[id] atiende /consejos/abc123 y recibe { id: "abc123" } en params.
archivos de metadata
sitemap.ts, robots.ts y opengraph-image.tsx generan /sitemap.xml, /robots.txt y la imagen que se ve al compartir el link.

# cómo lo usamos acá

Todo el sitio vive en src/app. Las secciones de la comunidad están dentro del grupo (platform), que comparte el layout con sidebar, navegación mobile y PCN OS. Las pantallas de login y registro están en autenticacion/, que tiene su propio layout más simple.

Casi todas las páginas exportan metadata (o generateMetadata si dependen de la base) para el título, la descripción y las tarjetas de Open Graph, y muchas tienen su opengraph-image.tsx que dibuja una tarjeta con estética de terminal.

# ejemplos del repo

~/src/apptree

Un recorte del árbol de rutas y qué URL genera cada archivo.

src/app/
├── layout.tsx               # layout raíz: <html>, fuentes, providers
├── (platform)/              # grupo: comparte sidebar, no aparece en la URL
│   ├── layout.tsx
│   ├── desarrollo/
│   │   ├── page.tsx         # → /desarrollo
│   │   ├── loading.tsx      # skeleton mientras carga
│   │   └── opengraph-image.tsx
│   └── consejos/[id]/
│       └── page.tsx         # → /consejos/:id
├── autenticacion/           # → /autenticacion/iniciar-sesion, /registro…
├── api/search/route.ts      # → GET /api/search?q=…
├── sitemap.ts               # → /sitemap.xml
└── robots.ts                # → /robots.txt
~/src/app/(platform)/consejos/[id]/opengraph-image.tsxtsx

La imagen para redes de cada consejo se genera con los datos del consejo. params es una Promise en Next 15+.

export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export const alt = 'Consejo de la comunidad programaConNosotros';

export default async function Image({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const advice = await prisma.advice.findUnique({
    where: { id },
    select: { content: true, author: { select: { name: true } } },
  });

  return renderTerminalCard({
    path: 'consejos',
    command: advice ? `fortune --from "${advice.author.name}"` : 'fortune',
    title: advice ? `“${advice.content}”` : 'Consejos de la comunidad',
    meta: advice ? [`@${advice.author.name}`] : [],
  });
}
$ man nextjs-app-router → documentación oficial ↗
›React Server Componentscomponentes que corren solo en el servidor1 ejemplo

# qué es

En el App Router todo componente es, por defecto, un Server Component: se ejecuta en el servidor, puede ser async, puede leer la base de datos o secretos directamente, y al navegador le llega solo el HTML resultante (su código no se suma al bundle de JavaScript). Cuando necesitás estado, efectos o eventos del navegador, marcás el archivo con "use client" y pasa a ser un Client Component.

# conceptos clave

async components
Un Server Component puede hacer await adentro del cuerpo: no hace falta useEffect ni un endpoint intermedio para traer datos.
"use client"
Marca la frontera: ese archivo y todo lo que importe se manda al navegador. Conviene poner la frontera lo más abajo posible del árbol.
props serializables
De servidor a cliente solo pasan datos que se puedan serializar (strings, números, objetos planos, fechas, JSX). Funciones no, salvo server actions.
APIs dinámicas
cookies(), headers() y params son asíncronas desde Next 15: siempre van con await.

# cómo lo usamos acá

Las páginas leen la base con Prisma directamente desde el componente. Por ejemplo, la página de un consejo busca el consejo y su autor sin pasar por una API.

Los componentes interactivos (formularios, el buscador, PCN OS, el cursor hacker) son Client Components, y las páginas los usan como hojas del árbol.

# ejemplos del repo

~/src/app/(platform)/consejos/[id]/page.tsxtsx

generateMetadata corre en el servidor, espera params y consulta la base para armar el título de la pestaña.

export async function generateMetadata(props: {
  params: Promise<{ id: string }>;
}): Promise<Metadata> {
  const params = await props.params;
  const advice = await prisma.advice.findUnique({
    where: { id: params.id },
    select: {
      content: true,
      author: {
        select: {
          name: true,
        },
      },
    },
  });
  // …
}

export default async function AdvicePage(props: { params: Promise<{ id: string }> }) {
  const params = await props.params;
  const sessionId = (await cookies()).get('sessionId');
  // …
}
$ man server-components → documentación oficial ↗
›Streaming y Suspensemandar la página por partes2 ejemplos

# qué es

Con streaming, el servidor manda el HTML a medida que lo tiene listo en vez de esperar a que termine todo. <Suspense> marca una parte que puede tardar: mientras se resuelve se muestra el fallback y, cuando los datos llegan, React reemplaza el skeleton por el contenido sin recargar.

# conceptos clave

<Suspense fallback>
Envuelve un componente async lento. El resto de la página se ve enseguida; solo esa parte espera.
loading.tsx
Un Suspense a nivel de ruta: cubre la página entera mientras se resuelve, ideal para que la navegación responda al instante.
caché de fetch
fetch(url, { next: { revalidate: 3600 } }) guarda la respuesta y la vuelve a pedir como mucho una vez por hora.

# cómo lo usamos acá

Esta misma página es el ejemplo: las estadísticas de colaboración piden datos a la API de GitHub, que puede tardar, así que van dentro de un <Suspense> con un skeleton. El resto de /desarrollo se muestra sin esperar.

Cada sección tiene además su loading.tsx con skeletons que imitan la forma real de la página.

# ejemplos del repo

~/src/app/(platform)/desarrollo/page.tsxtsx
<Section title="Estadísticas de colaboración">
  <Suspense fallback={<CollaborationStatsSkeleton />}>
    <CollaborationStats />
  </Suspense>
</Section>
~/src/app/(platform)/desarrollo/loading.tsxtsx
export default function Loading() {
  return (
    <>
      <div className="flex flex-1 flex-col p-4 pt-0">
        <div className="mt-4">
          <PageTitleSkeleton titleClassName="w-44" />
          <TextLineSkeleton lineClassName="h-5" className="h-3.5 w-52" />
        </div>
      </div>
    </>
  );
}
$ man streaming → documentación oficial ↗
›Server Actionsfunciones del servidor que llamás desde el cliente1 ejemplo

# qué es

Una Server Action es una función async en un archivo marcado con "use server". Desde un componente del cliente la llamás como cualquier función, pero Next la ejecuta en el servidor a través de un POST que arma solo. Reemplazan a la mayoría de los endpoints REST para mutaciones: crear, editar, borrar.

# conceptos clave

"use server"
Todas las funciones exportadas del archivo pasan a ser endpoints. Por eso cada una tiene que validar sus argumentos y chequear permisos: cualquiera puede invocarlas.
revalidatePath
Después de mutar, invalida la caché de una ruta para que la próxima visita muestre los datos nuevos.
redirect
Corta la ejecución lanzando una excepción especial y manda al usuario a otra URL.

# cómo lo usamos acá

Las acciones viven en src/actions, agrupadas por dominio (auth, events, testimonials, talks…), un archivo por acción y su test al lado.

Todas siguen el mismo patrón: límite de envíos, validación con Zod, sesión desde la cookie, escritura con Prisma y revalidatePath de las páginas afectadas.

# ejemplos del repo

~/src/actions/testimonials/create-testimonial.tsts
'use server';

import prisma from '@/lib/prisma';
import { testimonialSchema, TestimonialFormData } from '@/schemas/testimonial-schema';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
// …

export const createTestimonial = async (data: TestimonialFormData) => {
  await enforceRateLimit('createContent');

  const validatedData = testimonialSchema.parse(data);

  const sessionId = (await cookies()).get('sessionId')?.value;

  if (!sessionId) {
    throw new Error('Debes estar autenticado para crear un testimonio');
  }
  // …
  const testimonial = await prisma.testimonial.create({
    data: {
      body: validatedData.body,
      userId: session.userId,
    },
  });
  // …
  revalidatePath('/testimonios');
};
$ man server-actions → documentación oficial ↗
›Route Handlers y Proxyendpoints HTTP y la capa antes del request2 ejemplos

# qué es

Un route.ts dentro de app/ define un endpoint HTTP: exportás funciones con el nombre del método (GET, POST…) que reciben un Request y devuelven un Response. El proxy.ts (antes llamado middleware.ts, renombrado en Next 16) corre antes de cada request que matchee su matcher y puede redirigir, reescribir o tocar headers.

# conceptos clave

route.ts
Útil cuando el que llama no es un componente React: el buscador global, feeds, webhooks, descargas.
Response.json()
La API web estándar para devolver JSON, sin helpers propios de Next.
proxy.ts
Se ejecuta antes de renderizar. Next recomienda usarlo como último recurso y resolver la autorización cerca de los datos.

# cómo lo usamos acá

El buscador (⌘K) consulta /api/search, que mezcla un índice estático de las páginas con eventos, charlas, consejos y proyectos de la base.

El proxy está preparado para /perfil, pero hoy deja pasar todo: los permisos se verifican en cada página y cada server action.

# ejemplos del repo

~/src/app/api/search/route.tsts
export async function GET(request: NextRequest) {
  const query = (request.nextUrl.searchParams.get('q') ?? '').trim().slice(0, MAX_QUERY_LENGTH);

  if (!query) {
    return Response.json({ query, results: [] } satisfies SearchResponse);
  }

  let databaseEntries: Awaited<ReturnType<typeof loadDatabaseEntries>> = [];
  try {
    databaseEntries = await loadDatabaseEntries(query);
  } catch (error) {
    // Static content is still searchable when the database is unavailable.
    console.error('search: database lookup failed', error);
  // …
}
~/src/proxy.tsts
export function proxy(_request: NextRequest) {
  // …
  return NextResponse.next();
}

export const config = {
  matcher: ['/perfil/:path*'],
};
$ man route-handlers → documentación oficial ↗

$ ls notas/frontend # frontend

›React 19la UI como función del estado1 ejemplo

# qué es

React es una librería para construir interfaces con componentes: funciones que reciben props y devuelven JSX. Cuando cambia el estado, React vuelve a ejecutar el componente y actualiza en el DOM solo lo que cambió. Los hooks (useState, useEffect, useMemo…) son la forma de darle estado y efectos a esas funciones.

# conceptos clave

componentes
Piezas reutilizables y componibles. Se nombran en PascalCase y devuelven JSX.
hooks
Funciones use… que solo se llaman en el nivel superior de un componente o de otro hook, nunca dentro de ifs o loops.
custom hooks
Extraen lógica con estado a una función reutilizable (por ejemplo useContentMarks).
context / providers
Comparten un valor con todo un subárbol sin pasarlo prop por prop. Así se inyectan el tema y el cliente de React Query.

# cómo lo usamos acá

Los componentes están en src/components, agrupados por feature (events/, talks/, os/…). Los hooks propios están en src/hooks.

El layout raíz monta los providers globales una sola vez. Como necesitan estado del navegador, cada provider es un Client Component chico que recibe children.

# ejemplos del repo

~/src/components/react-query-provider.tsxtsx

El useState asegura que el QueryClient se cree una sola vez por pestaña y no en cada render.

'use client';

import { useState, type PropsWithChildren } from 'react';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';

export const ReactQueryProvider = ({ children }: PropsWithChildren) => {
  const [client] = useState(new QueryClient());

  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
$ man react → documentación oficial ↗
›TypeScriptJavaScript con tipos que se chequean antes de correr2 ejemplos

# qué es

TypeScript agrega tipos estáticos a JavaScript. El compilador revisa que uses bien cada valor (que no llames a algo que puede ser null, que no le pases un string a algo que espera un número) y después borra los tipos: en runtime es JavaScript normal. El editor usa esa información para autocompletar y refactorizar.

# conceptos clave

uniones discriminadas
Un tipo que es "una de varias formas" distinguidas por un campo común. Al chequear ese campo, TS sabe qué otros campos existen.
satisfies
Valida que un valor cumpla un tipo sin perder el tipo literal más preciso que infiere TS.
tipos derivados
z.infer, ReturnType, Awaited y keyof typeof sacan tipos de código que ya existe, así hay una sola fuente de verdad.

# cómo lo usamos acá

Todo el proyecto es TypeScript en modo estricto. Prisma genera los tipos de cada modelo y Zod los de cada formulario, así que casi nunca escribimos tipos a mano para los datos.

Antes de pushear, pnpm build corre el chequeo de tipos: si no compila, no se pushea.

# ejemplos del repo

~/src/actions/auth/sign-in.tsts

El resultado es una unión discriminada por success y error: quien llama sabe que email solo existe en el caso EMAIL_NOT_VERIFIED.

export const signIn = async (
  data: z.infer<typeof formSchema>,
): Promise<
  | { success: true; redirectTo: string }
  | { success: false; error: 'INVALID_CREDENTIALS' }
  | { success: false; error: 'EMAIL_NOT_VERIFIED'; email: string }
> => {
~/src/lib/rate-limit.tsts

satisfies chequea la forma de cada regla y keyof typeof convierte las claves en un tipo: enforceRateLimit("typo") no compila.

export const RATE_LIMITS = {
  signIn: { limit: 20, windowSeconds: 15 * 60 },
  signUp: { limit: 5, windowSeconds: 60 * 60 },
  // …
  photoDownload: { limit: 30, windowSeconds: 60 * 60 },
} satisfies Record<string, RateLimitRule>;

export type RateLimitName = keyof typeof RATE_LIMITS;
$ man typescript → documentación oficial ↗
›Tailwind CSSestilos con clases utilitarias3 ejemplos

# qué es

Tailwind es un framework de CSS "utility-first": en vez de escribir hojas de estilo con clases semánticas, componés el diseño con clases chicas que hacen una sola cosa (p-4, font-mono, border-b). En el build escanea el código y genera solo el CSS de las clases que usás. Los variants (hover:, md:, dark:) aplican una clase bajo una condición.

# conceptos clave

design tokens
Colores, fuentes y espaciados se definen en el bloque @theme de src/app/globals.css y se usan como clases (text-pcnGreen, border-pcnGreen-200).
variants custom
Con @custom-variant en el CSS podés crear tus propios prefijos condicionales.
valores arbitrarios
Corchetes para valores puntuales fuera de la escala: text-[11px], pb-[env(safe-area-inset-bottom)].
cn()
clsx arma la lista de clases condicionales y tailwind-merge resuelve conflictos (si pasás p-2 y p-4, gana la última).

# cómo lo usamos acá

La paleta pcnGreen es el verde fósforo del sitio con 9 niveles de opacidad. Las líneas finas de las grillas son border-pcnGreen-200.

Definimos dos variants propios para PCN OS: os: aplica en pantallas grandes cuando la página es el escritorio, y embedded: cuando la página está dentro de una ventana de PCN OS (un iframe). Un script en el layout raíz marca data-embedded en <html> antes del primer paint para que no haya parpadeo.

# ejemplos del repo

~/src/app/globals.csscss
/*
  PCN OS variants:
  - `os:` applies on large screens when the page is the desktop host (not inside a window).
  - `embedded:` applies when the page is rendered inside a PCN OS window (an iframe).
  - `lite:` applies in PCN OS liviano (see src/components/os/os-display-mode.ts).
  The `data-embedded` and `data-os-mode` attributes are set before paint by the scripts in the
  root layout; `data-os-mode="classic"` turns the desktop off, so `os:` excludes it.
*/
@custom-variant os {
  @media (width >= 1024px) {
    html:not([data-embedded]):not([data-os-mode='classic']) & {
      @slot;
    }
  }
}
@custom-variant embedded (html[data-embedded] &);
@custom-variant lite (html[data-os-mode='lite'] &);
~/src/components/ui/mobile-nav.tsxtsx

La barra de navegación mobile se oculta en desktop (md:hidden) y dentro de una ventana de PCN OS (embedded:hidden).

className="mobile-tab-bar pointer-events-auto fixed inset-x-0 bottom-0 z-[60] bg-transparent pb-[env(safe-area-inset-bottom)] embedded:hidden md:hidden"
~/src/components/ui/ruled-grid.tsxtsx

La grilla "con reglas" del sitio: celdas que comparten líneas finas en vez de flotar como tarjetas.

// A grid whose cells share hairlines instead of floating as separate cards:
// the grid draws the top/left edge and every cell draws its own bottom/right.
export const RuledGrid = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (
  <div className={cn('grid border-l border-t border-pcnGreen-200', className)} {...props} />
);
$ man tailwind → documentación oficial ↗
›shadcn/ui + Radixcomponentes accesibles que son tuyos1 ejemplo

# qué es

shadcn/ui no es una dependencia: es una colección de componentes que copiás a tu repo y modificás a gusto. Por debajo usan Radix UI, primitivas sin estilos que resuelven la parte difícil (foco, teclado, ARIA, portales) de diálogos, menús, tabs o selects. Los estilos los ponés vos con Tailwind.

# conceptos clave

primitivas headless
Radix da el comportamiento y la accesibilidad; la apariencia queda 100% en tus manos.
cva
class-variance-authority define variantes (variant, size) como un mapa de clases, con tipos generados para las props.
Slot / asChild
Permite que un <Button asChild> le pase sus estilos al hijo, por ejemplo un <Link>, sin anidar un botón dentro de un link.

# cómo lo usamos acá

Los componentes están en src/components/ui y fueron rediseñados con la estética de terminal: botones con texto en mono que se leen como llamadas a funciones (abrirGitHub();), tabs, diálogos y menús con bordes en verde fósforo.

# ejemplos del repo

~/src/components/ui/button.tsxtsx
// Buttons with a fill and a border label their action as a function call, e.g. `crearEvento();`.
const buttonVariants = cva(
  'inline-flex items-center justify-center whitespace-nowrap rounded-sm font-mono text-sm font-medium …',
  {
    variants: {
      variant: {
        default: primaryCta,
        destructive:
          'border border-red-500/60 bg-red-500/10 text-red-400 hover:bg-red-500/20 …',
        outline: secondaryCta,
        ghost: 'text-foreground/80 hover:bg-pcnGreen-100 hover:text-pcnGreen',
        link: 'text-pcnGreen underline-offset-4 hover:underline',
        pcn: primaryCta,
        // …
      },
    },
  },
);
$ man shadcn → documentación oficial ↗
›Zod + React Hook Formun schema, validado en el cliente y en el servidor2 ejemplos

# qué es

Zod describe la forma de los datos con un schema y lo valida en runtime: schema.parse(x) devuelve el dato tipado o lanza un error con mensajes por campo. React Hook Form maneja el estado de los formularios sin re-renderizar todo en cada tecla; con zodResolver usa el schema de Zod para validar.

# conceptos clave

schema
Fuente de verdad de un formulario: reglas, mensajes de error y, con z.infer, también el tipo TypeScript.
zodResolver
Conecta el schema a useForm para validar y mostrar los errores por campo.
doble validación
El cliente valida para dar feedback rápido; el servidor vuelve a validar porque el cliente no es confiable.

# cómo lo usamos acá

Los schemas viven en src/schemas y se importan desde los dos lados: el formulario los usa con zodResolver y la server action hace parse con el mismo schema.

# ejemplos del repo

~/src/schemas/testimonial-schema.tsts
import { z } from 'zod';

export const testimonialSchema = z.object({
  body: z.string().min(10, { message: 'El testimonio debe tener al menos 10 caracteres' }),
});

export type TestimonialFormData = z.infer<typeof testimonialSchema>;
~/src/components/talk-proposals/new-talk-proposal-form.tsxtsx

Un formulario con una lista dinámica de oradores: useFieldArray agrega y saca filas.

const form = useForm<TalkProposalFormData>({
  resolver: zodResolver(talkProposalSchema),
  defaultValues: {
    title: '',
    description: '',
    speakers: [defaults.firstSpeaker],
  },
});

const { fields, append, remove } = useFieldArray({
  control: form.control,
  name: 'speakers',
});
$ man forms → documentación oficial ↗
›TanStack Queryestado del servidor en el cliente1 ejemplo

# qué es

TanStack Query (React Query) cachea en el cliente datos que vienen del servidor. Cada consulta tiene una queryKey; la librería deduplica pedidos, los reintenta, los refresca cuando quedan viejos y permite actualizaciones optimistas: mostrar el cambio antes de que el servidor confirme y deshacerlo si falla.

# conceptos clave

useQuery
Lee y cachea. staleTime define cuánto tiempo el dato se considera fresco.
useMutation
Escribe. Tiene hooks de ciclo de vida: onMutate, onError, onSettled.
optimistic update
En onMutate guardás el estado anterior y escribís el nuevo en la caché; en onError lo restaurás.

# cómo lo usamos acá

Lo usamos donde la UI tiene que responder al instante, como marcar un artículo como leído o un video como visto. Las funciones de la query son server actions, así que no hay endpoints intermedios.

# ejemplos del repo

~/src/hooks/use-content-marks.tsts
const { data, isLoading } = useQuery({
  queryKey,
  queryFn: () => getContentMarks(contentType),
  staleTime: 60 * 1000,
});
// …
const mutation = useMutation({
  mutationFn: async (changes: SetMarkInput[]) => { /* … */ },
  onMutate: async (changes) => {
    await queryClient.cancelQueries({ queryKey });
    const previous = queryClient.getQueryData<MarksData>(queryKey);
    queryClient.setQueryData<MarksData>(queryKey, (current) => { /* … */ });
    return { previous };
  },
  onError: (_error, _changes, context) => {
    queryClient.setQueryData(queryKey, context?.previous);
    toast.error('No pudimos guardar el cambio. Probá de nuevo.');
  },
  onSettled: () => queryClient.invalidateQueries({ queryKey }),
});
$ man react-query → documentación oficial ↗
›TanStack Tabletablas con búsqueda, orden y paginación sin markup impuesto2 ejemplos

# qué es

TanStack Table es una librería "headless" para tablas: no dibuja nada, solo calcula. Le pasás los datos y la definición de columnas y te devuelve filas ya filtradas, ordenadas y paginadas; el HTML y los estilos los ponés vos. Es la misma idea que Radix, pero para tablas.

# conceptos clave

ColumnDef
Describe cada columna: de qué campo sale (accessorKey), cómo se dibuja su encabezado y su celda, y si se puede ordenar.
row models
Cada capacidad es un "modelo de filas" que activás a pedido: getSortedRowModel, getFilteredRowModel, getPaginationRowModel. Lo que no usás no pesa.
estado controlado
El orden y el filtro pueden vivir en tu useState, así los conectás con otros componentes como un buscador.
flexRender
Dibuja lo que definiste en la columna, sea un string o un componente.

# cómo lo usamos acá

La usamos en la lista de inscripciones de cada evento y en la tabla de /usuarios. El buscador global es el mismo SearchBar estilo grep del resto del sitio, conectado al globalFilter de la tabla; las filas canceladas se ven atenuadas.

# ejemplos del repo

~/src/components/events/registrations-data-table.tsxtsx
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState('');

const table = useReactTable({
  data,
  columns: registrationColumns,
  state: { sorting, globalFilter },
  onSortingChange: setSorting,
  onGlobalFilterChange: setGlobalFilter,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  initialState: { pagination: { pageSize: 100 } },
});
~/src/components/events/registrations-columns.tsxtsx
export const registrationColumns: ColumnDef<EventRegistrationRow>[] = [
  {
    accessorKey: 'name',
    meta: { className: 'min-w-[260px] whitespace-nowrap' },
    header: ({ column }) => (
      <SortableHeader
        label="Nombre"
        onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
      />
    ),
    // …
$ man tanstack-table → documentación oficial ↗
›Fechas: Intl y date-fnsla hora del evento, en la zona horaria de quien la lee2 ejemplos

# qué es

Un Date de JavaScript es un instante (milisegundos desde 1970 en UTC); la zona horaria aparece recién al mostrarlo. Intl.DateTimeFormat, que viene con el navegador y con Node, formatea un instante en un idioma y una zona dados. date-fns es una librería de funciones chicas e inmutables para formatear y calcular con fechas, con traducciones como es.

# conceptos clave

UTC en la base
Postgres guarda el instante; mostrarlo en hora argentina, española o mexicana es problema de la UI.
hydration mismatch
Si el servidor formatea en su zona y el navegador en la del visitante, el texto no coincide y React avisa. suppressHydrationWarning acepta esa diferencia puntual.
<time dateTime>
Marca la fecha en formato ISO para lectores de pantalla y buscadores, más allá de cómo se vea.
tree-shaking
Con date-fns importás solo las funciones que usás (format, formatDistanceToNow), no toda la librería.

# cómo lo usamos acá

Las fechas de los eventos se muestran con LocalDate, LocalTime y LocalDateTime: en el servidor se formatean en la hora de Buenos Aires y en el navegador en la zona de quien visita, en formato 24 h. date-fns se usa para textos más armados, como "hace 3 días" en los anuncios o el horario del flyer.

# ejemplos del repo

~/src/components/ui/local-date-time.tsxtsx
const CANONICAL_TZ = 'America/Argentina/Buenos_Aires';

function tz(): string | undefined {
  return typeof window === 'undefined' ? CANONICAL_TZ : undefined;
}

export function LocalTime({ date }: { date: Date | string }) {
  const d = new Date(date);
  const formatted = new Intl.DateTimeFormat('es-AR', {
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
    timeZone: tz(),
  }).format(d);
  return (
    <time dateTime={d.toISOString()} suppressHydrationWarning>
      {formatted}
    </time>
  );
}
~/src/components/announcements/announcement-card.tsxtsx
{formatDistanceToNow(new Date(announcement.createdAt), {
  addSuffix: true,
  locale: es,
})}
$ man fechas → documentación oficial ↗
›Motion + Embla Carouselanimaciones declarativas y carruseles táctiles3 ejemplos

# qué es

Motion (antes Framer Motion) anima componentes de React de forma declarativa: le decís el estado inicial, el final y el de salida, y la librería interpola. Su AnimatePresence resuelve algo que React solo no puede: animar un componente mientras se desmonta. Embla es un motor de carruseles liviano, con arrastre táctil y snap, sobre el que shadcn/ui arma su Carousel.

# conceptos clave

initial / animate / exit
Los tres estados de un motion.div: cómo aparece, cómo queda y cómo se va.
AnimatePresence
Mantiene montado al hijo que sale hasta que termina su animación de exit.
prefers-reduced-motion
Preferencia del sistema operativo para reducir animaciones; las respetamos en CSS y en los efectos de scroll.
scroll snap
El carrusel se acomoda siempre en un slide entero; Embla expone una API (selectedScrollSnap) para saber cuál se ve.

# cómo lo usamos acá

Motion se importa desde motion/react y anima detalles de la interfaz: el botón de volver arriba, el indicador de scroll, las ventanas y el dock de PCN OS, y los contadores que suben (NumberTicker). La home no lo usa: su hero y sus apariciones al scrollear son animaciones CSS, para que se vean sin esperar a que cargue el JavaScript. Embla mueve el carrusel de flyers de cada evento y los de charlas y lightning talks.

# ejemplos del repo

~/src/components/ui/scroll-hud-button.tsxtsx
<motion.div
  initial={{ opacity: 0, scale: 0.85, filter: 'blur(4px)' }}
  animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
  exit={{ opacity: 0, scale: 0.85, filter: 'blur(4px)' }}
  transition={{ duration: 0.18, ease: 'easeOut' }}
  // …
>
~/src/components/ui/scroll-to-top.tsxtsx

Sin AnimatePresence, el botón desaparecería de golpe al volver arriba.

<AnimatePresence>
  {isVisible && (
    <ScrollHudButton
      onClick={scrollToTop}
      label="Volver arriba"
      code={String(Math.round(progress * 100)).padStart(2, '0')}
      progress={progress}
      icon={<ChevronsUp className="h-4 w-4" strokeWidth={2.25} />}
    />
  )}
</AnimatePresence>
~/src/components/events/event-flyer-carousel.tsxtsx
const [api, setApi] = React.useState<CarouselApi>();
const [current, setCurrent] = React.useState(0);

React.useEffect(() => {
  if (!api) return;
  setCurrent(api.selectedScrollSnap());
  const handleSelect = () => setCurrent(api.selectedScrollSnap());
  api.on('select', handleSelect);
  return () => {
    api.off('select', handleSelect);
  };
}, [api]);
$ man motion → documentación oficial ↗
›next/font · next-themes · Sonnerfuentes, tema y notificaciones montados una sola vez2 ejemplos

# qué es

next/font descarga las fuentes en el build y las sirve desde tu propio dominio, sin pedidos a terceros ni saltos de layout cuando cargan. next-themes maneja el tema (claro/oscuro) poniendo una clase en <html> antes de que se pinte la página. Sonner es una librería de toasts: llamás a toast.success() desde cualquier lado y el <Toaster> montado en el layout los muestra.

# conceptos clave

variables CSS de fuente
GeistSans.variable expone la fuente como variable CSS, que Tailwind usa en font-sans y font-mono.
forcedTheme
Fija un tema sin importar la preferencia del sistema: el sitio es siempre oscuro, como una terminal.
toasts imperativos
No hace falta estado ni contexto propio: toast() funciona desde un handler, después de una server action.

# cómo lo usamos acá

El layout raíz carga Geist Sans y Geist Mono, fuerza el tema oscuro y monta el Toaster, al que le dimos estética de PCN OS: panel con scanlines, borde iluminado del color del toast y un prompt > antes del título. Los formularios y acciones avisan el resultado con toast.success o toast.error.

# ejemplos del repo

~/src/app/layout.tsxtsx
<html
  lang="es"
  className={`${GeistSans.variable} ${GeistMono.variable}`}
  suppressHydrationWarning
>
  {/* … */}
  <body className={GeistSans.className}>
    <ThemeProvider
      attribute="class"
      defaultTheme="dark"
      forcedTheme="dark"
      disableTransitionOnChange
    >
      <ReactQueryProvider>{children}</ReactQueryProvider>
      <Toaster closeButton position="top-right" />
      {/* … */}
    </ThemeProvider>
  </body>
</html>
~/src/app/(platform)/notificaciones/notifications-client.tsxtsx
try {
  await markNotificationAsRead(notificationId);
  toast.success('Notificación marcada como leída');
  router.refresh();
} catch (error: any) {
  toast.error(error.message || 'Error al marcar la notificación como leída');
}
$ man layout-raiz → documentación oficial ↗

$ ls notas/datos # backend y datos

›PrismaORM con tipos generados y migraciones3 ejemplos

# qué es

Prisma es un ORM para Node.js. Describís los modelos en schema.prisma, Prisma genera un cliente con tipos para cada tabla y relación, y Prisma Migrate convierte los cambios del schema en archivos SQL versionados que se aplican en orden en cada base.

# conceptos clave

schema.prisma
Modelos, campos, relaciones, índices y enums en un solo archivo, que es la fuente de verdad de la base.
Prisma Client
prisma.user.findUnique({ where, select, include }): consultas tipadas, con autocompletado de campos y relaciones. Se genera como código TypeScript del proyecto en src/generated/prisma (no se commitea).
driver adapter
Desde Prisma 7 el cliente no trae un engine en Rust: arma el SQL en TypeScript y lo manda por pg, el driver de Postgres de Node, a través de @prisma/adapter-pg. prisma.config.ts define la URL que usa el CLI para migrar.
migraciones
Cada cambio del schema es una carpeta en prisma/migrations con su migration.sql. En producción se aplican con prisma migrate deploy.
singleton
En desarrollo el hot reload recarga módulos; guardar el cliente en globalThis evita abrir una conexión nueva en cada recarga.

# cómo lo usamos acá

El schema tiene decenas de modelos (usuarios, sesiones, eventos, charlas, galería, proyectos…) y más de 70 migraciones desde 2024. El cliente se regenera solo en postinstall y prebuild.

Para un cambio de schema: editás schema.prisma, creás la migración con pnpm create-migration <nombre>, revisás el SQL generado y lo commiteás junto con el cambio.

# ejemplos del repo

~/prisma/schema.prismaprisma
model Testimonial {
  id        String   @id @default(cuid())
  body      String
  userId    String // Usuario que creó el testimonio (requerido)
  featured  Boolean  @default(false) // Si aparece en la home page
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@index([createdAt])
  @@index([featured])
}
~/prisma/migrations/20261008130000_add_user_instagram_url/migration.sqlsql
-- AlterTable
ALTER TABLE "User" ADD COLUMN "instagramUrl" TEXT;
~/src/lib/prisma.tsts
import { PrismaClient } from '@/generated/prisma/client';
import { pgAdapter } from '@/lib/database-url';

const prismaClientSingleton = () => {
  return new PrismaClient({ adapter: pgAdapter(process.env.DATABASE_URL) });
};

declare const globalThis: {
  prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;

const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();

export default prisma;

if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma;
$ man prisma → documentación oficial ↗
›Cache de datoslecturas cacheadas que cada escritura vence sola3 ejemplos

# qué es

Un cache de datos guarda el resultado de una consulta para no repetirla en cada request. Lo difícil no es guardar sino invalidar: saber cuándo lo guardado dejó de ser cierto. Next.js trae un data cache del lado del servidor (unstable_cache) donde cada entrada lleva tags, y revalidateTag vence todas las entradas de un tag de una vez.

# conceptos clave

unstable_cache
Envuelve una función async: la primera llamada con ciertos argumentos consulta la base y guarda el resultado; las siguientes lo leen de memoria o de .next/cache hasta que vence o se invalida.
tags
Etiquetas de cada entrada. revalidateTag(tag, { expire: 0 }) vence todas las que la llevan y el próximo request las recalcula.
invalidación por tabla
Cada lectura cacheada se etiqueta con las tablas que lee (db:Event); escribir en una tabla vence su tag. Más grueso que invalidar fila por fila, pero no hay forma de olvidarse un caso.
datos que dependen de la hora
Lo que cambia con el reloj (qué eventos son próximos) no se cachea filtrado: se cachea la lista completa y se filtra en cada request.

# cómo lo usamos acá

Casi todo lo que el sitio muestra es igual para todos y cambia poco, así que las lecturas públicas pasan por cached() (src/lib/cache.ts): eventos, charlas, galería, consejos, proyectos, miembros, perfiles, logros, el feed, la búsqueda, el sitemap. Una página vista por alguien sin sesión no toca Postgres; con sesión, solo la busca a ella y lo propio (tu inscripción, tus likes).

Nadie invalida a mano: el cliente de Prisma tiene una extensión que, después de cada escritura, calcula qué tablas tocó (incluidas las escrituras anidadas y lo que se borra en cascada, a partir de las relaciones del schema) y vence sus tags. Una server action nueva no tiene que acordarse de nada.

Al cachear una lectura nueva hay que declarar todas las tablas que lee, incluidas las de los include y los filtros por relación. En desarrollo, si una lectura cacheada toca una tabla que no declaró, la consola avisa con [cache] <nombre> reads <Tabla>.

Las URLs firmadas de la galería vencen, así que se cachean las filas sin firmar y se firman después; cada firma se guarda durante su hora. Las tablas de tracking, logs, sesiones y tokens no se cachean nunca.

# ejemplos del repo

~/src/lib/event-index.tsts

Una lectura cacheada: un nombre, la consulta y las tablas que lee.

export const listEventIndex = cached(
  'event-index',
  () =>
    prisma.event.findMany({
      where: { deletedAt: null },
      orderBy: { date: 'asc' },
      select: { id: true, name: true, date: true, endDate: true },
    }),
  { models: ['Event'] },
);
~/src/actions/events/fetch-upcoming-events.tsts

Lo que depende de la hora se filtra sobre la lista cacheada.

export const fetchUpcomingEvents = async (limit: number = 5) => {
  const now = new Date();
  const events = await listEventIndex();
  return events
    .filter(({ date, endDate }) => date >= now || (endDate !== null && endDate >= now))
    .slice(0, limit)
    .map(({ id, name, date }) => ({ id, name, date }));
};
~/src/lib/prisma.tsts

Cada escritura vence las lecturas de las tablas que tocó.

}).$extends({
  name: 'data-cache',
  query: {
    $allModels: {
      async $allOperations({ model, operation, args, query }) {
        if (!WRITES.has(operation)) {
          if (process.env.NODE_ENV !== 'production') checkCachedRead(modelsIn(model, args));
          return query(args);
        }
        const result = await query(args);
        expireModels(modelsIn(model, args, isDelete(operation)));
        return result;
      },
    },
  },
});
$ cd src/lib/cache.ts → ver el código ↗$ man cache-de-datos → documentación oficial ↗
›PostgreSQLla base de datos relacional2 ejemplos

# qué es

PostgreSQL es una base de datos relacional open-source: tablas con filas y columnas tipadas, relaciones con claves foráneas, transacciones ACID e índices para que las consultas no recorran la tabla entera. Se consulta con SQL; en este proyecto casi siempre a través de Prisma.

# conceptos clave

claves foráneas
onDelete: Cascade en Prisma se traduce a una FK que borra las filas hijas al borrar la madre.
índices
Aceleran filtros y ordenamientos frecuentes (@@index([createdAt])) a cambio de un poco más de costo al escribir.
búsqueda sin mayúsculas
mode: "insensitive" en Prisma usa ILIKE de Postgres para buscar ignorando mayúsculas.

# cómo lo usamos acá

En local Postgres corre en Docker Compose. Cada git worktree tiene además su propia base aislada, creada por scripts/setup-worktree-db.sh, así podés probar migraciones en una branch sin romper la de otra.

# ejemplos del repo

~/docker-compose.ymlyaml
database:
  image: postgres:13
  container_name: pcn-db
  restart: always
  environment:
    - POSTGRES_USER=${POSTGRES_USER}
    - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    - POSTGRES_DB=${POSTGRES_DB}
  ports:
    - '5432:5432'
  healthcheck:
    test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
    interval: 5s
    timeout: 5s
    retries: 5
~/src/app/api/search/route.tsts
const contains = { contains: query, mode: 'insensitive' as const };
const [events, talks, advice, projects] = await Promise.all([
  prisma.event.findMany({
    where: { deletedAt: null, OR: [{ name: contains }, { description: contains }] },
    orderBy: { date: 'desc' },
    take: DB_CANDIDATES,
    // …
  }),
  // …
]);
$ man postgres → documentación oficial ↗
›Autenticación propiasesiones en la base + cookie httpOnly2 ejemplos

# qué es

Autenticar es verificar quién sos; autorizar es decidir qué podés hacer. Un esquema clásico de sesiones: al loguearte, el servidor crea un registro de sesión y te manda su id en una cookie httpOnly (JavaScript no la puede leer). En cada request el servidor busca esa sesión para saber quién sos. Las contraseñas nunca se guardan en texto plano, sino como hash con un algoritmo lento como bcrypt.

# conceptos clave

bcrypt
Hash con sal y costo configurable: aunque se filtre la base, recuperar las contraseñas es carísimo.
cookie httpOnly
Protege la sesión de scripts inyectados (XSS). sameSite: "lax" ayuda contra CSRF y secure la limita a HTTPS.
rate limiting
Limitar intentos por usuario o IP frena ataques de fuerza bruta y el spam de formularios.
mensajes genéricos
Responder "credenciales inválidas" tanto si el email no existe como si la contraseña está mal evita revelar qué emails están registrados.

# cómo lo usamos acá

No usamos un proveedor externo: el modelo Session vive en Postgres y la cookie se llama sessionId. Todas las lecturas pasan por findSession() (en src/lib/session.ts), que descarta las sesiones vencidas; cerrar sesión borra la fila. Registrarse requiere verificar el email con un código de 6 dígitos.

Todos los formularios pasan por enforceRateLimit, una ventana deslizante en memoria (alcanza porque el sitio corre en un solo proceso). Los admins no tienen límite.

# ejemplos del repo

~/src/actions/auth/sign-in.tsts
const isPasswordValid = await bcrypt.compare(password, user.password);

if (!isPasswordValid) {
  return { success: false, error: 'INVALID_CREDENTIALS' };
}

if (!user.emailVerified) {
  return { success: false, error: 'EMAIL_NOT_VERIFIED', email };
}

await createSession(user.id);
~/src/lib/session.tsts
// La cookie lleva un token aleatorio; la base guarda solo su hash como id de la sesión
export const hashSessionToken = (token: string) =>
  createHash('sha256').update(token).digest('hex');

// Todas las lecturas de sesión pasan por acá: una sesión vencida no sirve en ningún lado
export const findSession = (token: string) =>
  prisma.session.findUnique({
    where: { id: hashSessionToken(token), expires: { gt: new Date() } },
    include: { user: true },
  });

// Cerrar sesión borra la fila, así la cookie deja de servir aunque alguien la copie
export const deleteCurrentSession = async () => {
  const cookieStore = await cookies();
  const token = cookieStore.get(SESSION_COOKIE)?.value;

  if (token) {
    await prisma.session.deleteMany({ where: { id: hashSessionToken(token) } });
  }

  cookieStore.delete(SESSION_COOKIE);
};
$ man auth → documentación oficial ↗
›AWS S3 + CloudFrontarchivos subidos directo del navegador2 ejemplos

# qué es

S3 es el almacenamiento de objetos de AWS: guardás archivos (imágenes, videos) en un "bucket" bajo una clave. CloudFront es su CDN: copia esos archivos en servidores cerca de cada visitante. Una URL prefirmada es un permiso temporal firmado por el servidor para que el navegador suba o baje un archivo puntual sin conocer las credenciales.

# conceptos clave

URL prefirmada
El archivo va del navegador a S3 sin pasar por nuestro servidor, que solo firma el permiso (válido 5 minutos).
Cache-Control immutable
Si la clave cambia cada vez que cambia el archivo, se puede cachear para siempre.
remotePatterns
Lista de dominios desde los que next/image acepta optimizar imágenes remotas.

# cómo lo usamos acá

Los flyers de eventos, las fotos de perfil, los logos de proyectos y la galería se guardan en S3 y se sirven por CloudFront.

Los archivos de la galería son privados: CloudFront solo los entrega con una URL firmada que generamos al renderizar. Cómo se optimizan las fotos y los videos antes de llegar ahí está en la nota de la galería, más abajo.

# ejemplos del repo

~/src/lib/s3.tsts
const uniqueFileName = `${folder}/${Date.now()}-${crypto.randomUUID()}.${extension}`;

const command = new PutObjectCommand({
  Bucket: S3_BUCKET,
  Key: uniqueFileName,
  ContentType: contentType,
});

// URL válida por 5 minutos
// Firmar host y content-type para que el navegador pueda enviar el content-type correcto
const uploadUrl = await getSignedUrl(s3Client, command, {
  expiresIn: 300,
  signableHeaders: new Set(['host', 'content-type']),
});

return { uploadUrl, fileUrl: publicFileUrl(uniqueFileName), key: uniqueFileName };
~/next.config.mjsjs
// CloudFront CDN for uploaded event flyers / photos
...(process.env.AWS_CLOUDFRONT_URL
  ? [{ protocol: 'https', hostname: new URL(process.env.AWS_CLOUDFRONT_URL).hostname }]
  : []),
$ man aws → documentación oficial ↗
›Resend + React Email + MailHogemails reales en prod, atrapados en local2 ejemplos

# qué es

Resend es un servicio para mandar emails desde código con una API: se verifica el dominio una vez (registros DNS) y cada email es una llamada con el remitente, el destinatario y el HTML. React Email permite escribir el contenido del email como un componente de React y convertirlo a HTML con render. MailHog es un servidor SMTP falso para desarrollo: acepta todos los emails y los muestra en una interfaz web, así podés probar flujos de verificación sin mandarle nada a nadie; en local Nodemailer le habla por SMTP.

# conceptos clave

SMTP
El protocolo con el que los servidores se pasan emails.
dominio verificado
Resend solo manda desde direcciones de un dominio que probaste que es tuyo con registros DNS (SPF y DKIM), que además evitan que los emails caigan en spam.

# cómo lo usamos acá

Los códigos de verificación de cuenta y de recuperación de contraseña se mandan por email. Las plantillas son componentes en src/components/auth con estilos inline (los clientes de email ignoran casi todo el CSS externo) y @react-email/render las convierte a HTML. En producción salen por Resend; con Docker Compose, MailHog queda en http://localhost:18025 para ver lo que el sitio "envió".

# ejemplos del repo

~/src/lib/email.tsts
const { error } = await new Resend(apiKey).emails.send({
  from: formatSender(getSender()),
  to,
  subject,
  html,
});
if (error) throw new Error(`Resend: ${error.message}`);
~/src/actions/auth/send-verification-code.tsts
// Enviar email con el código
const emailHtml = await render(EmailVerificationEmail({ userName: user.name, code }));
await sendEmail({
  to: user.email,
  subject: 'Verificá tu correo electrónico - Programa Con Nosotros',
  html: emailHtml,
});
$ man email → documentación oficial ↗
›GitHub REST APIlas estadísticas de esta página1 ejemplo

# qué es

GitHub expone una API REST pública para leer repos, commits, pull requests y contribuidores. Sin token permite 60 requests por hora por IP; con token, muchas más. Algunos endpoints de estadísticas se calculan en segundo plano y responden 202 hasta que el resultado está listo.

# conceptos clave

paginación
Las listas vienen de a páginas; el header Link trae la URL de la última página, que sirve para contar sin bajar todo.
snapshot
En vez de pedirle los números a GitHub en cada visita, un script los baja una vez y los guarda en un JSON que se commitea: la página no depende de que GitHub responda ni del límite de requests.
Promise.all
Los pedidos independientes salen en paralelo en vez de uno atrás del otro.

# cómo lo usamos acá

La sección "Estadísticas de colaboración" de esta página, las contribuciones de cada perfil y /vinculos leen src/data/github-stats.json. Ese archivo lo genera pnpm github:stats (scripts/update-github-stats.mjs): estrellas, commits, PRs mergeadas, tiempo mediano hasta el merge, lenguajes, líneas de código y el detalle de cada contribuidor. El sitio nunca llama a GitHub mientras renderiza.

# ejemplos del repo

~/scripts/update-github-stats.mjsjs
/** A /stats/* endpoint, or `null` if GitHub is still computing it after every retry. */
const githubStats = async (path) => {
  for (let attempt = 1; attempt <= STATS_ATTEMPTS; attempt++) {
    const response = await github(path);
    if (response.status === 200) return response.json();
    await new Promise((resolve) => setTimeout(resolve, STATS_RETRY_MS));
  }
  console.warn(`! ${path}: GitHub todavía lo está calculando, queda el valor anterior`);
  return null;
};

/** Total item count of a paginated endpoint, read from the `rel="last"` page of `per_page=1`. */
const countFromLinkHeader = (response, fallback) => {
  const match = response.headers.get('link')?.match(/[?&]page=(\d+)>; rel="last"/);
  return match ? Number(match[1]) : fallback;
};
$ man github-api → documentación oficial ↗

$ ls notas/calidad # testing y calidad

›Jesttests unitarios de las server actions2 ejemplos

# qué es

Jest es un framework de testing para JavaScript: corre archivos *.test.ts, te da describe/it/expect y un sistema de mocks para reemplazar dependencias (la base, las cookies) por dobles controlados. Un test unitario prueba una unidad aislada: rápido y determinista.

# conceptos clave

jest.mock
Reemplaza un módulo entero. Se "hoistea" arriba del archivo, antes de los imports.
mockDeep
jest-mock-extended crea un mock tipado de todo el cliente de Prisma, con cada método como jest.fn().
Arrange · Act · Assert
Preparás los mocks, llamás a la función y verificás el resultado y los efectos.

# cómo lo usamos acá

Cada server action tiene su test al lado (sign-in.ts → sign-in.test.ts). jest.setup.ts mockea globalmente Prisma, next/headers, next/cache y next/navigation, así los tests no necesitan base ni servidor.

# ejemplos del repo

~/jest.setup.tsts
jest.mock('@/lib/prisma', () => {
  const { mockDeep } = require('jest-mock-extended');
  return {
    __esModule: true,
    default: mockDeep(),
  };
});
// …
// redirect() normally throws to interrupt control flow; replicate that here so
// tests can assert on it with .rejects.toThrow('NEXT_REDIRECT:/some/path')
jest.mock('next/navigation', () => ({
  redirect: jest.fn().mockImplementation((url: string) => {
    throw new Error(`NEXT_REDIRECT:${url}`);
  }),
}));
~/src/actions/auth/sign-in.test.tsts
it('returns INVALID_CREDENTIALS when password is wrong', async () => {
  prismaMock.user.findUnique.mockResolvedValue(baseUser as any);
  (bcryptMock.compare as jest.Mock).mockResolvedValue(false);

  const result = await signIn(validInput);

  expect(result).toEqual({ success: false, error: 'INVALID_CREDENTIALS' });
  expect(prismaMock.session.create).not.toHaveBeenCalled();
});
$ man jest → documentación oficial ↗
›Playwrightun navegador de verdad, manejado por código2 ejemplos

# qué es

Playwright controla Chromium, Firefox y WebKit desde código. Sirve para tests end-to-end (abrir la página, hacer clic, verificar lo que se ve, como lo haría una persona) y para automatizar el navegador en general, por ejemplo sacar capturas.

# conceptos clave

E2E
Prueba el sistema entero: frontend, backend y base juntos. Más lento que un unitario, pero agarra errores de integración.
locators
page.getByRole(…) busca elementos como los ve un usuario (rol y texto) y no por clases CSS frágiles.
projects
Un mismo test corre en varios navegadores o tamaños de pantalla: acá, Desktop Chrome y un Pixel 7 para los specs mobile.

# cómo lo usamos acá

La suite E2E de regresión está en tests/e2e/ y corre una vez por semana con pnpm test:e2e: recrea una base con datos de prueba, compila la app y la recorre en Chromium (desktop y mobile), con un test por caso de /desarrollo/calidad. Además usamos Playwright para las capturas de las PRs: pnpm screenshot /ruta abre cada ruta en Chromium y guarda la página completa.

# ejemplos del repo

~/tests/e2e/smoke.spec.tsts
test.describe('signed in as a member', () => {
  test.use({ as: 'member' });

  test('the session from auth.setup works', async ({ page, db }) => {
    await page.goto('/notificaciones');
    await expect(page).toHaveURL(/\/notificaciones/);
  });
});
~/scripts/pr-screenshots.mjsjs
const browser = await chromium.launch();
const context = await browser.newContext({
  viewport: { width: 1280, height: 800 },
});

for (const route of routes) {
  // …
  const page = await context.newPage();
  await page.goto(url, { waitUntil: 'networkidle' });
  await page.screenshot({ path: outPath, fullPage: true });
  await page.close();
}
$ man playwright → documentación oficial ↗
›ESLint · Prettier · Huskyla calidad se chequea sola antes de pushear2 ejemplos

# qué es

ESLint analiza el código buscando errores y malas prácticas (por ejemplo, romper las reglas de los hooks). Prettier lo formatea siempre igual, así nadie discute de comas. Husky instala git hooks: scripts que git corre antes de un commit o de un push. lint-staged corre comandos solo sobre los archivos que estás commiteando.

# conceptos clave

pre-commit
Corre al hacer git commit. Acá: Prettier sobre los archivos staged.
pre-push
Corre al hacer git push y lo cancela si algo falla: lint, formato, tests y build.
eslint-config-prettier
Apaga las reglas de ESLint que se pisarían con Prettier.

# cómo lo usamos acá

Prettier usa prettier-plugin-tailwindcss, que además ordena las clases de Tailwind en un orden consistente.

# ejemplos del repo

~/.husky/pre-pushsh
pnpm lint
pnpm format:check
pnpm test
pnpm build
~/package.jsonjson
"lint-staged": {
  "*.{js,jsx,ts,tsx,json,jsonc,css,scss,md,mjs,cjs}": "prettier --write"
}
$ man git-hooks → documentación oficial ↗

$ ls notas/infra # infraestructura

›Docker y Docker Composeel mismo entorno en todas las máquinas2 ejemplos

# qué es

Docker empaqueta una aplicación con todo lo que necesita (sistema, Node, dependencias) en una imagen; un contenedor es esa imagen corriendo, aislado del resto. Docker Compose levanta varios contenedores relacionados (web, base, mail) con un solo comando y los conecta en una red privada donde se encuentran por nombre.

# conceptos clave

Dockerfile
La receta paso a paso para construir la imagen.
volúmenes
Montan carpetas del host en el contenedor: así el código que editás se ve adentro sin reconstruir.
healthcheck + depends_on
La web arranca recién cuando Postgres responde, no cuando su contenedor apenas existe.

# cómo lo usamos acá

docker-compose up -d levanta la web, Postgres y MailHog para desarrollar. En producción se construye Dockerfile.prod, que instala dependencias y hace el build de Next dentro de la imagen.

# ejemplos del repo

~/Dockerfile.proddockerfile
FROM node:24.21.0
WORKDIR /app
COPY . .
RUN npm install -g pnpm@9.4.0
RUN pnpm install \
		&& pnpm run build
EXPOSE 3000
CMD ["pnpm", "start"]
~/docker-compose.ymlyaml
web:
  build: .
  container_name: pcn-web
  restart: always
  ports:
    - '3000:3000'
  volumes:
    - .:/app
    - /app/node_modules
  depends_on:
    database:
      condition: service_healthy
    mailhog:
      condition: service_started
$ man docker → documentación oficial ↗
›Kamal + GitHub Actionspush a main = deploy a producción2 ejemplos

# qué es

GitHub Actions ejecuta workflows (CI/CD) en máquinas de GitHub cuando pasa algo en el repo, por ejemplo un push. Kamal es una herramienta de deploy: construye la imagen Docker, la sube a un registry, se conecta por SSH a tus servidores y reemplaza el contenedor viejo por el nuevo sin downtime, con un proxy que maneja HTTPS.

# conceptos clave

CI/CD
Integración y entrega continuas: cada cambio aprobado llega a producción de forma automática y repetible.
registry
Donde se guardan las imágenes construidas (en este caso, Amazon ECR) para que el servidor las descargue.
zero-downtime
kamal-proxy solo pasa el tráfico al contenedor nuevo cuando responde bien; recién ahí apaga el viejo.
secretos
Las credenciales viven en GitHub Secrets y llegan como variables de entorno; nunca se commitean.

# cómo lo usamos acá

Trabajamos en branches, las mergeamos a testing y de ahí a main. Cada push a main dispara el workflow: instala dependencias, aplica las migraciones pendientes con prisma migrate deploy y corre kamal deploy. El healthcheck que usa Kamal es la ruta /up.

# ejemplos del repo

~/.github/workflows/deployment.ymlyaml
on:
  workflow_dispatch:
  push:
    branches:
      - main
# …
      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run Prisma Migrations
        run: pnpm prisma migrate deploy

      - name: Run Kamal deploy
        run: kamal deploy
~/config/deploy.ymlyaml
service: pcn-website
# …
proxy:
  host: programaconnosotros.com
  ssl: true
  app_port: 3000
# …
builder:
  arch: amd64
  context: .
  dockerfile: Dockerfile.prod
  cache:
    type: gha
$ man kamal → documentación oficial ↗
›pnpm · portless · worktreesun entorno local por branch, sin pelear por puertos2 ejemplos

# qué es

pnpm es un package manager que guarda cada versión de cada paquete una sola vez en disco y la enlaza en cada proyecto: instala rápido y es estricto con las dependencias no declaradas. portless le da a cada servidor de desarrollo una URL estable https://<nombre>.localhost en vez de un puerto. Los git worktrees permiten tener varias branches del mismo repo en carpetas distintas al mismo tiempo.

# conceptos clave

lockfile
pnpm-lock.yaml fija la versión exacta de cada dependencia; --frozen-lockfile falla si no coincide con package.json.
overrides
Fuerzan la versión de una dependencia transitiva, por ejemplo para tapar una vulnerabilidad.
git worktree
Otra carpeta con otra branch del mismo repo, sin clonar de nuevo: ideal para revisar una PR sin frenar lo tuyo.

# cómo lo usamos acá

pnpm dev corre Next a través de portless: el checkout principal responde en https://pcn-website.localhost y un worktree en la branch foo en https://foo.pcn-website.localhost. Cada worktree tiene su propia base de datos, creada automáticamente.

# ejemplos del repo

~/package.jsonjson
"dev": "portless run next dev",
"dev:docker": "next dev",
"setup-worktree-db": "bash scripts/setup-worktree-db.sh",
~/scripts/setup-worktree-db.shbash
# ── 4. Derive the per-worktree database name ─────────────────────────────────
WORKTREE_BASENAME=$(basename "$CUR")
# lowercase, replace non-alphanumeric runs with _, strip trailing underscores
DB_NAME="pcn_$(echo "$WORKTREE_BASENAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '_' | sed 's/_*$//')"
$ man portless → documentación oficial ↗

$ ls notas/modulos # módulos del sitio

›Galería · fotos y videos optimizadosdel celular a la CDN: WebP, H.264 y URLs firmadas7 ejemplos

# qué es

Optimizar medios es achicar lo que baja cada visitante sin que se note: redimensionar al tamaño en que realmente se va a ver, recomprimir en formatos modernos (WebP para fotos, H.264 en MP4 para video), borrar metadatos que no hacen falta y servir una versión chica (thumbnail) en las grillas y la grande solo cuando alguien la abre. Una foto de celular pesa entre 3 y 10 MB y un minuto de video 4K cientos de MB; servidos tal cual, una grilla de fotos se vuelve inusable con datos móviles.

# conceptos clave

WebP
Formato de imagen de Google que pesa bastante menos que JPEG a calidad similar y que hoy soportan todos los navegadores.
EXIF
Metadatos que la cámara guarda en la foto: fecha, modelo, orientación y, muchas veces, la ubicación GPS exacta. Publicarlos tal cual es un problema de privacidad.
orientación
El sensor guarda la foto siempre "acostada" y un flag EXIF dice cómo girarla. Si borrás los metadatos sin aplicar antes ese giro, la foto queda de costado.
thumbnail
Versión chica de la imagen para grillas y listados. La original solo se descarga al abrirla.
WebCodecs
API del navegador que da acceso a los encoders y decoders de video del sistema: permite recomprimir un video en la compu del que lo sube, sin servidores de transcodificación.
H.264 vs HEVC
Codecs de video. Los iPhone graban en HEVC, que no todos los navegadores reproducen; H.264 en un MP4 se ve en todos lados.
fast start
Poner el índice del MP4 (el átomo moov) al principio del archivo para que el video arranque mientras se sigue descargando.
presigned POST
A diferencia del PUT prefirmado, lleva una policy con condiciones que S3 hace cumplir, como content-length-range para limitar el tamaño.
URL firmada de CloudFront
URL con una firma y una fecha de vencimiento: sin ella la CDN no entrega el archivo, así nadie puede listar ni enlazar los archivos para siempre.
loading="lazy"
El navegador posterga la descarga de una imagen hasta que está por entrar en pantalla.

# cómo lo usamos acá

Solo los admins suben contenido, desde /galeria/subir. Los archivos nunca pasan por nuestro servidor de ida: el navegador los manda directo a S3 con URLs prefirmadas, de a uno por vez, y después una server action termina el trabajo. Mientras sube, la página pide un wake lock para que el celular no apague la pantalla y avisa antes de cerrar la pestaña.

Fotos: en el navegador, exifr lee la fecha en que se sacó (DateTimeOriginal o CreateDate, y si no hay EXIF la fecha del archivo) para precompletar el formulario. Las fotos HEIC de iPhone se convierten a JPEG en el navegador con heic-to (libheif en un worker, cargado solo cuando hace falta), después de leer la fecha, porque sharp no decodifica HEIC. El original va a gallery/originals/ con un PUT prefirmado de 5 minutos; después createPhoto lo baja, optimizePhoto lo gira según el EXIF y genera dos WebP sin metadatos (GPS incluido): full de 2560 px a calidad 80 y thumb de 640 px a calidad 70. Se guardan bajo una carpeta con UUID, se borra el original y se crea el GalleryItem con el ancho y el alto.

Videos: antes de subirlo, readVideo carga el archivo en un <video> oculto para leer duración y medidas y captura un cuadro (al segundo 1, o a un décimo en clips cortos) en un canvas como portada JPEG de hasta 1280 px; si el navegador no puede, la portada es negra. Después compressVideo lo recodifica en el navegador con Mediabunny (WebCodecs): H.264 + AAC en MP4 con fast start, lado corto de hasta 1080 px, hasta 30 fps y 5 Mbps, más o menos 40 MB por minuto. Si el navegador no puede (sin VideoEncoder, o Firefox, que no codifica AAC y perdería el audio) o el resultado no es más chico, se sube el original.

El video se sube con un presigned POST de 15 minutos cuya policy rechaza más de 500 MB. createVideo verifica con un HEAD que el archivo llegó y convierte la portada a WebP (1280 px, calidad 75) con sharp. No hay ffmpeg ni transcodificación en el servidor: todo el trabajo pesado lo hace el navegador de quien sube.

Todo lo que está bajo gallery/ se guarda con Cache-Control: public, max-age=31536000, immutable (cada archivo nuevo es una clave nueva, así que nunca hay que invalidar caché) y CloudFront solo lo entrega con URL firmada. signGalleryItem firma al renderizar la página, con vencimiento al final de la hora siguiente: la misma foto tiene la misma URL durante toda una hora, así el navegador y la CDN la cachean.

En la UI, la grilla usa solo los thumbnails de 640 px con <img loading="lazy" decoding="async">; al pasar las 94 fotos viejas a thumbnails, la grilla pasó de ~16 MB a 3,3 MB. Cada foto o video tiene su página /galeria/[id] con la versión grande o un <video preload="metadata"> con la portada, que reproduce el MP4 progresivo y salta con range requests. Las flechas del teclado navegan y router.prefetch precalienta las vecinas. Los filtros por tipo, evento y persona viven en la URL, y la descarga del original pasa por /api/galeria/[id]/descargar, con rate limit de 30 por hora, que devuelve una URL prefirmada de S3 con Content-Disposition: attachment.

# ejemplos del repo

~/src/lib/photo-processing.tsts

.rotate() sin argumentos aplica la orientación del EXIF; sharp no copia los metadatos a la salida salvo que se lo pidas, así que el GPS desaparece. clone() reusa la misma decodificación para las dos versiones.

export const FULL_SIZE = 2560;
export const THUMB_SIZE = 640;

const resize = { fit: 'inside', withoutEnlargement: true } as const;

export async function optimizePhoto(input: Buffer) {
  const image = sharp(input, { failOn: 'none' }).rotate();

  const [full, thumb] = await Promise.all([
    image
      .clone()
      .resize({ width: FULL_SIZE, height: FULL_SIZE, ...resize })
      .webp({ quality: 80 })
      .toBuffer({ resolveWithObject: true }),
    image
      .clone()
      .resize({ width: THUMB_SIZE, height: THUMB_SIZE, ...resize })
      .webp({ quality: 70 })
      .toBuffer(),
  ]);

  return { full: full.data, thumb, width: full.info.width, height: full.info.height };
}
~/src/components/photo-gallery/upload-media.tsts

La fecha de la foto sale del EXIF en el navegador. exifr se importa dinámicamente para no sumarlo al bundle de las páginas que no suben fotos.

export async function readTakenAt(file: File) {
  if (!isVideo(file)) {
    try {
      const { default: exifr } = await import('exifr');
      const exif = await exifr.parse(file, ['DateTimeOriginal', 'CreateDate']);
      const date = exif?.DateTimeOriginal ?? exif?.CreateDate;
      if (date instanceof Date && !Number.isNaN(date.getTime())) return date;
    } catch {
      // No EXIF: fall back to the file date.
    }
  }
  return new Date(file.lastModified);
}
~/src/components/photo-gallery/upload-media.tsts

La compresión de video corre en el navegador. Si la conversión perdiera una pista (audio o video) o el navegador no puede codificar H.264, devuelve null y se sube el original.

// Videos are re-encoded in the browser before uploading: H.264 (plays everywhere, unlike the
// HEVC iPhones record) with the short side capped at 1080p, at a bitrate that keeps a minute
// around 40 MB instead of the hundreds a 4K phone clip weighs.
const COMPRESSED_MAX_SHORT_SIDE = 1080;
// 60 fps phone clips come out at twice the bitrate; 30 is plenty for event videos.
const COMPRESSED_MAX_FRAME_RATE = 30;
const COMPRESSED_VIDEO_BITRATE = 5_000_000;
const COMPRESSED_AUDIO_BITRATE = 128_000;
// …
    if (!(await canEncodeVideo('avc', { ...size, quality }))) return null;

    const target = new BufferTarget();
    const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target });
    const conversion = await Conversion.init({
      input,
      output,
      tracks: 'primary',
      video: {
        codec: 'avc',
        ...size,
        fit: 'contain',
        frameRate,
        quality,
        // Bake the phone's rotation into the frames so every player shows it upright.
        allowTransformationMetadata: false,
        forceTranscode: true,
      },
      audio: { codec: 'aac', quality: new Quality({ bitrate: COMPRESSED_AUDIO_BITRATE }) },
      showWarnings: false,
    });
    // Keep both picture and sound: Firefox, for one, can't encode AAC and would drop the audio.
    const audioTrack = await input.getPrimaryAudioTrack();
    const keepsAll = [track, audioTrack].every((t) => !t || conversion.utilizedTracks.includes(t));
    if (!conversion.isValid || !keepsAll) return null;
~/src/components/photo-gallery/photo-uploader.tsxtsx

El flujo de un video: comprimir, subir con POST firmado, subir la portada y recién ahí crear el registro.

if (item.video) {
  update(item.key, { status: 'compressing' });
  const compressed = await compressVideo(item.file, (progress) =>
    update(item.key, { progress }),
  ).catch(() => null);
  const file = compressed?.file ?? item.file;

  update(item.key, { status: 'uploading', progress: 0, uploadedSize: compressed?.file.size });
  const { url, fields, key } = await getVideoUploadUrl(file.type, file.size);
  await postFile(url, fields, file, (progress) => update(item.key, { progress }));

  const poster = await getPhotoUploadUrl('poster.jpg', 'image/jpeg');
  await putFile(poster.uploadUrl, item.video.poster, 'image/jpeg');

  created = await createVideo(key, poster.key, {
  // …
~/src/lib/s3.tsts

Con un POST firmado es S3 el que rechaza un archivo de más de maxBytes, aunque alguien manipule el navegador.

export async function getPresignedPost(key: string, contentType: string, maxBytes: number) {
  return createPresignedPost(s3Client, {
    Bucket: S3_BUCKET,
    Key: key,
    Conditions: [
      ['content-length-range', 1, maxBytes],
      ['eq', '$Content-Type', contentType],
    ],
    Fields: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable' },
    Expires: 15 * 60,
  });
}
~/src/lib/gallery-signing.tsts

El vencimiento se redondea a la hora para que la URL no cambie en cada render y se pueda cachear.

export function signGallerySrc(src: string, now = Date.now()) {
  const expiresAt = new Date(Math.ceil(now / HOUR_MS) * HOUR_MS + HOUR_MS);
  if (!isSignedGallerySrc(src)) return { url: src, expiresAt };
  if (!KEY_PAIR_ID || !PRIVATE_KEY) throw new Error('Falta configurar la firma de CloudFront');

  const url = getSignedUrl({
    url: src,
    keyPairId: KEY_PAIR_ID,
    privateKey: PRIVATE_KEY,
    dateLessThan: expiresAt.toISOString(),
  });
  return { url, expiresAt };
}
// …
export const signGalleryItem = <T extends { src: string; thumbSrc: string }>(item: T) => ({
  ...item,
  thumbUrl: signGallerySrc(item.thumbSrc).url,
  fullUrl: signGallerySrc(item.src).url,
});
~/src/components/photo-gallery/photo-card.tsxtsx

En la grilla solo se cargan los thumbnails, y recién cuando están por entrar en pantalla.

{/* eslint-disable-next-line @next/next/no-img-element */}
<img
  src={photo.thumbUrl}
  alt=""
  loading="lazy"
  decoding="async"
  className="h-full w-full object-cover object-top brightness-[0.8] saturate-[0.7] …"
/>
$ cd src/components/photo-gallery → ver el código ↗$ man galeria → documentación oficial ↗
›Eventos · variantes, permisos y calendarioun solo modelo para meetups, coworks, online y multi-día7 ejemplos

# qué es

Un módulo de eventos resuelve siempre lo mismo: publicar qué pasa, cuándo y dónde, decidir quién puede crearlo y gestionarlo, y ayudar a la gente a llegar (agendarlo, encontrar el lugar, inscribirse). La clave de diseño es cubrir muchas variantes con un solo modelo y campos opcionales en vez de tablas distintas por tipo de evento.

# conceptos clave

campos opcionales como variantes
Un endDate nulo es un evento de un día; isOnline cambia la dirección por un link de streaming; capacity nulo es cupo ilimitado. La UI y la validación se adaptan a cada combinación.
soft delete
Borrar un evento solo le pone deletedAt: las inscripciones, charlas y fotos quedan, y todas las consultas filtran deletedAt: null.
permisos por recurso
Además de roles globales (admin), hay permisos sobre cada evento: quién lo creó y quiénes lo organizan.
funciones puras de permisos
Las reglas viven en funciones sin acceso a la base, fáciles de testear; un wrapper aparte busca los datos y las aplica.
iCalendar (.ics)
Formato estándar (RFC 5545) que entienden Google Calendar, Apple Calendar y Outlook. Es texto plano con líneas CLAVE:valor de hasta 75 bytes.
slug reutilizable
Un atajo como /cowork no apunta a un evento fijo sino al próximo que tenga ese slug, ideal para series que se repiten.

# cómo lo usamos acá

El modelo Event cubre todas las variantes: presencial (con placeName, address, city, y un link de Google Maps opcional, googleMapsUrl, del que salen el mapa embebido y el link "abrir en Google Maps") u online (isOnline + streamingUrl); de un día o de varios (endDate); con cupo (capacity) o sin límite; con inscripción propia o externa (externalRegistrationUrl, por ejemplo Luma); con convocatoria de charlas (callForSpeakersEnabled); con un "cupo completo" manual (markedAsFull); con uno o varios flyers (flyerImages, un carrusel que se ordena al subirlo) y sponsors. El schema de Zod pide lugar, ciudad y dirección solo si el evento no es online.

Hay tres niveles de permisos: los admins pueden todo; los ambassadors (isAmbassador) crean eventos y editan o eliminan los que crearon; y cualquier usuario cargado como organizador puede editar el evento y gestionar sus inscripciones, charlas y propuestas. Quien crea un evento queda como organizador automáticamente, y elegir organizadores (con un buscador de miembros) queda para quien lo creó. Los eventos organizados aparecen en el perfil de cada persona.

Convocatoria de charlas: si está activa, el evento muestra "proponer →" y cualquiera con sesión manda una propuesta con título, descripción y uno o más speakers (precompletado con su perfil). Quienes gestionan el evento la aceptan o rechazan (PENDING, ACCEPTED, REJECTED) y con un clic la convierten en una Talk, que también aparece en /charlas. Las nuevas propuestas e inscripciones generan notificaciones in-app para los admins.

Mientras el evento no terminó, la página ofrece "agregar a Google Calendar" (un link de plantilla con fechas en UTC y zona horaria de Buenos Aires; si no hay hora de fin asume una hora y lo avisa) y "descargar .ics", generado por un route handler. Las fechas se muestran en la zona horaria de quien visita, en formato 24 h.

El campo shortcut arma URLs cortas para flyers: /[shortcut] busca el próximo evento con ese slug y redirige a él, con sus propias tarjetas de Open Graph. En /eventos los próximos se muestran "en cartelera" y los pasados en un "museo" de flyers agrupado por año y numerado desde el Nº 001; el badge de estado ("Inscripciones abiertas", "Cupo completo", "En curso") se recalcula en el cliente cada minuto. Cada evento tiene además sus fotos de la galería, sus anuncios y una imagen de Open Graph generada.

# ejemplos del repo

~/prisma/schema.prismaprisma

Las variantes son columnas opcionales del mismo modelo.

model Event {
  id                      String    @id @default(cuid())
  date                    DateTime
  endDate                 DateTime?
  name                    String
  description             String
  city                    String? // Nombre de la ciudad
  address                 String? // Dirección específica (calle, número, etc.)
  placeName               String? // Nombre del lugar (bar, universidad, etc.)
  flyerImages             String[]  @default([])
  googleMapsUrl           String? // Link de Google Maps del lugar; de ahí salen el mapa y los links
  capacity                Int? // Cupo máximo del evento (opcional)
  externalRegistrationUrl String? // URL externa de inscripción (ej: Luma)
  markedAsFull            Boolean   @default(false)
  callForSpeakersEnabled  Boolean   @default(false)
  isOnline                Boolean   @default(false)
  streamingUrl            String?
  shortcut                String? // Slug para URL corta de flyers (ej: "cowork" → /cowork). Reutilizable entre eventos.
  deletedAt               DateTime? // Eliminación lógica
  // …
  createdBy     User?               @relation("UserCreatedEvents", fields: [createdById], references: [id], onDelete: SetNull)
  organizers    EventOrganizer[]
  galleryItems  GalleryItem[]
  registrations EventRegistration[]
  sponsors      Sponsor[]
  announcements Announcement[]
  talkProposals TalkProposal[]
  talks         Talk[]
}
~/src/schemas/event-schema.tsts

superRefine valida reglas que dependen de otro campo: la ubicación es obligatoria solo para eventos presenciales.

.superRefine((data, ctx) => {
  if (!data.isOnline) {
    if (!data.city || data.city.length < 2) {
      ctx.addIssue({
        code: z.ZodIssueCode.too_small,
        minimum: 2,
        type: 'string',
        inclusive: true,
        message: 'La ciudad debe tener al menos 2 caracteres',
        path: ['city'],
      });
    }
    // … lo mismo para placeName y address
~/src/lib/event-permissions.tsts

Reglas puras, sin base de datos: se testean con objetos armados a mano.

export function canCreateEvents(user: EventUser | null | undefined): user is EventUser {
  return !!user && (isSiteAdmin(user) || user.isAmbassador);
}

const isEventCreator = (user: EventUser, event: EventOwnership) =>
  user.isAmbassador && !event.deletedAt && event.createdById === user.id;

// Editar el evento y gestionar sus inscripciones, charlas y propuestas.
export function canEditEvent(user: EventUser | null | undefined, event: EventOwnership) {
  if (isSiteAdmin(user)) return true;
  if (!user || event.deletedAt) return false;
  return (
    isEventCreator(user, event) ||
    event.organizers.some((organizer) => organizer.userId === user.id)
  );
}

// Eliminar el evento y elegir sus organizadores queda para quien lo creó.
export function canDeleteEvent(user: EventUser | null | undefined, event: EventOwnership) {
  if (isSiteAdmin(user)) return true;
  return !!user && isEventCreator(user, event);
}
~/src/lib/event-access.tsts

El wrapper que usan las páginas (getEventManager) y las server actions (requireEventManager).

/** El usuario logueado si puede gestionar el evento, o null. */
export async function getEventManager(eventId: string) {
  const user = (await getCurrentSession())?.user;
  return (await canManageEventById(user, eventId)) ? user! : null;
}

/** Para Server Actions: lanza un error salvo que quien llama gestione el evento. */
export async function requireEventManager(eventId: string) {
  const user = await getEventManager(eventId);
  if (!user) throw new Error('No autorizado');
  return user;
}
~/src/app/(platform)/eventos/[id]/calendario.ics/route.tsts

Una carpeta con punto en el nombre sirve un archivo: /eventos/:id/calendario.ics.

export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const event = await fetchEvent(id);

  if (!event) return new Response('Not found', { status: 404 });

  return new Response(createIcsFile(event), {
    headers: {
      'Content-Type': 'text/calendar; charset=utf-8',
      'Content-Disposition': `attachment; filename="pcn-evento-${event.id}.ics"`,
      'Cache-Control': 'no-store',
    },
  });
}
~/src/lib/google-calendar.tsts
const location = event.isOnline
  ? event.streamingUrl
  : [event.placeName, event.address, event.city].filter(Boolean).join(', ');
// …
const url = new URL('https://calendar.google.com/calendar/r/eventedit');

url.searchParams.set('action', 'TEMPLATE');
url.searchParams.set('dates', `${utcDateTime(event.date)}/${utcDateTime(endDate)}`);
url.searchParams.set('stz', EVENT_TIME_ZONE);
url.searchParams.set('etz', EVENT_TIME_ZONE);
url.searchParams.set('text', event.name);
url.searchParams.set('details', details);
if (location) url.searchParams.set('location', location);
~/src/lib/event-shortcuts.tsts

Varios eventos comparten el slug (cada cowork usa "cowork"); siempre gana el próximo.

export async function findNextEventByShortcut(slug: string) {
  const now = new Date();

  return prisma.event.findFirst({
    where: {
      deletedAt: null,
      shortcut: slug.toLowerCase(),
      OR: [{ date: { gte: now } }, { endDate: { gte: now } }],
    },
    orderBy: { date: 'asc' },
    include: { galleryItems: { where: visibleGalleryItem, select: { src: true }, take: 1 } },
  });
}
$ cd src/app/(platform)/eventos → ver el código ↗
›Eventos · inscripcionescupo, inscripción externa, cancelación y gestión4 ejemplos

# qué es

Inscribirse parece un simple INSERT, pero tiene varios casos: la persona no tiene sesión, ya estaba inscripta, se había dado de baja y vuelve, el evento se llenó, o la inscripción se maneja en otra plataforma. Además, quien organiza necesita ver la lista y el perfil del público.

# conceptos clave

restricción única
@@unique([eventId, userId]) garantiza en la base una sola fila por persona y evento, aunque lleguen dos pedidos a la vez.
reactivar en vez de duplicar
Cancelar pone cancelledAt; volver a inscribirse lo vuelve a null. Así el historial queda y la restricción única se respeta.
condición de carrera
Contar los inscriptos y después insertar son dos pasos: dos personas pueden ver el último lugar libre al mismo tiempo. El error P2002 de Prisma avisa cuando la base frenó un duplicado.
validar en el servidor
La UI esconde el botón cuando no hay cupo, pero la server action vuelve a contar: el cliente nunca es la fuente de verdad.
redirect con intención
Si no hay sesión, el botón manda al login con redirect y autoRegister=true; al volver, la página termina la inscripción sola.

# cómo lo usamos acá

Inscripción propia (cuando el evento no tiene externalRegistrationUrl): hace falta tener cuenta. Sin sesión, el botón lleva a /autenticacion/iniciar-sesion?redirect=/eventos/:id&autoRegister=true (también funciona desde el registro); al volver, EventDetailClient llama a registerEvent sola y muestra un diálogo de confirmación. Con sesión, el botón llama directo a registerEvent, que decide en el servidor si hay lugar o si la persona va a la lista de espera.

registerEvent tiene rate limit (20 cada 10 minutos) y corre en una transacción que bloquea la fila del evento (SELECT … FOR UPDATE), así dos personas no se quedan con el último lugar. Rechaza si ya hay una inscripción activa o si ya está esperando, cuenta las activas contra capacity (o respeta markedAsFull), reactiva una cancelada o crea una nueva, y notifica a los admins. Cancelar (cancelRegistration) solo puede hacerlo la propia persona y es un soft delete; quienes gestionan el evento pueden además borrar una inscripción definitivamente.

Lista de espera propia (EventWaitlistEntry): si no hay lugar, registerEvent suma a la persona al final de la fila y le muestra su posición. Cuando se libera un lugar (alguien cancela, se borra una inscripción o se edita el cupo), promoteFromWaitlist en src/lib/event-waitlist.ts inscribe en orden de llegada a quienes esperan, dentro de la misma transacción, y notifyPromotions les manda un email y avisa a los admins. Las filas no se borran: promotedAt y cancelledAt guardan quién consiguió lugar y quién se bajó. No promueve si el evento está marcado como lleno a mano o ya terminó.

Inscripción externa: si el evento tiene externalRegistrationUrl, el botón abre esa URL (Luma, por ejemplo) y no se cuenta cupo en el sitio; si está marcado como lleno, el botón pasa a ser unirmeAListaDeEspera(); y lleva a la lista de espera de esa plataforma. El evento se considera completo si tiene markedAsFull o si las inscripciones activas llegaron a capacity; la página lo muestra con "Cupo completo" y, si no, con "Quedan N lugares disponibles.".

Quienes gestionan el evento ven /eventos/[id]/inscripciones: totales de activas y canceladas, cuántas son de estudiantes y cuántas de profesionales (según el perfil), una tabla de TanStack Table con búsqueda, orden por nombre y fecha, y acción para borrar, y la lista de espera en el orden en que se va a promover. El panel de admin muestra una barra de inscriptos sobre el cupo para cada evento próximo.

Lo que todavía no tiene, por si querés contribuir: check-in con QR, emails de confirmación o recordatorio y exportar la lista a CSV.

# ejemplos del repo

~/prisma/schema.prismaprisma
model EventRegistration {
  id          String    @id @default(cuid())
  eventId     String
  userId      String // Usuario registrado (requerido)
  cancelledAt DateTime? // Fecha de cancelación (si fue cancelada)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
  user  User  @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([eventId, userId])
  @@index([eventId])
  @@index([userId])
  @@index([cancelledAt])
}
~/src/lib/event-waitlist.tsts

Con la fila del evento bloqueada, los lugares libres pasan a quienes esperan, en orden de llegada.

for (;;) {
  if (event.capacity !== null) {
    const active = await tx.eventRegistration.count({
      where: { eventId: event.id, cancelledAt: null },
    });
    if (active >= event.capacity) break;
  }

  const next = await tx.eventWaitlistEntry.findFirst({
    where: activeWaitlistWhere(event.id),
    orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
    include: { user: { select: { name: true, email: true } } },
  });
  if (!next) break;
  // …
~/src/components/events/event-detail-client.tsxtsx

Al volver del login con autoRegister=true, la página completa la inscripción una sola vez.

useEffect(() => {
  if (
    autoRegister &&
    isAuthenticated &&
    !isRegistered &&
    !isWaitlisted &&
    !hasAutoRegistered &&
    !externalRegistrationUrl
  ) {
    const performAutoRegister = async () => {
      setHasAutoRegistered(true);
      setIsAutoRegistering(true);

      try {
        await registerEvent(eventId, { skipRedirect: true });
        // …
~/src/app/(platform)/eventos/[id]/page.tsxtsx
const isFull = event.markedAsFull || (capacityInfo !== null && !capacityInfo.available);
$ cd src/actions/events → ver el código ↗
›PCN OS · un escritorio hecho de iframesventanas que son páginas reales, carga diferida y tres modos según la compu5 ejemplos

# qué es

Un "escritorio web" imita un sistema operativo dentro del navegador: barra de menú, dock, ventanas que se mueven y se apilan. La decisión que más pesa es qué hay dentro de cada ventana. Renderizar componentes ahí obliga a duplicar ruteo y estado; usar un iframe por ventana reutiliza las páginas reales tal cual, con su scroll, sus diálogos y su diseño responsive al tamaño de la ventana, pero cada ventana pasa a ser una copia entera de la app. El resto del diseño es administrar ese costo: no cargar el escritorio donde no se usa y ofrecer modos más livianos para compus con pocos recursos.

# conceptos clave

host y ventana
El mismo layout corre en dos roles: el documento de arriba dibuja el escritorio y cada iframe dibuja solo la página. Un atributo en <html> (data-embedded) dice cuál es cuál.
postMessage
Canal entre documentos. Host y ventanas son del mismo origen y se mandan mensajes tipados: a dónde navegó la ventana, que la tocaron, que abra otra ventana, que suene música.
decidir antes del paint
Un script inline en el <head> marca atributos en <html> antes del primer paint, así el CSS muestra el layout correcto desde el primer frame. Decidirlo en JavaScript después de hidratar siempre parpadea.
import() diferido
Un import() dinámico crea un chunk aparte que solo se descarga cuando se pide. Pedirlo apenas corre el módulo adelanta la descarga a antes de la hidratación.
degradación progresiva
Ante poco hardware, apagar primero lo más caro (más iframes vivos, desenfoques, trabajo en cada frame) y dejar el resto, en vez de un todo o nada.
frames largos
Un frame de más de 50 ms (menos de 20 fps) es un tirón visible. Medir qué proporción de frames son largos distingue una compu lenta de una pantalla limitada a 30 fps.

# cómo lo usamos acá

En pantallas de 1024px o más, PcnOs reemplaza el layout clásico. Cada programa del dock abre una ventana OsWindow con un iframe de la URL real; el estado vive en un useReducer (abrir, enfocar, minimizar, maximizar, mover, redimensionar) y el orden de apilado es un array de ids. Cada ventana guarda src (con la que se creó el iframe, fija para no recargarlo) y path (dónde está ahora). Se mueven desde la barra de título y se redimensionan desde cualquier borde o esquina con pointer events: mientras se arrastra, la ventana se actualiza directo en el DOM una vez por frame (translate al mover) y recién al soltar pasa al reducer, así el escritorio no se re-renderiza en cada movimiento. Siempre quedan dentro del escritorio y con un tamaño mínimo. La sesión (ventanas abiertas, su página, posición, tamaño, estado y orden) se guarda en sessionStorage (os-session.ts): al recargar vuelven todas donde estaban, escaladas si cambió la pantalla, y la de la URL queda al frente.

OsBridge corre dentro de cada ventana: avisa a dónde navegó y cuándo la tocaron, y en fase de captura intercepta los links que deben abrir otra ventana (perfiles, detalle de eventos) antes de que <Link> navegue. El cursor hacker se dibuja una sola vez en el escritorio: las ventanas le reportan el puntero.

El variant de Tailwind os: combina el media query con los atributos de <html>; el servidor manda el escritorio (hidden os:block) y el layout clásico (os:hidden) y el CSS elige. Después de hidratar, OsGate desmonta el árbol que no se ve.

El escritorio carga en dos partes: el fondo, la barra de menú y el estado se renderizan en el servidor y nunca se desmontan; el dock, las ventanas, los widgets, el launcher y el reproductor (con framer-motion) viven en os-desktop-parts.ts y se importan solo en un escritorio. Celulares, tablets y las páginas dentro de ventanas no los descargan. Como el primer paint no cambia, no hay parpadeo.

Hay tres modos: completo, liviano y clásico. El script del <head> elige antes del paint: la elección guardada, o liviano si la compu tiene 4 núcleos o menos, 4 GB o menos, o ahorro de datos. Si nadie eligió, el escritorio además mide sus frames unos segundos: si traba pasa a liviano, y si en liviano sigue trabando sugiere el clásico. Cuando el liviano fue automático, un aviso explica que no se está viendo la experiencia completa por los recursos de la compu. El modo se cambia desde el menú PCN_OS, y las ventanas abiertas lo siguen por el evento storage.

El modo liviano apaga, en orden de costo: deja solo 3 ventanas con su página cargada (las demás quedan en pausa y se recargan donde estaban al volver), quita backdrop-filter y el brillo desenfocado del fondo, los widgets y el cursor hacker, la magnificación del dock y las animaciones de ventanas. El clásico no tiene escritorio ni iframes. La documentación completa está en docs/pcn-os-y-rendimiento.md.

# ejemplos del repo

~/src/components/os/pcn-os.tsxts

La parte pesada del escritorio es un chunk aparte. En un escritorio la descarga arranca apenas corre el módulo; en el resto nunca se pide.

let desktopParts: Promise<OsDesktopParts> | null = null;
const loadDesktopParts = () => (desktopParts ??= import('./os-desktop-parts'));

// On a desktop host, start downloading right away, while the page is still hydrating, so the
// dock and the first window show up as early as before. Elsewhere it is never requested.
if (typeof window !== 'undefined' && isOsHost()) void loadDesktopParts();

/** The heavy desktop components, once the desktop is active and they have loaded. */
const useDesktopParts = (enabled: boolean) => {
  const [parts, setParts] = useState<OsDesktopParts | null>(null);
  useEffect(() => {
    if (!enabled || parts) return;
    let cancelled = false;
    void loadDesktopParts().then((loaded) => {
      if (!cancelled) setParts(loaded);
    });
    return () => {
      cancelled = true;
    };
  }, [enabled, parts]);
  return parts;
};
~/src/components/os/pcn-os.tsxts

El escritorio solo acepta mensajes de su propio origen y de sus propias ventanas.

const onMessage = (event: MessageEvent) => {
  if (event.origin !== window.location.origin || !isOsMessage(event.data)) return;
  const id = [...iframes.current].find(([, f]) => f.contentWindow === event.source)?.[0];
  if (!id) return;
  if (event.data.type === 'focus') dispatch({ type: 'focus', id });
  if (event.data.type === 'location')
    dispatch({ type: 'location', id, path: event.data.path, title: event.data.title });
  if (event.data.type === 'open' && viewport)
    dispatch({ type: 'openPath', path: event.data.path, viewport });
  // …
};
~/src/components/os/pcn-os.tsxts

En liviano, solo las ventanas visibles más recientes mantienen su página.

const liveWindowIds = new Set(
  state.order
    .filter((id) => !state.windows.find((win) => win.id === id)?.minimized)
    .slice(-LITE_LIVE_WINDOWS),
);
~/src/components/os/os-window.tsxtsx

Una ventana en pausa se recarga en la página donde estaba, no en la que se abrió.

const [src, setSrc] = useState(win.src);
const [prevSuspended, setPrevSuspended] = useState(suspended);
if (suspended !== prevSuspended) {
  setPrevSuspended(suspended);
  if (suspended) {
    setSrc(win.path);
    setLoaded(false);
  }
}
~/src/components/os/os-performance-notice.tsxts

La medición cuenta frames largos durante 5 segundos; más del 20% es una compu que traba.

const tick = (time: number) => {
  if (!start) {
    start = last = time;
  } else {
    frames += 1;
    if (time - last > LONG_FRAME_MS) longFrames += 1;
    last = time;
  }
  if (time - start < SAMPLE_MS) {
    frame = requestAnimationFrame(tick);
    return;
  }
  if (!aborted && frames > 0 && longFrames / frames > SLOW_SHARE) onSlow();
};
$ cd src/components/os → ver el código ↗$ man pcn-os → documentación oficial ↗
›Rendimiento · primer paint de la homeque se vea antes de que cargue el JavaScript4 ejemplos

# qué es

El primer paint útil (y el LCP, el elemento más grande en pantalla) depende de lo que el HTML del servidor ya muestra. Todo lo que espera a que el JavaScript hidrate llega tarde: un contenido que arranca invisible para animar su entrada, un número que se llena en el cliente, una sección estática mandada como componente de cliente. La receta es renderizar en el servidor todo lo que no es interactivo, animar con CSS en vez de JavaScript, y dejar que el navegador saltee el trabajo de lo que está fuera de pantalla.

# conceptos clave

LCP
Largest Contentful Paint: cuándo aparece el elemento más grande de la pantalla. Un elemento con opacity: 0 no cuenta hasta que se ve, así que una entrada animada en JS retrasa el LCP lo que tarde el bundle.
server vs client component
Un server component se manda como HTML y no suma JavaScript; un 'use client' arrastra al bundle todo lo que importa. Las secciones estáticas no necesitan ser de cliente.
animaciones CSS
Corren en el primer paint, sin esperar al JavaScript, y el navegador las puede componer fuera del hilo principal.
scroll-driven animations
animation-timeline: view() ata el progreso de una animación CSS a cuánto entró el elemento en la pantalla. Reemplaza al IntersectionObserver para apariciones al scrollear.
@property
Registra una custom property con un tipo (por ejemplo <integer>) para que se pueda interpolar en una animación. Con un contador de CSS se imprime un número que sube sin JavaScript.
content-visibility
content-visibility: auto saltea el layout y el pintado de lo que está lejos de la pantalla; contain-intrinsic-size reserva su alto para que el scroll no salte.

# cómo lo usamos acá

La home (page.tsx) solo espera la sesión, que el layout ya leyó; cada sección con datos llega por streaming detrás de un skeleton. El envoltorio de las secciones, home-sections.tsx, es un server component: las secciones estáticas (bento, logros, preguntas, footer…) llegan como HTML y solo hidratan las hojas interactivas, como los reproductores o el botón de instalar la app.

El hero y las apariciones al scrollear usaban framer-motion con opacity: 0 inicial, así que la página aparecía recién después de hidratar. Ahora el hero usa las clases de tailwindcss-animate (el título solo se desliza, sin fade, porque es lo más grande de la pantalla) y Reveal es un div con una animación CSS ligada al scroll; donde no hay soporte, el contenido simplemente está.

Los números del hero (500+ miembros…) se renderizaban vacíos en el servidor porque los escribía NumberTicker en el cliente. Ahora CountUp los anima solo con CSS y el valor real va además en un sr-only para lectores de pantalla.

La foto de fondo del hero es de 3024px y 2.7 MB y se ve al 22% de opacidad bajo dos gradientes, así que se sirve con quality={40}. Next 16 solo acepta las calidades declaradas en images.qualities; sin declararla, la home tiraba un error de React que encontramos bisecando los cambios.

Dos trampas de los server components: una constante exportada desde un archivo 'use client' llega a un server component como referencia de cliente y no como valor (por eso WHATSAPP_GROUP_URL vive en src/data/whatsapp-group.ts), y lo mismo pasa con el script inline que elige el modo de PCN OS, que vive en un archivo sin la directiva.

# ejemplos del repo

~/src/app/globals.csscss

Aparición al scrollear sin JavaScript, y sin layout ni pintado hasta que la sección se acerca.

.reveal {
  content-visibility: auto;
  contain-intrinsic-size: auto 600px;
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal {
      animation: reveal-in linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 35%;
    }
  }
}
~/src/app/globals.csscss

Un entero registrado que se anima de 0 al valor y se imprime con un contador.

@property --count {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}
.count-up {
  --count: var(--count-to);
  counter-reset: count var(--count);
  animation: count-up 1.8s cubic-bezier(0.22, 1, 0.36, 1) 0.3s both;
}
.count-up::after {
  content: counter(count);
}
@keyframes count-up {
  from {
    --count: 0;
  }
}
~/src/components/home/home-hero.tsxtsx
const CountUp = ({ value }: { value: number }) => (
  <span className="tabular-nums tracking-tight">
    <span aria-hidden className="count-up" style={{ '--count-to': value } as CSSProperties} />
    <span className="sr-only">{value}</span>
  </span>
);
~/next.config.mjsjs
images: {
  // 75 is the default; 40 is for the home hero backdrop, shown faded under gradients.
  qualities: [40, 75],
  // …
},
$ cd src/components/home → ver el código ↗$ man rendimiento-home → documentación oficial ↗
›Cursor hackerun puntero propio que sigue al mouse sin trabar2 ejemplos

# qué es

Un cursor personalizado esconde el nativo (cursor: none) y dibuja uno propio con elementos posicionados en fixed que se mueven con cada pointermove. El riesgo es que se sienta lento o trabe: hay que moverlo solo con transform (lo resuelve la GPU, sin recalcular el layout), agrupar las actualizaciones en un requestAnimationFrame y dejar de animar cuando no hay nada que mover. Las animaciones con inercia ("lerp": recorrer una fracción de la distancia que falta en cada frame) tienen que depender del tiempo y no de la cantidad de frames, o en una pantalla de 120 Hz van el doble de rápido que en una de 60 Hz.

# conceptos clave

pointer events
pointermove, pointerdown y pointerup unifican mouse, touch y lápiz; pointerType dice cuál es, y el cursor solo reacciona a mouse.
lerp por tiempo
Si en un frame de 60 Hz se recorre la fracción r, en n frames se recorre 1 - (1 - r)^n. Con n medido desde el último frame, la inercia se siente igual en cualquier pantalla.
loop que se detiene
El requestAnimationFrame se pide solo cuando algo se mueve y deja de pedirse cuando los corchetes llegaron: con el mouse quieto el cursor no gasta nada.
mix-blend-mode: difference
Resta el color del elemento al del fondo: el verde sobre negro sigue verde y sobre un fondo verde da casi negro, así el cursor se ve sobre cualquier cosa.
postMessage
La forma de que una página le hable a otra ventana o iframe del mismo origen. Es asíncrono: el mensaje llega en una tarea posterior.

# cómo lo usamos acá

HackerCursor está montado en el layout raíz y solo se activa con (hover: hover) and (pointer: fine), sin prefers-reduced-motion y en el modo completo de PCN OS (en liviano y clásico queda el nativo). Recién cuando se activa agrega la clase pcn-cursor a <html>, que es la que esconde el cursor nativo: si el JS no corre, la página nunca queda sin puntero. Los campos de texto conservan el I-beam.

El cuadrado sigue al mouse exacto y los corchetes lo persiguen con inercia (RING_FOLLOW, la fracción por frame de 60 Hz, escalada al tiempo real del frame). Sobre algo clickeable los corchetes crecen y una etiqueta dice qué hace el click: cd para links internos, open ↗ para externos, exec para botones, o el texto de un atributo data-cursor. Al soltar el click sale una ráfaga de caracteres hex que se borran solos al terminar su animación.

En PCN OS cada ventana es un iframe y el iframe se queda con los eventos del mouse. Para no tener dos cursores (o uno congelado en el borde), dentro de una ventana el componente no dibuja nada: esconde el nativo y le manda al escritorio cada movimiento por postMessage, con qué hay debajo. El escritorio busca de qué iframe vino el mensaje y le suma su getBoundingClientRect() para pasar las coordenadas a las suyas. Solo la última ventana que reportó puede esconder el cursor, porque un blur puede llegar tarde.

# ejemplos del repo

~/src/components/ui/hacker-cursor.tsxts

La inercia de los corchetes, escalada al tiempo real de cada frame.

const render = (now: number) => {
  frame = 0;
  // Frames elapsed at 60 Hz since the last render; capped so a stalled tab doesn't teleport.
  const frames = lastFrame ? Math.min((now - lastFrame) / (1000 / 60), 4) : 1;
  lastFrame = now;
  // Ease the brackets towards the pointer; snap once they're close enough to stop the loop.
  const follow = ease(RING_FOLLOW, frames);
  ringPos.x += (target.x - ringPos.x) * follow;
  ringPos.y += (target.y - ringPos.y) * follow;
  // …
  if (settled) lastFrame = 0;
  else frame = requestAnimationFrame(render);
};
~/src/components/ui/hacker-cursor.tsxts

El escritorio pasa el puntero que reporta una ventana a sus propias coordenadas.

const frameElement = [...document.querySelectorAll('iframe')].find(
  (iframe) => iframe.contentWindow === event.source,
);
if (!frameElement) return;
// Window coordinates are relative to its page; shift them onto the desktop.
const rect = frameElement.getBoundingClientRect();
const x = rect.left + event.data.x;
const y = rect.top + event.data.y;
moveTo(x, y, event.data);
$ cd src/components/ui/hacker-cursor.tsx → ver el código ↗$ man cursor-hacker → documentación oficial ↗
›PWA · pull to refreshel gesto de recargar que la app instalada no trae1 ejemplo

# qué es

Una PWA instalada corre sin la interfaz del navegador, y con ella pierde el gesto de tirar hacia abajo para recargar. Reimplementarlo es escuchar los toques, decidir cuándo un arrastre es un "pull" (desde arriba de todo, claramente vertical, sin un diálogo ni un scroll interno de por medio), mostrar un indicador con resistencia y, al soltar, volver a pedir los datos sin recargar la página entera.

# conceptos clave

display-mode: standalone
Media query que es verdadero cuando la app corre instalada. En iOS, además, navigator.standalone.
listeners no pasivos
Para cancelar el rebote nativo de iOS hace falta preventDefault en touchmove, que solo funciona si el listener se registró con passive: false.
router.refresh()
Vuelve a renderizar los server components de la ruta actual y mezcla el resultado sin perder el scroll ni el estado de los componentes de cliente.
transiciones async
startTransition con una función async mantiene isPending en verdadero hasta que termina, ideal para sostener un spinner mientras llegan los datos.

# cómo lo usamos acá

PullToRefresh está montado en el layout de (platform) y solo se activa instalada y con puntero táctil; en el navegador queda el gesto nativo. Las páginas con contenido que vive en el repo (cursos, videos, podcast…) están en una lista de excepciones: las páginas nuevas tienen pull to refresh por defecto.

Al soltar pasado el umbral se llama a router.refresh() para los server components y a invalidateQueries() para lo que usa React Query, todo dentro de startTransition, y el indicador gira hasta que isPending vuelve a falso (con un mínimo de 600 ms para que se lea como "se actualizó").

# ejemplos del repo

~/src/components/pull-to-refresh.tsxts
if (pullRef.current >= THRESHOLD) {
  setDistance(THRESHOLD);
  if (navigator.vibrate) navigator.vibrate(10);
  startTransition(async () => {
    router.refresh();
    await Promise.all([
      queryClient.invalidateQueries(),
      new Promise((resolve) => window.setTimeout(resolve, MIN_SPIN_MS)),
    ]);
  });
} else {
  setDistance(0);
}
$ cd src/components/pull-to-refresh.tsx → ver el código ↗$ man pull-to-refresh → documentación oficial ↗

## Herramientas de desarrollo

Frontend
shadcn/ui + Radix · React Hook Form · TanStack Query · TanStack Table · Motion · Sonner · date-fns · Embla Carousel · Lucide
Backend & datos
Prisma · Zod · bcryptjs · Nodemailer · React Email
Imágenes y video
AWS S3 · CloudFront (URLs firmadas) · sharp · exifr · Mediabunny
Testing & calidad
Jest · jest-mock-extended · Playwright · ESLint · Prettier · Husky · lint-staged
Infraestructura & dev
Docker Compose · Dev Containers · Portless · Kamal · GitHub Actions · MailHog

¿Querés conocer más herramientas del ecosistema? ~/herramientas →

## Convenciones de contribución

Flujo de Git
Trabajá en tu propia branch y abrí una PR hacia testing. Nunca se hacen cambios directos en main ni testing. Una vez aprobada la PR, el equipo mergea testing a main.
Título de la PR
El formato es: [ID del ticket de Notion] - Título del ticket en Notion.
pnpm es obligatorio
Este proyecto usa pnpm como package manager. No uses npm ni yarn — el proyecto está configurado para pnpm@9.4.0.
Pre-commit
Prettier formatea automáticamente los archivos modificados vía lint-staged. No necesitás formatearlo a mano.
Pre-push
Antes de pushear, Husky corre pnpm lint, pnpm format:check, pnpm test y pnpm build. Todos deben pasar.

## Testing y calidad

Tests unitarios (Jest)
Más de 90 archivos de test (*.test.ts) colocalizados junto al código que prueban: server actions, src/lib, schemas y route handlers. Se ejecutan con pnpm test o en modo watch con pnpm test:watch.
Tests E2E (Playwright)
Tests end-to-end en tests/ que corren en Chromium, Firefox y WebKit. Se ejecutan con npx playwright test.
Calidad automatizada
El hook pre-push de Husky ejecuta lint, format check, tests y build antes de cada push. No se puede pushear código que rompa alguno de estos checks.

Las técnicas, los checks y todos los casos de prueba, manuales y automatizados, en ~/desarrollo/calidad →

## Estadísticas de colaboración

## Team de desarrollo (18)

  • 01
    Agus

    Agus@agustin-sanc_

    Tech Lead & Sr. Full-Stack Engineer · Dizenz & Eagerworks

  • 02
    Facu

    Facu@FacuBzn_

    Ssr. Backend (JS/TS) · C&S Informática

  • 03
    Mauri

    Mauri@MauriJC_

    Ssr. Full-Stack (JS/TS) · Dizenz

  • 04
    Germán

    Germán@gmanavarro_

    Sr. Backend (JS/TS) · Entropy

  • 05
    Nico

    Nico@nicofuentesg_

    Ssr. Frontend (JS/TS) · Dizenz

  • 06
    Mati

    Mati@MatiasDG539_

    Jr. Engineer · Eagerworks

  • 07
    Lemi

    Lemi@emilianogsh_

    Ssr. QA Engineer · Dizenz

  • 08
    Alejo

    Alejo@Alejoboga20_

    Sr. Full-Stack (TS/Python) · Pendo.io

  • 09
    Carlos

    Carlos@SpagnoloCarlos_

    Sr. Frontend (JS/TS) · WebExport

  • 10
    Maxi

    Maxi@MaxiR23_

    Contributor

  • 11
    Lean

    Lean@contrera-lean_

    Contributor

  • 12
    Facu M.

    Facu M.@facmartoni_

    Sr. Full-Stack & AI Engineer

  • 13
    Chelo

    Chelo@Chelo154_

    Sr. Backend (Python & Java) · Bowery

  • 14
    Benja

    Benja@cortesjpb_

    Sr. Full-Stack Engineer

  • 15
    Vicky

    Vicky@vickygrillo_

    Ssr. QA Engineer · Dizenz

  • 16
    FedericoV21

    FedericoV21_

    Contributor

  • 17
    luki1qq

    luki1qq_

    Contributor

  • 18
    shadownrx

    shadownrx_

    Contributor

## Por qué contribuir

  • ›Mejorá tus habilidades trabajando con código de producción real
  • ›Aprendé de otros desarrolladores de la comunidad
  • ›Fortalecé tu portafolio con contribuciones reales en GitHub
  • ›Conocé a otros miembros de PCN y expandí tu red
  • ›Ayudá a otros desarrolladores a crecer en su carrera

$ ¿Listo para empezar? Elegí un issue o proponé una mejora.

commits

1.918

PRs mergeadas

101

3 abiertas

contribuidores

17

merge (mediana)

2h

de PR abierta a merge

stars

21

forks

9

$ git log --since="52 weeks ago" | wc -l1.236· 15 semanas activas

0 commits · sem. 8 oct
0 commits · sem. 15 oct
0 commits · sem. 22 oct
0 commits · sem. 29 oct
0 commits · sem. 5 nov
0 commits · sem. 12 nov
0 commits · sem. 19 nov
0 commits · sem. 26 nov
0 commits · sem. 3 dic
5 commits · sem. 10 dic
143 commits · sem. 17 dic
281 commits · sem. 24 dic
0 commits · sem. 31 dic
0 commits · sem. 7 ene
0 commits · sem. 14 ene
0 commits · sem. 21 ene
0 commits · sem. 28 ene
0 commits · sem. 4 feb
0 commits · sem. 11 feb
0 commits · sem. 18 feb
0 commits · sem. 25 feb
0 commits · sem. 4 mar
0 commits · sem. 11 mar
0 commits · sem. 18 mar
0 commits · sem. 25 mar
0 commits · sem. 1 abr
36 commits · sem. 8 abr
19 commits · sem. 15 abr
84 commits · sem. 22 abr
3 commits · sem. 29 abr
10 commits · sem. 6 may
0 commits · sem. 13 may
15 commits · sem. 20 may
0 commits · sem. 27 may
20 commits · sem. 3 jun
47 commits · sem. 10 jun
0 commits · sem. 17 jun
1 commits · sem. 24 jun
0 commits · sem. 1 jul
0 commits · sem. 8 jul
0 commits · sem. 15 jul
0 commits · sem. 22 jul
0 commits · sem. 29 jul
0 commits · sem. 5 ago
0 commits · sem. 12 ago
0 commits · sem. 19 ago
0 commits · sem. 26 ago
0 commits · sem. 2 sept
1 commits · sem. 9 sept
0 commits · sem. 16 sept
1 commits · sem. 23 sept
570 commits · sem. 30 sept
oct 20257 oct

$ git log --numstat | awk '{a+=$1; d+=$2} END {print a-d}'141.466líneas de código

$ github-linguist --breakdown

  • TypeScript99%
  • JavaScript0,8%
  • CSS0,5%
  • Shell0,1%
  • Ruby<0,1%
  • Dockerfile<0,1%
  • Makefile<0,1%

$ gh pr list --state merged | sort -rn· 17 personas

  1. 1agustin-sanc████████████████████49 PRs · 1.865 commits+327,9k −119,5kdesde jun 2024~/agustín →
  2. 2FacuBzn████░░░░░░░░░░░░░░░░10 PRs · 9 commits+3,0k −405desde mar 2025~/juan →
  3. 3MatiasDG539████░░░░░░░░░░░░░░░░10 PRs · 9 commits+3,9k −2,8kdesde mar 2025~/matias →
  4. 4MauriJC████░░░░░░░░░░░░░░░░9 PRs · 9 commits+1,9k −247desde abr 2025~/mauricio →
  5. 5nicofuentesg██░░░░░░░░░░░░░░░░░░6 PRs · 6 commits+4,5k −2,1kdesde abr 2025~/nicolas →
  6. 6gmanavarro██░░░░░░░░░░░░░░░░░░4 PRs · 7 commits+2,4k −158desde feb 2025~/german →
  7. 7SpagnoloCarlos█░░░░░░░░░░░░░░░░░░░2 PRs · 2 commits+281 −2desde abr 2025
  8. 8contrera-lean█░░░░░░░░░░░░░░░░░░░2 PRs · 2 commits+87 −41desde dic 2025~/leandro →
  9. 9Alejoboga20█░░░░░░░░░░░░░░░░░░░1 PRs · 1 commits+2,4k −1,8kdesde ago 2024~/alejo →
  10. 10cortesjpb█░░░░░░░░░░░░░░░░░░░1 PRs · 1 commits+5 −11desde ago 2024
  11. 11facmartoni█░░░░░░░░░░░░░░░░░░░1 PRs · 1 commits+109 −1desde sept 2026~/facundo →
  12. 12FedericoV21█░░░░░░░░░░░░░░░░░░░1 PRs · 1 commits+1,5k −299desde sept 2026~/federico →
  13. 13luki1qq█░░░░░░░░░░░░░░░░░░░1 PRs · 1 commits+15 −1desde sept 2026~/lucas →
  14. 14Chelo154█░░░░░░░░░░░░░░░░░░░1 PRs · 1 commits+25 −17desde jul 2025~/marcelo →
  15. 15MaxiR23█░░░░░░░░░░░░░░░░░░░1 PRs · 1 commits+124 −10desde sept 2026~/maxi →
  16. 16shadownrx█░░░░░░░░░░░░░░░░░░░1 PRs · 1 commits+701 −10desde sept 2026~/salvador →
  17. 17emilianogsh█░░░░░░░░░░░░░░░░░░░1 PRs · 1 commits+586 −6desde may 2025~/emiliano →

repo creado hace 2 años · datos de GitHub al 7 de octubre de 2026

  • 01
    Agus

    Agus@agustin-sanc_

    Tech Lead & Sr. Full-Stack Engineer · Dizenz & Eagerworks

  • 02
    Facu

    Facu@FacuBzn_

    Ssr. Backend (JS/TS) · C&S Informática

  • 03
    Mauri

    Mauri@MauriJC_

    Ssr. Full-Stack (JS/TS) · Dizenz

  • 04
    Germán

    Germán@gmanavarro_

    Sr. Backend (JS/TS) · Entropy

  • 05
    Nico

    Nico@nicofuentesg_

    Ssr. Frontend (JS/TS) · Dizenz

  • 06
    Mati

    Mati@MatiasDG539_

    Jr. Engineer · Eagerworks

  • 07
    Lemi

    Lemi@emilianogsh_

    Ssr. QA Engineer · Dizenz

  • 08
    Alejo

    Alejo@Alejoboga20_

    Sr. Full-Stack (TS/Python) · Pendo.io

  • 09
    Carlos

    Carlos@SpagnoloCarlos_

    Sr. Frontend (JS/TS) · WebExport

  • 10
    Maxi

    Maxi@MaxiR23_

    Contributor

  • 11
    Lean

    Lean@contrera-lean_

    Contributor

  • 12
    Facu M.

    Facu M.@facmartoni_

    Sr. Full-Stack & AI Engineer

  • 13
    Chelo

    Chelo@Chelo154_

    Sr. Backend (Python & Java) · Bowery

  • 14
    Benja

    Benja@cortesjpb_

    Sr. Full-Stack Engineer

  • 15
    Vicky

    Vicky@vickygrillo_

    Ssr. QA Engineer · Dizenz

  • 16
    FedericoV21

    FedericoV21_

    Contributor

  • 17
    luki1qq

    luki1qq_

    Contributor

  • 18
    shadownrx

    shadownrx_

    Contributor