shivao-projeto/server/public/index.html
PontualTech / Karlão 7a523b8873 feat(mobile): scaffold Capacitor pra Android Play Store + adapter nativo
ESTRUTURA NOVA: mobile/ + scripts/sync-html.mjs

- mobile/package.json: Capacitor 6 + plugins (geolocation, local-notifications, network, preferences, status-bar)
- mobile/capacitor.config.json: appId br.com.pontualtech.shivao, allowNavigation pra OSM/Windy/CDNs
- mobile/.gitignore: protege keystore (NUNCA commitar chaves privadas)
- mobile/README.md: setup completo (JDK + Android Studio + keystore + build APK/AAB + Play Store submission + iOS futuro + troubleshooting)
- scripts/sync-html.mjs: copia app/diario-bordo.html → server/public + mobile/www (1 fonte da verdade)

ADAPTER NATIVO no HTML (sincronizado app/ + server/public/):
- isNative() / nativePlatform() detecta Capacitor
- nativeWatchPosition() usa Capacitor.Geolocation (background-capable) com fallback navigator.geolocation
- nativeNotify() usa Capacitor.LocalNotifications com fallback toast
- initServiceWorker() pula registro no Capacitor (WebView nativo já tem cache próprio)

NÃO INCLUI (ainda):
- Build local: precisa JDK 17 + Android Studio (~3GB) — instruções no README
- Keystore: gerar 1 vez via keytool (script no README)
- AAB pra Play Store: comandos no README
- Conta Google Play Developer: $25 1× pelo dono

Próximo passo manual: instalar JDK + Android Studio, rodar 'npm install && npx cap add android'.

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

3845 lines
222 KiB
HTML
Raw 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">
<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=Fraunces:ital,opsz,wght,SOFT@0,9..144,400..700,0..100;1,9..144,400..700,0..100&family=Manrope:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&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-meta{font-family:var(--f-mono);font-size:10.5px;letter-spacing:.06em;color:rgba(250,242,221,.65);margin-top:2px}
.boat-meta input{
background:transparent;border:none;color:inherit;
font:inherit;padding:1px 0;width:100%;
}
.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)}
.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}
}
</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-info">
<div class="boat-tagline">Diário de Bordo · Logbook</div>
<input class="boat-name" id="boat-name" value="Shivao" maxlength="40" spellcheck="false">
<div class="boat-meta"><input id="boat-model" placeholder="Modelo / Classe / Marina" maxlength="48"></div>
</div>
</div>
</header>
<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>
<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>
<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>Fundear o Shivao</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>
<div class="field">
<label class="field-label">Raio de segurança</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. Considere: linha de fundeio + maré + giro do barco.</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>
<div class="toast" id="toast"></div>
<div class="zone-toast" id="zone-toast"></div>
<script>
const state={boat:{name:'Shivao',model:''},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'}};
const STORAGE_KEY='diario_bordo_v3';
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'}}}catch(e){console.warn(e)}
// 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')}}
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);L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(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 n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})}
document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})});
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 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()};
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');
}
function deletePending(id){if(!confirm('Apagar pendência?'))return;state.pending=state.pending.filter(p=>p.id!==id);saveState();renderAll();toast('Removido')}
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');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(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();
// tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000);
})();
document.addEventListener('visibilitychange',async()=>{if(document.visibilityState==='visible'){if(tracking.active&&!tracking.wakeLock)await requestWakeLock();if(anchorWatch.active&&!anchorWatch.wakeLock)await requestAnchorWakeLock()}});
// ============ 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();
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}
);
}
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);L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(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(){return state.cloud&&state.cloud.url&&state.cloud.token}
function cloudUrl(path){return state.cloud.url.replace(/\/$/,'')+path}
async function cloudFetch(path,opts={}){
if(!cloudConfigured())throw new Error('Nuvem não configurada');
const auth=(state.auth&&state.auth.accessToken)?state.auth.accessToken:state.cloud.token;
let r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth}`,'Content-Type':'application/json',...(opts.headers||{})}});
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;
}
// ===== Auth (multi-tenant SaaS) =====
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:',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}
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()});
t.addEventListener('change',()=>{state.cloud.token=t.value.trim();saveState();renderCloudStatus()});
}
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);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(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>`;
}
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 =====
const isNative=()=>!!(window.Capacitor&&window.Capacitor.isNativePlatform&&window.Capacitor.isNativePlatform());
const nativePlatform=()=>(window.Capacitor&&window.Capacitor.getPlatform)?window.Capacitor.getPlatform():'web';
async function nativeWatchPosition(onUpdate,onError,opts){
if(isNative()&&window.Capacitor.Plugins.Geolocation){
try{
const{Geolocation}=window.Capacitor.Plugins;
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)}
}
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);
}
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 (apenas Web — Capacitor usa cache nativo) =====
function initServiceWorker(){
if(isNative())return;
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(){
if(typeof DeviceOrientationEvent==='undefined')return;
if(typeof DeviceOrientationEvent.requestPermission==='function')return; // iOS espera tap
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){
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();
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}
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;
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);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(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>