(function () {
"use strict";
const DEFAULT_PREFS = {
theme: "dark",
timeFormat: "12",
locationQuery: "New York, NY",
sections: {
current: true,
map: true,
hourly: true,
daily: true,
weekly: true,
air: true,
history: true,
archives: true
},
layers: {
radar: true,
wind: true,
rain: true,
aqi: true,
clouds: true
},
metrics: {
humidity: true,
wind: true,
precip: true,
pressure: true,
uv: true,
aqi: true,
pollen: true
}
};
const WEATHER_CODES = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Rime fog",
51: "Light drizzle",
53: "Drizzle",
55: "Dense drizzle",
56: "Light freezing drizzle",
57: "Freezing drizzle",
61: "Light rain",
63: "Rain",
65: "Heavy rain",
66: "Light freezing rain",
67: "Freezing rain",
71: "Light snow",
73: "Snow",
75: "Heavy snow",
77: "Snow grains",
80: "Light showers",
81: "Showers",
82: "Heavy showers",
85: "Light snow showers",
86: "Snow showers",
95: "Thunderstorm",
96: "Thunderstorm with hail",
99: "Severe thunderstorm"
};
const state = {
prefs: loadPrefs(),
dashboard: null,
selectedLocation: null,
map: null,
baseLayer: null,
layers: {},
nextRefreshAt: 0,
countdownTimer: null,
refreshTimer: null,
searchTimer: null,
lastArchives: []
};
const $ = (id) => document.getElementById(id);
document.addEventListener("DOMContentLoaded", init);
function init() {
$("locationInput").value = state.prefs.locationQuery || DEFAULT_PREFS.locationQuery;
bindEvents();
applyPreferences();
loadWeather(false);
startTimers();
if (window.lucide) {
window.lucide.createIcons();
}
}
function bindEvents() {
$("searchForm").addEventListener("submit", (event) => {
event.preventDefault();
state.selectedLocation = null;
state.prefs.locationQuery = $("locationInput").value.trim() || DEFAULT_PREFS.locationQuery;
savePrefs();
hideSearchResults();
loadWeather(false);
});
$("locationInput").addEventListener("input", () => {
state.selectedLocation = null;
window.clearTimeout(state.searchTimer);
state.searchTimer = window.setTimeout(searchLocations, 280);
});
$("refreshButton").addEventListener("click", () => loadWeather(true));
$("themeButton").addEventListener("click", toggleTheme);
document.querySelectorAll("[data-time-format]").forEach((button) => {
button.addEventListener("click", () => {
state.prefs.timeFormat = button.dataset.timeFormat === "24" ? "24" : "12";
savePrefs();
applyPreferences();
if (state.dashboard) {
renderDashboard();
renderArchives();
}
});
});
$("exportPrefs").addEventListener("click", exportPreferences);
$("importPrefsButton").addEventListener("click", () => $("importPrefsFile").click());
$("importPrefsFile").addEventListener("change", importPreferences);
$("archiveForm").addEventListener("submit", (event) => {
event.preventDefault();
loadArchives();
});
$("exportArchives").addEventListener("click", exportArchives);
$("closeArchiveDialog").addEventListener("click", () => $("archiveDialog").close());
document.querySelectorAll("[data-pref]").forEach((input) => {
input.addEventListener("change", () => {
setPath(state.prefs, input.dataset.pref, input.checked);
savePrefs();
applyPreferences();
if (state.dashboard) {
renderDashboard();
}
});
});
}
function startTimers() {
window.clearInterval(state.refreshTimer);
window.clearInterval(state.countdownTimer);
state.nextRefreshAt = Date.now() + 300000;
state.refreshTimer = window.setInterval(() => loadWeather(true), 300000);
state.countdownTimer = window.setInterval(updateCountdown, 1000);
updateCountdown();
}
function updateCountdown() {
const remaining = Math.max(0, state.nextRefreshAt - Date.now());
const totalSeconds = Math.ceil(remaining / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = String(totalSeconds % 60).padStart(2, "0");
$("refreshCountdown").textContent = `${minutes}:${seconds}`;
}
async function searchLocations() {
const q = $("locationInput").value.trim();
if (q.length < 2) {
hideSearchResults();
return;
}
try {
const response = await fetch(`api.php?action=search&q=${encodeURIComponent(q)}`);
const payload = await response.json();
if (!payload.ok) {
throw new Error(payload.error || "Location search failed.");
}
renderSearchResults(payload.locations || []);
} catch (error) {
setStatus(error.message, true);
}
}
function renderSearchResults(locations) {
const box = $("searchResults");
box.innerHTML = "";
if (!locations.length) {
hideSearchResults();
return;
}
locations.forEach((location) => {
const button = document.createElement("button");
button.type = "button";
button.className = "search-result";
button.textContent = location.label;
button.addEventListener("click", () => {
state.selectedLocation = location;
state.prefs.locationQuery = location.label;
$("locationInput").value = location.label;
savePrefs();
hideSearchResults();
loadWeather(false);
});
box.appendChild(button);
});
box.hidden = false;
}
function hideSearchResults() {
$("searchResults").hidden = true;
$("searchResults").innerHTML = "";
}
async function loadWeather(force) {
const params = new URLSearchParams({ action: "weather", force: force ? "1" : "0" });
const selected = state.selectedLocation;
if (selected) {
params.set("lat", selected.latitude);
params.set("lon", selected.longitude);
params.set("name", selected.name || selected.label || "Selected location");
params.set("admin1", selected.admin1 || "");
params.set("country", selected.country || "");
params.set("country_code", selected.country_code || "");
params.set("timezone", selected.timezone || "auto");
} else {
params.set("q", $("locationInput").value.trim() || state.prefs.locationQuery || DEFAULT_PREFS.locationQuery);
}
setStatus(force ? "Refreshing weather data..." : "Loading weather data...");
try {
const response = await fetch(`api.php?${params.toString()}`);
const payload = await response.json();
if (!payload.ok) {
throw new Error(payload.error || "Weather request failed.");
}
state.dashboard = payload.dashboard;
state.selectedLocation = payload.dashboard.location;
state.prefs.locationQuery = payload.dashboard.location.label;
$("locationInput").value = payload.dashboard.location.label;
savePrefs();
state.nextRefreshAt = Date.now() + ((payload.dashboard.refresh_seconds || 300) * 1000);
renderDashboard();
await loadArchives();
const stale = [
payload.dashboard.forecast?._cache?.stale,
payload.dashboard.air_quality?._cache?.stale,
payload.dashboard.rainviewer?._cache?.stale
].some(Boolean);
setStatus(stale ? "Showing cached weather data while a provider is unreachable." : "Weather data updated.");
} catch (error) {
setStatus(error.message, true);
}
}
function renderDashboard() {
const data = state.dashboard;
if (!data) {
return;
}
$("locationLabel").textContent = data.location.label;
renderCurrent(data);
renderMap(data);
renderHourly(data);
renderDaily(data);
renderWeekly(data);
renderAir(data);
renderHistory(data);
applyPreferences();
if (window.lucide) {
window.lucide.createIcons();
}
}
function renderCurrent(data) {
const current = data.forecast.current || {};
const hourly = data.forecast.hourly || {};
const air = data.air_quality.current || {};
const code = current.weather_code;
const summary = labelForCode(code, data);
$("currentTemp").textContent = formatNumber(current.temperature_2m, 0, " F");
$("currentSummary").textContent = `${summary}. Feels like ${formatNumber(current.apparent_temperature, 0, " F")}. Updated ${formatDateTime(current.time)}.`;
const metrics = [
["Humidity", current.relative_humidity_2m, "%", "metrics.humidity", "Relative humidity"],
["Wind", current.wind_speed_10m, " mph", "metrics.wind", `${formatNumber(current.wind_gusts_10m, 0, " mph gusts")} from ${formatNumber(current.wind_direction_10m, 0, " deg")}`],
["Precip", current.precipitation, " in", "metrics.precip", `${formatNumber(current.rain, 2, " in rain")} now`],
["Clouds", current.cloud_cover, "%", "metrics.precip", "Current sky cover"],
["Pressure", current.pressure_msl, " hPa", "metrics.pressure", "Mean sea level"],
["AQI", air.us_aqi, "", "metrics.aqi", aqiLabel(air.us_aqi)],
["PM2.5", air.pm2_5, " ug/m3", "metrics.aqi", "Fine particles"],
["UV", air.uv_index, "", "metrics.uv", `Clear sky ${formatNumber(air.uv_index_clear_sky, 1, "")}`],
["Visibility", firstFuture(hourly.time, hourly.visibility), " m", "metrics.pressure", "Nearest hourly value"]
];
$("metricGrid").innerHTML = metrics
.filter((item) => getPath(state.prefs, item[3]))
.map((item) => metricCard(item[0], item[1], item[2], item[4]))
.join("");
const indexes = nextIndexes(hourly.time || [], 24);
const temps = indexes.map((index) => hourly.temperature_2m?.[index]).filter(isFiniteNumber);
drawSpark($("todaySpark"), temps, "Temperature", "#39a8ff");
}
function metricCard(label, value, suffix, hint) {
return `
${escapeHtml(formatNumber(current.temperature_2m, 0, " F"))}, ${escapeHtml(labelForCode(current.weather_code, data))}
Wind ${escapeHtml(formatNumber(current.wind_speed_10m, 0, " mph"))}
AQI ${escapeHtml(formatNumber(air.us_aqi, 0, ""))}
`);
if (state.prefs.layers.radar) {
addRadarLayer(data.rainviewer);
} else {
$("radarTime").textContent = "Radar hidden";
}
if (state.prefs.layers.aqi && isFiniteNumber(air.us_aqi)) {
state.layers.aqi = L.circle([lat, lon], {
radius: 22000,
color: aqiColor(air.us_aqi),
fillColor: aqiColor(air.us_aqi),
fillOpacity: 0.18,
weight: 2
}).addTo(state.map).bindPopup(`AQI ${formatNumber(air.us_aqi, 0, "")}: ${aqiLabel(air.us_aqi)}`);
}
if (state.prefs.layers.rain && isFiniteNumber(current.precipitation)) {
const rainRadius = 10000 + (Number(current.precipitation) * 60000);
state.layers.rain = L.circle([lat, lon], {
radius: Math.max(10000, rainRadius),
color: "#39a8ff",
fillColor: "#39a8ff",
fillOpacity: 0.16,
weight: 2
}).addTo(state.map).bindPopup(`Precipitation ${formatNumber(current.precipitation, 2, " in")}`);
}
if (state.prefs.layers.clouds && isFiniteNumber(current.cloud_cover)) {
state.layers.clouds = L.circle([lat, lon], {
radius: 12000 + (Number(current.cloud_cover) * 450),
color: "#f7fbff",
fillColor: "#9fb0c4",
fillOpacity: 0.1,
weight: 1
}).addTo(state.map).bindPopup(`Cloud cover ${formatNumber(current.cloud_cover, 0, "%")}`);
}
if (state.prefs.layers.wind && isFiniteNumber(current.wind_speed_10m)) {
const icon = L.divIcon({
className: "",
html: `
${escapeHtml(labelForCode(daily.weather_code?.[i], data))}
Rain ${escapeHtml(formatNumber(daily.precipitation_sum?.[i], 2, " in"))} · Wind ${escapeHtml(formatNumber(daily.wind_speed_10m_max?.[i], 0, " mph"))}
${escapeHtml(formatDay(daily.time?.[group[0]]))} to ${escapeHtml(formatDay(daily.time?.[group[group.length - 1]]))}
Total precip ${escapeHtml(formatNumber(precip, 2, " in"))}
Peak wind ${escapeHtml(formatNumber(wind, 0, " mph"))}
${escapeHtml(item[3] || "")}
No archive snapshots found.
${escapeHtml(formatDateTime(item.observed_at))} · ${escapeHtml(summary.weather_label || "Weather snapshot")}