Просмотр исходного кода

Say when a change did not reach the server, instead of failing silently on a weak connection (#513)

* Say when a change did not reach the server, instead of failing silently on a weak connection

* Count a click on its own as input, and keep a clock start from reporting success when it cannot be read back
Bernt Christian Egeland 2 дней назад
Родитель
Сommit
fbe9370fb7

+ 4 - 0
messages/de/common.json

@@ -165,5 +165,9 @@
     "title": "Sie wurden abgemeldet",
     "description": "Melden Sie sich erneut an, um weiterzuarbeiten. Kopieren Sie vorher, was Sie gerade eingegeben haben.",
     "signIn": "Anmelden"
+  },
+  "connectionLost": {
+    "title": "Torqvoice ist nicht erreichbar",
+    "description": "Ihre letzte Änderung wurde möglicherweise nicht gespeichert. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut."
   }
 }

+ 4 - 0
messages/en/common.json

@@ -165,5 +165,9 @@
     "title": "You have been signed out",
     "description": "Sign in again to keep working. Copy anything you were typing first.",
     "signIn": "Sign in"
+  },
+  "connectionLost": {
+    "title": "Couldn't reach Torqvoice",
+    "description": "Your last change may not have been saved. Check your connection and try again."
   }
 }

+ 4 - 0
messages/es/common.json

@@ -165,5 +165,9 @@
     "title": "Se ha cerrado tu sesión",
     "description": "Inicia sesión de nuevo para seguir trabajando. Copia primero lo que estabas escribiendo.",
     "signIn": "Iniciar sesión"
+  },
+  "connectionLost": {
+    "title": "No se pudo conectar con Torqvoice",
+    "description": "Puede que tu último cambio no se haya guardado. Revisa tu conexión e inténtalo de nuevo."
   }
 }

+ 4 - 0
messages/fr/common.json

@@ -165,5 +165,9 @@
     "title": "Votre session a expiré",
     "description": "Reconnectez-vous pour continuer. Copiez d'abord ce que vous étiez en train de saisir.",
     "signIn": "Se connecter"
+  },
+  "connectionLost": {
+    "title": "Impossible de joindre Torqvoice",
+    "description": "Votre dernière modification n'a peut-être pas été enregistrée. Vérifiez votre connexion et réessayez."
   }
 }

+ 4 - 0
messages/it/common.json

@@ -165,5 +165,9 @@
     "title": "La sessione è scaduta",
     "description": "Accedi di nuovo per continuare a lavorare. Copia prima ciò che stavi scrivendo.",
     "signIn": "Accedi"
+  },
+  "connectionLost": {
+    "title": "Impossibile raggiungere Torqvoice",
+    "description": "L'ultima modifica potrebbe non essere stata salvata. Controlla la connessione e riprova."
   }
 }

+ 4 - 0
messages/lt/common.json

@@ -165,5 +165,9 @@
     "title": "Sesija baigėsi",
     "description": "Prisijunkite iš naujo, kad galėtumėte tęsti darbą. Pirmiausia nukopijuokite tai, ką rašėte.",
     "signIn": "Prisijungti"
+  },
+  "connectionLost": {
+    "title": "Nepavyko pasiekti Torqvoice",
+    "description": "Paskutinis pakeitimas galėjo būti neišsaugotas. Patikrinkite ryšį ir bandykite dar kartą."
   }
 }

+ 4 - 0
messages/nb/common.json

@@ -165,5 +165,9 @@
     "title": "Du er logget ut",
     "description": "Logg inn igjen for å fortsette. Kopier det du holdt på å skrive først.",
     "signIn": "Logg inn"
+  },
+  "connectionLost": {
+    "title": "Får ikke kontakt med Torqvoice",
+    "description": "Den siste endringen din ble kanskje ikke lagret. Sjekk tilkoblingen og prøv igjen."
   }
 }

+ 4 - 0
messages/nl/common.json

@@ -165,5 +165,9 @@
     "title": "Je bent afgemeld",
     "description": "Meld je opnieuw aan om verder te werken. Kopieer eerst wat je aan het typen was.",
     "signIn": "Aanmelden"
+  },
+  "connectionLost": {
+    "title": "Torqvoice is niet bereikbaar",
+    "description": "Je laatste wijziging is mogelijk niet opgeslagen. Controleer je verbinding en probeer het opnieuw."
   }
 }

