Todos los artículos

Integraciones y APIs

Reconstruir con WordPress un tablero de trenes en tiempo real

Por Rolando Escobar, desarrollador WordPress para agencias · · 6 min de lectura

Un tablero de salidas tiene un solo trabajo: mostrar los próximos trenes en este momento, aunque el feed de datos que lo alimenta esté teniendo un mal día. Si se queda en blanco o muestra horarios viejos como si fueran actuales, la gente deja de confiar en él.

Reconstruí la herramienta de trenes en vivo del sitio de Moynihan Train Hall porque la versión anterior no funcionaba de forma confiable. La herramienta muestra las salidas y llegadas de Amtrak, los próximos trenes del Long Island Rail Road con sus paradas y el estado del servicio de LIRR, y se actualiza sin recargar la página. En este artículo te explico el patrón detrás de un tablero así. El código es una versión de tutorial, no el código del cliente.

Cómo encajan las piezas

El tablero tiene cinco partes, y cada una resuelve una falla concreta:

  1. Consulta del lado del servidor. WordPress llama al feed de datos de transporte con wp_remote_get() y un timeout corto. El navegador nunca habla con el feed, así que las claves se quedan en el servidor y tú controlas cada cuánto se consulta.
  2. Dos cachés. Un transient de vida corta absorbe el tráfico. Una copia de la «última versión buena», de vida más larga, mantiene el tablero útil cuando el feed falla.
  3. Fragmentos HTML. PHP genera la tabla, así que la primera carga y cada actualización comparten el mismo marcado y el mismo escapado.
  4. Un endpoint de actualización. Una acción de admin-ajax.php devuelve el fragmento y una línea de estado en JSON.
  5. Un front end accesible. Un botón de actualizar real, una hora de última actualización visible y una región viva cortés.

El script público de la herramienta de Moynihan sigue la misma forma. Dos acciones, ajax_refresh y ajax_amtrak_refresh, devuelven JSON con HTML para salidas, llegadas, próximos trenes y estado del servicio, además de un valor lastUpdate que llena la línea «Last updated».

Consulta el feed con timeout y dos cachés

Guarda la URL del feed y cualquier clave de API en wp-config.php como constantes, nunca en el plugin. Luego envuelve la petición en una función que siempre devuelva algo que el tablero pueda mostrar:

// wp-config.php: define( 'RW_TRANSIT_FEED_URL', '...' ); define( 'RW_TRANSIT_FEED_KEY', '...' );

function rw_board_get_departures(): array {
	$cached = get_transient( 'rw_board_departures' );
	if ( is_array( $cached ) ) {
		return $cached;
	}

	$response = wp_remote_get(
		RW_TRANSIT_FEED_URL,
		array(
			'timeout' => 5,
			'headers' => array(
				'Accept'    => 'application/json',
				'x-api-key' => RW_TRANSIT_FEED_KEY,
			),
		)
	);

	$code = (int) wp_remote_retrieve_response_code( $response );
	$body = json_decode( wp_remote_retrieve_body( $response ), true );

	if ( 200 === $code && is_array( $body ) ) {
		$data = array(
			'trains'     => rw_board_normalize( $body ), // Guarda solo los campos que muestras.
			'fetched_at' => time(),
			'stale'      => false,
		);
		set_transient( 'rw_board_departures', $data, 30 );
		update_option( 'rw_board_last_good', $data, false );
		return $data;
	}

	error_log( 'Transit feed failed: ' . ( is_wp_error( $response ) ? $response->get_error_message() : 'HTTP ' . $code ) );

	// Sirve la última copia buena, marcada como desactualizada, y reintenta el feed en un minuto.
	$fallback = get_option( 'rw_board_last_good' );
	if ( ! is_array( $fallback ) ) {
		$fallback = array( 'trains' => array(), 'fetched_at' => 0 );
	}
	$fallback['stale'] = true;
	set_transient( 'rw_board_departures', $fallback, MINUTE_IN_SECONDS );

	return $fallback;
}

Algunas decisiones ahí importan más de lo que parece:

  • El transient de 30 segundos hace que el feed reciba como máximo dos llamadas por minuto, sin importar cuánta gente esté mirando el tablero.
  • La última copia buena vive en una opción, no en un transient. Los transients pueden desaparecer en cualquier momento, sobre todo con una caché de objetos persistente que expulsa claves cuando le falta memoria. Una opción se queda hasta que la reemplazas. El autoload está desactivado porque solo este código la necesita.
  • Los fallos también se guardan en caché. Cuando el feed está caído, el respaldo se guarda por un minuto para que cada visitante no dispare otra petición lenta que va a fallar.
  • fetched_at registra cuándo llegaron los datos del feed, no cuándo se sirvieron. Esa es la hora que el tablero debe mostrar; si no, un tablero desactualizado parecerá fresco.
  • El timeout es explícito. Una petición que espera 30 segundos a un feed caído mantiene ocupado un worker de PHP durante 30 segundos.

rw_board_normalize() convierte los campos del feed a tu propio formato (un timestamp Unix, un destino, una vía y un estado). Depende por completo del feed, así que no aparece aquí. Tener tu propio formato también evita que las plantillas se rompan cuando el feed le cambia el nombre a un campo.

Genera un fragmento y expón una acción de actualización

Una función genera la tabla, otra arma la línea de estado y un handler de AJAX devuelve ambas. Todo lo que vino del feed se escapa aquí, una sola vez, para que el navegador pueda insertar el resultado tal cual.

function rw_board_render( array $data ): string {
	if ( empty( $data['trains'] ) ) {
		return '<p>' . esc_html__( 'Departures are not available right now.', 'rw-board' ) . '</p>';
	}
	$rows = '';
	foreach ( $data['trains'] as $train ) {
		$rows .= sprintf(
			'<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>',
			esc_html( wp_date( get_option( 'time_format' ), (int) $train['time'] ) ),
			esc_html( $train['destination'] ),
			esc_html( $train['track'] ?: __( 'TBA', 'rw-board' ) ),
			esc_html( $train['status'] )
		);
	}
	$head = '';
	foreach ( array( __( 'Time', 'rw-board' ), __( 'To', 'rw-board' ), __( 'Track', 'rw-board' ), __( 'Status', 'rw-board' ) ) as $label ) {
		$head .= '<th scope="col">' . esc_html( $label ) . '</th>';
	}
	return '<table><caption>' . esc_html__( 'Next departures', 'rw-board' ) . '</caption><thead><tr>'
		. $head . '</tr></thead><tbody>' . $rows . '</tbody></table>';
}

function rw_board_status_text( array $data ): string {
	if ( ! $data['fetched_at'] ) {
		return __( 'Live departures are unavailable right now.', 'rw-board' );
	}
	$time = wp_date( get_option( 'time_format' ), $data['fetched_at'] );
	/* translators: %s: time of the last successful update. */
	$text = $data['stale'] ? __( 'Live data is delayed. Showing departures from %s.', 'rw-board' ) : __( 'Last updated: %s', 'rw-board' );
	return sprintf( $text, $time );
}

add_action( 'wp_ajax_rw_board_refresh', 'rw_board_ajax_refresh' );
add_action( 'wp_ajax_nopriv_rw_board_refresh', 'rw_board_ajax_refresh' );

function rw_board_ajax_refresh(): void {
	check_ajax_referer( 'rw_board_refresh', 'nonce' );
	$data = rw_board_get_departures();
	nocache_headers();
	wp_send_json_success(
		array(
			'departures' => rw_board_render( $data ),
			'status'     => rw_board_status_text( $data ),
		)
	);
}

El hook wp_ajax_nopriv_ está ahí porque los visitantes no tienen sesión iniciada. nocache_headers() evita que proxies y navegadores guarden una respuesta que solo es válida por unos segundos. Configura en Ajustes > Generales la zona horaria de la estación, porque wp_date() la usa.

Una ruta REST con 'permission_callback' => '__return_true' funciona igual de bien que admin-ajax (consulta el capítulo de AJAX del Plugin Handbook para la parte de admin-ajax). En ambos casos, el handler lee de la caché y solo llama al feed cuando el transient vence, así que una ráfaga de clics en actualizar casi no cuesta nada.

Marcado que funciona antes de que cargue JavaScript

Un shortcode imprime la primera versión del tablero en el servidor, así la página muestra las salidas aunque el script falle. También le pasa al script el endpoint, un nonce y el mensaje de error.

add_shortcode( 'rw_departures_board', 'rw_board_shortcode' );

function rw_board_shortcode(): string {
	$data = rw_board_get_departures();

	wp_enqueue_script( 'rw-board', plugins_url( 'board.js', __FILE__ ), array(), '1.0.0', array( 'in_footer' => true ) );
	wp_add_inline_script(
		'rw-board',
		'window.rwBoard = ' . wp_json_encode(
			array(
				'ajaxUrl'   => admin_url( 'admin-ajax.php' ),
				'nonce'     => wp_create_nonce( 'rw_board_refresh' ),
				'errorText' => __( 'Could not refresh. The times shown may be out of date.', 'rw-board' ),
			)
		) . ';',
		'before'
	);

	ob_start();
	?>
	<section class="rw-board" aria-labelledby="rw-board-title">
		<h2 id="rw-board-title"><?php esc_html_e( 'Departures', 'rw-board' ); ?></h2>
		<p role="status" data-board-status><?php echo esc_html( rw_board_status_text( $data ) ); ?></p>
		<button type="button" data-board-refresh>
			<svg aria-hidden="true" focusable="false" width="16" height="16"><use href="#rw-icon-refresh"></use></svg>
			<?php esc_html_e( 'Refresh departures', 'rw-board' ); ?>
		</button>
		<div data-board-target aria-busy="false">
			<?php echo rw_board_render( $data ); // Escapado dentro de rw_board_render(). ?>
		</div>
	</section>
	<?php
	return (string) ob_get_clean();
}

Dos detalles de accesibilidad hacen la mayor parte del trabajo. La línea de estado tiene role="status", que la convierte en una región viva cortés (polite): cuando su texto cambia, los lectores de pantalla lo anuncian en cuanto el usuario está inactivo. La región viva es la línea de estado corta, no la tabla. Si pusieras aria-live en la tabla, cada actualización leería todas las filas. El botón tiene texto visible y su ícono es decorativo, así que queda oculto para las tecnologías de apoyo. Si haces girar el ícono mientras carga, detén la animación con prefers-reduced-motion.

Actualiza con fetch y anuncia con cortesía

El script hace POST al endpoint, reemplaza el fragmento y actualiza la línea de estado. También maneja los botones «View all stops», como los que usa la herramienta de Moynihan para mostrar las paradas de cada tren de LIRR.

const board = document.querySelector('.rw-board');

if (board) {
  const button = board.querySelector('[data-board-refresh]');
  const target = board.querySelector('[data-board-target]');
  const status = board.querySelector('[data-board-status]');
  let busy = false;

  button.addEventListener('click', async () => {
    if (busy) return;
    busy = true;
    button.setAttribute('aria-disabled', 'true');
    target.setAttribute('aria-busy', 'true');

    try {
      const response = await fetch(window.rwBoard.ajaxUrl, {
        method: 'POST',
        body: new URLSearchParams({ action: 'rw_board_refresh', nonce: window.rwBoard.nonce }),
        signal: AbortSignal.timeout(8000),
      });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const json = await response.json();
      if (!json.success) throw new Error('Refresh failed');

      // Recuerda los paneles "View all stops" abiertos, cambia el HTML y vuelve a abrirlos.
      const open = [...target.querySelectorAll('button[aria-controls][aria-expanded="true"]')]
        .map((el) => el.getAttribute('aria-controls'));
      target.innerHTML = json.data.departures; // Escapado en el servidor.
      open.forEach((id) => target.querySelector(`[aria-controls="${CSS.escape(id)}"]`)?.click());

      status.textContent = json.data.status;
    } catch (error) {
      status.textContent = window.rwBoard.errorText;
    } finally {
      busy = false;
      button.removeAttribute('aria-disabled');
      target.setAttribute('aria-busy', 'false');
    }
  });

  // Delegado, para que los botones sigan funcionando después de cada actualización.
  target.addEventListener('click', (event) => {
    const toggle = event.target.closest('button[aria-controls][aria-expanded]');
    const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
    if (!panel) return;
    const expanded = toggle.getAttribute('aria-expanded') === 'true';
    toggle.setAttribute('aria-expanded', String(!expanded));
    panel.hidden = expanded;
  });
}

Notas sobre las decisiones:

  • aria-disabled en lugar de disabled. Deshabilitar el botón mientras tiene el foco puede mandar el foco del teclado al inicio de la página en algunos navegadores. aria-disabled junto con la bandera busy bloquea los clics dobles y deja el foco donde estaba.
  • El foco nunca se mueve. El botón está fuera de la región que se reemplaza, así que presionarlo no te hace perder tu lugar, y la línea de estado informa el resultado.
  • AbortSignal.timeout() deja de esperar a los ocho segundos y muestra el texto de error en lugar de un indicador de carga que nunca termina.
  • innerHTML es seguro aquí solo porque cada valor del fragmento se escapó en PHP. Nunca insertes datos crudos del feed en el navegador.
  • El botón de paradas sigue el patrón disclosure. Genera cada uno como <button type="button" aria-expanded="false" aria-controls="stops-123"> con una lista de paradas hidden que tenga el ID correspondiente. Mantén los ID estables entre actualizaciones para poder restaurar los paneles abiertos.

Si agregas actualización automática con un temporizador, pausa la actualización mientras la pestaña esté oculta y dales a los usuarios una forma de detenerla. WCAG 2.2.2, Pausar, detener, ocultar cubre el contenido que se actualiza solo, y una línea de estado anunciada cada minuto cansa rápido.

Qué vigilar

  • Nonces y caché de página. Los nonces de visitantes sin sesión son válidos entre 12 y 24 horas. Si una caché de página guarda el HTML por más de 12 horas, las actualizaciones pueden empezar a fallar con un 403. Mantén más corta la vida de la caché de esa página o, como este endpoint es público y de solo lectura, quita la verificación del nonce y deja que el transient proteja el feed. Un nonce no es autenticación.
  • Estampidas de caché. En una página con mucho tráfico, varias peticiones pueden encontrar la caché vacía al mismo tiempo y llamar todas al feed. Un bloqueo corto, o actualizar la caché desde una tarea programada en lugar de bajo demanda, lo evita.
  • Di cuándo los datos están desactualizados. Horarios viejos mostrados como actuales son peores que un error. La línea de estado siempre debe decir qué tan viejos son los datos.
  • Registra con cuidado. Registra el código HTTP o el mensaje de error, nunca la URL completa de la petición si la clave viaja en ella.
  • Prueba el camino del fallo. Apunta la constante del feed a una URL que devuelva un 500 y confirma que el tablero conserva la última copia buena y muestra el mensaje de retraso.

Este es el tipo de trabajo que hago para clientes: Plugins e integraciones