a ver si ahora sí guarda el perfil
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
# Sprint 5 — Etapa 4F: Fix definitivo save de perfil + ETAPA_4E_PROMPT pendiente
|
||||
|
||||
## Contexto
|
||||
|
||||
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Supabase v2.106.2.
|
||||
|
||||
Esta etapa resuelve DOS issues simultáneos:
|
||||
|
||||
1. **Save de perfil produce "sin llamadas de red + sin mensajes en consola"** al hacer click en Guardar
|
||||
2. **Los Cambios 1-4 del ETAPA_4E_PROMPT.md nunca fueron aplicados al código** — solo se commitió el archivo del prompt
|
||||
|
||||
---
|
||||
|
||||
## Estado actual del código (ya verificado — no requiere re-lectura inicial)
|
||||
|
||||
- `src/components/ProfileSheet.tsx`: ya usa `fetch()` nativo con token de localStorage. ✅ NO tocar este archivo salvo el Cambio 3 de abajo.
|
||||
- `src/lib/supabase.ts`: `getSessionSafe()` sigue presente, `lockAcquireTimeout` NO configurado. ❌
|
||||
- `src/store/library-store.ts`: sigue con `await getSessionSafe()`. ❌
|
||||
- `src/store/process-store.ts`: sigue con `await getSessionSafe()` (2 ocurrencias). ❌
|
||||
- `src/features/report/useReportData.ts`: sigue con `await getSessionSafe()`. ❌
|
||||
|
||||
---
|
||||
|
||||
## Causa raíz: botón deshabilitado por `name.trim() === ''`
|
||||
|
||||
### Diagnóstico
|
||||
|
||||
`ProfileSheet.tsx` tiene:
|
||||
|
||||
```tsx
|
||||
<Button onClick={handleSave} disabled={!name.trim() || saving}>
|
||||
```
|
||||
|
||||
"Sin llamadas de red Y sin mensajes en consola" = el botón está deshabilitado. Click en un botón disabled no dispara ningún event handler — comportamiento nativo del browser, sin eventos, sin logs.
|
||||
|
||||
El botón está deshabilitado cuando `name.trim() === ''`. El `name` state se inicializa en `useEffect([open])` con `setName(user.name)`.
|
||||
|
||||
### Por qué `user.name` es cadena vacía
|
||||
|
||||
**En `SupabaseAuthService.buildAuthUser()`:**
|
||||
|
||||
```ts
|
||||
name: supabaseUser.user_metadata?.full_name as string ?? email,
|
||||
```
|
||||
|
||||
El operador `??` solo hace fallback con `null` o `undefined`. NO hace fallback con cadena vacía.
|
||||
Si Google devuelve `full_name: ""`, entonces `"" ?? email` = `""` → `user.name = ""`.
|
||||
|
||||
**La cadena de propagación:**
|
||||
|
||||
```
|
||||
buildAuthUser() → name = "" (Google devuelve full_name: "")
|
||||
upsertUserOnLogin() → inserta name="" en tabla users (ignoreDuplicates: true, primer login)
|
||||
syncProfileFromDB() → lee name="" del DB, lo retorna como ""
|
||||
AuthContext setUser → profile.name ?? prev.name = "" ?? prev.name = "" ← ?? no hace fallback!
|
||||
ProfileSheet useEffect → setName("") → name state = "" → botón disabled
|
||||
```
|
||||
|
||||
Mismo patrón en `getUserFromStorage()` del `AuthContext.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Cambios a aplicar — 7 archivos
|
||||
|
||||
### Cambio 1: `src/auth/SupabaseAuthService.ts`
|
||||
|
||||
**A) En `buildAuthUser`**, cambiar `?? email` por `|| email`:
|
||||
|
||||
```ts
|
||||
// ANTES:
|
||||
name: supabaseUser.user_metadata?.full_name as string ?? email,
|
||||
|
||||
// DESPUÉS:
|
||||
name: (supabaseUser.user_metadata?.full_name as string | undefined) || email,
|
||||
```
|
||||
|
||||
El operador `||` trata `""`, `null` y `undefined` como ausentes, haciendo fallback al email.
|
||||
|
||||
**B) En `syncProfileFromDB`**, cambiar `?? undefined` por `|| undefined`:
|
||||
|
||||
```ts
|
||||
// ANTES:
|
||||
name: (data.name as string | null) ?? undefined as unknown as string,
|
||||
|
||||
// DESPUÉS:
|
||||
name: ((data.name as string | null | undefined) || undefined) as unknown as string,
|
||||
```
|
||||
|
||||
Esto trata tanto `null` como `""` como ausentes (retorna `undefined`), evitando que un nombre vacío en DB se propague al estado del usuario.
|
||||
|
||||
### Cambio 2: `src/auth/AuthContext.tsx`
|
||||
|
||||
**A) En `getUserFromStorage()`**, mismo fix `|| email`:
|
||||
|
||||
```ts
|
||||
// ANTES:
|
||||
name: (u.user_metadata?.full_name as string) ?? email,
|
||||
|
||||
// DESPUÉS:
|
||||
name: (u.user_metadata?.full_name as string | undefined) || email,
|
||||
```
|
||||
|
||||
**B) En el `useEffect([user?.id])` IIFE**, cambiar `??` por `||` para que cadena vacía del DB también haga fallback:
|
||||
|
||||
```ts
|
||||
// ANTES:
|
||||
? { ...prev, company: profile.company, name: profile.name ?? prev.name }
|
||||
|
||||
// DESPUÉS:
|
||||
? { ...prev, company: profile.company, name: profile.name || prev.name }
|
||||
```
|
||||
|
||||
### Cambio 3: `src/components/ProfileSheet.tsx`
|
||||
|
||||
**En `useEffect([open])`**, agregar fallback a email:
|
||||
|
||||
```ts
|
||||
// ANTES:
|
||||
setName(user.name)
|
||||
|
||||
// DESPUÉS:
|
||||
setName(user.name || user.email || '')
|
||||
```
|
||||
|
||||
Aunque los Cambios 1-2 previenen que `user.name` llegue vacío en sesiones nuevas, este fallback protege a usuarios cuya fila en DB ya tiene `name = ""` de sesiones anteriores. Usando el email como fallback, el campo siempre tiene un valor no-vacío → botón habilitado.
|
||||
|
||||
### Cambio 4: `src/lib/supabase.ts`
|
||||
|
||||
Dos cambios (del ETAPA_4E_PROMPT.md Cambio 1):
|
||||
|
||||
**A) Agregar `lockAcquireTimeout: 30000`:**
|
||||
|
||||
```ts
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
lock: processLock,
|
||||
// Aumentado de 5000 (default) a 30000ms.
|
||||
// Con 5s, un token refresh lento dispara ProcessLockAcquireTimeoutError,
|
||||
// cuyo handler hace await previousOperation (colgada) → chain bloqueado permanente.
|
||||
// 30s da margen para refresh en redes lentas o cold starts de Supabase Auth.
|
||||
lockAcquireTimeout: 30000,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**B) Eliminar TODA la función `getSessionSafe()`** — declaración y export. No dejar ni stub ni comentario.
|
||||
|
||||
La función abandona la Promise de `getSession()` cuando gana el timeout. La Promise abandonada deja una operación en `PROCESS_LOCKS[name]` que bloquea el chain.
|
||||
|
||||
### Cambio 5: `src/store/library-store.ts`
|
||||
|
||||
```ts
|
||||
// ELIMINAR este import:
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
|
||||
// ELIMINAR esta línea en load():
|
||||
await getSessionSafe()
|
||||
```
|
||||
|
||||
No reemplazar con nada. Las queries a Supabase llaman `getSession()` internamente vía `_getAccessToken()` que espera `initializePromise` automáticamente.
|
||||
|
||||
### Cambio 6: `src/store/process-store.ts`
|
||||
|
||||
```ts
|
||||
// ELIMINAR este import:
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
|
||||
// ELIMINAR ambas líneas (en loadProcess() y en loadActivities()):
|
||||
await getSessionSafe()
|
||||
```
|
||||
|
||||
### Cambio 7: `src/features/report/useReportData.ts`
|
||||
|
||||
```ts
|
||||
// ELIMINAR este import:
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
|
||||
// ELIMINAR la línea:
|
||||
await getSessionSafe()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lo que NO entra
|
||||
|
||||
- NO agregar logs de debug
|
||||
- NO cambiar la estrategia de ProfileSheet (fetch nativo con localStorage token — es correcta, mantener)
|
||||
- NO implementar el "Cambio 5" del ETAPA_4E_PROMPT.md (ProfileSheet con supabase client + timeout) — ya está mejor con fetch nativo
|
||||
- NO cambios en simulación, heatmap, ComparisonTable, ni features nuevas
|
||||
- NO cambios en `AuthContext.tsx` más allá de los especificados en Cambio 2
|
||||
|
||||
---
|
||||
|
||||
## Orden de ejecución
|
||||
|
||||
1. Leer los 7 archivos antes de editar para confirmar estado actual
|
||||
2. Aplicar Cambio 1 (`SupabaseAuthService.ts`)
|
||||
3. Aplicar Cambio 2 (`AuthContext.tsx`)
|
||||
4. Aplicar Cambio 3 (`ProfileSheet.tsx`)
|
||||
5. Aplicar Cambio 4 (`supabase.ts`)
|
||||
6. Aplicar Cambio 5 (`library-store.ts`)
|
||||
7. Aplicar Cambio 6 (`process-store.ts`)
|
||||
8. Aplicar Cambio 7 (`useReportData.ts`)
|
||||
9. `npm run build` — debe dar 0 errores TypeScript
|
||||
10. `npm run test` — todos los Vitest verdes
|
||||
11. `git add -A && git commit -m "fix(sprint-5/etapa-4f): || fallback en name vacío + eliminar getSessionSafe + lockAcquireTimeout 30s"`
|
||||
12. `git push origin main` — **OBLIGATORIO antes de validar en producción**
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
- [ ] `npm run build` limpio (0 errores TypeScript)
|
||||
- [ ] `npm run test` — todos los Vitest verdes
|
||||
- [ ] **`git push origin main` ejecutado**
|
||||
- [ ] **Validación visual del director antes del OK formal**
|
||||
|
||||
Validación funcional (post-deploy, a cargo del director):
|
||||
- [ ] Guardar nombre en ProfileSheet → app NO se congela → nombre actualizado en dropdown
|
||||
- [ ] Guardar empresa → app NO se congela → empresa aparece en dropdown
|
||||
- [ ] Cmd+R después de guardar → valores persisten (Supabase)
|
||||
- [ ] Biblioteca carga correctamente (sin getSessionSafe)
|
||||
- [ ] Workspace carga correctamente (sin getSessionSafe)
|
||||
- [ ] Reporte carga correctamente (sin getSessionSafe)
|
||||
227
sprints/sprint-5/ETAPA_4E_PROMPT.md
Normal file
227
sprints/sprint-5/ETAPA_4E_PROMPT.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Sprint 5 — Etapa 4E: Fix definitivo del freeze al guardar perfil
|
||||
|
||||
## Contexto
|
||||
|
||||
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Supabase v2.106.2.
|
||||
|
||||
Etapas 4B, 4C, 4D intentaron corregir el freeze al guardar perfil. Ninguna resolvió el problema. Esta etapa identifica y corrige la causa raíz real.
|
||||
|
||||
---
|
||||
|
||||
## Causa raíz real — auditada en código fuente de `@supabase/auth-js`
|
||||
|
||||
### Componente 1: `getSessionSafe()` abandona Promises en `PROCESS_LOCKS`
|
||||
|
||||
`getSessionSafe()` en `src/lib/supabase.ts` está implementada así:
|
||||
|
||||
```ts
|
||||
await Promise.race([
|
||||
supabase.auth.getSession(), // Promise A — va al chain de processLock
|
||||
new Promise((resolve) => setTimeout(resolve, 4000)), // timeout 4s
|
||||
])
|
||||
```
|
||||
|
||||
Cuando el timeout gana, `getSessionSafe()` resuelve. **Pero la Promise A de `getSession()` sigue corriendo en background.** Internamente, `getSession()` llamó `processLock(name, acquireTimeout, fn_session)`, que actualizó `PROCESS_LOCKS[name] = op_A` **antes de ceder el hilo**. La operación `op_A` está en vuelo (token refresh via red) con `PROCESS_LOCKS[name]` apuntando a ella.
|
||||
|
||||
### Componente 2: `lockAcquireTimeout = 5000ms` demasiado corto
|
||||
|
||||
El cliente Supabase usa `lockAcquireTimeout = 5000ms` por defecto. Cada llamada a `getSession()` pasa ese timeout a `processLock(name, 5000, fn)`.
|
||||
|
||||
### La cadena del freeze
|
||||
|
||||
```
|
||||
t=0 → library-store.load() llama getSessionSafe()
|
||||
→ getSession() → processLock → PROCESS_LOCKS[name] = op_A
|
||||
→ op_A empieza: token refresh (request HTTP a Supabase Auth)
|
||||
|
||||
t=4s → getSessionSafe() timeout gana → resuelve
|
||||
→ op_A sigue corriendo en background (PROCESS_LOCKS[name] = op_A, PENDIENTE)
|
||||
|
||||
t=4s → library queries lanzan: supabaseGroupRepo.getAll() etc.
|
||||
→ _getAccessToken() → getSession() → _acquireLock(5000, fn_B)
|
||||
→ processLock(5000): previousOperation = op_A (aún pendiente)
|
||||
→ espera hasta que op_A resuelva... o hasta 5s
|
||||
|
||||
t=9s → ProcessLockAcquireTimeoutError para op_B
|
||||
→ PROCESS_LOCKS[name] = wrapper_B
|
||||
→ wrapper_B hace: await previousOperation (= op_A, AÚN COLGADA)
|
||||
→ wrapper_B nunca resuelve mientras op_A esté colgada
|
||||
|
||||
t=X → Usuario abre ProfileSheet → hace click en Guardar
|
||||
→ supabase.from('users').update() → getSession() → processLock
|
||||
→ previousOperation = wrapper_B (NUNCA RESUELVE)
|
||||
→ FREEZE PERMANENTE
|
||||
```
|
||||
|
||||
La 4D fue 100% correcta: el callback de `onAuthStateChange` ya no tiene queries. Pero el freeze viene de esta cadena completamente diferente, que ocurre en `getSessionSafe()` de los stores.
|
||||
|
||||
---
|
||||
|
||||
## Fix — 4 archivos
|
||||
|
||||
### Cambio 1: `src/lib/supabase.ts`
|
||||
|
||||
Dos cambios:
|
||||
|
||||
**A) Aumentar `lockAcquireTimeout` a 30s:**
|
||||
|
||||
```ts
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
lock: processLock,
|
||||
// Aumentado de 5000 (default) a 30000ms.
|
||||
// Con 5s, un token refresh lento dispara ProcessLockAcquireTimeoutError,
|
||||
// cuyo handler hace await previousOperation (colgada) → chain bloqueado permanente.
|
||||
// 30s da margen para refresh en redes lentas o cold starts de Supabase Auth.
|
||||
lockAcquireTimeout: 30000,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**B) Eliminar `getSessionSafe()`:**
|
||||
|
||||
Eliminar TODA la función `getSessionSafe()` — declaración y export. No dejar ni el tipo, ni un stub.
|
||||
|
||||
La función tenía un problema de diseño: el `Promise.race([getSession(), timeout])` abandona la Promise de `getSession()` cuando gana el timeout. La Promise abandonada deja una operación en `PROCESS_LOCKS[name]` que bloquea el chain.
|
||||
|
||||
### Cambio 2: `src/store/library-store.ts`
|
||||
|
||||
Eliminar la línea `await getSessionSafe()` y el import correspondiente:
|
||||
|
||||
```ts
|
||||
// ELIMINAR este import:
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
|
||||
// ELIMINAR esta línea en load():
|
||||
await getSessionSafe()
|
||||
```
|
||||
|
||||
No reemplazar con nada. Las queries a Supabase ya llaman `getSession()` internamente vía `_getAccessToken()`, que espera `initializePromise` automáticamente. El `Promise.race` de 15s que ya existe es suficiente como safety net general.
|
||||
|
||||
### Cambio 3: `src/store/process-store.ts`
|
||||
|
||||
Mismo tratamiento — hay 2 usos de `getSessionSafe()` (líneas 41 y 57):
|
||||
|
||||
```ts
|
||||
// ELIMINAR este import:
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
|
||||
// ELIMINAR ambas líneas:
|
||||
await getSessionSafe() // una en loadProcess(), otra en loadActivities()
|
||||
```
|
||||
|
||||
No reemplazar. Misma razón que en library-store.
|
||||
|
||||
### Cambio 4: `src/features/report/useReportData.ts`
|
||||
|
||||
Mismo tratamiento — 1 uso:
|
||||
|
||||
```ts
|
||||
// ELIMINAR este import:
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
|
||||
// ELIMINAR la línea:
|
||||
await getSessionSafe()
|
||||
```
|
||||
|
||||
### Cambio 5: `src/components/ProfileSheet.tsx`
|
||||
|
||||
Agregar timeout de 10s + estado de error visual. La red de seguridad: si en algún caso extremo la operación tarde más de 10s, el botón se re-habilita y se muestra un mensaje, en lugar de quedar congelado indefinidamente.
|
||||
|
||||
```tsx
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
setName(user.name)
|
||||
setCompany(user.company ?? '')
|
||||
setSaveError(null) // ← limpiar error al abrir
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
async function handleSave() {
|
||||
if (!user || !name.trim()) return
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
const savePromise = supabase
|
||||
.from('users')
|
||||
.update({ name: name.trim(), company: company.trim() || null })
|
||||
.eq('id', user.id)
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('La operación tardó demasiado. Revisá tu conexión e intentá de nuevo.')),
|
||||
10_000
|
||||
)
|
||||
)
|
||||
|
||||
const { error } = await Promise.race([savePromise, timeoutPromise])
|
||||
if (error) throw error
|
||||
updateUser({ name: name.trim(), company: company.trim() || undefined })
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Error al guardar')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
En el JSX del Sheet, mostrar el error si existe (después del bloque de inputs, antes del footer):
|
||||
|
||||
```tsx
|
||||
{saveError && (
|
||||
<p className="text-xs text-destructive mt-2">{saveError}</p>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verificación — qué buscar en el código fuente de Supabase
|
||||
|
||||
Si querés confirmar antes de implementar, estos son los puntos exactos a verificar en `node_modules/@supabase/auth-js/dist/module/`:
|
||||
|
||||
- `locks.js`: función `processLock` — el `catch (e instanceof LockAcquireTimeoutError)` hace `await previousOperation` → si `previousOperation` nunca resuelve, el chain queda bloqueado
|
||||
- `GoTrueClient.js` línea ~25: `lockAcquireTimeout: 5000` — el default que se pisa con el cambio en `createClient()`
|
||||
- `GoTrueClient.js` línea ~2239: `getSession()` usa `this.lockAcquireTimeout` (no -1)
|
||||
|
||||
---
|
||||
|
||||
## Qué NO entra
|
||||
|
||||
- Cambios en `AuthContext.tsx` o `SupabaseAuthService.ts` (la 4D fue correcta)
|
||||
- Cambios en el sistema de simulación, heatmap, o ComparisonTable
|
||||
- Ninguna feature nueva
|
||||
- No agregar nuevos `getSessionSafe()` equivalentes en ningún lado
|
||||
|
||||
---
|
||||
|
||||
## Orden de ejecución
|
||||
|
||||
1. `src/lib/supabase.ts`: agregar `lockAcquireTimeout: 30000`, eliminar `getSessionSafe()`
|
||||
2. `src/store/library-store.ts`: eliminar import + `await getSessionSafe()`
|
||||
3. `src/store/process-store.ts`: eliminar import + ambos `await getSessionSafe()`
|
||||
4. `src/features/report/useReportData.ts`: eliminar import + `await getSessionSafe()`
|
||||
5. `src/components/ProfileSheet.tsx`: agregar `saveError` state + timeout 10s + UI
|
||||
6. `npm run build` — debe dar 0 errores TypeScript
|
||||
7. `npm run test` — 528+ Vitest verdes
|
||||
8. `git push origin main` — OBLIGATORIO antes de validar en producción
|
||||
9. Commit: `fix(sprint-5/etapa-4e): lockAcquireTimeout 30s + eliminar getSessionSafe — corta freeze en guardar perfil`
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
- [ ] Guardar nombre en ProfileSheet → app NO se congela → nombre actualizado en dropdown
|
||||
- [ ] Guardar empresa → app NO se congela → empresa aparece en dropdown
|
||||
- [ ] Cmd+R después de guardar → valores persisten (Supabase)
|
||||
- [ ] Biblioteca carga correctamente después de eliminar `getSessionSafe()` de library-store
|
||||
- [ ] Workspace carga correctamente después de eliminar `getSessionSafe()` de process-store
|
||||
- [ ] Reporte carga correctamente después de eliminar `getSessionSafe()` de useReportData
|
||||
- [ ] Si save tarda > 10s → botón se re-habilita → mensaje de error visible (no freeze)
|
||||
- [ ] `npm run build` limpio
|
||||
- [ ] `npm run test` — 528+ Vitest verdes
|
||||
- [ ] **`git push origin main` ejecutado** antes de validar en producción
|
||||
- [ ] **Validación visual del director** antes del OK formal
|
||||
224
sprints/sprint-5/ETAPA_4F_PROMPT.md
Normal file
224
sprints/sprint-5/ETAPA_4F_PROMPT.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Sprint 5 — Etapa 4F: Fix definitivo save de perfil + ETAPA_4E_PROMPT pendiente
|
||||
|
||||
## Contexto
|
||||
|
||||
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Supabase v2.106.2.
|
||||
|
||||
Esta etapa resuelve DOS issues simultáneos:
|
||||
|
||||
1. **Save de perfil produce "sin llamadas de red + sin mensajes en consola"** al hacer click en Guardar
|
||||
2. **Los Cambios 1-4 del ETAPA_4E_PROMPT.md nunca fueron aplicados al código** — solo se commitió el archivo del prompt
|
||||
|
||||
---
|
||||
|
||||
## Estado actual del código (ya verificado — no requiere re-lectura inicial)
|
||||
|
||||
- `src/components/ProfileSheet.tsx`: ya usa `fetch()` nativo con token de localStorage. ✅ NO tocar este archivo salvo el Cambio 3 de abajo.
|
||||
- `src/lib/supabase.ts`: `getSessionSafe()` sigue presente, `lockAcquireTimeout` NO configurado. ❌
|
||||
- `src/store/library-store.ts`: sigue con `await getSessionSafe()`. ❌
|
||||
- `src/store/process-store.ts`: sigue con `await getSessionSafe()` (2 ocurrencias). ❌
|
||||
- `src/features/report/useReportData.ts`: sigue con `await getSessionSafe()`. ❌
|
||||
|
||||
---
|
||||
|
||||
## Causa raíz: botón deshabilitado por `name.trim() === ''`
|
||||
|
||||
### Diagnóstico
|
||||
|
||||
`ProfileSheet.tsx` tiene:
|
||||
|
||||
```tsx
|
||||
<Button onClick={handleSave} disabled={!name.trim() || saving}>
|
||||
```
|
||||
|
||||
"Sin llamadas de red Y sin mensajes en consola" = el botón está deshabilitado. Click en un botón disabled no dispara ningún event handler — comportamiento nativo del browser, sin eventos, sin logs.
|
||||
|
||||
El botón está deshabilitado cuando `name.trim() === ''`. El `name` state se inicializa en `useEffect([open])` con `setName(user.name)`.
|
||||
|
||||
### Por qué `user.name` es cadena vacía
|
||||
|
||||
**En `SupabaseAuthService.buildAuthUser()`:**
|
||||
|
||||
```ts
|
||||
name: supabaseUser.user_metadata?.full_name as string ?? email,
|
||||
```
|
||||
|
||||
El operador `??` solo hace fallback con `null` o `undefined`. NO hace fallback con cadena vacía.
|
||||
Si Google devuelve `full_name: ""`, entonces `"" ?? email` = `""` → `user.name = ""`.
|
||||
|
||||
**La cadena de propagación:**
|
||||
|
||||
```
|
||||
buildAuthUser() → name = "" (Google devuelve full_name: "")
|
||||
upsertUserOnLogin() → inserta name="" en tabla users (ignoreDuplicates: true, primer login)
|
||||
syncProfileFromDB() → lee name="" del DB, lo retorna como ""
|
||||
AuthContext setUser → profile.name ?? prev.name = "" ?? prev.name = "" ← ?? no hace fallback!
|
||||
ProfileSheet useEffect → setName("") → name state = "" → botón disabled
|
||||
```
|
||||
|
||||
Mismo patrón en `getUserFromStorage()` del `AuthContext.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Cambios a aplicar — 7 archivos
|
||||
|
||||
### Cambio 1: `src/auth/SupabaseAuthService.ts`
|
||||
|
||||
**A) En `buildAuthUser`**, cambiar `?? email` por `|| email`:
|
||||
|
||||
```ts
|
||||
// ANTES:
|
||||
name: supabaseUser.user_metadata?.full_name as string ?? email,
|
||||
|
||||
// DESPUÉS:
|
||||
name: (supabaseUser.user_metadata?.full_name as string | undefined) || email,
|
||||
```
|
||||
|
||||
El operador `||` trata `""`, `null` y `undefined` como ausentes, haciendo fallback al email.
|
||||
|
||||
**B) En `syncProfileFromDB`**, cambiar `?? undefined` por `|| undefined`:
|
||||
|
||||
```ts
|
||||
// ANTES:
|
||||
name: (data.name as string | null) ?? undefined as unknown as string,
|
||||
|
||||
// DESPUÉS:
|
||||
name: ((data.name as string | null | undefined) || undefined) as unknown as string,
|
||||
```
|
||||
|
||||
Esto trata tanto `null` como `""` como ausentes (retorna `undefined`), evitando que un nombre vacío en DB se propague al estado del usuario.
|
||||
|
||||
### Cambio 2: `src/auth/AuthContext.tsx`
|
||||
|
||||
**A) En `getUserFromStorage()`**, mismo fix `|| email`:
|
||||
|
||||
```ts
|
||||
// ANTES:
|
||||
name: (u.user_metadata?.full_name as string) ?? email,
|
||||
|
||||
// DESPUÉS:
|
||||
name: (u.user_metadata?.full_name as string | undefined) || email,
|
||||
```
|
||||
|
||||
**B) En el `useEffect([user?.id])` IIFE**, cambiar `??` por `||` para que cadena vacía del DB también haga fallback:
|
||||
|
||||
```ts
|
||||
// ANTES:
|
||||
? { ...prev, company: profile.company, name: profile.name ?? prev.name }
|
||||
|
||||
// DESPUÉS:
|
||||
? { ...prev, company: profile.company, name: profile.name || prev.name }
|
||||
```
|
||||
|
||||
### Cambio 3: `src/components/ProfileSheet.tsx`
|
||||
|
||||
**En `useEffect([open])`**, agregar fallback a email:
|
||||
|
||||
```ts
|
||||
// ANTES:
|
||||
setName(user.name)
|
||||
|
||||
// DESPUÉS:
|
||||
setName(user.name || user.email || '')
|
||||
```
|
||||
|
||||
Aunque los Cambios 1-2 previenen que `user.name` llegue vacío en sesiones nuevas, este fallback protege a usuarios cuya fila en DB ya tiene `name = ""` de sesiones anteriores. Usando el email como fallback, el campo siempre tiene un valor no-vacío → botón habilitado.
|
||||
|
||||
### Cambio 4: `src/lib/supabase.ts`
|
||||
|
||||
Dos cambios (del ETAPA_4E_PROMPT.md Cambio 1):
|
||||
|
||||
**A) Agregar `lockAcquireTimeout: 30000`:**
|
||||
|
||||
```ts
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
lock: processLock,
|
||||
// Aumentado de 5000 (default) a 30000ms.
|
||||
// Con 5s, un token refresh lento dispara ProcessLockAcquireTimeoutError,
|
||||
// cuyo handler hace await previousOperation (colgada) → chain bloqueado permanente.
|
||||
// 30s da margen para refresh en redes lentas o cold starts de Supabase Auth.
|
||||
lockAcquireTimeout: 30000,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**B) Eliminar TODA la función `getSessionSafe()`** — declaración y export. No dejar ni stub ni comentario.
|
||||
|
||||
La función abandona la Promise de `getSession()` cuando gana el timeout. La Promise abandonada deja una operación en `PROCESS_LOCKS[name]` que bloquea el chain.
|
||||
|
||||
### Cambio 5: `src/store/library-store.ts`
|
||||
|
||||
```ts
|
||||
// ELIMINAR este import:
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
|
||||
// ELIMINAR esta línea en load():
|
||||
await getSessionSafe()
|
||||
```
|
||||
|
||||
No reemplazar con nada. Las queries a Supabase llaman `getSession()` internamente vía `_getAccessToken()` que espera `initializePromise` automáticamente.
|
||||
|
||||
### Cambio 6: `src/store/process-store.ts`
|
||||
|
||||
```ts
|
||||
// ELIMINAR este import:
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
|
||||
// ELIMINAR ambas líneas (en loadProcess() y en loadActivities()):
|
||||
await getSessionSafe()
|
||||
```
|
||||
|
||||
### Cambio 7: `src/features/report/useReportData.ts`
|
||||
|
||||
```ts
|
||||
// ELIMINAR este import:
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
|
||||
// ELIMINAR la línea:
|
||||
await getSessionSafe()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lo que NO entra
|
||||
|
||||
- NO agregar logs de debug
|
||||
- NO cambiar la estrategia de ProfileSheet (fetch nativo con localStorage token — es correcta, mantener)
|
||||
- NO implementar el "Cambio 5" del ETAPA_4E_PROMPT.md (ProfileSheet con supabase client + timeout) — ya está mejor con fetch nativo
|
||||
- NO cambios en simulación, heatmap, ComparisonTable, ni features nuevas
|
||||
- NO cambios en `AuthContext.tsx` más allá de los especificados en Cambio 2
|
||||
|
||||
---
|
||||
|
||||
## Orden de ejecución
|
||||
|
||||
1. Leer los 7 archivos antes de editar para confirmar estado actual
|
||||
2. Aplicar Cambio 1 (`SupabaseAuthService.ts`)
|
||||
3. Aplicar Cambio 2 (`AuthContext.tsx`)
|
||||
4. Aplicar Cambio 3 (`ProfileSheet.tsx`)
|
||||
5. Aplicar Cambio 4 (`supabase.ts`)
|
||||
6. Aplicar Cambio 5 (`library-store.ts`)
|
||||
7. Aplicar Cambio 6 (`process-store.ts`)
|
||||
8. Aplicar Cambio 7 (`useReportData.ts`)
|
||||
9. `npm run build` — debe dar 0 errores TypeScript
|
||||
10. `npm run test` — todos los Vitest verdes
|
||||
11. `git add -A && git commit -m "fix(sprint-5/etapa-4f): || fallback en name vacío + eliminar getSessionSafe + lockAcquireTimeout 30s"`
|
||||
12. `git push origin main` — **OBLIGATORIO antes de validar en producción**
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
- [ ] `npm run build` limpio (0 errores TypeScript)
|
||||
- [ ] `npm run test` — todos los Vitest verdes
|
||||
- [ ] **`git push origin main` ejecutado**
|
||||
- [ ] **Validación visual del director antes del OK formal**
|
||||
|
||||
Validación funcional (post-deploy, a cargo del director):
|
||||
- [ ] Guardar nombre en ProfileSheet → app NO se congela → nombre actualizado en dropdown
|
||||
- [ ] Guardar empresa → app NO se congela → empresa aparece en dropdown
|
||||
- [ ] Cmd+R después de guardar → valores persisten (Supabase)
|
||||
- [ ] Biblioteca carga correctamente (sin getSessionSafe)
|
||||
- [ ] Workspace carga correctamente (sin getSessionSafe)
|
||||
- [ ] Reporte carga correctamente (sin getSessionSafe)
|
||||
Reference in New Issue
Block a user