+ 4 - 0
messages/pl/common.json

@@ -165,5 +165,9 @@
     "title": "Sesja wygasła",
     "description": "Zaloguj się ponownie, aby kontynuować pracę. Najpierw skopiuj wpisany tekst.",
     "signIn": "Zaloguj się"
+  },
+  "connectionLost": {
+    "title": "Nie można połączyć się z Torqvoice",
+    "description": "Ostatnia zmiana mogła nie zostać zapisana. Sprawdź połączenie i spróbuj ponownie."
   }
 }

+ 4 - 0
messages/pt-BR/common.json

@@ -165,5 +165,9 @@
     "title": "Sua sessão foi encerrada",
     "description": "Entre novamente para continuar trabalhando. Copie antes o que estava digitando.",
     "signIn": "Entrar"
+  },
+  "connectionLost": {
+    "title": "Não foi possível conectar ao Torqvoice",
+    "description": "Sua última alteração pode não ter sido salva. Verifique sua conexão e tente novamente."
   }
 }

+ 4 - 0
messages/pt-PT/common.json

@@ -165,5 +165,9 @@
     "title": "A sua sessão terminou",
     "description": "Inicie sessão novamente para continuar a trabalhar. Copie primeiro o que estava a escrever.",
     "signIn": "Iniciar sessão"
+  },
+  "connectionLost": {
+    "title": "Não foi possível contactar o Torqvoice",
+    "description": "A sua última alteração pode não ter sido guardada. Verifique a sua ligação e tente novamente."
   }
 }

+ 4 - 0
messages/ru/common.json

@@ -165,5 +165,9 @@
     "title": "Сеанс завершён",
     "description": "Войдите снова, чтобы продолжить работу. Сначала скопируйте то, что вы вводили.",
     "signIn": "Войти"
+  },
+  "connectionLost": {
+    "title": "Не удалось связаться с Torqvoice",
+    "description": "Последнее изменение могло не сохраниться. Проверьте подключение и повторите попытку."
   }
 }

+ 4 - 0
messages/tr/common.json

@@ -165,5 +165,9 @@
     "title": "Oturumunuz kapandı",
     "description": "Çalışmaya devam etmek için yeniden giriş yapın. Önce yazmakta olduğunuz metni kopyalayın.",
     "signIn": "Giriş yap"
+  },
+  "connectionLost": {
+    "title": "Torqvoice'a ulaşılamadı",
+    "description": "Son değişikliğiniz kaydedilmemiş olabilir. Bağlantınızı kontrol edip tekrar deneyin."
   }
 }

+ 118 - 0
src/__tests__/components/connection-watcher.test.tsx

@@ -0,0 +1,118 @@
+import { fireEvent, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const toast = vi.fn()
+vi.mock('@/components/notice-toast', () => ({
+  showNotice: (notice: unknown) => toast(notice),
+}))
+
+import { ConnectionWatcher, isNetworkError } from '@/components/connection-watcher'
+
+/**
+ * What the browser hands the page when a server action call nobody caught
+ * throws. Dispatched by hand, because a real rejected promise would fail the
+ * test run itself.
+ */
+function failUncaught(reason: unknown) {
+  const event = new Event('unhandledrejection')
+  Object.defineProperty(event, 'reason', { value: reason })
+  window.dispatchEvent(event)
+}
+
+const offline = () => new TypeError('Failed to fetch')
+
+describe('isNetworkError', () => {
+  it("recognises each browser's words for a request that never arrived", () => {
+    expect(isNetworkError(new TypeError('Failed to fetch'))).toBe(true)
+    expect(isNetworkError(new TypeError('NetworkError when attempting to fetch resource.'))).toBe(
+      true
+    )
+    expect(isNetworkError(new TypeError('Load failed'))).toBe(true)
+    expect(isNetworkError(new TypeError('The network connection was lost.'))).toBe(true)
+  })
+
+  it('leaves every other error alone', () => {
+    expect(
+      isNetworkError(new TypeError("Cannot read properties of undefined (reading 'id')"))
+    ).toBe(false)
+    expect(isNetworkError(new Error('Failed to fetch'))).toBe(false)
+    expect(isNetworkError('Failed to fetch')).toBe(false)
+  })
+})
+
+describe('ConnectionWatcher', () => {
+  beforeEach(() => {
+    toast.mockReset()
+    vi.useFakeTimers({ toFake: ['Date'] })
+  })
+  afterEach(() => {
+    vi.useRealTimers()
+  })
+
+  it('says so when a click of the person did not reach the server', () => {
+    render(<ConnectionWatcher />)
+    fireEvent.pointerDown(document.body)
+    failUncaught(offline())
+
+    expect(toast).toHaveBeenCalledTimes(1)
+    const [notice] = toast.mock.calls[0]
+    expect(notice).toMatchObject({
+      id: 'connection-lost',
+      title: "Couldn't reach Torqvoice",
+      description: 'Your last change may not have been saved. Check your connection and try again.',
+      dismissLabel: 'Dismiss',
+    })
+    // Nothing to press: retrying is the person's call, on the screen they are on
+    expect(notice).not.toHaveProperty('actionLabel')
+  })
+
+  it('also counts a keystroke, such as Enter in a form', () => {
+    render(<ConnectionWatcher />)
+    fireEvent.keyDown(document.body, { key: 'Enter' })
+    failUncaught(offline())
+
+    expect(toast).toHaveBeenCalledTimes(1)
+  })
+
+  it('also counts a click on its own, the way a screen reader presses a button', () => {
+    render(<ConnectionWatcher />)
+    fireEvent.click(document.body)
+    failUncaught(offline())
+
+    expect(toast).toHaveBeenCalledTimes(1)
+  })
+
+  it('stays quiet about a request the page made by itself, like a laptop waking up', () => {
+    render(<ConnectionWatcher />)
+    failUncaught(offline())
+
+    expect(toast).not.toHaveBeenCalled()
+  })
+
+  it('stays quiet once the last click is more than a minute old', () => {
+    render(<ConnectionWatcher />)
+    fireEvent.pointerDown(document.body)
+    vi.setSystemTime(Date.now() + 61 * 1000)
+    failUncaught(offline())
+
+    expect(toast).not.toHaveBeenCalled()
+  })
+
+  it('leaves any other error to the screen that threw it', () => {
+    render(<ConnectionWatcher />)
+    fireEvent.pointerDown(document.body)
+    failUncaught(new Error('Unauthorized'))
+    failUncaught(new TypeError('x is not a function'))
+
+    expect(toast).not.toHaveBeenCalled()
+  })
+
+  it('stops listening when the app unmounts', () => {
+    const { unmount } = render(<ConnectionWatcher />)
+    fireEvent.pointerDown(document.body)
+    unmount()
+    failUncaught(offline())
+
+    expect(toast).not.toHaveBeenCalled()
+  })
+})

+ 16 - 0
src/__tests__/components/notice-toast.test.tsx

@@ -59,4 +59,20 @@ describe('NoticeToast', () => {
     render(renderCard('app-update'))
     expect(screen.getByTestId('notice-toast')).toHaveTextContent('Reload')
   })
+
+  it('shows only the close button when there is nothing to do but know', () => {
+    render(
+      <NoticeToast
+        toastId="t1"
+        icon={RefreshCw}
+        title="Couldn't reach Torqvoice"
+        description="Your last change may not have been saved."
+        dismissLabel="Dismiss"
+      />
+    )
+
+    expect(screen.getByRole('status')).toHaveTextContent("Couldn't reach Torqvoice")
+    expect(screen.getAllByRole('button')).toHaveLength(1)
+    expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
+  })
 })

+ 142 - 0
src/__tests__/features/time-tracking/time-clock-provider.test.tsx

@@ -0,0 +1,142 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { useState } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const getMyClock = vi.fn()
+const startMyClock = vi.fn()
+vi.mock('@/features/time-tracking/Actions/timeClockActions', () => ({
+  getMyClock: () => getMyClock(),
+  startMyClock: (input: unknown) => startMyClock(input),
+  stopMyClock: vi.fn(),
+}))
+
+/** The app's one socket, reduced to what the clock listens to. */
+const resyncHandlers = new Set<() => void>()
+const realtime = {
+  onLegacy: () => () => undefined,
+  onResync: (handler: () => void) => {
+    resyncHandlers.add(handler)
+    return () => resyncHandlers.delete(handler)
+  },
+}
+vi.mock('@/features/realtime/RealtimeProvider', () => ({
+  useRealtime: () => realtime,
+}))
+
+import {
+  TimeClockProvider,
+  useTimeClock,
+} from '@/features/time-tracking/Components/TimeClockProvider'
+
+function ClockLine() {
+  const { open } = useTimeClock()
+  return <p>{open ? `Clocked in on ${open.jobTitle}` : 'Not clocked in'}</p>
+}
+
+/** A start button that says what start() told it. */
+function StartButton() {
+  const { start } = useTimeClock()
+  const [outcome, setOutcome] = useState('')
+  return (
+    <>
+      <button
+        type="button"
+        onClick={() =>
+          start('sr-2').then(
+            (ok) => setOutcome(ok ? 'Start worked' : 'Start refused'),
+            () => setOutcome('Start not confirmed')
+          )
+        }
+      >
+        Start
+      </button>
+      <p>{outcome}</p>
+    </>
+  )
+}
+
+const running = {
+  success: true,
+  data: {
+    technicianIds: ['tech-1'],
+    open: {
+      id: 'clock-1',
+      startedAt: '2026-11-07T07:30:00.000Z',
+      serviceRecordId: 'sr-1',
+      jobTitle: 'Front brake pads',
+      vehicleId: null,
+    },
+  },
+}
+
+describe('TimeClockProvider', () => {
+  beforeEach(() => {
+    getMyClock.mockReset()
+    startMyClock.mockReset()
+    resyncHandlers.clear()
+  })
+
+  it('keeps showing the running clock when a reconnection cannot read it', async () => {
+    getMyClock.mockResolvedValueOnce(running)
+    render(
+      <TimeClockProvider technicianIds={['tech-1']}>
+        <ClockLine />
+      </TimeClockProvider>
+    )
+    expect(await screen.findByText('Clocked in on Front brake pads')).toBeInTheDocument()
+
+    // A laptop waking up on another network: the socket is back, the request is not
+    getMyClock.mockRejectedValueOnce(new TypeError('Failed to fetch'))
+    await act(async () => {
+      for (const handler of resyncHandlers) handler()
+      await Promise.resolve()
+    })
+
+    expect(getMyClock).toHaveBeenCalledTimes(2)
+    expect(screen.getByText('Clocked in on Front brake pads')).toBeInTheDocument()
+  })
+
+  it('reads the clock again on a reconnection that works', async () => {
+    getMyClock.mockResolvedValueOnce(running)
+    render(
+      <TimeClockProvider technicianIds={['tech-1']}>
+        <ClockLine />
+      </TimeClockProvider>
+    )
+    await screen.findByText('Clocked in on Front brake pads')
+
+    // Stopped from the phone while the laptop slept
+    getMyClock.mockResolvedValueOnce({
+      success: true,
+      data: { technicianIds: ['tech-1'], open: null },
+    })
+    await act(async () => {
+      for (const handler of resyncHandlers) handler()
+      await Promise.resolve()
+    })
+
+    expect(await screen.findByText('Not clocked in')).toBeInTheDocument()
+  })
+
+  it('does not say a start worked when the clock cannot be read back', async () => {
+    getMyClock.mockResolvedValueOnce(running)
+    render(
+      <TimeClockProvider technicianIds={['tech-1']}>
+        <ClockLine />
+        <StartButton />
+      </TimeClockProvider>
+    )
+    await screen.findByText('Clocked in on Front brake pads')
+
+    // The start reaches the server, the read after it does not
+    startMyClock.mockResolvedValueOnce({
+      success: true,
+      data: { startedAt: '2026-11-07T09:00:00.000Z', closed: null },
+    })
+    getMyClock.mockRejectedValueOnce(new TypeError('Failed to fetch'))
+    fireEvent.click(screen.getByRole('button', { name: 'Start' }))
+
+    expect(await screen.findByText('Start not confirmed')).toBeInTheDocument()
+    expect(screen.queryByText('Start worked')).not.toBeInTheDocument()
+  })
+})

+ 2 - 0
src/app/(authenticated)/layout.tsx

@@ -20,6 +20,7 @@ import { BroadcastLive } from '@/components/broadcast-live'
 import { CurrencySettingsProvider } from '@/components/currency-settings-context'
 import { getCachedMembership } from '@/lib/cached-session'
 import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { ConnectionWatcher } from '@/components/connection-watcher'
 import { OnlineTracker } from '@/components/online-tracker'
 import { UpdateWatcher } from '@/components/update-watcher'
 import { buildVersion } from '@/lib/build-version.server'
@@ -413,6 +414,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
                               <PlateLookupCommand />
                               {isOwnerOrAdmin && <NotificationInitializer />}
                               <OnlineTracker />
+                              <ConnectionWatcher />
                               <UpdateWatcher version={buildVersion()} />
                               <InstallBanner
                                 appName={appBranding.name}

+ 81 - 0
src/components/connection-watcher.tsx

@@ -0,0 +1,81 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { useEffect } from 'react'
+import { WifiOff } from 'lucide-react'
+import { showNotice } from '@/components/notice-toast'
+
+const TOAST_ID = 'connection-lost'
+
+/**
+ * How long after a click or a keystroke a request that never arrived is
+ * taken to be the person's own. On a weak connection a save can take several
+ * seconds to fail, so this is generous. A request the page makes by itself,
+ * such as when a laptop wakes up on another network, has nobody waiting for
+ * it and stays quiet.
+ */
+export const RECENT_INPUT_MS = 60 * 1000
+
+/**
+ * What each browser's fetch throws when the request never reached the
+ * server: offline, a dropped connection, a network change. Chrome, Firefox
+ * and Safari (old and new) word it differently.
+ */
+const NETWORK_ERROR_MESSAGES = [
+  /^Failed to fetch/,
+  /^NetworkError when attempting to fetch resource/,
+  /^Load failed$/,
+  /^The network connection was lost/,
+  /^The Internet connection appears to be offline/,
+]
+
+export function isNetworkError(error: unknown): boolean {
+  if (!(error instanceof TypeError)) return false
+  return NETWORK_ERROR_MESSAGES.some((pattern) => pattern.test(error.message))
+}
+
+/**
+ * Says so when something the person just did did not reach the server.
+ *
+ * A server action that cannot reach the server throws instead of returning a
+ * result, and most screens only handle the result: the error they would show
+ * is never reached, the row they deleted stays, and nothing on screen says
+ * why. This catches what no screen caught, the same way UpdateWatcher
+ * catches an action of an older build.
+ */
+export function ConnectionWatcher() {
+  const t = useTranslations('common.connectionLost')
+  const tDismiss = useTranslations('common.updateBanner')
+
+  useEffect(() => {
+    let lastInputAt = Number.NEGATIVE_INFINITY
+
+    const onInput = () => {
+      lastInputAt = Date.now()
+    }
+
+    const onRejection = (event: PromiseRejectionEvent) => {
+      if (!isNetworkError(event.reason)) return
+      if (Date.now() - lastInputAt > RECENT_INPUT_MS) return
+      showNotice({
+        id: TOAST_ID,
+        icon: WifiOff,
+        title: t('title'),
+        description: t('description'),
+        dismissLabel: tDismiss('dismiss'),
+      })
+    }
+
+    // A screen reader can press a button with a click alone, no pointer or
+    // key before it, so the click counts as well
+    const inputEvents = ['pointerdown', 'keydown', 'click'] as const
+    for (const type of inputEvents) window.addEventListener(type, onInput, { capture: true })
+    window.addEventListener('unhandledrejection', onRejection)
+    return () => {
+      for (const type of inputEvents) window.removeEventListener(type, onInput, { capture: true })
+      window.removeEventListener('unhandledrejection', onRejection)
+    }
+  }, [t, tDismiss])
+
+  return null
+}

+ 10 - 6
src/components/notice-toast.tsx

