Se cambió el polling de tanstack query por realtime de supabase y postgres_changes

This commit is contained in:
2026-02-26 16:37:21 -06:00
parent d7d4eff523
commit f6b25ad86a
2 changed files with 183 additions and 72 deletions

View File

@@ -1,11 +1,12 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import type { AISubjectUnifiedInput } from '@/data' import type { AISubjectUnifiedInput } from '@/data'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { TablesInsert } from '@/types/supabase' import type { TablesInsert } from '@/types/supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -42,9 +43,10 @@ export function WizardControls({
const generateSubjectAI = useGenerateSubjectAI() const generateSubjectAI = useGenerateSubjectAI()
const createSubjectManual = useCreateSubjectManual() const createSubjectManual = useCreateSubjectManual()
const [isSpinningIA, setIsSpinningIA] = useState(false) const [isSpinningIA, setIsSpinningIA] = useState(false)
const [pollSubjectId, setPollSubjectId] = useState<string | null>(null)
const cancelledRef = useRef(false) const cancelledRef = useRef(false)
const pollStartedAtRef = useRef<number | null>(null) const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
const watchSubjectIdRef = useRef<string | null>(null)
const watchTimeoutRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
cancelledRef.current = false cancelledRef.current = false
@@ -53,61 +55,113 @@ export function WizardControls({
} }
}, []) }, [])
const subjectQuery = useQuery({ const stopSubjectWatch = useCallback(() => {
queryKey: pollSubjectId if (watchTimeoutRef.current) {
? qk.asignaturaMaybe(pollSubjectId) window.clearTimeout(watchTimeoutRef.current)
: ['asignaturas', 'detail-maybe', null], watchTimeoutRef.current = null
queryFn: () => subjects_get_maybe(pollSubjectId as string), }
enabled: Boolean(pollSubjectId),
refetchInterval: () => {
if (!pollSubjectId) return false
const startedAt = pollStartedAtRef.current ?? Date.now() watchSubjectIdRef.current = null
if (!pollStartedAtRef.current) pollStartedAtRef.current = startedAt
const elapsedMs = Date.now() - startedAt const ch = realtimeChannelRef.current
return elapsedMs >= 6 * 60 * 1000 ? false : 3000 if (ch) {
}, realtimeChannelRef.current = null
refetchIntervalInBackground: true, try {
staleTime: 0, supabaseBrowser().removeChannel(ch)
}) } catch {
// noop
}
}
}, [])
useEffect(() => { useEffect(() => {
if (!pollSubjectId) return return () => {
stopSubjectWatch()
}
}, [stopSubjectWatch])
const handleSubjectReady = (args: {
id: string
plan_estudio_id: string
estado?: unknown
}) => {
if (cancelledRef.current) return if (cancelledRef.current) return
const asig = subjectQuery.data const estado = String(args.estado ?? '').toLowerCase()
if (!asig) return
const estado = String(asig.estado).toLowerCase()
if (estado === 'generando') return if (estado === 'generando') return
setPollSubjectId(null) stopSubjectWatch()
pollStartedAtRef.current = null
setIsSpinningIA(false) setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false })) setWizard((w) => ({ ...w, isLoading: false }))
navigate({ navigate({
to: `/planes/${asig.plan_estudio_id}/asignaturas/${asig.id}`, to: `/planes/${args.plan_estudio_id}/asignaturas/${args.id}`,
state: { showConfetti: true }, state: { showConfetti: true },
}) })
}, [pollSubjectId, subjectQuery.data, navigate, setWizard]) }
useEffect(() => { const beginSubjectWatch = (args: { subjectId: string; planId: string }) => {
if (!pollSubjectId) return stopSubjectWatch()
if (!subjectQuery.isError) return
setPollSubjectId(null) watchSubjectIdRef.current = args.subjectId
pollStartedAtRef.current = null
setIsSpinningIA(false) // Timeout de seguridad (mismo límite que teníamos con polling)
setWizard((w) => ({ watchTimeoutRef.current = window.setTimeout(
...w, () => {
isLoading: false, if (cancelledRef.current) return
errorMessage: if (watchSubjectIdRef.current !== args.subjectId) return
(subjectQuery.error as any)?.message ??
'Error consultando el estado de la asignatura', stopSubjectWatch()
})) setIsSpinningIA(false)
}, [pollSubjectId, subjectQuery.isError, subjectQuery.error, setWizard]) setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
}))
},
6 * 60 * 1000,
)
const supabase = supabaseBrowser()
const channel = supabase.channel(`asignaturas-status-${args.subjectId}`)
realtimeChannelRef.current = channel
channel.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'asignaturas',
filter: `id=eq.${args.subjectId}`,
},
(payload) => {
if (cancelledRef.current) return
const next: any = (payload as any)?.new
if (!next?.id || !next?.plan_estudio_id) return
handleSubjectReady({
id: String(next.id),
plan_estudio_id: String(next.plan_estudio_id),
estado: next.estado,
})
},
)
channel.subscribe((status) => {
if (cancelledRef.current) return
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
stopSubjectWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'No se pudo suscribir al estado de la asignatura. Intenta de nuevo.',
}))
}
})
}
const uploadAiAttachments = async (args: { const uploadAiAttachments = async (args: {
planId: string planId: string
@@ -144,7 +198,7 @@ export function WizardControls({
errorMessage: null, errorMessage: null,
})) }))
let startedPolling = false let startedWaiting = false
try { try {
if (wizard.tipoOrigen === 'IA_SIMPLE') { if (wizard.tipoOrigen === 'IA_SIMPLE') {
@@ -188,6 +242,10 @@ export function WizardControls({
setIsSpinningIA(true) setIsSpinningIA(true)
// Inicia watch realtime antes de disparar la Edge para no perder updates.
startedWaiting = true
beginSubjectWatch({ subjectId, planId: wizard.plan_estudio_id })
const archivosAdjuntos = await uploadAiAttachments({ const archivosAdjuntos = await uploadAiAttachments({
planId: wizard.plan_estudio_id, planId: wizard.plan_estudio_id,
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({ files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
@@ -223,10 +281,16 @@ export function WizardControls({
await generateSubjectAI.mutateAsync(payload as any) await generateSubjectAI.mutateAsync(payload as any)
// Inicia polling; el efecto navega cuando deje de estar "generando". // Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
startedPolling = true const latest = await subjects_get_maybe(subjectId)
pollStartedAtRef.current = Date.now() if (latest) {
setPollSubjectId(subjectId) handleSubjectReady({
id: latest.id as any,
plan_estudio_id: latest.plan_estudio_id as any,
estado: (latest as any).estado,
})
}
return return
} }
@@ -360,14 +424,14 @@ export function WizardControls({
} }
} catch (err: any) { } catch (err: any) {
setIsSpinningIA(false) setIsSpinningIA(false)
setPollSubjectId(null) stopSubjectWatch()
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
isLoading: false, isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura', errorMessage: err?.message ?? 'Error creando la asignatura',
})) }))
} finally { } finally {
if (!startedPolling) { if (!startedWaiting) {
setIsSpinningIA(false) setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false })) setWizard((w) => ({ ...w, isLoading: false }))
} }

View File

@@ -4,6 +4,7 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { useEffect } from 'react'
import { import {
ai_generate_plan, ai_generate_plan,
@@ -26,6 +27,7 @@ import {
} from '../api/plans.api' } from '../api/plans.api'
import { lineas_delete } from '../api/subjects.api' import { lineas_delete } from '../api/subjects.api'
import { qk } from '../query/keys' import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
import type { import type {
PlanListFilters, PlanListFilters,
@@ -72,34 +74,79 @@ export function usePlanLineas(planId: UUID | null | undefined) {
} }
export function usePlanAsignaturas(planId: UUID | null | undefined) { export function usePlanAsignaturas(planId: UUID | null | undefined) {
return useQuery({ const qc = useQueryClient()
const query = useQuery({
queryKey: planId queryKey: planId
? qk.planAsignaturas(planId) ? qk.planAsignaturas(planId)
: ['planes', 'asignaturas', null], : ['planes', 'asignaturas', null],
queryFn: () => plan_asignaturas_list(planId as UUID), queryFn: () => plan_asignaturas_list(planId as UUID),
enabled: Boolean(planId), enabled: Boolean(planId),
refetchInterval: (query) => {
const data = query.state.data
if (!Array.isArray(data)) return false
const hayGenerando = data.some(
(a: any) => (a as { estado?: unknown }).estado === 'generando',
)
const qAny = query as any
if (!hayGenerando) {
qAny.__generandoSince = null
return false
}
const startedAt = qAny.__generandoSince ?? Date.now()
if (!qAny.__generandoSince) qAny.__generandoSince = startedAt
const elapsedMs = Date.now() - startedAt
return elapsedMs >= 6 * 60 * 1000 ? false : 3000
},
refetchIntervalInBackground: true,
}) })
useEffect(() => {
if (!planId) return
const supabase = supabaseBrowser()
const channel = supabase.channel(`plan-asignaturas-${planId}`)
channel.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'asignaturas',
filter: `plan_estudio_id=eq.${planId}`,
},
(payload: {
eventType?: 'INSERT' | 'UPDATE' | 'DELETE'
new?: any
old?: any
}) => {
const eventType = payload.eventType
if (eventType === 'DELETE') {
const oldRow: any = payload.old
const deletedId = oldRow?.id
if (!deletedId) return
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
if (!Array.isArray(prev)) return prev
return prev.filter((a: any) => String(a?.id) !== String(deletedId))
})
return
}
const newRow: any = payload.new
if (!newRow?.id) return
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
if (!Array.isArray(prev)) return prev
const idx = prev.findIndex(
(a: any) => String(a?.id) === String(newRow.id),
)
if (idx === -1) return [...prev, newRow]
const next = [...prev]
next[idx] = { ...prev[idx], ...newRow }
return next
})
},
)
channel.subscribe()
return () => {
try {
supabase.removeChannel(channel)
} catch {
// noop
}
}
}, [planId, qc])
return query
} }
export function usePlanHistorial( export function usePlanHistorial(