Se cambió el polling de tanstack query por realtime de supabase y postgres_changes
This commit is contained in:
@@ -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 }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user