From f95f3a145fc2c3c8943ef1f4dc8d3fa6ec109818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PontualTech=20/=20Karl=C3=A3o?= Date: Wed, 29 Apr 2026 21:21:03 -0300 Subject: [PATCH] =?UTF-8?q?feat(weather):=20mar=C3=A9s=20via=20GPS=20+=20a?= =?UTF-8?q?lertas=20tempestade=20v1.11.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marés (Open-Meteo Marine API, gratuito ilimitado): - URL marine expandida pra incluir sea_level_height_msl em hourly 48h - extractTides() faz peak detection (máximo/mínimo local 3 pontos) - Card '⚓ Marés' anexado ao weather widget mostrando próxima preamar e baixamar com hora + delta tempo + altura Alertas de tempestade: - checkStormConditions() avalia 7 indicadores: * vento sustentado (warn 25kn / danger 35kn) * rajadas (warn 30kn / danger 45kn) * ondas (warn 2m / danger 3.5m) * pressão caindo 3hPa em 3h (tempestade aproximando) * CAPE 1500/3000 J/kg (instabilidade atmosférica → trovoada) - Banner sticky no topo (vermelho danger, amarelo warn) com toque pra expandir detalhes - Push notification via Capacitor LocalNotifications (APK) ou Notification API (web) - Deduplica notif: só dispara se assinatura de alertas mudou - Permissão pedida no boot (5s delay) Disparado automaticamente após cada fetchWeather (forecast atualiza a cada N minutos via maybeAutoFetchWeather). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/diario-bordo.html | 212 +++++++++++++++++++++++++++++++- mobile/android/app/build.gradle | 4 +- mobile/package.json | 2 +- server/public/index.html | 212 +++++++++++++++++++++++++++++++- server/public/sw.js | 2 +- server/src/index.js | 2 +- 6 files changed, 419 insertions(+), 15 deletions(-) diff --git a/app/diario-bordo.html b/app/diario-bordo.html index 3b47fe8..da6cfb0 100644 --- a/app/diario-bordo.html +++ b/app/diario-bordo.html @@ -3781,6 +3781,16 @@ async function updateStorageInfo(){ try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)} } setTimeout(()=>{try{maybeAutoFetchWeather()}catch(e){}},3000); + // Pede permissão de notificações (alertas tempestade) + setTimeout(async()=>{ + try{ + const ln=window.Capacitor?.Plugins?.LocalNotifications; + if(ln){await ln.requestPermissions().catch(()=>{})} + else if('Notification' in window&&Notification.permission==='default'){ + await Notification.requestPermission().catch(()=>{}); + } + }catch{} + },5000); setTimeout(()=>{try{maybeShowWelcome()}catch(e){}},300); setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500); }catch(e){ @@ -5586,19 +5596,172 @@ 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}¤t=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}¤t=wave_height,wave_direction,wave_period&hourly=wave_height&forecast_hours=24&timezone=auto`; + // Forecast: vento, temp, pressão, lightning (CAPE), gust + const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl&hourly=wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl,cape&forecast_hours=24&wind_speed_unit=kn&timezone=auto`; + // Marine: ondas + nível do mar (sea_level_height_msl = maré) + const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}¤t=wave_height,wave_direction,wave_period&hourly=wave_height,sea_level_height_msl&forecast_hours=48&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}; + weather.tides=extractTides(m); renderWeather(); + checkStormConditions(weather.data,{lat,lng}); }catch(e){console.warn('weather',e.message)} weather.fetching=false; } +// Peak detection no array hourly de sea_level_height_msl +// Retorna { nextHigh: {time, height_m}, nextLow: {time, height_m} } +function extractTides(marineData){ + if(!marineData?.hourly?.sea_level_height_msl||!marineData?.hourly?.time)return null; + const sl=marineData.hourly.sea_level_height_msl; + const ts=marineData.hourly.time; + const now=Date.now(); + const peaks=[]; // {idx, type:'high'|'low', height, time} + for(let i=1;isl[i-1]&&sl[i]>sl[i+1])peaks.push({type:'high',height:sl[i],time:t}); + else if(sl[i]p.type==='high')||null, + nextLow:peaks.find(p=>p.type==='low')||null, + all:peaks.slice(0,8), // próximas 8 transições (~ 4 ciclos completos) + }; +} + +// ============ STORM ALERTS ============ +// Limiares — futuros: configuráveis via Settings +const STORM_THRESHOLDS={ + windWarn:25, // nós · vento sustentado + windDanger:35, + gustWarn:30, + gustDanger:45, + waveWarn:2.0, // metros + waveDanger:3.5, + pressureDropWarn:3, // hPa em 3h + capeWarn:1500, // J/kg · CAPE > 1500 = atmosfera instável (trovoada) + capeDanger:3000, +}; + +function checkStormConditions(weatherData,pos){ + if(!weatherData)return; + const alerts=[]; + const f=weatherData.forecast; + const m=weatherData.marine; + // Vento atual + let windKn=null,gustKn=null,pressureNow=null,cape=null; + if(weatherData.provider==='openmeteo'){ + windKn=f?.current?.wind_speed_10m; + gustKn=f?.current?.wind_gusts_10m; + pressureNow=f?.current?.pressure_msl; + // CAPE atual = primeiro valor hourly + cape=f?.hourly?.cape?.[0]; + }else if(weatherData.provider==='windy'){ + const ms=weatherData.main; + const u=ms?.['wind_u-surface']?.[0]??0; + const v=ms?.['wind_v-surface']?.[0]??0; + windKn=Math.sqrt(u*u+v*v)*1.94384; + const gu=ms?.['wind_u-gust-surface']?.[0]??ms?.['gust-surface']?.[0]??0; + gustKn=gu*1.94384; + pressureNow=ms?.['pressure-surface']?.[0]?ms['pressure-surface'][0]/100:null; + } + // Wave atual + const waveM=m?.current?.wave_height; + // Pressão caindo: compara atual com 3h atrás (dado anterior em hourly) + let pressureDrop3h=0; + if(f?.hourly?.pressure_msl?.length>3){ + const p0=f.hourly.pressure_msl[0]; + const p3=f.hourly.pressure_msl[3]; + if(p0!=null&&p3!=null)pressureDrop3h=p0-p3; + } + // Avalia condições + if(windKn!=null){ + if(windKn>=STORM_THRESHOLDS.windDanger)alerts.push({level:'danger',title:'Vento crítico',msg:`${windKn.toFixed(0)}kn · evite navegar`}); + else if(windKn>=STORM_THRESHOLDS.windWarn)alerts.push({level:'warn',title:'Vento forte',msg:`${windKn.toFixed(0)}kn · cuidado`}); + } + if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustDanger)alerts.push({level:'danger',title:'Rajadas perigosas',msg:`pico ${gustKn.toFixed(0)}kn`}); + else if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustWarn)alerts.push({level:'warn',title:'Rajadas fortes',msg:`pico ${gustKn.toFixed(0)}kn`}); + if(waveM!=null){ + if(waveM>=STORM_THRESHOLDS.waveDanger)alerts.push({level:'danger',title:'Mar perigoso',msg:`ondas ${waveM.toFixed(1)}m`}); + else if(waveM>=STORM_THRESHOLDS.waveWarn)alerts.push({level:'warn',title:'Mar agitado',msg:`ondas ${waveM.toFixed(1)}m`}); + } + if(pressureDrop3h>=STORM_THRESHOLDS.pressureDropWarn)alerts.push({level:'warn',title:'Pressão caindo',msg:`-${pressureDrop3h.toFixed(1)}hPa em 3h · tempestade aproximando`}); + if(cape!=null){ + if(cape>=STORM_THRESHOLDS.capeDanger)alerts.push({level:'danger',title:'Trovoada provável',msg:`CAPE ${Math.round(cape)} J/kg · risco de raios`}); + else if(cape>=STORM_THRESHOLDS.capeWarn)alerts.push({level:'warn',title:'Atmosfera instável',msg:`CAPE ${Math.round(cape)} · trovoadas isoladas`}); + } + // Salva no state pra UI ler + weather.alerts=alerts; + // Renderiza banner se alguma + renderStormBanner(); + // Notificação push em alertas novos (compara com último set) + const sig=alerts.map(a=>a.level+':'+a.title).join('|'); + if(alerts.length>0&&sig!==weather._lastAlertSig){ + weather._lastAlertSig=sig; + sendStormNotification(alerts); + }else if(alerts.length===0){ + weather._lastAlertSig=''; + } +} + +function renderStormBanner(){ + let banner=document.getElementById('storm-banner'); + const alerts=weather.alerts||[]; + if(alerts.length===0){ + if(banner)banner.remove(); + return; + } + if(!banner){ + banner=document.createElement('div'); + banner.id='storm-banner'; + banner.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9000;padding:10px 14px;font-family:var(--f-body);font-size:13px;font-weight:600;text-align:center;cursor:pointer;backdrop-filter:blur(8px)'; + banner.onclick=()=>{ + const det=document.getElementById('storm-banner-details'); + if(det)det.style.display=det.style.display==='block'?'none':'block'; + }; + document.body.appendChild(banner); + } + const worst=alerts.find(a=>a.level==='danger')||alerts[0]; + const bg=worst.level==='danger'?'rgba(239,68,68,.94)':'rgba(245,158,11,.94)'; + banner.style.background=bg; + banner.style.color='#fff'; + banner.innerHTML=`⚠ ${worst.title} · ${worst.msg}${alerts.length>1?` (+${alerts.length-1})`:''} · toque pra detalhes + `; +} + +async function sendStormNotification(alerts){ + const worst=alerts.find(a=>a.level==='danger')||alerts[0]; + const title=worst.level==='danger'?'⚠ ALERTA CRÍTICO':'🟡 Aviso meteorológico'; + const body=alerts.map(a=>`${a.title}: ${a.msg}`).join(' · '); + // Capacitor LocalNotifications + try{ + const ln=window.Capacitor?.Plugins?.LocalNotifications; + if(ln){ + await ln.schedule({notifications:[{ + id:Math.floor(Math.random()*1e9), + title,body,smallIcon:'ic_stat_anchor', + }]}); + return; + } + }catch(e){console.warn('storm notif',e.message)} + // Web Notifications API + if('Notification' in window){ + if(Notification.permission==='granted'){ + try{new Notification(title,{body,icon:'/icon.svg',tag:'storm-alert'})}catch{} + }else if(Notification.permission!=='denied'){ + try{await Notification.requestPermission()}catch{} + } + } +} + // Helpers de conversão de unidades function uvToSpeedDir(u,v){ // u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte). @@ -5624,8 +5787,47 @@ function renderWeather(){ if(!el)return; const d=weather.data; if(!d){el.innerHTML='';return} - if(d.provider==='windy')return renderWindyWeather(el,d); - return renderOpenMeteoWeather(el,d); + if(d.provider==='windy')renderWindyWeather(el,d); + else renderOpenMeteoWeather(el,d); + // Anexa card de marés se tiver dados + appendTidesCard(el); +} + +function appendTidesCard(weatherEl){ + if(!weather.tides||!weather.tides.nextHigh)return; + const t=weather.tides; + const fmtTide=(p)=>{ + if(!p)return '—'; + const d=new Date(p.time); + const hh=d.getHours().toString().padStart(2,'0'); + const mm=d.getMinutes().toString().padStart(2,'0'); + const diffMin=Math.round((d.getTime()-Date.now())/60000); + const inH=diffMin>=60?`em ${Math.floor(diffMin/60)}h${(diffMin%60).toString().padStart(2,'0')}`:`em ${diffMin}min`; + return `${hh}:${mm} (${inH}) · ${p.height>=0?'+':''}${p.height.toFixed(2)}m`; + }; + const tidesHtml=` +
+
⚓ Marés (próx. 48h)
+
+
+
↑ PREAMAR
+
${fmtTide(t.nextHigh)}
+
+
+
↓ BAIXAMAR
+
${fmtTide(t.nextLow)}
+
+
+
`; + // Append apenas se ainda não existe + if(!weatherEl.querySelector('.tides-block')){ + const div=document.createElement('div'); + div.className='tides-block'; + div.innerHTML=tidesHtml; + weatherEl.appendChild(div); + }else{ + weatherEl.querySelector('.tides-block').innerHTML=tidesHtml; + } } function renderWindyWeather(el,d){ @@ -6506,7 +6708,7 @@ async function removeBluetoothDevice(id){ renderBluetoothCard(); } -const APP_VERSION='1.10.19'; +const APP_VERSION='1.11.0'; function renderBluetoothCard(){ const el=document.getElementById('bt-list'); const supportEl=document.getElementById('bt-support'); diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index fcafd1d..2ec7a35 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "br.com.pontualtech.shivao" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 31 - versionName "1.10.18" + versionCode 32 + versionName "1.11.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/mobile/package.json b/mobile/package.json index 647b9d7..eed1cb8 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -1,6 +1,6 @@ { "name": "shivao-mobile", - "version": "1.10.18", + "version": "1.11.0", "description": "Shivao app nativo (Capacitor wrapper Android/iOS)", "main": "index.js", "type": "module", diff --git a/server/public/index.html b/server/public/index.html index 3b47fe8..da6cfb0 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -3781,6 +3781,16 @@ async function updateStorageInfo(){ try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)} } setTimeout(()=>{try{maybeAutoFetchWeather()}catch(e){}},3000); + // Pede permissão de notificações (alertas tempestade) + setTimeout(async()=>{ + try{ + const ln=window.Capacitor?.Plugins?.LocalNotifications; + if(ln){await ln.requestPermissions().catch(()=>{})} + else if('Notification' in window&&Notification.permission==='default'){ + await Notification.requestPermission().catch(()=>{}); + } + }catch{} + },5000); setTimeout(()=>{try{maybeShowWelcome()}catch(e){}},300); setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500); }catch(e){ @@ -5586,19 +5596,172 @@ 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}¤t=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}¤t=wave_height,wave_direction,wave_period&hourly=wave_height&forecast_hours=24&timezone=auto`; + // Forecast: vento, temp, pressão, lightning (CAPE), gust + const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl&hourly=wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl,cape&forecast_hours=24&wind_speed_unit=kn&timezone=auto`; + // Marine: ondas + nível do mar (sea_level_height_msl = maré) + const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}¤t=wave_height,wave_direction,wave_period&hourly=wave_height,sea_level_height_msl&forecast_hours=48&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}; + weather.tides=extractTides(m); renderWeather(); + checkStormConditions(weather.data,{lat,lng}); }catch(e){console.warn('weather',e.message)} weather.fetching=false; } +// Peak detection no array hourly de sea_level_height_msl +// Retorna { nextHigh: {time, height_m}, nextLow: {time, height_m} } +function extractTides(marineData){ + if(!marineData?.hourly?.sea_level_height_msl||!marineData?.hourly?.time)return null; + const sl=marineData.hourly.sea_level_height_msl; + const ts=marineData.hourly.time; + const now=Date.now(); + const peaks=[]; // {idx, type:'high'|'low', height, time} + for(let i=1;isl[i-1]&&sl[i]>sl[i+1])peaks.push({type:'high',height:sl[i],time:t}); + else if(sl[i]p.type==='high')||null, + nextLow:peaks.find(p=>p.type==='low')||null, + all:peaks.slice(0,8), // próximas 8 transições (~ 4 ciclos completos) + }; +} + +// ============ STORM ALERTS ============ +// Limiares — futuros: configuráveis via Settings +const STORM_THRESHOLDS={ + windWarn:25, // nós · vento sustentado + windDanger:35, + gustWarn:30, + gustDanger:45, + waveWarn:2.0, // metros + waveDanger:3.5, + pressureDropWarn:3, // hPa em 3h + capeWarn:1500, // J/kg · CAPE > 1500 = atmosfera instável (trovoada) + capeDanger:3000, +}; + +function checkStormConditions(weatherData,pos){ + if(!weatherData)return; + const alerts=[]; + const f=weatherData.forecast; + const m=weatherData.marine; + // Vento atual + let windKn=null,gustKn=null,pressureNow=null,cape=null; + if(weatherData.provider==='openmeteo'){ + windKn=f?.current?.wind_speed_10m; + gustKn=f?.current?.wind_gusts_10m; + pressureNow=f?.current?.pressure_msl; + // CAPE atual = primeiro valor hourly + cape=f?.hourly?.cape?.[0]; + }else if(weatherData.provider==='windy'){ + const ms=weatherData.main; + const u=ms?.['wind_u-surface']?.[0]??0; + const v=ms?.['wind_v-surface']?.[0]??0; + windKn=Math.sqrt(u*u+v*v)*1.94384; + const gu=ms?.['wind_u-gust-surface']?.[0]??ms?.['gust-surface']?.[0]??0; + gustKn=gu*1.94384; + pressureNow=ms?.['pressure-surface']?.[0]?ms['pressure-surface'][0]/100:null; + } + // Wave atual + const waveM=m?.current?.wave_height; + // Pressão caindo: compara atual com 3h atrás (dado anterior em hourly) + let pressureDrop3h=0; + if(f?.hourly?.pressure_msl?.length>3){ + const p0=f.hourly.pressure_msl[0]; + const p3=f.hourly.pressure_msl[3]; + if(p0!=null&&p3!=null)pressureDrop3h=p0-p3; + } + // Avalia condições + if(windKn!=null){ + if(windKn>=STORM_THRESHOLDS.windDanger)alerts.push({level:'danger',title:'Vento crítico',msg:`${windKn.toFixed(0)}kn · evite navegar`}); + else if(windKn>=STORM_THRESHOLDS.windWarn)alerts.push({level:'warn',title:'Vento forte',msg:`${windKn.toFixed(0)}kn · cuidado`}); + } + if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustDanger)alerts.push({level:'danger',title:'Rajadas perigosas',msg:`pico ${gustKn.toFixed(0)}kn`}); + else if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustWarn)alerts.push({level:'warn',title:'Rajadas fortes',msg:`pico ${gustKn.toFixed(0)}kn`}); + if(waveM!=null){ + if(waveM>=STORM_THRESHOLDS.waveDanger)alerts.push({level:'danger',title:'Mar perigoso',msg:`ondas ${waveM.toFixed(1)}m`}); + else if(waveM>=STORM_THRESHOLDS.waveWarn)alerts.push({level:'warn',title:'Mar agitado',msg:`ondas ${waveM.toFixed(1)}m`}); + } + if(pressureDrop3h>=STORM_THRESHOLDS.pressureDropWarn)alerts.push({level:'warn',title:'Pressão caindo',msg:`-${pressureDrop3h.toFixed(1)}hPa em 3h · tempestade aproximando`}); + if(cape!=null){ + if(cape>=STORM_THRESHOLDS.capeDanger)alerts.push({level:'danger',title:'Trovoada provável',msg:`CAPE ${Math.round(cape)} J/kg · risco de raios`}); + else if(cape>=STORM_THRESHOLDS.capeWarn)alerts.push({level:'warn',title:'Atmosfera instável',msg:`CAPE ${Math.round(cape)} · trovoadas isoladas`}); + } + // Salva no state pra UI ler + weather.alerts=alerts; + // Renderiza banner se alguma + renderStormBanner(); + // Notificação push em alertas novos (compara com último set) + const sig=alerts.map(a=>a.level+':'+a.title).join('|'); + if(alerts.length>0&&sig!==weather._lastAlertSig){ + weather._lastAlertSig=sig; + sendStormNotification(alerts); + }else if(alerts.length===0){ + weather._lastAlertSig=''; + } +} + +function renderStormBanner(){ + let banner=document.getElementById('storm-banner'); + const alerts=weather.alerts||[]; + if(alerts.length===0){ + if(banner)banner.remove(); + return; + } + if(!banner){ + banner=document.createElement('div'); + banner.id='storm-banner'; + banner.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9000;padding:10px 14px;font-family:var(--f-body);font-size:13px;font-weight:600;text-align:center;cursor:pointer;backdrop-filter:blur(8px)'; + banner.onclick=()=>{ + const det=document.getElementById('storm-banner-details'); + if(det)det.style.display=det.style.display==='block'?'none':'block'; + }; + document.body.appendChild(banner); + } + const worst=alerts.find(a=>a.level==='danger')||alerts[0]; + const bg=worst.level==='danger'?'rgba(239,68,68,.94)':'rgba(245,158,11,.94)'; + banner.style.background=bg; + banner.style.color='#fff'; + banner.innerHTML=`⚠ ${worst.title} · ${worst.msg}${alerts.length>1?` (+${alerts.length-1})`:''} · toque pra detalhes + `; +} + +async function sendStormNotification(alerts){ + const worst=alerts.find(a=>a.level==='danger')||alerts[0]; + const title=worst.level==='danger'?'⚠ ALERTA CRÍTICO':'🟡 Aviso meteorológico'; + const body=alerts.map(a=>`${a.title}: ${a.msg}`).join(' · '); + // Capacitor LocalNotifications + try{ + const ln=window.Capacitor?.Plugins?.LocalNotifications; + if(ln){ + await ln.schedule({notifications:[{ + id:Math.floor(Math.random()*1e9), + title,body,smallIcon:'ic_stat_anchor', + }]}); + return; + } + }catch(e){console.warn('storm notif',e.message)} + // Web Notifications API + if('Notification' in window){ + if(Notification.permission==='granted'){ + try{new Notification(title,{body,icon:'/icon.svg',tag:'storm-alert'})}catch{} + }else if(Notification.permission!=='denied'){ + try{await Notification.requestPermission()}catch{} + } + } +} + // Helpers de conversão de unidades function uvToSpeedDir(u,v){ // u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte). @@ -5624,8 +5787,47 @@ function renderWeather(){ if(!el)return; const d=weather.data; if(!d){el.innerHTML='';return} - if(d.provider==='windy')return renderWindyWeather(el,d); - return renderOpenMeteoWeather(el,d); + if(d.provider==='windy')renderWindyWeather(el,d); + else renderOpenMeteoWeather(el,d); + // Anexa card de marés se tiver dados + appendTidesCard(el); +} + +function appendTidesCard(weatherEl){ + if(!weather.tides||!weather.tides.nextHigh)return; + const t=weather.tides; + const fmtTide=(p)=>{ + if(!p)return '—'; + const d=new Date(p.time); + const hh=d.getHours().toString().padStart(2,'0'); + const mm=d.getMinutes().toString().padStart(2,'0'); + const diffMin=Math.round((d.getTime()-Date.now())/60000); + const inH=diffMin>=60?`em ${Math.floor(diffMin/60)}h${(diffMin%60).toString().padStart(2,'0')}`:`em ${diffMin}min`; + return `${hh}:${mm} (${inH}) · ${p.height>=0?'+':''}${p.height.toFixed(2)}m`; + }; + const tidesHtml=` +
+
⚓ Marés (próx. 48h)
+
+
+
↑ PREAMAR
+
${fmtTide(t.nextHigh)}
+
+
+
↓ BAIXAMAR
+
${fmtTide(t.nextLow)}
+
+
+
`; + // Append apenas se ainda não existe + if(!weatherEl.querySelector('.tides-block')){ + const div=document.createElement('div'); + div.className='tides-block'; + div.innerHTML=tidesHtml; + weatherEl.appendChild(div); + }else{ + weatherEl.querySelector('.tides-block').innerHTML=tidesHtml; + } } function renderWindyWeather(el,d){ @@ -6506,7 +6708,7 @@ async function removeBluetoothDevice(id){ renderBluetoothCard(); } -const APP_VERSION='1.10.19'; +const APP_VERSION='1.11.0'; function renderBluetoothCard(){ const el=document.getElementById('bt-list'); const supportEl=document.getElementById('bt-support'); diff --git a/server/public/sw.js b/server/public/sw.js index 1949b26..c996e09 100644 --- a/server/public/sw.js +++ b/server/public/sw.js @@ -1,7 +1,7 @@ // Shivao Service Worker — offline real // Estratégia: shell precachado, tiles cache-first, windy network-first, /api passa direto. // Versão usada nos cache names — bumpa essa string pra invalidar caches antigos em deploys. -const VERSION = 'shivao-v1.10.19'; +const VERSION = 'shivao-v1.11.0'; const SHELL_CACHE = `shivao-shell-${VERSION}`; const TILES_CACHE = 'shivao-tiles-v1'; // separado pra não invalidar tiles em update do shell const WINDY_CACHE = `shivao-windy-${VERSION}`; diff --git a/server/src/index.js b/server/src/index.js index fff6380..5c37fab 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -413,7 +413,7 @@ app.get('/.well-known/assetlinks.json', (req, res) => { }); // Atalho: /apk redireciona pra última APK release no Forgejo -const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.10.18/Shivao-v1.10.18.apk'; +const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.11.0/Shivao-v1.11.0.apk'; app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL)); // Página A4 imprimível com QR Code + instruções (cola no barco/marina)