Skip to content

All workDaily construction report

No signal on site.

A daily construction report app that went into production on a large subway job in São Paulo. The team records activity, people and photos on the phone, even with no signal. When the connection returns, everything uploads by itself and nothing is duplicated. At the end, the client signs and the PDF comes out ready.

Status
went into production, previous job
Period
Mar to Jul 2026
Stack
Next.js 14, TypeScript, Supabase (Postgres, Auth, Storage), Dexie (IndexedDB), Serwist, Puppeteer, Vercel

Done by me

App, offline queue, panel, signature and PDF reports.

Context

Internal project at a previous job. The screens use anonymized sample data.

Try to break it

Record with the network cut. Lose the server reply. Try to create a duplicate.

The same problem as the job-site app: the record waits on the device and is created only once on the server.

This demo needs JavaScript. The rest of the page works without it.

Inside

The lost reply

The worst case in the field is not having no network. It is the server saving the record and the reply getting lost on the way: the app tries again and duplicates it. So each record is born with a key. The server reuses what already exists with that key, and a partial unique index in the database guarantees the rest, even if the code is wrong.

SQL
-- migration 0022: a reply lost after commit became a retry, and the retry duplicated
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;

All or nothing on the device

Activity and photos go in a single IndexedDB transaction: either both stay or neither does. The photo is stored as a Blob, with no base64. A photo that already uploaded keeps its path and does not upload again on retry. Editing a queue item keeps the same key, so the guarantee still holds.

TypeScript
// excerpt from sync.ts, shortened
export const MAX_SYNC_ATTEMPTS = 5;

export async function syncPending() {
  if (running) return;           // concurrency lock
  if (!navigator.onLine) return;
  running = true;
  try {
    for (const item of await listUnsynced()) {
      if (!navigator.onLine) break;
      if (item.attempts >= MAX_SYNC_ATTEMPTS) continue; // waits for manual retry
      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; }
}

Paper at the end of the day

The PDF is generated on the server with Chromium packaged for a serverless function. Each signature gets a public 12-character code and a QR code: anyone can check on an open page, with no login, who signed and when.

How I know it works

The queue tests cover the retry, the limit of 5 attempts and the photo that does not upload twice. The unique index is the last barrier: even with a bug in the app, the database refuses the duplicate.

What this does not do

  • It syncs with the app open. It does not use Background Sync.
  • There is no growing wait: 5 attempts and then a manual resend on the pending screen.
  • The queue only creates records, so there is no edit conflict resolution.
  • The verification code identifies the signature. It is not a cryptographic signature or proof against tampering.

Screens

Activity list in the job-site app, with sample data
Activities, with sample data.
Phone screens: form, team and photos, and the pending queue
Form, photos and the pending queue.

What broke

  1. A deploy before the migration took down login for everyone.

    The rule became a defensive read and an explicit schema reload after each migration.

  2. Some records opened the old PDF.

    The route went through the service worker cache. PDF is now NetworkOnly.

Credits: Internal project developed at a previous job. Company, client and job site are not identified, and the screens use anonymized sample data. Not a Foxer client.

Tell me the problem.

One message is enough to start. I reply myself and, if it makes sense, I send a written proposal: what is in, what is out, deadline and price.

WhatsApp +55 11 99227-9903