All files / web/src/hooks usePrintJobRing.ts

100% Statements 77/77
100% Branches 8/8
100% Functions 1/1
100% Lines 77/77

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 781x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 2x 2x 3x 3x 3x 3x  
'use client'
 
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import type { Socket } from 'socket.io-client'
import { PRINT_JOB_UPDATED_EVENT, type PrintJobUpdatedEvent } from '@/lib/abacus/print/ring-events'
import { abacusPrintKeys } from '@/lib/queryKeys'
import { createSocket } from '@/lib/socket'
 
/**
 * Doorbell listener for Abacus Studio print jobs (Phase 2a, #8.4), modeled
 * on `useParentSocket`.
 *
 * Joins the viewer's `user:` room and, on each print-job-updated ring,
 * invalidates the print proxy query keys so React Query re-reads the real
 * state through the authenticated proxy. The ring payload itself is never
 * trusted as state — it's identifiers only.
 *
 * Mount once per surface that shows job state (the #9 print panel). This
 * socket is the ONLY push channel — there is no backstop poll. Rings missed
 * while disconnected are repaired by the reconcile invalidate on every
 * (re)connect below. Progress % never rings (the eink#486 doorbell contract
 * rings on phase transitions only), so between phases it's coarse until THH
 * ships throttled progress rings.
 *
 * @param userId - The viewer's user id (undefined = don't connect)
 */
export function usePrintJobRing(userId: string | undefined): { connected: boolean } {
  const [connected, setConnected] = useState(false)
  const socketRef = useRef<Socket | null>(null)
  const queryClient = useQueryClient()
 
  useEffect(() => {
    if (!userId) return
 
    const socket = createSocket({
      reconnection: true,
      reconnectionDelay: 1000,
      reconnectionAttempts: 5,
    })
    socketRef.current = socket
 
    socket.on('connect', () => {
      setConnected(true)
      socket.emit('join-user-channel', { userId })
      // Reconcile on every (re)connect: any ring that fired while this socket
      // was down is gone for good, so re-read the roster once. Bounded — one
      // invalidate per connect edge, no timers.
      queryClient.invalidateQueries({ queryKey: abacusPrintKeys.jobs() })
    })
 
    socket.on('disconnect', () => {
      setConnected(false)
    })
 
    socket.on(PRINT_JOB_UPDATED_EVENT, (data: PrintJobUpdatedEvent) => {
      // Job state changed → re-read the roster and the specific job.
      queryClient.invalidateQueries({ queryKey: abacusPrintKeys.jobs() })
      if (data.jobId) {
        queryClient.invalidateQueries({ queryKey: abacusPrintKeys.job(data.jobId) })
      }
      // A phase change can also mean AMS state moved (spool consumed/swapped);
      // refresh that printer's filament snapshot early rather than waiting
      // out the staleTime.
      if (data.printerId) {
        queryClient.invalidateQueries({ queryKey: abacusPrintKeys.filaments(data.printerId) })
      }
    })
 
    return () => {
      socket.disconnect()
      socketRef.current = null
    }
  }, [userId, queryClient])
 
  return { connected }
}