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
+21 -37
View File
@@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Camera, X } from 'lucide-react';
import { trpc } from '@/lib/trpc/client';
import { db } from '@/lib/queue/db';
import { runSync } from '@/lib/queue/sync';
// Resize to max 1600px on longest side and compress to JPEG q=0.8.
function compressImage(file: File): Promise<Blob> {
@@ -52,9 +54,10 @@ export default function NewRequestPage() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery();
const signUpload = trpc.storage.signPhotoUpload.useMutation();
const createRequest = trpc.maintenanceRequest.create.useMutation();
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
undefined,
{ staleTime: 60 * 60 * 1000 }, // 1h — serves from cache when offline
);
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@@ -62,9 +65,8 @@ export default function NewRequestPage() {
try {
const compressed = await compressImage(file);
if (photoPreview) URL.revokeObjectURL(photoPreview);
const preview = URL.createObjectURL(compressed);
setPhotoBlob(compressed);
setPhotoPreview(preview);
setPhotoPreview(URL.createObjectURL(compressed));
} catch {
setError('Não foi possível processar a foto. Tenta de novo.');
}
@@ -86,35 +88,24 @@ export default function NewRequestPage() {
try {
const clientRequestId = crypto.randomUUID();
let photoKey: string | undefined;
// 1. Upload photo if present
if (photoBlob) {
const { uploadUrl, photoKey: key } = await signUpload.mutateAsync({
contentType: 'image/jpeg',
byteSize: photoBlob.size,
});
const res = await fetch(uploadUrl, {
method: 'PUT',
body: photoBlob,
headers: { 'Content-Type': 'image/jpeg' },
});
if (!res.ok) throw new Error('Falha no upload da foto');
photoKey = key;
}
// 2. Create request
await createRequest.mutateAsync({
// Enqueue in IndexedDB immediately — returns control to the user
// regardless of network state. The SyncProvider will drain the queue.
await db.pending.add({
clientRequestId,
workstationId,
description: description.trim(),
photoKey,
clientRequestId,
photoBlob: photoBlob ?? undefined,
queuedAt: Date.now(),
retries: 0,
});
// 3. Confirm
// Attempt immediate sync if online (fire-and-forget)
if (navigator.onLine) runSync().catch(() => {});
router.push(`/maintenance/sent?cid=${clientRequestId}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao submeter pedido. Tenta de novo.');
setError(err instanceof Error ? err.message : 'Erro ao guardar pedido. Tenta de novo.');
setSubmitting(false);
}
}
@@ -124,7 +115,6 @@ export default function NewRequestPage() {
return (
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
{/* Header */}
<header className="flex items-center gap-3 border-b border-border bg-card px-4 py-3">
<Link href="/" className="rounded-md p-1 hover:bg-accent">
<ArrowLeft className="h-5 w-5" />
@@ -163,11 +153,7 @@ export default function NewRequestPage() {
{photoPreview ? (
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photoPreview}
alt="Pré-visualização"
className="h-48 w-full rounded-lg object-cover"
/>
<img src={photoPreview} alt="Pré-visualização" className="h-48 w-full rounded-lg object-cover" />
<button
type="button"
onClick={removePhoto}
@@ -199,9 +185,7 @@ export default function NewRequestPage() {
{/* Descrição */}
<div className="flex flex-col gap-1.5">
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium">
<span>
Descrição <span className="text-destructive">*</span>
</span>
<span>Descrição <span className="text-destructive">*</span></span>
<span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
{descLen}/1000
</span>
@@ -229,7 +213,7 @@ export default function NewRequestPage() {
disabled={!canSubmit}
className="w-full rounded-xl bg-primary px-6 py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
>
{submitting ? 'A enviar…' : 'Enviar pedido'}
{submitting ? 'A guardar…' : 'Enviar pedido'}
</button>
</div>
</form>