Pular para o conteúdo

Todos os trabalhosRelatório diário de obra

Sem sinal na obra.

App de relatório diário de obra que entrou em produção numa grande obra de metrô em São Paulo. A equipe registra atividade, pessoas e fotos pelo celular, mesmo sem sinal. Quando a conexão volta, tudo sobe sozinho e nada duplica. No fim, o cliente assina e o PDF sai pronto.

Estado
entrou em produção, emprego anterior
Período
03 a 07/2026
Stack
Next.js 14, TypeScript, Supabase (Postgres, Auth, Storage), Dexie (IndexedDB), Serwist, Puppeteer, Vercel

Feito por mim

Aplicativo, fila offline, painel, assinatura e relatórios em PDF.

Contexto

Projeto interno de um emprego anterior. As telas usam dados de exemplo anonimizados.

Tente quebrar

Registre com a rede cortada. Perca a resposta do servidor. Tente duplicar.

O mesmo problema do app de obra: o registro espera no aparelho e nasce uma vez só no servidor.

Esta demonstração precisa de JavaScript. O resto da página funciona sem ele.

Por dentro

A resposta perdida

O pior caso em campo não é ficar sem rede. É o servidor gravar e a resposta se perder no caminho: o app tenta de novo e duplica. Por isso cada registro nasce com uma chave. O servidor reaproveita o que já existe com aquela chave, e um índice único parcial no banco garante o resto, mesmo que o código erre.

SQL
-- migration 0022: resposta perdida depois do commit vira retry, e retry duplicava
alter table activities add column if not exists client_key text;
create unique index if not exists uq_activities_client_key
  on activities (client_key) where client_key is not null;

Tudo ou nada no aparelho

Atividade e fotos entram numa única transação do IndexedDB: ou as duas ficam, ou nenhuma. A foto é guardada como Blob, sem base64. Foto que já subiu guarda o caminho e não sobe de novo no retry. Editar um item da fila mantém a mesma chave, então a garantia continua valendo.

TypeScript
// trecho de sync.ts, resumido
export const MAX_SYNC_ATTEMPTS = 5;

export async function syncPending() {
  if (running) return;           // trava de concorrência
  if (!navigator.onLine) return;
  running = true;
  try {
    for (const item of await listUnsynced()) {
      if (!navigator.onLine) break;
      if (item.attempts >= MAX_SYNC_ATTEMPTS) continue; // espera retry manual
      try { await syncOne(item); }
      catch (e) {
        await patchActivity(item.localId, {
          status: 'error',
          error: e instanceof Error ? e.message : 'Falha ao sincronizar',
          attempts: item.attempts + 1,
        });
      }
    }
  } finally { running = false; }
}

O papel no fim do dia

O PDF é gerado no servidor com Chromium empacotado para função serverless. Cada assinatura ganha um código público de 12 caracteres e um QR: qualquer pessoa confere numa página aberta, sem login, quem assinou e quando.

Como sei que funciona

Os testes da fila cobrem o retry, o limite de 5 tentativas e a foto que não sobe duas vezes. O índice único é a última barreira: mesmo com bug no app, o banco recusa a duplicata.

O que isso não faz

  • Sincroniza com o app aberto. Não usa Background Sync.
  • Não há espera crescente: são 5 tentativas e, depois, reenvio manual na tela de pendentes.
  • A fila só cria registros, então não existe resolução de conflito de edição.
  • O código de verificação identifica a assinatura. Não é assinatura criptográfica nem prova contra adulteração.

Telas

Lista de atividades do app de obra, com dados de exemplo
Atividades, com dados de exemplo.
Telas do celular: formulário, equipe e fotos, e a fila de pendentes
Formulário, fotos e a fila de pendentes.

O que quebrou

  1. Um deploy antes da migration derrubou o login de todo mundo.

    A regra virou leitura defensiva e recarga explícita do esquema depois de cada migration.

  2. Alguns registros abriam o PDF antigo.

    A rota passava pelo cache do service worker. PDF agora é NetworkOnly.

Créditos: Projeto interno desenvolvido em um emprego anterior. Empresa, cliente e obra não são identificados, e as telas usam dados de exemplo anonimizados. Não é cliente da Foxer.

Me conta o problema.

Uma mensagem basta para começar. Eu mesmo respondo e, se fizer sentido, mando uma proposta por escrito: o que entra, o que fica de fora, prazo e preço.

WhatsApp (11) 99227-9903