feat(reliability): vigia reconnect (Wake Lock release + GPS retry exponencial)

Run 6 da squad shivao-melhoria — HANDOFF item 5 (Reconexão da vigia)
parcialmente resolvido sem Service Worker (decisão conservadora,
SW mal feito quebraria offline-first).

4 fixes em app/diario-bordo.html + server/public/index.html (sincronizados):

1. requestWakeLock (tracking, linha 1345) + requestAnchorWakeLock (anchor, 1831)
   - Adicionado wakeLock.addEventListener('release', ...) com auto-reacquire em 1s
   - Garante que sistema soltando o Wake Lock (background tab, low battery)
     não deixa a tela apagar permanentemente enquanto vigia/rastreio ativos

2. startGPS (tracking, linha 1347) + startAnchorGPS (anchor, 1901)
   - Retry exponencial: 1s, 2s, 4s, 8s, 16s, 30s (max)
   - PERMISSION_DENIED é fatal sem retry (com mensagem clara ao dono)
   - Outros erros (POSITION_UNAVAILABLE, TIMEOUT) → retry com backoff
   - Counter resetado a cada GPS update bem-sucedido (recuperação completa)

Combinado com visibilitychange listener existente (linha 1824) que re-acquire
Wake Lock quando tab volta foreground, cobre o cenário completo de:
- Tab em background no Android Chrome (GPS pausa, sistema solta Wake Lock)
- Tela apagada (Wake Lock soltada, GPS continua se permitido)
- Erro transitório de GPS (perda de sinal, recupera sozinho)
- App reaberto com vigia em andamento (loadAnchorState chama startAnchorGPS
  que agora tem retry built-in)

Service Worker real (push notifications + cache de tiles offline) fica
pra iteração futura com spec própria.

HANDOFF.md item 5 marcado como "parcialmente resolvido".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-27 13:28:09 -03:00
parent 5b02feae50
commit 78c6de538a
3 changed files with 50 additions and 24 deletions

View file

@ -86,13 +86,17 @@ Express + SQLite + Docker, deployável em Coolify ou qualquer VPS.
- Backend: vitest ou jest para os endpoints
- Frontend: playwright para fluxos críticos (especialmente alarme e GPS)
4. **Tratamento de erros no frontend**
- Várias chamadas async fazem `.catch(()=>{})` silenciosamente
- Falhas de sync não são reportadas ao usuário
4. **Tratamento de erros no frontend (parcialmente resolvido)**
- ✅ Catch silencioso de `dispatchWebhooks` em zona PROIBIDA (linha 3280) agora loga + toast (run 2026-04-27-131311 +catch step)
- Restantes: catches em fetches Windy/Open-Meteo (linhas 2712, 2733) — **fallbacks intencionais, mantidos conscientemente**
- Restantes: catches `}catch(e){}` em Wake Lock e tracking state — **best effort em iOS antigo, mantidos**
- Pendente futura: indicador visual de "última sync falhou" no painel cloud
5. **Reconexão da vigia** após app dormir
- Wake Lock pode falhar; GPS pode pausar quando tab inativo
- Investigar Service Worker + Background Sync API
5. **Reconexão da vigia (parcialmente resolvido)**
- ✅ Wake Lock auto-reacquire: `wakeLock.addEventListener('release', ...)` em tracking + anchor (run 2026-04-27-131311 +reconnect step)
- ✅ GPS retry exponencial (1s→2s→4s→8s→16s→30s) com `PERMISSION_DENIED` fatal sem retry, em tracking + anchor
- ✅ `visibilitychange` listener (já existia) re-acquire Wake Lock quando tab volta foreground
- Pendente: Service Worker real (para push notifications + offline tile cache) — exige spec própria, deixar pra futuro
6. **Refatoração do frontend**
- HTML monolítico tem ~3500 linhas

View file

