poke/html/weather.ejs
2025-08-29 15:01:38 +02:00

484 lines
25 KiB
Plaintext
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="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>PokeWeather</title>
<meta name="theme-color" content="#0ea5e9" />
<meta name="color-scheme" content="dark light" />
<style>
:root{--bg:#0b0f16;--fg:#eef3ff;--muted:#a7b4cc;--card:#0f1624;--ring:#ffffff22;--accent:#0ea5e9;--good:#7ee787;--bad:#f97373;--warn:#fbbf24;--br:16px}
@media (prefers-color-scheme:light){:root{--bg:#fbfbfe;--fg:#0b1020;--muted:#5b6b87;--card:#ffffff;--ring:#0b102018}}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:radial-gradient(1200px 800px at 20% 10%,#0c1528,var(--bg));color:var(--fg);font:15px/1.4 ui-sans-serif,system-ui,Segoe UI,Roboto,Inter,Arial}
/* Improved, stable navbar (no weird pill stretching) */
header{position:sticky;top:0;z-index:40}
.navwrap{backdrop-filter:saturate(1.4) blur(10px);background:linear-gradient(180deg,rgba(0,0,0,.35),rgba(0,0,0,.15));border-bottom:1px solid var(--ring)}
.wrap{max-width:1100px;margin:0 auto;padding:12px 14px}
.top{display:flex;align-items:center;gap:12px;justify-content:space-between;min-height:56px}
.brand{display:flex;align-items:center;gap:10px}
.brand .logo{width:36px;height:36px;border-radius:12px;background:linear-gradient(135deg,#00d2ff,#3a7bd5);display:grid;place-items:center;box-shadow:0 6px 20px #00d2ff44;flex:0 0 36px}
/* removed brand text on purpose */
.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--ring);border-radius:999px;padding:6px 10px;background:linear-gradient(180deg,transparent,rgba(255,255,255,.03));white-space:nowrap;max-width:58vw;overflow:hidden;text-overflow:ellipsis}
.controls{display:flex;gap:8px;flex-wrap:nowrap}
button,.btn{border:1px solid var(--ring);background:linear-gradient(180deg,transparent,rgba(255,255,255,.03));color:var(--fg);padding:10px 12px;border-radius:12px;cursor:pointer;white-space:nowrap}
button:active{transform:translateY(1px)}
.subnav{border-top:1px solid var(--ring)}
.subrow{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding-top:8px}
.tabs{display:flex;gap:8px}
.tab{padding:8px 12px;border:1px solid var(--ring);border-radius:999px;background:linear-gradient(180deg,transparent,rgba(255,255,255,.03));font-weight:700;text-decoration:none;color:inherit}
/* Search (works with & without JS) */
.search{display:flex;gap:8px;position:relative;flex:1;min-width:220px}
.search input[type="search"]{flex:1;min-width:140px;border:1px solid var(--ring);background:var(--card);color:var(--fg);padding:12px 14px;border-radius:12px;outline:none}
.search button[type="submit"]{padding:10px 12px}
.search .suggest{position:absolute;background:var(--card);border:1px solid var(--ring);border-radius:12px;margin-top:4px;overflow:hidden;display:none;z-index:30;max-height:300px;overflow:auto;top:100%;left:0;right:auto}
.search .suggest button{display:block;width:100%;text-align:left;padding:10px 12px;border:0;border-bottom:1px solid var(--ring);background:none;color:inherit}
.search .suggest button:last-child{border-bottom:0}
main{max-width:1100px;margin:14px auto;padding:0 14px;display:grid;gap:14px}
.grid{display:grid;gap:14px;grid-template-columns:repeat(12,1fr)}
.card{background:var(--card);border:1px solid var(--ring);border-radius:var(--br);padding:14px}
.current{grid-column:span 12;display:grid;gap:12px}
.curr-top{display:flex;gap:14px;align-items:center;justify-content:space-between;flex-wrap:wrap}
.temp{font-size:52px;font-weight:900;letter-spacing:-1px}
.desc{color:var(--muted);font-weight:600}
.details{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
.stat{border:1px dashed var(--ring);border-radius:12px;padding:10px}
.stat h4{margin:0 0 6px 0;font-size:12px;color:var(--muted);font-weight:700}
.stat div{font-weight:800}
.row{display:flex;gap:10px;align-items:center}
.bigicon{font-size:42px}
.section-title{font-size:14px;color:var(--muted);margin:0 0 8px 4px;font-weight:700;text-transform:uppercase;letter-spacing:.1em}
.scrollx{display:flex;gap:10px;overflow:auto;scroll-snap-type:x mandatory;padding-bottom:4px}
.hour{min-width:84px;scroll-snap-align:start;border:1px solid var(--ring);background:linear-gradient(180deg,transparent,rgba(255,255,255,.02));border-radius:12px;padding:10px;text-align:center}
.hour .t{font-weight:800}
.day{display:grid;grid-template-columns:1.2fr 2fr .8fr;gap:10px;align-items:center;border:1px solid var(--ring);border-radius:12px;padding:10px}
.day .hi{font-weight:800}
.day .lo{color:var(--muted)}
.panel{grid-column:span 12}
canvas{width:100%;height:120px;background:linear-gradient(180deg,transparent,rgba(255,255,255,.02));border-radius:12px;border:1px solid var(--ring)}
footer{opacity:.8;text-align:center;padding:18px;color:var(--muted)}
.hidden{display:none}
@media (min-width:780px){.current{grid-column:span 7}.panel{grid-column:span 5}}
/* Default: show JS UI; hide SSR block */
.ssr{display:none}
main{display:grid}
</style>
<!-- When JS is disabled/blocked, show SSR block and hide main -->
<noscript>
<style>
.ssr{display:block !important;}
main{display:none !important;}
</style>
</noscript>
</head>
<body class="<%= ssr && ssr.forceNoJS ? 'no-js' : '' %>">
<!-- NAVBAR -->
<header>
<div class="navwrap">
<div class="wrap top">
<div class="brand">
<div class="logo" aria-hidden="true"><span>☁️</span></div>
<span class="chip" id="locChip"><%= (ssr && ssr.name) || '—' %></span>
</div>
<div class="controls">
<button id="btnUnits" title="Toggle °C/°F">°C</button>
<button id="btnTheme" style="display:none" title="Toggle theme">🌗</button>
<button id="btnInstall" class="hidden" title="Install">⬇️ Install</button>
<a class="btn" href="#" id="btnShare" title="Share">🔗 Share</a>
</div>
</div>
<div class="wrap subrow subnav">
<div class="tabs" role="tablist" aria-label="Sections">
<a class="tab" href="#now">Now</a>
<a class="tab" href="#hourly">Hourly</a>
<a class="tab" href="#daily">7-Day</a>
</div>
<!-- SEARCH: real form so it works without JS -->
<form class="search" role="search" aria-label="Search location" action="/weather" method="GET" id="searchForm">
<input id="q" name="q" type="search" placeholder="Search city, place, or paste lat,lon" autocomplete="off" />
<input type="hidden" name="units" id="unitsHidden" value="<%= (ssr && ssr.units) || 'metric' %>"/>
<button id="btnSearch" type="submit" title="Search">Search</button>
<button id="btnGeo" type="button" title="Use my location">📍</button>
<div id="suggest" class="suggest" role="listbox" aria-label="Suggestions"></div>
</form>
</div>
</div>
</header>
<!-- ===== Server-Side Render (visible with <noscript> or if you choose to render body.no-js) ===== -->
<section class="ssr">
<div class="wrap" style="padding-top:14px">
<article class="card" aria-live="polite">
<h3 class="section-title">Now — <%= (ssr && ssr.name) || 'Unknown' %></h3>
<% if (ssr && ssr.current) { %>
<div class="row" style="justify-content:space-between;flex-wrap:wrap">
<div class="row">
<div class="bigicon"><%= ssr.icon %></div>
<div>
<div class="temp"><%= Math.round(ssr.current.temperature_2m) %>°</div>
<div class="desc"><%= ssr.desc %></div>
</div>
</div>
<div class="row" style="gap:12px">
<div class="stat"><h4>Feels like</h4><div><%= Math.round(ssr.current.apparent_temperature) %>°</div></div>
<div class="stat"><h4>Wind</h4><div><%= Math.round(ssr.current.wind_speed_10m) %> <%= ssr.windUnit %></div></div>
<div class="stat"><h4>Humidity</h4><div><%= Math.round(ssr.current.relative_humidity_2m) %>%</div></div>
<div class="stat"><h4>Pressure</h4><div><%= Math.round(ssr.current.pressure_msl) %> hPa</div></div>
</div>
</div>
<div class="details" style="margin-top:10px">
<div class="stat"><h4>Sunrise</h4><div><%= ssr.sunriseLocal %></div></div>
<div class="stat"><h4>Sunset</h4><div><%= ssr.sunsetLocal %></div></div>
<div class="stat"><h4>UV max</h4><div><%= ssr.daily.uv_index_max?.[0] ?? '—' %></div></div>
<div class="stat"><h4>Precip prob (next hr)</h4><div><%= ssr.popNext ?? '—' %>%</div></div>
</div>
<% } else { %>
<p style="color:var(--muted)">No data to show.</p>
<% } %>
</article>
<article class="card" style="margin-top:14px">
<h3 class="section-title">7-day forecast</h3>
<% if (ssr && ssr.daily) { %>
<div class="grid" style="grid-template-columns:repeat(1,1fr);gap:10px">
<% for (let i=0;i<ssr.daily.time.length;i++){ %>
<div class="day">
<div class="row" style="gap:10px">
<div style="font-size:22px"><%= ssr.dailyIcons[i] %></div>
<div><%= ssr.dailyLabels[i] %></div>
</div>
<div class="row">
<div class="hi"><%= Math.round(ssr.daily.temperature_2m_max[i]) %>°</div>
<div class="lo"><span style="color:var(--muted)"><%= Math.round(ssr.daily.temperature_2m_min[i]) %>°</span></div>
</div>
<div style="text-align:right;color:var(--muted)"><%= ssr.dailyTexts[i] %></div>
</div>
<% } %>
</div>
<% } %>
</article>
</div>
</section>
<!-- ===== JS APP (kept intact) ===== -->
<main>
<section id="now" class="grid">
<article class="card current" aria-live="polite">
<div class="curr-top">
<div class="row">
<div class="bigicon" id="currIcon">—</div>
<div>
<div class="temp" id="currTemp">—</div>
<div class="desc" id="currDesc">—</div>
</div>
</div>
<div class="row" style="gap:12px">
<div class="stat"><h4>Feels like</h4><div id="currFeels">—</div></div>
<div class="stat"><h4>Wind</h4><div id="currWind">—</div></div>
<div class="stat"><h4>Humidity</h4><div id="currHum">—</div></div>
<div class="stat"><h4>Pressure</h4><div id="currPress">—</div></div>
</div>
</div>
<div class="details">
<div class="stat"><h4>Sunrise</h4><div id="sunrise">—</div></div>
<div class="stat"><h4>Sunset</h4><div id="sunset">—</div></div>
<div class="stat"><h4>UV max</h4><div id="uv">—</div></div>
<div class="stat"><h4>Precip prob (next hr)</h4><div id="pop">—</div></div>
</div>
</article>
<aside id="hourly" class="panel card">
<h3 class="section-title">Next 24 hours</h3>
<div id="hours" class="scrollx" aria-label="Hourly forecast"></div>
<canvas id="chart" height="120" aria-label="Temperature chart" role="img"></canvas>
</aside>
</section>
<section id="daily" class="card">
<h3 class="section-title">7-day forecast</h3>
<div id="days" class="grid" style="grid-template-columns:repeat(1,1fr);gap:10px"></div>
</section>
</main>
<script>
/* Expose server data to the client + tell it were on the SSR route */
window.__SSR__ = <%- JSON.stringify(ssr || {}) %>;
window.__SSR_ROUTE__ = "/weather";
</script>
<script>
const $=s=>document.querySelector(s);const $$=s=>[...document.querySelectorAll(s)];
const units=JSON.parse(localStorage.getItem('pokeweather:units')||'{}');
let state={lat:null,lon:null,name:null,units:units.units||'<%= (ssr && ssr.units) || "metric" %>'};
const btnUnits=$('#btnUnits'); btnUnits.textContent=state.units==='metric'?"°C":"°F";
const weatherText=(c)=>{
if([0].includes(c))return"Clear sky";
if([1].includes(c))return"Mostly clear";
if([2].includes(c))return"Partly cloudy";
if([3].includes(c))return"Overcast";
if([45,48].includes(c))return"Fog";
if([51,53,55].includes(c))return"Drizzle";
if([56,57].includes(c))return"Freezing drizzle";
if([61,63,65].includes(c))return"Rain";
if([66,67].includes(c))return"Freezing rain";
if([71,73,75].includes(c))return"Snow";
if([77].includes(c))return"Snow grains";
if([80,81,82].includes(c))return"Showers";
if([85,86].includes(c))return"Snow showers";
if([95].includes(c))return"Thunderstorm";
if([96,99].includes(c))return"Storm & hail";return"—"}
const weatherIcon=(c,isDay)=>{
if(c===0)return isDay?"☀️":"🌙";
if([1,2].includes(c))return isDay?"🌤️":"☁️";
if([3].includes(c))return"☁️";
if([45,48].includes(c))return"🌫️";
if([51,53,55,80,81,82].includes(c))return"🌦️";
if([61,63,65].includes(c))return"🌧️";
if([66,67].includes(c))return"🌧️❄️";
if([71,73,75,77,85,86].includes(c))return"❄️";
if([95,96,99].includes(c))return"⛈️";return"☁️";
};
const fmt=(n,u='')=>n==null?'—':`${Math.round(n)}${u}`;
const fmtTime=(s)=>new Date(s).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
const fmtDay=(s)=>new Date(s).toLocaleDateString([], {weekday:'short',month:'short',day:'numeric'});
async function searchPlaces(q){
if(!q) return [];
const url=`https://nominatim.openstreetmap.org/search?format=json&limit=5&addressdetails=1&q=${encodeURIComponent(q)}`;
const r=await fetch(url,{headers:{'Accept-Language':navigator.language}});
if(!r.ok) return [];
return r.json();
}
function applyUnits(){
btnUnits.textContent=state.units==='metric'?"°C":"°F";
// Keep SSR search form in sync so non-JS round-trips preserve units
const uHidden=document.getElementById('unitsHidden');
if(uHidden) uHidden.value = state.units === 'metric' ? 'metric' : 'imperial';
if(state.lat&&state.lon) loadWeather(state.lat,state.lon,state.name);
localStorage.setItem('pokeweather:units',JSON.stringify({units:state.units}))
}
async function loadWeather(lat,lon,name){
state.lat=+lat;state.lon=+lon;state.name=name||state.name||`${lat.toFixed(3)},${lon.toFixed(3)}`;
$('#locChip').textContent=state.name;
const tu=state.units==='metric'?"celsius":"fahrenheit";
const wu=state.units==='metric'?"kmh":"mph";
const url = new URL("/api/weather", location.origin);
url.search={}.toString();
url.searchParams.set('latitude',lat);url.searchParams.set('longitude',lon);
url.searchParams.set('current','temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,wind_speed_10m,wind_direction_10m,pressure_msl');
url.searchParams.set('hourly','temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m');
url.searchParams.set('daily','weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,uv_index_max');
url.searchParams.set('timezone','auto');url.searchParams.set('forecast_days','7');
url.searchParams.set('temperature_unit',tu);url.searchParams.set('windspeed_unit',wu);
$('.current').classList.add('loading');
try{
const res=await fetch(url.toString());
if(!res.ok) throw new Error('Weather error');
const data=await res.json();
localStorage.setItem('pokeweather:last',JSON.stringify({when:Date.now(),state,data}));
render(data);
}catch(e){
const cached=JSON.parse(localStorage.getItem('pokeweather:last')||'null');
if(cached){render(cached.data);}
else alert('Could not load weather.');
}finally{$('.current').classList.remove('loading')}
}
function render(d){
const c=d.current; const daily=d.daily; const hourly=d.hourly;
$('#currIcon').textContent=weatherIcon(c.weather_code,c.is_day);
$('#currTemp').textContent=fmt(c.temperature_2m,'°');
$('#currDesc').textContent=weatherText(c.weather_code);
$('#currFeels').textContent=fmt(c.apparent_temperature,'°');
$('#currWind').textContent=`${fmt(c.wind_speed_10m)} ${state.units==='metric'?'km/h':'mph'} ↗ ${c.wind_direction_10m??'—'}°`;
$('#currHum').textContent=fmt(c.relative_humidity_2m,'%');
$('#currPress').textContent=fmt(c.pressure_msl,' hPa');
$('#sunrise').textContent=fmtTime(daily.sunrise[0]);
$('#sunset').textContent=fmtTime(daily.sunset[0]);
$('#uv').textContent=(daily.uv_index_max?.[0]??'—');
const nowIndex=hourly.time.findIndex(t=>Date.parse(t)>Date.now());
const popNext=hourly.precipitation_probability?.[nowIndex]??hourly.precipitation_probability?.[0]??null;
$('#pop').textContent=popNext==null?'—':popNext+"%";
// hourly tiles
const H=$('#hours'); H.innerHTML='';
const start=nowIndex>0?nowIndex-1:0; const end=Math.min(start+24,hourly.time.length);
for(let i=start;i<end;i++){
const el=document.createElement('div');
el.className='hour';
const t=new Date(hourly.time[i]).toLocaleTimeString([], {hour:'2-digit'});
el.innerHTML=`<div>${t}</div><div style="font-size:22px">${weatherIcon(hourly.weather_code[i],1)}</div><div class="t">${fmt(hourly.temperature_2m[i],'°')}</div><div style="color:var(--muted);font-size:12px">${(hourly.precipitation_probability?.[i]??0)}%</div>`;
H.appendChild(el);
}
// chart
drawChart($('#chart'), hourly.time.slice(start,end).map(t=>new Date(t)), hourly.temperature_2m.slice(start,end));
// 7-day
const D=$('#days'); D.innerHTML='';
for(let i=0;i<daily.time.length;i++){
const row=document.createElement('div'); row.className='day';
const dt=fmtDay(daily.time[i]);
const hi=fmt(daily.temperature_2m_max[i],'°');
const lo=fmt(daily.temperature_2m_min[i],'°');
row.innerHTML=`<div class="row" style="gap:10px"><div style="font-size:22px">${weatherIcon(daily.weather_code[i],1)}</div><div>${dt}</div></div><div class="row"><div class="hi">${hi}</div><div class="lo">${lo}</div></div><div style="text-align:right;color:var(--muted)">${weatherText(daily.weather_code[i])}</div>`;
D.appendChild(row);
}
}
function drawChart(canvas, xs, ys){
const ctx=canvas.getContext('2d');
const w=canvas.width=canvas.clientWidth*2; const h=canvas.height=canvas.clientHeight*2; ctx.scale(2,2);
const min=Math.min(...ys), max=Math.max(...ys), pad=8; const xstep=(canvas.clientWidth-2*pad)/(ys.length-1);
const ymap=v=>{if(max===min) return canvas.clientHeight/2; const t=(v-min)/(max-min); return canvas.clientHeight-pad - t*(canvas.clientHeight-2*pad)};
ctx.clearRect(0,0,canvas.clientWidth,canvas.clientHeight);
ctx.lineWidth=2; ctx.strokeStyle=getComputedStyle(document.documentElement).getPropertyValue('--accent');
ctx.beginPath(); ctx.moveTo(pad, ymap(ys[0]));
for(let i=1;i<ys.length;i++){ctx.lineTo(pad+i*xstep, ymap(ys[i]));}
ctx.stroke();
ctx.fillStyle=getComputedStyle(document.documentElement).getPropertyValue('--accent');
ys.forEach((v,i)=>{ctx.beginPath();ctx.arc(pad+i*xstep, ymap(v), 2.5, 0, Math.PI*2);ctx.fill()});
}
// Keep units in sync for SSR form and client
btnUnits.addEventListener('click',()=>{
state.units=state.units==='metric'?'imperial':'metric';
applyUnits();
});
$('#btnTheme').addEventListener('click',()=>{
const dark=document.documentElement.style.getPropertyValue('--bg')==='#0b0f16';
if(dark){document.documentElement.style.setProperty('--bg','#fbfbfe');document.documentElement.style.setProperty('--fg','#0b1020');document.documentElement.style.setProperty('--muted','#5b6b87');document.documentElement.style.setProperty('--card','#ffffff');document.documentElement.style.setProperty('--ring','#0b102018');}
else{document.documentElement.style.setProperty('--bg','#0b0f16');document.documentElement.style.setProperty('--fg','#eef3ff');document.documentElement.style.setProperty('--muted','#a7b4cc');document.documentElement.style.setProperty('--card','#0f1624');document.documentElement.style.setProperty('--ring','#ffffff22');}
});
$('#btnGeo').addEventListener('click',()=>{
if(!navigator.geolocation){alert('Geolocation not supported');return}
navigator.geolocation.getCurrentPosition(p=>{
reverseName(p.coords.latitude,p.coords.longitude).then(n=>loadWeather(p.coords.latitude,p.coords.longitude,n));
},e=>alert('Location denied'))
});
function reverseName(lat,lon){
const u=`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}`;
return fetch(u).then(r=>r.ok?r.json():null).then(j=>j?.display_name?.split(',').slice(0,2).join(', ')||`${lat.toFixed(3)},${lon.toFixed(3)}`);
}
const suggest=$('#suggest');
$('#q').addEventListener('input', debounce(async(e)=>{
const v=e.target.value.trim(); if(!v){suggest.style.display='none'; return}
const list=await searchPlaces(v); suggest.innerHTML='';
list.forEach(item=>{
const b=document.createElement('button'); b.type='button';
const label=item.display_name.split(',').slice(0,3).join(', ');
b.textContent=label; b.addEventListener('click',()=>{selectPlace(item)});
suggest.appendChild(b);
});
if(list.length){const r=$('#q').getBoundingClientRect(); suggest.style.minWidth=r.width+'px'; suggest.style.display='block'} else suggest.style.display='none';
},300));
function selectPlace(item){
suggest.style.display='none';
$('#q').value=item.display_name.split(',').slice(0,2).join(', ');
loadWeather(item.lat,item.lon,item.display_name.split(',').slice(0,2).join(', '));
}
// Intercept form submit in JS mode and route smartly.
const form = document.getElementById('searchForm');
form.addEventListener('submit',(e)=>{
e.preventDefault();
goSearch();
});
$('#btnSearch').addEventListener('click',()=>goSearch());
$('#q').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();goSearch()}});
function goSearch(){
const v=$('#q').value.trim();
if(!v) return;
// If user typed "lat,lon", stay fully client-side
const latlon=v.match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/);
if(latlon){
const lat=+latlon[1], lon=+latlon[2];
reverseName(lat,lon).then(n=>loadWeather(lat,lon,n));
return;
}
// Prefer SSR round-trip when available
if (window.__SSR_ROUTE__) {
const units = (state.units==='metric') ? 'metric' : 'imperial';
const url = new URL(window.__SSR_ROUTE__, location.origin);
url.searchParams.set('q', v);
url.searchParams.set('units', units);
location.href = url.toString();
return;
}
// Fallback: pure client search (for static build)
searchPlaces(v).then(list=>{
if(list[0]) selectPlace(list[0]);
else alert('No results');
});
}
$('#btnShare').addEventListener('click',async e=>{
e.preventDefault();
const url=new URL(location.href);
if(state.lat&&state.lon){url.searchParams.set('lat',state.lat);url.searchParams.set('lon',state.lon);url.searchParams.set('name',state.name)}
try{
if(navigator.share){
await navigator.share({title:'PokeWeather',text:`${state.name||'Weather'}: ${$('#currTemp').textContent} ${$('#currDesc').textContent}`,url:url.toString()});
}else{
await navigator.clipboard.writeText(url.toString());alert('Link copied!')
}
}catch{}
});
function debounce(fn,ms){let t;return (...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}}
(function init(){
// Sync hidden units field at boot
const uHidden=document.getElementById('unitsHidden');
if(uHidden) uHidden.value = state.units;
// If server already gave us a place, hydrate immediately so UI & search are “awake”
if (window.__SSR__ && typeof window.__SSR__.lat === "number" && typeof window.__SSR__.lon === "number") {
try {
localStorage.setItem('pokeweather:last', JSON.stringify({
when: Date.now(),
state: { lat: window.__SSR__.lat, lon: window.__SSR__.lon, name: window.__SSR__.name, units: state.units },
data: { current: window.__SSR__.current || {}, daily: window.__SSR__.daily || {}, hourly: window.__SSR__.hourly || {} }
}));
} catch {}
loadWeather(window.__SSR__.lat, window.__SSR__.lon, window.__SSR__.name);
return;
}
const sp=new URLSearchParams(location.search);
const lat=sp.get('lat'), lon=sp.get('lon'), name=sp.get('name');
if(lat&&lon){loadWeather(+lat,+lon,name);return}
const cached=JSON.parse(localStorage.getItem('pokeweather:last')||'null');
if(cached){state.units=cached.state?.units||state.units; btnUnits.textContent=state.units==='metric'?"°C":"°F"; if(uHidden) uHidden.value=state.units; loadWeather(cached.state.lat,cached.state.lon,cached.state.name); return}
if(navigator.geolocation){navigator.geolocation.getCurrentPosition(
p=>{reverseName(p.coords.latitude,p.coords.longitude).then(n=>loadWeather(p.coords.latitude,p.coords.longitude,n))},
()=>{}
)}
})();
</script>
</body>
</html>