@@ -11,13 +11,15 @@ export interface Notice {
   icon: LucideIcon
   title: string
   description: string
-  actionLabel: string
-  onAction: () => void
+  /** Left out when there is nothing to press, only something to know. */
+  actionLabel?: string
+  onAction?: () => void
   dismissLabel: string
 }
 
 /**
- * A notice that needs the person to do one thing: reload, sign in again.
+ * A notice that needs the person to do one thing: reload, sign in again, or
+ * just know that something they did did not reach the server.
  *
  * Drawn in the app's own look rather than sonner's plain card, because these
  * are the moments the app talks about itself, and they should look like it:
@@ -54,9 +56,11 @@ export function NoticeToast({
         <div className="min-w-0 flex-1 pr-5">
           <p className="font-semibold text-sm tracking-tight">{title}</p>
           <p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">{description}</p>
-          <Button size="sm" className="mt-3 h-7 px-3 text-xs" onClick={onAction}>
-            {actionLabel}
-          </Button>
+          {actionLabel && onAction && (
+            <Button size="sm" className="mt-3 h-7 px-3 text-xs" onClick={onAction}>
+              {actionLabel}
+            </Button>
+          )}
         </div>
         <button
           type="button"

+ 16 - 9
src/features/notifications/hooks/useNotificationWebSocket.ts

@@ -25,14 +25,18 @@ export function useNotificationWebSocket() {
     let alive = true
 
     // Fetch initial notifications
-    getNotifications().then((result) => {
-      if (!alive) return
-      if (result.success && result.data) {
-        useNotificationStore
-          .getState()
-          .setNotifications(result.data.notifications, result.data.unreadCount)
-      }
-    })
+    getNotifications()
+      .then((result) => {
+        if (!alive) return
+        if (result.success && result.data) {
+          useNotificationStore
+            .getState()
+            .setNotifications(result.data.notifications, result.data.unreadCount)
+        }
+      })
+      .catch(() => {
+        // Offline for a moment: the bell stays empty until the next page load
+      })
 
     return () => {
       alive = false
@@ -52,7 +56,10 @@ export function useNotificationWebSocket() {
       if (data.type.endsWith('_inbound') && isActiveConversation(data.entityUrl)) {
         useNotificationStore.getState().addNotification({ ...data, read: true })
         useNotificationStore.setState((s) => ({ unreadCount: Math.max(0, s.unreadCount - 1) }))
-        markNotificationRead(data.id)
+        markNotificationRead(data.id).catch(() => {
+          // Offline for a moment: it stays unread on the server, and shows so
+          // on the next page load
+        })
         return
       }
 

+ 22 - 3
src/features/time-tracking/Components/TimeClockProvider.tsx

@@ -67,12 +67,27 @@ export function TimeClockProvider({
   idsRef.current = technicianIds
   const isTechnician = technicianIds.length > 0
 
-  const refresh = useCallback(async () => {
+  /** Reads the clock, and throws when the server cannot be reached. */
+  const read = useCallback(async () => {
     if (idsRef.current.length === 0) return
     const result = await getMyClock()
     if (result.success && result.data) setOpen(result.data.open)
   }, [])
 
+  /**
+   * Reads the clock for whoever is not waiting on it: the page loading, a
+   * reconnection, an event from the phone. A dropped connection, often a
+   * laptop waking up on another network, leaves the clock as shown, and the
+   * next reconnection reads it again.
+   */
+  const refresh = useCallback(async () => {
+    try {
+      await read()
+    } catch {
+      // Nobody to tell: see above
+    }
+  }, [read])
+
   useEffect(() => {
     void refresh()
   }, [refresh])
@@ -124,13 +139,17 @@ export function TimeClockProvider({
         if (result.data.closed) {
           toast(t('switched', { minutes: formatMinutes(result.data.closed.minutes ?? 0) }))
         }
-        await refresh()
+        // Not the quiet refresh: when the clock cannot be read back, start
+        // must not say it worked with the old clock still on screen. The
+        // error reaches the caller and the connection notice, and the
+        // clock_started event or the next reconnection shows the clock.
+        await read()
         return true
       } finally {
         setBusy(false)
       }
     },
-    [busy, refresh, t]
+    [busy, read, t]
   )
 
   const stop = useCallback(async () => {

+ 6 - 3
src/features/workboard/hooks/useWorkBoardWebSocket.ts

@@ -65,13 +65,16 @@ export function useWorkBoardWebSocket() {
         case 'technician_updated':
         case 'technician_removed':
           // Reload full technician list for updates/removals
-          import('../Actions/technicianActions').then(({ getTechnicians }) => {
-            getTechnicians().then((res) => {
+          import('../Actions/technicianActions')
+            .then(({ getTechnicians }) => getTechnicians())
+            .then((res) => {
               if (res.success && res.data) {
                 store.setTechnicians(res.data as Parameters<typeof store.setTechnicians>[0])
               }
             })
-          })
+            .catch(() => {
+              // Offline for a moment: the board keeps the list it has
+            })
           break
 
         case 'service_times_updated': {