fix: two defects that only reproduce over a real network

Neither is visible against localhost, which is why both shipped.

**Install button never appeared on `/login`** — a regression from making the login a
lazy route. `beforeinstallprompt` fires once per page load and is never replayed;
`useInstallPrompt` attached its listener from a `useEffect`, i.e. at mount, and
`InstallAppPrompt` now mounts only after an extra round-trip for its chunk. Locally
that round-trip is a millisecond so the listener still won a race it should never
have been in. Over a real connection the event was long gone, so a user on a slow
link lost the install button entirely.

The listener now lives in `src/lib/installPrompt.ts` and registers when the module
evaluates — `main.tsx` imports it for its side effect before mounting React. The hook
only reads from that store. Reproduced deterministically by delaying
`/assets/LoginPage-*.js` by 1.5s via `route()`: absent before, present after (also at
a 4s delay). `test:pwa` against the HTTPS domain now passes.

**Revenue chart did not animate on a real iPhone.** Measured rather than guessed:
the path animation does run in WebKit, and it triggers with the chart 100% visible at
y=601..715 of an 844px viewport — so neither "broken" nor "fires too early". What
fits is that iOS Safari suspends `requestAnimationFrame` during momentum scrolling
while framer-motion interpolates against wall-clock time: the animation spends its
1.4s without painting a frame and snaps to the end on resume, which looks exactly
like it never ran.

The chart's three animations move to CSS keyframes, which keep their own timeline in
the engine. The component only decides *when* (a `useInView` setting
`data-ld-rev-visible`). `prefers-reduced-motion` resolves in CSS too, and still
resolves to the *drawn* state — a line left at `dashoffset: 1px` with no animation
would be invisible forever. Verified: `getAnimations()` returns a `CSSAnimation`, and
under reduced motion the line renders complete.

Not verified: no physical iPhone here, and Playwright WebKit on Windows does not
reproduce iOS's rAF suspension. This is the standard mitigation and changes nothing
on desktop, but on-device confirmation is still outstanding.

Verified: typecheck clean; landing 13/13, PWA passed, responsive 0 findings, visual
62 screens / 0 errors, unit 39/39, e2e 33/33, admin 17/17, booking 12/12.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
AgendaPro Dev
2026-07-28 15:09:29 -06:00
co-authored by Claude Opus 5
parent 842a9ea07e
commit 4281567207
7 changed files with 317 additions and 52 deletions
+63
View File
@@ -184,6 +184,69 @@ HALLAZGO DE SEGURIDAD, fuera del repo y sin tocar: el token de la API de Coolify
una llave SSH privada en texto plano. Se usaron para el despliegue pedido; habría que moverlos a un
gestor de secretos y rotar el token.
---
## 2026-07-28 — Dos defectos que solo aparecen en producción
Encontrados después del despliegue anterior: ninguno de los dos se ve contra `localhost`.
### 1. El botón «Instalar AgendaMax» no aparecía en `/login` (regresión propia)
`npm run test:pwa` con `PWA_BASE_URL=https://agendamax.urieljareth.org` falló en
`assertInstallAction(..., "login")`. Contra `localhost` pasaba.
Causa: `beforeinstallprompt` se dispara **una sola vez** por carga y no se repite.
`useInstallPrompt` registraba su listener dentro de un `useEffect`, o sea al montar el componente. Al
volver `LoginPage` una ruta `lazy` —cambio de esta misma tanda— `InstallAppPrompt` monta después de
una ida y vuelta de red extra para traer su chunk. En local eso es un milisegundo y el listener llega
a tiempo; sobre la red real el evento ya pasó. No era solo el test: un usuario en conexión lenta
perdía el botón de instalar.
Arreglo: `src/lib/installPrompt.ts`, un almacén que registra el listener al evaluarse el módulo, que
`main.tsx` importa por su efecto antes de montar React. `useInstallPrompt` pasa a leer de ahí
(`eventoDisponible`/`tomarEvento`/`devolverEvento`) y ya no añade listeners propios.
Reproducción determinista, porque el test local no lo veía: interceptar `/assets/LoginPage-*.js` con
`route()` y retrasarlo 1.5s, manteniendo el disparo sintético en `load`+100ms.
- Contra producción con el código viejo: evento a los 490ms, botón AUSENTE.
- Contra el build con el arreglo, retraso de 1.5s y de 4s: botón PRESENTE en los dos.
### 2. La gráfica de ingresos no animaba en un iPhone real (reportado por el usuario)
`aria-label="Ingresos mensuales en tendencia ascendente"`, en la landing.
Lo primero fue descartar hipótesis midiendo, no suponiendo:
- ¿Está roto el `pathLength`? No: en WebKit a 390px con scroll lento la animación **sí** corría
(dasharray 0.14 → 1 en ~1.5s).
- ¿Se dispara demasiado pronto, con el elemento asomando por el borde? **No.** Se dispara con la
gráfica en y=601..715 de un viewport de 844px, **100% visible**. Hipótesis descartada.
- ¿`prefers-reduced-motion`? Reproduce el síntoma **exactamente**: sale ya dibujada y estática. Es
comportamiento por diseño, no un defecto. Queda como causa posible del reporte.
Causa que sí se puede blindar: iOS Safari suspende `requestAnimationFrame` durante el scroll por
inercia. framer-motion interpola contra el reloj de pared, así que la animación consume su 1.4s sin
pintar un fotograma y al reanudarse salta al estado final — se ve idéntico a "nunca animó".
Arreglo: sacar las tres animaciones de la gráfica de framer-motion y pasarlas a keyframes CSS
(`ld-rev-trazo`, `ld-rev-aparecer`, `ld-rev-aterrizar` en index.css). El componente solo decide
cuándo empezar, con un `useInView` que pone `data-ld-rev-visible`. Las animaciones CSS llevan su
propia línea de tiempo en el motor y siguen dibujando cuando rAF no corre. `prefers-reduced-motion`
también se resuelve en CSS, así que el componente ya no necesita el hook.
Verificado: dashoffset 0.93 → 0 en ~1.5s con el área entrando en su retardo; con movimiento reducido
la línea queda dibujada (dashoffset 0), **no invisible**; y `getAnimations()` devuelve una
`CSSAnimation` llamada `ld-rev-trazo` de 1400ms en estado `running`, o sea que la lleva el motor.
**Lo que NO pude verificar:** no tengo un iPhone físico aquí, y Playwright WebKit en Windows no
reproduce la suspensión de rAF durante el scroll de iOS. El arreglo es la mitigación estándar para
ese comportamiento y no cambia nada visualmente en escritorio, pero la confirmación en el dispositivo
queda pendiente del usuario. Si con Reduce Motion **desactivado** sigue sin animar, la causa es otra
y hay que volver a medir en el dispositivo.
Gates tras los dos arreglos: typecheck 0 · test:landing 13/13 · test:pwa passed · audit:responsive 0
hallazgos · audit:visual 62 pantallas 0 fallos 0 errores · unit 39/39 · e2e 33/33 · admin 17/17 ·
booking 12/12.
FOLLOW-UPS DIFERIDOS:
1. `/login` en desktop deja un vacío bajo la tarjeta del formulario (columnas de altura muy distinta).
No es un defecto medible; revisar si molesta en uso real.