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

106 lines
3.3 KiB
TypeScript

import { createTRPCClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from '@repo/api';
import { db } from './db';
import { broadcast } from './broadcast';
let _client: ReturnType<typeof createTRPCClient<AppRouter>> | null = null;
function getClient() {
if (!_client) {
_client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc', transformer: superjson })],
});
}
return _client;
}
function httpStatus(err: unknown): number | null {
if (typeof err === 'object' && err !== null && 'data' in err) {
const d = (err as { data?: { httpStatus?: number } }).data;
return d?.httpStatus ?? null;
}
return null;
}
function isNetworkErr(err: unknown): boolean {
return err instanceof TypeError;
}
let _running = false;
export async function runSync(): Promise<void> {
if (_running || !navigator.onLine) return;
_running = true;
try {
const items = await db.pending.orderBy('queuedAt').toArray();
if (!items.length) return;
const client = getClient();
for (const item of items) {
try {
let photoKey: string | undefined;
if (item.photoBlob) {
const { uploadUrl, photoKey: key } = await client.storage.signPhotoUpload.mutate({
contentType: 'image/jpeg',
byteSize: item.photoBlob.size,
});
const res = await fetch(uploadUrl, {
method: 'PUT',
body: item.photoBlob,
headers: { 'Content-Type': 'image/jpeg' },
});
if (!res.ok) throw new Error(`Photo PUT ${res.status}`);
photoKey = key;
}
await client.maintenanceRequest.create.mutate({
workstationId: item.workstationId,
description: item.description,
photoKey,
clientRequestId: item.clientRequestId,
});
await db.pending.delete(item.clientRequestId);
broadcast({ type: 'synced', clientRequestId: item.clientRequestId });
} catch (err) {
if (isNetworkErr(err)) break; // no connectivity — stop this cycle
const status = httpStatus(err);
if (status === 409) {
// Already exists (duplicate clientRequestId) — idempotent success
await db.pending.delete(item.clientRequestId);
broadcast({ type: 'synced', clientRequestId: item.clientRequestId });
continue;
}
if (status !== null && status >= 400 && status < 500) {
// 4xx other than 409 — permanent failure, move to dead-letter
await db.deadLetters.put({
clientRequestId: item.clientRequestId,
error: err instanceof Error ? err.message : String(err),
failedAt: Date.now(),
});
await db.pending.delete(item.clientRequestId);
broadcast({ type: 'dead-letter', clientRequestId: item.clientRequestId });
continue;
}
// 5xx / unknown — increment retries, leave in queue
await db.pending.update(item.clientRequestId, {
retries: (item.retries ?? 0) + 1,
lastError: err instanceof Error ? err.message : String(err),
});
}
}
const remaining = await db.pending.count();
broadcast({ type: 'queue-update', pendingCount: remaining });
} finally {
_running = false;
}
}