@ -1342,9 +1342,9 @@ let trackingInterval=null;
function loadTrackingState(){try{const raw=localStorage.getItem(TRACKING_KEY);if(raw){const t=JSON.parse(raw);if(t.active&&confirm('Encontrei um rastreio em andamento. Continuar?')){Object.assign(tracking,t);tracking.watchId=null;startGPS();renderGPSBanner()}else{localStorage.removeItem(TRACKING_KEY)}}}catch(e){}}
function saveTrackingState(){try{const{watchId,wakeLock,...r}=tracking;localStorage.setItem(TRACKING_KEY,JSON.stringify(r))}catch(e){}}
async function requestWakeLock(){try{if('wakeLock' in navigator)tracking.wakeLock=await navigator.wakeLock.request('screen')}catch(e){}}
async function requestWakeLock(){try{if('wakeLock' in navigator){tracking.wakeLock=await navigator.wakeLock.request('screen');tracking.wakeLock.addEventListener('release',()=>{tracking.wakeLock=null;if(tracking.active)setTimeout(()=>{if(tracking.active&&!tracking.wakeLock)requestWakeLock()},1000)})}}catch(e){console.warn('wake lock tracking:',e)}}
async function releaseWakeLock(){try{if(tracking.wakeLock){await tracking.wakeLock.release();tracking.wakeLock=null}}catch(e){}}
function startGPS(){if(!navigator.geolocation){toast('GPS não disponível');return}tracking.watchId=navigator.geolocation.watchPosition(onGPSUpdate,e=>{toast('Erro GPS: '+e.message)},batteryGPSOptions())}
function startGPS(){if(!navigator.geolocation){toast('GPS não disponível');return}let r=0;function tryStart(){tracking.watchId=navigator.geolocation.watchPosition(p=>{r=0;onGPSUpdate(p)},e=>{if(e.code===e.PERMISSION_DENIED){toast('GPS sem permissão — rastreio interrompido');return}r++;const d=Math.min(1000*Math.pow(2,r-1),30000);toast(`GPS perdido (#${r}) — retentando em ${d/1000}s`);if(tracking.watchId)navigator.geolocation.clearWatch(tracking.watchId);setTimeout(()=>{if(tracking.active)tryStart()},d)},batteryGPSOptions())}tryStart()}
function onGPSUpdate(pos){
const p={lat:pos.coords.latitude,lng:pos.coords.longitude,ts:Date.now(),spd:pos.coords.speed||0,acc:pos.coords.accuracy};
@ -1828,7 +1828,7 @@ const anchorWatch={active:false,watchId:null,startedAt:null,anchorPos:null,swing
let anchorMap=null,anchorMarker=null,swingMarker=null,anchorBoatMarker=null,anchorCircle=null,anchorLine=null,swingAnchorLine=null,anchorTimer=null,autoRecenterTimer=null;
let pendingAnchorPos=null;
async function requestAnchorWakeLock(){try{if('wakeLock' in navigator)anchorWatch.wakeLock=await navigator.wakeLock.request('screen')}catch(e){}}
async function requestAnchorWakeLock(){try{if('wakeLock' in navigator){anchorWatch.wakeLock=await navigator.wakeLock.request('screen');anchorWatch.wakeLock.addEventListener('release',()=>{anchorWatch.wakeLock=null;if(anchorWatch.active)setTimeout(()=>{if(anchorWatch.active&&!anchorWatch.wakeLock)requestAnchorWakeLock()},1000)})}}catch(e){console.warn('wake lock anchor:',e)}}
async function releaseAnchorWakeLock(){try{if(anchorWatch.wakeLock){await anchorWatch.wakeLock.release();anchorWatch.wakeLock=null}}catch(e){}}
function loadAnchorState(){try{const raw=localStorage.getItem(ANCHOR_KEY);if(raw){const a=JSON.parse(raw);if(a.active&&confirm('Há uma vigia de fundeio em andamento. Continuar?')){Object.assign(anchorWatch,a);anchorWatch.watchId=null;anchorWatch.wakeLock=null;startAnchorGPS();anchorTimer=setInterval(updateAnchorUI,1000);requestAnchorWakeLock();renderAnchorBanner()}else{localStorage.removeItem(ANCHOR_KEY)}}}catch(e){}}
@ -1899,11 +1899,22 @@ async function confirmAnchor(){
}
function startAnchorGPS(){
let retry=0;
function tryStart(){
anchorWatch.watchId=navigator.geolocation.watchPosition(
onAnchorGPSUpdate,
err=>{toast('GPS perdido: '+err.message)},
pos=>{retry=0;onAnchorGPSUpdate(pos)},
err=>{
if(err.code===err.PERMISSION_DENIED){toast('GPS sem permissão — vigia COMPROMETIDA');return}
retry++;
const delay=Math.min(1000*Math.pow(2,retry-1),30000);
toast(`Vigia GPS perdido (#${retry}) — retentando em ${delay/1000}s`);
if(anchorWatch.watchId)navigator.geolocation.clearWatch(anchorWatch.watchId);
setTimeout(()=>{if(anchorWatch.active)tryStart()},delay);
},
batteryGPSOptions()
);
}
tryStart();
}
function onAnchorGPSUpdate(pos){
@ -3277,7 +3288,7 @@ function onZoneEnter(z){
setTimeout(stopAlarmSound,3500);
// dispatch webhooks com mensagem de zona
const text=`⛔ ${state.boat.name||'Veleiro'} entrou em zona PROIBIDA: ${z.name}`;
dispatchWebhooks(text).catch(()=>{});
dispatchWebhooks(text).catch(e=>{console.error('webhook zone alarm failed:',e);toast('Falha ao enviar alarme remoto')});
}else{
if('vibrate' in navigator)navigator.vibrate([200,80,200]);
}

View file

@ -1342,9 +1342,9 @@ let trackingInterval=null;
function loadTrackingState(){try{const raw=localStorage.getItem(TRACKING_KEY);if(raw){const t=JSON.parse(raw);if(t.active&&confirm('Encontrei um rastreio em andamento. Continuar?')){Object.assign(tracking,t);tracking.watchId=null;startGPS();renderGPSBanner()}else{localStorage.removeItem(TRACKING_KEY)}}}catch(e){}}
function saveTrackingState(){try{const{watchId,wakeLock,...r}=tracking;localStorage.setItem(TRACKING_KEY,JSON.stringify(r))}catch(e){}}
async function requestWakeLock(){try{if('wakeLock' in navigator)tracking.wakeLock=await navigator.wakeLock.request('screen')}catch(e){}}
async function requestWakeLock(){try{if('wakeLock' in navigator){tracking.wakeLock=await navigator.wakeLock.request('screen');tracking.wakeLock.addEventListener('release',()=>{tracking.wakeLock=null;if(tracking.active)setTimeout(()=>{if(tracking.active&&!tracking.wakeLock)requestWakeLock()},1000)})}}catch(e){console.warn('wake lock tracking:',e)}}
async function releaseWakeLock(){try{if(tracking.wakeLock){await tracking.wakeLock.release();tracking.wakeLock=null}}catch(e){}}
function startGPS(){if(!navigator.geolocation){toast('GPS não disponível');return}tracking.watchId=navigator.geolocation.watchPosition(onGPSUpdate,e=>{toast('Erro GPS: '+e.message)},batteryGPSOptions())}
function startGPS(){if(!navigator.geolocation){toast('GPS não disponível');return}let r=0;function tryStart(){tracking.watchId=navigator.geolocation.watchPosition(p=>{r=0;onGPSUpdate(p)},e=>{if(e.code===e.PERMISSION_DENIED){toast('GPS sem permissão — rastreio interrompido');return}r++;const d=Math.min(1000*Math.pow(2,r-1),30000);toast(`GPS perdido (#${r}) — retentando em ${d/1000}s`);if(tracking.watchId)navigator.geolocation.clearWatch(tracking.watchId);setTimeout(()=>{if(tracking.active)tryStart()},d)},batteryGPSOptions())}tryStart()}
function onGPSUpdate(pos){
const p={lat:pos.coords.latitude,lng:pos.coords.longitude,ts:Date.now(),spd:pos.coords.speed||0,acc:pos.coords.accuracy};
@ -1828,7 +1828,7 @@ const anchorWatch={active:false,watchId:null,startedAt:null,anchorPos:null,swing
let anchorMap=null,anchorMarker=null,swingMarker=null,anchorBoatMarker=null,anchorCircle=null,anchorLine=null,swingAnchorLine=null,anchorTimer=null,autoRecenterTimer=null;
let pendingAnchorPos=null;
async function requestAnchorWakeLock(){try{if('wakeLock' in navigator)anchorWatch.wakeLock=await navigator.wakeLock.request('screen')}catch(e){}}
async function requestAnchorWakeLock(){try{if('wakeLock' in navigator){anchorWatch.wakeLock=await navigator.wakeLock.request('screen');anchorWatch.wakeLock.addEventListener('release',()=>{anchorWatch.wakeLock=null;if(anchorWatch.active)setTimeout(()=>{if(anchorWatch.active&&!anchorWatch.wakeLock)requestAnchorWakeLock()},1000)})}}catch(e){console.warn('wake lock anchor:',e)}}
async function releaseAnchorWakeLock(){try{if(anchorWatch.wakeLock){await anchorWatch.wakeLock.release();anchorWatch.wakeLock=null}}catch(e){}}
function loadAnchorState(){try{const raw=localStorage.getItem(ANCHOR_KEY);if(raw){const a=JSON.parse(raw);if(a.active&&confirm('Há uma vigia de fundeio em andamento. Continuar?')){Object.assign(anchorWatch,a);anchorWatch.watchId=null;anchorWatch.wakeLock=null;startAnchorGPS();anchorTimer=setInterval(updateAnchorUI,1000);requestAnchorWakeLock();renderAnchorBanner()}else{localStorage.removeItem(ANCHOR_KEY)}}}catch(e){}}
@ -1899,11 +1899,22 @@ async function confirmAnchor(){
}
function startAnchorGPS(){
let retry=0;
function tryStart(){
anchorWatch.watchId=navigator.geolocation.watchPosition(
onAnchorGPSUpdate,
err=>{toast('GPS perdido: '+err.message)},
pos=>{retry=0;onAnchorGPSUpdate(pos)},
err=>{
if(err.code===err.PERMISSION_DENIED){toast('GPS sem permissão — vigia COMPROMETIDA');return}
retry++;
const delay=Math.min(1000*Math.pow(2,retry-1),30000);
toast(`Vigia GPS perdido (#${retry}) — retentando em ${delay/1000}s`);
if(anchorWatch.watchId)navigator.geolocation.clearWatch(anchorWatch.watchId);
setTimeout(()=>{if(anchorWatch.active)tryStart()},delay);
},
batteryGPSOptions()
);
}
tryStart();
}
function onAnchorGPSUpdate(pos){
@ -3277,7 +3288,7 @@ function onZoneEnter(z){
setTimeout(stopAlarmSound,3500);
// dispatch webhooks com mensagem de zona
const text=`⛔ ${state.boat.name||'Veleiro'} entrou em zona PROIBIDA: ${z.name}`;
dispatchWebhooks(text).catch(()=>{});
dispatchWebhooks(text).catch(e=>{console.error('webhook zone alarm failed:',e);toast('Falha ao enviar alarme remoto')});
}else{
if('vibrate' in navigator)navigator.vibrate([200,80,200]);
}