commit c69d2b147ba34542519efff479e40c6c1f6d2f5a Author: Ty Clifford Date: Tue Jun 30 19:48:01 2026 -0400 - initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f54a48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/data/*.sqlite +/data/*.sqlite-* +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0c8ab2 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Weather + +A PHP, SQLite, HTML5, and JavaScript weather dashboard that uses public weather APIs for forecasts, air quality, radar, historical comparison facts, local caching, and searchable archives. + +## Features + +- Search by city/state or ZIP code. +- Open-Meteo forecast data with current, hourly, daily, and 16-day projections. +- Open-Meteo air quality data with AQI, pollutants, UV, and pollen forecast values. +- RainViewer radar tiles on an interactive Leaflet map. +- Local SQLite API cache with a five minute weather refresh window. +- Hourly and daily archive snapshots with search and JSON detail export. +- Cookie-backed display preferences, map layer toggles, dark/light theme, and import/export. +- Current-date history facts comparing the projection to the same date last year. + +## Run + +Use any PHP server with SQLite/PDO enabled and point the document root at `public`. + +```sh +php -S 127.0.0.1:8080 -t public +``` + +This environment does not include a system `php` binary, but it does have FrankenPHP: + +```sh +frankenphp php-server --root public --listen 127.0.0.1:8080 +``` + +The SQLite database is created automatically at `data/weather.sqlite`. + +## APIs + +- Forecast, geocoding, air quality, and historical comparison data: Open-Meteo. +- Radar tiles: RainViewer. +- Base map: OpenStreetMap tile layer through Leaflet. diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/public/api.php b/public/api.php new file mode 100644 index 0000000..2621ae6 --- /dev/null +++ b/public/api.php @@ -0,0 +1,72 @@ + true, + 'locations' => $service->searchLocations((string) request_value('q', '')), + ]); + break; + + case 'weather': + json_response([ + 'ok' => true, + 'dashboard' => $service->dashboard([ + 'q' => request_value('q', ''), + 'lat' => request_value('lat'), + 'lon' => request_value('lon'), + 'name' => request_value('name'), + 'admin1' => request_value('admin1'), + 'country' => request_value('country'), + 'country_code' => request_value('country_code'), + 'timezone' => request_value('timezone'), + 'force' => request_value('force', false), + ]), + ]); + break; + + case 'archives': + json_response([ + 'ok' => true, + 'archives' => $service->listArchives([ + 'q' => request_value('q', ''), + 'kind' => request_value('kind', ''), + 'start' => request_value('start', ''), + 'end' => request_value('end', ''), + 'limit' => request_value('limit', 50), + ]), + ]); + break; + + case 'archive': + json_response([ + 'ok' => true, + 'archive' => $service->getArchive((int) request_value('id', 0)), + ]); + break; + + default: + json_response([ + 'ok' => false, + 'error' => 'Unknown API action.', + ], 404); + } +} catch (InvalidArgumentException $error) { + json_response([ + 'ok' => false, + 'error' => $error->getMessage(), + ], 422); +} catch (Throwable $error) { + json_response([ + 'ok' => false, + 'error' => $error->getMessage(), + ], 500); +} diff --git a/public/assets/css/styles.css b/public/assets/css/styles.css new file mode 100644 index 0000000..006479e --- /dev/null +++ b/public/assets/css/styles.css @@ -0,0 +1,714 @@ +:root { + color-scheme: dark; + --bg: #071019; + --bg-soft: #0d1827; + --panel: #101b2a; + --panel-2: #132235; + --line: rgba(255, 255, 255, 0.12); + --text: #f7fbff; + --muted: #9fb0c4; + --blue: #39a8ff; + --green: #45d483; + --pink: #ff5aa5; + --purple: #9c6dff; + --orange: #ff9f43; + --red: #ff595e; + --shadow: rgba(0, 0, 0, 0.28); + --radius: 8px; + --control-h: 42px; + --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body.light { + color-scheme: light; + --bg: #f5f8fb; + --bg-soft: #e8eef6; + --panel: #ffffff; + --panel-2: #eef4fa; + --line: rgba(21, 33, 52, 0.13); + --text: #152134; + --muted: #64748b; + --shadow: rgba(25, 38, 60, 0.14); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + background: + linear-gradient(135deg, rgba(57, 168, 255, 0.16), transparent 34%), + linear-gradient(225deg, rgba(255, 90, 165, 0.13), transparent 38%), + var(--bg); + color: var(--text); + font-family: var(--font); + letter-spacing: 0; +} + +button, +input, +select { + font: inherit; +} + +button { + min-height: var(--control-h); + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel-2); + color: var(--text); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 14px; + white-space: nowrap; +} + +button:hover { + border-color: rgba(57, 168, 255, 0.55); +} + +button svg, +.search svg { + width: 17px; + height: 17px; + flex: 0 0 auto; +} + +input, +select { + min-height: var(--control-h); + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.06); + color: var(--text); + padding: 0 12px; + width: 100%; + min-width: 0; +} + +select option { + color: #111827; +} + +input::placeholder { + color: var(--muted); +} + +.app-shell { + width: min(1720px, 100%); + min-height: 100vh; + margin: 0 auto; + padding: 18px; +} + +.topbar { + display: grid; + grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.1fr) auto; + gap: 16px; + align-items: center; + margin-bottom: 16px; +} + +.brand { + min-width: 0; + display: flex; + gap: 12px; + align-items: center; +} + +.brand-mark { + width: 42px; + height: 42px; + border-radius: 50%; + flex: 0 0 auto; + background: + conic-gradient(from 40deg, var(--blue), var(--green), var(--orange), var(--pink), var(--purple), var(--red), var(--blue)); + box-shadow: 0 0 26px rgba(57, 168, 255, 0.3); +} + +h1, +h2, +h3, +p { + margin: 0; +} + +h1 { + font-size: 1.55rem; + line-height: 1.05; +} + +.brand p { + color: var(--muted); + margin-top: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search { + position: relative; + min-width: 0; +} + +.search-box { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 52px; + padding: 5px 6px 5px 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.07); + box-shadow: 0 12px 30px var(--shadow); +} + +.search-box input { + border: 0; + background: transparent; + padding: 0; +} + +.search-box input:focus { + outline: 0; +} + +.search-results { + position: absolute; + z-index: 900; + top: calc(100% + 8px); + left: 0; + right: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + box-shadow: 0 18px 40px var(--shadow); +} + +.search-result { + width: 100%; + justify-content: flex-start; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + background: transparent; + min-height: 48px; +} + +.search-result:last-child { + border-bottom: 0; +} + +.top-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.layout { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.control-panel, +.data-section, +.current-card, +.metric-card, +.map-section, +.status-line { + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(16, 27, 42, 0.86); + box-shadow: 0 14px 36px var(--shadow); +} + +body.light .control-panel, +body.light .data-section, +body.light .current-card, +body.light .metric-card, +body.light .map-section, +body.light .status-line { + background: rgba(255, 255, 255, 0.88); +} + +.control-panel { + position: sticky; + top: 16px; + padding: 14px; +} + +.panel-heading, +.section-title, +.dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panel-heading { + padding-bottom: 12px; + border-bottom: 1px solid var(--line); +} + +.panel-heading h2, +.section-title h2 { + font-size: 1rem; +} + +.panel-heading span, +.section-title span { + color: var(--muted); + font-size: 0.88rem; +} + +.control-group { + padding: 14px 0; + display: grid; + gap: 8px; + border-bottom: 1px solid var(--line); +} + +.control-group h3 { + font-size: 0.78rem; + text-transform: uppercase; + color: var(--muted); +} + +.control-group label { + display: flex; + align-items: center; + gap: 9px; + min-height: 26px; + color: var(--text); +} + +.control-group input[type="checkbox"] { + width: 16px; + min-height: 16px; + accent-color: var(--pink); +} + +.control-actions { + display: grid; + gap: 10px; + padding-top: 14px; +} + +.content { + display: grid; + gap: 16px; + min-width: 0; +} + +.status-line { + min-height: 44px; + padding: 11px 14px; + color: var(--muted); +} + +.section-grid { + display: grid; + grid-template-columns: minmax(280px, 0.92fr) minmax(320px, 1.08fr); + gap: 16px; + min-width: 0; +} + +.two-column { + grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr); +} + +.current-card { + padding: 18px; + min-width: 0; +} + +.main-current { + min-height: 250px; + display: grid; + grid-template-columns: minmax(210px, 0.9fr) minmax(240px, 1.1fr); + gap: 16px; + align-items: center; +} + +.eyebrow { + color: var(--green); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} + +#currentTemp { + font-size: clamp(3rem, 8vw, 6rem); + line-height: 0.95; + margin: 8px 0; +} + +#currentSummary { + color: var(--muted); + font-size: 1rem; + line-height: 1.45; +} + +canvas { + display: block; + width: 100%; + max-width: 100%; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(3, minmax(120px, 1fr)); + gap: 12px; +} + +.metric-card { + min-height: 119px; + padding: 14px; + display: grid; + align-content: space-between; +} + +.metric-card .label { + color: var(--muted); + font-size: 0.82rem; +} + +.metric-card .value { + font-size: 1.45rem; + line-height: 1.1; + font-weight: 800; +} + +.metric-card .hint { + color: var(--muted); + font-size: 0.78rem; +} + +.map-section, +.data-section { + padding: 14px; + min-width: 0; +} + +.weather-map { + min-height: 480px; + height: 58vh; + max-height: 720px; + width: 100%; + margin-top: 12px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-soft); +} + +.leaflet-container { + background: var(--bg-soft); + font-family: var(--font); +} + +.map-badge { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 50%; + border: 2px solid #ffffff; + color: #ffffff; + font-weight: 800; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35); +} + +.wind-arrow { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 50%; + border: 2px solid #ffffff; + background: #9c6dff; + color: #ffffff; + font-weight: 900; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35); +} + +.scroll-table { + margin-top: 12px; + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius); +} + +table { + border-collapse: collapse; + width: 100%; + min-width: 760px; +} + +th, +td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--line); +} + +th { + color: var(--muted); + font-weight: 700; + font-size: 0.78rem; + text-transform: uppercase; + background: rgba(255, 255, 255, 0.04); +} + +td { + font-size: 0.92rem; +} + +tbody tr:hover { + background: rgba(57, 168, 255, 0.08); +} + +.daily-grid, +.weekly-grid, +.air-grid, +.archive-list { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.daily-grid { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.weekly-grid { + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); +} + +.air-grid { + grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); +} + +.mini-card, +.archive-card { + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.055); + padding: 12px; + min-width: 0; +} + +.mini-card h3, +.archive-card h3 { + font-size: 0.98rem; + margin-bottom: 8px; +} + +.mini-card p, +.archive-card p { + color: var(--muted); + line-height: 1.45; + font-size: 0.88rem; +} + +.temp-range { + display: flex; + justify-content: space-between; + gap: 8px; + margin: 9px 0; + font-weight: 800; +} + +.range-bar { + height: 8px; + border-radius: 999px; + background: linear-gradient(90deg, var(--blue), var(--green), var(--orange), var(--red)); +} + +.fact-list { + display: grid; + gap: 10px; + margin: 12px 0 0; + padding: 0; + list-style: none; +} + +.fact-list li { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 12px; + background: rgba(255, 255, 255, 0.055); + line-height: 1.45; +} + +.archive-search { + display: grid; + grid-template-columns: minmax(180px, 1fr) 130px 150px 150px auto; + gap: 10px; + margin-top: 12px; +} + +.archive-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +.archive-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} + +.chip { + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + padding: 4px 8px; + font-size: 0.78rem; +} + +dialog { + width: min(920px, calc(100vw - 28px)); + max-height: calc(100vh - 28px); + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + color: var(--text); + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.45); +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.58); +} + +.round-button { + width: 42px; + padding: 0; +} + +pre { + max-height: 65vh; + overflow: auto; + white-space: pre-wrap; + color: var(--text); + background: rgba(0, 0, 0, 0.18); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 12px; +} + +[hidden], +.is-hidden { + display: none !important; +} + +@media (max-width: 1120px) { + .topbar { + grid-template-columns: 1fr; + } + + .top-actions { + justify-content: flex-start; + } + + .layout { + grid-template-columns: 1fr; + } + + .control-panel { + position: static; + } + + .control-panel { + display: grid; + grid-template-columns: repeat(3, minmax(160px, 1fr)); + gap: 12px; + } + + .panel-heading, + .control-actions { + grid-column: 1 / -1; + } + + .control-group { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 12px; + } +} + +@media (max-width: 880px) { + .section-grid, + .two-column, + .main-current { + grid-template-columns: 1fr; + } + + .metric-grid { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + .archive-search { + grid-template-columns: 1fr 1fr; + } + + .archive-search button { + grid-column: 1 / -1; + } +} + +@media (max-width: 640px) { + .app-shell { + padding: 12px; + } + + .search-box { + grid-template-columns: 20px minmax(0, 1fr); + } + + .search-box button { + grid-column: 1 / -1; + width: 100%; + } + + .top-actions, + .control-actions { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .control-panel { + grid-template-columns: 1fr; + } + + .metric-grid, + .archive-search { + grid-template-columns: 1fr; + } + + .weather-map { + height: 420px; + min-height: 360px; + } + + .archive-card { + grid-template-columns: 1fr; + } +} diff --git a/public/assets/js/app.js b/public/assets/js/app.js new file mode 100644 index 0000000..94007f5 --- /dev/null +++ b/public/assets/js/app.js @@ -0,0 +1,999 @@ +(function () { + "use strict"; + + const DEFAULT_PREFS = { + theme: "dark", + 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); + $("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(label)} + ${escapeHtml(formatNumber(value, suffix === " in" ? 2 : 0, suffix))} + ${escapeHtml(hint || "")} +
+ `; + } + + function renderMap(data) { + const mapEl = $("weatherMap"); + if (!window.L) { + mapEl.textContent = "Map library unavailable."; + return; + } + + const lat = Number(data.location.latitude); + const lon = Number(data.location.longitude); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + return; + } + + if (!state.map) { + state.map = L.map("weatherMap", { + preferCanvas: true, + zoomControl: true + }).setView([lat, lon], 7); + state.baseLayer = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: "© OpenStreetMap contributors" + }).addTo(state.map); + } else { + state.map.setView([lat, lon], state.map.getZoom() || 7); + } + + Object.values(state.layers).forEach((layer) => { + if (layer && state.map.hasLayer(layer)) { + state.map.removeLayer(layer); + } + }); + state.layers = {}; + + const current = data.forecast.current || {}; + const air = data.air_quality.current || {}; + + state.layers.marker = L.marker([lat, lon]).addTo(state.map).bindPopup(` + ${escapeHtml(data.location.label)}
+ ${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(formatNumber(current.wind_speed_10m, 0, ""))}
`, + iconSize: [44, 44], + iconAnchor: [22, 22] + }); + state.layers.wind = L.marker([lat + 0.12, lon + 0.12], { icon }).addTo(state.map) + .bindPopup(`Wind ${formatNumber(current.wind_speed_10m, 0, " mph")} from ${formatNumber(current.wind_direction_10m, 0, " deg")}`); + } + + window.setTimeout(() => state.map.invalidateSize(), 120); + } + + function addRadarLayer(rainviewer) { + const frames = [ + ...((rainviewer?.radar?.past) || []), + ...((rainviewer?.radar?.nowcast) || []) + ]; + const frame = frames.length ? frames[frames.length - 1] : null; + if (!frame || !rainviewer.host || !frame.path) { + $("radarTime").textContent = "Radar unavailable"; + return; + } + + const tileUrl = `${rainviewer.host}${frame.path}/256/{z}/{x}/{y}/2/1_1.png`; + state.layers.radar = L.tileLayer(tileUrl, { + tileSize: 256, + opacity: 0.68, + maxNativeZoom: 7, + maxZoom: 12, + attribution: "Radar: RainViewer" + }).addTo(state.map); + $("radarTime").textContent = formatDateTime(new Date(frame.time * 1000).toISOString()); + } + + function renderHourly(data) { + const hourly = data.forecast.hourly || {}; + const indexes = nextIndexes(hourly.time || [], 48); + const columns = [ + ["Time", (i) => formatHour(hourly.time?.[i])], + ["Weather", (i) => labelForCode(hourly.weather_code?.[i], data)], + ["Temp", (i) => formatNumber(hourly.temperature_2m?.[i], 0, " F")] + ]; + + if (state.prefs.metrics.humidity) { + columns.push(["Humidity", (i) => formatNumber(hourly.relative_humidity_2m?.[i], 0, "%")]); + } + if (state.prefs.metrics.precip) { + columns.push(["Chance", (i) => formatNumber(hourly.precipitation_probability?.[i], 0, "%")]); + columns.push(["Precip", (i) => formatNumber(hourly.precipitation?.[i], 2, " in")]); + } + if (state.prefs.metrics.wind) { + columns.push(["Wind", (i) => formatNumber(hourly.wind_speed_10m?.[i], 0, " mph")]); + columns.push(["Gust", (i) => formatNumber(hourly.wind_gusts_10m?.[i], 0, " mph")]); + } + if (state.prefs.metrics.pressure) { + columns.push(["Pressure", (i) => formatNumber(hourly.pressure_msl?.[i], 0, " hPa")]); + } + if (state.prefs.metrics.uv) { + columns.push(["UV", (i) => formatNumber(hourly.uv_index?.[i], 1, "")]); + } + + $("hourlyHead").innerHTML = `${columns.map((column) => `${escapeHtml(column[0])}`).join("")}`; + $("hourlyBody").innerHTML = indexes.map((index) => { + return `${columns.map((column) => `${escapeHtml(column[1](index))}`).join("")}`; + }).join(""); + } + + function renderDaily(data) { + const daily = data.forecast.daily || {}; + const start = daily.time?.length > 1 ? 1 : 0; + const cards = []; + + for (let i = start; i < Math.min((daily.time || []).length, start + 16); i += 1) { + cards.push(` +
+

${escapeHtml(formatDay(daily.time?.[i]))}

+

${escapeHtml(labelForCode(daily.weather_code?.[i], data))}

+
+ ${escapeHtml(formatNumber(daily.temperature_2m_max?.[i], 0, " F"))} + ${escapeHtml(formatNumber(daily.temperature_2m_min?.[i], 0, " F"))} +
+
+

Rain ${escapeHtml(formatNumber(daily.precipitation_sum?.[i], 2, " in"))} · Wind ${escapeHtml(formatNumber(daily.wind_speed_10m_max?.[i], 0, " mph"))}

+
+ `); + } + + $("dailyGrid").innerHTML = cards.join(""); + } + + function renderWeekly(data) { + const daily = data.forecast.daily || {}; + const start = daily.time?.length > 1 ? 1 : 0; + const indexes = []; + for (let i = start; i < Math.min((daily.time || []).length, start + 16); i += 1) { + indexes.push(i); + } + + const groups = []; + for (let i = 0; i < indexes.length; i += 7) { + groups.push(indexes.slice(i, i + 7)); + } + + $("weeklyGrid").innerHTML = groups.map((group, index) => { + const highs = group.map((i) => daily.temperature_2m_max?.[i]).filter(isFiniteNumber); + const lows = group.map((i) => daily.temperature_2m_min?.[i]).filter(isFiniteNumber); + const precip = sum(group.map((i) => daily.precipitation_sum?.[i])); + const wind = max(group.map((i) => daily.wind_speed_10m_max?.[i])); + return ` +
+

Week ${index + 1}

+

${escapeHtml(formatDay(daily.time?.[group[0]]))} to ${escapeHtml(formatDay(daily.time?.[group[group.length - 1]]))}

+
+ ${escapeHtml(formatNumber(avg(highs), 0, " F avg high"))} + ${escapeHtml(formatNumber(avg(lows), 0, " F avg low"))} +
+

Total precip ${escapeHtml(formatNumber(precip, 2, " in"))}

+

Peak wind ${escapeHtml(formatNumber(wind, 0, " mph"))}

+
+ `; + }).join(""); + } + + function renderAir(data) { + const current = data.air_quality.current || {}; + const hourly = data.air_quality.hourly || {}; + const pollen = latestAirValue(hourly.time, hourly.grass_pollen); + const cards = [ + ["AQI", current.us_aqi, "", aqiLabel(current.us_aqi), "metrics.aqi"], + ["PM10", current.pm10, " ug/m3", "Coarse particles", "metrics.aqi"], + ["PM2.5", current.pm2_5, " ug/m3", "Fine particles", "metrics.aqi"], + ["Ozone", current.ozone, " ug/m3", "O3", "metrics.aqi"], + ["NO2", current.nitrogen_dioxide, " ug/m3", "Nitrogen dioxide", "metrics.aqi"], + ["CO", current.carbon_monoxide, " ug/m3", "Carbon monoxide", "metrics.aqi"], + ["UV", current.uv_index, "", "Current index", "metrics.uv"], + ["Grass pollen", pollen, "", "Nearest forecast", "metrics.pollen"] + ]; + + $("airGrid").innerHTML = cards + .filter((item) => getPath(state.prefs, item[4])) + .map((item) => ` +
+

${escapeHtml(item[0])}

+
+ ${escapeHtml(formatNumber(item[1], item[2] === "" ? 0 : 1, item[2]))} +
+

${escapeHtml(item[3] || "")}

+
+ `) + .join(""); + + const indexes = nextIndexes(hourly.time || [], 48); + const aqiValues = indexes.map((index) => hourly.us_aqi?.[index]).filter(isFiniteNumber); + drawSpark($("aqiSpark"), aqiValues, "AQI", "#45d483"); + } + + function renderHistory(data) { + $("historyDate").textContent = data.history.comparison_date || "Current date"; + const facts = data.history.facts || []; + $("historyFacts").innerHTML = facts.map((fact) => `
  • ${escapeHtml(fact)}
  • `).join(""); + } + + async function loadArchives() { + if (!state.prefs.sections.archives) { + return; + } + + const params = new URLSearchParams({ + action: "archives", + q: $("archiveQuery").value.trim(), + kind: $("archiveKind").value, + start: $("archiveStart").value, + end: $("archiveEnd").value, + limit: "80" + }); + + try { + const response = await fetch(`api.php?${params.toString()}`); + const payload = await response.json(); + if (!payload.ok) { + throw new Error(payload.error || "Archive search failed."); + } + state.lastArchives = payload.archives.items || []; + renderArchives(); + } catch (error) { + setStatus(error.message, true); + } + } + + function renderArchives() { + const list = $("archiveList"); + if (!state.lastArchives.length) { + list.innerHTML = `

    No archive snapshots found.

    `; + return; + } + + list.innerHTML = state.lastArchives.map((item) => { + const summary = item.summary || {}; + return ` +
    +
    +

    ${escapeHtml(item.location_name)}

    +

    ${escapeHtml(formatDateTime(item.observed_at))} · ${escapeHtml(summary.weather_label || "Weather snapshot")}

    +
    + ${escapeHtml(item.kind)} + ${escapeHtml(formatNumber(summary.temperature, 0, " F"))} + Wind ${escapeHtml(formatNumber(summary.wind_speed, 0, " mph"))} + AQI ${escapeHtml(formatNumber(summary.us_aqi, 0, ""))} +
    +
    + +
    + `; + }).join(""); + + list.querySelectorAll("[data-archive-id]").forEach((button) => { + button.addEventListener("click", () => openArchive(Number(button.dataset.archiveId))); + }); + + if (window.lucide) { + window.lucide.createIcons(); + } + } + + async function openArchive(id) { + try { + const response = await fetch(`api.php?action=archive&id=${encodeURIComponent(id)}`); + const payload = await response.json(); + if (!payload.ok) { + throw new Error(payload.error || "Archive load failed."); + } + $("archiveDialogTitle").textContent = `${payload.archive.location_name} ${payload.archive.kind}`; + $("archiveDialogBody").textContent = JSON.stringify(payload.archive, null, 2); + $("archiveDialog").showModal(); + } catch (error) { + setStatus(error.message, true); + } + } + + function applyPreferences() { + document.body.classList.toggle("light", state.prefs.theme === "light"); + $("themeLabel").textContent = state.prefs.theme === "light" ? "Dark" : "Light"; + + document.querySelectorAll("[data-pref]").forEach((input) => { + input.checked = Boolean(getPath(state.prefs, input.dataset.pref)); + }); + + Object.entries(state.prefs.sections).forEach(([name, visible]) => { + document.querySelectorAll(`[data-section="${name}"]`).forEach((section) => { + section.classList.toggle("is-hidden", !visible); + }); + }); + + if (state.map) { + window.setTimeout(() => state.map.invalidateSize(), 120); + } + } + + function toggleTheme() { + state.prefs.theme = state.prefs.theme === "light" ? "dark" : "light"; + savePrefs(); + applyPreferences(); + if (state.dashboard) { + renderDashboard(); + } + } + + function loadPrefs() { + const raw = readCookie("weather_preferences"); + if (!raw) { + return clone(DEFAULT_PREFS); + } + + try { + return mergeDeep(clone(DEFAULT_PREFS), JSON.parse(raw)); + } catch (error) { + return clone(DEFAULT_PREFS); + } + } + + function savePrefs() { + writeCookie("weather_preferences", JSON.stringify(state.prefs), 365); + } + + function exportPreferences() { + downloadJson("weather-preferences.json", { + exported_at: new Date().toISOString(), + preferences: state.prefs, + location: state.dashboard?.location || null + }); + } + + async function importPreferences(event) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + + try { + const text = await file.text(); + const parsed = JSON.parse(text); + const importedPrefs = parsed.preferences || parsed; + state.prefs = mergeDeep(clone(DEFAULT_PREFS), importedPrefs); + $("locationInput").value = state.prefs.locationQuery || DEFAULT_PREFS.locationQuery; + savePrefs(); + applyPreferences(); + loadWeather(false); + } catch (error) { + setStatus("Preference import failed.", true); + } + } + + function exportArchives() { + downloadJson("weather-archives.json", { + exported_at: new Date().toISOString(), + location: state.dashboard?.location || null, + archives: state.lastArchives + }); + } + + function setStatus(message, isError) { + const line = $("statusLine"); + line.textContent = message; + line.style.color = isError ? "var(--red)" : "var(--muted)"; + } + + function drawSpark(canvas, values, label, color) { + const ctx = canvas.getContext("2d"); + const ratio = window.devicePixelRatio || 1; + const width = canvas.clientWidth || canvas.width; + const height = canvas.clientHeight || canvas.height; + canvas.width = Math.max(1, Math.floor(width * ratio)); + canvas.height = Math.max(1, Math.floor(height * ratio)); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + ctx.clearRect(0, 0, width, height); + + ctx.fillStyle = getComputedStyle(document.body).getPropertyValue("--panel-2"); + ctx.fillRect(0, 0, width, height); + + ctx.strokeStyle = "rgba(255,255,255,0.12)"; + ctx.lineWidth = 1; + for (let i = 1; i < 4; i += 1) { + const y = (height / 4) * i; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + const clean = values.filter(isFiniteNumber); + if (clean.length < 2) { + ctx.fillStyle = getComputedStyle(document.body).getPropertyValue("--muted"); + ctx.fillText("No trend data", 12, 24); + return; + } + + const minValue = Math.min(...clean); + const maxValue = Math.max(...clean); + const spread = Math.max(1, maxValue - minValue); + const pad = 18; + + ctx.strokeStyle = color; + ctx.lineWidth = 3; + ctx.beginPath(); + clean.forEach((value, index) => { + const x = pad + (index / (clean.length - 1)) * (width - pad * 2); + const y = height - pad - ((value - minValue) / spread) * (height - pad * 2); + if (index === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + ctx.stroke(); + + ctx.fillStyle = color; + ctx.font = "700 12px Inter, system-ui, sans-serif"; + ctx.fillText(`${label}: ${Math.round(minValue)} to ${Math.round(maxValue)}`, 12, 20); + } + + function nextIndexes(times, count) { + const now = Date.now(); + const indexes = []; + const source = Array.isArray(times) ? times : []; + let start = source.findIndex((time) => new Date(time).getTime() >= now - 3600000); + if (start < 0) { + start = 0; + } + for (let i = start; i < source.length && indexes.length < count; i += 1) { + indexes.push(i); + } + return indexes; + } + + function firstFuture(times, values) { + if (!Array.isArray(times) || !Array.isArray(values)) { + return null; + } + const indexes = nextIndexes(times, 1); + return indexes.length ? values[indexes[0]] : null; + } + + function latestAirValue(times, values) { + return firstFuture(times, values); + } + + function labelForCode(code, data) { + if (code === null || code === undefined) { + return "Weather"; + } + const key = String(code); + return data?.weather_codes?.[key] || WEATHER_CODES[key] || "Weather"; + } + + function formatNumber(value, digits, suffix) { + if (!isFiniteNumber(value)) { + return "--"; + } + return `${Number(value).toFixed(digits)}${suffix}`; + } + + function formatDateTime(value) { + if (!value) { + return "--"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit" + }).format(date); + } + + function formatHour(value) { + if (!value) { + return "--"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return new Intl.DateTimeFormat(undefined, { + weekday: "short", + hour: "numeric" + }).format(date); + } + + function formatDay(value) { + if (!value) { + return "--"; + } + const date = new Date(`${value}T12:00:00`); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return new Intl.DateTimeFormat(undefined, { + weekday: "short", + month: "short", + day: "numeric" + }).format(date); + } + + function aqiLabel(value) { + if (!isFiniteNumber(value)) { + return "AQI unavailable"; + } + const aqi = Number(value); + if (aqi <= 50) { + return "Good"; + } + if (aqi <= 100) { + return "Moderate"; + } + if (aqi <= 150) { + return "Unhealthy for sensitive groups"; + } + if (aqi <= 200) { + return "Unhealthy"; + } + if (aqi <= 300) { + return "Very unhealthy"; + } + return "Hazardous"; + } + + function aqiColor(value) { + if (!isFiniteNumber(value)) { + return "#9fb0c4"; + } + const aqi = Number(value); + if (aqi <= 50) { + return "#45d483"; + } + if (aqi <= 100) { + return "#ffdf4d"; + } + if (aqi <= 150) { + return "#ff9f43"; + } + if (aqi <= 200) { + return "#ff595e"; + } + if (aqi <= 300) { + return "#9c6dff"; + } + return "#7f1d1d"; + } + + function sum(values) { + return values.filter(isFiniteNumber).reduce((total, value) => total + Number(value), 0); + } + + function max(values) { + const clean = values.filter(isFiniteNumber).map(Number); + return clean.length ? Math.max(...clean) : null; + } + + function avg(values) { + const clean = values.filter(isFiniteNumber).map(Number); + return clean.length ? clean.reduce((total, value) => total + value, 0) / clean.length : null; + } + + function isFiniteNumber(value) { + return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)); + } + + function getPath(object, path) { + return String(path).split(".").reduce((cursor, part) => { + if (!cursor || typeof cursor !== "object") { + return undefined; + } + return cursor[part]; + }, object); + } + + function setPath(object, path, value) { + const parts = String(path).split("."); + let cursor = object; + parts.slice(0, -1).forEach((part) => { + if (!cursor[part] || typeof cursor[part] !== "object") { + cursor[part] = {}; + } + cursor = cursor[part]; + }); + cursor[parts[parts.length - 1]] = value; + } + + function mergeDeep(target, source) { + if (!source || typeof source !== "object") { + return target; + } + Object.entries(source).forEach(([key, value]) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + target[key] = mergeDeep(target[key] || {}, value); + } else { + target[key] = value; + } + }); + return target; + } + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function readCookie(name) { + const match = document.cookie.split("; ").find((row) => row.startsWith(`${name}=`)); + if (!match) { + return null; + } + return decodeURIComponent(match.substring(name.length + 1)); + } + + function writeCookie(name, value, days) { + const expires = new Date(Date.now() + days * 86400000).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; + } + + function downloadJson(filename, data) { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } + + function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } +}()); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..1fe4675 --- /dev/null +++ b/public/index.php @@ -0,0 +1,216 @@ + + + + + + Weather + + + + + + +
    +
    +
    + +
    +

    Weather

    +

    Search a location

    +
    +
    + + + +
    + + +
    +
    + +
    + + +
    +
    Ready
    + +
    +
    +
    + Current +

    --

    +

    Waiting for weather data

    +
    + +
    +
    +
    + +
    +
    +

    Radar

    + Live layer +
    +
    +
    + +
    +
    +

    Hourly

    + Next 48 hours +
    +
    + + + +
    +
    +
    + +
    +
    +

    Daily

    + 16 day projection +
    +
    +
    + +
    +
    +

    Weekly

    + Grouped projection +
    +
    +
    + +
    +
    +
    +

    Air

    + AQI and pollutants +
    +
    + +
    + +
    +
    +

    History

    + Current date +
    +
      +
      +
      + +
      +
      +

      Archives

      + +
      + +
      +
      +
      +
      +
      + + +
      +

      Archive

      + +
      +
      
      +    
      + + + + + + diff --git a/src/WeatherService.php b/src/WeatherService.php new file mode 100644 index 0000000..28c80b2 --- /dev/null +++ b/src/WeatherService.php @@ -0,0 +1,931 @@ + */ + private array $stateNames = [ + 'AL' => 'Alabama', + 'AK' => 'Alaska', + 'AZ' => 'Arizona', + 'AR' => 'Arkansas', + 'CA' => 'California', + 'CO' => 'Colorado', + 'CT' => 'Connecticut', + 'DE' => 'Delaware', + 'DC' => 'District of Columbia', + 'FL' => 'Florida', + 'GA' => 'Georgia', + 'HI' => 'Hawaii', + 'ID' => 'Idaho', + 'IL' => 'Illinois', + 'IN' => 'Indiana', + 'IA' => 'Iowa', + 'KS' => 'Kansas', + 'KY' => 'Kentucky', + 'LA' => 'Louisiana', + 'ME' => 'Maine', + 'MD' => 'Maryland', + 'MA' => 'Massachusetts', + 'MI' => 'Michigan', + 'MN' => 'Minnesota', + 'MS' => 'Mississippi', + 'MO' => 'Missouri', + 'MT' => 'Montana', + 'NE' => 'Nebraska', + 'NV' => 'Nevada', + 'NH' => 'New Hampshire', + 'NJ' => 'New Jersey', + 'NM' => 'New Mexico', + 'NY' => 'New York', + 'NC' => 'North Carolina', + 'ND' => 'North Dakota', + 'OH' => 'Ohio', + 'OK' => 'Oklahoma', + 'OR' => 'Oregon', + 'PA' => 'Pennsylvania', + 'RI' => 'Rhode Island', + 'SC' => 'South Carolina', + 'SD' => 'South Dakota', + 'TN' => 'Tennessee', + 'TX' => 'Texas', + 'UT' => 'Utah', + 'VT' => 'Vermont', + 'VA' => 'Virginia', + 'WA' => 'Washington', + 'WV' => 'West Virginia', + 'WI' => 'Wisconsin', + 'WY' => 'Wyoming', + ]; + + /** @var array */ + private array $weatherCodes = [ + 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', + ]; + + public function __construct(PDO $db) + { + $this->db = $db; + $this->initializeSchema(); + } + + /** + * @return array> + */ + public function searchLocations(string $query): array + { + $query = trim($query); + if ($query === '') { + throw new InvalidArgumentException('Enter a city, state, or ZIP code.'); + } + + [$lookup, $stateFilter] = $this->splitLocationQuery($query); + $isZip = (bool) preg_match('/^\d{5}(?:-\d{4})?$/', $lookup); + + $params = [ + 'name' => $isZip ? substr($lookup, 0, 5) : $lookup, + 'count' => 12, + 'language' => 'en', + 'format' => 'json', + ]; + + if ($isZip || $stateFilter !== null) { + $params['countryCode'] = 'US'; + } + + $url = 'https://geocoding-api.open-meteo.com/v1/search?' . http_build_query($params); + $data = $this->cachedJson($url, 'geocoding', 'global', 86400, false); + $results = $data['results'] ?? []; + + if (!is_array($results)) { + return []; + } + + $locations = []; + foreach ($results as $result) { + if (!is_array($result) || !isset($result['latitude'], $result['longitude'], $result['name'])) { + continue; + } + + $admin1 = (string) ($result['admin1'] ?? ''); + if ($stateFilter !== null && !$this->matchesState($admin1, $stateFilter)) { + continue; + } + + $locations[] = $this->normalizeLocation($result); + } + + if ($locations === [] && $stateFilter !== null) { + foreach ($results as $result) { + if (is_array($result) && isset($result['latitude'], $result['longitude'], $result['name'])) { + $locations[] = $this->normalizeLocation($result); + } + } + } + + return $locations; + } + + /** + * @param array $input + * @return array + */ + public function dashboard(array $input): array + { + $force = filter_var($input['force'] ?? false, FILTER_VALIDATE_BOOL); + $location = $this->resolveLocation($input); + $locationKey = $this->locationKey($location); + + $forecast = $this->cachedJson($this->forecastUrl($location), 'forecast', $locationKey, 300, $force); + $airQuality = $this->cachedJson($this->airQualityUrl($location), 'air-quality', $locationKey, 300, $force); + $history = $this->historyFacts($location, $forecast); + $rainViewer = $this->cachedJson('https://api.rainviewer.com/public/weather-maps.json', 'rainviewer', 'global', 900, $force); + + $this->archiveSnapshot($location, $forecast, $airQuality, $history); + $todayArchiveCount = $this->countArchiveForDay($locationKey, gmdate('Y-m-d')); + $history['facts'][] = sprintf('Your local archive has %d saved snapshot%s for this location today.', $todayArchiveCount, $todayArchiveCount === 1 ? '' : 's'); + + return [ + 'generated_at' => gmdate('c'), + 'refresh_seconds' => 300, + 'location' => $location, + 'forecast' => $forecast, + 'air_quality' => $airQuality, + 'history' => $history, + 'rainviewer' => $rainViewer, + 'weather_codes' => $this->weatherCodes, + 'archive_summary' => $this->archiveSummary($locationKey), + ]; + } + + /** + * @param array $filters + * @return array + */ + public function listArchives(array $filters): array + { + $where = []; + $params = []; + + $q = trim((string) ($filters['q'] ?? '')); + if ($q !== '') { + $where[] = '(location_name LIKE :q OR summary_json LIKE :q OR payload_json LIKE :q)'; + $params[':q'] = '%' . $q . '%'; + } + + $kind = trim((string) ($filters['kind'] ?? '')); + if ($kind !== '' && in_array($kind, ['hourly', 'daily'], true)) { + $where[] = 'kind = :kind'; + $params[':kind'] = $kind; + } + + $start = trim((string) ($filters['start'] ?? '')); + if ($start !== '') { + $where[] = 'bucket_day >= :start'; + $params[':start'] = $start; + } + + $end = trim((string) ($filters['end'] ?? '')); + if ($end !== '') { + $where[] = 'bucket_day <= :end'; + $params[':end'] = $end; + } + + $limit = max(1, min(200, (int) ($filters['limit'] ?? 50))); + $sql = 'SELECT id, location_key, location_name, latitude, longitude, kind, bucket_hour, bucket_day, observed_at, summary_json, created_at, updated_at FROM archive_snapshots'; + if ($where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= ' ORDER BY observed_at DESC, id DESC LIMIT :limit'; + + $stmt = $this->db->prepare($sql); + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value, PDO::PARAM_STR); + } + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + $items = []; + foreach ($stmt->fetchAll() as $row) { + $row['summary'] = json_decode((string) $row['summary_json'], true) ?: []; + unset($row['summary_json']); + $items[] = $row; + } + + return [ + 'items' => $items, + 'count' => count($items), + ]; + } + + /** + * @return array + */ + public function getArchive(int $id): array + { + $stmt = $this->db->prepare('SELECT * FROM archive_snapshots WHERE id = :id'); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(); + + if (!$row) { + throw new RuntimeException('Archive snapshot not found.'); + } + + $row['summary'] = json_decode((string) $row['summary_json'], true) ?: []; + $row['payload'] = json_decode((string) $row['payload_json'], true) ?: []; + unset($row['summary_json'], $row['payload_json']); + + return $row; + } + + private function initializeSchema(): void + { + $this->db->exec( + "CREATE TABLE IF NOT EXISTS api_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cache_key TEXT NOT NULL UNIQUE, + endpoint TEXT NOT NULL, + location_key TEXT NOT NULL, + url TEXT NOT NULL, + response_json TEXT NOT NULL, + fetched_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + )" + ); + + $this->db->exec( + "CREATE TABLE IF NOT EXISTS archive_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + location_key TEXT NOT NULL, + location_name TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + kind TEXT NOT NULL, + bucket_hour TEXT NOT NULL, + bucket_day TEXT NOT NULL, + observed_at TEXT NOT NULL, + summary_json TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(location_key, kind, bucket_hour) + )" + ); + + $this->db->exec('CREATE INDEX IF NOT EXISTS idx_cache_expiry ON api_cache (expires_at)'); + $this->db->exec('CREATE INDEX IF NOT EXISTS idx_archive_location ON archive_snapshots (location_key)'); + $this->db->exec('CREATE INDEX IF NOT EXISTS idx_archive_day ON archive_snapshots (bucket_day)'); + $this->db->exec('CREATE INDEX IF NOT EXISTS idx_archive_kind ON archive_snapshots (kind)'); + } + + /** + * @param array $input + * @return array + */ + private function resolveLocation(array $input): array + { + $lat = $input['lat'] ?? null; + $lon = $input['lon'] ?? null; + + if (is_numeric($lat) && is_numeric($lon)) { + return $this->normalizeLocation([ + 'name' => (string) ($input['name'] ?? 'Selected location'), + 'admin1' => (string) ($input['admin1'] ?? ''), + 'country' => (string) ($input['country'] ?? ''), + 'country_code' => (string) ($input['country_code'] ?? ''), + 'latitude' => (float) $lat, + 'longitude' => (float) $lon, + 'timezone' => (string) ($input['timezone'] ?? 'auto'), + ]); + } + + $query = trim((string) ($input['q'] ?? '')); + $locations = $this->searchLocations($query !== '' ? $query : 'New York, NY'); + + if ($locations === []) { + throw new RuntimeException('No matching location was found.'); + } + + return $locations[0]; + } + + /** + * @param array $raw + * @return array + */ + private function normalizeLocation(array $raw): array + { + $name = (string) ($raw['name'] ?? 'Selected location'); + $admin1 = (string) ($raw['admin1'] ?? ''); + $country = (string) ($raw['country'] ?? ''); + $countryCode = strtoupper((string) ($raw['country_code'] ?? '')); + $latitude = round((float) $raw['latitude'], 5); + $longitude = round((float) $raw['longitude'], 5); + $timezone = (string) ($raw['timezone'] ?? 'auto'); + + $parts = array_values(array_filter([$name, $admin1, $countryCode ?: $country])); + + return [ + 'id' => (string) ($raw['id'] ?? md5($latitude . ',' . $longitude)), + 'name' => $name, + 'admin1' => $admin1, + 'country' => $country, + 'country_code' => $countryCode, + 'latitude' => $latitude, + 'longitude' => $longitude, + 'timezone' => $timezone, + 'elevation' => $raw['elevation'] ?? null, + 'population' => $raw['population'] ?? null, + 'label' => implode(', ', $parts), + ]; + } + + /** + * @return array{0: string, 1: string|null} + */ + private function splitLocationQuery(string $query): array + { + $query = trim($query); + $stateFilter = null; + $lookup = $query; + + $parts = array_values(array_filter(array_map('trim', explode(',', $query)), static fn (string $part): bool => $part !== '')); + if (count($parts) >= 3) { + $countryCandidate = strtoupper((string) end($parts)); + if (in_array($countryCandidate, ['US', 'USA', 'UNITED STATES', 'UNITED STATES OF AMERICA'], true)) { + array_pop($parts); + } + } + + if (count($parts) >= 2) { + $candidate = (string) end($parts); + $normalizedState = $this->normalizeStateCandidate($candidate); + if ($normalizedState !== null) { + array_pop($parts); + $lookup = trim((string) reset($parts)); + $stateFilter = $normalizedState; + } + } + + return [$lookup, $stateFilter]; + } + + private function normalizeStateCandidate(string $candidate): ?string + { + $candidate = trim($candidate); + $upper = strtoupper($candidate); + if (isset($this->stateNames[$upper])) { + return $this->stateNames[$upper]; + } + + foreach ($this->stateNames as $stateName) { + if (strcasecmp($stateName, $candidate) === 0) { + return $stateName; + } + } + + return null; + } + + private function matchesState(string $admin1, string $stateFilter): bool + { + return strcasecmp($admin1, $stateFilter) === 0; + } + + /** + * @param array $location + */ + private function locationKey(array $location): string + { + return md5(round((float) $location['latitude'], 3) . ',' . round((float) $location['longitude'], 3)); + } + + /** + * @param array $location + */ + private function forecastUrl(array $location): string + { + $params = [ + 'latitude' => $location['latitude'], + 'longitude' => $location['longitude'], + 'current' => implode(',', [ + 'temperature_2m', + 'relative_humidity_2m', + 'apparent_temperature', + 'is_day', + 'precipitation', + 'rain', + 'showers', + 'snowfall', + 'weather_code', + 'cloud_cover', + 'pressure_msl', + 'surface_pressure', + 'wind_speed_10m', + 'wind_direction_10m', + 'wind_gusts_10m', + ]), + 'hourly' => implode(',', [ + 'temperature_2m', + 'relative_humidity_2m', + 'dew_point_2m', + 'apparent_temperature', + 'precipitation_probability', + 'precipitation', + 'rain', + 'showers', + 'snowfall', + 'snow_depth', + 'weather_code', + 'pressure_msl', + 'surface_pressure', + 'cloud_cover', + 'visibility', + 'wind_speed_10m', + 'wind_direction_10m', + 'wind_gusts_10m', + 'uv_index', + 'uv_index_clear_sky', + 'is_day', + ]), + 'daily' => implode(',', [ + 'weather_code', + 'temperature_2m_max', + 'temperature_2m_min', + 'apparent_temperature_max', + 'apparent_temperature_min', + 'sunrise', + 'sunset', + 'daylight_duration', + 'sunshine_duration', + 'uv_index_max', + 'uv_index_clear_sky_max', + 'precipitation_sum', + 'rain_sum', + 'showers_sum', + 'snowfall_sum', + 'precipitation_hours', + 'precipitation_probability_max', + 'wind_speed_10m_max', + 'wind_gusts_10m_max', + 'wind_direction_10m_dominant', + ]), + 'forecast_days' => 16, + 'past_days' => 1, + 'timezone' => 'auto', + 'temperature_unit' => 'fahrenheit', + 'wind_speed_unit' => 'mph', + 'precipitation_unit' => 'inch', + ]; + + return 'https://api.open-meteo.com/v1/forecast?' . http_build_query($params); + } + + /** + * @param array $location + */ + private function airQualityUrl(array $location): string + { + $params = [ + 'latitude' => $location['latitude'], + 'longitude' => $location['longitude'], + 'current' => implode(',', [ + 'us_aqi', + 'pm10', + 'pm2_5', + 'carbon_monoxide', + 'nitrogen_dioxide', + 'sulphur_dioxide', + 'ozone', + 'uv_index', + 'uv_index_clear_sky', + ]), + 'hourly' => implode(',', [ + 'us_aqi', + 'us_aqi_pm2_5', + 'us_aqi_pm10', + 'us_aqi_nitrogen_dioxide', + 'us_aqi_ozone', + 'pm10', + 'pm2_5', + 'carbon_monoxide', + 'nitrogen_dioxide', + 'sulphur_dioxide', + 'ozone', + 'aerosol_optical_depth', + 'dust', + 'uv_index', + 'uv_index_clear_sky', + 'alder_pollen', + 'birch_pollen', + 'grass_pollen', + 'mugwort_pollen', + 'olive_pollen', + 'ragweed_pollen', + ]), + 'forecast_days' => 5, + 'timezone' => 'auto', + ]; + + return 'https://air-quality-api.open-meteo.com/v1/air-quality?' . http_build_query($params); + } + + /** + * @param array $location + * @param array $forecast + * @return array + */ + private function historyFacts(array $location, array $forecast): array + { + $timezoneName = is_string($location['timezone'] ?? null) && $location['timezone'] !== 'auto' + ? (string) $location['timezone'] + : 'UTC'; + + try { + $timezone = new DateTimeZone($timezoneName); + } catch (Throwable) { + $timezone = new DateTimeZone('UTC'); + } + + $today = new DateTimeImmutable('today', $timezone); + $lastYear = $today->modify('-1 year'); + $date = $lastYear->format('Y-m-d'); + + $params = [ + 'latitude' => $location['latitude'], + 'longitude' => $location['longitude'], + 'start_date' => $date, + 'end_date' => $date, + 'daily' => implode(',', [ + 'weather_code', + 'temperature_2m_max', + 'temperature_2m_min', + 'temperature_2m_mean', + 'precipitation_sum', + 'rain_sum', + 'snowfall_sum', + 'wind_speed_10m_max', + ]), + 'timezone' => 'auto', + 'temperature_unit' => 'fahrenheit', + 'wind_speed_unit' => 'mph', + 'precipitation_unit' => 'inch', + ]; + + $url = 'https://archive-api.open-meteo.com/v1/archive?' . http_build_query($params); + $historical = $this->cachedJson($url, 'history', $this->locationKey($location), 43200, false); + + $facts = []; + $todayDate = $today->format('F j'); + $place = (string) $location['label']; + $todayHigh = $this->arrayValue($forecast, ['daily', 'temperature_2m_max', 1]); + $lastHigh = $this->arrayValue($historical, ['daily', 'temperature_2m_max', 0]); + $lastLow = $this->arrayValue($historical, ['daily', 'temperature_2m_min', 0]); + $lastRain = $this->arrayValue($historical, ['daily', 'precipitation_sum', 0]); + $lastWind = $this->arrayValue($historical, ['daily', 'wind_speed_10m_max', 0]); + $lastCode = $this->arrayValue($historical, ['daily', 'weather_code', 0]); + + if (is_numeric($todayHigh) && is_numeric($lastHigh)) { + $delta = round((float) $todayHigh - (float) $lastHigh, 1); + if ($delta > 0) { + $facts[] = sprintf( + 'This %s is projected %.1f F warmer than the same date last year near %s.', + $todayDate, + abs($delta), + $place + ); + } elseif ($delta < 0) { + $facts[] = sprintf( + 'This %s is projected %.1f F cooler than the same date last year near %s.', + $todayDate, + abs($delta), + $place + ); + } else { + $facts[] = sprintf( + 'This %s is projected to match last year near %s at %.1f F.', + $todayDate, + $place, + (float) $todayHigh + ); + } + } + + if (is_numeric($lastHigh) && is_numeric($lastLow)) { + $facts[] = sprintf( + 'On %s, %s, the historical high was %.1f F and the low was %.1f F.', + $lastYear->format('F j'), + $lastYear->format('Y'), + (float) $lastHigh, + (float) $lastLow + ); + } + + if (is_numeric($lastRain)) { + $facts[] = sprintf( + 'That date recorded %.2f inches of precipitation in the Open-Meteo archive.', + (float) $lastRain + ); + } + + if (is_numeric($lastWind)) { + $facts[] = sprintf( + 'The strongest archived wind for that date was %.1f mph.', + (float) $lastWind + ); + } + + return [ + 'comparison_date' => $date, + 'facts' => $facts, + 'historical_daily' => $historical['daily'] ?? [], + 'historical_weather' => is_numeric($lastCode) ? ($this->weatherCodes[(int) $lastCode] ?? 'Unknown') : null, + ]; + } + + /** + * @param array $location + * @param array $forecast + * @param array $airQuality + * @param array $history + */ + private function archiveSnapshot(array $location, array $forecast, array $airQuality, array $history): void + { + $now = gmdate('c'); + $bucketHour = gmdate('Y-m-d H:00:00'); + $bucketDay = gmdate('Y-m-d'); + $observedAt = (string) ($forecast['current']['time'] ?? $now); + $locationKey = $this->locationKey($location); + + $summary = [ + 'temperature' => $this->arrayValue($forecast, ['current', 'temperature_2m']), + 'apparent_temperature' => $this->arrayValue($forecast, ['current', 'apparent_temperature']), + 'humidity' => $this->arrayValue($forecast, ['current', 'relative_humidity_2m']), + 'weather_code' => $this->arrayValue($forecast, ['current', 'weather_code']), + 'weather_label' => $this->codeLabel($this->arrayValue($forecast, ['current', 'weather_code'])), + 'precipitation' => $this->arrayValue($forecast, ['current', 'precipitation']), + 'rain' => $this->arrayValue($forecast, ['current', 'rain']), + 'snowfall' => $this->arrayValue($forecast, ['current', 'snowfall']), + 'cloud_cover' => $this->arrayValue($forecast, ['current', 'cloud_cover']), + 'pressure' => $this->arrayValue($forecast, ['current', 'pressure_msl']), + 'wind_speed' => $this->arrayValue($forecast, ['current', 'wind_speed_10m']), + 'wind_direction' => $this->arrayValue($forecast, ['current', 'wind_direction_10m']), + 'wind_gusts' => $this->arrayValue($forecast, ['current', 'wind_gusts_10m']), + 'us_aqi' => $this->arrayValue($airQuality, ['current', 'us_aqi']), + 'pm2_5' => $this->arrayValue($airQuality, ['current', 'pm2_5']), + 'pm10' => $this->arrayValue($airQuality, ['current', 'pm10']), + ]; + + $payload = [ + 'location' => $location, + 'forecast' => $forecast, + 'air_quality' => $airQuality, + 'history' => $history, + ]; + + foreach (['hourly' => $bucketHour, 'daily' => $bucketDay . ' 00:00:00'] as $kind => $kindBucketHour) { + $stmt = $this->db->prepare( + "INSERT INTO archive_snapshots ( + location_key, location_name, latitude, longitude, kind, bucket_hour, bucket_day, + observed_at, summary_json, payload_json, created_at, updated_at + ) VALUES ( + :location_key, :location_name, :latitude, :longitude, :kind, :bucket_hour, :bucket_day, + :observed_at, :summary_json, :payload_json, :created_at, :updated_at + ) + ON CONFLICT(location_key, kind, bucket_hour) DO UPDATE SET + observed_at = excluded.observed_at, + summary_json = excluded.summary_json, + payload_json = excluded.payload_json, + updated_at = excluded.updated_at" + ); + + $stmt->execute([ + ':location_key' => $locationKey, + ':location_name' => (string) $location['label'], + ':latitude' => (float) $location['latitude'], + ':longitude' => (float) $location['longitude'], + ':kind' => $kind, + ':bucket_hour' => $kindBucketHour, + ':bucket_day' => $bucketDay, + ':observed_at' => $observedAt, + ':summary_json' => json_encode($summary, JSON_UNESCAPED_SLASHES), + ':payload_json' => json_encode($payload, JSON_UNESCAPED_SLASHES), + ':created_at' => $now, + ':updated_at' => $now, + ]); + } + } + + /** + * @return array + */ + private function archiveSummary(string $locationKey): array + { + $stmt = $this->db->prepare( + "SELECT + COUNT(*) AS snapshots, + SUM(CASE WHEN kind = 'hourly' THEN 1 ELSE 0 END) AS hourly_snapshots, + SUM(CASE WHEN kind = 'daily' THEN 1 ELSE 0 END) AS daily_snapshots, + MIN(bucket_day) AS first_day, + MAX(bucket_day) AS last_day + FROM archive_snapshots + WHERE location_key = :location_key" + ); + $stmt->execute([':location_key' => $locationKey]); + $row = $stmt->fetch() ?: []; + + return [ + 'snapshots' => (int) ($row['snapshots'] ?? 0), + 'hourly_snapshots' => (int) ($row['hourly_snapshots'] ?? 0), + 'daily_snapshots' => (int) ($row['daily_snapshots'] ?? 0), + 'first_day' => $row['first_day'] ?? null, + 'last_day' => $row['last_day'] ?? null, + ]; + } + + private function countArchiveForDay(string $locationKey, string $day): int + { + $stmt = $this->db->prepare('SELECT COUNT(*) FROM archive_snapshots WHERE location_key = :location_key AND bucket_day = :bucket_day'); + $stmt->execute([':location_key' => $locationKey, ':bucket_day' => $day]); + + return (int) $stmt->fetchColumn(); + } + + private function codeLabel(mixed $code): ?string + { + if (!is_numeric($code)) { + return null; + } + + return $this->weatherCodes[(int) $code] ?? 'Unknown'; + } + + /** + * @param array $data + * @param array $path + */ + private function arrayValue(array $data, array $path): mixed + { + $cursor = $data; + foreach ($path as $part) { + if (!is_array($cursor) || !array_key_exists($part, $cursor)) { + return null; + } + $cursor = $cursor[$part]; + } + + return $cursor; + } + + /** + * @return array + */ + private function cachedJson(string $url, string $endpoint, string $locationKey, int $ttlSeconds, bool $force): array + { + $cacheKey = hash('sha256', $endpoint . '|' . $url); + $now = gmdate('c'); + + $stmt = $this->db->prepare('SELECT * FROM api_cache WHERE cache_key = :cache_key'); + $stmt->execute([':cache_key' => $cacheKey]); + $row = $stmt->fetch(); + + if (!$force && $row && strcmp((string) $row['expires_at'], $now) > 0) { + $data = json_decode((string) $row['response_json'], true); + if (is_array($data)) { + $data['_cache'] = [ + 'endpoint' => $endpoint, + 'fetched_at' => $row['fetched_at'], + 'expires_at' => $row['expires_at'], + 'stale' => false, + ]; + + return $data; + } + } + + try { + $data = $this->fetchJson($url); + $fetchedAt = gmdate('c'); + $expiresAt = gmdate('c', time() + $ttlSeconds); + + $upsert = $this->db->prepare( + "INSERT INTO api_cache (cache_key, endpoint, location_key, url, response_json, fetched_at, expires_at, created_at) + VALUES (:cache_key, :endpoint, :location_key, :url, :response_json, :fetched_at, :expires_at, :created_at) + ON CONFLICT(cache_key) DO UPDATE SET + endpoint = excluded.endpoint, + location_key = excluded.location_key, + url = excluded.url, + response_json = excluded.response_json, + fetched_at = excluded.fetched_at, + expires_at = excluded.expires_at" + ); + + $upsert->execute([ + ':cache_key' => $cacheKey, + ':endpoint' => $endpoint, + ':location_key' => $locationKey, + ':url' => $url, + ':response_json' => json_encode($data, JSON_UNESCAPED_SLASHES), + ':fetched_at' => $fetchedAt, + ':expires_at' => $expiresAt, + ':created_at' => $fetchedAt, + ]); + + $data['_cache'] = [ + 'endpoint' => $endpoint, + 'fetched_at' => $fetchedAt, + 'expires_at' => $expiresAt, + 'stale' => false, + ]; + + return $data; + } catch (Throwable $error) { + if ($row) { + $data = json_decode((string) $row['response_json'], true); + if (is_array($data)) { + $data['_cache'] = [ + 'endpoint' => $endpoint, + 'fetched_at' => $row['fetched_at'], + 'expires_at' => $row['expires_at'], + 'stale' => true, + 'error' => $error->getMessage(), + ]; + + return $data; + } + } + + throw $error; + } + } + + /** + * @return array + */ + private function fetchJson(string $url): array + { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 15, + 'header' => implode("\r\n", [ + 'Accept: application/json', + 'User-Agent: CodexWeather/1.0 (+local weather dashboard)', + ]), + ], + ]); + + $body = @file_get_contents($url, false, $context); + + if ($body === false) { + $message = 'Could not reach weather data provider.'; + $headers = function_exists('http_get_last_response_headers') ? http_get_last_response_headers() : []; + if (is_array($headers) && $headers !== []) { + $message .= ' ' . $headers[0]; + } + throw new RuntimeException($message); + } + + $data = json_decode($body, true); + if (!is_array($data)) { + throw new RuntimeException('Weather data provider returned invalid JSON.'); + } + + if (($data['error'] ?? false) === true) { + throw new RuntimeException((string) ($data['reason'] ?? 'Weather data provider returned an error.')); + } + + return $data; + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php new file mode 100644 index 0000000..b449f99 --- /dev/null +++ b/src/bootstrap.php @@ -0,0 +1,60 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $pdo->exec('PRAGMA journal_mode = WAL'); + $pdo->exec('PRAGMA foreign_keys = ON'); + + return $pdo; +} + +function weather_service(): WeatherService +{ + static $service = null; + + if ($service instanceof WeatherService) { + return $service; + } + + $service = new WeatherService(app_db()); + + return $service; +} + +function json_response(array $payload, int $status = 200): void +{ + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + echo json_encode($payload, JSON_UNESCAPED_SLASHES); +} + +function request_value(string $key, mixed $default = null): mixed +{ + return $_POST[$key] ?? $_GET[$key] ?? $default; +}