From 42815672077d11d9409e65e75cebf1f1284d326b Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Tue, 28 Jul 2026 15:09:29 -0600 Subject: [PATCH] fix: two defects that only reproduce over a real network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .superpowers/sdd/progress.md | 63 ++++++++++++++ CLAUDE.md | 23 ++++++ .../landing/visuals/RevenueLineVisual.tsx | 51 ++++++------ src/index.css | 77 +++++++++++++++++ src/lib/installPrompt.ts | 82 +++++++++++++++++++ src/lib/useInstallPrompt.ts | 69 +++++++++------- src/main.tsx | 4 + 7 files changed, 317 insertions(+), 52 deletions(-) create mode 100644 src/lib/installPrompt.ts diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index 05ca395..3b532ae 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index e12a8ed..a43123a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,6 +290,16 @@ números») es literal. Si cambias `PIE_COLORS`, cambia también el abanico. `prefers-reduced-motion` los `initial` resuelven al **estado final**, no se acortan: un `initial={{opacity:0}}` cuyo `whileInView` nunca corre deja el bloque invisible para siempre. El test lo verifica recorriendo las siete secciones. +- **La gráfica de ingresos es la excepción: se anima con CSS, no con framer-motion.** iOS Safari + suspende `requestAnimationFrame` mientras dura el scroll por inercia, y framer-motion interpola + contra el reloj de pared: la animación gasta su duración sin pintar un fotograma y al reanudarse + salta al estado final. En un iPhone se ve como si nunca hubiera animado — fue un defecto reportado + desde producción, y ningún audit lo detecta porque en WebKit de escritorio sí anima. + [RevenueLineVisual](src/components/landing/visuals/RevenueLineVisual.tsx) solo decide *cuándo* + empieza (un `useInView` que pone `data-ld-rev-visible`); el *cómo* son los keyframes `ld-rev-*` de + [src/index.css](src/index.css), que llevan su propia línea de tiempo en el motor. El trazo usa + `pathLength={1}` para que dasharray/dashoffset sean fracciones. Si añades otra animación de trazo + de línea en la landing, hazla igual. ### Barra lateral @@ -339,6 +349,19 @@ no-GET o cross-origin. Si tocas el service worker, conserva ese bypass (hay un t `pwa-e2e.mjs`) y sube el sufijo de `CACHE_NAME` (`agendamax-shell-vN`) para que el cleanup de `activate` purgue el anterior. Se registra únicamente en producción. Iconos: `npm run generate:pwa-icons`. +**`beforeinstallprompt` se captura a nivel de módulo, no en un hook.** El navegador lo dispara una +sola vez por carga y no lo repite. Como `LoginPage` es una ruta `lazy`, `InstallAppPrompt` monta +después de una ida y vuelta de red extra: un listener registrado al montar llega tarde y el botón +«Instalar AgendaMax» no aparece nunca. Por eso el listener vive en +[src/lib/installPrompt.ts](src/lib/installPrompt.ts), que `main.tsx` importa por su efecto **antes** +de montar React, y [useInstallPrompt](src/lib/useInstallPrompt.ts) solo lee de ese almacén. No +devuelvas el listener a un `useEffect`, y si vuelves eager alguna ruta no asumas que eso lo arregla. + +Detalle de verificación: `npm run test:pwa` contra `localhost` **no** detecta ese defecto, porque el +chunk lazy llega antes que el evento. Solo se ve contra el dominio HTTPS real +(`PWA_BASE_URL=https://…`), donde la latencia abre la ventana. Si tocas el arranque del prompt, +prueba contra producción o retrasa el chunk a propósito con `route()`. + ## Convenciones de trabajo - **No hagas commits salvo que se pidan explícitamente** (política registrada en diff --git a/src/components/landing/visuals/RevenueLineVisual.tsx b/src/components/landing/visuals/RevenueLineVisual.tsx index 9cb99c0..91d56ea 100644 --- a/src/components/landing/visuals/RevenueLineVisual.tsx +++ b/src/components/landing/visuals/RevenueLineVisual.tsx @@ -1,6 +1,6 @@ -import { motion } from "framer-motion"; -import { EASE_EXPO, inView } from "../../../lib/motion"; -import { useReducedMotion } from "../../../lib/useReducedMotion"; +import { useRef } from "react"; +import { useInView } from "framer-motion"; +import { inView } from "../../../lib/motion"; // Serie fija: doce meses con tendencia al alza y ruido creíble. Determinista a // propósito, para que el audit visual capture siempre la misma gráfica. @@ -23,14 +23,29 @@ function pathFrom(serie: number[]): string { .join(" "); } -/** Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve. */ +/** + * Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve. + * + * El trazo lo hacen animaciones CSS (`.ld-rev-*` en index.css) y no `motion.path`. No es + * una preferencia de estilo: iOS Safari suspende `requestAnimationFrame` durante el scroll + * por inercia, y framer-motion interpola contra el reloj de pared, así que la animación + * gastaba su duración sin pintar nada y saltaba al final. En un iPhone se veía como si + * nunca hubiera animado. Este componente solo decide CUÁNDO empieza; el CÓMO es del + * compositor, que sí sigue dibujando. + * + * `prefers-reduced-motion` también se resuelve en CSS, así que ya no hace falta el hook. + */ export function RevenueLineVisual({ className = "" }: { className?: string }) { - const reduced = useReducedMotion(); + const ref = useRef(null); + // Mismo umbral que el resto de la landing, y `once` para que no se repita al volver. + const visible = useInView(ref, inView); const d = pathFrom(SERIE); const area = `${d} L${W - PAD} ${H - PAD} L${PAD} ${H - PAD} Z`; return ( ))} - - + {/* `pathLength={1}` normaliza la longitud: deja que dasharray y dashoffset se + expresen como fracción del trazo, que es lo que animan los keyframes. */} + {/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */} - ); diff --git a/src/index.css b/src/index.css index 4ca81e9..2ea65f7 100644 --- a/src/index.css +++ b/src/index.css @@ -624,6 +624,83 @@ summary { color: transparent; } } + + /* ---- Gráfica de ingresos de la landing ---- + Se anima con CSS y NO con framer-motion a propósito. iOS Safari suspende + `requestAnimationFrame` mientras dura el scroll por inercia, y framer-motion + interpola contra el reloj de pared: la animación consume su duración sin pintar un + solo fotograma y al reanudarse salta al estado final. En un iPhone se ve exactamente + como si nunca hubiera animado, que es el defecto que se reportó. Una animación CSS + lleva su propia línea de tiempo y sí se dibuja. + El easing es el mismo `EASE_EXPO` de src/lib/motion.ts; si cambia uno, cambia el otro. */ + .ld-rev-linea { + stroke-dasharray: 1px 1px; /* con pathLength="1" las unidades son la longitud total */ + stroke-dashoffset: 1px; + } + .ld-rev-area { + opacity: 0; + } + .ld-rev-punto { + /* `fill-box` para que el centro del círculo sea el origen y no el del lienzo SVG. */ + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + } + [data-ld-rev-visible="true"] .ld-rev-linea { + animation: ld-rev-trazo 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; + } + [data-ld-rev-visible="true"] .ld-rev-area { + animation: ld-rev-aparecer 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards; + } + [data-ld-rev-visible="true"] .ld-rev-punto { + animation: ld-rev-aterrizar 0.5s cubic-bezier(0.16, 1, 0.3, 1) 1.3s forwards; + } +} + +@keyframes ld-rev-trazo { + from { + stroke-dashoffset: 1px; + } + to { + stroke-dashoffset: 0px; + } +} +@keyframes ld-rev-aparecer { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes ld-rev-aterrizar { + from { + transform: scale(0); + } + to { + transform: scale(1); + } +} + +/* Con movimiento reducido la gráfica sale ya dibujada. Igual que el resto de la landing, + el estado inicial resuelve al FINAL y no se acorta la transición: si dejara la línea en + `dashoffset: 1px` sin animación, quedaría invisible para siempre. */ +@media (prefers-reduced-motion: reduce) { + .ld-rev-linea, + [data-ld-rev-visible="true"] .ld-rev-linea { + stroke-dashoffset: 0px; + animation: none; + } + .ld-rev-area, + [data-ld-rev-visible="true"] .ld-rev-area { + opacity: 1; + animation: none; + } + .ld-rev-punto, + [data-ld-rev-visible="true"] .ld-rev-punto { + transform: scale(1); + animation: none; + } } /* ---- Anti auto-zoom de iOS (fuera de @layer para ganar a las utilidades) ---- diff --git a/src/lib/installPrompt.ts b/src/lib/installPrompt.ts new file mode 100644 index 0000000..ca67da5 --- /dev/null +++ b/src/lib/installPrompt.ts @@ -0,0 +1,82 @@ +/** + * Captura de `beforeinstallprompt` a nivel de módulo, no dentro de un hook. + * + * El navegador dispara `beforeinstallprompt` **una sola vez** por carga de página, poco + * después del `load`, y no lo repite: no hay forma de volver a pedirlo. Un listener que + * se registra al montar un componente sencillamente se lo pierde si el componente aparece + * más tarde. + * + * Y aparece más tarde: `LoginPage` se carga con `React.lazy`, así que `InstallAppPrompt` + * monta después de una ida y vuelta de red extra para traer su chunk. En local eso es un + * milisegundo y no se nota; sobre una conexión real el evento ya pasó y el botón de + * instalar no aparecía nunca. `pwa-e2e.mjs` contra el dominio HTTPS lo detecta; contra + * `localhost` no, porque ahí el chunk llega antes que el evento. + * + * Por eso el listener se registra al evaluar este módulo, que `main.tsx` importa antes de + * montar React, y el evento queda guardado hasta que alguien lo pida. + */ + +export interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; +} + +let capturado: BeforeInstallPromptEvent | null = null; +let instalado = false; +const suscriptores = new Set<() => void>(); + +function avisar() { + for (const notificar of suscriptores) notificar(); +} + +if (typeof window !== "undefined") { + window.addEventListener("beforeinstallprompt", (event) => { + // `preventDefault` evita el mini-infobar de Chrome; el botón propio queda a cargo. + event.preventDefault(); + capturado = event as BeforeInstallPromptEvent; + avisar(); + }); + window.addEventListener("appinstalled", () => { + capturado = null; + instalado = true; + avisar(); + }); +} + +/** El evento pendiente, o null si el navegador nunca lo disparó. */ +export function eventoDisponible(): BeforeInstallPromptEvent | null { + return capturado; +} + +export function yaInstalado(): boolean { + return instalado; +} + +/** Lo saca del almacén: un `BeforeInstallPromptEvent` solo se puede usar una vez. */ +export function tomarEvento(): BeforeInstallPromptEvent | null { + const evento = capturado; + if (evento) { + capturado = null; + avisar(); + } + return evento; +} + +/** Lo devuelve si `prompt()` falló, para no perder la única oportunidad de instalar. */ +export function devolverEvento(evento: BeforeInstallPromptEvent) { + capturado = evento; + avisar(); +} + +export function marcarInstalado() { + capturado = null; + instalado = true; + avisar(); +} + +export function suscribir(notificar: () => void): () => void { + suscriptores.add(notificar); + return () => { + suscriptores.delete(notificar); + }; +} diff --git a/src/lib/useInstallPrompt.ts b/src/lib/useInstallPrompt.ts index e5fe742..218a617 100644 --- a/src/lib/useInstallPrompt.ts +++ b/src/lib/useInstallPrompt.ts @@ -1,12 +1,15 @@ import { useEffect, useRef, useState } from "react"; +import { + devolverEvento, + eventoDisponible, + marcarInstalado, + suscribir, + tomarEvento, + yaInstalado, +} from "./installPrompt"; export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed"; -interface BeforeInstallPromptEvent extends Event { - prompt: () => Promise; - userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; -} - const DISMISSED_STORAGE_KEY = "agendamax-install-dismissed"; function hasDismissedPrompt() { @@ -44,13 +47,18 @@ function isIOSSafari() { return ios && /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua); } +/** + * El estado inicial ya consulta el almacén de `installPrompt`: cuando este hook corre, el + * evento puede haberse disparado hace rato. No se registra un listener propio aquí a + * propósito — ver el comentario de `src/lib/installPrompt.ts`. + */ export function useInstallPrompt() { const [state, setState] = useState(() => { if (typeof window === "undefined") return "unsupported"; - if (isStandalone()) return "installed"; + if (isStandalone() || yaInstalado()) return "installed"; + if (eventoDisponible()) return hasDismissedPrompt() ? "unsupported" : "available"; return isIOSSafari() ? "ios-instructions" : "unsupported"; }); - const [deferred, setDeferred] = useState(null); const [dismissed, setDismissed] = useState(() => typeof window !== "undefined" && hasDismissedPrompt()); const installInFlight = useRef(false); @@ -60,41 +68,44 @@ export function useInstallPrompt() { return; } - const onBeforeInstallPrompt = (event: Event) => { - event.preventDefault(); - setDeferred(event as BeforeInstallPromptEvent); - if (!hasDismissedPrompt()) setState("available"); - }; - const onInstalled = () => { - setDeferred(null); - clearDismissedPrompt(); - setDismissed(false); - setState("installed"); + const sincronizar = () => { + if (yaInstalado()) { + // Instalar borra el descarte: el aviso ya cumplió su función. + clearDismissedPrompt(); + setDismissed(false); + setState("installed"); + return; + } + if (eventoDisponible()) { + if (!hasDismissedPrompt()) setState("available"); + return; + } + // Sin evento pendiente: se cae al comportamiento de iOS, que nunca lo dispara. + setState(isIOSSafari() ? "ios-instructions" : "unsupported"); }; - window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt); - window.addEventListener("appinstalled", onInstalled); - return () => { - window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt); - window.removeEventListener("appinstalled", onInstalled); - }; + sincronizar(); + return suscribir(sincronizar); }, []); const install = async () => { - if (!deferred || installInFlight.current) return; - const event = deferred; + if (installInFlight.current) return; + const event = tomarEvento(); + if (!event) return; installInFlight.current = true; - setDeferred(null); try { await event.prompt(); const choice = await event.userChoice; - if (choice.outcome === "accepted") setState("installed"); - else { + if (choice.outcome === "accepted") { + marcarInstalado(); + setState("installed"); + } else { persistDismissedPrompt(); setDismissed(true); } } catch { - setDeferred(event); + // Devolver el evento al almacén: es la única oportunidad de instalar de esta carga. + devolverEvento(event); setState("available"); } finally { installInFlight.current = false; diff --git a/src/main.tsx b/src/main.tsx index bc3b336..85e9f08 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +// Importado por su efecto y antes de montar React a propósito: registra el listener de +// `beforeinstallprompt`, que el navegador dispara una sola vez y muy temprano. Si esperara +// a que monte el componente del botón —que vive en una ruta lazy— llegaría tarde. +import "./lib/installPrompt"; import App from "./App.tsx"; import "./index.css";