Skip to content

feat(events): events UI v3 (W-01…W-11) - #40

Merged
crstian19 merged 36 commits into
mainfrom
feature/gestion-eventos
Sep 16, 2026
Merged

crstian19 merged 36 commits into
mainfrom
feature/gestion-eventos

Conversation

@Saul-Gomez-J

@Saul-Gomez-J Saul-Gomez-J commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Size of the change

61 files, +5 613 / −153. Of the added lines:

lines %
Tests (src/tests/, 12 files) 1 513 27 %
Documentation 0
Production code (.astro, .ts, .tsx, CSS) 4 100 73 %

The v3 documentation lives entirely in the backend repository
(docs/SPEC-v3.md and docs/API.md); none is added here. The tests mostly
cover the admin panel helpers, which is where the logic is: filters, forms,
team grouping and vocabulary labelling.

Fixes on the events UI v3 (W-01…W-11) after a review with subagents.

  • Type as nullable what the backend serializes as null. Four lists
    (registration.specialties, registration.levels, submission.checks,
    submission.prize_requires) arrive as null when the slice is nil, and
    were typed string[]. Now astro check is what enforces the guard.
    Conversely, submission.fields can never be null (a struct by value in
    Go): the fallbacks treating it as optional were dead code.
  • A type guard instead of a text sweep. The regex over ev.<field> did
    not see a destructuring and complained about correct code. The _me.astro
    guard is rewritten because it did not catch what it claimed either: finding
    a call was enough, and .join( already counted.
  • preact-render-to-string becomes a declared devDependency. It came in
    by accident through npm hoisting, which is not a contract.
  • The panel labels specialties and levels like the public site. The
    organiser read devops where the participant read Devops. Nine spots in
    the panel now go through two shared helpers. The vocabulary editor stays raw
    on purpose: that is where the tokens that go into event.json are written.

Verification

tsc --noEmit clean, astro check 153 files / 0 errors and 857 tests, and
each commit on its own as well. The new guards were checked with mutations:
removing a ?? [] makes astro check fail on the destructured const, and the
three forms of the _me.astro antipattern fail the new guard (two of them
passed the old one).

Left out on purpose

The panel editor does not expose location, url or demo_day_end; the
Gauntlet shows up twice in /events; the repo has no LICENSE.

🤖 Generated with Claude Code

https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7

Saul-Gomez-J and others added 23 commits September 6, 2026 15:33
/api/events/* ya no responde 404 a los paths con segmento `admin` cuando
la petición trae cookie nan_session: la autorización (staff o no) la
decide el backend y el proxy propaga su 401/403. Sin cookie sigue siendo
404 sin tocar el backend. La admin key nunca se reenvía (esa vía es solo
directa contra cloud-api).

Para el panel: el content-type text/csv se conserva en ambos sentidos
(import y export de participantes) y Content-Disposition también
(export?download=1). Todo lo demás sigue normalizado a JSON.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
- `src/lib/eventsAdmin.ts`: `fetchStaffSession` (cookie → /api/auth/me, solo
  `role: staff`), `resolveAdminRoute` (404 por rewrite a quien no es staff,
  nunca 403), `adminFetch` (cliente SSR de la API de administración con el
  envelope de §6: warnings, dry_run, fields, detail; nunca lanza),
  `adminHref`, `ADMIN_SCREENS`, `STATUS_LABELS`, `fmtAdminDate`.
- `src/layouts/EventsAdmin.astro`: shell del panel (NanPage noindex/nofollow,
  enlace atrás, email del staff, título y navegación por pantallas del evento).
- `src/pages/events/admin/{index,_index}.astro`: portada del panel con el
  patrón envoltorio + cuerpo `_`; la guardia se resuelve en el fichero de ruta.
- Tests: `eventsAdmin.test.ts` (sesión, guardia, cliente) y `adminRoutes.test.ts`
  (toda ruta del panel llama a la guardia y es SSR; los cuerpos no; no hay /es/).

Solo español, sin variante /es/. Sin cambios de comportamiento fuera de
/events/admin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…/events/admin (SPEC v3 §8)

- Lista de eventos con filtro activos/archivados/todos, contadores, avisos y flash.
- /events/admin/nuevo: formulario completo del event.json con botón "simular" (dry_run).
- /events/admin/{slug}: ficha (fase, ventanas, contadores, avisos de coherencia), editor,
  clonado y archivado/desarchivado (con "forzar" si no está cerrado ni cancelado).
- Patrón POST → 303 con ?ok=&warn= (readFlash); el envoltorio de ruta procesa el
  formulario y el cuerpo _x.astro solo pinta.
- resolveAdminEventRoute: guardia de staff + ficha del evento + 404 de slug.
- Estilos del panel en src/styles/events-admin.css (CSS plano, sin @apply).
- Pruebas de formularios (eventsAdminForms) y guardia de rutas actualizada.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…a ficha del evento (SPEC v3 §4.2 y §8)

- Tarjeta "Estado" en /events/admin/{slug}: estado actual (y previo si está cancelado),
  automatización por fechas, y solo las transiciones que permite §4.2
  (statusSequence/stateTargets espejo de EventModules.StatusSequence).
- Flujo en dos pasos: "Previsualizar avisos" (POST admin/state con dry_run) muestra las
  consecuencias y un botón de confirmación; "Confirmar" hace la transición real y
  redirige con ?ok=estado&warn=…; no_change e invalid_transition se enseñan sin cambiar nada.
- Sweep manual (POST admin/sweep): redirige solo si hubo transición; si no, muestra el
  resultado (automation_off) en la página.
- Sin control de estado en eventos archivados.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…export) en /events/admin/{slug}/participantes (SPEC v3 §6.2 y §8)

Nueva pantalla de participantes del panel, sin JavaScript, sobre la API de
admin de participantes:

- Tabla con filtros (estado, reserva, equipo en formato equipos, búsqueda)
  leídos de `?estado=&reserva=&equipo=&q=` y traducidos a la query del
  backend; enlace de exportación CSV vía el proxy (`/api/events/{slug}/admin/
  participants/export.csv`, conserva text/csv y Content-Disposition).
- Acciones por fila: dar de baja, promover (reserva), pasar a reserva (sin
  equipo ni entrega), reincorporar (con casillas de reserva y, en formato
  individual, recuperar entrega) y editar (`?editar=<id>` abre la tarjeta).
- Alta manual (solo cuentas de NaN; 404 member_not_found y 409
  participant_exists se pintan con el formulario relleno) e importación CSV
  multipart con previsualización (`?dry_run=true`) e informe fila a fila.
- Especialidad y nivel son obligatorios en los formularios cuando el evento
  los define (el backend responde invalid_specialty / invalid_level si no).
- Eventos archivados: solo lectura (sin formularios).
- `eventsAdminForms.ts`: `doneHref` admite la pantalla de vuelta; helpers
  `str`, `on`, `readForm` exportados; textos nuevos de `?ok=` y de avisos.
- Pruebas de filtros y de `handleParticipantsForm` (alta, edición, cambios de
  estado, id inválido, importación multipart con y sin dry_run, sin fichero).

Probado de extremo a extremo con wrangler dev + servidor Go + plataforma
falsa (alta, duplicado, cuenta inexistente, filtros, edición, promote/demote,
baja, reincorporación, previsualización e importación real, export CSV,
evento archivado).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…n /events/admin/{slug}/equipos (SPEC v3 §6.3 y §8)

Nueva pantalla de equipos del panel, sin JavaScript, sobre la API de admin
de equipos:

- Tablero: una columna "Sin equipo" con los inscritos activos sin equipo
  (`GET …/admin/participants?team=none`, sin reserva ni bajas) y una
  columna por equipo (`GET …/admin/teams`) con origen, bloqueo por entrega,
  avisos de tamaño, balance (nivel medio y especialidades) y miembros.
- Mover: cada miembro lleva un selector con los demás equipos y "sin
  equipo" (`POST …/teams/{to}/members` o `DELETE …/teams/{from}/members/
  {pid}`); los de la columna libre solo tienen "Añadir". Los equipos con
  entrega activa no ofrecen mover ni disolver (decisión 19).
- Crear (nombre opcional y casillas con los inscritos libres), renombrar en
  la propia columna, disolver, y generar automáticamente con casilla
  `keep_manual`, previsualización (`dry_run`) en la página y generación real
  con redirección.
- Eventos individuales: aviso en vez de tablero. Archivados: solo lectura.
- `doneHref` elimina avisos repetidos (al mover, el backend puede avisar lo
  mismo del origen y del destino); textos nuevos de `?ok=`.
- Pruebas de `handleTeamsForm` (crear con ids filtrados, renombrar,
  disolver, mover a equipo y a "sin equipo", validación local, 409 del
  backend, previsualización y generación).

Probado de extremo a extremo con wrangler dev + servidor Go + plataforma
falsa (crear con miembros, añadir, sacar, mover entre equipos, renombrar,
previsualizar y generar, disolver, 404 y 409 del backend).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…slug}/entregas (SPEC v3 §6.4 y §8)

Pantalla de entregas del panel de administración sobre la API §6.4:

- src/lib/eventsAdminSubmissions.ts: tipos de la fila de
  GET …/admin/submissions (entrega + owner + owner_emails), etiquetas de
  checks, campos editables y handleSubmissionsForm con las acciones
  verify (POST …/admin/verify), update (PUT parcial: solo los campos que
  trae el formulario), withdraw/restore (POST …/{id}/withdraw|restore),
  check (PUT …/{id}/checks/{name} con {pass,reason} o {reset:true}) y
  prize (PUT …/{id}/prize-eligibility con {eligible,reason} o
  {reset:true}). Validación local de id, nombre de check y valores
  true/false sin tocar el backend.
- entregas.astro (guardia + POST + 303) y _entregas.astro (tabla con
  propietario y emails, enlaces a las URLs, checks con ✓/✗ y asterisco
  si están fijados a mano, puntos automáticos, elegibilidad de premio,
  estado activa/retirada; retirar/restaurar por fila; tarjeta
  ?editar=<id> con los campos visibles según submission.fields, el
  forzado o reinicio de cada check con motivo y la elegibilidad de
  premio; "Verificar todas"). Sin módulo de entregas → aviso; archivado
  → solo lectura.
- FLASH_LABELS: entrega_editada, entrega_retirada, entrega_restaurada,
  check_forzado, check_reiniciado, premio, premio_automatico,
  verificado.
- Pruebas: src/tests/lib/eventsAdminSubmissions.test.ts (8).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
La página pública /events mezcla en su agenda de "este mes" los eventos
publicados de GET /api/events (día y hora en Europe/Madrid, enlace a la
ficha, ubicación) con los estáticos de eventos.json, y añade un bloque de
suscripción al feed iCalendar del backend (SPEC v3 §6.1 bis): botón de
Google Calendar (cid=<feed>), enlace webcal:// para Apple y Outlook y la
URL del feed. Cada fila lleva "+ calendario" (plantilla de Google de ese
evento). La ficha /events/{slug} muestra dónde, el enlace, el demo day con
hora y fin, y "Añadir a mi calendario" (Google o .ics del evento).

- src/lib/eventsCalendar.ts: fetchEvents (SSR, tope de 2,5 s, lista vacía
  si la API falla), enlaces del feed, googleEventURL, eventDetails
  (descripción recortada a 800 caracteres sin perder el enlace),
  toAgendaItems y mergeAgenda.
- src/lib/events.ts: apiBase y ssrHeaders exportados; EventInfo gana
  location y url, EventDates gana demo_day_end.
- src/lib/agenda.ts: AgendaItem gana time y calendar.
- Proxy: deja pasar text/calendar con la caché del backend.
- i18n: tipos challenge/other, textos del bloque de calendario y de la
  ficha (es/en).
- Tests: eventsCalendar.test.ts y el paso del feed por el proxy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
Espejo del B-26 del backend: un evento sin módulo de inscripción va
draft → published → closed (SPEC v3 §4.1).

- eventsAdminForms.ts: statusSequence añade published a los informativos,
  así que el control de estado ofrece publicar, cerrar y retroceder.
- eventsAdmin.ts: etiquetas "Publicado" y "publicado, sin inscripción".
- events.ts: la fase published en el tipo EventPhase.
- Ficha /events/{slug}: panel "Abierto a todos" con el enlace del evento
  como CTA si lo tiene (es/en); la página de entrega lo trata como
  "todavía no abierto".
- Tests de secuencia y destinos de transición actualizados.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
Pantalla /events/admin/{slug}/votos (SPEC v3 §6.5 y §8): resumen de la
votación (estado, abierta o cerrada, ranking público o solo admin, votos
que cuentan y descartados, votantes, peso del voto), el ranking calculado
al momento por GET admin/leaderboard aunque no esté publicado, y la lista
de votos de GET admin/votes en solo lectura (decisión 23: no se anulan ni
se editan). Los únicos atajos son abrir y cerrar la votación (§4.4), los
dos con previsualización dry_run y confirmación: al cerrar se enseña el
ranking que quedaría congelado y público.

- src/lib/eventsAdminVotes.ts: tipos de las dos respuestas, voteStats y
  handleVotesForm (open_preview, open, close_preview, close).
- votos.astro y _votos.astro: ruta y plantilla, con el patrón del resto.
- eventsAdminForms.ts: flash votacion_abierta y votacion_cerrada.
- Tests: eventsAdminVotes.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
Pantalla /events/admin/{slug}/auditoria (SPEC v3 §6.6, §6.7 y §8): las
líneas de audit.jsonl del evento con filtros desde/acción/límite (GET
admin/audit), más recientes primero, con actor, sujeto, avisos y el
antes/después desplegable; y la lista de backups (GET admin/backups) con
restaurar en dos pasos: previsualización dry_run, que solo comprueba que
el backup valida, y confirmación. Archivado: sin restaurar.

- src/lib/eventsAdminAudit.ts: tipos, filtros (lo inválido se ignora en
  vez de provocar un 400), textos auxiliares y handleAuditForm
  (restore_preview, restore).
- auditoria.astro y _auditoria.astro: ruta y plantilla, con el patrón del
  resto.
- eventsAdminForms.ts: flash restaurado.
- Tests: eventsAdminAudit.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
Causa: nan-system.css (el reset y los estilos de elemento p, a, button…)
va sin capa y se carga después de global.css, así que ganaba a cualquier
utilidad de Tailwind, que vive en @layer utilities: mx-auto, mt-*, los
colores de párrafo, la monoespaciada… se ignoraban en las pantallas
escritas con utilidades (eventos, panel admin). Por eso la ficha salía
pegada a la izquierda, sin márgenes y en gris.

- styles/nan-system.css: reset y estilos de elemento dentro de
  @layer base (la misma capa de Tailwind, para que utilities quede
  después). La esquina recta se queda fuera de la capa a propósito: es
  regla de la casa y gana a cualquier rounded-*.
- Ficha del evento rediseñada: columna centrada (max-w-3xl), titular
  Archivo expandida a escala de ficha, descripción grande, fechas y
  dónde/enlace en tarjetas a dos columnas, premio con filete violeta,
  panel de fase con más cuerpo y botones y campos de la casa (.btn,
  .field) en formularios y CTAs.
- RegistrationBar, RegisterForm y LoginForm: mismo tratamiento.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
El backend devolvía "specialties": null y "levels": null en un taller
publicado (bug de clone en el servidor, corregido allí) y la pantalla
de participantes del panel hacía .length sobre null: el SSR fallaba y
la página llegaba en blanco con 200.

- admin/[slug]/_participantes.astro: las dos listas pasan por `?? []`
  una sola vez y los cuatro selects usan esa copia.
- [slug]/_index.astro: el mismo guard al pasar las listas a
  RegisterForm.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
… aviso anterior

La importación es el único POST del panel que escribe y pinta el resultado
en la misma respuesta (hay que enseñar el informe fila a fila), y ahí se
veían dos cosas viejas: los contadores de inscritos/reserva/bajas, que
`resolveAdminEventRoute` carga antes de atender el formulario, y el aviso
`?ok=` de la acción anterior, porque el formulario envía a la URL actual
con su query. Tras importar cinco altas la cabecera seguía diciendo
"2 / 6" y "Participante dado de alta.".

- eventsAdmin: reloadAdminEventView recarga la ficha; si falla se queda la
  anterior (un contador viejo es mejor que un 500).
- participantes.astro: la usa cuando la importación ha escrito.
- readFlash acepta el outcome y calla el flash si en este render se ha
  atendido un POST; las seis pantallas de /events/admin/{slug} lo pasan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
La ficha del panel pintaba la fase efectiva como "inscripción abierta" y
"votación abierta", pero la fase no garantiza la ventana: la inscripción
necesita además estar dentro de sus fechas y la votación que el
interruptor esté puesto (SPEC v3 §5). En el evento de pruebas la cabecera
decía "inscripción abierta" mientras la lista de ventanas de al lado
decía "Inscripción: cerrada".

Ahora las etiquetas de esas dos fases solo nombran la fase; quien manda
sobre lo abierto y lo cerrado es la lista de ventanas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…con mayúscula

En v3 las especialidades y los niveles los escribe quien organiza el
evento (SPEC v3 §3.2), pero `events.options` solo traduce el vocabulario
fijo de v2. En "Mi equipo" del hackatón de pruebas eso mezclaba
"Frontend/Senior" con "devops/senior" y "data/mid" en la misma lista.

`optionLabel` (en i18n, que es puro y se puede probar) traduce lo que
conoce y al resto le sube la inicial; los valores que ya vienen con
mayúscula, como "ML/IA", no se tocan. Lo usan la ficha "mi estado" y el
formulario de inscripción. Los checks de la entrega siguen con el
diccionario a secas: ese vocabulario sí lo fija el backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
El backend manda `invalid_url` con el campo en `data.field` (singular,
SPEC v3 §6.4 paso 6), mientras que el resto de errores usan la lista
`data.fields`. `adminFetch` solo leía la lista, así que al guardar una
entrega con una URL mala el panel enseñaba "Error 400: URL no válida" sin
decir cuál. Ahora se normalizan las dos formas y el aviso ya sale como
"URL no válida · campos: video_url".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
Los atajos de §4.4 dejan abrir la votación en construcción, entrega o
votación (con el interruptor cerrado) y cerrarla en votación, así que en
estado `voting` con la votación aún cerrada valen los dos. La pantalla
pintaba solo uno y ganaba el de cerrar, de modo que desde ese estado —el
que produce `voting_not_open` en el sitio público— no había forma de
abrir la votación sin salir a la ficha. Ahora se pintan los dos botones
cuando ambos aplican.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…mismo

Tres arreglos en el formulario de reasignar a un ausente (/events/{slug}/me),
vistos probándolo en local:

- La lista de ausentes incluía a quien la mira. El backend rechaza señalarse
  a sí mismo (§7.6.3) y el panel solo podía enseñar el error genérico
  "No se pudo registrar". Ahora se ofrecen solo los compañeros y, si no queda
  ninguno, el formulario no se pinta.
- Al ejecutarse la convocatoria (`filled` o `no_pool`) el equipo cambia, pero
  la lista de compañeros venía del servidor y seguía enseñando el equipo
  anterior justo debajo del aviso de que había cambiado. Se añade el botón
  "Ver el equipo actualizado" para recargar la ficha; con la convocatoria
  todavía pendiente no aparece, porque ahí no ha cambiado nada.
- El aviso de `no_pool` daba por hecho equipos de 3. En v3 el tamaño lo
  configura quien organiza, así que ahora dice que el equipo sigue con los
  miembros que quedan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
En v3 todas las fechas del evento son opcionales, pero los avisos de la
ficha y de la página de entrega interpolaban `{date}` sin comprobarlo: un
evento sin `submission_close` mostraba "Entrega tu proyecto antes del ."
y uno sin `submission_open`, "Las entregas abren el .".

Cada aviso que nombra una fecha tiene ahora gemelo sin ella, y la página
elige según haya fecha o no. Visto probando el reto solo
`reto-agente-terminal`, creado sin fechas desde el panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
Cuatro listas (registration.specialties, registration.levels,
submission.checks y submission.prize_requires) llegan como null cuando el
slice va nil en el backend: un event.json escrito a mano, o un evento
anterior al saneo de applyCreateDefaults. Estaban tipadas string[], así que
el compilador no pedía el ?? [] en ninguna lectura.

Tiparlas string[] | null no es defensivo: es lo que llega por el cable, y
hace que astro check marque en rojo cualquier lectura sin red, incluidas las
que pasan por una desestructuración.

Al revés, submission.fields NO puede ser null: en el backend es un struct por
valor y encoding/json siempre lo serializa como objeto. Los fallbacks que lo
trataban como opcional eran código muerto y se van.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
El barrido con regex sobre ev.<campo> era peor que lo que lo sustituye: no
veía una desestructuración (const { checks } = ev.submission), que es el
refactor más natural en un .astro, y protestaba por código correcto
(ev.submission.checks?.map(...)). Con los cuatro campos ya tipados nullable,
quien exige la red es astro check, que sí typechequea el frontmatter.

La guarda de _me.astro se reescribe porque tampoco cazaba lo que decía: la
regla anterior se conformaba con encontrar una llamada, y .join( ya contaba,
así que {[p.specialty, p.level].join(' · ')} pasaba. Ahora cada aparición
vale solo si va envuelta en el helper o si es una condición.

preact-render-to-string pasa a devDependency declarada: entraba de rebote con
@astrojs/preact y funcionaba por el izado de npm, que es un detalle del
gestor de paquetes y no un contrato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…blica

En v3 el vocabulario lo escribe quien organiza el evento (SPEC §3.2), así que
events.options solo traduce el fijo de v2 y el resto se enseña con la inicial
en mayúscula. La parte pública ya lo hacía; el panel no, y el mismo dato se
veía de dos formas: el organizador leía "devops" donde el participante leía
"Devops".

Los nueve puntos del panel pasan ahora por dos helpers compartidos, para que
haya un solo sitio que probar: las fichas de participante y de miembro de
equipo, la celda de la tabla, el resumen de reparto de equipos (que pintaba
devops×3) y los cuatro selects de alta y edición. En los selects solo cambia
el texto: el value sigue siendo el token que se guarda.

Sigue en crudo, a propósito, el editor del vocabulario en la pantalla del
evento: ahí se escriben los tokens que van al event.json, y es el único sitio
donde el valor literal es lo correcto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
@crstian19

Copy link
Copy Markdown
Contributor

Reviewed in depth. npm test (857 tests), astro check (153 files, 0 errors)
and npm run build all pass clean, and I verified the contract against
nan-events-backend#1: the ~30 routes the panel calls all exist and match
one-to-one, dry_run works both as a query param and in the body
(response.go:35), and the PUT is a merge — so location, url,
demo_day_end and the voting switches are not wiped on save. I confirmed
that last one by feeding UpdateEvent the exact body formToEventBody
produces: all five survive (clone() + Unmarshal merges per field, and
protectEventFields pins Voting.Open and LeaderboardPublic on top).

The overall shape is good and well argued in the comments: guards resolved in
the route file, POST→redirect→GET with no client JS, ?ok= filtered against
known keys, ids validated before they reach a URL, admin key never forwarded.
One correctness finding, then smaller things.

1. The nullable-type safety net only covers the public half

eed8127 types specialties, levels, checks and prize_requires as
string[] | null in events.ts so astro check enforces the ?? []. But
AdminEventView in src/lib/eventsAdmin.ts:157-163 declares the same data
from the same backend
as non-nullable string[].

The backend does send null: with a hand-written event.json missing those
keys, GET {slug}/admin serialises "specialties":null, "levels":null,
"checks":null and "prize_requires":nullAdminView() does
v.Event = d.Event with no sanitising and the struct tags have no omitempty.
Backend commit 55ff2a5 fixed the clone path for exactly this reason and its
message names this failure: "el panel web hacía .length sobre ese null y la
pantalla de participantes se quedaba [rota]". The load-from-disk path is still
open.

And the gap is measurable. Dropping the ?? []:

File astro check
_participantes.astro:46-47 (panel) 0 errors
_submission.astro:44-46 (public) 5 errors

So in the panel the ?? [] is discipline, not a compiler guarantee. A new
screen that writes specialties.map(...) passes the check and breaks at
runtime — the same bug 55ff2a5 just fixed.

Worth deciding with nan-events-backend#1 where this gets closed: sanitising on
load in the backend fixes it for every client at once; aligning the four fields
in AdminEventView with events.ts fixes it here. Doing both is cheap.

2. Security

Minor, but the stealth 404 isn't quite stealthy: hasSessionCookie does
cookie.includes('nan_session'), so a decoy cookie stops the proxy returning
404 and makes it call the backend. Verified with curl:

no cookie                               → 404 {"ok":false,"error":"not_found"}
Cookie: basura=xx-nan_session-xx        → reaches the backend
Cookie: no_es_nan_session_de_verdad=1   → reaches the backend

It grants no access (the backend still authorises), but the goal stated in the
comment — "the operation endpoints don't exist as far as the outside is
concerned" — isn't met. Parse the cookie name instead of substring-matching.

Two defence-in-depth notes, both optional:

  • sameOrigin() returns true when neither Origin nor Sec-Fetch-Site is
    present. For POST the safe default is the opposite.
  • The proxy doesn't check the origin itself; today Astro's checkOrigin covers
    it (verified: cross-origin POST with text/plain and with form-urlencod → 403 in both cases), but that's a default astro.config.mjsdoesn't pin, and it now guards admin operations. Anif (!sameOrigin(request))` in the han
    removes the dependency.

3. Reuse

  • eventsAdmin.ts:41-47 redefines apiBase() and ssrHeaders(), which
    events.ts already exports — and this PR exported them for that reason
    (eventsCalendar.ts does reuse them). The admin copy only adds an extra
    parameter nothing uses.
  • bad() is duplicated verbatim in eventsAdminTeams.ts:56 and
    eventsAdminSubmissions.ts:69, plus three inline copies of the same literal
    in Participants, Votes and Audit. Five copies of one error object: it belongs
    in eventsAdminForms.ts, next to readForm/doneHref.
  • SAFE_ID appears three times; SAFE_SEGMENT twice.
  • Duplicated types: LeaderboardRow (events.ts / eventsAdminVotes.ts, differing
    only in owner.type), Check vs SubmissionCheck, inline Owner.
  • Check labels live in three places: CHECKS (forms), CHECK_LABELS
    (submissions) and i18n events.options.

Same drift 558b9f7 fixes for specialties and levels.

4. Cleanup

  • _index.astro:62class:list={['adm-pill', { 'adm-pill-warn': false }]}:
    constant condition, the pill never gets marked. Should this be !w.open?
  • Proxy: text || '{}' returns {} with content-type: text/csv or
    text/calendar when the upstream body is empty.

5. Tests

Solid and well thought through — swapping the text scan for a type guard in
9e02b01 is the right call. Two gaps:

  • resolveAdminEventRoute has no test. eventsAdmin.test.ts covers
    fetchStaffSession, resolveAdminRoute and adminFetch, but
    resolveAdminEventRoute is the guard 6 of the 7 routes use, and the one that
    decides the 404 when the event doesn't exist. reloadAdminEventView isn't
    covered either.
  • The eventToForm → formToEventBody round-trip uses an event with no
    location, url or demo_day_end. The backend merge saves them, but the
    test doesn't document that dependency; adding them would make it explicit.

6. Language

Going forward, let's keep code comments and commit messages in English
across the repo (~1250 comment lines in src/ are currently in Spanish).
Identifiers are already English, so this is about prose.

To be explicit about what does not change: the panel's user-facing strings
stay in Spanish — STATUS_LABELS, FLASH_LABELS, PARTICIPANT_STATUS_LABELS,
button copy. That's product content, not code. No need to retrofit this PR
wholesale; new and touched lines is enough.

Saul-Gomez-J and others added 6 commits September 9, 2026 23:21
…adena

`includes('nan_session')` sobre toda la cabecera Cookie daba por buena
`basura=xx-nan_session-xx`. No concedía acceso (el backend sigue
autorizando) pero rompía el 404 sigiloso de las rutas admin: se podía
distinguir una ruta real de una inventada. fetchStaffSession tenía la
misma comprobación.

Nueva cookieHeaderHasSession() en events.ts que compara el nombre de
cada cookie; hasSessionCookie y fetchStaffSession la usan. Tests con
señuelos en el proxy, en la guardia de staff y en la función.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
… | null

EventInfo (web pública) ya tipaba specialties, levels, checks y
prize_requires como `string[] | null`, y el compilador obligaba al
`?? []`. En el panel eran `string[]`, así que los `?? []` que hay eran
disciplina, no garantía. Con el tipo alineado, astro check vigila
cualquier lectura nueva sin protección (hoy: 0 errores).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…rtidos en un solo sitio

eventsAdmin.ts era una isla: reimplementaba apiBase (idéntica),
ssrHeaders (con un parámetro `extra` que nadie usaba), la regex de
segmentos y su predicado, el tipo Windows, LeaderboardRow (que su propio
comentario decía "mismo formato que el público"), Owner tres veces como
literal y una fmtAdminDate que difería de fmtDate en tres tokens.
login-request.ts repetía apiBase y el Origin, y el json() de los dos
endpoints era idéntico carácter a carácter.

Ahora todo vive en events.ts (safeSegments, Check con forced/reason,
LeaderboardView, fmtUTC) y en un apiResponse.ts nuevo (json). Los
nombres que ya importaban las pantallas (SubmissionCheck, LeaderboardRow,
fmtAdminDate) se conservan como alias o re-export.

astro check 0 errores, 858 tests, build ok.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…hecks en eventsAdminForms

Los cinco manejadores de formularios del admin repetían el mismo preámbulo
(leer el form, sacar la acción, construir la redirección) y cada uno tenía
su propia función de error 400 y sus propias expresiones SAFE_ID/SAFE_CHECK.
Ahora todo sale de eventsAdminForms: beginForm devuelve fd, values, action y
back; badForm construye la respuesta de validación; SAFE_ID y SAFE_CHECK se
exportan una sola vez.

Las etiquetas de los checks también salen de una sola lista (CHECK_DEFS),
de la que se derivan CHECK_LABELS, CHECKS y SCORING_CHECKS.

Sin cambios de comportamiento; los 858 tests siguen pasando.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…ntallas

- Los seis cuerpos del panel de un evento declaraban la misma `interface Props`;
  ahora la comparten como `AdminScreenProps` en eventsAdmin.ts.
- Los cinco cuerpos públicos repetían el bloque "cookie → /me → sesión válida";
  ahora lo hace `eventSession(request, slug)` en events.ts.
- `ownerKind` (equipo / participante) vivía en _votos.astro y como ternario a
  mano en _entregas.astro; ahora sale de eventsAdmin.ts.
- El filtro "desde" de auditoría usa `isoToLocal` en lugar de un `slice(0, 16)`
  sin validar: un timestamp con offset se normaliza a UTC como en el resto de
  formularios.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…rrada

`'adm-pill-warn': false` era una condición constante y la píldora nunca se
pintaba en ámbar. Ahora se marca cuando la ventana está cerrada, como en el
resto del panel (ámbar = atención).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
Saul-Gomez-J and others added 2 commits September 10, 2026 10:33
…s que el formulario no manda

- `resolveAdminEventRoute` es la guarda de seis de las siete rutas del panel y
  no tenía test: staff + evento → ficha y slug del backend; evento inexistente
  → 404 por rewrite; sin staff → 404 sin pedir la ficha.
- `reloadAdminEventView` devuelve la ficha nueva o, si la recarga falla, la
  anterior.
- El test de ida y vuelta del formulario documenta que location, url y
  demo_day_end no van en el cuerpo del PUT y sobreviven porque el backend
  hace un merge por claves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
…español

El criterio del repo es español; los 61 ficheros del PR ya lo cumplían y
quedaban 50 ficheros anteriores (docs, rate limits, waitlist, community
signup, layout de docs y sus tests) con los comentarios en inglés.

Solo cambian comentarios (JSDoc, `//`, `/* */`, `<!-- -->`, `{/* */}` y CSS):
ni código, ni strings, ni nombres de tests, ni URLs. Se conservan tal cual
los identificadores, las etiquetas JSDoc, las directivas y las cadenas
citadas entre comillas o backticks (mucho copy en inglés de las docs que los
tests comprueban literalmente). Verificado quitando los comentarios de las
dos versiones y comparando el resto: idéntico en los 50 ficheros.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
@sre-helmcode

Copy link
Copy Markdown
Contributor

Re-reviewed at 87d2edb. vitest run (862 tests, up from 857), astro check
(154 files, 0 errors) and astro build all pass clean.

Verified rather than taken on trust, and confirmed fixed:

  • §1 nullable lists. 5165a93 types the four fields as string[] | null in
    AdminEventView and EventLike. I re-ran the measurement from the review:
    dropping the ?? [] from _participantes.astro:41-42 now gives 6 errors
    where it gave 0 before. In the panel it's a compiler guarantee now, not
    discipline. nan-events-backend#1 closed the load path on its side too, so
    both halves are covered, which was the ask.
  • §2 cookie parsing. f759bea replaces the substring match with
    cookieHeaderHasSession (events.ts:359), which splits on ; and compares
    the name. Ran it against the three cases from the review:
    basura=xx-nan_session-xx and no_es_nan_session_de_verdad=1 are both
    false, a=1; nan_session=abc is true.
  • §3 reuse. apiBase/ssrHeaders now come from events.ts, the five
    copies of bad() are one badForm (eventsAdminForms.ts:448), SAFE_ID and
    SAFE_SEGMENT live in one place each, and the duplicated types are aliases or
    re-exports. Check labels all derive from a single CHECK_DEFS. Leaving the
    i18n options separate for the public form is the right call: that's
    bilingual product copy, not configuration.
  • §4 the pill. 95407f6, !w.open. Correct.
  • §5 tests. 66adf3c covers resolveAdminEventRoute including the 404 for
    an unknown slug, and reloadAdminEventView including the fallback when the
    reload fails. The round-trip now puts demo_day_end in the event and asserts
    it does not travel in the body (eventsAdminForms.test.ts:96-112), which
    documents the backend-merge dependency. That was the point of the request.

Three things left.

1. §4: text || '{}' is still there, and the passthrough makes it worse

src/pages/api/events/[...path].ts:67 still returns {} for an empty upstream
body. With the content-type passthrough added on lines 55-57, an empty
participants/export.csv or calendar.ics is now served as {} with
content-type: text/csv, so the panel's own download (export?download=1)
hands the operator a file with {} inside it. Returning the body unchanged when
passthrough is true is enough.

2. §2 is fixed but nothing pins it

The fix is right, but neither cookieHeaderHasSession nor hasSessionCookie
appears in any file under src/tests/. For a security finding that's worth four
assertions. Using the repo's own mock pattern:

vi.mock('cloudflare:workers', () => ({ env: { CLOUD_API_URL: 'https://api.test' } }));
// ...
expect(cookieHeaderHasSession('basura=xx-nan_session-xx')).toBe(false);
expect(cookieHeaderHasSession('no_es_nan_session_de_verdad=1')).toBe(false);
expect(cookieHeaderHasSession('a=1; nan_session=abc')).toBe(true);
expect(cookieHeaderHasSession('')).toBe(false);

I ran exactly that against the current branch and it passes. It just needs to
live in the repo, so the next refactor of that helper can't quietly undo it.

3. §6 went the other way, and this one is not a preference

87d2edb is titled "los comentarios de los ficheros anteriores al PR pasan al
español" and translates 50 files from English to Spanish, on the stated grounds
that "el criterio del repo es español". That is not the criterion, and §6 said
so before the commit was written. Stating it as plainly as I can, because I'd
rather over-communicate this than have it drift again:

Everything written for other developers goes in English. No exceptions to
negotiate.
That covers, concretely:

  • code comments of every kind (//, /* */, JSDoc, <!-- -->, {/* */}, CSS
    comments)
  • commit messages and PR titles and descriptions
  • test names and describe / it strings
  • variable, function, type and file names, which are already English
  • READMEs, ADRs and anything else in the repo aimed at whoever reads the code next

The single exception is user-facing product copy, which stays in Spanish
exactly as it is today: STATUS_LABELS, FLASH_LABELS,
PARTICIPANT_STATUS_LABELS, button text, and the i18n catalogues. That is
product content, not code, and none of it changes.

This is the house style across our repos, not a taste call on this PR, and it's
the reason it's worth being firm about: the codebase has to read the same way to
everyone who opens it, including people who join later and don't speak Spanish.

Two separate problems with that commit, then:

  • It applies the criterion backwards, so it needs reverting.
  • It also pulls files into this PR that have nothing to do with events:
    astro.config.mjs, src/components/docs/ApiReference.astro,
    DocsTopBar.astro, RateLimits.astro, Pricing.astro, Models.astro,
    content.config.ts, env.d.ts, layouts/Docs.astro. Even had the criterion
    been the right way round, a repo-wide comment sweep doesn't belong in the
    events PR.

Reverting 87d2edb settles both at once. For this PR's own comment lines I'm
still not asking for a wholesale retrofit in this branch: new and touched lines,
plus every commit message from here on. But the direction is English, and any
future commit that moves prose the other way should be treated as a mistake to
revert rather than a style choice to discuss.

Saul-Gomez-J and others added 3 commits September 11, 2026 09:47
The house style is English for everything written for other developers
(comments, commit messages, test names); user-facing copy stays in Spanish.
87d2edb went the other way and also pulled nine files unrelated to events
into this PR. This reverts it wholesale.

This reverts commit 87d2edb.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
Passthrough responses (text/csv, text/calendar) were still going through
the `text || '{}'` fallback, so an empty participants export or calendar
feed reached the operator as a file with `{}` inside. The fallback now
applies only to JSON responses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
The request-level test from f759bea already covered the decoys through
hasSessionCookie; this adds the four direct assertions on the helper so
a refactor of it cannot quietly undo the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
@Saul-Gomez-J Saul-Gomez-J changed the title Interfaz de eventos v3 (W-01…W-11) feat(events): events UI v3 (W-01…W-11) Sep 13, 2026
@crstian19
crstian19 self-requested a review September 14, 2026 10:43
Saul-Gomez-J and others added 2 commits September 16, 2026 12:20
… (W-12)

Read the new `image_url` from the events API. When present, the event page
renders it under the header and passes it to the layout, which uses it as
og:image / twitter:image instead of the generic NaN card (the 1200x630 size
hints only go out with the generic card). The admin form does not expose the
field; the PUT merge keeps it.

Add the Gentleman Programming workshop cover under `public/img/events/`
(WebP, 1600 px wide, ~16 KB).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
… fallback

Review follow-up for the event cover:

- The page now renders the cover through safeImage (https only; http is
  blocked by the CSP and mixed content in production) and safeUrl in dev,
  where covers point at localhost. Nothing prints the raw field.
- NanBase falls back to the generic card on an empty image, not only on
  undefined.
- Source guards for both in eventsUiGuards; the calendar fixture carries
  image_url; the admin-form comment no longer claims null clears a string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7
@crstian19
crstian19 merged commit 1873ec0 into main Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants