Skip to content

ADR-009: Mecanismo técnico del recorrido guiado (E3)

Date: 2026-09-13 Status: Accepted

Context

doc/plans/2026-09-12-manual-usuario-y-recorrido-guiado.md decide QUÉ es el recorrido guiado (D1-D6, sección E3) pero no especifica CÓMO se implementa: cómo se resalta un control real en pantalla, cómo el runner cruza de /pago a la confirmación de pago sin perder el hilo, qué pasa exactamente la primera vez que alguien entra al sistema, y dónde vive el botón "?". Esas decisiones son las que fija este ADR — las de D1-D6 no se reabren acá.

Restricciones no negociables del plan: el tour nunca ejecuta una acción real (ni un submit, ni una carga de archivo), tiene que poder correr sin datos cargados, y un paso que apunta a un control sin permiso se saltea solo.

Decision

1. Definición de un paso y anclaje

ts
interface TourStep {
  selector: string   // valor de data-tour, ej. "pago-buscador"
  titulo: string
  texto: string
  ruta?: string       // nombre de ruta (router) que este paso necesita; si no coincide, el runner navega antes de resolver el elemento
  modulo?: string     // permiso a chequear con meStore.canRead(); sin esto, el paso siempre corre
}
interface TourDef {
  modulo: string       // mismo módulo que activa el ítem de sidebar — ata el tour a "su" pantalla
  titulo: string
  pasos: TourStep[]
}

Anclaje con data-tour="<pantalla>-<control>" (ej. pago-buscador, pago-iniciar-pago, caja-abrir) — prefijado por pantalla para que dos tours nunca choquen de selector, igual convención de nombres que ya usa el plan.

2. Overlay: bloquea toda la pantalla, nunca deja pasar un click al control real

Se evaluaron dos formas de "recortar" el control resaltado:

  • Recorte real (el control queda clickeable debajo del overlay) — descartada. Un usuario apurado puede confundir "está resaltado" con "hacé click acá" y terminar clickeando el botón de verdad (ej. Confirmar pago) sin querer. Viola la restricción de no ejecutar acciones reales, no solo en el guion del tour sino en la interacción física.
  • Overlay opaco a pantalla completa + un marco visual sobre el rect del control (elegida) — un único <div> fijo (position: fixed; inset: 0) con pointer-events: auto bloquea CUALQUIER click salvo en los propios botones del tour (Siguiente/Anterior/Salir, que viven en la tarjeta de tooltip, por encima del overlay). El control resaltado se dibuja con un outline/box-shadow sobre su getBoundingClientRect() — se ve destacado, pero no es clickeable. Esto es lo que se implementa.

El rect del control y de la tarjeta de tooltip se recalculan con ResizeObserver + listeners de scroll/resize (composable useElementRect) — sin esto, resalta un control que ya no está donde el overlay dice (ej. la pantalla scrolleó).

3. Espera de elemento — mismo mecanismo para "todavía no cargó" y "cambié de pantalla"

Un solo helper, waitForElement(selector, timeoutMs = 4000), resuelve por polling corto (cada 100ms) hasta encontrar el nodo o vencer el timeout. Se usa en dos casos con el mismo código:

  • El paso siguiente pertenece a la misma pantalla pero el elemento depende de datos async (ej. el botón de confirmar pago no existe hasta que la búsqueda trajo un resultado).
  • El paso siguiente declara ruta distinta a la actual: el runner hace router.push() primero, y recién después llama a waitForElement() sobre la pantalla nueva.

Si waitForElement() vence el timeout (el control nunca apareció — normalmente porque el usuario no tiene el permiso y un paso no filtró bien, o un layout cambió), el runner saltea ese paso en vez de trabar el tour ahí para siempre: un tour roto en un paso nunca debe dejar al usuario sin salida.

4. Disparo automático en el primer login

Columna users.onboarding_completed_at (nullable timestamp). Se agrega al payload de GET /me.

UX elegida — banner descartable, no modal bloqueante: la primera vez que onboarding_completed_at es null, AppLayout muestra un banner angosto (no tapa la pantalla) ofreciendo "¿Querés un recorrido guiado de esta pantalla?" con dos botones, "Empezar" y "Ahora no" — cualquiera de los dos marca onboarding_completed_at en el momento (POST /me/recorrido/completar), así que no vuelve a aparecer sin importar la elección. Se evalúa un modal bloqueante y se descarta: interrumpe el primer login con algo que el usuario no pidió, y el plan pide "menos intrusiva" explícitamente. Si la pantalla de aterrizaje del usuario no tiene un tour definido (ej. Dashboard), el banner ni se muestra — se marca completado en silencio, sin ofrecer un tour que no existe.

Reinicio: no existe una pantalla de Perfil en la app todavía, así que en vez de crearla solo para esto, el reinicio vive como una acción chica en el header (junto a "Salir") — POST /me/recorrido/reiniciar, pone onboarding_completed_at en null de nuevo. Superficie mínima nueva; si el proyecto suma una pantalla de Perfil más adelante, esta acción se muda ahí sin cambiar el endpoint.

Enmienda del 2026-09-13 — el recorrido se ofrece en cada visita (desktop), una sola vez (mobile)

Lo de arriba rigió hasta el 2026-09-13. Al usarlo, Carlos pidió que el banner dejara de apagarse para siempre: que "Ahora no" descarte solo esa vez y que al volver a entrar a la pantalla vuelva a aparecer. Con eso, "Reiniciar recorrido" dejó de tener razón de existir —existía solo porque el descarte era permanente— y se sacó del header.

