diff --git a/OBSERVATIONS.md b/OBSERVATIONS.md index 18508c6..f582fa5 100644 --- a/OBSERVATIONS.md +++ b/OBSERVATIONS.md @@ -1,4 +1,4 @@ -# OBSERVATIONS — Sprint 3 +# OBSERVATIONS — Sprint 4 > Hallazgos descubiertos durante el sprint actual. > Este archivo se vacía al cerrar el sprint: los hallazgos se promocionan a TECH_DEBT.md o BACKLOG.md, o quedan resueltos. @@ -18,13 +18,23 @@ ## Hallazgos activos -*(vacío — sprint recién abierto)* +*(vacío)* --- ## Hallazgos resueltos durante el sprint -*(vacío)* +### [RESOLVED] Web Locks API deadlock en supabase-js v2 — biblioteca colgaba indefinidamente + +**Etapa:** 5B (deploy Dokploy) +**Síntoma:** Biblioteca mostraba 0 procesos. Network tab sin llamadas a Supabase después de la primera carga. Import con timeout de 20s. Aparentemente aleatorio — "la primera vez funciona, después no". +**Diagnóstico inicial (asesor):** Race condition entre inicialización de sesión async y fetch de procesos. Correcto en dirección pero incompleto en causa raíz. +**Causa raíz real (Claude Code):** `supabase-js` v2 usa `navigator.locks` (Web Locks API) para coordinar refresh de sesión entre pestañas. Un lock atascado de una pestaña cerrada abruptamente bloqueaba para siempre cualquier llamada futura a `getSession()` o queries. El cliente quedaba en espera indefinida del lock — sin error, sin timeout. +**Fix aplicado:** +- `src/lib/supabase.ts`: cambiar a `processLock` (coordinación en memoria, sin Web Locks API) +- Timeouts con error visible y botón "Reintentar" como red de contención +**Lección para el método:** Síntomas de race condition (funciona la primera vez, no las siguientes) pueden esconder problemas de locks de sistema. Si el network tab no muestra ninguna llamada cuando debería haberlas, sospechar bloqueo a nivel de cliente antes del request, no solo timing de React state. +**Estado:** RESOLVED — Etapas 5 y 5B con OK formal del director (2026-06-18) --- diff --git a/package-lock.json b/package-lock.json index a6fb9ae..3a4b05b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.18", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", @@ -1961,6 +1962,182 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz", + "integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -2065,6 +2242,509 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz", + "integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-arrow": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", diff --git a/package.json b/package.json index 569e23a..402ae7e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.18", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", diff --git a/sprints/sprint-4/ETAPA_6_PROMPT.md b/sprints/sprint-4/ETAPA_6_PROMPT.md new file mode 100644 index 0000000..6086c43 --- /dev/null +++ b/sprints/sprint-4/ETAPA_6_PROMPT.md @@ -0,0 +1,202 @@ +# Sprint 4 — Etapa 6: Identidad en UI — header con usuario + "actualizado por" + +## Contexto + +InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind. +Etapas 1–5B completadas: auth Google, todos los repos en Supabase, RLS granular, UI de visibilidad en biblioteca, fix de Web Locks. + +Esta etapa agrega presencia del usuario en la UI: +1. **AppHeader** con avatar, nombre y logout +2. **"Actualizado por [nombre]"** en cada tarjeta de proceso de la biblioteca + +No hay cambios en PDF, simulación, workspace ni base de datos. + +--- + +## Estado actual + +| Elemento | Estado | +|---|---| +| `AppHeader.tsx` | Estático — solo logo + texto "InQ ROI". No usa `useAuth` | +| Tarjeta de proceso | Tiene timestamp relativo (`updatedAt`) pero sin nombre de persona | +| `updated_by` en `fromRow` | Se escribe en DB pero `fromRow` lo descarta — no llega al tipo `Process` | +| Resolución UUID → nombre | No existe. `ownerId` es UUID crudo, nunca se resuelve a nombre | + +--- + +## Alcance estricto — qué entra en esta etapa + +### 1. AppHeader — usuario logueado + logout + +Modificar `src/components/AppHeader.tsx` para consumir `useAuth()` y mostrar en el extremo derecho del header: + +**Versión mínima:** +- Nombre del usuario (`user.name`) y botón "Cerrar sesión" que llama `authService.signOut()` + +**Versión con wow (implementar esta):** +- Avatar circular con la foto del usuario (`user.avatarUrl`) o iniciales si no hay foto, usando el color naranja InQ `#F59845` como fondo de fallback +- Al hacer click en el avatar, despliega un pequeño dropdown con: + - Nombre completo + email del usuario + - Opción "Cerrar sesión" con ícono `LogOut` de Lucide + +El dropdown debe cerrarse al hacer click fuera. Usar el componente `DropdownMenu` de shadcn/ui. + +**Restricción de marca:** el header usa el gradiente `#F59845 → #ED3E8F` como fondo. El dropdown debe tener fondo blanco/neutro (no gradiente). El texto del nombre en el header (si se muestra fuera del dropdown) va en blanco. + +--- + +### 2. Tipo `Process` — agregar `updatedBy` + resolver nombres en query + +#### a) Actualizar `src/domain/types.ts` + +Agregar al final de la interfaz `Process`: + +```typescript +export interface Process { + // ... campos existentes sin cambios ... + updatedBy: string // 🆕 Sprint 4 — UUID del usuario que hizo el último update +} +``` + +#### b) Actualizar `src/persistence/supabase/process-repo.ts` + +**En `fromRow`:** agregar mapeo del campo: +```typescript +updatedBy: (row.updated_by as string) ?? '', +``` + +**En `getAll()`:** hacer un JOIN con la tabla `users` para traer nombres en una sola query (evitar N+1): + +```typescript +async getAll(): Promise { + await supabase.auth.getSession() // barrera de sincronización (fix Etapa 5B) + + const { data, error } = await supabase + .from('processes') + .select(` + *, + owner:users!owner_id(id, name, avatar_url), + updater:users!updated_by(id, name) + `) + .order('updated_at', { ascending: false }) + + if (error) throw error + return (data ?? []).map(fromRowWithUsers) +} +``` + +#### c) Tipo extendido para display — NO contaminar el dominio + +Definir en `src/persistence/supabase/process-repo.ts` (no en `domain/types.ts`): + +```typescript +// Solo para display — no es parte del dominio puro +export interface ProcessWithUserNames extends Process { + ownerName: string + ownerAvatarUrl?: string + updaterName: string +} +``` + +El dominio `Process` mantiene solo `ownerId` y `updatedBy` (UUIDs). La resolución a nombres es responsabilidad del repositorio, no del dominio. + +**`fromRowWithUsers`**: función nueva que mapea el resultado del JOIN: +```typescript +function fromRowWithUsers(row: ProcessRowWithJoins): ProcessWithUserNames { + return { + ...fromRow(row), + ownerName: (row.owner as { name: string } | null)?.name ?? 'Desconocido', + ownerAvatarUrl: (row.owner as { avatar_url: string } | null)?.avatar_url ?? undefined, + updaterName: (row.updater as { name: string } | null)?.name ?? 'Desconocido', + } +} +``` + +Si el JOIN falla por alguna razón (usuario eliminado, campo nulo), el fallback es `'Desconocido'` — no crashear. + +--- + +### 3. LibraryPage — "Actualizado por [nombre]" en tarjetas + +En `src/features/library/LibraryPage.tsx`: + +**Cambiar** el store/hook que provee los procesos para que use `ProcessWithUserNames` en lugar de `Process` — el tipo ya viene resuelto del repositorio. + +**En la `ProcessCard`**, reemplazar la línea actual: +```tsx +"Modificado {relativeDate(process.updatedAt)}" +``` + +Por: +```tsx +"Actualizado por {process.updaterName} · {relativeDate(process.updatedAt)}" +``` + +**Versión con wow (implementar esta):** +- Si el `updaterName` coincide con el usuario actual (`updaterName === currentUser.name`), mostrar "por vos" en lugar del nombre +- Tooltip al pasar por encima que muestra la fecha completa (no solo relativa) + +--- + +### 4. Workspace — "Actualizado por" en header del proceso (opcional, si cabe en scope) + +Si la estructura del workspace ya tiene un lugar para metadatos del proceso (nombre del proceso, cliente), agregar debajo del nombre: + +``` +Actualizado por [nombre] · hace 2 días +``` + +Si esto requiere cambios en el store o en cómo WorkspacePage carga el proceso, diferirlo — no es bloqueante para el sprint. + +--- + +## Qué NO entra en esta etapa + +- Panel de perfil de usuario (editar nombre, foto) +- Gestión de grupos de usuarios +- ResourcesPanel: UX de catálogo global → Etapa 7 +- Cambios en PDF +- Cambios en simulación +- Tests de integración de auth → Etapa 8 + +--- + +## Orden de ejecución + +1. Actualizar `src/domain/types.ts` — agregar `updatedBy: string` a `Process` +2. Actualizar `src/persistence/supabase/process-repo.ts`: + - `fromRow`: mapear `updated_by` + - Agregar `ProcessWithUserNames` interface + - Agregar `fromRowWithUsers` function + - Modificar `getAll()` para usar JOIN +3. Actualizar el store/hook de biblioteca para propagar `ProcessWithUserNames` +4. Modificar `ProcessCard` en `LibraryPage.tsx` — "Actualizado por [nombre]" +5. Modificar `AppHeader.tsx` — avatar + dropdown con logout +6. (Opcional) "Actualizado por" en workspace si es < 30 min de trabajo +7. `npm run build` — debe pasar sin errores TypeScript +8. `npm run test` — debe mantener 521+ tests verdes +9. Commit: `feat(sprint-4/etapa-6): identidad en UI — header con usuario + actualizado por` + +--- + +## Criterios de validación + +- [ ] AppHeader muestra avatar del usuario logueado (o iniciales con fondo naranja si no hay foto) +- [ ] Click en avatar despliega dropdown con nombre, email y "Cerrar sesión" +- [ ] "Cerrar sesión" funciona — redirige a `/login` +- [ ] Tarjetas de proceso muestran "Actualizado por [nombre]" +- [ ] Si el updater es el usuario actual, muestra "por vos" +- [ ] El JOIN en `getAll()` no rompe el build (TypeScript sin `any` injustificado) +- [ ] `Process.updatedBy` existe en `types.ts` +- [ ] `fromRow` mapea `updated_by` +- [ ] `npm run build` limpio +- [ ] 521+ tests verdes +- [ ] **Validación visual del director** — ver avatar en header y nombres en tarjetas antes del OK formal + +--- + +## Nota sobre el JOIN de Supabase + +El JOIN `users!owner_id(...)` usa la sintaxis de PostgREST para FK explícita. Si la FK no está declarada en el schema de Supabase (aunque exista en PostgreSQL), el join puede fallar. Verificar en Supabase Dashboard → Table Editor → processes que las FK `owner_id → users.id` y `updated_by → users.id` aparecen como relaciones. Si no aparecen, agregar al schema o usar un select con `.eq()` fallback. + +Alternativa si el JOIN falla: hacer `getAll()` + un `supabase.from('users').select('id, name, avatar_url')` separado y mapear en memoria. Menos elegante pero más robusto. Preferir el JOIN pero tener este fallback documentado. diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index f773e5d..6f4b90b 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,6 +1,44 @@ import { Link } from '@tanstack/react-router' +import { LogOut } from 'lucide-react' +import { useAuth } from '@/auth/AuthContext' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu' + +function initials(name: string): string { + const parts = name.trim().split(/\s+/) + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase() + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() +} + +function UserAvatar({ name, avatarUrl }: { name: string; avatarUrl?: string }) { + if (avatarUrl) { + return ( + {name} + ) + } + return ( +
+ {initials(name)} +
+ ) +} export function AppHeader() { + const { user, signOut } = useAuth() + return (
- - InQ ROI - +
+ + InQ ROI + + {user && ( + + + + + + +

{user.name}

+

{user.email}

+
+ + signOut()} className="text-destructive focus:text-destructive"> + + Cerrar sesión + +
+
+ )} +
) } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..db6bd29 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,76 @@ +import * as React from 'react' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { cn } from '@/lib/utils' + +const DropdownMenu = DropdownMenuPrimitive.Root +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuGroup = DropdownMenuPrimitive.Group +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuGroup, + DropdownMenuPortal, +} diff --git a/src/domain/types.ts b/src/domain/types.ts index ca47ad5..cd40dc0 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -19,6 +19,7 @@ export interface Process { groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar") tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear) + updatedBy: string // 🆕 Sprint 4 — UUID del usuario que hizo el último update } export type ActivityType = 'task' | 'subprocess' diff --git a/src/features/import/ImportPage.tsx b/src/features/import/ImportPage.tsx index 0f7f374..f7fc15b 100644 --- a/src/features/import/ImportPage.tsx +++ b/src/features/import/ImportPage.tsx @@ -81,6 +81,7 @@ export function ImportPage() { groupId: targetGroupId, tags: [], ownerId: user!.id, + updatedBy: user!.id, } const activityElements = extractActivityElements(graph) diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx index edd54e8..8a8188b 100644 --- a/src/features/library/LibraryPage.tsx +++ b/src/features/library/LibraryPage.tsx @@ -7,8 +7,8 @@ import { } from 'lucide-react' import { useLibraryStore } from '@/store/library-store' import { useAuth } from '@/auth/AuthContext' -import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' -import { formatCurrency } from '@/lib/format' +import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo' +import { formatCurrency, formatSimulationTimestamp } from '@/lib/format' import type { Process, ProcessGroup, Simulation } from '@/domain/types' import { AppHeader } from '@/components/AppHeader' @@ -123,18 +123,21 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul // ─── Process Card ───────────────────────────────────────────────────────────── -function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, onShared }: { - process: Process +function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, currentUserName, onShared }: { + process: ProcessWithUserNames simulation: Simulation | null | undefined showGroupChip?: boolean groupName?: string onDelete?: () => void currentUserId: string + currentUserName: string onShared?: () => void }) { const navigate = useNavigate() const [menuState, setMenuState] = useState(null) const isOwn = process.ownerId === currentUserId + const updatedByMe = process.updaterName === currentUserName + const fullDate = formatSimulationTimestamp(process.updatedAt) // Cierra el menú al hacer click fuera de él useEffect(() => { @@ -164,9 +167,11 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete,

{process.name || 'Sin nombre'}

-
+
- Modificado {relativeDate(process.updatedAt)} + + Actualizado por {updatedByMe ? 'vos' : process.updaterName} · {relativeDate(process.updatedAt)} +
{(process.tags.length > 0 || showGroupChip) && (
@@ -373,12 +378,14 @@ function HomeView({ onImport, ownerFilter, currentUserId, + currentUserName, onShared, }: { onNavigateToGroup: (groupId: string | null) => void onImport: (groupId?: string) => void ownerFilter: 'all' | 'mine' currentUserId: string + currentUserName: string onShared: () => void }) { const { groups, processes, settings, createGroup, isLoading, error, load } = useLibraryStore() @@ -512,6 +519,7 @@ function HomeView({ showGroupChip groupName={group?.name} currentUserId={currentUserId} + currentUserName={currentUserName} onShared={onShared} /> ) @@ -614,11 +622,12 @@ function HomeView({ // ─── ProcessCardWithSim (carga simulación lazy) ─────────────────────────────── -function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, onShared }: { - process: Process +function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, currentUserName, onShared }: { + process: ProcessWithUserNames showGroupChip?: boolean groupName?: string currentUserId: string + currentUserName: string onShared: () => void }) { const { getLatestSimulation, deleteProcess } = useLibraryStore() @@ -640,6 +649,7 @@ function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, groupName={groupName} onDelete={() => deleteProcess(process.id)} currentUserId={currentUserId} + currentUserName={currentUserName} onShared={onShared} /> ) @@ -651,12 +661,14 @@ function GroupView({ groupId, onBack, currentUserId, + currentUserName, ownerFilter, onShared, }: { groupId: string | null onBack: () => void currentUserId: string + currentUserName: string ownerFilter: 'all' | 'mine' onShared: () => void }) { @@ -721,6 +733,7 @@ function GroupView({ key={p.id} process={p} currentUserId={currentUserId} + currentUserName={currentUserName} onShared={onShared} /> ))} @@ -773,6 +786,7 @@ export function LibraryPage() { } const currentUserId = user!.id + const currentUserName = user!.name const totalGroups = groups.length return ( @@ -841,6 +855,7 @@ export function LibraryPage() { onImport={handleImport} ownerFilter={ownerFilter} currentUserId={currentUserId} + currentUserName={currentUserName} onShared={() => showToast('Proceso compartido con el equipo')} /> ) : ( @@ -848,6 +863,7 @@ export function LibraryPage() { groupId={activeGroupId} onBack={navigateHome} currentUserId={currentUserId} + currentUserName={currentUserName} ownerFilter={ownerFilter} onShared={() => showToast('Proceso compartido con el equipo')} /> diff --git a/src/persistence/supabase/process-repo.ts b/src/persistence/supabase/process-repo.ts index ecdc5d4..29fe31b 100644 --- a/src/persistence/supabase/process-repo.ts +++ b/src/persistence/supabase/process-repo.ts @@ -38,6 +38,31 @@ function fromRow(row: Record): Process { createdAt: new Date(row.created_at as string).getTime(), updatedAt: new Date(row.updated_at as string).getTime(), ownerId: (row.owner_id as string) ?? '', + updatedBy: (row.updated_by as string) ?? '', + } +} + +// Solo para display — no es parte del dominio puro. La resolución UUID → nombre +// es responsabilidad del repositorio, no del dominio (que solo conoce ownerId/updatedBy). +export interface ProcessWithUserNames extends Process { + ownerName: string + ownerAvatarUrl?: string + updaterName: string +} + +interface UserRef { + name: string | null + avatar_url?: string | null +} + +function fromRowWithUsers(row: Record): ProcessWithUserNames { + const owner = row.owner as UserRef | null + const updater = row.updater as UserRef | null + return { + ...fromRow(row), + ownerName: owner?.name ?? 'Desconocido', + ownerAvatarUrl: owner?.avatar_url ?? undefined, + updaterName: updater?.name ?? 'Desconocido', } } @@ -57,13 +82,16 @@ export const supabaseProcessRepo = { return fromRow(data) }, - async getAll(): Promise { + // JOIN con users para resolver owner/updater en una sola query (evita N+1). + // Si las FK owner_id/updated_by no están declaradas como relaciones en PostgREST, + // este select falla — en ese caso usar el fallback de dos queries + mapeo en memoria. + async getAll(): Promise { const { data, error } = await supabase .from('processes') - .select('*') + .select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name)') .order('updated_at', { ascending: false }) if (error) throw error - return (data ?? []).map(fromRow) + return (data ?? []).map((row) => fromRowWithUsers(row as Record)) }, async delete(id: string): Promise { diff --git a/src/store/library-store.ts b/src/store/library-store.ts index 4709827..f4e6e6c 100644 --- a/src/store/library-store.ts +++ b/src/store/library-store.ts @@ -1,15 +1,15 @@ import { create } from 'zustand' import { v4 as uuidv4 } from 'uuid' import { getSessionSafe } from '@/lib/supabase' -import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' +import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo' import { supabaseGroupRepo } from '@/persistence/supabase/group-repo' import { supabaseSettingsRepo } from '@/persistence/supabase/settings-repo' import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo' -import type { Process, ProcessGroup, AppSettings, Simulation } from '@/domain/types' +import type { ProcessGroup, AppSettings, Simulation } from '@/domain/types' interface LibraryState { groups: ProcessGroup[] - processes: Process[] + processes: ProcessWithUserNames[] settings: AppSettings isLoading: boolean error: string | null @@ -23,7 +23,7 @@ interface LibraryState { assignProcessToGroup: (processId: string, groupId: string | null, userId: string) => Promise - getProcessesByGroup: (groupId: string | null) => Process[] + getProcessesByGroup: (groupId: string | null) => ProcessWithUserNames[] getLatestSimulation: (processId: string) => Promise updateSettings: (updates: Partial>) => Promise diff --git a/tests/features/report/components.test.tsx b/tests/features/report/components.test.tsx index 22304b1..8eebe0b 100644 --- a/tests/features/report/components.test.tsx +++ b/tests/features/report/components.test.tsx @@ -229,7 +229,7 @@ describe('MethodologyFooter', () => { id: 'p1', name: 'Proceso Test', clientName: '', bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } expect(() => render()).not.toThrow() }) @@ -239,7 +239,7 @@ describe('MethodologyFooter', () => { id: 'p1', name: 'Proc', clientName: '', bpmnXml: '', currency: 'USD', overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } render() const text = document.body.textContent ?? '' @@ -251,7 +251,7 @@ describe('MethodologyFooter', () => { id: 'p1', name: 'Proc', clientName: '', bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } render() const text = document.body.textContent ?? '' @@ -338,7 +338,7 @@ describe('ReportPage — con datos completos', () => { updatedAt: Date.now() - 1000 * 60 * 30, groupId: null, tags: [], - ownerId: 'test-user', + ownerId: 'test-user', updatedBy: 'test-user', } const mockSimulation = { diff --git a/tests/features/report/roi-components.test.tsx b/tests/features/report/roi-components.test.tsx index 2972f29..02a56b9 100644 --- a/tests/features/report/roi-components.test.tsx +++ b/tests/features/report/roi-components.test.tsx @@ -590,7 +590,7 @@ const mockProcessBase = { currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000, - groupId: null, tags: [], ownerId: 'test-user', + groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } const mockSimulationBase = { diff --git a/tests/lib/export/csv-export-roi.test.ts b/tests/lib/export/csv-export-roi.test.ts index b830918..7c15e08 100644 --- a/tests/lib/export/csv-export-roi.test.ts +++ b/tests/lib/export/csv-export-roi.test.ts @@ -14,7 +14,7 @@ function makeProcess(extras: Partial = {}): Process { id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ', bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, - createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', ...extras, + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', ...extras, } } diff --git a/tests/lib/export/csv-export.test.ts b/tests/lib/export/csv-export.test.ts index 08b2f51..42f61db 100644 --- a/tests/lib/export/csv-export.test.ts +++ b/tests/lib/export/csv-export.test.ts @@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial = {}): Process { id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC', bpmnXml: '', currency, overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', ...extras, + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', ...extras, } } diff --git a/tests/lib/export/measure-sizes.test.ts b/tests/lib/export/measure-sizes.test.ts index b86061c..19c161d 100644 --- a/tests/lib/export/measure-sizes.test.ts +++ b/tests/lib/export/measure-sizes.test.ts @@ -66,7 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => { bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], ownerId: 'test-user', + groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } const simulation: Simulation = { diff --git a/tests/lib/export/pdf-export-roi.test.ts b/tests/lib/export/pdf-export-roi.test.ts index 838c175..d6e1eb6 100644 --- a/tests/lib/export/pdf-export-roi.test.ts +++ b/tests/lib/export/pdf-export-roi.test.ts @@ -51,7 +51,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], ownerId: 'test-user', + groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } const perActivityActual = [ diff --git a/tests/lib/export/pdf-export-sprint2.test.ts b/tests/lib/export/pdf-export-sprint2.test.ts index 2d93d39..3d280ff 100644 --- a/tests/lib/export/pdf-export-sprint2.test.ts +++ b/tests/lib/export/pdf-export-sprint2.test.ts @@ -43,7 +43,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], ownerId: 'test-user', + groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } // Simulación SIN recursos en ninguna actividad diff --git a/tests/lib/export/pdf-export.test.ts b/tests/lib/export/pdf-export.test.ts index 4e7eaf5..fd77fd6 100644 --- a/tests/lib/export/pdf-export.test.ts +++ b/tests/lib/export/pdf-export.test.ts @@ -54,7 +54,7 @@ const mockProcess: Process = { overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], ownerId: 'test-user', + groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } const mockSimulation: Simulation = { diff --git a/tests/lib/export/pdf-orientation.test.ts b/tests/lib/export/pdf-orientation.test.ts index 68321c6..8fe50ae 100644 --- a/tests/lib/export/pdf-orientation.test.ts +++ b/tests/lib/export/pdf-orientation.test.ts @@ -59,7 +59,7 @@ const mockProcess: Process = { overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], ownerId: 'test-user', + groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } const mockSimulation: Simulation = { diff --git a/tests/lib/export/pdf-roi-page1.test.ts b/tests/lib/export/pdf-roi-page1.test.ts index 9aa0665..5bb9727 100644 --- a/tests/lib/export/pdf-roi-page1.test.ts +++ b/tests/lib/export/pdf-roi-page1.test.ts @@ -56,7 +56,7 @@ const mockProcess: Process = { overheadPercentage: 0.2, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], ownerId: 'test-user', + groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } const mockSimulation: Simulation = { diff --git a/tests/lib/export/pdf-roi-pages2-5.test.ts b/tests/lib/export/pdf-roi-pages2-5.test.ts index 47e0855..42372fb 100644 --- a/tests/lib/export/pdf-roi-pages2-5.test.ts +++ b/tests/lib/export/pdf-roi-pages2-5.test.ts @@ -52,7 +52,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], ownerId: 'test-user', + groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } const mockRoi: RoiResult = { diff --git a/tests/lib/export/pdf-scenario-page1.test.ts b/tests/lib/export/pdf-scenario-page1.test.ts index 003befc..1d635be 100644 --- a/tests/lib/export/pdf-scenario-page1.test.ts +++ b/tests/lib/export/pdf-scenario-page1.test.ts @@ -52,7 +52,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], ownerId: 'test-user', + groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } const mockSimulation: Simulation = { diff --git a/tests/store/simulation-store-invalidation.test.ts b/tests/store/simulation-store-invalidation.test.ts index c778183..58b2493 100644 --- a/tests/store/simulation-store-invalidation.test.ts +++ b/tests/store/simulation-store-invalidation.test.ts @@ -94,7 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () => currency: 'USD', overheadPercentage: 0.2, annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000, createdAt: Date.now(), updatedAt: Date.now(), - groupId: null, tags: [], ownerId: 'test-user', + groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } useProcessStore.setState({ currentProcess: proc })