shivao-projeto/app/diario-bordo.html
PontualTech / Karlão 5dd3362469
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
feat(ble): diagnóstico verboso pra debugar pareamento BLE v1.9.2
Karlão reportou: "localiza mas não pareia" (picker abre, seleciona
device, mas conexão falha silenciosa). Sem ver onde trava, impossível fix.

Adicionado:
- setBleDiag() exibe cada step com timestamp + cor (info/ok/warn/err)
- Painel <details> expansível "📋 Diagnóstico" no card BLE
- Logs em cada operação: backend, init, picker, connect, getServices,
  battery read, notifications, device info
- Timeout do connect aumentado: 15s → 30s (BMS podem demorar)
- getServices() lista UUIDs descobertos no device — descobre se BMS
  expõe Battery Service padrão ou só protocolo proprietário
- Mensagens explícitas de erro em cada catch (e.message ou errorMessage)

Próximo passo: Karlão testa, abre painel diagnóstico, me passa screenshot
ou copia o log. Daí descubro exatamente onde trava (timeout, sem service,
permissão negada, etc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:39:41 -03:00

6916 lines
347 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0e2a3d">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Diário de Bordo">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<link rel="apple-touch-icon" href="/icon.svg">
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Diário de Bordo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg stroke='%230e2a3d' stroke-width='1' fill='none'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 2 L13.5 12 L12 22 L10.5 12 Z' fill='%230e2a3d'/%3E%3Cpath d='M2 12 L12 13.5 L22 12 L12 10.5 Z' fill='%23a07832'/%3E%3C/g%3E%3C/svg%3E">
<style>
:root{
/* paper / ink palette */
--bg-canvas:#efe5cd;
--bg-paper:#faf2dd;
--bg-aged:#e3d4ab;
--bg-shade:#ebd9a7;
/* ink */
--ink-deep:#0e2a3d;
--ink-mid:#2c4661;
--ink-soft:#5d7186;
--sepia:#7d6943;
--sepia-soft:#a89370;
/* rules */
--rule:#b89c6c;
--rule-soft:#d8c79a;
--rule-fade:#e6d9b1;
/* brass */
--brass:#a07832;
--brass-bright:#c89f54;
--brass-deep:#6f5217;
/* signal */
--ocean:#1f5b76;
--algae:#3f7768;
--algae-soft:#dde9d8;
--storm:#8c3434;
--storm-soft:#f0d9d9;
--sun:#b67025;
--sun-soft:#f3e3c2;
/* type */
--f-display:'Fraunces','Cormorant Garamond',Georgia,serif;
--f-body:'Manrope',-apple-system,BlinkMacSystemFont,sans-serif;
--f-mono:'JetBrains Mono','Courier New',monospace;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;padding:0;min-height:100vh;overflow-x:hidden;background:var(--bg-canvas);color:var(--ink-deep);font-family:var(--f-body);font-size:15px;line-height:1.55;font-weight:400}
body{
padding-bottom:env(safe-area-inset-bottom);
background-image:
radial-gradient(ellipse 800px 600px at top right,#f6ecd2 0%,transparent 70%),
radial-gradient(ellipse 600px 400px at bottom left,#e8dcb8 0%,transparent 70%),
url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.4 0 0 0 0 0.3 0 0 0 0 0.15 0 0 0 0.18 0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
background-attachment:fixed;
}
::selection{background:var(--brass-bright);color:var(--ink-deep)}
/* ============ HEADER ============ */
header{
background:var(--ink-deep);
color:var(--bg-paper);
padding:max(18px,env(safe-area-inset-top)) 16px 16px;
position:sticky;top:0;z-index:40;
border-bottom:1px solid var(--brass-deep);
box-shadow:0 1px 0 var(--brass) inset, 0 6px 24px rgba(14,42,61,.18);
}
.header-row{display:flex;align-items:center;gap:14px;max-width:780px;margin:0 auto}
.compass-mark{width:42px;height:42px;flex-shrink:0;color:var(--brass-bright)}
.boat-info{flex:1;min-width:0}
.boat-tagline{
font-family:var(--f-mono);font-size:9.5px;font-weight:500;
letter-spacing:.28em;text-transform:uppercase;
color:var(--brass-bright);opacity:.9;margin-bottom:1px;
}
.boat-name{
font-family:var(--f-display);font-style:italic;font-weight:500;
font-variation-settings:"opsz" 60,"SOFT" 50;
font-size:24px;line-height:1.05;letter-spacing:-.01em;
background:transparent;border:none;color:var(--bg-paper);
padding:1px 0;width:100%;
}
.boat-name:focus{outline:none;border-bottom:1px dashed var(--brass)}
.boat-selector{
display:inline-flex;align-items:center;gap:6px;cursor:pointer;
text-align:left;width:auto;
transition:opacity .15s;
}
.boat-selector:hover{opacity:.8}
.boat-selector:active{transform:scale(.98)}
.boat-chevron{font-size:14px;opacity:.6;font-style:normal;transform:translateY(1px)}
.boat-meta{font-family:var(--f-mono);font-size:10.5px;letter-spacing:.06em;color:rgba(250,242,221,.65);margin-top:2px;display:flex;align-items:center;gap:8px}
.boat-meta input{
background:transparent;border:none;color:inherit;
font:inherit;padding:1px 0;width:100%;
}
.boat-model-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.boat-edit-btn{
background:rgba(250,242,221,.08);border:1px solid rgba(250,242,221,.18);
color:rgba(250,242,221,.7);cursor:pointer;
width:22px;height:22px;border-radius:6px;
display:inline-flex;align-items:center;justify-content:center;
font-size:11px;flex-shrink:0;
transition:all .15s;
}
.boat-edit-btn:hover{background:rgba(250,242,221,.15);color:var(--bg-paper)}
.boat-meta input::placeholder{color:rgba(250,242,221,.4)}
.boat-meta input:focus{outline:none;border-bottom:1px dashed var(--brass)}
.boat-coord{font-family:var(--f-mono);font-size:9.5px;color:var(--brass-bright);letter-spacing:.1em;text-align:right;flex-shrink:0;line-height:1.5}
/* ============ LAYOUT ============ */
.container{max-width:780px;margin:0 auto;padding:18px 14px 96px}
.tabs{
display:flex;gap:0;
border-bottom:1px solid var(--rule);
margin-bottom:22px;
overflow-x:auto;scrollbar-width:none;
position:sticky;top:78px;z-index:30;
background:linear-gradient(to bottom,var(--bg-canvas) 0%,var(--bg-canvas) 92%,transparent 100%);
padding-top:6px;
}
.tabs::-webkit-scrollbar{display:none}
.tab{
flex:1 0 auto;padding:11px 14px 12px;
background:none;border:none;
font-family:var(--f-mono);font-size:10.5px;font-weight:500;
letter-spacing:.18em;text-transform:uppercase;
color:var(--sepia);cursor:pointer;
position:relative;white-space:nowrap;
transition:color .15s;
}
.tab.active{color:var(--ink-deep)}
.tab.active::after{
content:'';position:absolute;left:14px;right:14px;bottom:-1px;
height:2px;background:var(--brass);
}
.tab .badge{
font-family:var(--f-mono);font-size:9px;
background:var(--storm);color:#fff;
padding:1px 5px;border-radius:1px;margin-left:5px;letter-spacing:0;
}
.tab.active .badge{background:var(--brass)}
.panel{display:none;animation:fadeIn .25s ease}
.panel.active{display:block}
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
/* ============ TYPOGRAPHY ============ */
.section-header{
display:flex;align-items:baseline;gap:10px;
margin:24px 0 12px;
}
.section-header::before,.section-header::after{
content:'';flex:1;height:1px;background:var(--rule-soft);
}
.section-header h2{
font-family:var(--f-mono);font-size:10.5px;font-weight:500;
letter-spacing:.22em;text-transform:uppercase;
color:var(--sepia);margin:0;flex:0 0 auto;
}
.section-header .ornament{color:var(--brass);font-family:var(--f-display);font-style:italic}
.display-num{font-family:var(--f-mono);font-variant-numeric:tabular-nums;font-feature-settings:"tnum"}
.display-serif{font-family:var(--f-display);font-style:italic;font-variation-settings:"opsz" 60,"SOFT" 30}
.label-mono{font-family:var(--f-mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--sepia);font-weight:500}
/* ============ ALERTS ============ */
.alert{
background:var(--storm-soft);
border-left:3px solid var(--storm);
padding:11px 14px;margin-bottom:12px;
display:flex;align-items:flex-start;gap:10px;cursor:pointer;
border-top:1px solid var(--rule-soft);border-right:1px solid var(--rule-soft);border-bottom:1px solid var(--rule-soft);
}
.alert.warn{background:var(--sun-soft);border-left-color:var(--sun)}
.alert-glyph{width:20px;height:20px;flex-shrink:0;color:var(--storm);margin-top:1px}
.alert.warn .alert-glyph{color:var(--sun)}
.alert-text{flex:1;font-size:13px;color:var(--ink-mid)}
.alert-text strong{display:block;font-family:var(--f-display);font-style:italic;font-weight:600;font-size:14.5px;color:var(--ink-deep);margin-bottom:1px}
.alert-arrow{color:var(--sepia);font-family:var(--f-mono);font-size:14px}
/* ============ GPS CARD ============ */
.gps-card{
background:var(--ink-deep);color:var(--bg-paper);
border:1px solid var(--brass-deep);
padding:18px 18px 16px;
margin-bottom:18px;
position:relative;
background-image:
linear-gradient(135deg,rgba(255,255,255,.03) 0%,transparent 50%),
radial-gradient(ellipse at top right,rgba(200,159,84,.08) 0%,transparent 60%);
}
.gps-card::before{
content:'';position:absolute;inset:5px;border:1px solid rgba(160,120,50,.35);pointer-events:none;
}
.gps-card.idle{
background:var(--bg-paper);color:var(--ink-deep);
border:1px solid var(--rule);
background-image:none;
}
.gps-card.idle::before{border-color:var(--rule-soft)}
.gps-head{display:flex;align-items:center;gap:10px;margin-bottom:6px;position:relative}
.gps-head h3{
font-family:var(--f-display);font-style:italic;font-weight:500;
font-variation-settings:"opsz" 24;
font-size:18px;margin:0;letter-spacing:-.005em;
}
.gps-card.idle .gps-head h3{color:var(--ink-deep)}
.gps-card .gps-head h3{color:var(--brass-bright)}
.gps-sub{
font-family:var(--f-mono);font-size:10px;letter-spacing:.16em;
text-transform:uppercase;color:var(--sepia);
margin-bottom:14px;position:relative;
}
.gps-card:not(.idle) .gps-sub{color:rgba(200,159,84,.7)}
.live-dot{
display:inline-block;width:7px;height:7px;border-radius:50%;
background:#ff4444;margin-right:6px;
animation:livePulse 1.4s infinite;
box-shadow:0 0 0 0 rgba(255,68,68,.5);
}
@keyframes livePulse{0%,100%{box-shadow:0 0 0 0 rgba(255,68,68,.5)}50%{box-shadow:0 0 0 6px rgba(255,68,68,0)}}
.gps-stats{
display:grid;grid-template-columns:repeat(3,1fr);
gap:12px;margin:14px 0 16px;position:relative;
}
.gps-stat{position:relative}
.gps-stat-label{font-family:var(--f-mono);font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:rgba(200,159,84,.7);margin-bottom:2px;font-weight:500}
.gps-card.idle .gps-stat-label{color:var(--sepia)}
.gps-stat-value{
font-family:var(--f-mono);font-variant-numeric:tabular-nums;
font-size:24px;font-weight:500;line-height:1.05;
color:var(--brass-bright);
}
.gps-card.idle .gps-stat-value{color:var(--ink-deep)}
.gps-stat-unit{font-size:11px;font-weight:400;opacity:.6;margin-left:3px}
.gps-actions{display:flex;gap:8px;margin-top:6px;position:relative}
.gps-actions .btn{flex:1;justify-content:center}
/* ============ STATS GRID ============ */
.stats{
display:grid;grid-template-columns:repeat(2,1fr);gap:1px;
background:var(--rule-soft);
border:1px solid var(--rule);
margin-bottom:18px;
}
@media(min-width:560px){.stats{grid-template-columns:repeat(4,1fr)}}
.stat{
background:var(--bg-paper);
padding:14px 14px 12px;
position:relative;
}
.stat::before{
content:'';position:absolute;top:0;left:0;right:0;height:2px;
background:var(--brass);transform:scaleX(.4);transform-origin:left;
}
.stat-label{font-family:var(--f-mono);font-size:9.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--sepia);font-weight:500;margin-bottom:6px}
.stat-value{
font-family:var(--f-mono);font-variant-numeric:tabular-nums;
font-size:22px;font-weight:500;line-height:1;
color:var(--ink-deep);letter-spacing:-.01em;
}
.stat-sub{font-size:11px;color:var(--sepia);margin-top:4px;font-style:italic;font-family:var(--f-display);font-variation-settings:"opsz" 12}
/* ============ BUTTONS ============ */
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:6px;
padding:10px 16px;min-height:42px;
background:var(--bg-paper);color:var(--ink-deep);
border:1px solid var(--rule);
font-family:var(--f-mono);font-size:11px;font-weight:500;
letter-spacing:.14em;text-transform:uppercase;
cursor:pointer;
transition:all .15s;
}
.btn:hover{background:var(--bg-aged);border-color:var(--sepia)}
.btn:active{transform:translateY(1px)}
.btn-primary{
background:var(--ink-deep);color:var(--bg-paper);
border-color:var(--ink-deep);
box-shadow:inset 0 1px 0 var(--ink-mid),0 1px 0 var(--brass);
}
.btn-primary:hover{background:var(--ink-mid);border-color:var(--ink-mid);color:var(--bg-paper)}
.btn-brass{background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep)}
.btn-brass:hover{background:var(--brass-deep);color:var(--bg-paper)}
.btn-danger{color:var(--storm);border-color:var(--storm)}
.btn-danger:hover{background:var(--storm-soft);color:var(--storm)}
.btn-block{width:100%}
.btn-sm{padding:7px 11px;min-height:32px;font-size:10px}
.btn-big{padding:14px 18px;min-height:50px;font-size:11.5px}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}
/* ============ ENTRIES ============ */
.entries{display:flex;flex-direction:column;gap:14px}
.entry{
background:var(--bg-paper);
border:1px solid var(--rule-soft);
padding:14px 16px 12px;
position:relative;
}
.entry::before{
content:'';position:absolute;left:0;top:0;bottom:0;width:2px;
background:var(--ocean);
}
.entry.maint::before{background:var(--brass)}
.entry.pending::before{background:var(--sun)}
.entry.pending.overdue::before{background:var(--storm)}
.entry.pending.done{opacity:.6}
.entry.pending.done::before{background:var(--algae)}
.entry-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:6px}
.entry-meta{flex:1;min-width:0}
.entry-date{font-family:var(--f-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--sepia);margin-bottom:4px;font-weight:500}
.entry-date .sep{color:var(--rule);margin:0 6px}
.entry-title{
font-family:var(--f-display);font-style:italic;font-weight:500;
font-variation-settings:"opsz" 36,"SOFT" 30;
font-size:18.5px;line-height:1.2;color:var(--ink-deep);
letter-spacing:-.005em;
}
.entry-actions{display:flex;gap:1px;flex-shrink:0}
.icon-btn{
background:none;border:none;cursor:pointer;
width:30px;height:30px;color:var(--sepia);
display:flex;align-items:center;justify-content:center;
font-family:var(--f-mono);font-size:12px;
transition:all .15s;border-radius:1px;
}
.icon-btn:hover{background:var(--bg-aged);color:var(--ink-deep)}
.icon-btn.del:hover{background:var(--storm-soft);color:var(--storm)}
.icon-btn svg{width:14px;height:14px}
.entry-body{margin-top:6px}
.entry-divider{height:1px;background:var(--rule-fade);margin:8px 0;position:relative}
.entry-divider::after{
content:'◆';position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
background:var(--bg-paper);padding:0 8px;color:var(--rule);font-size:6px;
}
.entry-grid{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;align-items:baseline;font-size:13px;margin:6px 0}
.entry-grid dt{font-family:var(--f-mono);font-size:9.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--sepia);font-weight:500;padding-top:1px}
.entry-grid dd{margin:0;color:var(--ink-deep)}
.entry-grid dd .num{font-family:var(--f-mono);font-variant-numeric:tabular-nums;font-weight:500}
.entry-grid dd .delta{color:var(--sepia);font-family:var(--f-mono);font-size:11px}
.entry-passengers{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.pax-pill{
font-family:var(--f-mono);font-size:10px;letter-spacing:.05em;
background:transparent;color:var(--ink-mid);
padding:2px 8px;border:1px solid var(--rule);
border-radius:1px;
}
.entry-notes{
margin-top:8px;padding:8px 12px;
background:var(--bg-aged);border-left:2px solid var(--rule);
font-family:var(--f-display);font-style:italic;font-weight:400;
font-variation-settings:"opsz" 14,"SOFT" 60;
font-size:14px;color:var(--ink-mid);line-height:1.55;
white-space:pre-wrap;
}
.entry-track{
margin-top:8px;padding:8px 12px;
background:var(--bg-canvas);border:1px dashed var(--brass);
display:flex;justify-content:space-between;align-items:center;gap:10px;
flex-wrap:wrap;
}
.entry-track-stats{display:flex;gap:14px;font-family:var(--f-mono);font-size:11px;color:var(--ink-deep)}
.entry-track-stats span{color:var(--sepia);font-size:9.5px;letter-spacing:.15em;text-transform:uppercase;display:block;margin-bottom:1px}
.entry-track-stats strong{font-weight:500;font-variant-numeric:tabular-nums}
.btn-track{padding:6px 10px;font-size:10px;min-height:28px;background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep)}
.btn-track:hover{background:var(--brass-deep);color:var(--bg-paper)}
.status-pill{
display:inline-block;padding:1px 7px;
font-family:var(--f-mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;
font-weight:500;border:1px solid;border-radius:1px;
}
.status-overdue{color:var(--storm);border-color:var(--storm);background:var(--storm-soft)}
.status-soon{color:var(--sun);border-color:var(--sun);background:var(--sun-soft)}
.status-ok{color:var(--algae);border-color:var(--algae);background:var(--algae-soft)}
.status-done{color:var(--ink-mid);border-color:var(--rule);background:var(--bg-aged)}
/* ============ MEDIA ============ */
.media-grid{
display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));
gap:5px;margin-top:8px;
}
.media-thumb{
aspect-ratio:1;background:var(--bg-aged);
border:1px solid var(--rule-soft);
cursor:pointer;position:relative;overflow:hidden;
display:flex;align-items:center;justify-content:center;
}
.media-thumb img,.media-thumb video{width:100%;height:100%;object-fit:cover}
.media-thumb.audio{background:var(--ink-deep);color:var(--brass-bright)}
.media-thumb.audio svg{width:22px;height:22px}
.media-thumb.video::after{content:'';position:absolute;inset:0;background:rgba(14,42,61,.3) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23faf2dd'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E") center/22px no-repeat}
/* ============ EMPTY ============ */
.empty{
text-align:center;padding:48px 24px;
background:var(--bg-paper);
border:1px solid var(--rule-soft);
position:relative;
}
.empty::before,.empty::after{
content:'';position:absolute;left:50%;width:120px;height:1px;
background:var(--rule-soft);transform:translateX(-50%);
}
.empty::before{top:18px}
.empty::after{bottom:18px}
.empty-rose{color:var(--rule);margin:0 auto 14px;width:48px;height:48px;opacity:.6}
.empty-title{font-family:var(--f-display);font-style:italic;font-weight:500;font-variation-settings:"opsz" 24;font-size:19px;color:var(--ink-deep);margin-bottom:4px}
.empty-text{font-size:13.5px;color:var(--sepia);max-width:300px;margin:0 auto;line-height:1.55}
/* ============ MODALS ============ */
.modal-backdrop{
position:fixed;inset:0;background:rgba(14,42,61,.55);
display:none;align-items:flex-end;justify-content:center;
z-index:100;overflow-y:auto;
backdrop-filter:blur(3px);
}
.modal-backdrop.show{display:flex}
.modal{
background:var(--bg-paper);
width:100%;max-width:560px;
border-top:3px solid var(--brass);
margin-top:auto;max-height:95vh;
display:flex;flex-direction:column;
animation:slideUp .25s cubic-bezier(.2,.8,.2,1);
border-left:1px solid var(--rule);border-right:1px solid var(--rule);
background-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.4 0 0 0 0 0.3 0 0 0 0 0.15 0 0 0 0.1 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
}
@keyframes slideUp{from{transform:translateY(50px);opacity:0}}
@media(min-width:600px){
.modal-backdrop{align-items:center;padding:20px}
.modal{margin:0;max-height:90vh;border:1px solid var(--rule);border-top:3px solid var(--brass)}
}
.modal-head{
padding:14px 18px;
border-bottom:1px solid var(--rule-soft);
display:flex;justify-content:space-between;align-items:center;
flex-shrink:0;background:var(--bg-paper);
}
.modal-head h3{
font-family:var(--f-display);font-style:italic;font-weight:500;
font-variation-settings:"opsz" 30;font-size:19px;
margin:0;color:var(--ink-deep);
}
.modal-body{padding:18px;overflow-y:auto;flex:1}
.modal-foot{
padding:14px 18px;
border-top:1px solid var(--rule-soft);
display:flex;justify-content:flex-end;gap:8px;flex-shrink:0;
padding-bottom:max(14px,env(safe-area-inset-bottom));
background:var(--bg-paper);
}
.field{margin-bottom:14px}
.field label{display:block;margin-bottom:5px}
.field-label{font-family:var(--f-mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--sepia);font-weight:500}
.field input,.field textarea,.field select{
width:100%;padding:10px 12px;
background:var(--bg-canvas);
border:1px solid var(--rule);
font-family:var(--f-body);font-size:15px;
color:var(--ink-deep);
-webkit-appearance:none;border-radius:0;
transition:border-color .15s,background .15s;
}
.field input[type="number"],.field input[type="date"]{font-family:var(--f-mono);font-size:14px}
.field input:focus,.field textarea:focus,.field select:focus{
outline:none;border-color:var(--brass);background:var(--bg-paper);
box-shadow:0 0 0 3px rgba(160,120,50,.12);
}
.field textarea{resize:vertical;min-height:80px;font-family:var(--f-display);font-style:italic;font-variation-settings:"opsz" 14;font-size:15px}
.field-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.field-hint{font-size:10px;color:var(--sepia);margin-top:4px;font-style:italic;font-family:var(--f-display)}
/* === Fleet Manager === */
.fleet-list{display:flex;flex-direction:column;gap:8px;margin-bottom:18px;max-height:50vh;overflow-y:auto}
.fleet-item{
display:flex;align-items:center;gap:12px;
padding:12px 14px;background:var(--bg-canvas);
border:1px solid var(--rule);cursor:pointer;
transition:all .15s;
}
.fleet-item:hover{background:var(--bg-aged);border-color:var(--sepia)}
.fleet-item.active{border-left:3px solid var(--brass);background:var(--bg-aged)}
.fleet-icon{font-size:24px;flex-shrink:0;width:32px;text-align:center}
.fleet-info{flex:1;min-width:0}
.fleet-name{font-family:var(--f-display);font-style:italic;font-size:17px;color:var(--ink-deep);font-weight:500}
.fleet-meta{font-family:var(--f-mono);font-size:10px;letter-spacing:.06em;color:var(--sepia);margin-top:2px;text-transform:uppercase}
.fleet-active-badge{
background:var(--brass);color:var(--bg-paper);
font-family:var(--f-mono);font-size:9px;letter-spacing:.12em;
padding:3px 8px;text-transform:uppercase;
}
.fleet-edit-icon{
background:transparent;border:1px solid var(--rule);
width:28px;height:28px;display:flex;align-items:center;justify-content:center;
cursor:pointer;color:var(--sepia);font-size:12px;flex-shrink:0;
}
.fleet-edit-icon:hover{border-color:var(--brass);color:var(--brass)}
.fleet-empty{
text-align:center;padding:24px;color:var(--sepia);
font-family:var(--f-display);font-style:italic;font-size:14px;
border:1px dashed var(--rule);
}
.fleet-units-row{
display:flex;align-items:center;justify-content:space-between;
padding:14px 0 0;border-top:1px solid var(--rule);
}
.fleet-units-label{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--sepia)}
.fleet-units-toggle{display:flex;border:1px solid var(--rule)}
.fleet-units-toggle button{
background:var(--bg-canvas);border:none;
padding:7px 14px;font-family:var(--f-mono);font-size:11px;
letter-spacing:.08em;cursor:pointer;color:var(--ink-mid);
transition:all .15s;
}
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)}
/* === Welcome Screen === */
.welcome-screen{
position:fixed;inset:0;z-index:9999;
background:linear-gradient(135deg,#0e2a3d 0%,#1a3d54 50%,#0a1f2d 100%);
display:flex;align-items:center;justify-content:center;
padding:20px;overflow-y:auto;
}
.welcome-card{
background:var(--bg-paper);
border:1px solid rgba(212,160,74,.4);
padding:32px 28px;max-width:380px;width:100%;
box-shadow:0 30px 80px rgba(0,0,0,.5);
}
.welcome-logo{
font-size:64px;text-align:center;margin-bottom:8px;
filter:drop-shadow(0 4px 8px rgba(212,160,74,.3));
}
.welcome-title{
font-family:var(--f-display);font-style:italic;font-weight:500;
font-size:28px;color:var(--ink-deep);
text-align:center;margin:0 0 8px;letter-spacing:-.01em;
}
.welcome-tagline{
font-family:var(--f-display);font-style:italic;
color:var(--sepia);text-align:center;
font-size:14px;line-height:1.45;
margin:0 0 24px;
}
.welcome-buttons{display:flex;flex-direction:column;gap:8px}
.welcome-btn{
display:flex;align-items:center;justify-content:center;gap:10px;
padding:13px 16px;border:1px solid var(--rule);
background:var(--bg-canvas);color:var(--ink-deep);
font-family:var(--f-body);font-size:14.5px;font-weight:500;
cursor:pointer;width:100%;
transition:all .15s;text-align:center;
}
.welcome-btn:hover{background:var(--bg-aged);border-color:var(--brass)}
.welcome-btn:active{transform:scale(.98)}
.welcome-btn-google{background:#fff;border-color:#dadce0}
.welcome-btn-google:hover{background:#f8f9fa;box-shadow:0 1px 3px rgba(0,0,0,.1)}
.welcome-btn-email{}
.welcome-btn-primary{background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep);font-weight:600}
.welcome-btn-primary:hover{background:var(--brass-deep)}
.welcome-btn-text{
background:transparent;border:none;color:var(--sepia);
font-size:12.5px;text-decoration:underline;
}
.welcome-btn-text:hover{color:var(--ink-deep);background:transparent}
.welcome-tabs{display:flex;gap:0;margin-bottom:14px;border-bottom:1px solid var(--rule)}
.welcome-tab{
flex:1;background:transparent;border:none;
padding:10px;font-family:var(--f-mono);font-size:11px;
letter-spacing:.12em;text-transform:uppercase;color:var(--sepia);
cursor:pointer;border-bottom:2px solid transparent;
margin-bottom:-1px;
}
.welcome-tab.active{color:var(--brass);border-bottom-color:var(--brass)}
/* === Sync Indicator === */
.sync-indicator{
display:inline-block;font-size:10px;
margin-left:6px;cursor:help;
vertical-align:1px;
}
.sync-indicator[data-status="syncing"]{animation:syncPulse 1.2s infinite}
@keyframes syncPulse{0%,100%{opacity:1}50%{opacity:.4}}
/* === Boat Photo === */
.boat-photo-row{display:flex;gap:14px;align-items:flex-start}
.boat-photo-preview{
width:96px;height:96px;flex-shrink:0;
background:var(--bg-canvas);border:1px solid var(--rule);
display:flex;align-items:center;justify-content:center;
overflow:hidden;position:relative;
}
.boat-photo-preview img{width:100%;height:100%;object-fit:cover;display:block}
.boat-photo-placeholder{font-size:36px;opacity:.4}
.boat-photo-actions{flex:1;display:flex;flex-direction:column;justify-content:center}
/* Avatares pequenos (lista frota + header) */
.fleet-avatar{
width:44px;height:44px;flex-shrink:0;
background:var(--bg-aged);border:1px solid var(--rule);
display:flex;align-items:center;justify-content:center;
overflow:hidden;font-size:22px;
}
.fleet-avatar img{width:100%;height:100%;object-fit:cover}
.boat-header-avatar{
width:34px;height:34px;border-radius:50%;flex-shrink:0;
background:rgba(250,242,221,.12);border:1.5px solid rgba(250,242,221,.3);
overflow:hidden;display:flex;align-items:center;justify-content:center;
font-size:16px;color:rgba(250,242,221,.6);
}
.boat-header-avatar img{width:100%;height:100%;object-fit:cover}
/* === Anchor Calculator === */
.anchor-calc{
background:var(--bg-canvas);border:1px solid var(--rule);
border-left:3px solid var(--ocean);
padding:14px;margin:14px 0;
}
.anchor-calc-head{
font-family:var(--f-mono);font-size:11px;letter-spacing:.14em;
text-transform:uppercase;color:var(--ocean);
margin-bottom:10px;font-weight:600;
}
.anchor-calc .field{margin-bottom:10px}
.anchor-calc-result{
display:grid;grid-template-columns:1fr 1fr;gap:8px;
margin-top:10px;
}
.anchor-calc-result.full{grid-template-columns:1fr}
.anchor-calc-stat{
background:var(--bg-paper);padding:10px;border:1px solid var(--rule-soft);
}
.anchor-calc-stat-label{
font-family:var(--f-mono);font-size:9.5px;letter-spacing:.14em;
text-transform:uppercase;color:var(--sepia);margin-bottom:4px;
}
.anchor-calc-stat-value{
font-family:var(--f-display);font-style:italic;font-size:20px;
color:var(--ink-deep);font-weight:500;line-height:1;
}
.anchor-calc-stat-value.ok{color:var(--algae,#4a8c4a)}
.anchor-calc-stat-value.warn{color:var(--sun,#c8943a)}
.anchor-calc-stat-value.danger{color:var(--storm,#b04040)}
.anchor-calc-advice{
grid-column:1/-1;
padding:10px 12px;background:var(--ocean-soft,rgba(50,90,140,.08));
border-left:2px solid var(--ocean);
font-family:var(--f-display);font-style:italic;font-size:13px;
color:var(--ink-deep);line-height:1.5;
}
.anchor-calc-advice.danger{background:var(--storm-soft,rgba(176,64,64,.08));border-color:var(--storm)}
.anchor-calc-advice.warn{background:var(--sun-soft,rgba(200,148,58,.1));border-color:var(--sun)}
.anchor-calc-advice.ok{background:rgba(74,140,74,.08);border-color:var(--algae,#4a8c4a)}
.pax-input-row{display:flex;gap:6px}
.pax-input-row input{flex:1}
.pax-list{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px;min-height:24px}
.pax-tag{
background:transparent;color:var(--ink-mid);
padding:3px 6px 3px 10px;border:1px solid var(--rule);
font-family:var(--f-mono);font-size:11px;
display:inline-flex;align-items:center;gap:5px;
}
.pax-tag button{background:none;border:none;color:var(--sepia);cursor:pointer;font-size:14px;padding:0;line-height:1}
.media-toolbar{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:8px}
.media-btn{
background:var(--bg-canvas);color:var(--ink-deep);
border:1px solid var(--rule);
padding:12px 6px;font-family:var(--f-mono);
font-size:9.5px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;
display:flex;flex-direction:column;align-items:center;gap:5px;cursor:pointer;
transition:all .15s;
}
.media-btn:hover{background:var(--bg-aged);border-color:var(--sepia)}
.media-btn:active{background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep)}
.media-btn svg{width:18px;height:18px;color:var(--brass)}
.media-btn:active svg{color:var(--bg-paper)}
.modal-media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(74px,1fr));gap:5px;margin-top:8px}
.media-item{aspect-ratio:1;background:var(--bg-aged);border:1px solid var(--rule-soft);position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center}
.media-item img,.media-item video{width:100%;height:100%;object-fit:cover}
.media-item.audio{background:var(--ink-deep);color:var(--brass-bright)}
.media-item.audio svg{width:24px;height:24px}
.media-item.video::after{content:'';position:absolute;inset:0;background:rgba(14,42,61,.3) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23faf2dd'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E") center/24px no-repeat}
.media-remove{position:absolute;top:2px;right:2px;background:var(--ink-deep);color:var(--bg-paper);border:none;width:20px;height:20px;font-size:12px;cursor:pointer;line-height:1;padding:0;font-family:var(--f-mono)}
/* ============ RECORDER ============ */
.recorder-modal{max-width:340px}
.recorder-body{padding:32px 24px;text-align:center}
.rec-circle{
width:104px;height:104px;border-radius:50%;
background:var(--storm);color:var(--bg-paper);
display:flex;align-items:center;justify-content:center;
margin:0 auto 18px;border:none;cursor:pointer;
position:relative;
box-shadow:0 0 0 4px var(--bg-paper),0 0 0 5px var(--storm);
}
.rec-circle svg{width:38px;height:38px}
.rec-circle.recording{background:var(--ink-deep);box-shadow:0 0 0 4px var(--bg-paper),0 0 0 5px var(--ink-deep)}
.rec-circle.recording::after{content:'';position:absolute;inset:-12px;border-radius:50%;border:2px solid var(--storm);animation:recPulse 1.2s infinite}
@keyframes recPulse{0%{opacity:1;transform:scale(.95)}100%{opacity:0;transform:scale(1.4)}}
.rec-time{font-family:var(--f-mono);font-variant-numeric:tabular-nums;font-size:32px;font-weight:500;color:var(--ink-deep);margin-bottom:4px}
.rec-status{font-family:var(--f-display);font-style:italic;font-size:14px;color:var(--sepia);margin-bottom:18px;min-height:22px}
/* ============ TRACKING SCREEN ============ */
.tracking-modal{max-width:100%;width:100%;height:100vh;max-height:100vh;border-radius:0;border:none;border-top:none}
.tracking-head{
background:var(--ink-deep);color:var(--bg-paper);
padding:max(14px,env(safe-area-inset-top)) 18px 12px;
border-bottom:1px solid var(--brass-deep);
display:flex;justify-content:space-between;align-items:center;
}
.tracking-head h3{
font-family:var(--f-display);font-style:italic;font-weight:500;
font-variation-settings:"opsz" 24;font-size:18px;margin:0;
color:var(--brass-bright);display:flex;align-items:center;gap:8px;
}
.tracking-stats{
background:var(--ink-deep);color:var(--bg-paper);
padding:14px 18px 18px;
display:grid;grid-template-columns:repeat(2,1fr);gap:14px 18px;
border-bottom:1px solid var(--brass-deep);
position:relative;
}
.tracking-stats::after{
content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);
width:60px;height:3px;background:var(--brass);
}
.t-stat-label{font-family:var(--f-mono);font-size:9.5px;letter-spacing:.22em;text-transform:uppercase;color:rgba(200,159,84,.7);font-weight:500;margin-bottom:2px}
.t-stat-value{
font-family:var(--f-mono);font-variant-numeric:tabular-nums;
font-size:30px;font-weight:500;line-height:1;color:var(--brass-bright);
}
.t-stat-unit{font-size:13px;font-weight:400;color:rgba(200,159,84,.5);margin-left:3px}
.tracking-map{flex:1;min-height:240px;background:var(--bg-aged);border-bottom:1px solid var(--rule)}
.tracking-foot{
padding:14px 18px;display:flex;gap:8px;background:var(--bg-paper);
padding-bottom:max(14px,env(safe-area-inset-bottom));
}
.tracking-foot .btn{flex:1}
#map-modal-content{height:55vh;min-height:280px;background:var(--bg-aged);border:1px solid var(--rule)}
.map-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px;padding:12px;background:var(--bg-canvas);border:1px solid var(--rule-soft)}
.map-stat{text-align:center}
.map-stat-num{display:block;font-family:var(--f-mono);font-size:18px;font-weight:500;color:var(--brass-deep);font-variant-numeric:tabular-nums}
.map-stat-lbl{font-family:var(--f-mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--sepia);margin-top:2px;font-weight:500}
/* ============ VIEWER ============ */
.viewer-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.92);display:none;align-items:center;justify-content:center;z-index:200;padding:20px}
.viewer-backdrop.show{display:flex;flex-direction:column}
.viewer-content{max-width:100%;max-height:80vh}
.viewer-content img,.viewer-content video{max-width:100%;max-height:80vh;object-fit:contain}
.viewer-content audio{width:90vw;max-width:400px}
.viewer-close{position:absolute;top:14px;right:14px;background:rgba(255,255,255,.1);color:#fff;border:1px solid rgba(255,255,255,.25);width:40px;height:40px;font-size:18px;cursor:pointer;font-family:var(--f-mono)}
.viewer-actions{margin-top:18px;display:flex;gap:8px}
.viewer-actions .btn{background:rgba(255,255,255,.1);color:#fff;border-color:rgba(255,255,255,.25)}
.viewer-actions .btn:hover{background:rgba(255,255,255,.18);color:#fff}
/* ============ TOAST + FAB ============ */
.toast{
position:fixed;bottom:90px;left:50%;transform:translateX(-50%);
background:var(--ink-deep);color:var(--bg-paper);
padding:11px 18px;
font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:500;
z-index:300;opacity:0;transition:opacity .2s;pointer-events:none;
border:1px solid var(--brass);max-width:90%;
}
.toast.show{opacity:1}
.fab{
position:fixed;bottom:max(20px,env(safe-area-inset-bottom));right:18px;
background:var(--brass);color:var(--bg-paper);
border:1px solid var(--brass-deep);
border-radius:50%;width:58px;height:58px;
cursor:pointer;z-index:50;
box-shadow:0 4px 14px rgba(14,42,61,.35),inset 0 1px 0 var(--brass-bright);
display:flex;align-items:center;justify-content:center;
}
.fab:active{transform:translateY(1px)}
.fab svg{width:22px;height:22px}
/* ============ PENDING TOGGLE ============ */
.pending-toggle{display:flex;gap:0;margin-bottom:14px;border:1px solid var(--rule)}
.pending-toggle button{
flex:1;padding:9px;background:var(--bg-paper);border:none;
font-family:var(--f-mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;
cursor:pointer;color:var(--sepia);
border-right:1px solid var(--rule);
}
.pending-toggle button:last-child{border-right:none}
.pending-toggle button.active{background:var(--ink-deep);color:var(--bg-paper)}
.priority-row{display:flex;gap:0;border:1px solid var(--rule)}
.priority-row label{flex:1;text-align:center;padding:9px;border:none;font-family:var(--f-mono);font-size:11px;letter-spacing:.1em;cursor:pointer;background:var(--bg-paper);color:var(--sepia);border-right:1px solid var(--rule);text-transform:uppercase;font-weight:500;margin:0}
.priority-row label:last-child{border-right:none}
.priority-row input{display:none}
.priority-row label:has(input:checked){background:var(--ink-deep);color:var(--brass-bright)}
/* ============ EXPORT PANEL ============ */
.export-card{background:var(--bg-paper);border:1px solid var(--rule);padding:14px 16px;margin-bottom:14px}
.export-card-title{font-family:var(--f-display);font-style:italic;font-weight:500;font-size:17px;margin-bottom:4px;color:var(--ink-deep)}
.export-card-text{font-size:13.5px;color:var(--sepia);line-height:1.55;font-family:var(--f-display);font-style:italic;font-variation-settings:"opsz" 14}
.export-actions{display:flex;flex-direction:column;gap:7px}
/* ============ ANCHOR WATCH ============ */
.anchor-card{
background:var(--bg-paper);color:var(--ink-deep);
border:1px solid var(--rule);
padding:18px 18px 16px;margin-bottom:18px;
position:relative;
}
.anchor-card::before{content:'';position:absolute;inset:5px;border:1px solid var(--rule-soft);pointer-events:none}
.anchor-card.active{
background:var(--ink-deep);color:var(--bg-paper);
border-color:var(--brass-deep);
background-image:radial-gradient(ellipse at top right,rgba(63,119,104,.12) 0%,transparent 60%);
}
.anchor-card.active::before{border-color:rgba(160,120,50,.35)}
.anchor-card.warn{background:#3d2818;color:var(--bg-paper);border-color:var(--sun)}
.anchor-card.warn::before{border-color:rgba(182,112,37,.4)}
.anchor-status{font-family:var(--f-mono);font-size:9.5px;letter-spacing:.22em;text-transform:uppercase;font-weight:500}
.anchor-status.safe{color:var(--algae)}
.anchor-card.active .anchor-status.safe{color:#7eb695}
.anchor-status.warn{color:var(--sun)}
.anchor-card.warn .anchor-status.warn{color:var(--sun)}
/* ============ ALARM SCREEN ============ */
.alarm-backdrop{
position:fixed;inset:0;
background:radial-gradient(circle at center,#5a1818 0%,#2a0808 100%);
display:none;align-items:center;justify-content:center;z-index:500;
padding:20px;flex-direction:column;
animation:alarmFlash 1s infinite alternate;
}
@keyframes alarmFlash{from{background:radial-gradient(circle at center,#5a1818 0%,#2a0808 100%)}to{background:radial-gradient(circle at center,#8c2828 0%,#4a1414 100%)}}
.alarm-backdrop.show{display:flex}
.alarm-icon{
width:80px;height:80px;color:#fff;margin-bottom:14px;
animation:alarmShake .15s infinite alternate;
}
@keyframes alarmShake{from{transform:rotate(-3deg) scale(1)}to{transform:rotate(3deg) scale(1.05)}}
.alarm-title{
font-family:var(--f-display);font-style:italic;font-weight:700;
font-variation-settings:"opsz" 96,"SOFT" 0;
font-size:36px;color:#fff;text-align:center;line-height:1;
margin-bottom:6px;letter-spacing:-.02em;
text-shadow:0 0 24px rgba(255,255,255,.3);
}
.alarm-sub{
font-family:var(--f-mono);font-size:11px;letter-spacing:.22em;
text-transform:uppercase;color:rgba(255,255,255,.85);
margin-bottom:18px;text-align:center;
}
.alarm-distance{
font-family:var(--f-mono);font-variant-numeric:tabular-nums;
font-size:54px;font-weight:600;color:#fff;line-height:1;
margin-bottom:4px;
}
.alarm-distance-lbl{font-family:var(--f-mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:rgba(255,255,255,.7);margin-bottom:24px}
.alarm-actions{
display:flex;flex-direction:column;gap:8px;
width:100%;max-width:340px;margin-bottom:14px;
}
.alarm-btn{
display:flex;align-items:center;justify-content:center;gap:8px;
padding:14px;background:rgba(255,255,255,.95);color:var(--storm);
border:none;cursor:pointer;
font-family:var(--f-mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;font-weight:600;
}
.alarm-btn svg{width:18px;height:18px}
.alarm-btn.wa{background:#25d366;color:#fff}
.alarm-btn.sms{background:#1f5b76;color:#fff}
.alarm-btn.mail{background:var(--brass);color:#fff}
.alarm-btn:active{transform:scale(.98)}
.alarm-dismiss{
margin-top:8px;background:transparent;border:1px solid rgba(255,255,255,.4);
color:rgba(255,255,255,.85);padding:11px 22px;cursor:pointer;
font-family:var(--f-mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;
}
.alarm-dismiss:active{background:rgba(255,255,255,.1)}
/* ============ ANCHOR WATCH FULLSCREEN ============ */
.anchor-modal{max-width:100%;width:100%;height:100vh;max-height:100vh;border-radius:0;border:none;border-top:none}
.anchor-head{
background:var(--ink-deep);color:var(--bg-paper);
padding:max(14px,env(safe-area-inset-top)) 18px 12px;
border-bottom:1px solid var(--brass-deep);
display:flex;justify-content:space-between;align-items:center;
}
.anchor-head h3{
font-family:var(--f-display);font-style:italic;font-weight:500;
font-variation-settings:"opsz" 24;font-size:18px;margin:0;
color:var(--brass-bright);
}
.anchor-stats-bar{
background:var(--ink-deep);color:var(--bg-paper);
padding:14px 18px 18px;
display:grid;grid-template-columns:repeat(3,1fr);gap:14px;
border-bottom:1px solid var(--brass-deep);position:relative;
}
.anchor-stats-bar::after{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:60px;height:3px;background:var(--brass)}
.anchor-stat{text-align:left}
.anchor-stat-lbl{font-family:var(--f-mono);font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:rgba(200,159,84,.7);margin-bottom:2px;font-weight:500}
.anchor-stat-val{font-family:var(--f-mono);font-variant-numeric:tabular-nums;font-size:24px;font-weight:500;line-height:1;color:var(--brass-bright)}
.anchor-stat-val.warn{color:var(--sun)}
.anchor-stat-val.alarm{color:#ff5555}
.anchor-stat-unit{font-size:11px;color:rgba(200,159,84,.5);margin-left:3px}
.anchor-map{flex:1;min-height:240px;background:var(--bg-aged)}
.anchor-foot{
padding:14px 18px;display:flex;gap:8px;background:var(--bg-paper);
padding-bottom:max(14px,env(safe-area-inset-bottom));flex-direction:column;
}
.anchor-foot-row{display:flex;gap:8px}
.anchor-foot-row .btn{flex:1}
.anchor-radius-row{display:flex;align-items:center;gap:10px;padding:8px 0}
.anchor-radius-row label{font-family:var(--f-mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--sepia);font-weight:500;flex-shrink:0}
.anchor-radius-row input[type=range]{flex:1;-webkit-appearance:none;height:4px;background:var(--rule-soft);border:none;outline:none}
.anchor-radius-row input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:var(--brass);border:2px solid var(--bg-paper);box-shadow:0 1px 4px rgba(0,0,0,.2);cursor:pointer}
.anchor-radius-row input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:50%;background:var(--brass);border:2px solid var(--bg-paper);cursor:pointer}
.anchor-radius-val{font-family:var(--f-mono);font-size:13px;font-weight:500;color:var(--ink-deep);min-width:50px;text-align:right;font-variant-numeric:tabular-nums}
/* ============ CONTACTS ============ */
.contact-card{
background:var(--bg-paper);border:1px solid var(--rule-soft);
padding:12px 14px;margin-bottom:8px;
display:flex;justify-content:space-between;align-items:flex-start;gap:10px;
}
.contact-info{flex:1;min-width:0}
.contact-name{font-family:var(--f-display);font-style:italic;font-weight:500;font-size:16px;color:var(--ink-deep);margin-bottom:2px}
.contact-meta{font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);letter-spacing:.04em;line-height:1.5}
.contact-channels{display:flex;gap:5px;margin-top:5px}
.channel-pill{font-family:var(--f-mono);font-size:9px;letter-spacing:.15em;text-transform:uppercase;padding:2px 7px;border:1px solid var(--rule);background:var(--bg-canvas);color:var(--ink-mid);font-weight:500}
.channel-pill.on{background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep)}
/* ============ INSTALL BANNER ============ */
.install-tip{font-family:var(--f-display);font-style:italic;font-size:13px;color:var(--sepia);padding:8px 0;line-height:1.55}
/* ============ ZONES ============ */
.zone-entry{
background:var(--bg-paper);border:1px solid var(--rule-soft);
padding:12px 14px;margin-bottom:8px;cursor:pointer;
position:relative;border-left:3px solid;
}
.zone-entry.forbidden{border-left-color:var(--storm)}
.zone-entry.attention{border-left-color:var(--sun)}
.zone-entry-name{font-family:var(--f-display);font-style:italic;font-weight:500;font-size:16px;color:var(--ink-deep);margin-bottom:2px}
.zone-entry-meta{font-family:var(--f-mono);font-size:10.5px;letter-spacing:.04em;color:var(--sepia)}
.zone-toast{
position:fixed;top:88px;left:50%;transform:translateX(-50%);
background:var(--storm);color:#fff;padding:10px 16px;
font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
z-index:90;border:1px solid rgba(255,255,255,.2);max-width:90%;
display:none;animation:slideDown .25s ease;
}
.zone-toast.warn{background:var(--sun)}
@keyframes slideDown{from{transform:translate(-50%,-20px);opacity:0}}
.zone-toast.show{display:block}
@media print{
header,.tabs,.toolbar,.fab,.entry-actions,.alert,.gps-card,.anchor-card{display:none!important}
.panel{display:block!important;page-break-inside:avoid}
body{background:#fff;background-image:none}
.entry{break-inside:avoid;border:1px solid #999}
}
/* ═══════════════════════════════════════════════════════
MODERN PROFESSIONAL OVERLAY · v2 — Apr 2026
Mantém identidade marítima (cores Fraunces + Manrope + JetBrains Mono),
mas moderniza profundamente: depth, microinterações, polish premium.
═══════════════════════════════════════════════════════ */
:root{
/* spacing scale (8pt base) */
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
/* radius scale (sutis — alinhados ao tom marítimo) */
--r-sm:4px; --r-md:8px; --r-lg:12px; --r-xl:16px; --r-pill:9999px;
/* shadow scale (depth profissional) */
--sh-1:0 1px 2px rgba(14,42,61,.06), 0 1px 1px rgba(14,42,61,.04);
--sh-2:0 2px 4px rgba(14,42,61,.06), 0 4px 8px rgba(14,42,61,.05);
--sh-3:0 4px 8px rgba(14,42,61,.07), 0 8px 16px rgba(14,42,61,.06);
--sh-4:0 8px 16px rgba(14,42,61,.08), 0 16px 32px rgba(14,42,61,.08);
--sh-5:0 16px 32px rgba(14,42,61,.10), 0 32px 64px rgba(14,42,61,.10);
--sh-glow:0 0 0 4px rgba(160,120,50,.12);
--sh-glow-blue:0 0 0 4px rgba(31,91,118,.18);
/* transitions */
--t-fast:120ms cubic-bezier(.4,0,.2,1);
--t-base:200ms cubic-bezier(.4,0,.2,1);
--t-slow:320ms cubic-bezier(.4,0,.2,1);
/* superfícies elevadas premium */
--surface-1:#fbf5e2;
--surface-2:#ffffff;
--surface-elevated:rgba(255,255,255,.78);
--border-subtle:rgba(184,156,108,.22);
--border-strong:rgba(184,156,108,.55);
}
/* ── BACKGROUND limpo elegante (sem ruído) ── */
body{
background:
radial-gradient(ellipse 1200px 800px at 50% -200px, #f8eed5 0%, transparent 60%),
linear-gradient(180deg, #efe5cd 0%, #e7d9b6 100%);
background-attachment:fixed;
background-image:
radial-gradient(ellipse 1200px 800px at 50% -200px, #f8eed5 0%, transparent 60%),
linear-gradient(180deg, #efe5cd 0%, #e7d9b6 100%);
}
/* ── HEADER premium com glow sutil + gradient ── */
header{
background:linear-gradient(180deg, #0e2a3d 0%, #143447 100%);
box-shadow:
inset 0 1px 0 rgba(200,159,84,.30),
inset 0 -1px 0 rgba(160,120,50,.55),
0 4px 24px rgba(14,42,61,.18),
0 12px 48px rgba(14,42,61,.10);
border-bottom:none;
}
.header-row{padding:2px 0}
.boat-name{
text-shadow:0 1px 2px rgba(0,0,0,.25);
letter-spacing:-.015em;
}
.compass-mark{
filter:drop-shadow(0 2px 8px rgba(200,159,84,.35));
transition:transform var(--t-base);
}
.compass-mark:hover{transform:rotate(15deg)}
/* ── TABS modernas: pill nav com indicador animado ── */
.tabs{
background:rgba(239,229,205,.92);
backdrop-filter:blur(12px) saturate(1.2);
-webkit-backdrop-filter:blur(12px) saturate(1.2);
border-bottom:1px solid var(--border-subtle);
padding:8px 4px 0;
gap:2px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset;
}
.tab{
border-radius:var(--r-md) var(--r-md) 0 0;
padding:13px 18px 14px;
transition:color var(--t-base), background var(--t-base);
position:relative;
}
.tab:hover{
color:var(--ink-mid);
background:rgba(255,255,255,.4);
}
.tab.active{
color:var(--ink-deep);
background:linear-gradient(180deg, rgba(255,255,255,.85), rgba(255,255,255,.5));
}
.tab.active::after{
height:3px;left:14px;right:14px;bottom:0;
background:linear-gradient(90deg, var(--brass), var(--brass-bright));
border-radius:2px 2px 0 0;
box-shadow:0 0 8px rgba(160,120,50,.4);
}
/* ── CARDS com depth real (sombra multi-layer) ── */
.gps-card,.anchor-card,.export-card,.empty,.entry,.contact-card{
border-radius:var(--r-lg);
box-shadow:var(--sh-2);
transition:box-shadow var(--t-base), transform var(--t-base);
overflow:hidden;
}
.entry{
border-radius:var(--r-md);
box-shadow:var(--sh-1);
border-color:var(--border-subtle);
}
.entry:hover{box-shadow:var(--sh-2)}
.gps-card,.anchor-card{
border-radius:var(--r-lg);
background:linear-gradient(165deg, #0e2a3d 0%, #1a3d54 100%);
border:1px solid rgba(200,159,84,.4);
box-shadow:
inset 0 1px 0 rgba(200,159,84,.2),
var(--sh-3),
0 0 32px rgba(31,91,118,.15);
}
.gps-card::before,.anchor-card::before{display:none}
.gps-card.idle{
background:linear-gradient(165deg, #fbf5e2 0%, #f3e7c4 100%);
border:1px solid var(--border-subtle);
box-shadow:var(--sh-2);
}
.gps-stats,.anchor-stats-bar{
background:rgba(0,0,0,.15);
padding:14px;
border-radius:var(--r-md);
margin:12px 0 14px;
}
.gps-card.idle .gps-stats{background:rgba(184,156,108,.08)}
.export-card,.empty{
background:var(--surface-elevated);
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
border-radius:var(--r-lg);
border:1px solid var(--border-subtle);
box-shadow:var(--sh-2);
}
.export-card:hover{box-shadow:var(--sh-3)}
/* ── BUTTONS premium com lift e ripple ── */
.btn{
border-radius:var(--r-md);
transition:all var(--t-base);
font-weight:600;
letter-spacing:.12em;
position:relative;
overflow:hidden;
}
.btn:hover{
transform:translateY(-1px);
box-shadow:var(--sh-2);
}
.btn:active{transform:translateY(0); box-shadow:var(--sh-1)}
.btn:focus-visible{
outline:none;
box-shadow:var(--sh-glow);
}
.btn-primary{
background:linear-gradient(180deg, #143a52 0%, #0e2a3d 100%);
border-color:rgba(0,0,0,.4);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.10),
var(--sh-1);
}
.btn-primary:hover{
background:linear-gradient(180deg, #1a4a66 0%, #143a52 100%);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.15),
var(--sh-3);
}
.btn-brass{
background:linear-gradient(180deg, #b88a3c 0%, #8d6826 100%);
border-color:rgba(0,0,0,.25);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.18),
var(--sh-1);
}
.btn-brass:hover{
background:linear-gradient(180deg, #c89954 0%, #a07832 100%);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.25),
var(--sh-3);
}
.btn-danger{
background:transparent;
border:1px solid var(--storm);
color:var(--storm);
}
.btn-danger:hover{
background:linear-gradient(180deg, #a04545 0%, #8c3434 100%);
color:#fff;
border-color:transparent;
}
/* ── FIELDS modernos com focus ring ── */
.field input,.field textarea,.field select{
border-radius:var(--r-md);
background:var(--surface-2);
border:1px solid var(--border-subtle);
padding:12px 14px;
font-size:15px;
transition:border-color var(--t-base), box-shadow var(--t-base), background var(--t-base);
}
.field input:hover,.field textarea:hover,.field select:hover{
border-color:var(--border-strong);
}
.field input:focus,.field textarea:focus,.field select:focus{
border-color:var(--brass);
background:#fff;
box-shadow:var(--sh-glow);
}
.field-label{font-size:11px; font-weight:600; color:var(--ink-mid); margin-bottom:6px}
/* ── MODAL com depth maior + entrance polida ── */
.modal-backdrop{
background:rgba(14,42,61,.65);
backdrop-filter:blur(8px) saturate(1.1);
-webkit-backdrop-filter:blur(8px) saturate(1.1);
}
.modal{
border-radius:var(--r-xl) var(--r-xl) 0 0;
border-top:none;
box-shadow:
0 -4px 12px rgba(0,0,0,.05),
0 -16px 48px rgba(14,42,61,.25);
background-image:none;
background:linear-gradient(180deg, #fcf6e4 0%, #f8f0d4 100%);
}
@media(min-width:600px){
.modal{
border-radius:var(--r-xl);
box-shadow:0 24px 64px rgba(14,42,61,.30), 0 8px 16px rgba(14,42,61,.10);
}
}
.modal-head{
background:transparent;
padding:18px 22px;
border-bottom:1px solid var(--border-subtle);
}
.modal-body{padding:22px}
.modal-foot{
background:rgba(0,0,0,.02);
padding:16px 22px;
}
/* ── FAB redesenhado: floating elegante ── */
.fab{
border-radius:var(--r-pill) !important;
background:linear-gradient(135deg, #b88a3c 0%, #8d6826 100%) !important;
box-shadow:
0 4px 12px rgba(160,120,50,.4),
0 12px 32px rgba(14,42,61,.15),
inset 0 1px 0 rgba(255,255,255,.25) !important;
transition:transform var(--t-base), box-shadow var(--t-base);
}
.fab:hover{
transform:translateY(-2px) scale(1.05);
box-shadow:
0 8px 20px rgba(160,120,50,.5),
0 16px 40px rgba(14,42,61,.20),
inset 0 1px 0 rgba(255,255,255,.30);
}
.fab:active{transform:translateY(0) scale(1)}
/* ── STATS GRID polido ── */
.stats{
border-radius:var(--r-lg);
background:transparent;
border:none;
gap:10px;
box-shadow:none;
display:grid;
}
.stat{
border-radius:var(--r-md);
background:var(--surface-elevated);
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
border:1px solid var(--border-subtle);
box-shadow:var(--sh-1);
padding:16px 16px 14px;
transition:transform var(--t-base), box-shadow var(--t-base);
}
.stat:hover{transform:translateY(-2px); box-shadow:var(--sh-2)}
.stat::before{
height:3px; border-radius:2px;
background:linear-gradient(90deg, var(--brass), var(--brass-bright));
transform:scaleX(.5);
transition:transform var(--t-base);
}
.stat:hover::before{transform:scaleX(1)}
/* ── EMPTY STATE elegante ── */
.empty{padding:56px 28px; border-radius:var(--r-lg)}
.empty::before,.empty::after{display:none}
.empty-rose{
width:64px; height:64px;
filter:drop-shadow(0 4px 12px rgba(160,120,50,.25));
margin-bottom:18px;
}
.empty-title{font-size:21px}
.empty-text{font-size:14px; line-height:1.65; opacity:.85}
/* ── STATUS PILLS arredondados ── */
.status-pill{
border-radius:var(--r-pill);
padding:3px 10px;
font-size:9.5px;
letter-spacing:.16em;
}
/* ── ALERTS com sombra sutil ── */
.alert{
border-radius:var(--r-md);
border:1px solid var(--border-subtle);
border-left-width:4px;
box-shadow:var(--sh-1);
padding:14px 16px;
transition:transform var(--t-base);
}
.alert:hover{transform:translateX(2px)}
/* ── PASSENGER PILLS modernos ── */
.pax-pill,.pax-tag,.channel-pill{
border-radius:var(--r-pill);
padding:4px 12px;
}
/* ── ICON BUTTONS polidos ── */
.icon-btn{
border-radius:var(--r-md);
transition:all var(--t-base);
}
.icon-btn:hover{
background:rgba(184,156,108,.15);
transform:scale(1.08);
}
/* ── MEDIA THUMBS ── */
.media-thumb,.media-item{
border-radius:var(--r-md);
transition:transform var(--t-base), box-shadow var(--t-base);
}
.media-thumb:hover,.media-item:hover{
transform:scale(1.04);
box-shadow:var(--sh-2);
z-index:2;
}
/* ── SENSOR WIDGET premium (top-right) ── */
#sensors-widget{
border-radius:var(--r-lg) !important;
background:linear-gradient(165deg, rgba(14,42,61,.92), rgba(20,58,82,.92)) !important;
backdrop-filter:blur(20px) saturate(1.4);
-webkit-backdrop-filter:blur(20px) saturate(1.4);
box-shadow:
0 4px 12px rgba(0,0,0,.15),
0 8px 24px rgba(14,42,61,.18),
inset 0 1px 0 rgba(200,159,84,.2) !important;
border:1px solid rgba(200,159,84,.18);
transition:transform var(--t-base), box-shadow var(--t-base);
}
#sensors-widget:hover{
transform:translateY(-1px);
box-shadow:
0 8px 16px rgba(0,0,0,.20),
0 16px 32px rgba(14,42,61,.22);
}
/* ── AUTH BOX polido ── */
#auth-box{
background:linear-gradient(165deg, rgba(255,255,255,.5), rgba(255,255,255,.2));
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
border-radius:var(--r-md);
padding:14px;
margin-top:14px !important;
border:1px solid var(--border-subtle);
border-top:1px solid var(--border-subtle) !important;
}
/* ── CURSOR pointer em interativos críticos ── */
.tab,.btn,.fab,.icon-btn,.media-btn,.media-thumb,.media-item,.alert,
.entry-actions button,.modal-head button,.pax-tag button{
cursor:pointer;
}
/* ── FOCUS visible global ── */
*:focus-visible{
outline:2px solid var(--brass);
outline-offset:2px;
border-radius:var(--r-sm);
}
/* ── REDUCED MOTION (accessibility) ── */
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{
animation-duration:.01ms !important;
animation-iteration-count:1 !important;
transition-duration:.01ms !important;
}
}
/* ── LARGE SCREENS: mais respiro ── */
@media(min-width:780px){
.container{padding:32px 24px 96px}
.stats{grid-template-columns:repeat(4,1fr)}
}
/* ── SCROLLBAR sutil (Firefox + WebKit) ── */
*{scrollbar-width:thin; scrollbar-color:var(--brass) transparent}
::-webkit-scrollbar{width:8px; height:8px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(160,120,50,.35); border-radius:var(--r-pill)}
::-webkit-scrollbar-thumb:hover{background:rgba(160,120,50,.55)}
/* ── TYPOGRAPHY refinement ── */
.boat-name{font-size:26px}
.entry-title{font-size:20px; line-height:1.25}
.modal-head h3{font-size:20px}
.empty-title{margin-bottom:6px}
/* ── SECTION HEADER sutilmente refinado ── */
.section-header h2{font-size:11px; font-weight:600; color:var(--ink-mid)}
/* ── TOOLBAR refinada ── */
.toolbar{gap:10px; margin-bottom:18px}
/* ════════════════════════════════════════════════════════════════════
v3 — "MARINE PRO DARK" REDESIGN (override completo)
Bottom nav · Inter + Mono · Dark navy + cyan · Cards modernos
════════════════════════════════════════════════════════════════════ */
:root{
/* Tipografia — sem Fraunces editorial */
--f-display:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
--f-body:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
--f-mono:'JetBrains Mono','SF Mono','Consolas',monospace;
/* Paleta marine pro dark */
--m-bg:#0d2538; /* deep navy (canvas) */
--m-bg-2:#0f2a40; /* surface 1 */
--m-bg-3:#163a55; /* surface 2 (cards) */
--m-bg-4:#1d4a6b; /* surface elevated */
--m-border:rgba(255,255,255,.08);
--m-border-strong:rgba(255,255,255,.18);
--m-text:#e8f1f8; /* primary text */
--m-text-mid:#b3c5d6; /* secondary */
--m-text-soft:#7d97ad; /* tertiary */
--m-accent:#06b6d4; /* cyan accent */
--m-accent-2:#22d3ee; /* cyan brighter */
--m-accent-glow:rgba(6,182,212,.20);
--m-ok:#10b981; /* anchored / safe */
--m-warn:#f59e0b; /* drift / soon */
--m-danger:#ef4444; /* alarm */
--m-info:#3b82f6; /* info */
/* Spacing scale */
--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;
/* Radius */
--m-r-sm:6px;--m-r-md:10px;--m-r-lg:14px;--m-r-xl:20px;--m-r-pill:9999px;
/* Shadows (subtle, marine) */
--m-sh-1:0 1px 2px rgba(0,0,0,.32);
--m-sh-2:0 2px 6px rgba(0,0,0,.28),0 1px 2px rgba(0,0,0,.24);
--m-sh-3:0 8px 20px rgba(0,0,0,.32),0 2px 6px rgba(0,0,0,.24);
}
html,body{
background:var(--m-bg);
color:var(--m-text);
font-family:var(--f-body);
font-feature-settings:'cv11','ss03';
font-size:15px;line-height:1.5;
}
body{
background-image:
radial-gradient(ellipse 800px 600px at top right,rgba(34,211,238,.04) 0%,transparent 70%),
radial-gradient(ellipse 700px 500px at bottom left,rgba(6,182,212,.03) 0%,transparent 70%),
linear-gradient(180deg,#0a1f30 0%,#0d2538 100%);
background-attachment:fixed;
padding-bottom:calc(76px + env(safe-area-inset-bottom));
}
/* Mata o italic editorial em TUDO */
.boat-name,.entry-title,.empty-title,.modal-head h3,
.entry-notes,.field textarea,.export-card-title,
.export-card-text,.field-hint,h1,h2,h3,h4,h5,
.gps-card,.anchor-card,.fleet-name,.welcome-tagline{
font-family:var(--f-body)!important;
font-style:normal!important;
font-variation-settings:normal!important;
}
/* ── HEADER COMPACTO ── */
header{
background:linear-gradient(180deg,var(--m-bg-2) 0%,var(--m-bg) 100%);
border-bottom:1px solid var(--m-border);
padding:max(10px,env(safe-area-inset-top)) 14px 10px;
box-shadow:var(--m-sh-1);
}
.header-row{gap:10px;max-width:760px}
.compass-mark{display:none} /* compact: avatar é suficiente */
.boat-tagline{
font-family:var(--f-mono);font-size:9px;color:var(--m-accent);
letter-spacing:.18em;opacity:.85;margin-bottom:1px;
}
.boat-name,.boat-selector{
font-family:var(--f-body)!important;font-style:normal!important;
font-size:17px!important;font-weight:600;letter-spacing:-.01em;
color:var(--m-text)!important;line-height:1.2;
}
.boat-meta{
font-family:var(--f-body);font-size:11px;color:var(--m-text-soft);
text-transform:none;letter-spacing:0;
}
.boat-edit-btn{display:none}
.boat-header-avatar{
width:38px;height:38px;border:2px solid var(--m-accent);
background:var(--m-bg-3);color:var(--m-accent);
box-shadow:0 0 0 3px var(--m-accent-glow);
}
/* ── SAFETY STATUS BAR (logo abaixo do header) ── */
.safety-bar{
display:flex;justify-content:space-between;align-items:center;
background:var(--m-bg-2);border-bottom:1px solid var(--m-border);
padding:6px 14px;font-family:var(--f-mono);font-size:10.5px;
color:var(--m-text-mid);letter-spacing:.04em;
position:sticky;top:0;z-index:39;
}
.safety-bar-item{display:inline-flex;align-items:center;gap:5px}
.safety-bar-dot{width:7px;height:7px;border-radius:50%;background:var(--m-text-soft)}
.safety-bar-dot.ok{background:var(--m-ok);box-shadow:0 0 6px var(--m-ok)}
.safety-bar-dot.warn{background:var(--m-warn);box-shadow:0 0 6px var(--m-warn);animation:pulseDot 1.5s infinite}
.safety-bar-dot.danger{background:var(--m-danger);box-shadow:0 0 8px var(--m-danger);animation:pulseDot .8s infinite}
@keyframes pulseDot{50%{opacity:.4}}
/* ── TOP TABS REMOVIDAS (substituídas pela bottom nav) ── */
.tabs{display:none!important}
/* ── CONTAINER ── */
.container{padding:14px 12px 20px;max-width:760px}
.panel{padding:0;background:transparent}
/* ── CARDS DARK MODERNOS ── */
.export-card,.gps-card,.anchor-card,.empty,.contact-card{
background:var(--m-bg-3);
border:1px solid var(--m-border);
border-radius:var(--m-r-lg);
box-shadow:var(--m-sh-2);
padding:16px;margin-bottom:12px;
color:var(--m-text);
}
.export-card-title{
color:var(--m-text)!important;font-weight:600;font-size:15px;
letter-spacing:-.005em;margin-bottom:6px;
}
.export-card-text{
color:var(--m-text-mid)!important;font-size:13px;line-height:1.5;
}
.gps-card,.anchor-card{
background:linear-gradient(165deg,var(--m-bg-3),var(--m-bg-2));
border:1px solid var(--m-border-strong);
}
.gps-card.idle{
background:var(--m-bg-3);
color:var(--m-text);
}
.gps-stats{gap:14px}
.gps-stat-value,.t-stat-value{
color:var(--m-accent)!important;
font-family:var(--f-mono);font-weight:700;
font-variant-numeric:tabular-nums;
}
.gps-stat-label,.t-stat-label{
color:var(--m-text-soft)!important;
font-size:9.5px;letter-spacing:.14em;
}
.entry{
background:var(--m-bg-3);
border:1px solid var(--m-border);
border-left:3px solid var(--m-accent);
border-radius:var(--m-r-md);
box-shadow:var(--m-sh-1);
padding:14px;margin-bottom:10px;
color:var(--m-text);
}
.entry-title{color:var(--m-text)!important;font-weight:600;font-size:15px}
.entry-meta{color:var(--m-text-mid)}
.entry.maint{border-left-color:var(--m-warn)}
.entry.pending{border-left-color:var(--m-warn)}
.entry.pending.overdue{border-left-color:var(--m-danger)}
.entry.pending.done{border-left-color:var(--m-ok);opacity:.7}
.entry-grid dt{color:var(--m-text-soft)}
.entry-grid dd{color:var(--m-text)}
.entry-notes{
background:var(--m-bg-2)!important;border-left-color:var(--m-accent)!important;
color:var(--m-text-mid)!important;border-radius:var(--m-r-sm);
font-style:normal!important;
}
/* ── STATS DASHBOARD ── */
.stats{gap:8px}
.stat{
background:var(--m-bg-3);
border:1px solid var(--m-border);
border-radius:var(--m-r-md);
padding:14px 12px;
position:relative;overflow:hidden;
}
.stat::before{
content:'';position:absolute;top:0;left:0;right:0;height:2px;
background:linear-gradient(90deg,var(--m-accent),var(--m-accent-2));
opacity:.7;
}
.stat-value{
color:var(--m-accent)!important;
font-family:var(--f-mono);font-weight:700;
font-variant-numeric:tabular-nums;
font-size:24px;
}
.stat-label{
color:var(--m-text-soft)!important;
font-size:9.5px;letter-spacing:.14em;
}
.stat-sub{color:var(--m-text-mid)!important;font-style:normal!important;font-size:11px}
/* ── BUTTONS ── */
.btn{
background:var(--m-bg-3);border:1px solid var(--m-border-strong);
color:var(--m-text);font-family:var(--f-body);
font-size:14px;font-weight:500;
border-radius:var(--m-r-md);
min-height:44px;padding:11px 18px;
text-transform:none;letter-spacing:0;
transition:all .15s;
}
.btn:hover{background:var(--m-bg-4);border-color:var(--m-accent)}
.btn-primary,.btn-brass{
background:var(--m-accent);color:#001a25;border-color:var(--m-accent);
font-weight:600;
}
.btn-primary:hover,.btn-brass:hover{background:var(--m-accent-2);color:#001a25;border-color:var(--m-accent-2)}
.btn-danger{background:transparent;color:var(--m-danger);border-color:var(--m-danger)}
.btn-danger:hover{background:rgba(239,68,68,.1);color:var(--m-danger)}
.btn-block{width:100%}
.btn-sm{min-height:34px;padding:7px 14px;font-size:12.5px}
.btn-big{min-height:54px;padding:14px 22px;font-size:15px;font-weight:600}
/* ── FAB acima da bottom nav ── */
.fab{
bottom:calc(86px + env(safe-area-inset-bottom))!important;
right:18px!important;
background:linear-gradient(135deg,var(--m-accent),var(--m-accent-2))!important;
color:#001a25!important;
width:56px!important;height:56px!important;
box-shadow:0 6px 20px var(--m-accent-glow),0 2px 8px rgba(0,0,0,.5)!important;
}
.fab:hover{transform:scale(1.06) translateY(-2px)!important}
.fab svg{stroke:#001a25!important}
/* ── BOTTOM NAVIGATION ── */
.bottom-nav{
position:fixed;bottom:0;left:0;right:0;z-index:48;
background:var(--m-bg-2);
border-top:1px solid var(--m-border);
padding:6px 0 max(6px,env(safe-area-inset-bottom));
display:flex;justify-content:space-around;align-items:stretch;
backdrop-filter:blur(18px);
-webkit-backdrop-filter:blur(18px);
background:rgba(15,42,64,.92);
}
.bn-item{
flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:3px;padding:6px 4px;
background:none;border:none;cursor:pointer;
color:var(--m-text-soft);
font-family:var(--f-body);font-size:10px;font-weight:500;
transition:color .15s;
min-height:54px;
}
.bn-item:hover{color:var(--m-text-mid)}
.bn-item.active{color:var(--m-accent)}
.bn-item svg{width:22px;height:22px;stroke-width:2;transition:stroke-width .15s}
.bn-item.active svg{stroke-width:2.4}
.bn-badge{
position:absolute;top:4px;right:50%;transform:translate(14px,0);
background:var(--m-danger);color:#fff;
font-family:var(--f-mono);font-size:9px;font-weight:700;
min-width:16px;height:16px;border-radius:8px;
display:flex;align-items:center;justify-content:center;padding:0 4px;
}
.bn-item{position:relative}
/* ── MODAIS dark ── */
.modal{
background:var(--m-bg-3);
color:var(--m-text);
border:1px solid var(--m-border-strong);
border-radius:var(--m-r-xl) var(--m-r-xl) 0 0;
border-top:1px solid var(--m-border-strong);
}
@media(min-width:600px){
.modal{border-radius:var(--m-r-xl);}
}
.modal-head{border-bottom:1px solid var(--m-border)}
.modal-head h3{color:var(--m-text);font-style:normal;font-weight:600;font-size:17px}
.modal-foot{border-top:1px solid var(--m-border);background:var(--m-bg-2)}
.modal-backdrop{background:rgba(0,0,0,.7);backdrop-filter:blur(6px)}
.icon-btn{color:var(--m-text-mid);background:transparent;border:none}
.icon-btn:hover{color:var(--m-text);background:var(--m-bg-2)}
/* ── FORM FIELDS dark ── */
.field-label{color:var(--m-text-soft);font-size:10px;letter-spacing:.12em;font-weight:600;text-transform:uppercase}
.field input,.field textarea,.field select{
background:var(--m-bg-2)!important;
border:1px solid var(--m-border-strong)!important;
color:var(--m-text)!important;
border-radius:var(--m-r-sm);
font-family:var(--f-body);font-style:normal!important;
font-size:15px;padding:11px 13px;
}
.field input:focus,.field textarea:focus,.field select:focus{
outline:none!important;
border-color:var(--m-accent)!important;
box-shadow:0 0 0 3px var(--m-accent-glow)!important;
background:var(--m-bg-3)!important;
}
.field-hint{color:var(--m-text-soft);font-style:normal!important}
/* ── SECTION HEADERS / TOOLBARS ── */
.section-header h2{color:var(--m-text-soft);text-transform:uppercase;letter-spacing:.12em;font-size:10.5px}
.toolbar{margin-bottom:14px}
/* ── EMPTY STATES ── */
.empty{text-align:center;padding:32px 20px;color:var(--m-text-soft)}
.empty-title{color:var(--m-text);font-weight:600;font-size:16px;font-style:normal!important}
.empty-text{color:var(--m-text-soft);font-size:13.5px;line-height:1.55}
/* ── SYNC INDICATOR ── */
.sync-indicator{font-size:10px;margin-left:5px}
/* ── WELCOME SCREEN dark ── */
.welcome-screen{background:linear-gradient(135deg,var(--m-bg) 0%,var(--m-bg-2) 50%,#06151f 100%)}
.welcome-card{
background:var(--m-bg-3);
border:1px solid var(--m-border-strong);
border-radius:var(--m-r-xl);
box-shadow:var(--m-sh-3);
color:var(--m-text);
}
.welcome-title{color:var(--m-text);font-style:normal;font-weight:700;font-size:24px}
.welcome-tagline{color:var(--m-text-mid);font-style:normal;font-size:14px}
.welcome-btn{
background:var(--m-bg-2);border:1px solid var(--m-border-strong);
color:var(--m-text);border-radius:var(--m-r-md);
font-family:var(--f-body);font-weight:500;
}
.welcome-btn:hover{background:var(--m-bg-4);border-color:var(--m-accent)}
.welcome-btn-google{background:#fff;color:#3c4043;border-color:#dadce0}
.welcome-btn-google:hover{background:#f1f3f4;color:#3c4043;border-color:#dadce0}
.welcome-btn-primary{background:var(--m-accent);color:#001a25;border-color:var(--m-accent)}
.welcome-btn-primary:hover{background:var(--m-accent-2);color:#001a25;border-color:var(--m-accent-2)}
.welcome-btn-text{color:var(--m-text-soft);background:transparent;border:none}
.welcome-tab{color:var(--m-text-soft);text-transform:uppercase;letter-spacing:.12em}
.welcome-tab.active{color:var(--m-accent);border-bottom-color:var(--m-accent)}
/* ── FLEET & ANCHOR CALC dark ── */
.fleet-item{background:var(--m-bg-2);border:1px solid var(--m-border)}
.fleet-item:hover{background:var(--m-bg-4);border-color:var(--m-accent)}
.fleet-item.active{border-left:3px solid var(--m-accent);background:var(--m-bg-4)}
.fleet-name{color:var(--m-text);font-style:normal;font-weight:600;font-size:15px}
.fleet-meta{color:var(--m-text-soft);text-transform:uppercase;letter-spacing:.06em}
.fleet-active-badge{background:var(--m-accent);color:#001a25;font-weight:700}
.fleet-edit-icon{background:transparent;border:1px solid var(--m-border-strong);color:var(--m-text-mid)}
.fleet-units-toggle{border:1px solid var(--m-border-strong);background:var(--m-bg-2)}
.fleet-units-toggle button{background:transparent;color:var(--m-text-mid)}
.fleet-units-toggle button.active{background:var(--m-accent);color:#001a25}
.anchor-calc{background:var(--m-bg-2);border:1px solid var(--m-border);border-left:3px solid var(--m-accent)}
.anchor-calc-head{color:var(--m-accent);font-weight:600}
.anchor-calc-stat{background:var(--m-bg-3);border:1px solid var(--m-border)}
.anchor-calc-stat-label{color:var(--m-text-soft)}
.anchor-calc-stat-value{color:var(--m-text);font-style:normal;font-weight:700;font-family:var(--f-mono);font-variant-numeric:tabular-nums}
.anchor-calc-stat-value.ok{color:var(--m-ok)}
.anchor-calc-stat-value.warn{color:var(--m-warn)}
.anchor-calc-stat-value.danger{color:var(--m-danger)}
.anchor-calc-advice{background:rgba(6,182,212,.08);border-left-color:var(--m-accent);color:var(--m-text-mid);font-style:normal}
/* ── BADGES & TAGS ── */
.badge{background:var(--m-danger);color:#fff;font-family:var(--f-mono);font-weight:700}
.pax-tag{background:var(--m-bg-3);border:1px solid var(--m-border);color:var(--m-text);border-radius:var(--m-r-pill)}
/* ── ALERTS ── */
.alert-overdue,.alert-danger{background:rgba(239,68,68,.08);border-left:3px solid var(--m-danger);color:var(--m-text)}
.alert-soon,.alert-warn{background:rgba(245,158,11,.08);border-left:3px solid var(--m-warn);color:var(--m-text)}
.alert-info{background:rgba(59,130,246,.08);border-left:3px solid var(--m-info);color:var(--m-text)}
/* ── TOAST ── */
.toast{
background:var(--m-bg-3);color:var(--m-text);
border:1px solid var(--m-border-strong);
border-radius:var(--m-r-md);
box-shadow:var(--m-sh-3);
font-family:var(--f-body);font-style:normal;font-weight:500;
}
/* ── pax/checklist/tags ajustes ── */
.checklist-card{background:var(--m-bg-3);border:1px solid var(--m-border);border-radius:var(--m-r-md);color:var(--m-text)}
.weather-widget{background:var(--m-bg-3)!important;border:1px solid var(--m-border)!important;color:var(--m-text)!important;border-radius:var(--m-r-md)}
/* ── SCROLLBAR thin estilo iOS ── */
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-thumb{background:var(--m-border-strong);border-radius:3px}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<header>
<div class="header-row">
<svg class="compass-mark" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke="currentColor" stroke-width="1">
<circle cx="50" cy="50" r="46"/>
<circle cx="50" cy="50" r="38"/>
<circle cx="50" cy="50" r="3" fill="currentColor"/>
</g>
<g fill="currentColor">
<path d="M50 4 L54 50 L50 50 Z"/>
<path d="M50 4 L46 50 L50 50 Z" opacity=".55"/>
<path d="M50 96 L46 50 L50 50 Z"/>
<path d="M50 96 L54 50 L50 50 Z" opacity=".55"/>
<path d="M96 50 L50 46 L50 50 Z" opacity=".7"/>
<path d="M96 50 L50 54 L50 50 Z" opacity=".4"/>
<path d="M4 50 L50 54 L50 50 Z" opacity=".7"/>
<path d="M4 50 L50 46 L50 50 Z" opacity=".4"/>
</g>
<g fill="currentColor" opacity=".5">
<path d="M82 18 L52 48 L50 50 Z"/>
<path d="M18 82 L48 52 L50 50 Z"/>
<path d="M18 18 L48 48 L50 50 Z"/>
<path d="M82 82 L52 52 L50 50 Z"/>
</g>
<text x="50" y="14" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="6" fill="currentColor" font-weight="600">N</text>
</svg>
<div class="boat-header-avatar" id="boat-header-avatar" onclick="openFleetManager()" style="cursor:pointer" title="Gerenciar frota"></div>
<div class="boat-info">
<div class="boat-tagline">Diário de Bordo · Logbook <span class="sync-indicator" id="sync-indicator" data-status="disabled" title="Sync na nuvem desligado"></span></div>
<button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota">
<span id="boat-name-display">Shivao</span>
<span class="boat-chevron" aria-hidden="true"></span>
</button>
<div class="boat-meta">
<span id="boat-model-display" class="boat-model-text">⛵ Veleiro</span>
<button type="button" class="boat-edit-btn" onclick="openFleetManager()" title="Editar embarcação"></button>
</div>
</div>
</div>
</header>
<!-- Safety status bar (sempre visível, marine pro) -->
<div class="safety-bar" id="safety-bar">
<span class="safety-bar-item"><span class="safety-bar-dot" id="sb-gps-dot"></span><span id="sb-gps">GPS aguardando</span></span>
<span class="safety-bar-item" id="sb-anchor-wrap" style="display:none"><span class="safety-bar-dot ok" id="sb-anchor-dot"></span><span id="sb-anchor">Fundeado</span></span>
<span class="safety-bar-item"><span id="sb-battery"></span></span>
</div>
<div class="container">
<div class="tabs" role="tablist">
<button class="tab active" data-panel="overview">Sumário</button>
<button class="tab" data-panel="trips">Travessias</button>
<button class="tab" data-panel="maintenance">Reparos</button>
<button class="tab" data-panel="pending">Pendências <span class="badge" id="pending-badge" style="display:none">0</span></button>
<button class="tab" data-panel="zones">Zonas</button>
<button class="tab" data-panel="export">Arquivo</button>
</div>
<div class="panel active" id="panel-overview">
<div id="alerts-area"></div>
<div id="battery-indicator" style="font-family:var(--f-mono);font-size:10.5px;letter-spacing:.1em;text-align:right;margin-bottom:4px;cursor:pointer" onclick="cycleBatteryMode()"></div>
<div id="weather-widget"></div>
<div id="gps-banner"></div>
<div id="anchor-banner"></div>
<div class="stats">
<div class="stat"><div class="stat-label">Horímetro</div><div class="stat-value" id="stat-hours">0.0</div><div class="stat-sub" id="stat-hours-sub">— sem leituras</div></div>
<div class="stat"><div class="stat-label">Travessias</div><div class="stat-value" id="stat-trips">0</div><div class="stat-sub" id="stat-trips-sub">registradas</div></div>
<div class="stat"><div class="stat-label">Reparos</div><div class="stat-value" id="stat-maint">0</div><div class="stat-sub" id="stat-maint-sub">no diário</div></div>
<div class="stat"><div class="stat-label">Pendentes</div><div class="stat-value" id="stat-pending">0</div><div class="stat-sub" id="stat-pending-sub">a fazer</div></div>
</div>
<div id="recent-entries"></div>
<div style="margin-top:18px;text-align:center"><button class="btn btn-sm" onclick="openChecklistsModal()" style="background:transparent;border:none;color:var(--brass);font-family:var(--f-mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">📋 Checklists de Bordo</button></div>
</div>
<div class="panel" id="panel-trips">
<div class="toolbar" style="display:grid;grid-template-columns:2fr 1fr;gap:8px"><button class="btn btn-primary btn-big" onclick="openTripModal()">⊕ Nova Travessia</button><label class="btn btn-big" style="cursor:pointer">↑ GPX<input type="file" accept=".gpx,application/gpx+xml" onchange="importGPX(event)" style="display:none"></label></div>
<div class="entries" id="trips-list"></div>
</div>
<div class="panel" id="panel-maintenance">
<div class="toolbar"><button class="btn btn-primary btn-block btn-big" onclick="openMaintModal()">⊕ Registrar Reparo</button></div>
<div class="entries" id="maint-list"></div>
</div>
<div class="panel" id="panel-pending">
<div class="toolbar"><button class="btn btn-primary btn-block btn-big" onclick="openPendingModal()">⊕ Adicionar Pendência</button></div>
<div class="pending-toggle">
<button class="active" data-filter="active" onclick="filterPending('active')">Ativas</button>
<button data-filter="all" onclick="filterPending('all')">Todas</button>
</div>
<div class="entries" id="pending-list"></div>
</div>
<div class="panel" id="panel-zones">
<div class="toolbar"><button class="btn btn-primary btn-block btn-big" onclick="openZoneEditor()">⊕ Nova Zona</button></div>
<div style="font-family:var(--f-display);font-style:italic;font-size:13px;color:var(--sepia);line-height:1.55;padding:10px 12px;background:var(--bg-paper);border:1px dashed var(--rule);margin-bottom:14px">
<strong style="color:var(--ink-deep);font-style:normal;font-family:var(--f-mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase">Geofencing &middot; vigia de áreas</strong><br>
Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção ativa durante rastreio e fundeio.
</div>
<div class="entries" id="zones-list"></div>
</div>
<div class="panel" id="panel-export">
<div class="export-card">
<div class="export-card-title">Arquivamento &amp; Compartilhamento</div>
<div class="export-card-text">O arquivo completo inclui mídias (fotos, áudios, vídeos). O CSV exporta apenas os dados para planilhas.</div>
</div>
<div class="export-actions">
<button class="btn btn-block btn-big" onclick="exportJSON(true)">Backup completo · com mídia</button>
<button class="btn btn-block" onclick="exportJSON(false)">Backup leve · só dados</button>
<button class="btn btn-block" onclick="exportCSV('trips')">CSV · Travessias</button>
<button class="btn btn-block" onclick="exportCSV('maint')">CSV · Reparos</button>
<button class="btn btn-block" onclick="exportCSV('pending')">CSV · Pendências</button>
<button class="btn btn-block btn-brass" onclick="shareData()">Compartilhar diário</button>
<button class="btn btn-block" onclick="window.print()">Imprimir / PDF</button>
<div style="height:1px;background:var(--rule-soft);margin:6px 0"></div>
<label class="btn btn-block" style="cursor:pointer">Importar backup<input type="file" accept=".json" onchange="importJSON(event)" style="display:none"></label>
<button class="btn btn-block btn-danger" onclick="resetAll()">Apagar todo o diário</button>
</div>
<div class="export-card" style="margin-top:16px"><div class="export-card-title">Espaço a bordo</div><div id="storage-info" style="margin-top:6px;font-family:var(--f-mono);font-size:11.5px;color:var(--ink-mid);letter-spacing:.05em">calculando...</div></div>
<div class="export-card"><div class="export-card-title">Instalar como aplicativo</div><div class="export-card-text">No Chrome (Android): toque no menu (⋮) → "Adicionar à tela inicial". O diário abrirá em tela cheia, como um aplicativo nativo.</div></div>
<div class="export-card" style="border-color:var(--brass);background:linear-gradient(180deg,var(--bg-paper),var(--bg-canvas))">
<div class="export-card-title">☁️ Sincronização na nuvem</div>
<div class="export-card-text" style="margin-bottom:10px" id="cloud-status">Não conectado · use seu próprio servidor (Coolify/VPS) para sync automático e alarmes remotos.</div>
<div class="field"><label class="field-label">Servidor</label><input type="url" id="cloud-url" placeholder="https://shivao.seu-dominio.com" style="font-family:var(--f-mono);font-size:13px"></div>
<div class="field"><label class="field-label">Token de acesso</label><input type="password" id="cloud-token" placeholder="cole o BOAT_TOKEN do servidor" style="font-family:var(--f-mono);font-size:12px"></div>
<div style="display:flex;flex-direction:column;gap:6px">
<button class="btn btn-block btn-primary" onclick="testCloudConnection()">Testar conexão</button>
<button class="btn btn-block btn-brass" onclick="cloudPushAll()">↑ Enviar tudo para nuvem</button>
<button class="btn btn-block" onclick="cloudPullAll()">↓ Baixar da nuvem</button>
<button class="btn btn-block" onclick="cloudTestAlarm()">Disparar mensagem de teste</button>
<button class="btn btn-block btn-danger" onclick="cloudDisconnect()">Desconectar</button>
</div>
<div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
<div id="auth-box" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--rule)"></div>
</div>
<!-- Google Calendar -->
<div class="export-card" id="gcal-card" style="display:none;border-color:#4285F4;background:linear-gradient(180deg,var(--bg-paper),rgba(66,133,244,.05))">
<div class="export-card-title">📅 Google Agenda · sincronizar pendências</div>
<div class="export-card-text" style="margin-bottom:10px" id="gcal-status">Verificando…</div>
<div id="gcal-actions" style="display:flex;flex-direction:column;gap:6px"></div>
<div id="gcal-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
</div>
<div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))">
<div class="export-card-title">📡 Compartilhar posição em tempo real</div>
<div class="export-card-text" style="margin-bottom:10px">Crie um link público temporário. Sua tripulação vê a posição do Shivao no mapa, sem precisar de login. Requer servidor na nuvem.</div>
<div id="share-active-list" style="margin-bottom:10px"></div>
<div class="field-row">
<div class="field"><label class="field-label">Duração</label><select id="share-duration"><option value="60">1 hora</option><option value="180">3 horas</option><option value="360" selected>6 horas</option><option value="720">12 horas</option><option value="1440">24 horas</option><option value="4320">3 dias</option></select></div>
<div class="field" style="display:flex;align-items:flex-end"><button class="btn btn-block btn-primary" onclick="createShare()">Criar link</button></div>
</div>
<div id="share-result" style="font-family:var(--f-mono);font-size:11px;color:var(--ink-deep);margin-top:6px;letter-spacing:.04em;line-height:1.55"></div>
</div>
<!-- Cartas Náuticas -->
<div class="export-card" id="charts-card">
<div class="export-card-title">⚓ Cartas Náuticas</div>
<div class="export-card-text" style="margin-bottom:10px">Provedor de cartas usado nos mapas (rastreio, fundeio, zonas). OpenSeaMap é grátis e cobre o essencial (sondas, faróis, bóias). Navionics requer chave aprovada pela Garmin.</div>
<div class="field"><label class="field-label">Provedor</label>
<select id="chart-provider" onchange="saveChartCfg()">
<option value="opensea">OpenSeaMap (grátis · padrão)</option>
<option value="navionics">Navionics (requer chave)</option>
<option value="osm">Apenas OSM (sem cartas náuticas)</option>
</select>
</div>
<div class="field"><label class="field-label">Chave Navionics (navKey)</label>
<input type="password" id="chart-nav-key" placeholder="cole a chave após aprovação Garmin" style="font-family:var(--f-mono);font-size:11px" onchange="saveChartCfg()">
<div class="field-hint" style="margin-top:6px">Solicite em <a href="https://www.garmin.com/en-US/forms/navionics-web-api/" target="_blank" rel="noopener" style="color:var(--m-accent,#06b6d4);text-decoration:underline">garmin.com/forms/navionics-web-api</a>. Após aprovação, seu domínio (shivao.pontualtech.work) precisa ser autorizado pela Navionics.</div>
</div>
<button class="btn btn-block" onclick="testChartCfg()">Testar e salvar</button>
<div id="chart-status" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:8px;letter-spacing:.04em;line-height:1.55"></div>
</div>
<!-- Bluetooth & Instrumentos -->
<div class="export-card" id="bluetooth-card">
<div class="export-card-title">📡 Bluetooth · Baterias & Acessórios</div>
<div class="export-card-text" style="margin-bottom:8px">Pareie BMS de bateria de lítio (com BLE), fones, smartwatch, smart shunts. Mostra nível de carga em tempo real no app.</div>
<div id="bt-support" style="font-family:var(--f-mono);font-size:10.5px;letter-spacing:.04em;margin-bottom:10px">Verificando suporte...</div>
<button class="btn btn-block btn-primary" onclick="pairBluetoothDevice()">+ Parear novo dispositivo Bluetooth</button>
<div id="bt-list" style="margin-top:14px"></div>
<details style="margin-top:10px">
<summary style="cursor:pointer;font-family:var(--f-mono);font-size:11px;color:var(--m-text-soft,#7d97ad);letter-spacing:.06em">📋 Diagnóstico (logs do pareamento)</summary>
<div id="bt-diag" style="background:var(--m-bg-2,#0f2a40);border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:6px;padding:10px;margin-top:6px;max-height:200px;overflow-y:auto"></div>
</details>
<div class="field-hint" style="margin-top:8px">Limitações: <strong>iOS Safari não suporta Web Bluetooth</strong>. APK Android usa plugin nativo. BMS proprietários (Victron, JBD) podem aparecer mas não expor Battery Service padrão.</div>
</div>
<!-- Raymarine Gateway -->
<div class="export-card" id="nmea-gateway-card">
<div class="export-card-title">⚓ Instrumentos Raymarine (gateway NMEA 2000)</div>
<div class="export-card-text" style="margin-bottom:10px">Pra ler dados de Raymarine (profundidade, vento, GPS, piloto automático), instale um gateway NMEA 2000→WiFi no barco e conecte ao bus SeaTalkNG. Recomendados: <strong>Yacht Devices YDWG-02</strong>, <strong>Actisense W2K-1</strong>.</div>
<div class="field"><label class="field-label">IP do Gateway</label>
<input type="text" id="nmea-gateway-ip" placeholder="ex: 192.168.4.1" style="font-family:var(--f-mono);font-size:13px">
</div>
<div class="field"><label class="field-label">Porta TCP/UDP</label>
<input type="number" id="nmea-gateway-port" placeholder="ex: 1457 (YDWG default)" style="font-family:var(--f-mono);font-size:13px">
</div>
<button class="btn btn-block" onclick="saveNmeaGatewayCfg()">Salvar configuração</button>
<div id="nmea-gateway-status" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:8px;letter-spacing:.04em;line-height:1.55"></div>
<div class="field-hint" style="margin-top:8px"><strong>Status:</strong> apenas slot de configuração. Parser NMEA 2000 PGNs (depth, wind, AIS, autopilot) será ativado em v1.10 quando você tiver o gateway físico instalado.</div>
</div>
<!-- Export OpenCPN -->
<div class="export-card" id="opencpn-card">
<div class="export-card-title">🗺️ Exportar para OpenCPN</div>
<div class="export-card-text" style="margin-bottom:10px"><a href="https://opencpn.org/" target="_blank" rel="noopener" style="color:var(--m-accent,#06b6d4);text-decoration:underline">OpenCPN</a> é o app de navegação náutica open-source mais usado em PCs/notebooks. Exporte um GPX consolidado com todas suas viagens, fundeios e zonas pra abrir no OpenCPN ou em qualquer plotter compatível.</div>
<button class="btn btn-block btn-primary" onclick="exportOpenCPN()">↓ Baixar GPX completo</button>
<div class="field-hint" style="margin-top:8px">Inclui: tracks de cada travessia · waypoints de fundeios históricos · routes das zonas demarcadas. Compatível também com Garmin, Raymarine, B&G via importação GPX.</div>
</div>
<div class="export-card" style="border-color:var(--ocean);background:linear-gradient(180deg,var(--bg-paper),#dde6ec)">
<div class="export-card-title">🌬 Meteorologia · Windy Point Forecast</div>
<div class="export-card-text" style="margin-bottom:10px">Cole sua chave da <a href="https://api.windy.com/" target="_blank" style="color:var(--ocean);font-style:normal;text-decoration:underline">Windy API</a> para usar dados premium (vento u/v, ondas, modelos GFS/ECMWF). Sem chave, usa Open-Meteo grátis.</div>
<div class="field"><label class="field-label">Chave da API Windy</label><input type="password" id="windy-key" placeholder="cole sua chave aqui" style="font-family:var(--f-mono);font-size:11px"></div>
<div class="field"><label class="field-label">Modelo preferido</label>
<select id="windy-model"><option value="gfs">GFS · global, todos parâmetros</option><option value="iconEu">ICON-EU · Europa, alta resolução</option><option value="nam">NAM · América do Norte</option><option value="namConus">NAM-CONUS · EUA continental</option><option value="namHawaii">NAM-Hawaii</option><option value="namAlaska">NAM-Alaska</option><option value="arome">AROME · França</option><option value="canHrdps">HRDPS · Canadá</option></select>
<div class="field-hint">GFS é o universal. Ondas usam sempre GFS Wave em paralelo.</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<button class="btn btn-block btn-primary" onclick="saveWeatherKey()">Salvar</button>
<button class="btn btn-block" onclick="testWindyKey()">Testar chave</button>
</div>
<div id="windy-status" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:8px;letter-spacing:.04em;line-height:1.55"></div>
</div>
</div>
</div>
<!-- Modal Travessia -->
<div class="modal-backdrop" id="trip-modal">
<div class="modal">
<div class="modal-head"><h3 id="trip-modal-title">Nova Travessia</h3><button class="icon-btn" onclick="closeModal('trip-modal')"></button></div>
<div class="modal-body">
<input type="hidden" id="trip-id"><input type="hidden" id="trip-track-data">
<div class="field"><label class="field-label">Destino &amp; Rota</label><input id="trip-destination" placeholder="ex: Marina → Ilha Grande"></div>
<div class="field-row">
<div class="field"><label class="field-label">Saída</label><input type="date" id="trip-date-start"></div>
<div class="field"><label class="field-label">Chegada</label><input type="date" id="trip-date-end"></div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Horímetro saída</label><input type="number" step="0.1" inputmode="decimal" id="trip-hours-start" placeholder="0.0"></div>
<div class="field"><label class="field-label">Horímetro chegada</label><input type="number" step="0.1" inputmode="decimal" id="trip-hours-end" placeholder="0.0"></div>
</div>
<div class="field"><label class="field-label">Tripulação</label>
<div class="pax-input-row"><input id="pax-input" placeholder="Nome a bordo" onkeydown="if(event.key==='Enter'){event.preventDefault();addPax()}"><button class="btn btn-sm" onclick="addPax()">Anotar</button></div>
<div class="pax-list" id="pax-list"></div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Vento</label><input type="text" id="trip-wind" placeholder="12-18 NE"></div>
<div class="field"><label class="field-label">Distância (mn)</label><input type="number" step="0.1" inputmode="decimal" id="trip-distance"></div>
</div>
<div id="trip-track-info"></div>
<div class="field"><label class="field-label">Notas de bordo</label><textarea id="trip-notes" placeholder="Condições do mar, ocorrências, momentos..."></textarea></div>
<div class="field"><label class="field-label">Mídia &amp; Registros</label>
<div class="media-toolbar">
<label class="media-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 7h4l2-3h6l2 3h4v13H3z"/><circle cx="12" cy="13" r="4"/></svg>Fotografar<input type="file" accept="image/*" capture="environment" onchange="handleMediaUpload(event,'photo','trip')" style="display:none"></label>
<button class="media-btn" onclick="openRecorder('trip')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg>Áudio</button>
<label class="media-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="6" width="13" height="12" rx="1"/><path d="M16 10l5-3v10l-5-3z"/></svg>Vídeo<input type="file" accept="video/*" capture="environment" onchange="handleMediaUpload(event,'video','trip')" style="display:none"></label>
</div>
<div class="modal-media-grid" id="trip-media-grid"></div>
</div>
</div>
<div class="modal-foot"><button class="btn" onclick="closeModal('trip-modal')">Cancelar</button><button class="btn btn-primary" onclick="saveTrip()">Lançar no diário</button></div>
</div>
</div>
<!-- Modal Reparo -->
<div class="modal-backdrop" id="maint-modal">
<div class="modal">
<div class="modal-head"><h3 id="maint-modal-title">Novo Reparo</h3><button class="icon-btn" onclick="closeModal('maint-modal')"></button></div>
<div class="modal-body">
<input type="hidden" id="maint-id"><input type="hidden" id="maint-from-pending">
<div class="field"><label class="field-label">Serviço realizado</label><input id="maint-title" placeholder="ex: Troca de óleo do motor"></div>
<div class="field-row">
<div class="field"><label class="field-label">Data</label><input type="date" id="maint-date"></div>
<div class="field"><label class="field-label">Horímetro</label><input type="number" step="0.1" inputmode="decimal" id="maint-hours"></div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Categoria</label><select id="maint-category"><option value="motor">Motor</option><option value="velas">Velas &amp; Cordames</option><option value="casco">Casco &amp; Pintura</option><option value="eletrica">Elétrica</option><option value="eletronica">Eletrônica</option><option value="seguranca">Segurança</option><option value="outros">Outros</option></select></div>
<div class="field"><label class="field-label">Custo</label><input type="number" step="0.01" inputmode="decimal" id="maint-cost" placeholder="R$"></div>
</div>
<div class="field"><label class="field-label">Estaleiro / Prestador</label><input id="maint-vendor" placeholder="ex: Estaleiro Costa Verde"></div>
<div class="field"><label class="field-label">Detalhes &amp; peças</label><textarea id="maint-notes" placeholder="Peças trocadas, marcas, próxima revisão..."></textarea></div>
<div class="field"><label class="field-label">Comprovantes &amp; mídia</label>
<div class="media-toolbar">
<label class="media-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 7h4l2-3h6l2 3h4v13H3z"/><circle cx="12" cy="13" r="4"/></svg>Fotografar<input type="file" accept="image/*" capture="environment" onchange="handleMediaUpload(event,'photo','maint')" style="display:none"></label>
<button class="media-btn" onclick="openRecorder('maint')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg>Áudio</button>
<label class="media-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="6" width="13" height="12" rx="1"/><path d="M16 10l5-3v10l-5-3z"/></svg>Vídeo<input type="file" accept="video/*" capture="environment" onchange="handleMediaUpload(event,'video','maint')" style="display:none"></label>
</div>
<div class="modal-media-grid" id="maint-media-grid"></div>
</div>
</div>
<div class="modal-foot"><button class="btn" onclick="closeModal('maint-modal')">Cancelar</button><button class="btn btn-primary" onclick="saveMaint()">Lançar no diário</button></div>
</div>
</div>
<!-- Modal Pendência -->
<div class="modal-backdrop" id="pending-modal">
<div class="modal">
<div class="modal-head"><h3 id="pending-modal-title">Nova Pendência</h3><button class="icon-btn" onclick="closeModal('pending-modal')"></button></div>
<div class="modal-body">
<input type="hidden" id="pending-id">
<div class="field"><label class="field-label">O que precisa fazer</label><input id="pending-title" placeholder="ex: Trocar bateria"></div>
<div class="field"><label class="field-label">Categoria</label><select id="pending-category"><option value="motor">Motor</option><option value="velas">Velas &amp; Cordames</option><option value="casco">Casco &amp; Pintura</option><option value="eletrica">Elétrica</option><option value="eletronica">Eletrônica</option><option value="seguranca">Segurança</option><option value="outros">Outros</option></select></div>
<div class="field-row">
<div class="field"><label class="field-label">Data prevista</label><input type="date" id="pending-due-date"><div class="field-hint">opcional</div></div>
<div class="field"><label class="field-label">Ao chegar em (h)</label><input type="number" step="0.1" inputmode="decimal" id="pending-due-hours" placeholder="ex: 250"><div class="field-hint">horímetro alvo</div></div>
</div>
<div class="field"><label class="field-label">Custo estimado</label><input type="number" step="0.01" inputmode="decimal" id="pending-est-cost" placeholder="R$"></div>
<div class="field"><label class="field-label">Prioridade</label>
<div class="priority-row">
<label><input type="radio" name="pending-priority" value="low"><span>Baixa</span></label>
<label><input type="radio" name="pending-priority" value="normal" checked><span>Normal</span></label>
<label><input type="radio" name="pending-priority" value="high"><span>Alta</span></label>
</div>
</div>
<div class="field"><label class="field-label">Detalhes</label><textarea id="pending-notes" placeholder="Especificações, fornecedores, links..."></textarea></div>
</div>
<div class="modal-foot"><button class="btn" onclick="closeModal('pending-modal')">Cancelar</button><button class="btn btn-primary" onclick="savePending()">Anotar</button></div>
</div>
</div>
<!-- Recorder -->
<div class="modal-backdrop" id="recorder-modal">
<div class="modal recorder-modal">
<div class="recorder-body">
<button class="rec-circle" id="rec-button" onclick="toggleRecording()"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg></button>
<div class="rec-time" id="rec-time">00:00</div>
<div class="rec-status" id="rec-status">toque para gravar</div>
<div style="display:flex;gap:8px;justify-content:center">
<button class="btn" onclick="cancelRecording()">Cancelar</button>
<button class="btn btn-primary" id="rec-save" onclick="saveRecording()" style="display:none">Salvar</button>
</div>
</div>
</div>
</div>
<!-- Tracking -->
<div class="modal-backdrop" id="tracking-modal" style="align-items:stretch;padding:0">
<div class="modal tracking-modal">
<div class="tracking-head">
<h3><span class="live-dot"></span>Rastreio em curso</h3>
<button class="icon-btn" onclick="minimizeTracking()" style="color:var(--brass-bright)"></button>
</div>
<div class="tracking-stats">
<div><div class="t-stat-label">Velocidade</div><div class="t-stat-value"><span id="t-speed">0.0</span><span class="t-stat-unit">nós</span></div></div>
<div><div class="t-stat-label">Distância</div><div class="t-stat-value"><span id="t-distance">0.00</span><span class="t-stat-unit">mn</span></div></div>
<div><div class="t-stat-label">Tempo</div><div class="t-stat-value" id="t-duration">00:00</div></div>
<div><div class="t-stat-label">Vel. máxima</div><div class="t-stat-value"><span id="t-maxspeed">0.0</span><span class="t-stat-unit">nós</span></div></div>
</div>
<div class="tracking-map" id="tracking-map"></div>
<div class="tracking-foot">
<button class="btn" onclick="minimizeTracking()">Minimizar</button>
<button class="btn btn-danger" onclick="stopTracking()">Parar &amp; Salvar</button>
</div>
</div>
</div>
<!-- Map Viewer -->
<div class="modal-backdrop" id="map-modal">
<div class="modal">
<div class="modal-head"><h3>Carta da travessia</h3><button class="icon-btn" onclick="closeMapModal()"></button></div>
<div class="modal-body">
<div id="map-modal-content"></div>
<div class="map-stats" id="map-modal-stats"></div>
</div>
<div class="modal-foot"><button class="btn" onclick="closeMapModal()">Fechar</button></div>
</div>
</div>
<!-- Media Viewer -->
<div class="viewer-backdrop" id="viewer-modal" onclick="if(event.target===this)closeViewer()">
<button class="viewer-close" onclick="closeViewer()"></button>
<div class="viewer-content" id="viewer-content"></div>
<div class="viewer-actions">
<button class="btn" onclick="downloadCurrentMedia()">Baixar</button>
<button class="btn btn-danger" onclick="deleteCurrentMedia()">Apagar</button>
</div>
</div>
<!-- Anchor Setup Modal -->
<div class="modal-backdrop" id="anchor-setup-modal">
<div class="modal">
<div class="modal-head"><h3 id="anchor-modal-title">Fundear</h3><button class="icon-btn" onclick="closeModal('anchor-setup-modal')"></button></div>
<div class="modal-body">
<div id="anchor-setup-status" style="font-family:var(--f-display);font-style:italic;font-size:14px;color:var(--sepia);margin-bottom:14px;text-align:center;padding:16px;background:var(--bg-canvas);border:1px dashed var(--rule)">Aguardando posição do GPS…</div>
<div class="field"><label class="field-label">Posição registrada</label>
<div id="anchor-coord" style="font-family:var(--f-mono);font-size:13px;color:var(--ink-deep);padding:10px 12px;background:var(--bg-canvas);border:1px solid var(--rule)"></div>
</div>
<!-- Anchor Calculator -->
<div class="anchor-calc">
<div class="anchor-calc-head">⚓ Calculadora de fundeio</div>
<div class="field-row">
<div class="field"><label class="field-label">Profundidade <span id="anchor-depth-unit">(m)</span></label>
<input type="number" id="anchor-depth" step="0.5" min="0" placeholder="0.0" oninput="recalcAnchor()">
</div>
<div class="field"><label class="field-label">Amarra lançada <span id="anchor-chain-unit">(m)</span></label>
<input type="number" id="anchor-chain" step="1" min="0" placeholder="0" oninput="recalcAnchor()">
</div>
</div>
<div class="field"><label class="field-label">Vento atual (nós) <span id="anchor-wind-source" style="font-weight:400;color:var(--sepia);text-transform:none;letter-spacing:0">manual</span></label>
<input type="number" id="anchor-wind" step="1" min="0" placeholder="ex: 12" oninput="recalcAnchor()">
</div>
<div id="anchor-calc-result" class="anchor-calc-result"></div>
<button type="button" class="btn btn-block btn-sm" onclick="applyRecommendedRadius()" id="anchor-apply-recommend" style="display:none;margin-top:8px">⚓ Aplicar raio sugerido</button>
</div>
<div class="field">
<label class="field-label">Raio de segurança (alarme)</label>
<div class="anchor-radius-row">
<input type="range" id="anchor-radius" min="15" max="200" step="5" value="50" oninput="updateRadiusLabel(this.value)">
<span class="anchor-radius-val" id="anchor-radius-val">50 m</span>
</div>
<div class="field-hint">Distância máxima da âncora antes de disparar o alarme. Use a calculadora acima pra estimar.</div>
</div>
<div class="field"><label class="field-label">Contatos de emergência</label>
<div id="anchor-contacts-summary" style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);padding:8px 0">Nenhum contato configurado</div>
<button class="btn btn-block btn-sm" onclick="openContactsModal()">Configurar contatos</button>
</div>
<div style="font-family:var(--f-display);font-style:italic;font-size:13px;color:var(--sepia);line-height:1.55;padding:10px 12px;background:var(--sun-soft);border-left:2px solid var(--sun);margin-top:14px">
Mantenha o app aberto e o celular conectado à energia. O GPS pode ser pausado pelo Android se a tela for desligada por muito tempo.
</div>
</div>
<div class="modal-foot">
<button class="btn" onclick="closeModal('anchor-setup-modal')">Cancelar</button>
<button class="btn btn-primary" id="anchor-confirm-btn" onclick="confirmAnchor()" disabled>Iniciar vigia</button>
</div>
</div>
</div>
<!-- Anchor Watch Fullscreen -->
<div class="modal-backdrop" id="anchor-watch-modal" style="align-items:stretch;padding:0">
<div class="modal anchor-modal">
<div class="anchor-head">
<h3 id="anchor-head-title">Vigia de Fundeio</h3>
<button class="icon-btn" onclick="minimizeAnchor()" style="color:var(--brass-bright)"></button>
</div>
<div class="anchor-stats-bar">
<div class="anchor-stat"><div class="anchor-stat-lbl">Distância</div><div class="anchor-stat-val" id="aw-distance">0<span class="anchor-stat-unit">m</span></div></div>
<div class="anchor-stat"><div class="anchor-stat-lbl">Raio</div><div class="anchor-stat-val" id="aw-radius">50<span class="anchor-stat-unit">m</span></div></div>
<div class="anchor-stat"><div class="anchor-stat-lbl">Tempo</div><div class="anchor-stat-val" id="aw-duration">00:00</div></div>
</div>
<div class="anchor-map" id="anchor-map"></div>
<div class="anchor-foot">
<div class="anchor-radius-row">
<label>Raio</label>
<input type="range" id="aw-radius-slider" min="15" max="200" step="5" value="50" oninput="adjustAnchorRadius(this.value)">
<span class="anchor-radius-val" id="aw-radius-slider-val">50 m</span>
</div>
<div class="anchor-foot-row">
<button class="btn btn-sm" onclick="recenterSwing()" title="Boat aqui é o novo centro de giro">⊙ Recentrar</button>
<button class="btn btn-sm" id="auto-recenter-btn" onclick="toggleAutoRecenter()">Auto: off</button>
<button class="btn btn-sm" onclick="testAlarmSound()">Testar alarme</button>
</div>
<div class="anchor-foot-row">
<button class="btn" onclick="minimizeAnchor()">Minimizar</button>
<button class="btn btn-danger" onclick="stopAnchorWatch()">Parar vigia</button>
</div>
</div>
</div>
</div>
<!-- Alarm Screen -->
<div class="alarm-backdrop" id="alarm-screen">
<svg class="alarm-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 L22 20 H2 Z"/><line x1="12" y1="9" x2="12" y2="14"/><circle cx="12" cy="17.5" r="1" fill="currentColor"/></svg>
<div class="alarm-title" id="alarm-title">Shivao derivando!</div>
<div class="alarm-sub">Saiu da posição de fundeio</div>
<div class="alarm-distance" id="alarm-distance">0<span style="font-size:24px">m</span></div>
<div class="alarm-distance-lbl">distância da âncora</div>
<div class="alarm-actions" id="alarm-actions"></div>
<button class="alarm-dismiss" onclick="dismissAlarm()">Cancelar alarme</button>
</div>
<!-- Contacts Modal -->
<div class="modal-backdrop" id="contacts-modal">
<div class="modal">
<div class="modal-head"><h3>Contatos de emergência</h3><button class="icon-btn" onclick="closeModal('contacts-modal')"></button></div>
<div class="modal-body">
<div id="contacts-list"></div>
<div style="border-top:1px solid var(--rule-soft);padding-top:14px;margin-top:14px">
<div class="field"><label class="field-label">Nome</label><input id="contact-name" placeholder="ex: Maria (esposa)"></div>
<div class="field"><label class="field-label">Telefone com DDI</label><input id="contact-phone" placeholder="ex: 5521999998888" inputmode="tel"><div class="field-hint">só números, com código do país (55 = Brasil)</div></div>
<div class="field"><label class="field-label">E-mail (opcional)</label><input id="contact-email" type="email" placeholder="contato@email.com"></div>
<div class="field"><label class="field-label">Canais de envio</label>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">
<label style="display:flex;align-items:center;gap:6px;padding:8px;border:1px solid var(--rule);font-family:var(--f-mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;cursor:pointer"><input type="checkbox" id="contact-ch-wa" checked>WhatsApp</label>
<label style="display:flex;align-items:center;gap:6px;padding:8px;border:1px solid var(--rule);font-family:var(--f-mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;cursor:pointer"><input type="checkbox" id="contact-ch-sms" checked>SMS</label>
<label style="display:flex;align-items:center;gap:6px;padding:8px;border:1px solid var(--rule);font-family:var(--f-mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;cursor:pointer"><input type="checkbox" id="contact-ch-mail">E-mail</label>
</div>
</div>
<button class="btn btn-primary btn-block" onclick="addContact()">Adicionar contato</button>
</div>
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--rule-soft)">
<label class="field-label">Mensagem do alerta</label>
<textarea id="alarm-message-template" style="width:100%;padding:10px 12px;background:var(--bg-canvas);border:1px solid var(--rule);font-family:var(--f-display);font-style:italic;font-size:14px;min-height:90px;margin-top:5px" placeholder="Mensagem padrão...">🚨 ALERTA: Shivao saiu da posição de fundeio.
Estou em {LAT}, {LNG}
Mapa: https://maps.google.com/?q={LAT},{LNG}
Hora: {HORA}</textarea>
<div class="field-hint">Use {LAT}, {LNG}, {HORA}, {DIST}, {NOME} como variáveis.</div>
</div>
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--rule-soft)">
<div style="font-family:var(--f-display);font-style:italic;font-weight:500;font-size:16px;color:var(--ink-deep);margin-bottom:4px">Bots &amp; Webhooks · envio automático</div>
<div style="font-family:var(--f-display);font-style:italic;font-size:13px;color:var(--sepia);line-height:1.55;margin-bottom:14px">Disparam imediatamente quando o alarme toca. Sem necessidade de tocar em botão.</div>
<div class="field"><label class="field-label">Telegram · token do bot</label>
<input id="wh-tg-token" type="password" placeholder="123456:ABC-DEF..." style="font-family:var(--f-mono);font-size:12px"></div>
<div class="field"><label class="field-label">Telegram · chat IDs (vírgula)</label>
<input id="wh-tg-chats" placeholder="ex: 123456789, -100123456" style="font-family:var(--f-mono);font-size:12px">
<div class="field-hint">Crie bot em @BotFather, envie /start, depois acesse api.telegram.org/bot&lt;TOKEN&gt;/getUpdates para ver os IDs.</div>
</div>
<div class="field"><label class="field-label">Discord · webhook URL</label>
<input id="wh-discord" type="url" placeholder="https://discord.com/api/webhooks/..." style="font-family:var(--f-mono);font-size:11px">
<div class="field-hint">No Discord: editar canal → Integrações → Webhooks → Novo webhook → copiar URL.</div>
</div>
<div class="field"><label class="field-label">Webhook genérico · URL</label>
<input id="wh-generic" type="url" placeholder="https://..." style="font-family:var(--f-mono);font-size:11px">
<div class="field-hint">Para n8n, IFTTT, Zapier, ntfy.sh, etc. Recebe POST JSON.</div>
</div>
<button class="btn btn-block btn-sm" onclick="testWebhooks()">Disparar teste nos webhooks</button>
<div id="webhook-test-result" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:8px;letter-spacing:.04em;line-height:1.55"></div>
</div>
</div>
<div class="modal-foot">
<button class="btn btn-primary" onclick="saveContactsConfig()">Salvar</button>
</div>
</div>
</div>
<!-- Anchor History Modal -->
<div class="modal-backdrop" id="anchor-history-modal">
<div class="modal">
<div class="modal-head"><h3>Histórico de Fundeios</h3><button class="icon-btn" onclick="closeModal('anchor-history-modal')"></button></div>
<div class="modal-body" id="anchor-history-body"></div>
<div class="modal-foot"><button class="btn" onclick="closeModal('anchor-history-modal')">Fechar</button></div>
</div>
</div>
<!-- Anchor History Map Modal -->
<div class="modal-backdrop" id="anchor-history-map-modal">
<div class="modal">
<div class="modal-head"><h3 id="ahm-title">Fundeio</h3><button class="icon-btn" onclick="closeAnchorHistoryMap()"></button></div>
<div class="modal-body">
<div id="anchor-history-map" style="height:55vh;min-height:280px;background:var(--bg-aged);border:1px solid var(--rule)"></div>
<div class="map-stats" id="ahm-stats"></div>
</div>
<div class="modal-foot"><button class="btn" onclick="closeAnchorHistoryMap()">Fechar</button></div>
</div>
</div>
<!-- Checklists Modal -->
<div class="modal-backdrop" id="checklists-modal">
<div class="modal">
<div class="modal-head"><h3 id="checklists-modal-title">Checklists de Bordo</h3><button class="icon-btn" onclick="closeModal('checklists-modal')"></button></div>
<div class="modal-body" id="checklists-body"></div>
<div class="modal-foot"><button class="btn" onclick="closeModal('checklists-modal')">Fechar</button></div>
</div>
</div>
<!-- Run Checklist Modal -->
<div class="modal-backdrop" id="run-checklist-modal">
<div class="modal">
<div class="modal-head"><h3 id="run-checklist-title">Checklist</h3><button class="icon-btn" onclick="closeModal('run-checklist-modal')"></button></div>
<div class="modal-body" id="run-checklist-body"></div>
<div class="modal-foot">
<div id="run-checklist-progress" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);letter-spacing:.04em;margin-right:auto;align-self:center"></div>
<button class="btn" onclick="closeModal('run-checklist-modal')">Fechar</button>
<button class="btn btn-primary" onclick="resetCurrentChecklist()">Limpar</button>
</div>
</div>
</div>
<!-- Zone Editor Modal -->
<div class="modal-backdrop" id="zone-edit-modal" style="align-items:stretch;padding:0">
<div class="modal anchor-modal">
<div class="anchor-head">
<h3 id="zone-edit-title">Nova Zona</h3>
<button class="icon-btn" onclick="closeZoneEditor()" style="color:var(--brass-bright)"></button>
</div>
<div style="background:var(--ink-deep);color:var(--bg-paper);padding:10px 18px;font-family:var(--f-mono);font-size:11px;letter-spacing:.05em" id="zone-edit-help">Toque no mapa para marcar o centro da zona</div>
<div class="anchor-map" id="zone-map"></div>
<div class="anchor-foot">
<div class="anchor-radius-row">
<label>Raio</label>
<input type="range" id="zone-radius" min="20" max="2000" step="10" value="100" oninput="updateZoneRadius(this.value)">
<span class="anchor-radius-val" id="zone-radius-val">100 m</span>
</div>
<div class="field-row">
<div class="field" style="margin:0"><label class="field-label">Nome</label><input id="zone-name" placeholder="ex: Pedras submersas"></div>
<div class="field" style="margin:0"><label class="field-label">Tipo</label><select id="zone-type"><option value="forbidden">⛔ Proibida</option><option value="attention">⚠️ Atenção</option></select></div>
</div>
<div class="anchor-foot-row">
<button class="btn" onclick="closeZoneEditor()">Cancelar</button>
<button class="btn btn-danger" id="zone-delete-btn" onclick="deleteZone()" style="display:none">Apagar</button>
<button class="btn btn-primary" onclick="saveZone()">Salvar</button>
</div>
</div>
</div>
</div>
<button class="fab" id="fab" onclick="quickAdd()" title="Adicionar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
<!-- Bottom Navigation (substitui top tabs) -->
<nav class="bottom-nav" id="bottom-nav">
<button class="bn-item active" data-panel="overview" onclick="switchPanel('overview')" aria-label="Início">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12l9-9 9 9"/><path d="M5 10v10h14V10"/><path d="M9 20v-6h6v6"/></svg>
<span>Início</span>
</button>
<button class="bn-item" data-panel="trips" onclick="switchPanel('trips')" aria-label="Travessias">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M2 17.5L12 22l10-4.5"/><path d="M2 12.5L12 17l10-4.5"/><path d="M12 2L2 7l10 5 10-5z"/></svg>
<span>Travessias</span>
</button>
<button class="bn-item" data-panel="pending" onclick="switchPanel('pending')" aria-label="Pendências">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
<span>Pendências</span>
<span class="bn-badge" id="bn-badge-pending" style="display:none">0</span>
</button>
<button class="bn-item" data-panel="zones" onclick="switchPanel('zones')" aria-label="Zonas">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
<span>Zonas</span>
</button>
<button class="bn-item" data-panel="export" onclick="switchPanel('export')" aria-label="Mais">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
<span>Mais</span>
</button>
</nav>
<!-- Fleet Manager Modal -->
<div class="modal-backdrop" id="fleet-modal" onclick="if(event.target===this)closeModal('fleet-modal')">
<div class="modal" style="max-width:520px">
<div class="modal-head">
<h3>⚓ Minha Frota</h3>
<button class="icon-btn" onclick="closeModal('fleet-modal')"></button>
</div>
<div class="modal-body">
<div id="fleet-list" class="fleet-list"></div>
<div class="fleet-units-row">
<span class="fleet-units-label">Unidades</span>
<div class="fleet-units-toggle" id="fleet-units-toggle">
<button type="button" data-unit="metric" onclick="setUnits('metric')">Metros</button>
<button type="button" data-unit="imperial" onclick="setUnits('imperial')">Pés</button>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn" onclick="closeModal('fleet-modal')">Fechar</button>
<button class="btn btn-primary" onclick="openBoatEditor(null)">+ Nova embarcação</button>
</div>
</div>
</div>
<!-- Boat Editor Modal -->
<div class="modal-backdrop" id="boat-editor-modal" onclick="if(event.target===this)closeModal('boat-editor-modal')">
<div class="modal" style="max-width:480px">
<div class="modal-head">
<h3 id="boat-editor-title">Nova embarcação</h3>
<button class="icon-btn" onclick="closeModal('boat-editor-modal')"></button>
</div>
<div class="modal-body">
<form id="boat-editor-form" onsubmit="saveBoatFromForm(event)">
<input type="hidden" id="boat-edit-id">
<input type="hidden" id="boat-edit-photo-id">
<div class="field">
<label class="field-label">Foto da embarcação</label>
<div class="boat-photo-row">
<div class="boat-photo-preview" id="boat-photo-preview">
<span class="boat-photo-placeholder"></span>
</div>
<div class="boat-photo-actions">
<label class="btn btn-sm btn-block" style="cursor:pointer;display:block">
📷 Tirar foto
<input type="file" accept="image/*" capture="environment" onchange="handleBoatPhoto(event)" style="display:none">
</label>
<label class="btn btn-sm btn-block" style="cursor:pointer;display:block;margin-top:6px">
🖼 Da galeria
<input type="file" accept="image/*" onchange="handleBoatPhoto(event)" style="display:none">
</label>
<button type="button" class="btn btn-sm btn-danger btn-block" onclick="clearBoatPhoto()" id="boat-photo-clear" style="margin-top:6px;display:none">Remover foto</button>
</div>
</div>
</div>
<div class="field"><label class="field-label">Nome</label>
<input type="text" id="boat-edit-name" required maxlength="40" placeholder="ex: Shivao, Maré Alta">
</div>
<div class="field"><label class="field-label">Tipo de embarcação</label>
<select id="boat-edit-type">
<option value="sailing">⛵ Veleiro</option>
<option value="motor">🛥️ Lancha / Motor</option>
<option value="catamaran">🚤 Catamarã</option>
<option value="rib">🚣 Bote / RIB</option>
<option value="other">🛳️ Outro</option>
</select>
</div>
<div class="field"><label class="field-label">Modelo / Classe (opcional)</label>
<input type="text" id="boat-edit-model" maxlength="48" placeholder="Beneteau 36, Delta 28...">
</div>
<div class="field-row">
<div class="field"><label class="field-label">Comprimento <span id="boat-len-unit">(m)</span></label>
<input type="number" id="boat-edit-length" step="0.1" min="0" placeholder="0.0">
</div>
<div class="field"><label class="field-label">Boca / Largura <span id="boat-beam-unit">(m)</span></label>
<input type="number" id="boat-edit-beam" step="0.1" min="0" placeholder="0.0">
</div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Calado <span id="boat-draft-unit">(m)</span></label>
<input type="number" id="boat-edit-draft" step="0.1" min="0" placeholder="profundidade casco">
</div>
<div class="field"><label class="field-label">Amarra a bordo <span id="boat-chain-unit">(m)</span></label>
<input type="number" id="boat-edit-chain" step="1" min="0" placeholder="total disponível">
</div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Ano (opcional)</label>
<input type="number" id="boat-edit-year" min="1900" max="2100" placeholder="ex: 2018">
</div>
<div class="field"><label class="field-label">Data de cadastro</label>
<input type="date" id="boat-edit-registered-at">
</div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Horímetro inicial</label>
<input type="number" id="boat-edit-engine-hours-initial" step="0.1" min="0" placeholder="ex: 1240.5">
<div class="field-hint" style="margin-top:4px">Horas do motor no dia que começou a registrar.</div>
</div>
<div class="field"><label class="field-label">Matrícula / TIE</label>
<input type="text" id="boat-edit-registration" maxlength="32" placeholder="ex: SP-2348-CT">
</div>
</div>
<div class="field"><label class="field-label">Notas (opcional)</label>
<textarea id="boat-edit-notes" maxlength="500" rows="2" placeholder="Marina, observações, manutenção pendente..."></textarea>
</div>
<div class="field-hint">⚓ Comprimento + calado são usados pra calcular raio de giro no fundeio.</div>
</form>
</div>
<div class="modal-foot">
<button class="btn btn-danger" id="boat-edit-delete" onclick="deleteBoatFromEditor()" style="display:none">Excluir</button>
<button class="btn" onclick="closeModal('boat-editor-modal')">Cancelar</button>
<button class="btn btn-primary" onclick="document.getElementById('boat-editor-form').requestSubmit()">Salvar</button>
</div>
</div>
</div>
<!-- Welcome / Login Screen -->
<div class="welcome-screen" id="welcome-screen" style="display:none">
<div class="welcome-card">
<div class="welcome-logo"></div>
<h1 class="welcome-title">Shivão · Diário de Bordo</h1>
<p class="welcome-tagline">Sua viagem, suas fotos, sua âncora — em qualquer dispositivo.</p>
<div class="welcome-buttons">
<button class="welcome-btn welcome-btn-google" id="welcome-google" onclick="welcomeGoogleClick()">
<svg viewBox="0 0 18 18" width="18" height="18"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/></svg>
<span>Entrar com Google</span>
</button>
<button class="welcome-btn welcome-btn-email" onclick="welcomeShowEmail()">
<span style="font-size:18px">✉️</span>
<span>Continuar com email</span>
</button>
<button class="welcome-btn welcome-btn-text" onclick="welcomeShowAdvanced()">
⚙️ Configurar servidor próprio (avançado)
</button>
</div>
<!-- Email form (hidden initially) -->
<div id="welcome-email-form" style="display:none;margin-top:18px">
<div class="welcome-tabs">
<button class="welcome-tab active" id="we-tab-login" onclick="welcomeSwitchTab('login')">Entrar</button>
<button class="welcome-tab" id="we-tab-signup" onclick="welcomeSwitchTab('signup')">Criar conta</button>
</div>
<div class="field"><input type="email" id="we-email" placeholder="seu@email.com" autocomplete="email"></div>
<div class="field" id="we-name-field" style="display:none"><input type="text" id="we-name" placeholder="Seu nome" autocomplete="name"></div>
<div class="field"><input type="password" id="we-pwd" placeholder="senha (mín 8 caracteres)" autocomplete="current-password"></div>
<button class="welcome-btn welcome-btn-primary" id="we-submit" onclick="welcomeEmailSubmit()">Entrar</button>
<div id="we-msg" style="margin-top:8px;font-size:12px;color:var(--storm);text-align:center"></div>
<button class="welcome-btn welcome-btn-text" onclick="welcomeBack()" style="margin-top:8px">← Voltar</button>
</div>
<!-- Advanced (server URL/token) form -->
<div id="welcome-advanced-form" style="display:none;margin-top:18px">
<p style="font-size:12px;color:var(--sepia);text-align:center;margin-bottom:12px">Pra usar com seu próprio servidor Shivão self-hosted.</p>
<div class="field"><input type="url" id="we-srv-url" placeholder="https://seu-shivao.com"></div>
<div class="field"><input type="password" id="we-srv-token" placeholder="BOAT_TOKEN do servidor"></div>
<button class="welcome-btn welcome-btn-primary" onclick="welcomeAdvancedSubmit()">Conectar</button>
<div id="we-srv-msg" style="margin-top:8px;font-size:12px;color:var(--storm);text-align:center"></div>
<button class="welcome-btn welcome-btn-text" onclick="welcomeBack()" style="margin-top:8px">← Voltar</button>
</div>
<button class="welcome-btn welcome-btn-text" onclick="welcomeSkip()" style="margin-top:18px;font-size:13px;opacity:.85">⊘ Usar sem login (modo offline)</button>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="zone-toast" id="zone-toast"></div>
<script>
const state={boat:{name:'Shivao',model:''},boats:[],activeBoatId:null,units:'metric',trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
// ============ NAUTICAL HELPERS (multi-boat + fleet + calculations) ============
// Storage interno SEMPRE em metros (ISO). Conversão só pra display.
const FT_PER_M=3.28084;
const M_PER_FT=0.3048;
function isImperial(){return state.units==='imperial'}
function lengthUnit(){return isImperial()?'ft':'m'}
// Converte storage (sempre metros) → display
function lenToDisplay(meters){if(meters==null||isNaN(meters))return null;return isImperial()?meters*FT_PER_M:meters}
// Converte display (que usuário digitou) → storage (metros)
function lenFromInput(value){if(value==null||value==='')return null;const n=parseFloat(value);if(isNaN(n))return null;return isImperial()?n*M_PER_FT:n}
function fmtLen(meters,decimals=1){if(meters==null||isNaN(meters))return '—';return lenToDisplay(meters).toFixed(decimals)+' '+lengthUnit()}
// Tipos de embarcação com defaults sensatos
const BOAT_TYPES={
sailing:{label:'Veleiro',icon:'⛵',defaultBeamRatio:0.30,defaultDraftRatio:0.18,scopeBonus:0},
motor:{label:'Motor',icon:'🛥️',defaultBeamRatio:0.32,defaultDraftRatio:0.10,scopeBonus:-0.5},
catamaran:{label:'Catamarã',icon:'🚤',defaultBeamRatio:0.55,defaultDraftRatio:0.08,scopeBonus:-0.5},
rib:{label:'Bote/RIB',icon:'🚣',defaultBeamRatio:0.35,defaultDraftRatio:0.06,scopeBonus:-1},
other:{label:'Outro',icon:'🛳️',defaultBeamRatio:0.28,defaultDraftRatio:0.15,scopeBonus:0},
};
// Migration: state.boat (antigo) → state.boats[0] (novo)
function migrateBoatsSchema(){
if(!state.boats||!Array.isArray(state.boats))state.boats=[];
if(state.boats.length===0&&state.boat&&state.boat.name){
const b={
id:uid(),
name:state.boat.name||'Shivao',
model:state.boat.model||'',
type:'sailing',
length:null,beam:null,draft:null,
chainTotal:null,
year:null,
photoId:null,
engineHoursInitial:null,
registeredAt:null,
registrationNumber:'',
notes:'',
createdAt:Date.now(),
};
state.boats.push(b);
state.activeBoatId=b.id;
}
// Garante campos novos em barcos existentes (forward-compat)
state.boats.forEach(b=>{
if(!('photoId' in b))b.photoId=null;
if(!('engineHoursInitial' in b))b.engineHoursInitial=null;
if(!('registeredAt' in b))b.registeredAt=null;
if(!('registrationNumber' in b))b.registrationNumber='';
if(!('notes' in b))b.notes='';
});
if(!state.activeBoatId&&state.boats[0])state.activeBoatId=state.boats[0].id;
if(!state.units)state.units='metric';
}
function activeBoat(){
return state.boats.find(b=>b.id===state.activeBoatId)||state.boats[0]||null;
}
function setActiveBoat(id){
state.activeBoatId=id;
saveState();
bindHeader();
renderAll();
}
function addBoat(data){
const b={
id:uid(),
name:data.name||'Embarcação',
model:data.model||'',
type:data.type||'sailing',
length:lenFromInput(data.length),
beam:lenFromInput(data.beam),
draft:lenFromInput(data.draft),
chainTotal:lenFromInput(data.chainTotal),
year:data.year?parseInt(data.year):null,
photoId:data.photoId||null,
engineHoursInitial:data.engineHoursInitial?parseFloat(data.engineHoursInitial):null,
registeredAt:data.registeredAt||null,
registrationNumber:data.registrationNumber||'',
notes:data.notes||'',
createdAt:Date.now(),
};
state.boats.push(b);
if(!state.activeBoatId)state.activeBoatId=b.id;
saveState();
return b;
}
function updateBoat(id,data){
const b=state.boats.find(x=>x.id===id);
if(!b)return;
if('name' in data)b.name=data.name;
if('model' in data)b.model=data.model;
if('type' in data)b.type=data.type;
if('length' in data)b.length=lenFromInput(data.length);
if('beam' in data)b.beam=lenFromInput(data.beam);
if('draft' in data)b.draft=lenFromInput(data.draft);
if('chainTotal' in data)b.chainTotal=lenFromInput(data.chainTotal);
if('year' in data)b.year=data.year?parseInt(data.year):null;
if('photoId' in data)b.photoId=data.photoId;
if('engineHoursInitial' in data)b.engineHoursInitial=data.engineHoursInitial?parseFloat(data.engineHoursInitial):null;
if('registeredAt' in data)b.registeredAt=data.registeredAt||null;
if('registrationNumber' in data)b.registrationNumber=data.registrationNumber||'';
if('notes' in data)b.notes=data.notes||'';
saveState();
}
function removeBoat(id){
if(state.boats.length<=1){toast('Mantenha pelo menos 1 embarcação');return}
const b=state.boats.find(x=>x.id===id);
// Limpa foto do IndexedDB se houver
if(b?.photoId){dbDelete(b.photoId).catch(()=>{})}
state.boats=state.boats.filter(b=>b.id!==id);
if(state.activeBoatId===id)state.activeBoatId=state.boats[0]?.id||null;
saveState();
}
// ============ ANCHOR CALCULATIONS (regras náuticas) ============
// Scope ratio = comprimento_amarra / profundidade
// Recomendado: 5:1 normal, 7:1 vento moderado (15-25kn), 10:1 tempestade (>25kn)
function scopeRatio(chainDeployedM,depthM){
if(!depthM||depthM<=0)return null;
return chainDeployedM/depthM;
}
// Raio efetivo de giro = √(amarra² profundidade²) + comprimento_do_barco
// Esse é o círculo dentro do qual a popa do barco pode oscilar com a corrente/vento
function calcSwingRadius(chainDeployedM,depthM,boatLengthM){
if(!chainDeployedM||!depthM||chainDeployedM<=depthM)return null;
const horizontal=Math.sqrt(chainDeployedM*chainDeployedM-depthM*depthM);
return horizontal+(boatLengthM||0);
}
// Recomenda comprimento de amarra baseado em condição
// windKn: vento em nós; depth: profundidade
// Retorna {chainM, ratio, condition}
function recommendChain(depthM,windKn,boatType){
let baseRatio=5; // calmo
let condition='Condição calma';
if(windKn>=25){baseRatio=10;condition='Tempestade'}
else if(windKn>=15){baseRatio=7;condition='Vento moderado'}
else if(windKn>=8){baseRatio=6;condition='Vento leve'}
// Ajuste por tipo de embarcação
const t=BOAT_TYPES[boatType]||BOAT_TYPES.sailing;
const ratio=Math.max(3,baseRatio+t.scopeBonus);
return {chainM:depthM*ratio,ratio,condition};
}
// Status visual da configuração atual de fundeio
function anchorStatus(chainDeployedM,depthM,windKn){
if(!chainDeployedM||!depthM)return {level:'unknown',msg:'Informe profundidade + amarra'};
const r=scopeRatio(chainDeployedM,depthM);
const minR=windKn>=25?10:windKn>=15?7:5;
if(r>=minR)return {level:'ok',msg:`Adequado (${r.toFixed(1)}:1)`};
if(r>=minR-1)return {level:'warn',msg:`Justo (${r.toFixed(1)}:1) — folgar mais ${((minR-r)*depthM).toFixed(0)}m`};
return {level:'danger',msg:`INSUFICIENTE (${r.toFixed(1)}:1) — necessário mínimo ${minR}:1`};
}
// Recupera velocidade de vento atual (em nós) do cache de weather, qualquer provider
function currentWindKnots(){
try{
if(!weather||!weather.data)return null;
const d=weather.data;
if(d.provider==='openmeteo'){
return d.forecast?.current?.wind_speed_10m??null;
}
if(d.provider==='windy'){
const m=d.main;
const u=m?.['wind_u-surface']?.[0];
const v=m?.['wind_v-surface']?.[0];
if(u==null||v==null)return null;
return Math.sqrt(u*u+v*v)*1.94384;
}
}catch(e){}
return null;
}
// Dica geral baseada em vento + condição
function anchorAdvice(windKn,boatType){
const t=BOAT_TYPES[boatType]||BOAT_TYPES.sailing;
if(windKn>=30)return `🌊 Tempestade · scope mínimo 10:1, considere segunda âncora`;
if(windKn>=20)return `🌬️ Vento forte · scope ${t.scopeBonus<0?'7':'8'}:1 + reforçar amarras de bordo`;
if(windKn>=12)return `💨 Vento moderado · scope 7:1 ${t.label==='Catamarã'?'(catamarã: vigiar deriva lateral)':''}`;
if(windKn>=5)return `🍃 Vento leve · scope 5-6:1 padrão`;
return `⚓ Calmo · scope mínimo 5:1`;
}
const STORAGE_KEY='diario_bordo_v3';
// ============ NAUTICAL CHART LAYERS (OpenSeaMap + Navionics) ============
// addMapLayers(map): adiciona base OSM + overlay de cartas náuticas configurável
// Chart provider: salvo em state.chartCfg.provider ('osm'|'opensea'|'navionics')
// Navionics requer state.chartCfg.navKey (obtido via formulário Garmin)
function getChartProvider(){
const cfg=state.chartCfg||{};
return cfg.provider||(cfg.navKey?'navionics':'opensea');
}
function addMapLayers(map){
// Base layer: OSM padrão (sempre)
const osm=L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{
maxZoom:19,attribution:'© OpenStreetMap',className:'map-tiles-osm'
});
// Base layer: Esri Satélite (alternativo)
const sat=L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{
maxZoom:19,attribution:'Tiles © Esri'
});
// Overlay: OpenSeaMap (cartas náuticas grátis — sondas, faróis, bóias)
const seamap=L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',{
maxZoom:18,attribution:'© OpenSeaMap',opacity:.95,className:'map-tiles-seamark'
});
// Adiciona base padrão
osm.addTo(map);
// Aplica camada náutica conforme config
const provider=getChartProvider();
let nauticalLayer=null;
if(provider==='opensea'){
seamap.addTo(map);
nauticalLayer=seamap;
}else if(provider==='navionics'&&state.chartCfg?.navKey){
nauticalLayer=tryAddNavionicsLayer(map);
if(!nauticalLayer){seamap.addTo(map);nauticalLayer=seamap}
}
// Layer switcher (canto superior direito)
const baseLayers={'OSM Padrão':osm,'Satélite':sat};
const overlayLayers={'OpenSeaMap (cartas grátis)':seamap};
if(state.chartCfg?.navKey){
overlayLayers['Navionics ⚓']={addTo:m=>tryAddNavionicsLayer(m),removeFrom:m=>{}};
}
L.control.layers(baseLayers,overlayLayers,{position:'topright',collapsed:true}).addTo(map);
return map;
}
let _navionicsScriptLoaded=null;
function loadNavionicsScript(){
if(_navionicsScriptLoaded)return _navionicsScriptLoaded;
_navionicsScriptLoaded=new Promise((resolve,reject)=>{
const s=document.createElement('script');
s.src='https://webapiv2.navionics.com/dist/webapi/webapi.min.js';
s.async=true;
s.onload=()=>resolve(window.JNC);
s.onerror=()=>reject(new Error('Navionics script failed'));
document.head.appendChild(s);
});
return _navionicsScriptLoaded;
}
function tryAddNavionicsLayer(map){
const key=state.chartCfg?.navKey;
if(!key)return null;
loadNavionicsScript().then(JNC=>{
if(!JNC?.Leaflet?.NavionicsOverlay)return;
try{
const overlay=new JNC.Leaflet.NavionicsOverlay({
navKey:key,
chartType:JNC.NAVIONICS_CHARTS.NAUTICAL,
isTransparent:true,logoPayoff:true,zIndex:5,
});
overlay.addTo(map);
console.log('[navionics] overlay added');
}catch(e){console.warn('[navionics] add failed',e.message)}
}).catch(e=>console.warn('[navionics] load failed',e.message));
return {addTo:()=>{},removeFrom:()=>{}}; // placeholder pra layer control
}
const TRACKING_KEY='diario_tracking_v3';
const ANCHOR_KEY='diario_anchor_v3';
let pendingFilter='active';
let editingMedia=[];
let mediaUrlCache=new Map();
let db,mapInstance=null,trackingMap=null,trackingPolyline=null,trackingMarker=null;
const COMPASS_ROSE_SVG=`<svg class="empty-rose" viewBox="0 0 100 100"><g fill="none" stroke="currentColor" stroke-width=".8"><circle cx="50" cy="50" r="46"/><circle cx="50" cy="50" r="34"/><circle cx="50" cy="50" r="2.5" fill="currentColor"/></g><g fill="currentColor"><path d="M50 6 L53 50 L50 50 Z"/><path d="M50 6 L47 50 L50 50 Z" opacity=".55"/><path d="M50 94 L47 50 L50 50 Z"/><path d="M50 94 L53 50 L50 50 Z" opacity=".55"/><path d="M94 50 L50 47 L50 50 Z" opacity=".7"/><path d="M94 50 L50 53 L50 50 Z" opacity=".4"/><path d="M6 50 L50 53 L50 50 Z" opacity=".7"/><path d="M6 50 L50 47 L50 50 Z" opacity=".4"/></g></svg>`;
function openDB(){return new Promise((r,j)=>{const q=indexedDB.open('diario_bordo_db',1);q.onupgradeneeded=e=>{const d=e.target.result;if(!d.objectStoreNames.contains('media')){const s=d.createObjectStore('media',{keyPath:'id'});s.createIndex('parent','parentId',{unique:false})}};q.onsuccess=e=>{db=e.target.result;r(db)};q.onerror=e=>j(e)})}
function dbGet(id){return new Promise((r,j)=>{const q=db.transaction('media','readonly').objectStore('media').get(id);q.onsuccess=()=>r(q.result);q.onerror=()=>j(q.error)})}
function dbPut(item){return new Promise((r,j)=>{const q=db.transaction('media','readwrite').objectStore('media').put(item);q.onsuccess=()=>r(q.result);q.onerror=()=>j(q.error)})}
function dbDelete(id){return new Promise((r,j)=>{const q=db.transaction('media','readwrite').objectStore('media').delete(id);q.onsuccess=()=>r();q.onerror=()=>j(q.error)})}
function dbAll(){return new Promise((r,j)=>{const q=db.transaction('media','readonly').objectStore('media').getAll();q.onsuccess=()=>r(q.result);q.onerror=()=>j(q.error)})}
function loadState(){try{const raw=localStorage.getItem(STORAGE_KEY);if(raw){const d=JSON.parse(raw);Object.assign(state,d);state.pending=d.pending||[];state.passengers=d.passengers||[];state.contacts=d.contacts||[];if(d.alarmTemplate)state.alarmTemplate=d.alarmTemplate;state.cloud=d.cloud||{url:'',token:'',lastSync:0};state.webhooks=d.webhooks||{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}};state.anchorHistory=d.anchorHistory||[];state.zones=d.zones||[];state.checklists=d.checklists||[];state.weatherCfg=d.weatherCfg||{windyKey:'',model:'gfs'};state.boats=d.boats||[];state.activeBoatId=d.activeBoatId||null;state.units=d.units||'metric'}}catch(e){console.warn(e)}
// Default cloud URL pra usuários social/email (não precisam digitar nada)
if(!state.cloud.url&&typeof DEFAULT_CLOUD_URL!=='undefined')state.cloud.url=DEFAULT_CLOUD_URL;
migrateBoatsSchema();
// popular checklists padrão se vazio
if(!state.checklists.length){
state.checklists=[
{id:'cl_safe',name:'Segurança · pré-saída',items:['Coletes salva-vidas a bordo','Rádio VHF testado (canal 16)','Sinalizadores no prazo','Extintor verificado','Bomba de porão funcionando','Kit de primeiros socorros','Plano de viagem comunicado em terra']},
{id:'cl_motor',name:'Motor · pré-partida',items:['Nível de óleo OK','Nível de combustível suficiente','Filtro de água do mar limpo','Cinta do alternador OK','Válvula da água do mar aberta','Hélice livre de cordas/algas','Partida testada em ponto morto']},
{id:'cl_vela',name:'Vela · pré-içar',items:['Velas escolhidas conforme vento','Cordames sem nós ou enroscos','Bujarrona pronta','Mestra com cunha de saída livre','Driças nos pontos','Escotas correndo livre']},
{id:'cl_anchor',name:'Fundeio',items:['Profundidade adequada','Tipo de fundo apropriado','Linha de fundeio: ≥ 5x profundidade','Comprimento de cabo conferido','Vigia configurada no app','Posição de outros barcos','Distância de costa segura']},
{id:'cl_long',name:'Travessia longa',items:['Previsão do tempo (24h+)','Combustível para 1.5x distância','Água potável suficiente','Comida não-perecível extra','Bateria reserva carregada','Cartas náuticas atualizadas','Plano de portos alternativos','Tripulação descansada']}
].map(c=>({...c,items:c.items.map(text=>({id:uid(),text}))}));
saveState();
}
}
function saveState(){
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){toast('Erro ao salvar')}
// Auto-push pra nuvem (debounced) — só se cloud configurada
if(typeof scheduleCloudPush==='function')scheduleCloudPush();
}
function uid(){return Date.now().toString(36)+Math.random().toString(36).slice(2,7)}
const MESES=['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ'];
function fmtDate(iso){if(!iso)return'—';const[y,m,d]=iso.split('-');return`${d} ${MESES[parseInt(m)-1]} ${y.slice(2)}`}
function fmtDateLong(iso){if(!iso)return'—';const[y,m,d]=iso.split('-');return`${d} ${MESES[parseInt(m)-1]} ${y}`}
function fmtDateRange(a,b){if(!a&&!b)return'—';if(a&&b&&a!==b)return`${fmtDate(a)}${fmtDate(b)}`;return fmtDate(a||b)}
function fmtHours(h){if(h==null||h==='')return'—';return Number(h).toFixed(1)}
function fmtMoney(v){if(!v)return'R$ 0,00';return'R$ '+Number(v).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})}
function escapeHtml(s){if(!s)return'';return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c])}
function todayISO(){return new Date().toISOString().slice(0,10)}
function daysBetween(a,b){return Math.round((new Date(b)-new Date(a))/86400000)}
function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');clearTimeout(toast._tm);toast._tm=setTimeout(()=>t.classList.remove('show'),2400)}
function getMediaUrl(item){if(mediaUrlCache.has(item.id))return mediaUrlCache.get(item.id);const u=URL.createObjectURL(item.blob);mediaUrlCache.set(item.id,u);return u}
function clearMediaCache(){mediaUrlCache.forEach(u=>URL.revokeObjectURL(u));mediaUrlCache.clear()}
function haversine(p1,p2){const R=6371000,r=Math.PI/180;const a1=p1.lat*r,a2=p2.lat*r;const dA=(p2.lat-p1.lat)*r,dG=(p2.lng-p1.lng)*r;const a=Math.sin(dA/2)**2+Math.cos(a1)*Math.cos(a2)*Math.sin(dG/2)**2;return 2*R*Math.atan2(Math.sqrt(a),Math.sqrt(1-a))}
function metersToNM(m){return m/1852}
function msToKnots(ms){return ms*1.94384}
function fmtDuration(s){const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60);if(h>0)return`${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;return`${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`}
const tracking={active:false,watchId:null,startedAt:null,points:[],totalDist:0,maxSpeed:0,wakeLock:null};
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');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}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};
if(p.acc>50)return;
const last=tracking.points[tracking.points.length-1];
if(last){const d=haversine(last,p);if(d<batteryDistanceFilter())return;if(p.ts-last.ts<batteryTimeFilter())return;tracking.totalDist+=d}
if(p.spd>tracking.maxSpeed)tracking.maxSpeed=p.spd;
tracking.points.push(p);saveTrackingState();updateTrackingUI();updateTrackingMap(p);
}
async function startTracking(){
if(tracking.active){toast('Já está rastreando');return}
if(!navigator.geolocation){toast('Sem GPS no dispositivo');return}
try{await new Promise((r,j)=>navigator.geolocation.getCurrentPosition(r,j,{enableHighAccuracy:true,timeout:15000}))}
catch(e){toast('Permita acesso ao GPS');return}
tracking.active=true;tracking.startedAt=Date.now();tracking.points=[];tracking.totalDist=0;tracking.maxSpeed=0;
saveTrackingState();await requestWakeLock();startGPS();
openTrackingModal();trackingInterval=setInterval(updateTrackingUI,1000);renderGPSBanner();
toast('Rastreio iniciado');
}
async function stopTracking(){
if(!tracking.active)return;
if(tracking.points.length<2){if(!confirm('Poucos pontos coletados. Descartar?'))return;cancelTracking();return}
if(!confirm('Parar rastreio e lançar como nova travessia?'))return;
if(tracking.watchId!=null)navigator.geolocation.clearWatch(tracking.watchId);
await releaseWakeLock();clearInterval(trackingInterval);
const td={startedAt:tracking.startedAt,endedAt:Date.now(),points:tracking.points.map(p=>({lat:p.lat,lng:p.lng,ts:p.ts,spd:p.spd})),distance:tracking.totalDist,maxSpeed:tracking.maxSpeed,avgSpeed:tracking.points.length>1?tracking.totalDist/((Date.now()-tracking.startedAt)/1000):0};
tracking.active=false;localStorage.removeItem(TRACKING_KEY);
closeModal('tracking-modal');
await openTripModal(null,td);renderGPSBanner();toast('Rastreio finalizado');
}
function cancelTracking(){if(tracking.watchId!=null)navigator.geolocation.clearWatch(tracking.watchId);releaseWakeLock();clearInterval(trackingInterval);tracking.active=false;tracking.points=[];tracking.totalDist=0;tracking.maxSpeed=0;localStorage.removeItem(TRACKING_KEY);closeModal('tracking-modal');renderGPSBanner()}
function minimizeTracking(){closeModal('tracking-modal')}
function reopenTracking(){if(tracking.active)openTrackingModal()}
function openTrackingModal(){
openModal('tracking-modal');
setTimeout(()=>{
if(!trackingMap){trackingMap=L.map('tracking-map').setView([0,0],2);addMapLayers(trackingMap)}
setTimeout(()=>trackingMap.invalidateSize(),100);
if(tracking.points.length){const ll=tracking.points.map(p=>[p.lat,p.lng]);if(trackingPolyline)trackingPolyline.remove();trackingPolyline=L.polyline(ll,{color:'#a07832',weight:4,opacity:.85}).addTo(trackingMap);const last=tracking.points[tracking.points.length-1];if(trackingMarker)trackingMarker.remove();trackingMarker=L.marker([last.lat,last.lng]).addTo(trackingMap);trackingMap.fitBounds(trackingPolyline.getBounds(),{padding:[20,20]})}
updateTrackingUI();
},50);
}
function updateTrackingMap(p){if(!trackingMap)return;const ll=tracking.points.map(x=>[x.lat,x.lng]);if(trackingPolyline)trackingPolyline.remove();trackingPolyline=L.polyline(ll,{color:'#a07832',weight:4,opacity:.85}).addTo(trackingMap);if(trackingMarker)trackingMarker.remove();trackingMarker=L.marker([p.lat,p.lng]).addTo(trackingMap);if(tracking.points.length<3)trackingMap.setView([p.lat,p.lng],15);else trackingMap.panTo([p.lat,p.lng])}
function updateTrackingUI(){
if(!tracking.active)return;
const last=tracking.points[tracking.points.length-1];
const cs=last?msToKnots(last.spd||0):0;
document.getElementById('t-speed').textContent=cs.toFixed(1);
document.getElementById('t-distance').textContent=metersToNM(tracking.totalDist).toFixed(2);
document.getElementById('t-maxspeed').textContent=msToKnots(tracking.maxSpeed).toFixed(1);
document.getElementById('t-duration').textContent=fmtDuration((Date.now()-tracking.startedAt)/1000);
const sb=document.getElementById('gps-banner');
if(sb&&sb.querySelector('#b-speed')){sb.querySelector('#b-speed').textContent=cs.toFixed(1);sb.querySelector('#b-distance').textContent=metersToNM(tracking.totalDist).toFixed(2);sb.querySelector('#b-duration').textContent=fmtDuration((Date.now()-tracking.startedAt)/1000)}
}
function renderGPSBanner(){
const el=document.getElementById('gps-banner');
if(tracking.active){
el.innerHTML=`<div class="gps-card">
<div class="gps-head"><h3>Rastreio em curso</h3></div>
<div class="gps-sub"><span class="live-dot"></span>GPS ativo · gravando trajeto</div>
<div class="gps-stats">
<div class="gps-stat"><div class="gps-stat-label">Velocidade</div><div class="gps-stat-value"><span id="b-speed">0.0</span><span class="gps-stat-unit">nós</span></div></div>
<div class="gps-stat"><div class="gps-stat-label">Distância</div><div class="gps-stat-value"><span id="b-distance">0.00</span><span class="gps-stat-unit">mn</span></div></div>
<div class="gps-stat"><div class="gps-stat-label">Tempo</div><div class="gps-stat-value" id="b-duration">00:00</div></div>
</div>
<div class="gps-actions">
<button class="btn" onclick="reopenTracking()" style="background:transparent;color:var(--brass-bright);border-color:var(--brass)">Ver carta</button>
<button class="btn btn-danger" onclick="stopTracking()" style="background:var(--storm);color:#fff;border-color:var(--storm)">Parar</button>
</div>
</div>`;
updateTrackingUI();
}else{
el.innerHTML=`<div class="gps-card idle">
<div class="gps-head"><h3>Rastreio GPS</h3></div>
<div class="gps-sub">Trace sua travessia · calcule distância &amp; velocidade em tempo real</div>
<div class="gps-actions"><button class="btn btn-primary btn-block btn-big" onclick="startTracking()">Iniciar rastreio</button></div>
</div>`;
}
}
function bindHeader(){
const nameEl=document.getElementById('boat-name-display');
const metaEl=document.getElementById('boat-model-display');
const avatarEl=document.getElementById('boat-header-avatar');
if(!nameEl||!metaEl)return;
const b=activeBoat();
if(!b){
nameEl.textContent='Sem embarcação';
metaEl.textContent='Toque para adicionar';
if(avatarEl)avatarEl.innerHTML='⛵';
return;
}
nameEl.textContent=b.name||'Embarcação';
const t=BOAT_TYPES[b.type]||BOAT_TYPES.sailing;
const parts=[`${t.icon} ${t.label}`];
if(b.model)parts.push(b.model);
if(b.length)parts.push(fmtLen(b.length,1));
metaEl.textContent=parts.join(' · ');
// Avatar: foto se houver, senão ícone do tipo
if(avatarEl){
avatarEl.innerHTML=t.icon;
if(b.photoId){
dbGet(b.photoId).then(item=>{
if(item&&activeBoat()?.id===b.id){
avatarEl.innerHTML=`<img src="${getMediaUrl(item)}" alt="">`;
}
}).catch(()=>{});
}
}
// sync legacy state.boat para compat
state.boat={name:b.name,model:b.model};
}
// ============ FLEET MANAGER ============
function openFleetManager(){
renderFleetList();
syncUnitsToggle();
openModal('fleet-modal');
}
function renderFleetList(){
const el=document.getElementById('fleet-list');
if(!el)return;
if(!state.boats||state.boats.length===0){
el.innerHTML=`<div class="fleet-empty">Nenhuma embarcação ainda.<br>Toque "+ Nova" pra começar.</div>`;
return;
}
el.innerHTML=state.boats.map(b=>{
const t=BOAT_TYPES[b.type]||BOAT_TYPES.sailing;
const isActive=b.id===state.activeBoatId;
const meta=[t.label];
if(b.model)meta.push(b.model);
if(b.length)meta.push(fmtLen(b.length,1));
return `<div class="fleet-item ${isActive?'active':''}" onclick="setActiveBoatAndClose('${b.id}')">
<div class="fleet-avatar" data-boat-id="${b.id}">${t.icon}</div>
<div class="fleet-info">
<div class="fleet-name">${escapeHtml(b.name)}</div>
<div class="fleet-meta">${meta.map(escapeHtml).join(' · ')}</div>
</div>
${isActive?'<div class="fleet-active-badge">ATIVA</div>':''}
<button type="button" class="fleet-edit-icon" onclick="event.stopPropagation();openBoatEditor('${b.id}')" title="Editar">✎</button>
</div>`;
}).join('');
// Carrega fotos async — não bloqueia a render
state.boats.forEach(b=>{if(b.photoId)loadBoatAvatarInto(`.fleet-avatar[data-boat-id="${b.id}"]`,b.photoId)});
}
async function loadBoatAvatarInto(selector,photoId){
if(!photoId)return;
try{
const item=await dbGet(photoId);
if(!item)return;
const url=getMediaUrl(item);
document.querySelectorAll(selector).forEach(el=>{
el.innerHTML=`<img src="${url}" alt="">`;
});
}catch(e){}
}
function setActiveBoatAndClose(id){
setActiveBoat(id);
renderFleetList();
toast('Embarcação ativa: '+(activeBoat()?.name||''));
}
function openBoatEditor(id){
const isNew=!id;
document.getElementById('boat-editor-title').textContent=isNew?'Nova embarcação':'Editar embarcação';
document.getElementById('boat-edit-id').value=id||'';
const b=isNew?{}:state.boats.find(x=>x.id===id)||{};
document.getElementById('boat-edit-name').value=b.name||'';
document.getElementById('boat-edit-type').value=b.type||'sailing';
document.getElementById('boat-edit-model').value=b.model||'';
// Mostrar valores convertidos pra unidade atual
const setLen=(elId,m)=>{const el=document.getElementById(elId);el.value=m==null?'':lenToDisplay(m).toFixed(1)};
setLen('boat-edit-length',b.length);
setLen('boat-edit-beam',b.beam);
setLen('boat-edit-draft',b.draft);
// Amarra usa metros inteiros normalmente
const chainEl=document.getElementById('boat-edit-chain');
chainEl.value=b.chainTotal==null?'':lenToDisplay(b.chainTotal).toFixed(0);
document.getElementById('boat-edit-year').value=b.year||'';
// Novos campos
document.getElementById('boat-edit-engine-hours-initial').value=b.engineHoursInitial||'';
document.getElementById('boat-edit-registered-at').value=b.registeredAt||(isNew?new Date().toISOString().slice(0,10):'');
document.getElementById('boat-edit-registration').value=b.registrationNumber||'';
document.getElementById('boat-edit-notes').value=b.notes||'';
document.getElementById('boat-edit-photo-id').value=b.photoId||'';
renderBoatPhotoPreview(b.photoId);
// Atualizar labels de unidade
const unitLabel=`(${lengthUnit()})`;
['boat-len-unit','boat-beam-unit','boat-draft-unit','boat-chain-unit'].forEach(id=>{
const el=document.getElementById(id);if(el)el.textContent=unitLabel;
});
// Botão excluir só aparece em edição e se houver mais de 1 barco
document.getElementById('boat-edit-delete').style.display=(!isNew&&state.boats.length>1)?'inline-flex':'none';
closeModal('fleet-modal');
openModal('boat-editor-modal');
setTimeout(()=>document.getElementById('boat-edit-name').focus(),100);
}
async function renderBoatPhotoPreview(photoId){
const wrap=document.getElementById('boat-photo-preview');
const clearBtn=document.getElementById('boat-photo-clear');
if(!wrap)return;
if(!photoId){
const t=BOAT_TYPES[document.getElementById('boat-edit-type').value]||BOAT_TYPES.sailing;
wrap.innerHTML=`<span class="boat-photo-placeholder">${t.icon}</span>`;
if(clearBtn)clearBtn.style.display='none';
return;
}
try{
const item=await dbGet(photoId);
if(!item){wrap.innerHTML='<span class="boat-photo-placeholder">⛵</span>';return}
const url=getMediaUrl(item);
wrap.innerHTML=`<img src="${url}" alt="foto da embarcação">`;
if(clearBtn)clearBtn.style.display='block';
}catch(e){
wrap.innerHTML='<span class="boat-photo-placeholder">⛵</span>';
}
}
async function handleBoatPhoto(ev){
const file=ev.target.files?.[0];
ev.target.value='';
if(!file)return;
if(!file.type.startsWith('image/')){toast('Selecione uma imagem');return}
// Comprime/redimensiona pra max 1280px (economia de storage e sync)
try{
const blob=await resizeImage(file,1280,0.85);
const oldId=document.getElementById('boat-edit-photo-id').value;
if(oldId){await dbDelete(oldId).catch(()=>{})}
const newId='boat-photo-'+uid();
await dbPut({id:newId,kind:'photo',blob,mime:blob.type||'image/jpeg',parentId:document.getElementById('boat-edit-id').value||'pending',parentType:'boat',createdAt:Date.now()});
document.getElementById('boat-edit-photo-id').value=newId;
await renderBoatPhotoPreview(newId);
toast('Foto carregada');
}catch(e){
console.warn('photo upload failed',e);
toast('Erro ao carregar foto');
}
}
async function clearBoatPhoto(){
const oldId=document.getElementById('boat-edit-photo-id').value;
if(oldId){await dbDelete(oldId).catch(()=>{})}
document.getElementById('boat-edit-photo-id').value='';
renderBoatPhotoPreview(null);
}
// Resize/compress image via canvas (mantém proporção, max maxDim, jpeg quality)
function resizeImage(file,maxDim=1280,quality=0.85){
return new Promise((resolve,reject)=>{
const img=new Image();
img.onload=()=>{
try{
let{width,height}=img;
if(width>height){if(width>maxDim){height=Math.round(height*maxDim/width);width=maxDim}}
else{if(height>maxDim){width=Math.round(width*maxDim/height);height=maxDim}}
const canvas=document.createElement('canvas');
canvas.width=width;canvas.height=height;
const ctx=canvas.getContext('2d');
ctx.drawImage(img,0,0,width,height);
canvas.toBlob(b=>b?resolve(b):reject(new Error('toBlob null')),'image/jpeg',quality);
}catch(e){reject(e)}
finally{URL.revokeObjectURL(img.src)}
};
img.onerror=()=>{URL.revokeObjectURL(img.src);reject(new Error('image load error'))};
img.src=URL.createObjectURL(file);
});
}
async function saveBoatFromForm(ev){
ev.preventDefault();
const id=document.getElementById('boat-edit-id').value;
const photoId=document.getElementById('boat-edit-photo-id').value||null;
const data={
name:document.getElementById('boat-edit-name').value.trim(),
type:document.getElementById('boat-edit-type').value,
model:document.getElementById('boat-edit-model').value.trim(),
length:document.getElementById('boat-edit-length').value,
beam:document.getElementById('boat-edit-beam').value,
draft:document.getElementById('boat-edit-draft').value,
chainTotal:document.getElementById('boat-edit-chain').value,
year:document.getElementById('boat-edit-year').value,
photoId,
engineHoursInitial:document.getElementById('boat-edit-engine-hours-initial').value,
registeredAt:document.getElementById('boat-edit-registered-at').value,
registrationNumber:document.getElementById('boat-edit-registration').value.trim(),
notes:document.getElementById('boat-edit-notes').value.trim(),
};
if(!data.name){toast('Informe o nome da embarcação');return}
let boatId=id;
if(id){updateBoat(id,data);toast('Embarcação atualizada')}
else{const b=addBoat(data);state.activeBoatId=b.id;boatId=b.id;saveState();toast('Embarcação adicionada')}
// Re-vincular parentId da foto (nova embarcação tinha 'pending' como parentId)
if(photoId){
try{
const item=await dbGet(photoId);
if(item&&item.parentId!==boatId){
item.parentId=boatId;
await dbPut(item);
}
}catch(e){}
}
closeModal('boat-editor-modal');
bindHeader();
renderAll();
openFleetManager();
}
function deleteBoatFromEditor(){
const id=document.getElementById('boat-edit-id').value;
if(!id)return;
const b=state.boats.find(x=>x.id===id);
if(!b)return;
if(!confirm(`Excluir "${b.name}"? Esta ação não pode ser desfeita.`))return;
removeBoat(id);
closeModal('boat-editor-modal');
bindHeader();
renderAll();
openFleetManager();
toast('Embarcação removida');
}
function setUnits(u){
if(u!=='metric'&&u!=='imperial')return;
state.units=u;
saveState();
syncUnitsToggle();
bindHeader();
renderAll();
toast(u==='metric'?'Unidade: metros':'Unidade: pés');
}
function syncUnitsToggle(){
const wrap=document.getElementById('fleet-units-toggle');
if(!wrap)return;
wrap.querySelectorAll('button').forEach(b=>{
b.classList.toggle('active',b.dataset.unit===state.units);
});
}
function switchPanel(name){
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('active',x.dataset.panel===name));
document.querySelectorAll('.bn-item').forEach(x=>x.classList.toggle('active',x.dataset.panel===name));
document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));
const p=document.getElementById('panel-'+name);
if(p)p.classList.add('active');
// FAB visível em panels que têm "criar item"
document.getElementById('fab').style.display=['trips','maintenance','pending','zones','overview'].includes(name)?'flex':'none';
if(name==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox();refreshGoogleStatus();renderBluetoothCard();renderNmeaGatewayCard()}
if(name==='pending'&&_gcalConnected&&Date.now()-_gcalLastPullAt>GCAL_PULL_INTERVAL_MS)googlePullNow();
if(name==='zones')renderZones();
window.scrollTo(0,0);
}
// Compat: top tabs (escondidos via CSS mas mantém handlers caso re-exibidos)
document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>switchPanel(t.dataset.panel))});
// Safety bar: atualiza bateria, GPS, anchor a cada 10s
function updateSafetyBar(){
const bat=document.getElementById('battery-indicator');
const sbBat=document.getElementById('sb-battery');
if(sbBat&&bat){sbBat.textContent=bat.textContent.trim()||'—'}
const sbGps=document.getElementById('sb-gps');
const sbGpsDot=document.getElementById('sb-gps-dot');
if(sbGps&&sbGpsDot){
if(tracking?.active||(typeof lastGpsPos!=='undefined'&&lastGpsPos)){
sbGps.textContent='GPS ativo';sbGpsDot.className='safety-bar-dot ok';
}else{
sbGps.textContent='GPS aguardando';sbGpsDot.className='safety-bar-dot';
}
}
const sbAnchorWrap=document.getElementById('sb-anchor-wrap');
const sbAnchorDot=document.getElementById('sb-anchor-dot');
const sbAnchor=document.getElementById('sb-anchor');
if(sbAnchorWrap&&anchorWatch){
if(anchorWatch.active){
sbAnchorWrap.style.display='inline-flex';
const breach=anchorWatch.currentDist>anchorWatch.radius;
sbAnchorDot.className='safety-bar-dot '+(breach?'danger':'ok');
sbAnchor.textContent=breach?'⚠ DERIVANDO!':`Fundeado · raio ${Math.round(anchorWatch.currentDist||0)}/${anchorWatch.radius}m`;
}else{
sbAnchorWrap.style.display='none';
}
}
// Bottom nav badge pendências
const overdue=(state.pending||[]).filter(p=>!p.done&&p.dueDate&&p.dueDate<new Date().toISOString().slice(0,10)).length;
const bnBadge=document.getElementById('bn-badge-pending');
if(bnBadge){bnBadge.style.display=overdue>0?'flex':'none';bnBadge.textContent=overdue}
}
setInterval(updateSafetyBar,5000);
setTimeout(updateSafetyBar,1500);
function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()}
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}
document.querySelectorAll('.modal-backdrop').forEach(b=>{b.addEventListener('click',e=>{if(e.target===b&&b.id!=='tracking-modal')b.classList.remove('show')})});
let editingPax=[];
function renderPaxList(){document.getElementById('pax-list').innerHTML=editingPax.map((p,i)=>`<span class="pax-tag">${escapeHtml(p)}<button onclick="removePax(${i})">×</button></span>`).join('')}
function addPax(){const i=document.getElementById('pax-input'),v=i.value.trim();if(!v)return;editingPax.push(v);i.value='';renderPaxList()}
function removePax(i){editingPax.splice(i,1);renderPaxList()}
async function handleMediaUpload(event,kind,pt){const f=event.target.files[0];if(!f)return;editingMedia.push({id:uid(),kind,blob:f,mime:f.type,parentType:pt});await renderModalMedia(pt);event.target.value=''}
async function renderModalMedia(pt){const grid=document.getElementById(pt+'-media-grid');grid.innerHTML='';for(const it of editingMedia){const url=getMediaUrl(it);const div=document.createElement('div');div.className='media-item '+it.kind;if(it.kind==='photo')div.innerHTML=`<img src="${url}" alt=""><button class="media-remove" onclick="removeEditingMedia('${it.id}','${pt}')">×</button>`;else if(it.kind==='video')div.innerHTML=`<video src="${url}" muted></video><button class="media-remove" onclick="removeEditingMedia('${it.id}','${pt}')">×</button>`;else div.innerHTML=`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg><button class="media-remove" onclick="removeEditingMedia('${it.id}','${pt}')">×</button>`;grid.appendChild(div)}}
function removeEditingMedia(id,pt){editingMedia=editingMedia.filter(m=>m.id!==id);if(mediaUrlCache.has(id)){URL.revokeObjectURL(mediaUrlCache.get(id));mediaUrlCache.delete(id)}renderModalMedia(pt)}
async function loadEntryMedia(pid){const all=await dbAll();return all.filter(m=>m.parentId===pid)}
async function saveEditingMediaFor(pid,pt){for(const i of editingMedia){if(!i.saved)await dbPut({id:i.id,kind:i.kind,blob:i.blob,mime:i.mime,parentId:pid,parentType:pt,createdAt:Date.now()})}const ex=await loadEntryMedia(pid);const keep=new Set(editingMedia.map(m=>m.id));for(const e of ex)if(!keep.has(e.id))await dbDelete(e.id)}
async function deleteAllMediaFor(pid){const ex=await loadEntryMedia(pid);for(const e of ex)await dbDelete(e.id)}
let mediaRecorder=null,recordedChunks=[],recordedBlob=null,recStartTime=0,recTimer=null,recParentType=null;
async function openRecorder(pt){recParentType=pt;recordedBlob=null;recordedChunks=[];const b=document.getElementById('rec-button');b.classList.remove('recording');b.innerHTML='<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg>';document.getElementById('rec-time').textContent='00:00';document.getElementById('rec-status').textContent='toque para gravar';document.getElementById('rec-save').style.display='none';openModal('recorder-modal')}
async function toggleRecording(){
if(!mediaRecorder||mediaRecorder.state==='inactive'){
try{
const s=await navigator.mediaDevices.getUserMedia({audio:true});
const m=MediaRecorder.isTypeSupported('audio/webm;codecs=opus')?'audio/webm;codecs=opus':MediaRecorder.isTypeSupported('audio/mp4')?'audio/mp4':'';
mediaRecorder=new MediaRecorder(s,m?{mimeType:m}:{});
recordedChunks=[];
mediaRecorder.ondataavailable=e=>{if(e.data.size>0)recordedChunks.push(e.data)};
mediaRecorder.onstop=()=>{
recordedBlob=new Blob(recordedChunks,{type:mediaRecorder.mimeType||'audio/webm'});
s.getTracks().forEach(t=>t.stop());
document.getElementById('rec-status').textContent='gravação pronta';
document.getElementById('rec-save').style.display='inline-flex';
const b=document.getElementById('rec-button');b.classList.remove('recording');b.innerHTML='<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg>';
clearInterval(recTimer);
};
mediaRecorder.start();recStartTime=Date.now();
const b=document.getElementById('rec-button');b.classList.add('recording');b.innerHTML='<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10"/></svg>';
document.getElementById('rec-status').textContent='gravando · toque para parar';
recTimer=setInterval(()=>{const sec=Math.floor((Date.now()-recStartTime)/1000),mm=Math.floor(sec/60),ss=sec%60;document.getElementById('rec-time').textContent=`${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`},200);
}catch(e){toast('Permita acesso ao microfone')}
}else{mediaRecorder.stop()}
}
function cancelRecording(){if(mediaRecorder&&mediaRecorder.state==='recording')mediaRecorder.stop();clearInterval(recTimer);recordedBlob=null;closeModal('recorder-modal')}
async function saveRecording(){if(!recordedBlob)return;editingMedia.push({id:uid(),kind:'audio',blob:recordedBlob,mime:recordedBlob.type,parentType:recParentType});await renderModalMedia(recParentType);recordedBlob=null;closeModal('recorder-modal')}
async function openTripModal(id,trackData){
document.getElementById('trip-id').value=id||'';
document.getElementById('trip-modal-title').textContent=id?'Editar Travessia':(trackData?'Lançar Travessia':'Nova Travessia');
editingMedia=[];
document.getElementById('trip-track-data').value=trackData?JSON.stringify(trackData):'';
if(id){
const t=state.trips.find(x=>x.id===id);if(!t)return;
document.getElementById('trip-destination').value=t.destination||'';
document.getElementById('trip-date-start').value=t.dateStart||'';
document.getElementById('trip-date-end').value=t.dateEnd||'';
document.getElementById('trip-hours-start').value=t.hoursStart??'';
document.getElementById('trip-hours-end').value=t.hoursEnd??'';
document.getElementById('trip-wind').value=t.wind||'';
document.getElementById('trip-distance').value=t.distance??'';
document.getElementById('trip-notes').value=t.notes||'';
editingPax=[...(t.passengers||[])];
const md=await loadEntryMedia(id);
editingMedia=md.map(m=>({...m,saved:true,parentType:'trip'}));
document.getElementById('trip-track-info').innerHTML=t.track?renderTrackInfo(t.track):'';
}else{
['trip-destination','trip-date-end','trip-hours-start','trip-hours-end','trip-wind','trip-notes'].forEach(k=>document.getElementById(k).value='');
document.getElementById('trip-date-start').value=todayISO();editingPax=[];
if(trackData){
const dn=metersToNM(trackData.distance);
document.getElementById('trip-distance').value=dn.toFixed(2);
document.getElementById('trip-date-start').value=new Date(trackData.startedAt).toISOString().slice(0,10);
document.getElementById('trip-date-end').value=new Date(trackData.endedAt).toISOString().slice(0,10);
document.getElementById('trip-track-info').innerHTML=renderTrackInfo(trackData);
}else{document.getElementById('trip-distance').value='';document.getElementById('trip-track-info').innerHTML=''}
}
renderPaxList();await renderModalMedia('trip');openModal('trip-modal');
}
function renderTrackInfo(t){
const dur=(t.endedAt-t.startedAt)/1000;
const dn=metersToNM(t.distance),mk=msToKnots(t.maxSpeed),ak=msToKnots(t.avgSpeed||t.distance/dur);
return `<div class="field"><label class="field-label">Carta de Rastreio</label>
<div class="map-stats" style="margin-top:0">
<div class="map-stat"><span class="map-stat-num">${dn.toFixed(2)}</span><span class="map-stat-lbl">milhas</span></div>
<div class="map-stat"><span class="map-stat-num">${ak.toFixed(1)}</span><span class="map-stat-lbl">nós méd</span></div>
<div class="map-stat"><span class="map-stat-num">${mk.toFixed(1)}</span><span class="map-stat-lbl">nós máx</span></div>
</div>
<div style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:6px;letter-spacing:.05em">${t.points.length} pontos · ${fmtDuration(dur)} navegando</div>
</div>`;
}
async function saveTrip(){
const id=document.getElementById('trip-id').value;
const dest=document.getElementById('trip-destination').value.trim();
const ds=document.getElementById('trip-date-start').value;
if(!dest&&!ds){toast('Informe destino ou data');return}
const tid=id||uid();
const tj=document.getElementById('trip-track-data').value;
let track=null;if(tj){try{track=JSON.parse(tj)}catch(e){}}
const data={id:tid,destination:dest,dateStart:ds,dateEnd:document.getElementById('trip-date-end').value,hoursStart:parseFloat(document.getElementById('trip-hours-start').value)||null,hoursEnd:parseFloat(document.getElementById('trip-hours-end').value)||null,wind:document.getElementById('trip-wind').value.trim(),distance:parseFloat(document.getElementById('trip-distance').value)||null,notes:document.getElementById('trip-notes').value.trim(),passengers:[...editingPax]};
if(id){const ex=state.trips.find(x=>x.id===id);if(ex&&ex.track&&!track)data.track=ex.track;else if(track)data.track=track}else if(track)data.track=track;
if(id){const idx=state.trips.findIndex(x=>x.id===id);if(idx>=0)state.trips[idx]=data}else state.trips.unshift(data);
editingPax.forEach(p=>{if(!state.passengers.includes(p))state.passengers.push(p)});
await saveEditingMediaFor(tid,'trip');saveState();closeModal('trip-modal');await renderAll();toast('Lançado no diário');
}
async function deleteTrip(id){if(!confirm('Apagar travessia e mídias?'))return;state.trips=state.trips.filter(t=>t.id!==id);await deleteAllMediaFor(id);saveState();await renderAll();toast('Removido')}
const CATEGORIES={motor:'Motor',velas:'Velas & Cordames',casco:'Casco & Pintura',eletrica:'Elétrica',eletronica:'Eletrônica',seguranca:'Segurança',outros:'Outros'};
async function openMaintModal(id,fp){
document.getElementById('maint-id').value=id||'';
document.getElementById('maint-from-pending').value=fp||'';
document.getElementById('maint-modal-title').textContent=id?'Editar Reparo':'Novo Reparo';
editingMedia=[];
if(id){const m=state.maint.find(x=>x.id===id);if(!m)return;document.getElementById('maint-title').value=m.title||'';document.getElementById('maint-date').value=m.date||'';document.getElementById('maint-hours').value=m.hours??'';document.getElementById('maint-category').value=m.category||'motor';document.getElementById('maint-cost').value=m.cost??'';document.getElementById('maint-vendor').value=m.vendor||'';document.getElementById('maint-notes').value=m.notes||'';const md=await loadEntryMedia(id);editingMedia=md.map(x=>({...x,saved:true,parentType:'maint'}))}
else{['maint-title','maint-hours','maint-cost','maint-vendor','maint-notes'].forEach(k=>document.getElementById(k).value='');document.getElementById('maint-date').value=todayISO();document.getElementById('maint-category').value='motor';const last=currentEngineHours();if(last!==null)document.getElementById('maint-hours').value=last;if(fp){const p=state.pending.find(x=>x.id===fp);if(p){document.getElementById('maint-title').value=p.title||'';document.getElementById('maint-category').value=p.category||'motor';document.getElementById('maint-cost').value=p.estimatedCost??'';document.getElementById('maint-notes').value=p.notes||''}}}
await renderModalMedia('maint');openModal('maint-modal');
}
async function saveMaint(){
const id=document.getElementById('maint-id').value,fp=document.getElementById('maint-from-pending').value;
const t=document.getElementById('maint-title').value.trim();
if(!t){toast('Informe o serviço');return}
const mid=id||uid();
const data={id:mid,title:t,date:document.getElementById('maint-date').value,hours:parseFloat(document.getElementById('maint-hours').value)||null,category:document.getElementById('maint-category').value,cost:parseFloat(document.getElementById('maint-cost').value)||null,vendor:document.getElementById('maint-vendor').value.trim(),notes:document.getElementById('maint-notes').value.trim()};
if(id){const idx=state.maint.findIndex(x=>x.id===id);if(idx>=0)state.maint[idx]=data}else state.maint.unshift(data);
if(fp){const p=state.pending.find(x=>x.id===fp);if(p){p.done=true;p.completedMaintId=mid;p.completedAt=Date.now()}}
await saveEditingMediaFor(mid,'maint');saveState();closeModal('maint-modal');await renderAll();toast('Reparo lançado');
}
async function deleteMaint(id){if(!confirm('Apagar reparo e mídias?'))return;state.maint=state.maint.filter(m=>m.id!==id);await deleteAllMediaFor(id);saveState();await renderAll();toast('Removido')}
function openPendingModal(id){
document.getElementById('pending-id').value=id||'';
document.getElementById('pending-modal-title').textContent=id?'Editar Pendência':'Nova Pendência';
if(id){const p=state.pending.find(x=>x.id===id);if(!p)return;document.getElementById('pending-title').value=p.title||'';document.getElementById('pending-category').value=p.category||'motor';document.getElementById('pending-due-date').value=p.dueDate||'';document.getElementById('pending-due-hours').value=p.dueHours??'';document.getElementById('pending-est-cost').value=p.estimatedCost??'';document.getElementById('pending-notes').value=p.notes||'';document.querySelector(`input[name="pending-priority"][value="${p.priority||'normal'}"]`).checked=true}
else{['pending-title','pending-due-date','pending-due-hours','pending-est-cost','pending-notes'].forEach(k=>document.getElementById(k).value='');document.getElementById('pending-category').value='motor';document.querySelector('input[name="pending-priority"][value="normal"]').checked=true}
openModal('pending-modal');
}
function savePending(){
const id=document.getElementById('pending-id').value,t=document.getElementById('pending-title').value.trim();
if(!t){toast('Informe o que precisa fazer');return}
const existing=id?state.pending.find(x=>x.id===id):null;
const data={id:id||uid(),title:t,category:document.getElementById('pending-category').value,dueDate:document.getElementById('pending-due-date').value||null,dueHours:parseFloat(document.getElementById('pending-due-hours').value)||null,estimatedCost:parseFloat(document.getElementById('pending-est-cost').value)||null,priority:document.querySelector('input[name="pending-priority"]:checked').value,notes:document.getElementById('pending-notes').value.trim(),done:false,createdAt:Date.now()};
// Preserva googleEventId em edições
if(existing?.googleEventId){data.googleEventId=existing.googleEventId;data.googleHtmlLink=existing.googleHtmlLink}
if(id){const idx=state.pending.findIndex(x=>x.id===id);if(idx>=0)Object.assign(state.pending[idx],data)}else state.pending.unshift(data);
saveState();closeModal('pending-modal');renderAll();toast('Anotado');
// Auto-sync com Google Agenda (se conectado, com prazo)
if(_gcalConnected&&data.dueDate){
const p=state.pending.find(x=>x.id===data.id);
if(p)googleSyncPending(p);
}
}
function deletePending(id){
if(!confirm('Apagar pendência?'))return;
const p=state.pending.find(x=>x.id===id);
state.pending=state.pending.filter(p=>p.id!==id);
saveState();renderAll();toast('Removido');
// Auto-delete no Google Agenda
if(_gcalConnected&&p?.googleEventId){
googleSyncPending({...p,deleted:true,id:p.id});
}
}
function markPendingDone(id){openMaintModal(null,id)}
function filterPending(f){pendingFilter=f;document.querySelectorAll('.pending-toggle button').forEach(b=>b.classList.toggle('active',b.dataset.filter===f));renderPending()}
function pendingStatus(p){if(p.done)return{kind:'done',label:'Feito'};const today=todayISO(),cur=currentEngineHours();let o=false,s=false;if(p.dueDate){if(p.dueDate<today)o=true;else if(daysBetween(today,p.dueDate)<=7)s=true}if(p.dueHours!=null&&cur!=null){if(cur>=p.dueHours)o=true;else if(p.dueHours-cur<=10)s=true}if(o)return{kind:'overdue',label:'Atrasado'};if(s)return{kind:'soon',label:'Em breve'};return{kind:'ok',label:'Em dia'}}
function currentEngineHours(){let max=null;state.trips.forEach(t=>{if(t.hoursEnd!=null&&(max===null||t.hoursEnd>max))max=t.hoursEnd;if(t.hoursStart!=null&&(max===null||t.hoursStart>max))max=t.hoursStart});state.maint.forEach(m=>{if(m.hours!=null&&(max===null||m.hours>max))max=m.hours});return max}
function totalEngineHoursLogged(){let total=0;state.trips.forEach(t=>{if(t.hoursStart!=null&&t.hoursEnd!=null&&t.hoursEnd>=t.hoursStart)total+=(t.hoursEnd-t.hoursStart)});return total}
async function renderTrips(){
const list=document.getElementById('trips-list');
if(state.trips.length===0){list.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Diário em branco</div><div class="empty-text">Nenhuma travessia registrada. Lance a primeira para começar a história deste veleiro.</div></div>`;return}
const sorted=[...state.trips].sort((a,b)=>(b.dateStart||'').localeCompare(a.dateStart||''));
const allMedia=await dbAll();
const mp={};allMedia.forEach(m=>{if(!mp[m.parentId])mp[m.parentId]=[];mp[m.parentId].push(m)});
list.innerHTML=sorted.map(t=>{
const hd=(t.hoursStart!=null&&t.hoursEnd!=null)?(t.hoursEnd-t.hoursStart).toFixed(1):null;
const md=mp[t.id]||[];
const trackBlock=t.track?`<div class="entry-track">
<div class="entry-track-stats">
<div><span>milhas</span><strong>${metersToNM(t.track.distance).toFixed(2)}</strong></div>
<div><span>nós méd</span><strong>${msToKnots(t.track.avgSpeed||0).toFixed(1)}</strong></div>
<div><span>nós máx</span><strong>${msToKnots(t.track.maxSpeed).toFixed(1)}</strong></div>
</div>
<div style="display:flex;gap:5px"><button class="btn btn-track" onclick="openMapModal('${t.id}')">Ver carta</button><button class="btn btn-track" onclick="exportTripGPX('${t.id}')" style="background:transparent;color:var(--brass);border-color:var(--brass)">↓ GPX</button></div>
</div>`:'';
const fields=[];
if(t.hoursStart!=null||t.hoursEnd!=null){fields.push(`<dt>Motor</dt><dd><span class="num">${fmtHours(t.hoursStart)}${fmtHours(t.hoursEnd)}</span>${hd?` <span class="delta">(${hd}h)</span>`:''}</dd>`)}
if(t.distance!=null)fields.push(`<dt>Distância</dt><dd><span class="num">${t.distance}</span> mn</dd>`);
if(t.wind)fields.push(`<dt>Vento</dt><dd>${escapeHtml(t.wind)}</dd>`);
return `<div class="entry">
<div class="entry-head">
<div class="entry-meta">
<div class="entry-date">${fmtDateRange(t.dateStart,t.dateEnd)}</div>
<div class="entry-title">${escapeHtml(t.destination)||'sem destino'}</div>
</div>
<div class="entry-actions"><button class="icon-btn" onclick="openTripModal('${t.id}')" title="Editar">✎</button><button class="icon-btn del" onclick="deleteTrip('${t.id}')" title="Apagar">×</button></div>
</div>
${fields.length?`<div class="entry-body"><dl class="entry-grid">${fields.join('')}</dl>`:'<div class="entry-body">'}
${trackBlock}
${(t.passengers&&t.passengers.length)?`<div class="entry-passengers">${t.passengers.map(p=>`<span class="pax-pill">${escapeHtml(p)}</span>`).join('')}</div>`:''}
${t.notes?`<div class="entry-notes">${escapeHtml(t.notes)}</div>`:''}
${renderMediaThumbnails(md)}
</div>
</div>`;
}).join('');
}
function renderMediaThumbnails(media){
if(!media.length)return'';
return `<div class="media-grid">`+media.map(m=>{const u=getMediaUrl(m);if(m.kind==='photo')return`<div class="media-thumb" onclick="openViewer('${m.id}')"><img src="${u}" alt=""></div>`;if(m.kind==='video')return`<div class="media-thumb video" onclick="openViewer('${m.id}')"><video src="${u}" muted></video></div>`;return`<div class="media-thumb audio" onclick="openViewer('${m.id}')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg></div>`}).join('')+`</div>`;
}
async function renderMaint(){
const list=document.getElementById('maint-list');
if(state.maint.length===0){list.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Nenhum reparo registrado</div><div class="empty-text">O histórico de manutenção valoriza o barco e ajuda em revisões futuras.</div></div>`;return}
const sorted=[...state.maint].sort((a,b)=>(b.date||'').localeCompare(a.date||''));
const allMedia=await dbAll();
const mp={};allMedia.forEach(m=>{if(!mp[m.parentId])mp[m.parentId]=[];mp[m.parentId].push(m)});
list.innerHTML=sorted.map(m=>{
const md=mp[m.id]||[];
const fields=[];
if(m.hours!=null)fields.push(`<dt>Horímetro</dt><dd><span class="num">${fmtHours(m.hours)}</span> h</dd>`);
if(m.cost!=null)fields.push(`<dt>Custo</dt><dd><span class="num">${fmtMoney(m.cost)}</span></dd>`);
if(m.vendor)fields.push(`<dt>Local</dt><dd>${escapeHtml(m.vendor)}</dd>`);
return `<div class="entry maint">
<div class="entry-head">
<div class="entry-meta">
<div class="entry-date">${fmtDate(m.date)}<span class="sep">·</span>${CATEGORIES[m.category]||m.category}</div>
<div class="entry-title">${escapeHtml(m.title)}</div>
</div>
<div class="entry-actions"><button class="icon-btn" onclick="openMaintModal('${m.id}')">✎</button><button class="icon-btn del" onclick="deleteMaint('${m.id}')">×</button></div>
</div>
<div class="entry-body">
${fields.length?`<dl class="entry-grid">${fields.join('')}</dl>`:''}
${m.notes?`<div class="entry-notes">${escapeHtml(m.notes)}</div>`:''}
${renderMediaThumbnails(md)}
</div>
</div>`;
}).join('');
}
function renderPending(){
const list=document.getElementById('pending-list');
let items=[...state.pending];
if(pendingFilter==='active')items=items.filter(p=>!p.done);
const po={high:0,normal:1,low:2},so={overdue:0,soon:1,ok:2,done:3};
items.sort((a,b)=>{const sa=so[pendingStatus(a).kind],sb=so[pendingStatus(b).kind];if(sa!==sb)return sa-sb;return po[a.priority||'normal']-po[b.priority||'normal']});
const aa=state.pending.filter(p=>!p.done&&['overdue','soon'].includes(pendingStatus(p).kind)).length;
const badge=document.getElementById('pending-badge');
if(aa>0){badge.textContent=aa;badge.style.display='inline-block'}else badge.style.display='none';
if(items.length===0){list.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Tudo em ordem</div><div class="empty-text">Nenhuma manutenção pendente. Anote serviços previstos para não esquecer.</div></div>`;return}
list.innerHTML=items.map(p=>{
const st=pendingStatus(p),cur=currentEngineHours();
const hl=(p.dueHours!=null&&cur!=null)?(p.dueHours-cur):null;
let dt='';if(p.dueDate)dt+=`Até ${fmtDate(p.dueDate)}`;
if(p.dueHours!=null){if(dt)dt+=' · ';dt+=`${p.dueHours}h motor`;if(hl!=null)dt+=hl>0?` (faltam ${hl.toFixed(1)}h)`:` (passou em ${(-hl).toFixed(1)}h)`}
const pl=p.priority==='high'?'Alta':p.priority==='low'?'Baixa':'Normal';
const fields=[];
if(dt)fields.push(`<dt>Prazo</dt><dd>${dt}</dd>`);
if(p.estimatedCost!=null)fields.push(`<dt>Estimado</dt><dd><span class="num">${fmtMoney(p.estimatedCost)}</span></dd>`);
fields.push(`<dt>Prioridade</dt><dd>${pl}</dd>`);
return `<div class="entry pending ${st.kind==='overdue'?'overdue':''} ${p.done?'done':''}">
<div class="entry-head">
<div class="entry-meta">
<div class="entry-date">${CATEGORIES[p.category]||p.category}<span class="sep">·</span><span class="status-pill status-${st.kind}">${st.label}</span></div>
<div class="entry-title">${escapeHtml(p.title)}</div>
</div>
<div class="entry-actions">${!p.done?`<button class="icon-btn" onclick="markPendingDone('${p.id}')" title="Feito">✓</button>`:''}<button class="icon-btn" onclick="openPendingModal('${p.id}')">✎</button><button class="icon-btn del" onclick="deletePending('${p.id}')">×</button></div>
</div>
<div class="entry-body">
<dl class="entry-grid">${fields.join('')}</dl>
${p.notes?`<div class="entry-notes">${escapeHtml(p.notes)}</div>`:''}
</div>
</div>`;
}).join('');
}
function renderOverview(){
const cur=currentEngineHours();
document.getElementById('stat-hours').textContent=cur!=null?fmtHours(cur):'—';
document.getElementById('stat-hours-sub').textContent=cur!=null?`${totalEngineHoursLogged().toFixed(1)} h navegadas`:'sem leituras';
document.getElementById('stat-trips').textContent=state.trips.length;
document.getElementById('stat-trips-sub').textContent=state.trips.length===1?'registrada':'registradas';
document.getElementById('stat-maint').textContent=state.maint.length;
const tc=state.maint.reduce((s,m)=>s+(m.cost||0),0);
document.getElementById('stat-maint-sub').textContent=tc?fmtMoney(tc):'no diário';
const ap=state.pending.filter(p=>!p.done);
document.getElementById('stat-pending').textContent=ap.length;
const et=ap.reduce((s,p)=>s+(p.estimatedCost||0),0);
document.getElementById('stat-pending-sub').textContent=et?`~${fmtMoney(et)}`:'a fazer';
// alerts
const aa=document.getElementById('alerts-area');
const overdue=state.pending.filter(p=>!p.done&&pendingStatus(p).kind==='overdue');
const soon=state.pending.filter(p=>!p.done&&pendingStatus(p).kind==='soon');
let h='';
const warnGlyph='<svg class="alert-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2 L22 20 H2 Z"/><line x1="12" y1="9" x2="12" y2="14"/><circle cx="12" cy="17.5" r="1" fill="currentColor"/></svg>';
const clockGlyph='<svg class="alert-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="10"/><path d="M12 7v5l3 2"/></svg>';
if(overdue.length)h+=`<div class="alert" onclick="goToPending()">${warnGlyph}<div class="alert-text"><strong>${overdue.length} ${overdue.length===1?'pendência atrasada':'pendências atrasadas'}</strong>${overdue.slice(0,2).map(p=>escapeHtml(p.title)).join(', ')}${overdue.length>2?'…':''}</div><div class="alert-arrow">→</div></div>`;
if(soon.length)h+=`<div class="alert warn" onclick="goToPending()">${clockGlyph}<div class="alert-text"><strong>${soon.length} ${soon.length===1?'pendência próxima':'pendências próximas'}</strong>${soon.slice(0,2).map(p=>escapeHtml(p.title)).join(', ')}${soon.length>2?'…':''}</div><div class="alert-arrow">→</div></div>`;
aa.innerHTML=h;
// recent
const recent=document.getElementById('recent-entries');
const all=[...state.trips.map(t=>({...t,_kind:'trip',_date:t.dateStart})),...state.maint.map(m=>({...m,_kind:'maint',_date:m.date}))].filter(x=>x._date).sort((a,b)=>b._date.localeCompare(a._date)).slice(0,4);
if(all.length===0){recent.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Bem-vindo a bordo</div><div class="empty-text">Comece registrando uma travessia ou um reparo. O diário fica salvo neste dispositivo.</div></div>`;return}
recent.innerHTML=`<div class="section-header"><span class="ornament">✦</span><h2>Últimos lançamentos</h2><span class="ornament">✦</span></div><div class="entries">${all.map(it=>it._kind==='trip'?`<div class="entry"><div class="entry-head"><div class="entry-meta"><div class="entry-date">${fmtDateRange(it.dateStart,it.dateEnd)}${it.track?'<span class="sep">·</span>com rastreio':''}</div><div class="entry-title">${escapeHtml(it.destination)||'sem destino'}</div></div></div></div>`:`<div class="entry maint"><div class="entry-head"><div class="entry-meta"><div class="entry-date">${fmtDate(it.date)}<span class="sep">·</span>${CATEGORIES[it.category]||''}${it.cost?'<span class="sep">·</span>'+fmtMoney(it.cost):''}</div><div class="entry-title">${escapeHtml(it.title)}</div></div></div></div>`).join('')}</div>`;
}
function goToPending(){document.querySelector('.tab[data-panel="pending"]').click()}
async function renderAll(){clearMediaCache();renderGPSBanner();renderAnchorBanner();renderOverview();await renderTrips();await renderMaint();renderPending()}
function openMapModal(tid){
const t=state.trips.find(x=>x.id===tid);
if(!t||!t.track){toast('Sem rastreio salvo');return}
openModal('map-modal');
setTimeout(()=>{
if(mapInstance){mapInstance.remove();mapInstance=null}
mapInstance=L.map('map-modal-content');
addMapLayers(mapInstance);
const ll=t.track.points.map(p=>[p.lat,p.lng]);
L.polyline(ll,{color:'#a07832',weight:4,opacity:.85}).addTo(mapInstance);
if(ll.length){L.marker(ll[0]).addTo(mapInstance).bindPopup('Saída');L.marker(ll[ll.length-1]).addTo(mapInstance).bindPopup('Chegada');mapInstance.fitBounds(ll,{padding:[20,20]})}
setTimeout(()=>mapInstance.invalidateSize(),100);
const dur=(t.track.endedAt-t.track.startedAt)/1000;
document.getElementById('map-modal-stats').innerHTML=`
<div class="map-stat"><span class="map-stat-num">${metersToNM(t.track.distance).toFixed(2)}</span><span class="map-stat-lbl">milhas</span></div>
<div class="map-stat"><span class="map-stat-num">${msToKnots(t.track.avgSpeed||t.track.distance/dur).toFixed(1)}</span><span class="map-stat-lbl">nós méd</span></div>
<div class="map-stat"><span class="map-stat-num">${msToKnots(t.track.maxSpeed).toFixed(1)}</span><span class="map-stat-lbl">nós máx</span></div>`;
},50);
}
function closeMapModal(){closeModal('map-modal');if(mapInstance){setTimeout(()=>{mapInstance.remove();mapInstance=null},300)}}
let currentViewerMediaId=null;
async function openViewer(id){const it=await dbGet(id);if(!it)return;currentViewerMediaId=id;const u=getMediaUrl(it);const c=document.getElementById('viewer-content');if(it.kind==='photo')c.innerHTML=`<img src="${u}" alt="">`;else if(it.kind==='video')c.innerHTML=`<video src="${u}" controls autoplay></video>`;else c.innerHTML=`<audio src="${u}" controls autoplay></audio>`;document.getElementById('viewer-modal').classList.add('show')}
function closeViewer(){const c=document.getElementById('viewer-content');c.querySelectorAll('video,audio').forEach(el=>el.pause());c.innerHTML='';document.getElementById('viewer-modal').classList.remove('show');currentViewerMediaId=null}
async function deleteCurrentMedia(){if(!currentViewerMediaId)return;if(!confirm('Apagar mídia?'))return;await dbDelete(currentViewerMediaId);closeViewer();await renderAll();toast('Apagada')}
async function downloadCurrentMedia(){if(!currentViewerMediaId)return;const it=await dbGet(currentViewerMediaId);if(!it)return;const ext=it.kind==='photo'?'jpg':it.kind==='video'?'mp4':'webm';const u=URL.createObjectURL(it.blob);const a=document.createElement('a');a.href=u;a.download=`midia-${it.id}.${ext}`;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),1000)}
function blobToBase64(b){return new Promise((r,j)=>{const f=new FileReader();f.onload=()=>r(f.result);f.onerror=j;f.readAsDataURL(b)})}
function base64ToBlob(d){const[m,b]=d.split(',');const mime=m.match(/:(.*?);/)[1];const bin=atob(b);const arr=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)arr[i]=bin.charCodeAt(i);return new Blob([arr],{type:mime})}
async function exportJSON(includeMedia){
const p=JSON.parse(JSON.stringify(state));
if(includeMedia){toast('Empacotando mídias...');const all=await dbAll();p._media=[];for(const m of all){const d=await blobToBase64(m.blob);p._media.push({id:m.id,kind:m.kind,mime:m.mime,parentId:m.parentId,parentType:m.parentType,createdAt:m.createdAt,data:d})}}
const blob=new Blob([JSON.stringify(p)],{type:'application/json'});
triggerDownload(blob,`diario-${slug(state.boat.name)}-${todayISO()}${includeMedia?'-completo':''}.json`);
toast('Backup gerado');
}
function exportCSV(kind){
let rows,name;
if(kind==='trips'){rows=[['Data Saida','Data Chegada','Destino','Hor Saida','Hor Chegada','Horas','Distancia','Vento','Tripulacao','GPS Dist mn','GPS Vmax kn','GPS Vmed kn','Notas']];state.trips.forEach(t=>{const d=(t.hoursStart!=null&&t.hoursEnd!=null)?(t.hoursEnd-t.hoursStart).toFixed(1):'';const td=t.track?metersToNM(t.track.distance).toFixed(2):'';const tm=t.track?msToKnots(t.track.maxSpeed).toFixed(1):'';const ta=t.track?msToKnots(t.track.avgSpeed||0).toFixed(1):'';rows.push([t.dateStart||'',t.dateEnd||'',t.destination||'',t.hoursStart??'',t.hoursEnd??'',d,t.distance??'',t.wind||'',(t.passengers||[]).join('; '),td,tm,ta,(t.notes||'').replace(/\n/g,' ')])});name=`travessias-${slug(state.boat.name)}-${todayISO()}.csv`}
else if(kind==='maint'){rows=[['Data','Servico','Categoria','Horimetro','Custo','Local','Detalhes']];state.maint.forEach(m=>rows.push([m.date||'',m.title||'',CATEGORIES[m.category]||'',m.hours??'',m.cost??'',m.vendor||'',(m.notes||'').replace(/\n/g,' ')]));name=`reparos-${slug(state.boat.name)}-${todayISO()}.csv`}
else{rows=[['Status','Servico','Categoria','Prazo','Hor Alvo','Custo Est','Prioridade','Detalhes']];state.pending.forEach(p=>rows.push([p.done?'Feito':pendingStatus(p).label,p.title,CATEGORIES[p.category]||'',p.dueDate||'',p.dueHours??'',p.estimatedCost??'',p.priority||'normal',(p.notes||'').replace(/\n/g,' ')]));name=`pendencias-${slug(state.boat.name)}-${todayISO()}.csv`}
const csv=rows.map(r=>r.map(c=>{const s=String(c);return /[",;\n]/.test(s)?`"${s.replace(/"/g,'""')}"`:s}).join(';')).join('\n');
const blob=new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8'});
triggerDownload(blob,name);toast('CSV gerado');
}
function triggerDownload(blob,fn){const u=URL.createObjectURL(blob);const a=document.createElement('a');a.href=u;a.download=fn;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),1000)}
function slug(s){return(s||'veleiro').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')}
async function shareData(){
const cur=currentEngineHours();
const summary=`Diário de Bordo — ${state.boat.name}\nHorímetro: ${cur!=null?fmtHours(cur)+'h':'—'}\n${state.trips.length} travessias · ${state.maint.length} reparos\n${state.pending.filter(p=>!p.done).length} pendentes`;
const json=JSON.stringify(state);
const blob=new Blob([json],{type:'application/json'});
const file=new File([blob],`diario-${slug(state.boat.name)}-${todayISO()}.json`,{type:'application/json'});
if(navigator.canShare&&navigator.canShare({files:[file]})){try{await navigator.share({title:`Diário — ${state.boat.name}`,text:summary,files:[file]});return}catch(e){}}
else if(navigator.share){try{await navigator.share({title:`Diário — ${state.boat.name}`,text:summary});return}catch(e){}}
exportJSON(false);toast('Compartilhe o arquivo baixado');
}
async function importJSON(event){
const file=event.target.files[0];if(!file)return;
const reader=new FileReader();
reader.onload=async(e)=>{try{const data=JSON.parse(e.target.result);if(!data.trips&&!data.maint&&!data.pending&&!data.boat)throw new Error('Formato inválido');if(!confirm('Importar substituirá os dados atuais. Continuar?'))return;Object.assign(state,{boat:data.boat||state.boat,trips:data.trips||[],maint:data.maint||[],pending:data.pending||[],passengers:data.passengers||[]});const all=await dbAll();for(const m of all)await dbDelete(m.id);if(data._media&&Array.isArray(data._media)){for(const m of data._media){const blob=base64ToBlob(m.data);await dbPut({id:m.id,kind:m.kind,blob,mime:m.mime,parentId:m.parentId,parentType:m.parentType,createdAt:m.createdAt})}}saveState();bindHeader();await renderAll();toast('Dados importados')}catch(err){alert('Arquivo inválido: '+err.message)}};
reader.readAsText(file);event.target.value='';
}
async function resetAll(){if(!confirm('Apagar TODO o diário?'))return;if(!confirm('Tem certeza? Recomendo backup antes.'))return;localStorage.removeItem(STORAGE_KEY);localStorage.removeItem(TRACKING_KEY);const all=await dbAll();for(const m of all)await dbDelete(m.id);state.boat={name:'Shivao',model:''};state.trips=[];state.maint=[];state.pending=[];state.passengers=[];bindHeader();await renderAll();toast('Diário apagado')}
async function updateStorageInfo(){
try{
const all=await dbAll();let tb=0;all.forEach(m=>tb+=m.blob.size);
const mb=(tb/1024/1024).toFixed(2);
let extra='';
if(navigator.storage&&navigator.storage.estimate){const e=await navigator.storage.estimate();const u=(e.usage/1024/1024).toFixed(1);const q=(e.quota/1024/1024).toFixed(0);extra=` · TOTAL: ${u} de ${q} MB`}
document.getElementById('storage-info').textContent=`${all.length} mídias · ${mb} MB${extra}`;
}catch(e){document.getElementById('storage-info').textContent='—'}
}
(async()=>{
await openDB();loadState();bindHeader();await renderAll();
document.getElementById('fab').style.display='none';
loadTrackingState();
loadAnchorState();
initBattery();
initServiceWorker();
initSensorWidget();
// Realtime sync: conecta WebSocket se cloud configurada
setSyncStatus(cloudConfigured()?'syncing':'disabled');
if(cloudConfigured()){rtConnect();refreshGoogleStatus()}
// tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000);
// Welcome screen — só pra usuários sem login
setTimeout(maybeShowWelcome,300);
// Retoma polling do OAuth se app foi morto durante login Google
setTimeout(resumePollingIfPending,500);
})();
// Re-tenta init Google Sign-In quando o script async carrega
window.addEventListener('load',()=>setTimeout(()=>{if(document.getElementById('welcome-screen').style.display==='flex')initGoogleSignIn()},500));
document.addEventListener('visibilitychange',async()=>{
if(document.visibilityState==='visible'){
if(tracking.active&&!tracking.wakeLock)await requestWakeLock();
if(anchorWatch.active&&!anchorWatch.wakeLock)await requestAnchorWakeLock();
// Reconecta WS ao voltar ao foreground
if(cloudConfigured()&&(!_wsConn||_wsConn.readyState!==WebSocket.OPEN))rtConnect();
// Retoma polling Google se sessão pendente
resumePollingIfPending();
}
});
window.addEventListener('online',()=>{if(cloudConfigured())rtConnect()});
window.addEventListener('offline',()=>setSyncStatus('offline'));
// ============ ANCHOR WATCH ============
const anchorWatch={active:false,watchId:null,startedAt:null,anchorPos:null,swingPos:null,radius:50,currentDist:0,maxDist:0,wakeLock:null,alarmFired:false,alarmCount:0,autoRecenter:false,recentPositions:[]};
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');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){}}
function saveAnchorState(){try{const{watchId,wakeLock,...r}=anchorWatch;localStorage.setItem(ANCHOR_KEY,JSON.stringify(r))}catch(e){}}
async function openAnchorSetup(){
if(tracking.active){toast('Pare o rastreio antes de fundear');return}
if(!navigator.geolocation){toast('Sem GPS no dispositivo');return}
document.getElementById('anchor-setup-status').textContent='Aguardando posição do GPS…';
document.getElementById('anchor-coord').textContent='—';
document.getElementById('anchor-confirm-btn').disabled=true;
document.getElementById('anchor-radius').value=50;
updateRadiusLabel(50);
pendingAnchorPos=null;
updateContactsSummary();
initAnchorCalc();
openModal('anchor-setup-modal');
// tentar obter posição
navigator.geolocation.getCurrentPosition(
pos=>{
pendingAnchorPos={lat:pos.coords.latitude,lng:pos.coords.longitude,acc:pos.coords.accuracy};
document.getElementById('anchor-setup-status').textContent=`Precisão do GPS: ±${Math.round(pos.coords.accuracy)}m`;
document.getElementById('anchor-coord').textContent=`${pendingAnchorPos.lat.toFixed(6)}, ${pendingAnchorPos.lng.toFixed(6)}`;
document.getElementById('anchor-confirm-btn').disabled=false;
},
err=>{document.getElementById('anchor-setup-status').textContent='Erro: '+err.message},
{enableHighAccuracy:true,timeout:20000,maximumAge:0}
);
}
// ============ ANCHOR CALCULATOR (modal) ============
let anchorCalcLastRecommendedRadius=null;
function initAnchorCalc(){
// Atualiza label de embarcação no título
const b=activeBoat();
const titleEl=document.getElementById('anchor-modal-title');
if(titleEl)titleEl.textContent=b?`Fundear · ${b.name}`:'Fundear';
// Atualiza unidades nos labels
const u=`(${lengthUnit()})`;
const dEl=document.getElementById('anchor-depth-unit');if(dEl)dEl.textContent=u;
const cEl=document.getElementById('anchor-chain-unit');if(cEl)cEl.textContent=u;
// Reset inputs
document.getElementById('anchor-depth').value='';
document.getElementById('anchor-chain').value='';
// Pré-popular vento se Windy/OpenMeteo já fetchou
const wKn=currentWindKnots();
const wEl=document.getElementById('anchor-wind');
const srcEl=document.getElementById('anchor-wind-source');
if(wKn!=null){
wEl.value=Math.round(wKn);
if(srcEl)srcEl.textContent=`auto: ${weather.data?.provider==='windy'?'Windy':'Open-Meteo'}`;
}else{
wEl.value='';
if(srcEl)srcEl.textContent='manual (sem GPS na meteo ainda)';
}
document.getElementById('anchor-apply-recommend').style.display='none';
document.getElementById('anchor-calc-result').innerHTML='';
recalcAnchor();
}
function recalcAnchor(){
const depthDisplay=parseFloat(document.getElementById('anchor-depth').value);
const chainDisplay=parseFloat(document.getElementById('anchor-chain').value);
const windKn=parseFloat(document.getElementById('anchor-wind').value)||0;
const depth=isNaN(depthDisplay)?null:lenFromInput(depthDisplay);
const chain=isNaN(chainDisplay)?null:lenFromInput(chainDisplay);
const b=activeBoat();
const boatLength=b?.length||0;
const boatType=b?.type||'sailing';
const out=document.getElementById('anchor-calc-result');
if(!depth){
out.classList.add('full');
out.innerHTML=`<div class="anchor-calc-advice">${anchorAdvice(windKn,boatType)}<br><small style="color:var(--sepia)">Informe a profundidade pra calcular.</small></div>`;
document.getElementById('anchor-apply-recommend').style.display='none';
return;
}
out.classList.remove('full');
const rec=recommendChain(depth,windKn,boatType);
const status=anchorStatus(chain,depth,windKn);
const swing=calcSwingRadius(chain,depth,boatLength);
// Sugestão de raio: swing radius + buffer GPS (15m) ou se não tiver chain, baseado em recomendação
const recommendedRadiusM=swing
? Math.ceil(swing+15)
: Math.ceil(Math.sqrt(Math.max(0,rec.chainM*rec.chainM-depth*depth))+boatLength+15);
anchorCalcLastRecommendedRadius=recommendedRadiusM;
const advice=anchorAdvice(windKn,boatType);
const adviceClass=status.level==='danger'?'danger':status.level==='warn'?'warn':status.level==='ok'?'ok':'';
out.innerHTML=`
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Amarra recomendada</div>
<div class="anchor-calc-stat-value">${fmtLen(rec.chainM,0)}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">${rec.ratio.toFixed(1)}:1 · ${rec.condition}</div>
</div>
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Status atual</div>
<div class="anchor-calc-stat-value ${status.level}">${status.level==='ok'?'✓ OK':status.level==='warn'?'⚠ JUSTO':status.level==='danger'?'✗ INSUF.':'—'}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">${escapeHtml(status.msg)}</div>
</div>
${swing?`
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Raio de giro</div>
<div class="anchor-calc-stat-value">${fmtLen(swing,0)}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">amarra→popa + barco</div>
</div>
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Raio sugerido (alarme)</div>
<div class="anchor-calc-stat-value">${fmtLen(recommendedRadiusM,0)}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">+15m buffer GPS</div>
</div>`:''}
<div class="anchor-calc-advice ${adviceClass}">${advice}${b?` · <strong>${BOAT_TYPES[boatType].icon} ${b.name}</strong> ${b.length?`(${fmtLen(b.length,1)})`:''}`:''}</div>
`;
document.getElementById('anchor-apply-recommend').style.display=swing?'inline-flex':'none';
}
function applyRecommendedRadius(){
if(!anchorCalcLastRecommendedRadius)return;
// Slider só aceita 15-200m
const v=Math.max(15,Math.min(200,anchorCalcLastRecommendedRadius));
document.getElementById('anchor-radius').value=v;
updateRadiusLabel(v);
toast(`Raio de alarme ajustado para ${v}m`);
}
function updateRadiusLabel(v){document.getElementById('anchor-radius-val').textContent=`${v} m`}
function updateContactsSummary(){
const el=document.getElementById('anchor-contacts-summary');
if(!el)return;
const n=state.contacts.length;
const wh=state.webhooks||{};
const whCount=(wh.telegram?.token?1:0)+(wh.discord?.url?1:0)+(wh.generic?.url?1:0);
let parts=[];
if(n>0)parts.push(`${n} contato${n>1?'s':''}`);
if(whCount>0)parts.push(`${whCount} webhook${whCount>1?'s':''} (auto)`);
if(parts.length===0){el.textContent='Nenhum contato ou webhook configurado';el.style.color='var(--storm)'}
else{el.textContent=parts.join(' · ');el.style.color='var(--algae)'}
}
async function confirmAnchor(){
if(!pendingAnchorPos){toast('Aguarde o GPS');return}
anchorWatch.active=true;
anchorWatch.startedAt=Date.now();
anchorWatch.anchorPos=pendingAnchorPos;
anchorWatch.swingPos={lat:pendingAnchorPos.lat,lng:pendingAnchorPos.lng};
anchorWatch.radius=parseInt(document.getElementById('anchor-radius').value);
anchorWatch.currentDist=0;
anchorWatch.maxDist=0;
anchorWatch.alarmFired=false;
anchorWatch.alarmCount=0;
anchorWatch.autoRecenter=false;
anchorWatch.recentPositions=[];
saveAnchorState();
await requestAnchorWakeLock();
startAnchorGPS();
anchorTimer=setInterval(updateAnchorUI,1000);
// notify cloud + start heartbeat
cloudAnchorStart();
startHeartbeat();
closeModal('anchor-setup-modal');
openAnchorWatchModal();
renderAnchorBanner();
toast('Vigia ativada · âncora marcada');
}
function startAnchorGPS(){
let retry=0;
function tryStart(){
anchorWatch.watchId=navigator.geolocation.watchPosition(
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){
if(!anchorWatch.active||!anchorWatch.anchorPos)return;
if(pos.coords.accuracy>50)return;
const cur={lat:pos.coords.latitude,lng:pos.coords.longitude};
const center=anchorWatch.swingPos||anchorWatch.anchorPos;
const d=haversine(center,cur);
anchorWatch.currentDist=d;
anchorWatch.lastPos=cur;
if(d>anchorWatch.maxDist)anchorWatch.maxDist=d;
// mantém últimos 30 pontos para auto-recenter
anchorWatch.recentPositions.push({lat:cur.lat,lng:cur.lng,ts:Date.now()});
if(anchorWatch.recentPositions.length>30)anchorWatch.recentPositions.shift();
saveAnchorState();
if(d>anchorWatch.radius&&!anchorWatch.alarmFired){
anchorWatch.alarmFired=true;
anchorWatch.alarmCount++;
triggerAnchorAlarm();
}else if(d<=anchorWatch.radius*0.85&&anchorWatch.alarmFired){
anchorWatch.alarmFired=false;
}
updateAnchorUI();
updateAnchorMap(cur);
}
function recenterSwing(){
if(!anchorWatch.active||!anchorWatch.lastPos)return;
if(!confirm('Definir a posição atual do barco como novo centro de giro?\n\nIsso é útil quando o barco já se acomodou na corrente/vento e não está exatamente sobre a âncora.'))return;
anchorWatch.swingPos={lat:anchorWatch.lastPos.lat,lng:anchorWatch.lastPos.lng};
saveAnchorState();
drawAnchorOnMap();
updateAnchorUI();
toast('Centro recentrado');
}
function toggleAutoRecenter(){
anchorWatch.autoRecenter=!anchorWatch.autoRecenter;
const btn=document.getElementById('auto-recenter-btn');
if(btn)btn.textContent='Auto: '+(anchorWatch.autoRecenter?'on':'off');
if(anchorWatch.autoRecenter){
autoRecenterTimer=setInterval(autoRecenterCheck,10*60*1000); // a cada 10min
toast('Auto-recentro ativado · ajusta a cada 10 min');
}else{
if(autoRecenterTimer){clearInterval(autoRecenterTimer);autoRecenterTimer=null}
toast('Auto-recentro desativado');
}
saveAnchorState();
}
function autoRecenterCheck(){
if(!anchorWatch.active||!anchorWatch.autoRecenter)return;
const pts=anchorWatch.recentPositions;
if(pts.length<10)return;
// média dos últimos 10 pontos
const recent=pts.slice(-10);
const avgLat=recent.reduce((s,p)=>s+p.lat,0)/recent.length;
const avgLng=recent.reduce((s,p)=>s+p.lng,0)/recent.length;
// só recentra se a diferença for significativa (>5m do swingPos atual) E moderada (<radius*0.5)
const cur=anchorWatch.swingPos;
const shift=haversine(cur,{lat:avgLat,lng:avgLng});
if(shift<5||shift>anchorWatch.radius*0.5)return;
anchorWatch.swingPos={lat:avgLat,lng:avgLng};
saveAnchorState();
drawAnchorOnMap();
toast(`Auto-recentro · ${shift.toFixed(1)}m`);
}
function updateAnchorUI(){
if(!anchorWatch.active)return;
const d=anchorWatch.currentDist;
document.getElementById('aw-distance').innerHTML=`${Math.round(d)}<span class="anchor-stat-unit">m</span>`;
document.getElementById('aw-radius').innerHTML=`${anchorWatch.radius}<span class="anchor-stat-unit">m</span>`;
document.getElementById('aw-duration').textContent=fmtDuration((Date.now()-anchorWatch.startedAt)/1000);
// status color
const distEl=document.getElementById('aw-distance');
distEl.classList.remove('warn','alarm');
if(d>anchorWatch.radius)distEl.classList.add('alarm');
else if(d>anchorWatch.radius*0.8)distEl.classList.add('warn');
// banner update
const bd=document.getElementById('ab-distance');
if(bd)bd.textContent=Math.round(d)+'m';
const bs=document.getElementById('ab-status');
if(bs){
if(d>anchorWatch.radius){bs.textContent='ALERTA · derivando';bs.className='anchor-status warn'}
else if(d>anchorWatch.radius*0.8){bs.textContent='Atenção · próximo do limite';bs.className='anchor-status warn'}
else{bs.textContent='Em segurança';bs.className='anchor-status safe'}
}
// also update alarm distance display if visible
const ad=document.getElementById('alarm-distance');
if(ad&&document.getElementById('alarm-screen').classList.contains('show')){
ad.innerHTML=`${Math.round(d)}<span style="font-size:24px">m</span>`;
}
}
function openAnchorWatchModal(){
openModal('anchor-watch-modal');
setTimeout(()=>{
if(!anchorMap){anchorMap=L.map('anchor-map').setView([anchorWatch.anchorPos.lat,anchorWatch.anchorPos.lng],17);addMapLayers(anchorMap)}
setTimeout(()=>anchorMap.invalidateSize(),100);
drawAnchorOnMap();
document.getElementById('aw-radius-slider').value=anchorWatch.radius;
document.getElementById('aw-radius-slider-val').textContent=anchorWatch.radius+' m';
updateAnchorUI();
},50);
}
function drawAnchorOnMap(){
if(!anchorMap||!anchorWatch.anchorPos)return;
const a=[anchorWatch.anchorPos.lat,anchorWatch.anchorPos.lng];
const sp=anchorWatch.swingPos||anchorWatch.anchorPos;
const s=[sp.lat,sp.lng];
// âncora (ponto fixo onde foi largada)
if(!anchorMarker){
const anchorIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="28" height="28" fill="#a07832" stroke="#0e2a3d" stroke-width="1"><circle cx="12" cy="5" r="2.5"/><path d="M11 7v15h2V7z"/><path d="M5 12a7 7 0 0 0 14 0" stroke="#0e2a3d" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M8 10h8" stroke="#0e2a3d" stroke-width="2" stroke-linecap="round"/></svg>',iconSize:[28,28],iconAnchor:[14,14],className:''});
anchorMarker=L.marker(a,{icon:anchorIcon,zIndexOffset:1000,title:'Âncora'}).addTo(anchorMap);
}else anchorMarker.setLatLng(a);
// centro de giro (swing pos) - pequeno marcador se diferente da âncora
const isDifferent=haversine(anchorWatch.anchorPos,sp)>3;
if(isDifferent){
if(!swingMarker){
const swingIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="14" height="14" fill="#1f5b76" stroke="#fff" stroke-width="1.5"><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2" fill="#fff"/></svg>',iconSize:[14,14],iconAnchor:[7,7],className:''});
swingMarker=L.marker(s,{icon:swingIcon,zIndexOffset:900,title:'Centro de giro'}).addTo(anchorMap);
}else swingMarker.setLatLng(s);
if(swingAnchorLine)swingAnchorLine.remove();
swingAnchorLine=L.polyline([a,s],{color:'#a07832',weight:1.5,opacity:.5,dashArray:'2,4'}).addTo(anchorMap);
}else{
if(swingMarker){swingMarker.remove();swingMarker=null}
if(swingAnchorLine){swingAnchorLine.remove();swingAnchorLine=null}
}
// círculo de raio centrado em swingPos
if(anchorCircle)anchorCircle.remove();
anchorCircle=L.circle(s,{radius:anchorWatch.radius,color:'#a07832',weight:2,fillColor:'#a07832',fillOpacity:.08,dashArray:'5,5'}).addTo(anchorMap);
// posição do barco
if(anchorWatch.lastPos){
const c=[anchorWatch.lastPos.lat,anchorWatch.lastPos.lng];
if(!anchorBoatMarker){
const boatIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="22" height="22" fill="#1f5b76" stroke="#0e2a3d" stroke-width="1.5"><path d="M3 18h18l-2-6H5z"/><path d="M12 3v9" stroke-width="1.5"/></svg>',iconSize:[22,22],iconAnchor:[11,11],className:''});
anchorBoatMarker=L.marker(c,{icon:boatIcon}).addTo(anchorMap);
}else anchorBoatMarker.setLatLng(c);
if(anchorLine)anchorLine.remove();
anchorLine=L.polyline([s,c],{color:'#1f5b76',weight:2,opacity:.6,dashArray:'3,3'}).addTo(anchorMap);
}
}
function updateAnchorMap(cur){
if(!anchorMap)return;
drawAnchorOnMap();
}
function adjustAnchorRadius(v){
anchorWatch.radius=parseInt(v);
document.getElementById('aw-radius-slider-val').textContent=v+' m';
saveAnchorState();
drawAnchorOnMap();
updateAnchorUI();
// re-evaluate alarm state
if(anchorWatch.currentDist<=anchorWatch.radius*0.85)anchorWatch.alarmFired=false;
}
function minimizeAnchor(){closeModal('anchor-watch-modal')}
async function stopAnchorWatch(){
if(!confirm('Encerrar vigia de fundeio?'))return;
if(anchorWatch.watchId!=null)navigator.geolocation.clearWatch(anchorWatch.watchId);
await releaseAnchorWakeLock();
clearInterval(anchorTimer);
if(autoRecenterTimer){clearInterval(autoRecenterTimer);autoRecenterTimer=null}
stopHeartbeat();
cloudAnchorStop();
// salvar no histórico
const entry={
id:uid(),
startedAt:anchorWatch.startedAt,
endedAt:Date.now(),
anchorPos:anchorWatch.anchorPos,
swingPos:anchorWatch.swingPos,
radius:anchorWatch.radius,
maxDist:anchorWatch.maxDist,
alarmCount:anchorWatch.alarmCount,
lastPos:anchorWatch.lastPos
};
state.anchorHistory.unshift(entry);
if(state.anchorHistory.length>50)state.anchorHistory=state.anchorHistory.slice(0,50);
saveState();
anchorWatch.active=false;
anchorWatch.alarmFired=false;
localStorage.removeItem(ANCHOR_KEY);
closeModal('anchor-watch-modal');
if(anchorMap){setTimeout(()=>{anchorMap.remove();anchorMap=null;anchorMarker=null;swingMarker=null;anchorBoatMarker=null;anchorCircle=null;anchorLine=null;swingAnchorLine=null},300)}
stopAlarm();
renderAnchorBanner();
toast('Vigia encerrada · salva no histórico');
}
function renderAnchorBanner(){
const el=document.getElementById('anchor-banner');
if(!el)return;
if(anchorWatch.active){
const d=Math.round(anchorWatch.currentDist);
const status=d>anchorWatch.radius?'ALERTA · derivando':d>anchorWatch.radius*0.8?'Atenção · próximo do limite':'Em segurança';
const cls=d>anchorWatch.radius*0.8?'warn':'safe';
el.innerHTML=`<div class="anchor-card active">
<div class="gps-head" style="position:relative"><h3 style="color:var(--brass-bright)">Vigia de fundeio</h3></div>
<div class="anchor-status ${cls}" id="ab-status" style="margin-bottom:14px;position:relative">${status}</div>
<div class="gps-stats" style="position:relative">
<div class="gps-stat"><div class="gps-stat-label">Distância</div><div class="gps-stat-value"><span id="ab-distance">${d}m</span></div></div>
<div class="gps-stat"><div class="gps-stat-label">Raio</div><div class="gps-stat-value">${anchorWatch.radius}<span class="gps-stat-unit">m</span></div></div>
<div class="gps-stat"><div class="gps-stat-label">Tempo</div><div class="gps-stat-value">${fmtDuration((Date.now()-anchorWatch.startedAt)/1000)}</div></div>
</div>
<div class="gps-actions" style="position:relative">
<button class="btn" onclick="openAnchorWatchModal()" style="background:transparent;color:var(--brass-bright);border-color:var(--brass)">Ver carta</button>
<button class="btn" onclick="stopAnchorWatch()" style="background:var(--storm);color:#fff;border-color:var(--storm)">Levantar âncora</button>
</div>
</div>`;
}else{
const histCount=state.anchorHistory.length;
el.innerHTML=`<div class="anchor-card">
<div class="gps-head"><h3 style="font-family:var(--f-display);font-style:italic;font-weight:500;font-size:18px;margin:0;color:var(--ink-deep)">Vigia de fundeio</h3></div>
<div class="gps-sub" style="margin-bottom:14px">Marca a posição da âncora · alarme se o Shivao derivar</div>
<div class="gps-actions"><button class="btn btn-block btn-big" style="background:var(--algae);color:#fff;border-color:var(--algae)" onclick="openAnchorSetup()">Fundear</button></div>
${histCount>0?`<div style="text-align:center;margin-top:10px"><button class="btn btn-sm" onclick="openAnchorHistory()" style="background:transparent;border:none;color:var(--brass);font-family:var(--f-mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;padding:4px;text-decoration:underline">Ver histórico (${histCount})</button></div>`:''}
</div>`;
}
}
// ============ ALARM ============
let alarmCtx=null,alarmOscillators=[],alarmInterval=null,vibrationInterval=null,alarmWakeLock=null;
async function triggerAnchorAlarm(){
// wake lock for screen
try{if('wakeLock' in navigator)alarmWakeLock=await navigator.wakeLock.request('screen')}catch(e){}
playAlarmSound();
startVibration();
showAlarmScreen();
// dispara webhooks automáticos (Telegram, Discord, genérico) - sem precisar tocar
dispatchWebhooks(buildAlarmText()).then(r=>{
if(r.sent.length)console.log('webhooks ok:',r.sent);
if(r.failed.length)console.warn('webhooks falhas:',r.failed);
});
// dispara também no servidor para notificar todos os contatos (se cloud configurada)
cloudAnchorAlarm();
}
function playAlarmSound(){
try{
if(!alarmCtx)alarmCtx=new (window.AudioContext||window.webkitAudioContext)();
if(alarmCtx.state==='suspended')alarmCtx.resume();
stopAlarmSound(); // clear any existing
let toggle=false;
const playTone=(freq)=>{
const osc=alarmCtx.createOscillator();
const gain=alarmCtx.createGain();
osc.type='square';
osc.frequency.value=freq;
gain.gain.value=0;
gain.gain.setValueAtTime(0,alarmCtx.currentTime);
gain.gain.linearRampToValueAtTime(.45,alarmCtx.currentTime+.02);
gain.gain.linearRampToValueAtTime(.45,alarmCtx.currentTime+.32);
gain.gain.linearRampToValueAtTime(0,alarmCtx.currentTime+.36);
osc.connect(gain);gain.connect(alarmCtx.destination);
osc.start();osc.stop(alarmCtx.currentTime+.4);
alarmOscillators.push(osc);
};
playTone(900);
alarmInterval=setInterval(()=>{toggle=!toggle;playTone(toggle?900:1200)},420);
}catch(e){console.error('alarm sound:',e)}
}
function stopAlarmSound(){
if(alarmInterval){clearInterval(alarmInterval);alarmInterval=null}
alarmOscillators.forEach(o=>{try{o.stop()}catch(e){}});
alarmOscillators=[];
}
function startVibration(){
if(!('vibrate' in navigator))return;
navigator.vibrate([800,300,400,300,800,500]);
vibrationInterval=setInterval(()=>navigator.vibrate([800,300,400,300,800,500]),3100);
}
function stopVibration(){
if(vibrationInterval){clearInterval(vibrationInterval);vibrationInterval=null}
if('vibrate' in navigator)navigator.vibrate(0);
}
function stopAlarm(){
stopAlarmSound();
stopVibration();
document.getElementById('alarm-screen').classList.remove('show');
if(alarmWakeLock){try{alarmWakeLock.release()}catch(e){}alarmWakeLock=null}
}
function showAlarmScreen(){
const el=document.getElementById('alarm-screen');
document.getElementById('alarm-title').textContent=`${state.boat.name} derivando!`;
// build action buttons for each contact
renderAlarmActions();
el.classList.add('show');
}
function dismissAlarm(){
if(!confirm('Cancelar o alarme? O Shivao ainda está fora do raio.\n\nIsto silencia o som mas NÃO encerra a vigia.'))return;
stopAlarm();
}
function testAlarmSound(){
playAlarmSound();
startVibration();
setTimeout(()=>{stopAlarmSound();stopVibration()},2500);
toast('Alarme testado · 2.5s');
}
function renderAlarmActions(){
const el=document.getElementById('alarm-actions');
if(!state.contacts.length){
el.innerHTML=`<div style="text-align:center;color:rgba(255,255,255,.85);font-family:var(--f-display);font-style:italic;font-size:15px;padding:14px">Nenhum contato cadastrado.<br>Configure em Vigia → Contatos.</div>`;
return;
}
const waSvg='<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.5 14.4c-.3-.1-1.7-.8-2-.9-.3-.1-.5-.1-.7.1l-1 1.2c-.2.2-.4.2-.7.1-.9-.4-1.7-.9-2.5-1.7-.7-.7-1.2-1.5-1.7-2.4-.1-.3-.1-.5.1-.7l.7-.7c.2-.2.2-.4.3-.6 0-.2 0-.4-.1-.6L9 6.4c-.1-.3-.4-.5-.7-.5h-1c-.4 0-.7.1-.9.4-.5.5-.8 1.2-.8 2 0 1.7.6 3.3 1.7 4.7.7 1.1 1.5 2.1 2.5 3 1 .8 2 1.5 3.2 2 1.6.7 2.9 1 3.6 1 .8 0 1.5-.3 2-.8.3-.3.4-.6.4-1v-.9c0-.4-.2-.5-.5-.5z"/><path d="M19 5C17.1 3.1 14.6 2 12 2 9.4 2 6.9 3.1 5 5 3.1 6.9 2 9.4 2 12c0 1.7.5 3.4 1.4 4.9L2 22l5.2-1.4c1.5.8 3.1 1.2 4.8 1.2 5.5 0 10-4.5 10-10 0-2.6-1.1-5.1-3-6.8zm-7 15.1c-1.5 0-3-.4-4.3-1.2l-.3-.2-3.1.8.8-3-.2-.3c-2.4-3.9-1.2-9.1 2.7-11.5C12 1.5 17 2.7 19.4 6.7c2.4 3.9 1.2 9.1-2.7 11.5-1.4.8-2.9 1.2-4.7.9z" opacity=".5"/></svg>';
const smsSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12c0 4-4 7-9 7-1.5 0-3-.3-4.3-.9L3 19l1-3.5C3.4 14.4 3 13.2 3 12c0-4 4-7 9-7s9 3 9 7z"/></svg>';
const mailSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 7l9 6 9-6"/></svg>';
const buttons=[];
for(const c of state.contacts){
const msg=buildAlarmMessage(c);
if(c.channels?.includes('whatsapp')&&c.phone)buttons.push(`<button class="alarm-btn wa" onclick="sendWhatsApp('${c.phone}',\`${msg.replace(/`/g,'\\`')}\`)">${waSvg} WhatsApp · ${escapeHtml(c.name)}</button>`);
if(c.channels?.includes('sms')&&c.phone)buttons.push(`<button class="alarm-btn sms" onclick="sendSMS('${c.phone}',\`${msg.replace(/`/g,'\\`')}\`)">${smsSvg} SMS · ${escapeHtml(c.name)}</button>`);
if(c.channels?.includes('email')&&c.email)buttons.push(`<button class="alarm-btn mail" onclick="sendEmail('${c.email}',\`${msg.replace(/`/g,'\\`')}\`)">${mailSvg} E-mail · ${escapeHtml(c.name)}</button>`);
}
el.innerHTML=buttons.join('');
}
function buildAlarmMessage(contact){
const pos=anchorWatch.lastPos||anchorWatch.anchorPos||{lat:0,lng:0};
const lat=pos.lat.toFixed(6);
const lng=pos.lng.toFixed(6);
const now=new Date();
const hora=`${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
const dist=Math.round(anchorWatch.currentDist);
const tpl=state.alarmTemplate||'';
return tpl.replace(/{LAT}/g,lat).replace(/{LNG}/g,lng).replace(/{HORA}/g,hora).replace(/{DIST}/g,dist).replace(/{NOME}/g,state.boat.name||'Veleiro');
}
function sendWhatsApp(phone,msg){
const cleanPhone=phone.replace(/\D/g,'');
const url=`https://wa.me/${cleanPhone}?text=${encodeURIComponent(msg)}`;
window.open(url,'_blank');
}
function sendSMS(phone,msg){
const url=`sms:+${phone.replace(/\D/g,'')}?body=${encodeURIComponent(msg)}`;
window.location.href=url;
}
function sendEmail(email,msg){
const subject=`ALERTA: ${state.boat.name||'Veleiro'} derivando`;
const url=`mailto:${email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(msg)}`;
window.location.href=url;
}
// ============ CONTACTS ============
function openContactsModal(){
renderContactsList();
document.getElementById('alarm-message-template').value=state.alarmTemplate||'';
['contact-name','contact-phone','contact-email'].forEach(k=>document.getElementById(k).value='');
document.getElementById('contact-ch-wa').checked=true;
document.getElementById('contact-ch-sms').checked=true;
document.getElementById('contact-ch-mail').checked=false;
// carrega webhooks
document.getElementById('wh-tg-token').value=state.webhooks?.telegram?.token||'';
document.getElementById('wh-tg-chats').value=state.webhooks?.telegram?.chatIds||'';
document.getElementById('wh-discord').value=state.webhooks?.discord?.url||'';
document.getElementById('wh-generic').value=state.webhooks?.generic?.url||'';
document.getElementById('webhook-test-result').innerHTML='';
openModal('contacts-modal');
}
function renderContactsList(){
const el=document.getElementById('contacts-list');
if(!state.contacts.length){el.innerHTML='<div style="text-align:center;padding:18px;color:var(--sepia);font-family:var(--f-display);font-style:italic;font-size:14px">Nenhum contato cadastrado</div>';return}
el.innerHTML=state.contacts.map(c=>{
const channels=(c.channels||[]).map(ch=>`<span class="channel-pill on">${ch==='whatsapp'?'WhatsApp':ch==='sms'?'SMS':'E-mail'}</span>`).join('');
return `<div class="contact-card">
<div class="contact-info">
<div class="contact-name">${escapeHtml(c.name)}</div>
<div class="contact-meta">${c.phone?'+'+escapeHtml(c.phone):''}${c.phone&&c.email?' · ':''}${c.email?escapeHtml(c.email):''}</div>
<div class="contact-channels">${channels}</div>
</div>
<button class="icon-btn del" onclick="removeContact('${c.id}')">×</button>
</div>`;
}).join('');
}
function addContact(){
const name=document.getElementById('contact-name').value.trim();
const phone=document.getElementById('contact-phone').value.replace(/\D/g,'');
const email=document.getElementById('contact-email').value.trim();
if(!name){toast('Informe o nome');return}
if(!phone&&!email){toast('Informe telefone ou e-mail');return}
const channels=[];
if(document.getElementById('contact-ch-wa').checked&&phone)channels.push('whatsapp');
if(document.getElementById('contact-ch-sms').checked&&phone)channels.push('sms');
if(document.getElementById('contact-ch-mail').checked&&email)channels.push('email');
if(!channels.length){toast('Escolha ao menos um canal');return}
state.contacts.push({id:uid(),name,phone,email,channels});
saveState();
renderContactsList();
['contact-name','contact-phone','contact-email'].forEach(k=>document.getElementById(k).value='');
toast('Contato adicionado');
}
function removeContact(id){
if(!confirm('Remover este contato?'))return;
state.contacts=state.contacts.filter(c=>c.id!==id);
saveState();
renderContactsList();
updateContactsSummary();
}
function saveContactsConfig(){
state.alarmTemplate=document.getElementById('alarm-message-template').value;
state.webhooks={
telegram:{token:document.getElementById('wh-tg-token').value.trim(),chatIds:document.getElementById('wh-tg-chats').value.trim()},
discord:{url:document.getElementById('wh-discord').value.trim()},
generic:{url:document.getElementById('wh-generic').value.trim()}
};
saveState();
closeModal('contacts-modal');
updateContactsSummary();
toast('Configuração salva');
}
// ============ CLOUD SYNC ============
let heartbeatInterval=null;
let cloudAutoSyncTimeout=null;
function cloudConfigured(){
if(!state.cloud||!state.cloud.url)return false;
// Auth: tem JWT (login Google/email) OU BOAT_TOKEN (avançado/legacy)
if(state.auth&&state.auth.accessToken)return true;
if(state.cloud.token)return true;
return false;
}
function cloudUrl(path){return state.cloud.url.replace(/\/$/,'')+path}
async function cloudFetch(path,opts={}){
if(!cloudConfigured())throw new Error('Nuvem não configurada');
// Usa JWT se logado (multi-tenant SaaS), senão BOAT_TOKEN legado (single-tenant pessoal)
const auth=(state.auth&&state.auth.accessToken)?state.auth.accessToken:state.cloud.token;
const doFetch=()=>fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth}`,'Content-Type':'application/json',...(opts.headers||{})}});
let r=await doFetch();
// 401 com JWT? Tenta refresh + retry 1×
if(r.status===401&&state.auth&&state.auth.refreshToken){
const ok=await authRefresh();
if(ok){const auth2=state.auth.accessToken;r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth2}`,'Content-Type':'application/json',...(opts.headers||{})}})}
}
if(!r.ok){let detail='';try{const j=await r.clone().json();detail=j.error||JSON.stringify(j.issues||{})}catch{}throw new Error(`HTTP ${r.status}${detail?' · '+detail:''}`)}
return r;
}
// ============ WELCOME / LOGIN SCREEN ============
// Default cloud URL — usuários não-avançados não precisam configurar nada
const DEFAULT_CLOUD_URL='https://shivao.pontualtech.work';
const GOOGLE_CLIENT_ID_FRONTEND='989184529532-uceun7l7a12e63fdrkilnh8vml0v0lv4.apps.googleusercontent.com';
function maybeShowWelcome(){
const ws=document.getElementById('welcome-screen');
if(!ws)return;
// Mostra welcome SE: não tem auth nem token configurado E não dispensou explicitamente
const dismissed=localStorage.getItem('shivao_welcome_dismissed')==='1';
// Logado via JWT (Google/email) → não precisa welcome
const hasJwtAuth=state.auth&&state.auth.accessToken;
// Conectado via BOAT_TOKEN avançado → não precisa welcome
const hasBoatToken=state.cloud&&state.cloud.token;
const isAuthenticated=hasJwtAuth||hasBoatToken;
const needsSetup=!isAuthenticated&&!dismissed;
ws.style.display=needsSetup?'flex':'none';
// Se mostrar, prepara o Google Sign-In quando carregar
if(needsSetup&&window.google?.accounts?.id){
initGoogleSignIn();
}
}
function welcomeShowEmail(){
document.getElementById('welcome-email-form').style.display='block';
document.querySelector('.welcome-buttons').style.display='none';
document.getElementById('welcome-advanced-form').style.display='none';
}
function welcomeShowAdvanced(){
document.getElementById('welcome-advanced-form').style.display='block';
document.querySelector('.welcome-buttons').style.display='none';
document.getElementById('welcome-email-form').style.display='none';
}
function welcomeBack(){
document.querySelector('.welcome-buttons').style.display='flex';
document.getElementById('welcome-email-form').style.display='none';
document.getElementById('welcome-advanced-form').style.display='none';
}
function welcomeSwitchTab(t){
document.getElementById('we-tab-login').classList.toggle('active',t==='login');
document.getElementById('we-tab-signup').classList.toggle('active',t==='signup');
document.getElementById('we-name-field').style.display=t==='signup'?'block':'none';
document.getElementById('we-submit').textContent=t==='login'?'Entrar':'Criar conta';
}
function welcomeSkip(){
if(!confirm('Continuar sem sincronização? Seus dados ficam só neste dispositivo.'))return;
localStorage.setItem('shivao_welcome_dismissed','1');
document.getElementById('welcome-screen').style.display='none';
}
async function welcomeEmailSubmit(){
const email=document.getElementById('we-email').value.trim();
const pwd=document.getElementById('we-pwd').value;
const name=document.getElementById('we-name').value.trim();
const isSignup=document.getElementById('we-tab-signup').classList.contains('active');
const msg=document.getElementById('we-msg');
msg.style.color='var(--storm)';msg.textContent='';
if(!email||!pwd){msg.textContent='Preencha email e senha';return}
if(isSignup&&pwd.length<8){msg.textContent='Senha mínima: 8 caracteres';return}
// Garante URL hardcoded
state.cloud.url=DEFAULT_CLOUD_URL;
saveState();
try{
if(isSignup)await authSignup(email,pwd,name);
else await authLogin(email,pwd);
msg.style.color='var(--algae)';msg.textContent='Logado! Sincronizando...';
welcomeFinish();
}catch(e){msg.textContent=e.message}
}
async function welcomeAdvancedSubmit(){
const url=document.getElementById('we-srv-url').value.trim();
const tok=document.getElementById('we-srv-token').value.trim();
const msg=document.getElementById('we-srv-msg');
if(!url||!tok){msg.textContent='Preencha URL e token';return}
state.cloud={url:url.replace(/\/$/,''),token:tok,lastSync:0};
saveState();
msg.style.color='var(--algae)';msg.textContent='Conectando...';
welcomeFinish();
}
function welcomeFinish(){
document.getElementById('welcome-screen').style.display='none';
// Conecta sync automaticamente
rtConnect();
refreshGoogleStatus();
// Pull inicial pra puxar dados existentes da conta
setTimeout(()=>autoPullNow().catch(()=>{}),500);
}
function initGoogleSignIn(){
if(!window.google?.accounts?.id||window._gsiInited)return;
try{
window.google.accounts.id.initialize({
client_id:GOOGLE_CLIENT_ID_FRONTEND,
callback:onGoogleCredential,
auto_select:false,
ux_mode:'popup',
});
window._gsiInited=true;
}catch(e){console.warn('[gsi] init failed',e)}
}
function isInWebViewApp(){
// Detecta Capacitor ou WebView (Android wv) — onde GSI popup não funciona bem
if(window.Capacitor)return true;
const ua=navigator.userAgent;
if(/wv|; ?Version\//.test(ua)&&/Android/.test(ua))return true;
return false;
}
function welcomeGoogleClick(){
// Em apps Capacitor/WebView, GSI popup não funciona — usar redirect + polling
if(isInWebViewApp()){
return startGoogleRedirectFlow();
}
// Browser web: tenta GSI popup
if(!window.google?.accounts?.id){
toast('Google Sign-In carregando, tentando alternativa...');
return startGoogleRedirectFlow();
}
initGoogleSignIn();
try{
window.google.accounts.id.prompt((notif)=>{
// Se prompt foi bloqueado (FedCM, popup blocker etc), fallback pro redirect
if(notif.isNotDisplayed?.()||notif.isSkippedMoment?.()){
startGoogleRedirectFlow();
}
});
}catch(e){
console.warn('[gsi] prompt failed',e);
startGoogleRedirectFlow();
}
}
let _googleAuthPolling=null;
const PENDING_SESSION_KEY='shivao_pending_google_session';
function savePendingSession(session){
localStorage.setItem(PENDING_SESSION_KEY,JSON.stringify({session,startedAt:Date.now()}));
}
function getPendingSession(){
try{
const raw=localStorage.getItem(PENDING_SESSION_KEY);
if(!raw)return null;
const d=JSON.parse(raw);
if(Date.now()-d.startedAt>10*60*1000){localStorage.removeItem(PENDING_SESSION_KEY);return null}
return d.session;
}catch(e){return null}
}
function clearPendingSession(){
localStorage.removeItem(PENDING_SESSION_KEY);
}
async function startGoogleRedirectFlow(){
toast('Abrindo Google...');
try{
state.cloud.url=DEFAULT_CLOUD_URL;
saveState();
const r=await fetch(cloudUrl('/api/auth/google/start'));
if(!r.ok)throw new Error('HTTP '+r.status);
const{url,session}=await r.json();
if(!url||!session)throw new Error('servidor sem URL/session');
// PERSISTE session no localStorage — sobrevive a app morrer/reabrir
savePendingSession(session);
// Abre URL no browser EXTERNO (em Capacitor isso usa Custom Tabs)
if(window.open){window.open(url,'_blank','noopener')}else{location.href=url}
// Inicia polling (também resume após reabrir o app via resumePollingIfPending)
startSessionPolling(session);
}catch(e){
console.warn('[gsi-redirect]',e);
toast('Erro: '+e.message);
}
}
function startSessionPolling(session){
if(_googleAuthPolling)clearInterval(_googleAuthPolling);
let tries=0;
console.log('[gsi-poll] starting for session',session);
// Faz uma chamada IMEDIATA primeiro (caso já esteja pronto)
const pollOnce=async()=>{
tries++;
if(tries>120){clearInterval(_googleAuthPolling);_googleAuthPolling=null;clearPendingSession();toast('Tempo esgotado. Tente de novo.');return}
try{
const pr=await fetch(cloudUrl('/api/auth/google/poll?session='+encodeURIComponent(session)));
if(pr.status===204)return; // ainda esperando
if(!pr.ok)return;
const j=await pr.json();
if(j.accessToken&&j.refreshToken){
clearInterval(_googleAuthPolling);_googleAuthPolling=null;
clearPendingSession();
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
toast('Bem-vindo, '+(j.user.name||j.user.email));
welcomeFinish();
if(typeof renderAuthBox==='function')renderAuthBox();
}
}catch(e){console.warn('[gsi-poll]',e.message)}
};
pollOnce(); // imediato
_googleAuthPolling=setInterval(pollOnce,2000);
}
// Retoma polling se app foi morto e reaberto durante OAuth
function resumePollingIfPending(){
const session=getPendingSession();
if(!session)return false;
if(_googleAuthPolling)return true; // já rodando
if(state.auth)return clearPendingSession(),false; // já logado
console.log('[gsi-poll] resuming session',session);
toast('Verificando login Google...');
startSessionPolling(session);
return true;
}
async function onGoogleCredential(resp){
if(!resp?.credential){toast('Sem credential do Google');return}
state.cloud.url=DEFAULT_CLOUD_URL;
saveState();
try{
const r=await fetch(cloudUrl('/api/auth/google'),{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({credential:resp.credential}),
});
const j=await r.json();
if(!r.ok)throw new Error(j.error||'login failed');
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
toast('Bem-vindo, '+(j.user.name||j.user.email));
welcomeFinish();
if(typeof renderAuthBox==='function')renderAuthBox();
}catch(e){
console.warn('[google login]',e);
toast('Falha login Google: '+e.message);
}
}
// ============ REALTIME SYNC (WebSocket + auto push/pull) ============
// Conecta ao /ws do servidor, escuta notificações de mudança de outros devices
// e dispara pull/push automáticos. Reconnect com backoff exponencial.
const SYNC_DEBOUNCE_MS=2500;
const SYNC_PULL_DEBOUNCE_MS=300;
let _wsConn=null;
let _wsReconnectTimer=null;
let _wsReconnectAttempt=0;
let _wsHeartbeatTimer=null;
let _wsPushTimer=null;
let _wsPullTimer=null;
let _wsLastPushAt=0;
let _wsSyncInFlight=false;
function getDeviceId(){
let id=localStorage.getItem('shivao_device_id');
if(!id){id='dev-'+Math.random().toString(36).slice(2,12);localStorage.setItem('shivao_device_id',id)}
return id;
}
function setSyncStatus(status,detail){
// status: 'online' | 'syncing' | 'offline' | 'disabled' | 'error'
const el=document.getElementById('sync-indicator');
if(!el)return;
el.dataset.status=status;
const labels={online:'🟢',syncing:'🟡',offline:'🔴',disabled:'⚫',error:'⚠️'};
const titles={
online:'Sincronizado em tempo real',
syncing:'Sincronizando...',
offline:'Sem conexão (mudanças salvas localmente)',
disabled:'Sync na nuvem desligado — toque em Arquivo Nuvem',
error:'Erro de sincronização'
};
el.textContent=labels[status]||'';
el.title=(titles[status]||'')+(detail?' — '+detail:'');
}
function rtConnect(){
if(!cloudConfigured()){setSyncStatus('disabled');return}
if(_wsConn&&(_wsConn.readyState===WebSocket.OPEN||_wsConn.readyState===WebSocket.CONNECTING))return;
try{
const baseUrl=state.cloud.url.replace(/\/$/,'').replace(/^http/,'ws');
const tok=(state.auth&&state.auth.accessToken)?state.auth.accessToken:state.cloud.token;
const url=`${baseUrl}/ws?token=${encodeURIComponent(tok)}&device=${encodeURIComponent(getDeviceId())}`;
_wsConn=new WebSocket(url);
setSyncStatus('syncing','conectando...');
}catch(e){
console.warn('[rt] connect failed',e);
scheduleRtReconnect();
return;
}
_wsConn.onopen=()=>{
_wsReconnectAttempt=0;
setSyncStatus('online');
// Pull inicial: garante state fresco quando conecta
schedulePull();
// Heartbeat
if(_wsHeartbeatTimer)clearInterval(_wsHeartbeatTimer);
_wsHeartbeatTimer=setInterval(()=>{
if(_wsConn&&_wsConn.readyState===WebSocket.OPEN){
try{_wsConn.send(JSON.stringify({type:'ping'}))}catch(e){}
}
},25000);
};
_wsConn.onmessage=(ev)=>{
let msg;try{msg=JSON.parse(ev.data)}catch(e){return}
if(msg.type==='hello'){
console.log('[rt] hello',msg);
}else if(msg.type==='state:changed'){
// Outro device alterou o state: pull
if(msg.originDeviceId===getDeviceId())return; // echo do nosso próprio push
schedulePull();
}else if(msg.type==='presence'){
const el=document.getElementById('sync-indicator');
if(el&&msg.count>1)el.title=`Sincronizado em tempo real · ${msg.count} dispositivos online`;
}else if(msg.type==='error'){
console.warn('[rt] server error',msg);
setSyncStatus('error',msg.code);
}
};
_wsConn.onerror=(e)=>{console.warn('[rt] ws error',e)};
_wsConn.onclose=(ev)=>{
if(_wsHeartbeatTimer){clearInterval(_wsHeartbeatTimer);_wsHeartbeatTimer=null}
_wsConn=null;
if(!cloudConfigured()){setSyncStatus('disabled');return}
setSyncStatus('offline');
if(ev.code!==1008)scheduleRtReconnect(); // 1008 = auth fail, não retentar
};
}
function scheduleRtReconnect(){
if(_wsReconnectTimer)clearTimeout(_wsReconnectTimer);
const delays=[1000,2000,5000,15000,30000,60000];
const delay=delays[Math.min(_wsReconnectAttempt,delays.length-1)];
_wsReconnectAttempt++;
_wsReconnectTimer=setTimeout(()=>rtConnect(),delay);
}
function rtDisconnect(){
if(_wsReconnectTimer){clearTimeout(_wsReconnectTimer);_wsReconnectTimer=null}
if(_wsHeartbeatTimer){clearInterval(_wsHeartbeatTimer);_wsHeartbeatTimer=null}
if(_wsConn){try{_wsConn.close(1000,'client disconnect')}catch(e){}_wsConn=null}
setSyncStatus('disabled');
}
// Push debounced — chamado de saveState. Só sobe quando configurado.
function scheduleCloudPush(){
if(!cloudConfigured())return;
if(_wsPushTimer)clearTimeout(_wsPushTimer);
_wsPushTimer=setTimeout(()=>autoPushNow().catch(()=>{}),SYNC_DEBOUNCE_MS);
}
async function autoPushNow(){
if(!cloudConfigured())return;
if(_wsSyncInFlight)return;
_wsSyncInFlight=true;
setSyncStatus('syncing','enviando...');
try{
const{cloud,...dataNoCloud}=state;
await cloudFetch('/api/data?device='+encodeURIComponent(getDeviceId()),{
method:'POST',
headers:{'X-Device-Id':getDeviceId()},
body:JSON.stringify({data:dataNoCloud}),
});
state.cloud.lastSync=Date.now();
_wsLastPushAt=Date.now();
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){}
setSyncStatus(_wsConn&&_wsConn.readyState===WebSocket.OPEN?'online':'offline');
if(typeof renderCloudStatus==='function')renderCloudStatus();
}catch(e){
console.warn('[rt] push failed',e.message);
setSyncStatus('error',e.message);
}finally{
_wsSyncInFlight=false;
}
}
// Pull debounced — chamado quando WS notifica mudança remota
function schedulePull(){
if(_wsPullTimer)clearTimeout(_wsPullTimer);
_wsPullTimer=setTimeout(()=>autoPullNow().catch(()=>{}),SYNC_PULL_DEBOUNCE_MS);
}
async function autoPullNow(){
if(!cloudConfigured())return;
if(_wsSyncInFlight)return;
// Não puxa se acabamos de empurrar (echo guard adicional)
if(Date.now()-_wsLastPushAt<1000)return;
_wsSyncInFlight=true;
setSyncStatus('syncing','baixando...');
try{
const r=await cloudFetch('/api/data');
const{data,updated_at}=await r.json();
if(!data){setSyncStatus('online');_wsSyncInFlight=false;return}
// Se o updated_at remoto é mais antigo que nosso último push, ignora (evita rollback)
if(updated_at&&_wsLastPushAt&&updated_at<_wsLastPushAt){_wsSyncInFlight=false;setSyncStatus('online');return}
const cloudKeep=state.cloud;
const authKeep=state.auth;
Object.assign(state,data);
state.cloud=cloudKeep;
if(authKeep)state.auth=authKeep;
state.cloud.lastSync=Date.now();
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){}
bindHeader();
if(typeof renderAll==='function')await renderAll();
setSyncStatus('online','recebido de outro dispositivo');
toast('🔄 Atualizado por outro dispositivo');
}catch(e){
console.warn('[rt] pull failed',e.message);
setSyncStatus('error',e.message);
}finally{
_wsSyncInFlight=false;
}
}
// ============ GOOGLE CALENDAR (sync pendencias <-> Google Agenda) ============
let _gcalEnabled=false,_gcalConnected=false,_gcalEmail=null,_gcalLastPullAt=0;
const GCAL_PULL_INTERVAL_MS=2*60*1000; // pull a cada 2min quando aba ativa
async function refreshGoogleStatus(){
if(!cloudConfigured()){
_gcalEnabled=false;
renderGoogleCard();
return;
}
try{
const r=await cloudFetch('/api/google/status');
const j=await r.json();
_gcalEnabled=!!j.enabled;
_gcalConnected=!!j.connected;
_gcalEmail=j.email||null;
renderGoogleCard();
}catch(e){
_gcalEnabled=false;
renderGoogleCard();
}
}
function renderGoogleCard(){
const card=document.getElementById('gcal-card');
const statusEl=document.getElementById('gcal-status');
const actionsEl=document.getElementById('gcal-actions');
if(!card)return;
if(!cloudConfigured()){card.style.display='none';return}
if(!_gcalEnabled){
card.style.display='block';
statusEl.textContent='Funcionalidade desativada no servidor — admin precisa configurar GOOGLE_CLIENT_ID/SECRET.';
actionsEl.innerHTML='';
return;
}
card.style.display='block';
if(_gcalConnected){
statusEl.innerHTML=`Conectado como <strong>${escapeHtml(_gcalEmail||'')}</strong>. Pendências com prazo viram eventos no Google Agenda automaticamente.`;
actionsEl.innerHTML=`
<button class="btn btn-block" onclick="googleSyncAllPending()">⟳ Sincronizar todas pendências agora</button>
<button class="btn btn-block" onclick="googlePullNow()">↓ Buscar mudanças do Google</button>
<button class="btn btn-block btn-danger" onclick="googleDisconnect()">Desconectar Google</button>
`;
}else{
statusEl.textContent='Conecte sua conta Google pra que pendências com prazo virem eventos na sua agenda — e mudanças no Google voltem pro Shivão.';
actionsEl.innerHTML='<button class="btn btn-block btn-primary" onclick="googleConnect()">🔗 Conectar Google Agenda</button>';
}
}
async function googleConnect(){
try{
const r=await cloudFetch('/api/google/auth-url?return_to='+encodeURIComponent(location.href));
const j=await r.json();
if(!j.url)throw new Error('sem URL');
// Abre numa nova aba (popup pode ser bloqueado, então _blank)
const w=window.open(j.url,'_blank');
if(!w)toast('Permita popups e tente de novo');
// Re-checa status a cada 3s por até 2min
let tries=0;
const iv=setInterval(async()=>{
tries++;
await refreshGoogleStatus();
if(_gcalConnected){clearInterval(iv);toast('✓ Google conectado');try{w.close()}catch(e){}}
if(tries>40){clearInterval(iv)}
},3000);
}catch(e){toast('Erro: '+e.message)}
}
async function googleDisconnect(){
if(!confirm('Desconectar Google Agenda? Eventos já criados continuam lá, mas não recebem mais updates.'))return;
try{
await cloudFetch('/api/google/disconnect',{method:'POST'});
toast('Google desconectado');
await refreshGoogleStatus();
}catch(e){toast('Erro: '+e.message)}
}
async function googleSyncAllPending(){
if(!_gcalConnected)return toast('Conecte o Google primeiro');
let ok=0,fail=0;
toast('Sincronizando pendências...');
for(const p of state.pending){
if(!p.title||p.archived)continue;
try{
const r=await cloudFetch('/api/google/sync-pending',{
method:'POST',
body:JSON.stringify({pending:p}),
});
const j=await r.json();
if(j.event?.id){p.googleEventId=j.event.id;p.googleHtmlLink=j.event.htmlLink}
ok++;
}catch(e){fail++}
}
saveState();
toast(`Google: ${ok} ok${fail?', '+fail+' falhas':''}`);
}
async function googleSyncPending(p){
// Auto-sync de uma única pendência (chamada após criar/editar/deletar)
if(!_gcalConnected||!cloudConfigured())return;
try{
const r=await cloudFetch('/api/google/sync-pending',{
method:'POST',
body:JSON.stringify({pending:p}),
});
const j=await r.json();
if(j.deleted)return;
if(j.event?.id&&j.event.id!==p.googleEventId){
p.googleEventId=j.event.id;
p.googleHtmlLink=j.event.htmlLink;
saveState();
}
}catch(e){console.warn('[gcal] sync pending failed',e.message)}
}
async function googlePullNow(){
if(!_gcalConnected)return;
try{
const r=await cloudFetch('/api/google/pull');
const j=await r.json();
let touched=0;
for(const ev of(j.items||[])){
if(!ev.googleEventId)continue;
// Localiza pendência local pelo googleEventId
let p=state.pending.find(x=>x.googleEventId===ev.googleEventId);
if(p){
if(ev.deleted){
state.pending=state.pending.filter(x=>x!==p);
touched++;
continue;
}
if(ev.title)p.title=ev.title;
if(ev.notes!==undefined)p.notes=ev.notes;
if(ev.dueDate)p.dueDate=ev.dueDate;
if(ev.completed!==undefined)p.completed=ev.completed;
touched++;
}else if(!ev.deleted){
// Evento criado direto no Google: cria pendência nova
state.pending.push({
id:'p_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5),
title:ev.title||'(sem título)',
notes:ev.notes||'',
dueDate:ev.dueDate||'',
completed:!!ev.completed,
googleEventId:ev.googleEventId,
createdAt:Date.now(),
});
touched++;
}
}
if(touched>0){
saveState();
if(typeof renderPending==='function')renderPending();
toast(`📅 ${touched} pendências sincronizadas do Google`);
}
_gcalLastPullAt=Date.now();
}catch(e){console.warn('[gcal] pull failed',e.message)}
}
// ===== Auth (multi-tenant SaaS — Login/Signup) =====
async function authSignup(email,password,name){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
const r=await fetch(cloudUrl('/api/auth/signup'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password,name})});
const j=await r.json();
if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
await refreshLicense();
renderAuthBox();
}
async function authLogin(email,password){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
const r=await fetch(cloudUrl('/api/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password})});
const j=await r.json();
if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
await refreshLicense();
renderAuthBox();
}
async function authRefresh(){
if(!state.auth||!state.auth.refreshToken)return false;
try{
const r=await fetch(cloudUrl('/api/auth/refresh'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refreshToken:state.auth.refreshToken})});
if(!r.ok){state.auth=null;saveState();return false}
const j=await r.json();
state.auth.accessToken=j.accessToken;
saveState();
return true;
}catch{return false}
}
function authLogout(){
state.auth=null;
state.license=null;
saveState();
renderAuthBox();
toast('Sessão encerrada');
}
async function refreshLicense(){
try{
const r=await cloudFetch('/api/license');
state.license=await r.json();
saveState();
}catch(e){console.warn('license fetch:',e.message)}
}
function renderAuthBox(){
const box=document.getElementById('auth-box');
if(!box)return;
if(state.auth&&state.auth.user){
const u=state.auth.user;
const lic=state.license||{plan:'free',features:[]};
const planLabel={free:'Free (Âncora)',pro:'Pro',captain:'Captain'}[lic.plan]||lic.plan;
box.innerHTML=`<div style="font-family:var(--f-mono);font-size:11px;line-height:1.7"><div><strong>${escapeHtml(u.email)}</strong> ${u.name?'· '+escapeHtml(u.name):''}</div><div>Plano: <strong style="color:var(--brass)">${planLabel}</strong> ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}</div></div><button class="btn btn-block" style="margin-top:10px" onclick="authLogout()">Sair</button>${lic.plan==='free'?'<button class="btn btn-block btn-brass" style="margin-top:6px" onclick="openUpgradeModal()">⚡ Fazer upgrade pra Pro</button>':''}`;
}else{
box.innerHTML=`
<div style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em">CONTA · multi-usuário SaaS</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="btn btn-sm" id="auth-tab-login" onclick="document.getElementById('auth-form-login').style.display='block';document.getElementById('auth-form-signup').style.display='none';this.classList.add('btn-brass');document.getElementById('auth-tab-signup').classList.remove('btn-brass')" style="flex:1">Entrar</button>
<button class="btn btn-sm" id="auth-tab-signup" onclick="document.getElementById('auth-form-signup').style.display='block';document.getElementById('auth-form-login').style.display='none';this.classList.add('btn-brass');document.getElementById('auth-tab-login').classList.remove('btn-brass')" style="flex:1">Cadastrar</button>
</div>
<div id="auth-form-login">
<div class="field"><label class="field-label">Email</label><input type="email" id="login-email" placeholder="seu@email.com"></div>
<div class="field"><label class="field-label">Senha</label><input type="password" id="login-pwd" placeholder="••••••••"></div>
<button class="btn btn-block btn-primary" onclick="onAuthLoginClick()">Entrar</button>
</div>
<div id="auth-form-signup" style="display:none">
<div class="field"><label class="field-label">Email</label><input type="email" id="signup-email" placeholder="seu@email.com"></div>
<div class="field"><label class="field-label">Nome (opcional)</label><input type="text" id="signup-name" placeholder="Seu nome"></div>
<div class="field"><label class="field-label">Senha (mín 8 chars)</label><input type="password" id="signup-pwd" placeholder="••••••••"></div>
<button class="btn btn-block btn-primary" onclick="onAuthSignupClick()">Criar conta</button>
</div>
<div id="auth-msg" style="margin-top:8px;font-size:11px;color:var(--storm)"></div>`;
document.getElementById('auth-tab-login').classList.add('btn-brass');
}
}
async function onAuthLoginClick(){
const e=document.getElementById('login-email').value.trim();
const p=document.getElementById('login-pwd').value;
const msg=document.getElementById('auth-msg');
msg.style.color='var(--storm)';msg.textContent='';
try{await authLogin(e,p);msg.style.color='var(--algae)';msg.textContent='Logado!';toast('Bem-vindo')}catch(err){msg.textContent=err.message}
}
// ===== Upgrade modal (Asaas billing) =====
let _upgradeChosen={plan:'pro',cycle:'yearly',billingType:'PIX'};
async function openUpgradeModal(){
if(!state.auth){toast('Faça login primeiro');return}
// Cria modal dinamicamente (não precisa adicionar HTML no body)
const existing=document.getElementById('upgrade-modal');
if(existing)existing.remove();
const m=document.createElement('div');
m.id='upgrade-modal';
m.className='modal-backdrop';
m.style.cssText='align-items:center;justify-content:center;display:flex;z-index:9999';
m.innerHTML=`<div class="modal" style="max-width:420px;padding:24px"><h3 style="margin:0 0 12px;font-family:var(--f-display),Georgia,serif;font-style:italic;color:var(--brass)">Upgrade · escolha seu plano</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:12px 0">
<button id="up-pro" class="btn" onclick="_upgradeChoose('pro')" style="padding:10px;text-align:left"><strong>Pro</strong><br><small style="opacity:.8">R\$19/mês ou R\$149/ano</small></button>
<button id="up-captain" class="btn" onclick="_upgradeChoose('captain')" style="padding:10px;text-align:left"><strong>Captain</strong><br><small style="opacity:.8">R\$39/mês ou R\$299/ano</small></button>
</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button id="up-monthly" class="btn btn-sm" onclick="_upgradeCycle('monthly')" style="flex:1">Mensal</button>
<button id="up-yearly" class="btn btn-sm" onclick="_upgradeCycle('yearly')" style="flex:1">Anual (-35%)</button>
</div>
<div style="display:flex;gap:6px;margin-bottom:14px">
<button id="up-pix" class="btn btn-sm" onclick="_upgradeType('PIX')" style="flex:1">PIX (instantâneo)</button>
<button id="up-cc" class="btn btn-sm" onclick="_upgradeType('CREDIT_CARD')" style="flex:1">Cartão</button>
<button id="up-bol" class="btn btn-sm" onclick="_upgradeType('BOLETO')" style="flex:1">Boleto</button>
</div>
<div id="up-summary" style="font-family:var(--f-mono);font-size:12px;color:var(--ink);background:var(--bg-aged);padding:10px;border-radius:4px;margin-bottom:12px"></div>
<button class="btn btn-block btn-primary" onclick="_doCheckout()">Continuar pra pagamento</button>
<button class="btn btn-block" onclick="document.getElementById('upgrade-modal').remove()" style="margin-top:6px">Cancelar</button>
<div id="up-result" style="margin-top:14px"></div>
</div>`;
document.body.appendChild(m);
_upgradeRefresh();
}
function _upgradeChoose(p){_upgradeChosen.plan=p;_upgradeRefresh()}
function _upgradeCycle(c){_upgradeChosen.cycle=c;_upgradeRefresh()}
function _upgradeType(t){_upgradeChosen.billingType=t;_upgradeRefresh()}
function _upgradeRefresh(){
const c=_upgradeChosen;
['up-pro','up-captain'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
['up-monthly','up-yearly'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
['up-pix','up-cc','up-bol'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
document.getElementById('up-'+c.plan)?.classList.add('btn-brass');
document.getElementById('up-'+c.cycle)?.classList.add('btn-brass');
document.getElementById('up-'+(c.billingType==='PIX'?'pix':c.billingType==='CREDIT_CARD'?'cc':'bol'))?.classList.add('btn-brass');
const prices={pro:{monthly:19,yearly:149},captain:{monthly:39,yearly:299}};
const v=prices[c.plan][c.cycle];
document.getElementById('up-summary').textContent=`Plano ${c.plan==='pro'?'Pro':'Captain'} · ${c.cycle==='monthly'?'mensal':'anual'} · R$ ${v.toFixed(2)} via ${c.billingType==='PIX'?'PIX':c.billingType==='CREDIT_CARD'?'Cartão':'Boleto'}`;
}
async function _doCheckout(){
const out=document.getElementById('up-result');out.innerHTML='<em>Criando cobrança…</em>';
try{
const r=await cloudFetch('/api/billing/checkout',{method:'POST',body:JSON.stringify(_upgradeChosen)});
const data=await r.json();
let html='';
if(data.pix&&data.pix.qrCode){
html=`<div style="text-align:center"><strong>Escaneie o QR Code PIX:</strong><br><img src="data:image/png;base64,${data.pix.qrCode}" style="max-width:240px;margin:10px auto;border:4px solid #fff;border-radius:8px"><br><div style="font-family:var(--f-mono);font-size:10px;word-break:break-all;background:var(--bg-aged);padding:6px;border-radius:4px;margin:6px 0"><strong>Copia e cola:</strong><br>${data.pix.payload||''}</div><button class="btn btn-sm" onclick="navigator.clipboard.writeText('${data.pix.payload||''}').then(()=>toast('PIX copiado!'))">Copiar PIX</button></div>`;
}else if(data.invoiceUrl){
html=`<a href="${data.invoiceUrl}" target="_blank" class="btn btn-block btn-primary">Abrir página de pagamento ↗</a>`;
}
html+=`<div style="margin-top:10px;font-size:11px;opacity:.8">Após pagar, sua licença ativa em segundos. Pode fechar esta tela e voltar quando quiser.</div>`;
html+=`<button class="btn btn-block" style="margin-top:10px" onclick="checkPaymentStatus('${data.paymentId}')">Verificar pagamento</button>`;
out.innerHTML=html;
}catch(e){out.innerHTML='<span style="color:var(--storm)">Erro: '+e.message+'</span>'}
}
async function checkPaymentStatus(paymentId){
try{
const r=await cloudFetch('/api/billing/payment/'+paymentId);
const p=await r.json();
if(['RECEIVED','CONFIRMED','RECEIVED_IN_CASH'].includes(p.status)){
toast('Pago! Licença ativada 🎉');
await refreshLicense();renderAuthBox();
document.getElementById('upgrade-modal')?.remove();
}else{
toast('Status: '+p.status);
}
}catch(e){toast('Erro: '+e.message)}
}
async function onAuthSignupClick(){
const e=document.getElementById('signup-email').value.trim();
const n=document.getElementById('signup-name').value.trim();
const p=document.getElementById('signup-pwd').value;
const msg=document.getElementById('auth-msg');
msg.style.color='var(--storm)';msg.textContent='';
if(p.length<8){msg.textContent='Senha precisa de no mínimo 8 caracteres';return}
try{await authSignup(e,p,n);msg.style.color='var(--algae)';msg.textContent='Conta criada e logado!';toast('Conta criada')}catch(err){msg.textContent=err.message}
}
function bindCloudInputs(){
const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token');
if(!u||!t)return;
u.value=state.cloud?.url||'';
t.value=state.cloud?.token||'';
u.addEventListener('change',()=>{state.cloud.url=u.value.trim();saveState();renderCloudStatus();rtDisconnect();if(cloudConfigured())rtConnect()});
t.addEventListener('change',()=>{state.cloud.token=t.value.trim();saveState();renderCloudStatus();rtDisconnect();if(cloudConfigured())rtConnect()});
}
function renderCloudStatus(){
const el=document.getElementById('cloud-status');
if(!el)return;
if(cloudConfigured()){
const ls=state.cloud.lastSync?`última sync: ${new Date(state.cloud.lastSync).toLocaleString('pt-BR')}`:'aguardando sincronização';
el.textContent=`Conectado · ${ls}`;
el.style.color='var(--algae)';
}else{
el.textContent='Não conectado · use seu próprio servidor (Coolify/VPS) para sync automático e alarmes remotos.';
el.style.color='var(--sepia)';
}
}
async function testCloudConnection(){
try{
if(!cloudConfigured()){toast('Preencha servidor e token');return}
toast('Testando...');
const r=await cloudFetch('/api/info');
const info=await r.json();
document.getElementById('cloud-info').innerHTML=`Servidor OK · canais ativos: <strong>${info.channels.join(', ')||'(nenhum!)'}</strong> · timeout heartbeat: ${info.heartbeatTimeoutSec}s`;
toast('Conexão OK');
renderCloudStatus();
}catch(e){toast('Falhou: '+e.message);document.getElementById('cloud-info').innerHTML=`<span style="color:var(--storm)">Erro: ${escapeHtml(e.message)}</span>`}
}
async function cloudPushAll(){
if(!cloudConfigured()){toast('Configure a nuvem primeiro');return}
try{
toast('Enviando dados...');
const{cloud,...dataNoCloud}=state;
await cloudFetch('/api/data',{method:'POST',body:JSON.stringify({data:dataNoCloud})});
// upload de mídias
const allMedia=await dbAll();
const remote=await cloudFetch('/api/media/list');
const remoteList=await remote.json();
const remoteIds=new Set(remoteList.map(m=>m.id));
let uploaded=0;
for(const m of allMedia){
if(remoteIds.has(m.id))continue;
const fd=new FormData();
fd.append('file',m.blob,`${m.id}.bin`);
fd.append('id',m.id);
fd.append('parent_id',m.parentId||'');
fd.append('parent_type',m.parentType||'');
fd.append('kind',m.kind);
fd.append('created_at',String(m.createdAt||Date.now()));
await fetch(cloudUrl('/api/media'),{method:'POST',headers:{'Authorization':`Bearer ${state.cloud.token}`},body:fd});
uploaded++;
}
state.cloud.lastSync=Date.now();
saveState();
renderCloudStatus();
toast(`Enviado · ${uploaded} mídias novas`);
}catch(e){toast('Erro: '+e.message)}
}
async function cloudPullAll(){
if(!cloudConfigured()){toast('Configure a nuvem primeiro');return}
if(!confirm('Baixar substituirá os dados locais. Continuar?'))return;
try{
toast('Baixando dados...');
const r=await cloudFetch('/api/data');
const{data}=await r.json();
if(!data){toast('Nuvem vazia');return}
const cloudKeep=state.cloud;
Object.assign(state,data);
state.cloud=cloudKeep;
state.cloud.lastSync=Date.now();
saveState();bindHeader();
// baixar mídias faltantes
const remote=await cloudFetch('/api/media/list');
const remoteList=await remote.json();
const localAll=await dbAll();
const localIds=new Set(localAll.map(m=>m.id));
let downloaded=0;
for(const rm of remoteList){
if(localIds.has(rm.id))continue;
const f=await fetch(cloudUrl('/api/media/'+rm.id),{headers:{'Authorization':`Bearer ${state.cloud.token}`}});
if(!f.ok)continue;
const blob=await f.blob();
await dbPut({id:rm.id,kind:rm.kind,blob,mime:rm.mime,parentId:rm.parent_id,parentType:rm.parent_type,createdAt:rm.created_at});
downloaded++;
}
await renderAll();
renderCloudStatus();
toast(`Baixado · ${downloaded} mídias novas`);
}catch(e){toast('Erro: '+e.message)}
}
async function cloudTestAlarm(){
if(!cloudConfigured()){toast('Configure a nuvem primeiro');return}
try{
toast('Disparando teste...');
const r=await cloudFetch('/api/test',{method:'POST'});
const res=await r.json();
document.getElementById('cloud-info').innerHTML=`Teste enviado · canais: <strong>${res.sent.join(', ')||'(nenhum!)'}</strong>${res.failed.length?`<br><span style="color:var(--storm)">falhas: ${res.failed.map(f=>f.channel).join(', ')}</span>`:''}`;
toast('Teste enviado');
}catch(e){toast('Erro: '+e.message)}
}
function cloudDisconnect(){
if(!confirm('Desconectar da nuvem? Os dados locais ficam preservados.'))return;
state.cloud={url:'',token:'',lastSync:0};
saveState();
document.getElementById('cloud-url').value='';
document.getElementById('cloud-token').value='';
document.getElementById('cloud-info').innerHTML='';
renderCloudStatus();
toast('Desconectado');
}
// ---- Anchor watch sync with cloud ----
async function cloudAnchorStart(){
if(!cloudConfigured())return;
try{
await cloudFetch('/api/anchor/start',{method:'POST',body:JSON.stringify({boat_name:state.boat.name,anchor_lat:anchorWatch.anchorPos.lat,anchor_lng:anchorWatch.anchorPos.lng,radius:anchorWatch.radius})});
}catch(e){console.warn('cloud anchor start',e)}
}
async function cloudAnchorStop(){
if(!cloudConfigured())return;
try{await cloudFetch('/api/anchor/stop',{method:'POST',body:'{}'})}catch(e){}
}
async function cloudAnchorAlarm(){
if(!cloudConfigured())return;
try{
const pos=anchorWatch.lastPos||anchorWatch.anchorPos;
const r=await cloudFetch('/api/anchor/alarm',{method:'POST',body:JSON.stringify({boat_name:state.boat.name,lat:pos.lat,lng:pos.lng,distance:anchorWatch.currentDist,radius:anchorWatch.radius,reason:'drift'})});
const res=await r.json();
console.log('cloud alarm fired:',res);
toast(`Servidor avisou: ${res.sent.join(', ')||'(canais offline)'}`);
}catch(e){console.warn('cloud alarm',e);toast('Falha ao avisar servidor — alarme local ativo')}
}
function startHeartbeat(){
if(heartbeatInterval)return;
// primeiro imediato, depois a cada 30s
sendHeartbeat();
heartbeatInterval=setInterval(sendHeartbeat,30000);
}
function stopHeartbeat(){if(heartbeatInterval){clearInterval(heartbeatInterval);heartbeatInterval=null}}
async function sendHeartbeat(){
if(!cloudConfigured()||!anchorWatch.active)return;
try{
const pos=anchorWatch.lastPos||anchorWatch.anchorPos;
if(!pos)return;
await cloudFetch('/api/anchor/heartbeat',{method:'POST',body:JSON.stringify({lat:pos.lat,lng:pos.lng,distance:anchorWatch.currentDist})});
}catch(e){console.warn('heartbeat falhou',e.message)}
}
// auto-sync on changes (debounced)
const _origSaveState=saveState;
saveState=function(){_origSaveState();if(cloudConfigured()){clearTimeout(cloudAutoSyncTimeout);cloudAutoSyncTimeout=setTimeout(()=>{cloudPushDataOnly()},5000)}};
async function cloudPushDataOnly(){
if(!cloudConfigured())return;
try{
const{cloud,...dataNoCloud}=state;
await cloudFetch('/api/data',{method:'POST',body:JSON.stringify({data:dataNoCloud})});
state.cloud.lastSync=Date.now();
_origSaveState();
renderCloudStatus();
}catch(e){console.warn('auto-sync',e.message)}
}
// ============ WEBHOOKS (envio automático sem cliques) ============
function buildAlarmText(){
const pos=anchorWatch.lastPos||anchorWatch.anchorPos||{lat:0,lng:0};
const lat=pos.lat.toFixed(6),lng=pos.lng.toFixed(6);
const now=new Date();
const hora=`${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
const dist=Math.round(anchorWatch.currentDist);
const tpl=state.alarmTemplate||'';
return tpl.replace(/{LAT}/g,lat).replace(/{LNG}/g,lng).replace(/{HORA}/g,hora).replace(/{DIST}/g,dist).replace(/{NOME}/g,state.boat.name||'Veleiro');
}
async function dispatchWebhooks(text){
const tasks=[];
const wh=state.webhooks||{};
// Telegram
if(wh.telegram?.token&&wh.telegram?.chatIds){
const chats=wh.telegram.chatIds.split(',').map(s=>s.trim()).filter(Boolean);
for(const chatId of chats){
tasks.push(fetch(`https://api.telegram.org/bot${wh.telegram.token}/sendMessage`,{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({chat_id:chatId,text,disable_notification:false})
}).then(r=>r.ok?{ok:'telegram '+chatId}:{err:'telegram '+chatId+' '+r.status}).catch(e=>({err:'telegram '+e.message})));
}
}
// Discord
if(wh.discord?.url){
const pos=anchorWatch.lastPos||anchorWatch.anchorPos||{lat:0,lng:0};
const lat=pos.lat.toFixed(6),lng=pos.lng.toFixed(6);
const dist=Math.round(anchorWatch.currentDist);
const payload={
username:state.boat.name||'Shivao',
embeds:[{
title:'🚨 ALERTA DE FUNDEIO',
description:text,
color:0x8c3434,
fields:[
{name:'Distância da âncora',value:`${dist} m / ${anchorWatch.radius} m`,inline:true},
{name:'Posição',value:`${lat}, ${lng}`,inline:true},
{name:'Mapa',value:`[Abrir](https://maps.google.com/?q=${lat},${lng})`,inline:true}
],
timestamp:new Date().toISOString()
}]
};
tasks.push(fetch(wh.discord.url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?{ok:'discord'}:{err:'discord '+r.status}).catch(e=>({err:'discord '+e.message})));
}
// Generic
if(wh.generic?.url){
const pos=anchorWatch.lastPos||anchorWatch.anchorPos||{lat:0,lng:0};
const payload={
boat:state.boat.name||'Shivao',
lat:pos.lat,lng:pos.lng,
distance:Math.round(anchorWatch.currentDist),
radius:anchorWatch.radius,
ts:Date.now(),
message:text,
mapsUrl:`https://maps.google.com/?q=${pos.lat},${pos.lng}`
};
tasks.push(fetch(wh.generic.url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?{ok:'webhook'}:{err:'webhook '+r.status}).catch(e=>({err:'webhook '+e.message})));
}
if(!tasks.length)return{sent:[],failed:[]};
const results=await Promise.all(tasks);
const sent=results.filter(r=>r.ok).map(r=>r.ok);
const failed=results.filter(r=>r.err).map(r=>r.err);
return{sent,failed};
}
async function testWebhooks(){
const text=`✅ Teste — ${state.boat.name||'Veleiro'} · sistema operacional · ${new Date().toLocaleString('pt-BR')}`;
// Salva config dos webhooks ANTES do teste (para usar valores atuais sem precisar salvar contatos)
const tg={token:document.getElementById('wh-tg-token').value.trim(),chatIds:document.getElementById('wh-tg-chats').value.trim()};
const dc={url:document.getElementById('wh-discord').value.trim()};
const gn={url:document.getElementById('wh-generic').value.trim()};
const oldWebhooks=state.webhooks;
state.webhooks={telegram:tg,discord:dc,generic:gn};
const r=await dispatchWebhooks(text);
state.webhooks=oldWebhooks; // restaura caso usuário cancele
const el=document.getElementById('webhook-test-result');
if(r.sent.length===0&&r.failed.length===0){el.innerHTML='<span style="color:var(--storm)">Nenhum webhook configurado</span>';return}
el.innerHTML=`Enviados: <strong>${r.sent.join(', ')||'(nenhum)'}</strong>${r.failed.length?`<br><span style="color:var(--storm)">Falhas: ${r.failed.join(', ')}</span>`:''}`;
toast(r.sent.length?'Teste enviado':'Falhou');
}
// ============ ANCHOR HISTORY ============
function openAnchorHistory(){
const list=document.getElementById('anchor-history-body');
if(state.anchorHistory.length===0){
list.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Sem fundeios anteriores</div><div class="empty-text">Quando levantar âncora, o fundeio fica registrado aqui.</div></div>`;
}else{
list.innerHTML=state.anchorHistory.map(h=>{
const dur=fmtDuration((h.endedAt-h.startedAt)/1000);
const date=new Date(h.startedAt).toLocaleString('pt-BR',{day:'2-digit',month:'short',year:'2-digit',hour:'2-digit',minute:'2-digit'});
const maxKn=h.maxDist?h.maxDist.toFixed(0):'0';
const alarms=h.alarmCount||0;
return `<div class="entry">
<div class="entry-head">
<div class="entry-meta">
<div class="entry-date">${escapeHtml(date)}</div>
<div class="entry-title">Fundeio · ${dur}</div>
</div>
<div class="entry-actions"><button class="icon-btn" onclick="openAnchorHistoryMap('${h.id}')" title="Ver mapa">⚐</button><button class="icon-btn del" onclick="deleteAnchorHistory('${h.id}')">×</button></div>
</div>
<div class="entry-body">
<dl class="entry-grid">
<dt>Posição</dt><dd><span class="num">${h.anchorPos.lat.toFixed(5)}, ${h.anchorPos.lng.toFixed(5)}</span></dd>
<dt>Raio</dt><dd><span class="num">${h.radius}</span> m</dd>
<dt>Desvio máx.</dt><dd><span class="num">${maxKn}</span> m ${alarms?`<span style="color:var(--storm);font-family:var(--f-mono);font-size:10px;letter-spacing:.15em;text-transform:uppercase;margin-left:6px">· ${alarms} alarme${alarms>1?'s':''}</span>`:''}</dd>
</dl>
</div>
</div>`;
}).join('');
}
openModal('anchor-history-modal');
}
let historyMap=null;
function openAnchorHistoryMap(id){
const h=state.anchorHistory.find(x=>x.id===id);
if(!h)return;
document.getElementById('ahm-title').textContent='Fundeio · '+new Date(h.startedAt).toLocaleDateString('pt-BR');
openModal('anchor-history-map-modal');
setTimeout(()=>{
if(historyMap){historyMap.remove();historyMap=null}
historyMap=L.map('anchor-history-map').setView([h.anchorPos.lat,h.anchorPos.lng],17);
addMapLayers(historyMap);
const a=[h.anchorPos.lat,h.anchorPos.lng];
const sp=h.swingPos||h.anchorPos;
const s=[sp.lat,sp.lng];
const anchorIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="28" height="28" fill="#a07832" stroke="#0e2a3d" stroke-width="1"><circle cx="12" cy="5" r="2.5"/><path d="M11 7v15h2V7z"/><path d="M5 12a7 7 0 0 0 14 0" stroke="#0e2a3d" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M8 10h8" stroke="#0e2a3d" stroke-width="2" stroke-linecap="round"/></svg>',iconSize:[28,28],iconAnchor:[14,14],className:''});
L.marker(a,{icon:anchorIcon,title:'Âncora'}).addTo(historyMap);
L.circle(s,{radius:h.radius,color:'#a07832',weight:2,fillColor:'#a07832',fillOpacity:.08,dashArray:'5,5'}).addTo(historyMap);
if(haversine(h.anchorPos,sp)>3){
const swingIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="14" height="14" fill="#1f5b76" stroke="#fff" stroke-width="1.5"><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2" fill="#fff"/></svg>',iconSize:[14,14],iconAnchor:[7,7],className:''});
L.marker(s,{icon:swingIcon,title:'Centro de giro'}).addTo(historyMap);
L.polyline([a,s],{color:'#a07832',weight:1.5,opacity:.5,dashArray:'2,4'}).addTo(historyMap);
}
if(h.lastPos){
const c=[h.lastPos.lat,h.lastPos.lng];
const boatIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="22" height="22" fill="#1f5b76" stroke="#0e2a3d" stroke-width="1.5"><path d="M3 18h18l-2-6H5z"/><path d="M12 3v9" stroke-width="1.5"/></svg>',iconSize:[22,22],iconAnchor:[11,11],className:''});
L.marker(c,{icon:boatIcon,title:'Última posição'}).addTo(historyMap);
}
setTimeout(()=>historyMap.invalidateSize(),100);
const dur=fmtDuration((h.endedAt-h.startedAt)/1000);
document.getElementById('ahm-stats').innerHTML=`
<div class="map-stat"><span class="map-stat-num">${h.maxDist.toFixed(0)}</span><span class="map-stat-lbl">m máx</span></div>
<div class="map-stat"><span class="map-stat-num">${h.radius}</span><span class="map-stat-lbl">m raio</span></div>
<div class="map-stat"><span class="map-stat-num">${h.alarmCount||0}</span><span class="map-stat-lbl">alarmes</span></div>`;
},50);
}
function closeAnchorHistoryMap(){closeModal('anchor-history-map-modal');if(historyMap){setTimeout(()=>{historyMap.remove();historyMap=null},300)}}
function deleteAnchorHistory(id){
if(!confirm('Apagar este registro de fundeio?'))return;
state.anchorHistory=state.anchorHistory.filter(h=>h.id!==id);
saveState();
openAnchorHistory();
renderAnchorBanner();
}
// ============ WEATHER (Open-Meteo) ============
const weather={lastFetch:0,lastPos:null,data:null,fetching:false};
async function fetchWeather(lat,lng){
if(state.weatherCfg?.windyKey)return fetchWeatherWindy(lat,lng);
return fetchWeatherOpenMeteo(lat,lng);
}
async function fetchWeatherWindy(lat,lng){
if(weather.fetching)return;
weather.fetching=true;
const key=state.weatherCfg.windyKey;
const model=state.weatherCfg.model||'gfs';
try{
// Modelo principal: vento, temp, pressão, nuvens, precipitação
const reqMain=fetch('https://api.windy.com/api/point-forecast/v2',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({lat,lon:lng,model,parameters:['wind','windGust','temp','pressure','lclouds','mclouds','hclouds','rh','past3hprecip'],levels:['surface'],key})
}).then(async r=>{
if(!r.ok)throw new Error('Windy '+r.status+': '+(await r.text()).slice(0,100));
return r.json();
});
// Modelo de ondas em paralelo
const reqWaves=fetch('https://api.windy.com/api/point-forecast/v2',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({lat,lon:lng,model:'gfsWave',parameters:['waves','swell1','wwind'],levels:['surface'],key})
}).then(r=>r.ok?r.json():null).catch(()=>null);
const[main,waves]=await Promise.all([reqMain,reqWaves]);
weather.data={provider:'windy',main,waves,model,fetchedAt:Date.now(),lat,lng};
weather.lastFetch=Date.now();
weather.lastPos={lat,lng};
renderWeather();
}catch(e){
console.warn('windy fail, falling back:',e.message);
toast('Windy: '+e.message.slice(0,40)+' · usando fallback');
weather.fetching=false;
return fetchWeatherOpenMeteo(lat,lng);
}
weather.fetching=false;
}
async function fetchWeatherOpenMeteo(lat,lng){
if(weather.fetching)return;
weather.fetching=true;
try{
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code&hourly=wind_speed_10m,wind_direction_10m,weather_code&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}&current=wave_height,wave_direction,wave_period&hourly=wave_height&forecast_hours=24&timezone=auto`;
const[fR,mR]=await Promise.all([fetch(fUrl),fetch(mUrl).catch(()=>null)]);
const f=await fR.json();
const m=mR&&mR.ok?await mR.json():null;
weather.data={provider:'openmeteo',forecast:f,marine:m,fetchedAt:Date.now(),lat,lng};
weather.lastFetch=Date.now();
weather.lastPos={lat,lng};
renderWeather();
}catch(e){console.warn('weather',e.message)}
weather.fetching=false;
}
// Helpers de conversão de unidades
function uvToSpeedDir(u,v){
// u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte).
const speedMs=Math.sqrt(u*u+v*v);
// Direção meteorológica: de onde o vento vem
const dir=(270-Math.atan2(v,u)*180/Math.PI+360)%360;
return{speedMs,dir};
}
function kelvinToC(k){return k-273.15}
function paToHpa(p){return p/100}
const WMO_CODES={0:'Céu limpo',1:'Maioria limpo',2:'Parcial nublado',3:'Nublado',45:'Neblina',48:'Neblina densa',51:'Garoa leve',53:'Garoa',55:'Garoa intensa',61:'Chuva leve',63:'Chuva',65:'Chuva forte',71:'Neve leve',73:'Neve',75:'Neve forte',77:'Granizo',80:'Pancadas',81:'Pancadas fortes',82:'Pancadas violentas',95:'Trovoada',96:'Trovoada+granizo',99:'Trovoada forte'};
const WMO_ICONS={0:'☀',1:'🌤',2:'⛅',3:'☁',45:'🌫',48:'🌫',51:'🌦',53:'🌦',55:'🌧',61:'🌧',63:'🌧',65:'⛈',71:'🌨',73:'🌨',75:'❄',77:'❄',80:'🌧',81:'⛈',82:'⛈',95:'⛈',96:'⛈',99:'⛈'};
function windCardinal(deg){
if(deg==null)return'';
const dirs=['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'];
return dirs[Math.round(deg/22.5)%16];
}
function renderWeather(){
const el=document.getElementById('weather-widget');
if(!el)return;
const d=weather.data;
if(!d){el.innerHTML='';return}
if(d.provider==='windy')return renderWindyWeather(el,d);
return renderOpenMeteoWeather(el,d);
}
function renderWindyWeather(el,d){
const m=d.main;
if(!m||!m.ts||!m.ts.length){el.innerHTML='';return}
const u=m['wind_u-surface']?.[0]??0;
const v=m['wind_v-surface']?.[0]??0;
const{speedMs,dir}=uvToSpeedDir(u,v);
const speedKn=speedMs*1.94384;
const dirCard=windCardinal(dir);
const gustMs=m['gust-surface']?.[0]??0;
const gustKn=gustMs*1.94384;
const tempK=m['temp-surface']?.[0];
const tempC=tempK?kelvinToC(tempK):null;
const pressPa=m['pressure-surface']?.[0];
const pressHpa=pressPa?paToHpa(pressPa):null;
const lc=m['lclouds-surface']?.[0]??0;
const mc=m['mclouds-surface']?.[0]??0;
const hc=m['hclouds-surface']?.[0]??0;
const totalClouds=Math.max(lc,mc,hc);
const cloudIcon=totalClouds<20?'☀':totalClouds<50?'🌤':totalClouds<80?'⛅':'☁';
const precip=m['past3hprecip-surface']?.[0]??0;
const precipIcon=precip>2?'🌧':precip>0.2?'🌦':'';
const desc=precipIcon?(precip>2?'Chuva':'Garoa'):(totalClouds<20?'Céu limpo':totalClouds<50?'Predominio claro':totalClouds<80?'Parcial nublado':'Nublado');
// pico vento próximas 24h
let peakKn=speedKn;
const tsArr=m.ts||[];const uArr=m['wind_u-surface']||[];const vArr=m['wind_v-surface']||[];
const cutoff=Date.now()+24*3600*1000;
for(let i=0;i<tsArr.length&&tsArr[i]<cutoff;i++){
const s=Math.sqrt(uArr[i]*uArr[i]+vArr[i]*vArr[i])*1.94384;
if(s>peakKn)peakKn=s;
}
const peakNote=peakKn>speedKn+3?` · pico ${peakKn.toFixed(0)}kn 24h`:'';
// ondas
let waveText='';
if(d.waves){
const wh=d.waves['waves_height-surface']?.[0];
const wp=d.waves['waves_period-surface']?.[0];
const wd=d.waves['waves_direction-surface']?.[0];
const sh=d.waves['swell1_height-surface']?.[0];
const sp=d.waves['swell1_period-surface']?.[0];
if(wh!=null)waveText=`<br>🌊 ${wh.toFixed(1)}m${wp?` · ${wp.toFixed(0)}s`:''}${wd!=null?` · ${windCardinal(wd)}`:''}${sh!=null&&sp?` · swell ${sh.toFixed(1)}m/${sp.toFixed(0)}s`:''}`;
}
el.innerHTML=`<div style="background:var(--bg-paper);border:1px solid var(--rule);padding:10px 14px;margin-bottom:14px;display:grid;grid-template-columns:1fr auto auto;gap:14px;align-items:center;position:relative">
<div>
<div class="label-mono" style="margin-bottom:2px">${escapeHtml(desc)} ${cloudIcon}${precipIcon?' '+precipIcon:''} <span style="opacity:.6">· Windy ${escapeHtml(d.model)}</span></div>
<div style="font-family:var(--f-mono);font-size:13px;color:var(--ink-deep);font-variant-numeric:tabular-nums">
💨 ${speedKn.toFixed(0)} kn ${dirCard}${gustKn>speedKn+1?` · rajada ${gustKn.toFixed(0)}`:''}${peakNote}${waveText}
${pressHpa?`<br>📊 ${pressHpa.toFixed(0)} hPa`:''}
</div>
</div>
<div style="font-family:var(--f-display);font-style:italic;font-size:24px;color:var(--ink-deep);font-variant-numeric:tabular-nums">${tempC!=null?tempC.toFixed(0)+'°':''}</div>
<button class="icon-btn" onclick="refreshWeather()" title="Atualizar">↻</button>
</div>`;
}
function renderOpenMeteoWeather(el,d){
const cur=d.forecast?.current;
if(!cur){el.innerHTML='';return}
const wave=d.marine?.current;
const code=cur.weather_code;
const desc=WMO_CODES[code]||'—';
const icon=WMO_ICONS[code]||'';
const wDir=windCardinal(cur.wind_direction_10m);
const hourly=d.forecast?.hourly;
let peakWind=cur.wind_speed_10m;
if(hourly&&hourly.wind_speed_10m){
const next6=hourly.wind_speed_10m.slice(0,6);
peakWind=Math.max(...next6,cur.wind_speed_10m);
}
const peakNote=peakWind>cur.wind_speed_10m+3?` · pico ${peakWind.toFixed(0)}kn 6h`:'';
el.innerHTML=`<div style="background:var(--bg-paper);border:1px solid var(--rule);padding:10px 14px;margin-bottom:14px;display:grid;grid-template-columns:1fr auto auto;gap:14px;align-items:center">
<div>
<div class="label-mono" style="margin-bottom:2px">${escapeHtml(desc)} ${icon} <span style="opacity:.6">· Open-Meteo</span></div>
<div style="font-family:var(--f-mono);font-size:13px;color:var(--ink-deep);font-variant-numeric:tabular-nums">
💨 ${cur.wind_speed_10m.toFixed(0)} kn ${wDir}${cur.wind_gusts_10m?` · rajada ${cur.wind_gusts_10m.toFixed(0)}`:''}${peakNote}
${wave?`<br>🌊 ${wave.wave_height.toFixed(1)}m${wave.wave_period?` · ${wave.wave_period.toFixed(0)}s`:''}`:''}
</div>
</div>
<div style="font-family:var(--f-display);font-style:italic;font-size:24px;color:var(--ink-deep);font-variant-numeric:tabular-nums">${cur.temperature_2m.toFixed(0)}°</div>
<button class="icon-btn" onclick="refreshWeather()" title="Atualizar">↻</button>
</div>`;
}
async function refreshWeather(){
// tenta usar última posição GPS conhecida
let pos=null;
if(tracking.points.length){const p=tracking.points[tracking.points.length-1];pos={lat:p.lat,lng:p.lng}}
else if(anchorWatch.lastPos)pos=anchorWatch.lastPos;
else if(anchorWatch.anchorPos)pos=anchorWatch.anchorPos;
else if(state.anchorHistory.length)pos=state.anchorHistory[0].anchorPos;
if(!pos&&navigator.geolocation){
try{
const p=await new Promise((r,j)=>navigator.geolocation.getCurrentPosition(r,j,{enableHighAccuracy:false,timeout:10000,maximumAge:600000}));
pos={lat:p.coords.latitude,lng:p.coords.longitude};
}catch(e){toast('Permita o GPS para obter previsão');return}
}
if(!pos){toast('Sem posição para consultar');return}
toast('Consultando previsão...');
await fetchWeather(pos.lat,pos.lng);
toast('Previsão atualizada');
}
function maybeAutoFetchWeather(){
// auto-fetch se nunca buscou OU a posição mudou >10km OU faz mais de 1h
const now=Date.now();
let pos=null;
if(anchorWatch.lastPos)pos=anchorWatch.lastPos;
else if(tracking.points.length){const p=tracking.points[tracking.points.length-1];pos={lat:p.lat,lng:p.lng}}
if(!pos)return;
if(weather.lastFetch&&now-weather.lastFetch<3600000){
if(weather.lastPos){
const moved=haversine(weather.lastPos,pos);
if(moved<10000)return;
}else return;
}
fetchWeather(pos.lat,pos.lng);
}
function bindWeatherInputs(){
const k=document.getElementById('windy-key');
const m=document.getElementById('windy-model');
if(!k||!m)return;
k.value=state.weatherCfg?.windyKey||'';
m.value=state.weatherCfg?.model||'gfs';
// status
const st=document.getElementById('windy-status');
if(state.weatherCfg?.windyKey)st.innerHTML=`<span style="color:var(--algae)">Chave configurada · usando Windy ${state.weatherCfg.model}</span>`;
else st.innerHTML=`<span style="color:var(--sepia)">Sem chave · usando Open-Meteo (grátis)</span>`;
// bind charts cfg também (mesmo fluxo abrir aba)
bindChartInputs();
}
function bindChartInputs(){
const p=document.getElementById('chart-provider');
const k=document.getElementById('chart-nav-key');
const st=document.getElementById('chart-status');
if(!p||!k||!st)return;
p.value=state.chartCfg?.provider||'opensea';
k.value=state.chartCfg?.navKey||'';
if(state.chartCfg?.provider==='navionics'&&state.chartCfg?.navKey){
st.innerHTML='<span style="color:var(--m-ok,#10b981)">Navionics ativo · cartas oficiais</span>';
}else if(state.chartCfg?.provider==='opensea'||!state.chartCfg?.provider){
st.innerHTML='<span style="color:var(--m-text-mid,#b3c5d6)">OpenSeaMap ativo · cartas náuticas grátis</span>';
}else{
st.innerHTML='<span style="color:var(--m-text-soft,#7d97ad)">Apenas OSM · sem overlay náutico</span>';
}
}
function saveChartCfg(){
state.chartCfg={
provider:document.getElementById('chart-provider').value,
navKey:document.getElementById('chart-nav-key').value.trim(),
};
saveState();
bindChartInputs();
}
function testChartCfg(){
saveChartCfg();
toast('Cartas salvas — abra um mapa pra ver');
}
// ============ BLUETOOTH (Web Bluetooth + Capacitor native plugin) ============
// 2 backends: navigator.bluetooth (Chrome PC/Android web) + Capacitor BluetoothLe (APK Android/iOS)
// UUIDs em formato 128-bit (compatível com ambos backends)
const BLE_BATTERY_SERVICE='0000180f-0000-1000-8000-00805f9b34fb'; // 0x180F
const BLE_BATTERY_CHAR='00002a19-0000-1000-8000-00805f9b34fb'; // 0x2A19
const BLE_DEVICE_INFO='0000180a-0000-1000-8000-00805f9b34fb'; // 0x180A
const BLE_MANUFACTURER_CHAR='00002a29-0000-1000-8000-00805f9b34fb';
const BLE_MODEL_CHAR='00002a24-0000-1000-8000-00805f9b34fb';
const _bleConnections=new Map(); // id → {device, server, batteryChar}
// Detecta backend: Capacitor nativo se disponível, senão Web Bluetooth
function bleBackend(){
if(window.Capacitor?.Plugins?.BluetoothLe)return 'capacitor';
if(navigator.bluetooth)return 'web';
return null;
}
function bleSupported(){return bleBackend()!==null}
let _bleNativeInitialized=false;
async function ensureBleNativeReady(){
if(_bleNativeInitialized)return;
const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(!ble)return;
await ble.initialize({androidNeverForLocation:true});
_bleNativeInitialized=true;
}
// Diagnóstico visível: mostra cada passo no card BLE
function setBleDiag(msg,type){
const el=document.getElementById('bt-diag');
if(!el)return;
const colors={info:'var(--m-text-mid,#b3c5d6)',ok:'var(--m-ok,#10b981)',err:'var(--m-danger,#ef4444)',warn:'var(--m-warn,#f59e0b)'};
const time=new Date().toLocaleTimeString('pt-BR',{hour12:false});
const line=`<div style="color:${colors[type||'info']};font-family:var(--f-mono);font-size:11px;line-height:1.5">${time} · ${escapeHtml(msg)}</div>`;
el.innerHTML=line+el.innerHTML;
// Mantém só últimas 12 linhas
const lines=el.children;
while(lines.length>12)el.removeChild(lines[lines.length-1]);
console.log('[ble]',msg);
}
async function pairBluetoothDevice(){
const backend=bleBackend();
setBleDiag('Backend: '+(backend||'NENHUM'),backend?'info':'err');
if(!backend){toast('Bluetooth indisponível');return}
try{
let deviceId,deviceName;
if(backend==='capacitor'){
setBleDiag('Inicializando plugin nativo...');
await ensureBleNativeReady();
setBleDiag('Plugin OK · abrindo picker...');
const ble=window.Capacitor.Plugins.BluetoothLe;
const result=await ble.requestDevice({
services:[],
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
allowDuplicates:false,
});
if(!result?.deviceId){setBleDiag('Picker cancelado','warn');return}
deviceId=result.deviceId;
deviceName=result.name||'Dispositivo BLE';
setBleDiag('Selecionado: '+deviceName+' ('+deviceId+')','ok');
}else{
setBleDiag('Abrindo picker do navegador...');
const device=await navigator.bluetooth.requestDevice({
acceptAllDevices:true,
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
});
if(!device){return}
deviceId=device.id;
deviceName=device.name||'Dispositivo BLE';
_bleConnections.set(deviceId,{device,backend:'web'});
device.addEventListener('gattserverdisconnected',()=>{
const c=_bleConnections.get(deviceId);if(c)c.connected=false;
renderBluetoothCard();
setBleDiag('Disconnected: '+deviceName,'warn');
});
setBleDiag('Selecionado: '+deviceName,'ok');
}
setBleDiag('Conectando GATT...');
const info=await connectAndRead(deviceId,deviceName);
setBleDiag('Connect OK · battery='+(info.battery??'N/A')+' · mfr='+(info.manufacturer||'N/A'),info.battery!=null?'ok':'warn');
if(!state.btDevices)state.btDevices=[];
const existing=state.btDevices.find(d=>d.id===deviceId);
if(existing){
Object.assign(existing,{name:deviceName||existing.name,lastBattery:info.battery,lastSeen:Date.now(),manufacturer:info.manufacturer,model:info.model});
}else{
state.btDevices.push({
id:deviceId,name:deviceName,
lastBattery:info.battery,lastSeen:Date.now(),
manufacturer:info.manufacturer,model:info.model,
addedAt:Date.now(),backend,
});
}
saveState();
renderBluetoothCard();
toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)'));
}catch(e){
if(e.name==='NotFoundError'||/cancel/i.test(e.message||'')){setBleDiag('Cancelado','warn');return}
const msg=e.message||e.errorMessage||JSON.stringify(e).slice(0,100)||'erro desconhecido';
setBleDiag('ERRO: '+msg,'err');
console.warn('[ble] pair failed',e);
toast('Falhou: '+msg);
}
}
async function connectAndRead(deviceId,deviceName){
const info={battery:null,manufacturer:null,model:null,services:[]};
const backend=bleBackend();
try{
if(backend==='capacitor'){
const ble=window.Capacitor.Plugins.BluetoothLe;
try{
await ble.connect({deviceId,timeout:30000});
setBleDiag('GATT conectado','ok');
}catch(e){
setBleDiag('connect() falhou: '+(e.message||e.errorMessage||'?'),'err');
throw e;
}
const conn=_bleConnections.get(deviceId)||{};
conn.backend='capacitor';conn.deviceId=deviceId;conn.connected=true;
_bleConnections.set(deviceId,conn);
// Discover all services pra diagnóstico
try{
const r=await ble.getServices({deviceId});
const svcs=(r.services||r||[]).map(s=>s.uuid||s).slice(0,8);
setBleDiag('Serviços encontrados: '+svcs.length+' ('+svcs.map(u=>u.slice(4,8)).join(', ')+')','info');
info.services=svcs;
}catch(e){setBleDiag('getServices falhou: '+e.message,'warn')}
// Battery
try{
const r=await ble.read({deviceId,service:BLE_BATTERY_SERVICE,characteristic:BLE_BATTERY_CHAR});
info.battery=parseDataView(r.value).getUint8(0);
setBleDiag('Battery Service OK: '+info.battery+'%','ok');
try{
await ble.startNotifications({deviceId,service:BLE_BATTERY_SERVICE,characteristic:BLE_BATTERY_CHAR});
ble.addListener('notification|'+deviceId+'|'+BLE_BATTERY_SERVICE+'|'+BLE_BATTERY_CHAR,(ev)=>{
const newVal=parseDataView(ev.value).getUint8(0);
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){dev.lastBattery=newVal;dev.lastSeen=Date.now();saveState();renderBluetoothCard()}
});
setBleDiag('Notificações ativas','ok');
}catch(e){setBleDiag('startNotifications falhou: '+e.message,'warn')}
}catch(e){setBleDiag('Sem Battery Service padrão (BMS pode usar protocolo proprietário)','warn')}
// Device info
try{
const r=await ble.read({deviceId,service:BLE_DEVICE_INFO,characteristic:BLE_MANUFACTURER_CHAR});
info.manufacturer=new TextDecoder().decode(parseDataView(r.value));
setBleDiag('Fabricante: '+info.manufacturer,'info');
}catch(e){}
try{
const r=await ble.read({deviceId,service:BLE_DEVICE_INFO,characteristic:BLE_MODEL_CHAR});
info.model=new TextDecoder().decode(parseDataView(r.value));
setBleDiag('Modelo: '+info.model,'info');
}catch(e){}
}else{
const conn=_bleConnections.get(deviceId);
const device=conn?.device;
if(!device)return info;
const server=await device.gatt.connect();
conn.server=server;conn.connected=true;
try{
const svc=await server.getPrimaryService(BLE_BATTERY_SERVICE);
const batChar=await svc.getCharacteristic(BLE_BATTERY_CHAR);
const val=await batChar.readValue();
info.battery=val.getUint8(0);
try{
await batChar.startNotifications();
batChar.addEventListener('characteristicvaluechanged',(ev)=>{
const newVal=ev.target.value.getUint8(0);
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){dev.lastBattery=newVal;dev.lastSeen=Date.now();saveState();renderBluetoothCard()}
});
}catch{}
conn.batteryChar=batChar;
}catch{}
try{
const svc=await server.getPrimaryService(BLE_DEVICE_INFO);
try{const c=await svc.getCharacteristic(BLE_MANUFACTURER_CHAR);info.manufacturer=new TextDecoder().decode(await c.readValue())}catch{}
try{const c=await svc.getCharacteristic(BLE_MODEL_CHAR);info.model=new TextDecoder().decode(await c.readValue())}catch{}
}catch{}
}
}catch(e){console.warn('[ble] connect failed',deviceName,e.message||e.errorMessage)}
return info;
}
// Helper: o plugin Capacitor envia value como string base64 ou DataView; converte pra DataView
function parseDataView(v){
if(v instanceof DataView)return v;
if(typeof v==='string'){
// base64 → Uint8Array → DataView
const bin=atob(v);
const bytes=new Uint8Array(bin.length);
for(let i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);
return new DataView(bytes.buffer);
}
if(v?.buffer)return new DataView(v.buffer);
return new DataView(new ArrayBuffer(0));
}
async function reconnectBluetoothDevice(id){
const dev=state.btDevices?.find(d=>d.id===id);
if(!dev){toast('Dispositivo não encontrado');return}
const backend=bleBackend();
if(!backend){toast('Bluetooth indisponível');return}
toast('Reconectando '+dev.name+'...');
try{
if(backend==='capacitor'){
await ensureBleNativeReady();
await connectAndRead(id,dev.name);
renderBluetoothCard();
}else{
// Web Bluetooth: tenta getDevices() (Chrome ≥85)
if(!navigator.bluetooth.getDevices){
toast('Reconexão automática indisponível — pareie de novo');return;
}
const devices=await navigator.bluetooth.getDevices();
const device=devices.find(d=>d.id===id);
if(!device){toast('Sem permissão p/ esse device — pareie manualmente');return}
_bleConnections.set(id,{device,backend:'web'});
device.addEventListener('gattserverdisconnected',()=>{
const c=_bleConnections.get(id);if(c)c.connected=false;renderBluetoothCard();
});
await connectAndRead(id,device.name);
renderBluetoothCard();
}
}catch(e){
console.warn('[ble] reconnect failed',e.message);
toast('Falha: '+(e.message||e.errorMessage));
}
}
async function removeBluetoothDevice(id){
if(!confirm('Remover dispositivo da lista?'))return;
const conn=_bleConnections.get(id);
try{
if(conn?.backend==='capacitor'){
const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(ble)await ble.disconnect({deviceId:id}).catch(()=>{});
}else if(conn?.device?.gatt?.connected){
conn.device.gatt.disconnect();
}
}catch{}
_bleConnections.delete(id);
state.btDevices=(state.btDevices||[]).filter(d=>d.id!==id);
saveState();
renderBluetoothCard();
}
function renderBluetoothCard(){
const el=document.getElementById('bt-list');
const supportEl=document.getElementById('bt-support');
if(!el)return;
if(supportEl){
supportEl.textContent=bleSupported()
? 'Bluetooth disponível neste navegador.'
: 'Web Bluetooth indisponível (use Chrome no PC ou Android — iOS Safari não suporta).';
supportEl.style.color=bleSupported()?'var(--m-ok,#10b981)':'var(--m-warn,#f59e0b)';
}
const devices=state.btDevices||[];
if(devices.length===0){
el.innerHTML='<div style="color:var(--m-text-soft);font-size:13px;padding:12px 0">Nenhum dispositivo pareado ainda.</div>';
return;
}
el.innerHTML=devices.map(d=>{
const conn=_bleConnections.get(d.id);
const isConnected=conn?.device?.gatt?.connected;
const bat=d.lastBattery;
const batColor=bat==null?'var(--m-text-soft)':bat<20?'var(--m-danger,#ef4444)':bat<50?'var(--m-warn,#f59e0b)':'var(--m-ok,#10b981)';
const batIcon=bat==null?'❓':bat<20?'🪫':bat<50?'🔋':'🔋';
return `<div class="bt-device" style="display:flex;align-items:center;gap:12px;padding:12px;background:var(--m-bg-2,#0f2a40);border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:8px;margin-bottom:8px">
<div style="font-size:24px">${batIcon}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;color:var(--m-text,#e8f1f8);font-size:14px">${escapeHtml(d.name)}</div>
<div style="font-family:var(--f-mono);font-size:11px;color:var(--m-text-soft,#7d97ad);margin-top:2px">
${bat!=null?`<span style="color:${batColor};font-weight:700">${bat}%</span> · `:''}${isConnected?'conectado':'offline'}${d.manufacturer?` · ${escapeHtml(d.manufacturer)}`:''}${d.model?` ${escapeHtml(d.model)}`:''}
</div>
</div>
${!isConnected?`<button class="btn btn-sm" onclick="reconnectBluetoothDevice('${d.id}')" title="Reconectar">↻</button>`:''}
<button class="btn btn-sm btn-danger" onclick="removeBluetoothDevice('${d.id}')" title="Remover">✕</button>
</div>`;
}).join('');
}
// Re-render quando entra na aba Mais
function refreshBluetoothCard(){renderBluetoothCard()}
// ============ RAYMARINE / NMEA 2000 GATEWAY ============
// Slot pra quando Karlão tiver gateway NMEA 2000 → WiFi (Yacht Devices YDWG-02, etc)
function saveNmeaGatewayCfg(){
const ip=document.getElementById('nmea-gateway-ip')?.value.trim()||'';
const port=document.getElementById('nmea-gateway-port')?.value.trim()||'';
state.nmeaGateway={ip,port:port?parseInt(port):0,enabled:!!ip};
saveState();
toast(ip?'Gateway salvo · será usado em viagens futuras':'Gateway desativado');
renderNmeaGatewayCard();
}
function renderNmeaGatewayCard(){
const ipEl=document.getElementById('nmea-gateway-ip');
const portEl=document.getElementById('nmea-gateway-port');
if(!ipEl||!portEl)return;
ipEl.value=state.nmeaGateway?.ip||'';
portEl.value=state.nmeaGateway?.port||'';
const st=document.getElementById('nmea-gateway-status');
if(st){
if(state.nmeaGateway?.ip)st.innerHTML='<span style="color:var(--m-ok,#10b981)">Configurado · '+escapeHtml(state.nmeaGateway.ip)+':'+(state.nmeaGateway.port||'?')+'</span>';
else st.innerHTML='<span style="color:var(--m-text-soft,#7d97ad)">Não configurado · sem leitura de instrumentos Raymarine</span>';
}
}
// ============ EXPORT GPX para OpenCPN ============
function gpxEscape(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]))}
function exportOpenCPN(){
const now=new Date().toISOString();
let gpx=`<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Shivao Diario de Bordo" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>Shivao — ${gpxEscape(activeBoat()?.name||'Diário de Bordo')}</name>
<desc>Export consolidado de viagens, fundeios e zonas</desc>
<time>${now}</time>
</metadata>
`;
// Waypoints: anchorages históricos
const anchors=state.anchorHistory||[];
for(const a of anchors){
if(!a.anchorPos)continue;
gpx+=` <wpt lat="${a.anchorPos.lat.toFixed(6)}" lon="${a.anchorPos.lng.toFixed(6)}">
<name>⚓ ${gpxEscape(a.label||'Fundeio '+new Date(a.startedAt||Date.now()).toLocaleDateString('pt-BR'))}</name>
<desc>Raio ${a.radius||'?'}m · ${a.duration?Math.round(a.duration/60000)+'min':'desconhecido'}</desc>
<sym>Anchor</sym>
<type>anchorage</type>
</wpt>
`;
}
// Tracks: cada viagem com pontos GPS
const trips=(state.trips||[]).filter(t=>t.track&&t.track.length>0);
for(const t of trips){
gpx+=` <trk>
<name>${gpxEscape(t.destination||'Travessia '+new Date(t.dateStart||Date.now()).toLocaleDateString('pt-BR'))}</name>
<desc>${gpxEscape((t.notes||'').slice(0,200))}</desc>
<trkseg>
`;
for(const pt of t.track){
const lat=pt.lat||pt[0];
const lng=pt.lng||pt[1];
const ts=pt.t||pt.ts;
if(!lat||!lng)continue;
gpx+=` <trkpt lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}">${ts?`<time>${new Date(ts).toISOString()}</time>`:''}</trkpt>
`;
}
gpx+=` </trkseg>
</trk>
`;
}
// Routes: zonas (forbidden/attention) como rotas fechadas
const zones=state.zones||[];
for(const z of zones){
if(!z.center||!z.radius)continue;
// Aproxima círculo como polígono de 16 pontos
const points=[];
for(let i=0;i<=16;i++){
const angle=(i/16)*2*Math.PI;
const dLat=(z.radius/111000)*Math.cos(angle);
const dLng=(z.radius/(111000*Math.cos(z.center.lat*Math.PI/180)))*Math.sin(angle);
points.push([z.center.lat+dLat,z.center.lng+dLng]);
}
gpx+=` <rte>
<name>${z.kind==='forbidden'?'⛔ ':'⚠ '}${gpxEscape(z.name||'Zona')}</name>
<desc>${z.kind} · raio ${Math.round(z.radius)}m</desc>
`;
for(const [lat,lng] of points){
gpx+=` <rtept lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}"></rtept>
`;
}
gpx+=` </rte>
`;
}
gpx+=`</gpx>`;
// Download
const blob=new Blob([gpx],{type:'application/gpx+xml'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url;
a.download=`shivao-${new Date().toISOString().slice(0,10)}.gpx`;
document.body.appendChild(a);a.click();a.remove();
setTimeout(()=>URL.revokeObjectURL(url),1000);
toast(`GPX gerado · ${trips.length} tracks · ${anchors.length} fundeios · ${zones.length} zonas`);
}
function saveWeatherKey(){
state.weatherCfg={windyKey:document.getElementById('windy-key').value.trim(),model:document.getElementById('windy-model').value};
saveState();
bindWeatherInputs();
// limpa cache para forçar próxima busca usar novo provider
weather.lastFetch=0;
toast('Salvo · forçando nova busca');
refreshWeather();
}
async function testWindyKey(){
const key=document.getElementById('windy-key').value.trim();
const model=document.getElementById('windy-model').value;
if(!key){document.getElementById('windy-status').innerHTML='<span style="color:var(--storm)">Cole a chave primeiro</span>';return}
document.getElementById('windy-status').innerHTML='Testando...';
try{
// tenta uma posição genérica (Rio de Janeiro)
const r=await fetch('https://api.windy.com/api/point-forecast/v2',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({lat:-23,lon:-43,model,parameters:['wind','temp'],levels:['surface'],key})
});
if(!r.ok){
const txt=await r.text();
document.getElementById('windy-status').innerHTML=`<span style="color:var(--storm)">Erro ${r.status}: ${escapeHtml(txt.slice(0,140))}</span>`;
return;
}
const j=await r.json();
const pts=j.ts?j.ts.length:0;
document.getElementById('windy-status').innerHTML=`<span style="color:var(--algae)">✓ OK · ${pts} pontos do modelo ${model} retornados</span>`;
toast('Chave válida');
}catch(e){
document.getElementById('windy-status').innerHTML=`<span style="color:var(--storm)">Falhou: ${escapeHtml(e.message)}</span>`;
}
}
// ===== Capacitor adapter (detecta nativo + usa APIs nativas com fallback Web) =====
const isNative=()=>!!(window.Capacitor&&window.Capacitor.isNativePlatform&&window.Capacitor.isNativePlatform());
const nativePlatform=()=>(window.Capacitor&&window.Capacitor.getPlatform)?window.Capacitor.getPlatform():'web';
// Geolocation: Capacitor.Geolocation (background-capable) > navigator.geolocation
async function nativeWatchPosition(onUpdate,onError,opts){
if(isNative()&&window.Capacitor.Plugins.Geolocation){
try{
const{Geolocation}=window.Capacitor.Plugins;
// Pede permission upfront
const perm=await Geolocation.requestPermissions({permissions:['location']}).catch(()=>({location:'denied'}));
if(perm.location==='denied'){onError({code:1,PERMISSION_DENIED:1,message:'Permissão negada'});return null}
const id=await Geolocation.watchPosition({enableHighAccuracy:!!opts?.enableHighAccuracy,timeout:opts?.timeout||15000,maximumAge:opts?.maximumAge||0},(pos,err)=>{
if(err){onError({code:err.code||3,message:err.message||'GPS erro',PERMISSION_DENIED:1,POSITION_UNAVAILABLE:2,TIMEOUT:3});return}
onUpdate({coords:{latitude:pos.coords.latitude,longitude:pos.coords.longitude,accuracy:pos.coords.accuracy,speed:pos.coords.speed},timestamp:pos.timestamp});
});
return{native:true,id};
}catch(e){console.warn('[native gps] fallback to web:',e.message)}
}
// Fallback web
const id=navigator.geolocation.watchPosition(onUpdate,onError,opts);
return{native:false,id};
}
async function nativeClearWatch(handle){
if(!handle)return;
if(handle.native&&isNative()){try{await window.Capacitor.Plugins.Geolocation.clearWatch({id:handle.id})}catch(e){}}
else if(handle.id!=null)navigator.geolocation.clearWatch(handle.id);
}
// Local notifications: nativo no Android, fallback toast no web
async function nativeNotify(title,body,id){
if(isNative()&&window.Capacitor.Plugins.LocalNotifications){
try{
const{LocalNotifications}=window.Capacitor.Plugins;
await LocalNotifications.requestPermissions().catch(()=>{});
await LocalNotifications.schedule({notifications:[{id:id||Date.now()%2147483647,title,body,sound:null,smallIcon:'ic_stat_icon_config_sample'}]});
return;
}catch(e){console.warn('[native notify]',e.message)}
}
toast(title+(body?': '+body:''));
}
// ===== Service Worker (offline real — só registra no Web, no Capacitor o WebView usa cache nativo) =====
function initServiceWorker(){
if(isNative())return; // Capacitor não usa SW (tem cache próprio + APIs nativas)
if(!('serviceWorker' in navigator))return;
navigator.serviceWorker.register('/sw.js').then(reg=>{
console.log('[SW] registered:',reg.scope);
}).catch(e=>console.warn('[SW] failed:',e.message));
}
// ===== Sensores: Bússola + Barômetro + Status Offline =====
const sensors={heading:null,pressure:null,pressureTrend:null,_pressHistory:[],compassActive:false,barometerActive:false};
function initSensorWidget(){
// Cria widget flutuante (canto superior direito, abaixo do header)
const w=document.createElement('div');
w.id='sensors-widget';
w.style.cssText='position:fixed;top:64px;right:12px;background:rgba(14,42,61,.92);color:#efe5cd;padding:8px 12px;border-radius:8px;font-family:var(--f-mono),monospace;font-size:11px;z-index:998;box-shadow:0 2px 8px rgba(0,0,0,.3);min-width:140px;backdrop-filter:blur(4px)';
w.innerHTML='<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer" id="sw-toggle"><span id="sw-online" style="color:#3f7768">●</span><span id="sw-compass">🧭 ---°</span><span id="sw-pressure" style="opacity:.7">🌡 ---</span><span style="opacity:.5;font-size:10px">▼</span></div><div id="sw-extra" style="display:none;margin-top:8px;padding-top:8px;border-top:1px solid rgba(160,120,50,.3);font-size:10px;line-height:1.5"></div>';
document.body.appendChild(w);
document.getElementById('sw-toggle').addEventListener('click',toggleSensorPanel);
// Online/offline status
updateOnlineStatus();
window.addEventListener('online',updateOnlineStatus);
window.addEventListener('offline',updateOnlineStatus);
// Tenta iniciar bússola e barômetro automaticamente (sem permission no Android; iOS espera tap)
tryStartCompass();
tryStartBarometer();
}
function toggleSensorPanel(){
const ex=document.getElementById('sw-extra');
const open=ex.style.display==='none';
ex.style.display=open?'block':'none';
if(open)renderSensorPanel();
}
function renderSensorPanel(){
const ex=document.getElementById('sw-extra');
const online=navigator.onLine;
const cardinal=sensors.heading!==null?headingToCardinal(sensors.heading):'—';
const trend=sensors.pressureTrend===null?'—':(sensors.pressureTrend>0?'↑ subindo':sensors.pressureTrend<0?'↓ caindo':'→ estável');
ex.innerHTML=`
<div>Conexão: <strong>${online?'online':'offline'}</strong></div>
<div>Bússola: <strong>${sensors.heading!==null?Math.round(sensors.heading)+'° '+cardinal:'sem dados'}</strong></div>
<div>Pressão: <strong>${sensors.pressure!==null?sensors.pressure.toFixed(1)+' hPa':'sem sensor'}</strong></div>
<div>Tendência: <strong>${trend}</strong></div>
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
${sensors.heading===null?'<button onclick="requestCompassPermission()" style="background:#a07832;color:#0e2a3d;border:none;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Ativar bússola</button>':''}
<button onclick="precacheCurrentMapArea()" style="background:#3f7768;color:#efe5cd;border:none;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Pré-cachear mapa</button>
<button onclick="showCacheSizes()" style="background:#0e2a3d;color:#efe5cd;border:1px solid #a07832;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Cache</button>
</div>
<div id="sw-cache-info" style="margin-top:6px;font-size:9px;opacity:.7"></div>`;
}
function updateOnlineStatus(){
const dot=document.getElementById('sw-online');
if(!dot)return;
dot.style.color=navigator.onLine?'#3f7768':'#8c3434';
dot.title=navigator.onLine?'Online':'Offline — usando cache';
}
function headingToCardinal(deg){
const dirs=['N','NE','L','SE','S','SO','O','NO'];
return dirs[Math.round(deg/45)%8];
}
function tryStartCompass(){
// iOS Safari requer permission via gesture do usuário (DeviceOrientationEvent.requestPermission)
// Android Chrome: funciona direto sem permission
if(typeof DeviceOrientationEvent==='undefined')return;
if(typeof DeviceOrientationEvent.requestPermission==='function'){
// iOS — aguarda usuário clicar "Ativar bússola"
return;
}
// Android: pode iniciar direto
attachCompassListener();
}
function requestCompassPermission(){
if(typeof DeviceOrientationEvent==='undefined'){toast('Bússola não suportada');return}
if(typeof DeviceOrientationEvent.requestPermission==='function'){
DeviceOrientationEvent.requestPermission().then(state=>{
if(state==='granted')attachCompassListener();
else toast('Permissão de bússola negada');
}).catch(()=>toast('Falha ao pedir permissão'));
}else{
attachCompassListener();
}
}
function attachCompassListener(){
if(sensors.compassActive)return;
sensors.compassActive=true;
window.addEventListener('deviceorientationabsolute',onCompass);
window.addEventListener('deviceorientation',onCompass);
}
function onCompass(e){
// iOS: e.webkitCompassHeading (0-360 azimuth real)
// Android: e.alpha (0-360, mas relativo — pra absoluto, use deviceorientationabsolute)
let h=null;
if(typeof e.webkitCompassHeading==='number')h=e.webkitCompassHeading;
else if(typeof e.alpha==='number')h=360-e.alpha;
if(h===null||isNaN(h))return;
sensors.heading=h;
const c=document.getElementById('sw-compass');
if(c)c.textContent='🧭 '+Math.round(h)+'° '+headingToCardinal(h);
}
function tryStartBarometer(){
if(typeof Barometer==='undefined')return;
try{
const bar=new Barometer({frequency:1});
bar.addEventListener('reading',()=>{
sensors.pressure=bar.pressure;
sensors._pressHistory.push({ts:Date.now(),p:bar.pressure});
if(sensors._pressHistory.length>30)sensors._pressHistory.shift();
// tendência: diferença entre média recente e antiga
if(sensors._pressHistory.length>=10){
const half=Math.floor(sensors._pressHistory.length/2);
const old=sensors._pressHistory.slice(0,half).reduce((s,x)=>s+x.p,0)/half;
const recent=sensors._pressHistory.slice(half).reduce((s,x)=>s+x.p,0)/(sensors._pressHistory.length-half);
sensors.pressureTrend=recent-old;
}
const el=document.getElementById('sw-pressure');
if(el){el.textContent='🌡 '+bar.pressure.toFixed(1);el.style.opacity='1'}
});
bar.addEventListener('error',e=>console.warn('[barometer]',e.error));
bar.start();
sensors.barometerActive=true;
}catch(e){console.warn('[barometer] no permission/sensor:',e.message)}
}
async function precacheCurrentMapArea(){
if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo ainda — recarregue');return}
// Pede ao usuário um centro/raio se não há mapa visível
if(!confirm('Pré-cachear mapa de uma área de ~50km no entorno da posição atual?\nVai baixar ~200 tiles (uns 5-10 MB).'))return;
if(!navigator.geolocation){toast('Sem GPS pra centro do cache');return}
navigator.geolocation.getCurrentPosition(pos=>{
const lat=pos.coords.latitude,lng=pos.coords.longitude;
const d=0.5; // ~50km de lat/lng buffer
const bounds={north:lat+d,south:lat-d,east:lng+d,west:lng-d};
toast('Baixando tiles…');
const channel=new MessageChannel();
channel.port1.onmessage=ev=>{
if(ev.data.type==='PRECACHE_DONE')toast(`Mapa cacheado: ${ev.data.done}/${ev.data.total}`);
if(ev.data.type==='PRECACHE_ERROR')toast('Erro: '+ev.data.error);
};
navigator.serviceWorker.controller.postMessage({type:'PRECACHE_TILES',bounds,minZoom:8,maxZoom:13},[channel.port2]);
},err=>toast('GPS erro: '+err.message),{timeout:10000});
}
async function showCacheSizes(){
if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo');return}
const channel=new MessageChannel();
channel.port1.onmessage=ev=>{
if(ev.data.type==='CACHE_SIZE_REPORT'){
const s=ev.data.sizes;
const info=document.getElementById('sw-cache-info');
if(info){
const total=Object.values(s).reduce((a,b)=>a+b,0);
info.innerHTML=`Cache: <strong>${total}</strong> itens (shell ${s['shivao-shell-shivao-v1']||0}, tiles ${s['shivao-tiles-v1']||0}, windy ${s['shivao-windy-shivao-v1']||0})`;
}
}
};
navigator.serviceWorker.controller.postMessage({type:'CACHE_SIZE'},[channel.port2]);
}
const battery={level:1,charging:true,saverMode:'auto',forced:'',handler:null};
async function initBattery(){
if(!navigator.getBattery)return;
try{
battery.handler=await navigator.getBattery();
const sync=()=>{battery.level=battery.handler.level;battery.charging=battery.handler.charging;updateBatteryUI()};
sync();
battery.handler.addEventListener('levelchange',sync);
battery.handler.addEventListener('chargingchange',sync);
}catch(e){}
}
function effectiveBatteryMode(){
// forced > auto baseado no nível
if(battery.forced)return battery.forced;
if(battery.charging)return 'normal';
if(battery.level>0.5)return 'normal';
if(battery.level>0.2)return 'eco';
if(battery.level>0.1)return 'low';
return 'critical';
}
function batteryGPSOptions(){
const m=effectiveBatteryMode();
if(m==='normal')return{enableHighAccuracy:true,maximumAge:0,timeout:15000};
if(m==='eco')return{enableHighAccuracy:true,maximumAge:5000,timeout:20000};
if(m==='low')return{enableHighAccuracy:false,maximumAge:30000,timeout:25000};
return{enableHighAccuracy:false,maximumAge:60000,timeout:30000};
}
function batteryDistanceFilter(){
const m=effectiveBatteryMode();
return m==='normal'?5:m==='eco'?8:m==='low'?15:25;
}
function batteryTimeFilter(){
const m=effectiveBatteryMode();
return m==='normal'?2000:m==='eco'?5000:m==='low'?15000:30000;
}
function setBatteryMode(mode){
battery.forced=mode==='auto'?'':mode;
updateBatteryUI();
// se rastreio ativo, reiniciar GPS com novas opções
if(tracking.active&&tracking.watchId!=null){
navigator.geolocation.clearWatch(tracking.watchId);
startGPS();
}
if(anchorWatch.active&&anchorWatch.watchId!=null){
navigator.geolocation.clearWatch(anchorWatch.watchId);
startAnchorGPS();
}
toast('Modo: '+(mode==='auto'?'automático':mode));
}
function updateBatteryUI(){
const el=document.getElementById('battery-indicator');
if(!el)return;
if(!battery.handler){el.innerHTML='';return}
const pct=Math.round(battery.level*100);
const m=effectiveBatteryMode();
const modeLbl={normal:'normal',eco:'eco',low:'baixo',critical:'crítico'}[m];
const color=m==='critical'?'var(--storm)':m==='low'?'var(--sun)':m==='eco'?'var(--brass)':'var(--algae)';
const forced=battery.forced?` (manual)`:'';
el.innerHTML=`<span style="color:${color}">⚡ ${pct}%${battery.charging?' ↑':''} · GPS ${modeLbl}${forced}</span>`;
}
function cycleBatteryMode(){
const modes=['auto','normal','eco','low','critical'];
const cur=battery.forced||'auto';
const next=modes[(modes.indexOf(cur)+1)%modes.length];
setBatteryMode(next);
}
// ============ CHECKLISTS ============
let activeChecklist=null,checkedItems=new Set();
function openChecklistsModal(){
const body=document.getElementById('checklists-body');
body.innerHTML=state.checklists.map(c=>`
<div class="contact-card" style="cursor:pointer" onclick="runChecklist('${c.id}')">
<div class="contact-info">
<div class="contact-name">${escapeHtml(c.name)}</div>
<div class="contact-meta">${c.items.length} ${c.items.length===1?'item':'itens'}</div>
</div>
<div class="entry-actions">
<button class="icon-btn" onclick="event.stopPropagation();editChecklist('${c.id}')" title="Editar">✎</button>
<button class="icon-btn del" onclick="event.stopPropagation();deleteChecklist('${c.id}')" title="Apagar">×</button>
</div>
</div>
`).join('')+`
<div style="margin-top:14px;padding-top:14px;border-top:1px solid var(--rule-soft)">
<button class="btn btn-block" onclick="newChecklist()">+ Nova checklist</button>
</div>`;
openModal('checklists-modal');
}
function runChecklist(id){
const c=state.checklists.find(x=>x.id===id);
if(!c)return;
activeChecklist=c;
checkedItems.clear();
closeModal('checklists-modal');
document.getElementById('run-checklist-title').textContent=c.name;
renderRunChecklist();
openModal('run-checklist-modal');
}
function renderRunChecklist(){
if(!activeChecklist)return;
const body=document.getElementById('run-checklist-body');
body.innerHTML=activeChecklist.items.map(it=>{
const checked=checkedItems.has(it.id);
return `<label style="display:flex;align-items:flex-start;gap:10px;padding:10px 12px;margin-bottom:6px;background:${checked?'var(--algae-soft)':'var(--bg-canvas)'};border:1px solid ${checked?'var(--algae)':'var(--rule)'};cursor:pointer;transition:all .15s">
<input type="checkbox" ${checked?'checked':''} onchange="toggleChecklistItem('${it.id}')" style="margin-top:3px;flex-shrink:0;width:20px;height:20px;accent-color:var(--algae)">
<span style="font-family:var(--f-display);font-style:italic;font-size:15px;color:var(--ink-deep);${checked?'text-decoration:line-through;opacity:.7':''}">${escapeHtml(it.text)}</span>
</label>`;
}).join('');
const total=activeChecklist.items.length;
const done=checkedItems.size;
const prog=document.getElementById('run-checklist-progress');
if(done===total&&total>0)prog.innerHTML=`<span style="color:var(--algae)">✓ COMPLETO ${done}/${total}</span>`;
else if(done>0)prog.innerHTML=`<span style="color:var(--brass)">${done}/${total}</span>`;
else prog.textContent=`${done}/${total}`;
}
function toggleChecklistItem(id){
if(checkedItems.has(id))checkedItems.delete(id);
else checkedItems.add(id);
renderRunChecklist();
}
function resetCurrentChecklist(){checkedItems.clear();renderRunChecklist();toast('Limpo')}
function newChecklist(){
const name=prompt('Nome da checklist:');
if(!name||!name.trim())return;
const c={id:uid(),name:name.trim(),items:[]};
state.checklists.push(c);
saveState();
editChecklist(c.id);
}
function editChecklist(id){
const c=state.checklists.find(x=>x.id===id);
if(!c)return;
closeModal('checklists-modal');
document.getElementById('run-checklist-title').textContent='Editar: '+c.name;
const body=document.getElementById('run-checklist-body');
body.innerHTML=`
<div class="field"><label class="field-label">Nome</label><input id="cl-edit-name" value="${escapeHtml(c.name)}" oninput="state.checklists.find(x=>x.id==='${c.id}').name=this.value;saveState()"></div>
<div class="field"><label class="field-label">Itens</label>
${c.items.map(it=>`
<div style="display:flex;gap:6px;margin-bottom:5px">
<input value="${escapeHtml(it.text)}" oninput="state.checklists.find(x=>x.id==='${c.id}').items.find(i=>i.id==='${it.id}').text=this.value;saveState()" style="flex:1;font-family:var(--f-display);font-style:italic;font-size:14px">
<button class="btn btn-sm" onclick="(()=>{const cl=state.checklists.find(x=>x.id==='${c.id}');cl.items=cl.items.filter(i=>i.id!=='${it.id}');saveState();editChecklist('${c.id}')})()">×</button>
</div>`).join('')}
</div>
<button class="btn btn-block btn-sm" onclick="(()=>{const cl=state.checklists.find(x=>x.id==='${c.id}');cl.items.push({id:uid(),text:''});saveState();editChecklist('${c.id}')})()">+ Adicionar item</button>
<button class="btn btn-block" style="margin-top:10px" onclick="closeModal('run-checklist-modal');openChecklistsModal()">Voltar</button>`;
document.getElementById('run-checklist-progress').innerHTML='';
openModal('run-checklist-modal');
}
function deleteChecklist(id){
if(!confirm('Apagar esta checklist?'))return;
state.checklists=state.checklists.filter(c=>c.id!==id);
saveState();
openChecklistsModal();
}
function trackToGPX(trip){
const t=trip.track;
if(!t||!t.points.length)return null;
const name=trip.destination||'Travessia';
const startTs=t.startedAt||t.points[0].ts||Date.now();
const xmlEsc=s=>String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
const ptXml=t.points.map(p=>{
const time=p.ts?new Date(p.ts).toISOString():new Date(startTs).toISOString();
return ` <trkpt lat="${p.lat}" lon="${p.lng}"><time>${time}</time>${p.spd?`<speed>${p.spd}</speed>`:''}</trkpt>`;
}).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Shivao Diário de Bordo" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${xmlEsc(name)}</name>
<time>${new Date(startTs).toISOString()}</time>
</metadata>
<trk>
<name>${xmlEsc(name)}</name>
<trkseg>
${ptXml}
</trkseg>
</trk>
</gpx>`;
}
function exportTripGPX(tripId){
const t=state.trips.find(x=>x.id===tripId);
if(!t||!t.track){toast('Sem rastreio para exportar');return}
const xml=trackToGPX(t);
const blob=new Blob([xml],{type:'application/gpx+xml'});
triggerDownload(blob,`${slug(t.destination||'travessia')}-${(t.dateStart||todayISO()).replace(/-/g,'')}.gpx`);
toast('GPX baixado');
}
// ============ GEOFENCING / ZONES ============
let zoneMap=null,zoneMarker=null,zoneCircle=null,editingZone=null,pendingZoneCenter=null;
const zoneEnterState=new Map(); // zoneId → boolean (currently inside?)
function renderZones(){
const list=document.getElementById('zones-list');
if(!state.zones.length){
list.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Nenhuma zona marcada</div><div class="empty-text">Crie zonas para receber alertas ao se aproximar (pedras, áreas restritas, marina, ondas).</div></div>`;
return;
}
list.innerHTML=state.zones.map(z=>{
const typeLabel=z.type==='forbidden'?'⛔ Proibida':'⚠️ Atenção';
return `<div class="zone-entry ${z.type}" onclick="openZoneEditor('${z.id}')">
<div class="zone-entry-name">${escapeHtml(z.name||'sem nome')}</div>
<div class="zone-entry-meta">${typeLabel} · raio ${z.radius}m · ${z.center.lat.toFixed(5)}, ${z.center.lng.toFixed(5)}${z.notes?'<br>'+escapeHtml(z.notes):''}</div>
</div>`;
}).join('');
}
function openZoneEditor(id){
editingZone=id?state.zones.find(z=>z.id===id):null;
pendingZoneCenter=editingZone?{...editingZone.center}:null;
document.getElementById('zone-edit-title').textContent=editingZone?'Editar Zona':'Nova Zona';
document.getElementById('zone-name').value=editingZone?.name||'';
document.getElementById('zone-type').value=editingZone?.type||'forbidden';
const r=editingZone?.radius||100;
document.getElementById('zone-radius').value=r;
document.getElementById('zone-radius-val').textContent=r+' m';
document.getElementById('zone-delete-btn').style.display=editingZone?'inline-flex':'none';
document.getElementById('zone-edit-help').textContent=editingZone?'Toque no mapa para mover o centro':'Toque no mapa para marcar o centro da zona';
openModal('zone-edit-modal');
setTimeout(()=>{
if(zoneMap){zoneMap.remove();zoneMap=null;zoneMarker=null;zoneCircle=null}
let initLat=-23,initLng=-43,initZoom=12;
if(editingZone){initLat=editingZone.center.lat;initLng=editingZone.center.lng;initZoom=15}
else if(anchorWatch.lastPos){initLat=anchorWatch.lastPos.lat;initLng=anchorWatch.lastPos.lng;initZoom=15}
else if(tracking.points.length){const last=tracking.points[tracking.points.length-1];initLat=last.lat;initLng=last.lng;initZoom=15}
zoneMap=L.map('zone-map').setView([initLat,initLng],initZoom);
addMapLayers(zoneMap);
// mostrar zonas existentes (exceto a editada)
state.zones.forEach(z=>{
if(editingZone&&z.id===editingZone.id)return;
L.circle([z.center.lat,z.center.lng],{radius:z.radius,color:z.type==='forbidden'?'#8c3434':'#b67025',fillColor:z.type==='forbidden'?'#8c3434':'#b67025',fillOpacity:.15,weight:1.5,opacity:.6}).addTo(zoneMap);
});
if(pendingZoneCenter)drawZonePreview();
zoneMap.on('click',e=>{
pendingZoneCenter={lat:e.latlng.lat,lng:e.latlng.lng};
drawZonePreview();
document.getElementById('zone-edit-help').textContent='Ajuste o raio e o nome';
});
setTimeout(()=>zoneMap.invalidateSize(),100);
},50);
}
function drawZonePreview(){
if(!zoneMap||!pendingZoneCenter)return;
const ll=[pendingZoneCenter.lat,pendingZoneCenter.lng];
const type=document.getElementById('zone-type').value;
const color=type==='forbidden'?'#8c3434':'#b67025';
if(!zoneMarker)zoneMarker=L.marker(ll,{draggable:true}).addTo(zoneMap);
else zoneMarker.setLatLng(ll);
zoneMarker.off('dragend');
zoneMarker.on('dragend',e=>{const p=e.target.getLatLng();pendingZoneCenter={lat:p.lat,lng:p.lng};drawZonePreview()});
if(zoneCircle)zoneCircle.remove();
const r=parseInt(document.getElementById('zone-radius').value);
zoneCircle=L.circle(ll,{radius:r,color,fillColor:color,fillOpacity:.2,weight:2}).addTo(zoneMap);
}
function updateZoneRadius(v){
document.getElementById('zone-radius-val').textContent=v+' m';
if(zoneCircle&&pendingZoneCenter){
zoneCircle.setRadius(parseInt(v));
}
}
document.getElementById('zone-type').addEventListener('change',()=>{if(pendingZoneCenter)drawZonePreview()});
function saveZone(){
if(!pendingZoneCenter){toast('Toque no mapa para marcar o centro');return}
const name=document.getElementById('zone-name').value.trim()||'Zona';
const type=document.getElementById('zone-type').value;
const radius=parseInt(document.getElementById('zone-radius').value);
const data={id:editingZone?.id||uid(),name,type,center:pendingZoneCenter,radius,createdAt:editingZone?.createdAt||Date.now()};
if(editingZone){const idx=state.zones.findIndex(z=>z.id===editingZone.id);state.zones[idx]=data}
else state.zones.unshift(data);
saveState();
closeZoneEditor();
renderZones();
toast('Zona salva');
}
function deleteZone(){
if(!editingZone)return;
if(!confirm('Apagar esta zona?'))return;
state.zones=state.zones.filter(z=>z.id!==editingZone.id);
zoneEnterState.delete(editingZone.id);
saveState();
closeZoneEditor();
renderZones();
toast('Zona apagada');
}
function closeZoneEditor(){
closeModal('zone-edit-modal');
if(zoneMap){setTimeout(()=>{zoneMap.remove();zoneMap=null;zoneMarker=null;zoneCircle=null;pendingZoneCenter=null;editingZone=null},300)}
}
// Detection — chamada de onGPSUpdate e onAnchorGPSUpdate
function checkZones(curPos){
if(!state.zones||!state.zones.length)return;
for(const z of state.zones){
const d=haversine(z.center,curPos);
const inside=d<=z.radius;
const wasInside=zoneEnterState.get(z.id)||false;
if(inside&&!wasInside){
zoneEnterState.set(z.id,true);
onZoneEnter(z);
}else if(!inside&&wasInside){
zoneEnterState.set(z.id,false);
onZoneExit(z);
}
}
}
function onZoneEnter(z){
console.log('[zone] entered',z.name,z.type);
showZoneToast(`Entrou em ${z.name}`,z.type);
if(z.type==='forbidden'){
// alarme curto + vibração forte
playAlarmSound();
if('vibrate' in navigator)navigator.vibrate([300,100,300,100,300]);
setTimeout(stopAlarmSound,3500);
// dispatch webhooks com mensagem de zona
const text=`${state.boat.name||'Veleiro'} entrou em zona PROIBIDA: ${z.name}`;
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]);
}
}
function onZoneExit(z){
console.log('[zone] exited',z.name);
if(z.type==='forbidden')showZoneToast(`Saiu de ${z.name}`,'attention');
}
function showZoneToast(msg,kind){
const t=document.getElementById('zone-toast');
t.textContent=msg;
t.className='zone-toast show'+(kind==='attention'?' warn':'');
clearTimeout(showZoneToast._tm);
showZoneToast._tm=setTimeout(()=>t.className='zone-toast',5000);
}
// Hook into existing GPS handlers
const _origOnGPSUpdate=onGPSUpdate;
onGPSUpdate=function(pos){_origOnGPSUpdate(pos);if(pos&&pos.coords)checkZones({lat:pos.coords.latitude,lng:pos.coords.longitude})};
const _origOnAnchorGPSUpdate=onAnchorGPSUpdate;
onAnchorGPSUpdate=function(pos){_origOnAnchorGPSUpdate(pos);if(pos&&pos.coords&&pos.coords.accuracy<=50)checkZones({lat:pos.coords.latitude,lng:pos.coords.longitude})};
// Render zones on tracking + anchor maps
const _origUpdateTrackingMap=updateTrackingMap;
updateTrackingMap=function(p){_origUpdateTrackingMap(p);drawZonesOnMap(trackingMap)};
const _origDrawAnchorOnMap=drawAnchorOnMap;
drawAnchorOnMap=function(){_origDrawAnchorOnMap();drawZonesOnMap(anchorMap)};
const drawnZonesByMap=new WeakMap();
function drawZonesOnMap(map){
if(!map||!state.zones||!state.zones.length)return;
let layers=drawnZonesByMap.get(map);
if(layers)layers.forEach(l=>l.remove());
layers=[];
state.zones.forEach(z=>{
const color=z.type==='forbidden'?'#8c3434':'#b67025';
const c=L.circle([z.center.lat,z.center.lng],{radius:z.radius,color,fillColor:color,fillOpacity:.15,weight:1.5,opacity:.6}).addTo(map);
layers.push(c);
});
drawnZonesByMap.set(map,layers);
}
// ============ GPX IMPORT ============
function parseGPX(text){
const parser=new DOMParser();
const doc=parser.parseFromString(text,'application/xml');
if(doc.querySelector('parsererror'))throw new Error('XML inválido');
const points=[];
doc.querySelectorAll('trkpt').forEach(pt=>{
const lat=parseFloat(pt.getAttribute('lat'));
const lng=parseFloat(pt.getAttribute('lon'));
if(isNaN(lat)||isNaN(lng))return;
const timeEl=pt.querySelector('time');
const ts=timeEl?new Date(timeEl.textContent).getTime():0;
const speedEl=pt.querySelector('speed,extensions speed');
const spd=speedEl?parseFloat(speedEl.textContent):0;
points.push({lat,lng,ts,spd:isNaN(spd)?0:spd});
});
// se não tiver trkpt, tenta rtept (route points)
if(!points.length){
doc.querySelectorAll('rtept').forEach(pt=>{
const lat=parseFloat(pt.getAttribute('lat'));
const lng=parseFloat(pt.getAttribute('lon'));
if(!isNaN(lat)&&!isNaN(lng))points.push({lat,lng,ts:0,spd:0});
});
}
if(!points.length)throw new Error('Nenhum ponto encontrado no GPX');
// Nome da trilha
const nameEl=doc.querySelector('trk > name, rte > name, metadata > name');
const name=nameEl?nameEl.textContent.trim():'';
// Calcular estatísticas
let dist=0,maxSpd=0;
for(let i=1;i<points.length;i++){
dist+=haversine(points[i-1],points[i]);
if(points[i].spd>maxSpd)maxSpd=points[i].spd;
}
const haveTime=points[0].ts>0&&points[points.length-1].ts>0;
const startedAt=haveTime?points[0].ts:Date.now();
const endedAt=haveTime?points[points.length-1].ts:Date.now();
const dur=(endedAt-startedAt)/1000;
// se não tiver speeds individuais, calcular máx pela velocidade ponto-a-ponto
if(maxSpd===0&&haveTime){
for(let i=1;i<points.length;i++){
const dt=(points[i].ts-points[i-1].ts)/1000;
if(dt>0){const sp=haversine(points[i-1],points[i])/dt;if(sp>maxSpd)maxSpd=sp}
}
}
const avgSpd=dur>0?dist/dur:0;
return {
name,
track:{startedAt,endedAt,points,distance:dist,maxSpeed:maxSpd,avgSpeed:avgSpd}
};
}
async function importGPX(event){
const file=event.target.files[0];if(!file)return;
try{
const text=await file.text();
const{name,track}=parseGPX(text);
event.target.value='';
toast(`GPX: ${track.points.length} pts · ${metersToNM(track.distance).toFixed(2)}mn`);
// abrir modal de viagem com track preenchido
await openTripModal(null,track);
// pré-preencher destino com nome do GPX se disponível
if(name)document.getElementById('trip-destination').value=name;
}catch(e){
alert('Falha ao importar GPX: '+e.message);
event.target.value='';
}
}
// ============ LIVE SHARE ============
let sharePositionInterval=null;
async function createShare(){
if(!cloudConfigured()){toast('Configure a nuvem primeiro');return}
const dur=parseInt(document.getElementById('share-duration').value);
try{
const r=await cloudFetch('/api/share/create',{method:'POST',body:JSON.stringify({durationMinutes:dur,boatName:state.boat.name,zones:state.zones||[]})});
const{token,url,expiresAt}=await r.json();
const shareUrl=url||(state.cloud.url.replace(/\/$/,'')+'/share/'+token);
const expDate=new Date(expiresAt).toLocaleString('pt-BR');
document.getElementById('share-result').innerHTML=`
<strong style="color:var(--algae)">Link criado · expira ${expDate}</strong><br>
<input value="${shareUrl}" readonly onclick="this.select()" style="width:100%;padding:6px 8px;font-family:var(--f-mono);font-size:11px;background:var(--bg-canvas);border:1px solid var(--rule);margin-top:6px">
<div style="display:flex;gap:6px;margin-top:6px"><button class="btn btn-sm" onclick="navigator.clipboard.writeText('${shareUrl}').then(()=>toast('Copiado'))">Copiar</button><button class="btn btn-sm" onclick="shareLink('${shareUrl.replace(/'/g,'')}')">Compartilhar</button></div>`;
startSharePosting();
renderShareList();
}catch(e){toast('Erro: '+e.message)}
}
function shareLink(url){
if(navigator.share)navigator.share({title:`${state.boat.name||'Shivao'} ao vivo`,text:'Acompanhe a posição do barco em tempo real',url}).catch(()=>{});
else{navigator.clipboard.writeText(url);toast('Link copiado')}
}
async function renderShareList(){
const el=document.getElementById('share-active-list');
if(!el||!cloudConfigured()){if(el)el.innerHTML='';return}
try{
const r=await cloudFetch('/api/share/list');
const list=await r.json();
if(!list.length){el.innerHTML='';stopSharePosting();return}
el.innerHTML=`<div class="label-mono" style="margin-bottom:6px">Links ativos</div>`+list.map(s=>{
const exp=new Date(s.expiresAt).toLocaleString('pt-BR');
const url=state.cloud.url.replace(/\/$/,'')+'/share/'+s.token;
return `<div style="background:var(--bg-canvas);border:1px solid var(--rule);padding:8px 10px;margin-bottom:5px;font-family:var(--f-mono);font-size:11px"><div style="display:flex;justify-content:space-between;align-items:center;gap:6px"><div><strong>${escapeHtml(s.boatName||'')}</strong> · expira ${exp}</div><button class="btn btn-sm" onclick="revokeShare('${s.token}')">Revogar</button></div><div style="font-size:10px;color:var(--sepia);margin-top:3px;word-break:break-all">${url}</div></div>`;
}).join('');
if(list.length)startSharePosting();
}catch(e){console.warn('share list',e.message)}
}
async function revokeShare(token){
if(!confirm('Revogar este link? Quem está vendo perderá o acesso.'))return;
try{await cloudFetch('/api/share/'+token,{method:'DELETE'});toast('Revogado');renderShareList();}catch(e){toast('Erro: '+e.message)}
}
function startSharePosting(){
if(sharePositionInterval)return;
postSharePosition();
sharePositionInterval=setInterval(postSharePosition,30000);
}
function stopSharePosting(){if(sharePositionInterval){clearInterval(sharePositionInterval);sharePositionInterval=null}}
async function postSharePosition(){
if(!cloudConfigured())return;
// só posta se tiver posição recente (rastreio ou fundeio ativos)
let pos=null,spd=0;
if(tracking.active&&tracking.points.length){const p=tracking.points[tracking.points.length-1];pos={lat:p.lat,lng:p.lng};spd=p.spd||0}
else if(anchorWatch.active&&anchorWatch.lastPos){pos=anchorWatch.lastPos}
if(!pos){
// tenta pegar uma posição rápida
if(!navigator.geolocation)return;
try{
const p=await new Promise((r,j)=>navigator.geolocation.getCurrentPosition(r,j,{enableHighAccuracy:false,timeout:8000,maximumAge:60000}));
pos={lat:p.coords.latitude,lng:p.coords.longitude};
spd=p.coords.speed||0;
}catch(e){return}
}
try{
await cloudFetch('/api/share/position',{method:'POST',body:JSON.stringify({lat:pos.lat,lng:pos.lng,speed:spd,boatName:state.boat.name})});
}catch(e){console.warn('share post',e.message)}
}
</script>
</body>
</html>