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.
This commit is contained in:
2026-05-16 16:55:59 +01:00
parent 617c81357f
commit b7e3208eb2
17 changed files with 3965 additions and 75 deletions
+26
View File
@@ -0,0 +1,26 @@
export const CHANNEL = 'mai-call-sync';
export type SyncMessage =
| { type: 'queue-update'; pendingCount: number }
| { type: 'synced'; clientRequestId: string }
| { type: 'dead-letter'; clientRequestId: string };
let _ch: BroadcastChannel | null = null;
function ch(): BroadcastChannel | null {
if (typeof window === 'undefined') return null;
if (!_ch) _ch = new BroadcastChannel(CHANNEL);
return _ch;
}
export function broadcast(msg: SyncMessage): void {
ch()?.postMessage(msg);
}
export function subscribeBroadcast(handler: (msg: SyncMessage) => void): () => void {
const c = ch();
if (!c) return () => {};
const listener = (e: MessageEvent<SyncMessage>) => handler(e.data);
c.addEventListener('message', listener);
return () => c.removeEventListener('message', listener);
}
+40
View File
@@ -0,0 +1,40 @@
import Dexie, { type Table } from 'dexie';
export interface PendingRequest {
clientRequestId: string; // primary key
workstationId: string;
description: string;
photoBlob?: Blob;
queuedAt: number;
retries: number;
lastError?: string;
}
export interface DeadLetter {
clientRequestId: string;
error: string;
failedAt: number;
}
class MaiCallDb extends Dexie {
pending!: Table<PendingRequest, string>;
deadLetters!: Table<DeadLetter, string>;
constructor() {
super('mai-call-v1');
this.version(1).stores({
pending: 'clientRequestId, queuedAt',
deadLetters: 'clientRequestId',
});
}
}
// Single browser-side instance. Guards against SSR import.
function makeDb(): MaiCallDb {
if (typeof window === 'undefined') {
return null as unknown as MaiCallDb; // never called server-side
}
return new MaiCallDb();
}
export const db = makeDb();
+105
View File
@@ -0,0 +1,105 @@
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;
}
}