FieldOps/apps/operator-pwa/app/sync-provider.tsx
Pedro Gomes 9418b360bc MAI CALL - step 13
pnpm test:e2e verde. Passo 13 completo.

O que foi feito:

Polish:

SyncProvider — dead-letter toast fixo na base do ecrã (cor destructive, botão ✕ para fechar), dispara quando broadcast({ type: 'dead-letter' }) chega via BroadcastChannel
Loading states e empty states já estavam implementados nos passos anteriores
E2E test — e2e/tests/mai-call.spec.ts:

Substitui o ping.spec.ts obsoleto
Arranca ambos os servidores (operator-pwa :3000 + admin-web :3001) com AUTH_DEV_AUTOLOGIN=true
Fluxo completo em 13.7s: formulário → IndexedDB → sync automático → admin queue → claim (OPEN→CLAIMED) → enable RESOLVED filter → resolve dialog → confirm (CLAIMED→RESOLVED)
pnpm test:e2e passa ✓
2026-05-16 17:06:27 +01:00

103 lines
3.1 KiB
TypeScript

'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { subscribeBroadcast, type SyncMessage } from '@/lib/queue/broadcast';
import { runSync } from '@/lib/queue/sync';
import { db } from '@/lib/queue/db';
interface SyncState {
pendingCount: number;
deadLetterCount: number;
}
const SyncCtx = createContext<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
export const useSyncState = () => useContext(SyncCtx);
export function SyncProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
const [failedIds, setFailedIds] = useState<string[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refreshCounts = useCallback(async () => {
const [p, d] = await Promise.all([db.pending.count(), db.deadLetters.count()]);
setState({ pendingCount: p, deadLetterCount: d });
}, []);
const sync = useCallback(async () => {
await runSync();
await refreshCounts();
}, [refreshCounts]);
useEffect(() => {
refreshCounts();
const unsub = subscribeBroadcast(async (msg: SyncMessage) => {
await refreshCounts();
if (msg.type === 'dead-letter') {
setFailedIds((prev) => [...prev, msg.clientRequestId]);
}
});
const onOnline = () => sync();
const onVisible = () => {
if (document.visibilityState === 'visible' && navigator.onLine) sync();
};
window.addEventListener('online', onOnline);
document.addEventListener('visibilitychange', onVisible);
timerRef.current = setInterval(() => {
if (navigator.onLine) sync();
}, 10_000);
if ('serviceWorker' in navigator && 'SyncManager' in window) {
navigator.serviceWorker.ready
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then((reg) => (reg as any).sync.register('mai-call-sync'))
.catch(() => {/* not supported or permission denied */});
}
if (navigator.onLine) sync();
return () => {
unsub();
window.removeEventListener('online', onOnline);
document.removeEventListener('visibilitychange', onVisible);
if (timerRef.current) clearInterval(timerRef.current);
};
}, [sync, refreshCounts]);
return (
<SyncCtx.Provider value={state}>
{children}
{failedIds.length > 0 && (
<div className="fixed bottom-4 left-4 right-4 z-50 flex flex-col gap-2">
{failedIds.map((id) => (
<div
key={id}
className="flex items-center justify-between rounded-lg bg-destructive px-4 py-3 text-sm text-destructive-foreground shadow-lg"
>
<span>Pedido {id.slice(0, 8)} falhou contacta o supervisor.</span>
<button
onClick={() => setFailedIds((prev) => prev.filter((x) => x !== id))}
className="ml-4 shrink-0 opacity-80 hover:opacity-100"
aria-label="Fechar"
>
</button>
</div>
))}
</div>
)}
</SyncCtx.Provider>
);
}