Lo que rige ahora:

  • Descarte por visita, en memoria. OnboardingBanner.vue guarda si se descartó y resetea ese estado al cambiar de módulo: entrar a otra pantalla y volver, recargar, o volver a entrar a la app, vuelven a ofrecer el recorrido. No se persiste en localStorage ni en el backend — el punto es justamente que esté siempre a mano. El watch va sobre el módulo y no sobre la ruta, para que moverse entre dos rutas del mismo módulo (listado de Transacciones ↔ detalle) no repita el ofrecimiento en medio de una operación.
  • Asimetría deliberada con mobile. El banner completo ocupa ~98 px de un fold de ~523 px en un teléfono de 375 px (header de 64 px + barra inferior de 64 px + pb-20 del main), o sea casi un quinto de lo visible, y empuja el primer control real de la pantalla fuera de la vista. Para un cajero que entra a Pago decenas de veces por día eso es un obstáculo, no un recordatorio. En mobile se muestra una tira de una línea (~42 px) una sola vez, mientras onboarding_completed_at siga en null.
  • La reposición permanente en mobile es el botón "?", que ya está en el header también en mobile y ya ofrece "Ver el recorrido de esta pantalla" (§5). Lo único que le faltaba era descubribilidad: se le agregó un punto naranja, visible solo en mobile, cuando la pantalla activa tiene un recorrido disponible.
  • onboarding_completed_at sigue existiendo y conserva su significado —"ya se le ofreció alguna vez"—, pero ahora solo lo usa la tira de mobile. El auto-completado silencioso (pantalla sin tour, o sin permiso) queda igual que antes.
  • POST /me/recorrido/reiniciar queda sin consumidor. Se deja el endpoint con su test en vez de borrarlo, porque la pantalla de Perfil que falta (P2 de la auditoría del 2026-09-13) es su destino natural. Queda anotado acá para que no sea código muerto silencioso: si al llegar a esa pantalla no se usa, se borra ahí.

Se evaluaron y descartaron dos alternativas para mobile: un FAB flotante sobre la barra inferior (no ocupa layout, pero tapa el CTA principal del fold inferior y, repitiéndose en cada visita, molesta más que el banner) y una tira que se colapsa al scrollear (la de más código, agrega un listener de scroll a todas las pantallas, y en pantallas cortas sin scroll nunca colapsa — o sea, 42 px permanentes igual).

5. El botón "?"

Vive en AppLayout.vue (header, global) — no por-vista, para no repetir el mismo componente en cada pantalla. Resuelve el módulo de la ruta activa (route.meta.module) y ofrece hasta dos opciones: "Leer el manual" (siempre, deep-link a /documentacion/manual-usuario#<modulo>) y "Ver el recorrido" (solo si existe un TourDef para ese módulo Y meStore.canRead(modulo)). Sin meta.module en la ruta activa (ej. el propio manual), el botón no ofrece "Ver el recorrido" — solo tiene sentido sobre una pantalla operativa.

6. Mapa de archivos

Backend

  • database/migrations/2026_09_13_000000_add_onboarding_completed_at_to_users_table.php
  • app/Http/Controllers/OnboardingController.php (2 métodos: completar, reiniciar)
  • routes/api.phpPOST /me/recorrido/completar, POST /me/recorrido/reiniciar
  • app/Http/Controllers/MeController.php — sumar onboarding_completed_at a show()

Frontend

  • src/tours/types.tsTourStep/TourDef
  • src/tours/pago.ts (primero, valida el mecanismo) → luego caja.ts, prefondeo.ts
  • src/stores/tour.ts — el runner (estado + iniciar/siguiente/anterior/salir)
  • src/composables/useElementRect.ts
  • src/utils/waitForElement.ts
  • src/components/tour/TourOverlay.vue — montado una vez en App.vue
  • src/components/tour/HelpMenu.vue — sumado a AppLayout.vue
  • src/components/tour/OnboardingBanner.vue — sumado a AppLayout.vue
  • src/services/onboarding.ts
  • data-tour="..." en los elementos reales de PagoView.vue, MatchResultCard.vue, DocumentacionPrePago.vue, DatosPagoForm.vue, DocumentacionPago.vue (tour de Pago); luego CajaView.vue (tour de Caja) y PrefondeoView.vue (tour de Prefondeo Holding — Prefondeo País queda fuera de los 3 tours de "terminado" del plan).

Alternatives Considered

  • Librería de terceros (Shepherd.js, Intro.js, driver.js) — descartada: ninguna resuelve sola la navegación cross-screen contra un SPA con guards de permisos propios, y sumar una dependencia para ~300 líneas de mecanismo propio no se justifica (mismo criterio que el resto del proyecto: no sumar una librería cuando el problema es chico y a medida). El manual (E1) tampoco usó una librería de contenido, solo marked para lo estrictamente necesario (Markdown→HTML).
  • Modal bloqueante en el primer login — descartada, ver punto 4.
  • Recorte real dejando el control clickeable bajo el overlay — descartada, ver punto 2.

Consequences

  • Positivo: mecanismo reusable para cualquier pantalla futura con solo declarar un TourDef nuevo y sumar atributos data-tour — mismo patrón que "un archivo Markdown nuevo" para el manual.
  • Negativo: cada pantalla con tour necesita mantener sus atributos data-tour sincronizados con el archivo del tour — un refactor de UI que borre un data-tour no rompe la build (no es TypeScript), solo deja ese paso saltado en silencio. Mitigado parcialmente por waitForElement() saltando el paso en vez de trabar el tour, pero no hay una alarma automática — queda para una guardia futura (ej. un test que valide que cada selector de cada TourDef existe en el DOM renderizado).
  • Neutral: el reinicio del tour vive en el header en vez de una pantalla de Perfil que no existe todavía — si se crea esa pantalla más adelante, es un cambio de UI, no de contrato de API.

Documentación viva — se actualiza junto con el código, no es un anexo aparte.