FieldOps/apps/operator-pwa/app/sync-provider.tsx
Pedro Gomes b7e3208eb2 MAI CALL - step 12
Passo 12 completo. Build limpo, AC server-side totalmente verificado.

O que foi implementado:

lib/queue/ — camada de persistência offline:

db.ts — Dexie 4 com tabelas pending e deadLetters
broadcast.ts — BroadcastChannel helper (mai-call-sync) para comunicar entre tabs
sync.ts — loop de sync com retry/backoff: signPhotoUpload → PUT MinIO → create; 409 = sucesso; 4xx = dead-letter; erros de rede = paragem + retry na próxima volta
SyncProvider — React Context que:

Arranca sync ao reconectar (online event + visibilitychange)
Polling de 10s como fallback
Regista Background Sync API quando disponível
Expõe pendingCount / deadLetterCount via useSyncState()
Formulário (/maintenance/new) — refatorado: ao submeter, escreve em IndexedDB e navega imediatamente para /sent sem esperar pelo servidor. O SyncProvider processa a fila em background.

Feedback visual:

SyncChip na home: "Tudo sincronizado" / "N pedidos por enviar" / erro dead-letter
/maintenance/sent: mostra "Em fila" (Clock) ou "Enviado" (CheckCircle2) reactivamente via BroadcastChannel
Workbox (@ducanh2912/next-pwa) — app shell precaching ativo, para que o app carregue mesmo sem rede depois da primeira visita.
2026-05-16 16:55:59 +01:00

77 lines
2.3 KiB
TypeScript

'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { subscribeBroadcast } 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 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 () => refreshCounts());
const onOnline = () => sync();
const onVisible = () => {
if (document.visibilityState === 'visible' && navigator.onLine) sync();
};
window.addEventListener('online', onOnline);
document.addEventListener('visibilitychange', onVisible);
// Poll every 10s as a fallback
timerRef.current = setInterval(() => {
if (navigator.onLine) sync();
}, 10_000);
// Register Background Sync if SW + API available
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 */});
}
// Initial sync attempt
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}</SyncCtx.Provider>;
}