- initial

This commit is contained in:
Ty Clifford
2026-06-30 19:48:01 -04:00
commit c69d2b147b
9 changed files with 3032 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/data/*.sqlite
/data/*.sqlite-*
.DS_Store
+36
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/src/bootstrap.php';
try {
$action = (string) request_value('action', 'weather');
$service = weather_service();
switch ($action) {
case 'search':
json_response([
'ok' => 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);
}
+714
View File
@@ -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;
}
}
+999
View File
@@ -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 `
<article class="metric-card">
<span class="label">${escapeHtml(label)}</span>
<strong class="value">${escapeHtml(formatNumber(value, suffix === " in" ? 2 : 0, suffix))}</strong>
<span class="hint">${escapeHtml(hint || "")}</span>
</article>
`;
}
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: "&copy; 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(`
<strong>${escapeHtml(data.location.label)}</strong><br>
${escapeHtml(formatNumber(current.temperature_2m, 0, " F"))}, ${escapeHtml(labelForCode(current.weather_code, data))}<br>
Wind ${escapeHtml(formatNumber(current.wind_speed_10m, 0, " mph"))}<br>
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: `<div class="wind-arrow" title="Wind">${escapeHtml(formatNumber(current.wind_speed_10m, 0, ""))}</div>`,
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 = `<tr>${columns.map((column) => `<th>${escapeHtml(column[0])}</th>`).join("")}</tr>`;
$("hourlyBody").innerHTML = indexes.map((index) => {
return `<tr>${columns.map((column) => `<td>${escapeHtml(column[1](index))}</td>`).join("")}</tr>`;
}).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(`
<article class="mini-card">
<h3>${escapeHtml(formatDay(daily.time?.[i]))}</h3>
<p>${escapeHtml(labelForCode(daily.weather_code?.[i], data))}</p>
<div class="temp-range">
<span>${escapeHtml(formatNumber(daily.temperature_2m_max?.[i], 0, " F"))}</span>
<span>${escapeHtml(formatNumber(daily.temperature_2m_min?.[i], 0, " F"))}</span>
</div>
<div class="range-bar"></div>
<p>Rain ${escapeHtml(formatNumber(daily.precipitation_sum?.[i], 2, " in"))} · Wind ${escapeHtml(formatNumber(daily.wind_speed_10m_max?.[i], 0, " mph"))}</p>
</article>
`);
}
$("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 `
<article class="mini-card">
<h3>Week ${index + 1}</h3>
<p>${escapeHtml(formatDay(daily.time?.[group[0]]))} to ${escapeHtml(formatDay(daily.time?.[group[group.length - 1]]))}</p>
<div class="temp-range">
<span>${escapeHtml(formatNumber(avg(highs), 0, " F avg high"))}</span>
<span>${escapeHtml(formatNumber(avg(lows), 0, " F avg low"))}</span>
</div>
<p>Total precip ${escapeHtml(formatNumber(precip, 2, " in"))}</p>
<p>Peak wind ${escapeHtml(formatNumber(wind, 0, " mph"))}</p>
</article>
`;
}).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) => `
<article class="mini-card">
<h3>${escapeHtml(item[0])}</h3>
<div class="temp-range">
<span>${escapeHtml(formatNumber(item[1], item[2] === "" ? 0 : 1, item[2]))}</span>
</div>
<p>${escapeHtml(item[3] || "")}</p>
</article>
`)
.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) => `<li>${escapeHtml(fact)}</li>`).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 = `<div class="mini-card"><p>No archive snapshots found.</p></div>`;
return;
}
list.innerHTML = state.lastArchives.map((item) => {
const summary = item.summary || {};
return `
<article class="archive-card">
<div>
<h3>${escapeHtml(item.location_name)}</h3>
<p>${escapeHtml(formatDateTime(item.observed_at))} · ${escapeHtml(summary.weather_label || "Weather snapshot")}</p>
<div class="archive-meta">
<span class="chip">${escapeHtml(item.kind)}</span>
<span class="chip">${escapeHtml(formatNumber(summary.temperature, 0, " F"))}</span>
<span class="chip">Wind ${escapeHtml(formatNumber(summary.wind_speed, 0, " mph"))}</span>
<span class="chip">AQI ${escapeHtml(formatNumber(summary.us_aqi, 0, ""))}</span>
</div>
</div>
<button type="button" data-archive-id="${Number(item.id)}">
<i data-lucide="eye"></i>
<span>View</span>
</button>
</article>
`;
}).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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
}());
+216
View File
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Weather</title>
<link rel="preconnect" href="https://unpkg.com">
<link rel="preconnect" href="https://tile.openstreetmap.org">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="assets/css/styles.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark"></span>
<div>
<h1>Weather</h1>
<p id="locationLabel">Search a location</p>
</div>
</div>
<form class="search" id="searchForm" autocomplete="off">
<div class="search-box">
<i data-lucide="search"></i>
<input id="locationInput" name="q" type="search" placeholder="City, state, or ZIP" aria-label="City, state, or ZIP">
<button type="submit">
<i data-lucide="navigation"></i>
<span>Go</span>
</button>
</div>
<div class="search-results" id="searchResults" hidden></div>
</form>
<div class="top-actions">
<button class="icon-button" type="button" id="refreshButton" title="Refresh now">
<i data-lucide="refresh-cw"></i>
<span>Refresh</span>
</button>
<button class="icon-button" type="button" id="themeButton" title="Switch theme">
<i data-lucide="sun-moon"></i>
<span id="themeLabel">Light</span>
</button>
</div>
</header>
<main class="layout">
<aside class="control-panel">
<div class="panel-heading">
<h2>Controls</h2>
<span id="refreshCountdown">5:00</span>
</div>
<div class="control-group">
<h3>Views</h3>
<label><input type="checkbox" data-pref="sections.current"> Current</label>
<label><input type="checkbox" data-pref="sections.map"> Radar</label>
<label><input type="checkbox" data-pref="sections.hourly"> Hourly</label>
<label><input type="checkbox" data-pref="sections.daily"> Daily</label>
<label><input type="checkbox" data-pref="sections.weekly"> Weekly</label>
<label><input type="checkbox" data-pref="sections.air"> Air</label>
<label><input type="checkbox" data-pref="sections.history"> History</label>
<label><input type="checkbox" data-pref="sections.archives"> Archives</label>
</div>
<div class="control-group">
<h3>Map</h3>
<label><input type="checkbox" data-pref="layers.radar"> Radar</label>
<label><input type="checkbox" data-pref="layers.wind"> Wind</label>
<label><input type="checkbox" data-pref="layers.rain"> Rain</label>
<label><input type="checkbox" data-pref="layers.aqi"> Air quality</label>
<label><input type="checkbox" data-pref="layers.clouds"> Clouds</label>
</div>
<div class="control-group">
<h3>Data</h3>
<label><input type="checkbox" data-pref="metrics.humidity"> Humidity</label>
<label><input type="checkbox" data-pref="metrics.wind"> Wind</label>
<label><input type="checkbox" data-pref="metrics.precip"> Precipitation</label>
<label><input type="checkbox" data-pref="metrics.pressure"> Pressure</label>
<label><input type="checkbox" data-pref="metrics.uv"> UV</label>
<label><input type="checkbox" data-pref="metrics.aqi"> AQI</label>
<label><input type="checkbox" data-pref="metrics.pollen"> Pollen</label>
</div>
<div class="control-actions">
<button type="button" id="exportPrefs">
<i data-lucide="download"></i>
<span>Export prefs</span>
</button>
<button type="button" id="importPrefsButton">
<i data-lucide="upload"></i>
<span>Import prefs</span>
</button>
<input id="importPrefsFile" type="file" accept="application/json" hidden>
</div>
</aside>
<section class="content">
<section class="status-line" id="statusLine" aria-live="polite">Ready</section>
<section class="section-grid" id="currentSection" data-section="current">
<div class="current-card main-current">
<div>
<span class="eyebrow">Current</span>
<h2 id="currentTemp">--</h2>
<p id="currentSummary">Waiting for weather data</p>
</div>
<canvas id="todaySpark" width="360" height="150" aria-label="Temperature trend"></canvas>
</div>
<div class="metric-grid" id="metricGrid"></div>
</section>
<section class="map-section" id="mapSection" data-section="map">
<div class="section-title">
<h2>Radar</h2>
<span id="radarTime">Live layer</span>
</div>
<div id="weatherMap" class="weather-map"></div>
</section>
<section class="data-section" id="hourlySection" data-section="hourly">
<div class="section-title">
<h2>Hourly</h2>
<span id="hourlyMeta">Next 48 hours</span>
</div>
<div class="scroll-table">
<table>
<thead id="hourlyHead"></thead>
<tbody id="hourlyBody"></tbody>
</table>
</div>
</section>
<section class="data-section" id="dailySection" data-section="daily">
<div class="section-title">
<h2>Daily</h2>
<span>16 day projection</span>
</div>
<div class="daily-grid" id="dailyGrid"></div>
</section>
<section class="data-section" id="weeklySection" data-section="weekly">
<div class="section-title">
<h2>Weekly</h2>
<span>Grouped projection</span>
</div>
<div class="weekly-grid" id="weeklyGrid"></div>
</section>
<section class="section-grid two-column">
<div class="data-section" id="airSection" data-section="air">
<div class="section-title">
<h2>Air</h2>
<span id="airMeta">AQI and pollutants</span>
</div>
<div class="air-grid" id="airGrid"></div>
<canvas id="aqiSpark" width="420" height="160" aria-label="AQI trend"></canvas>
</div>
<div class="data-section" id="historySection" data-section="history">
<div class="section-title">
<h2>History</h2>
<span id="historyDate">Current date</span>
</div>
<ul class="fact-list" id="historyFacts"></ul>
</div>
</section>
<section class="data-section" id="archivesSection" data-section="archives">
<div class="section-title">
<h2>Archives</h2>
<button type="button" id="exportArchives">
<i data-lucide="file-json"></i>
<span>Export results</span>
</button>
</div>
<form class="archive-search" id="archiveForm">
<input id="archiveQuery" type="search" placeholder="Search archive" aria-label="Search archive">
<select id="archiveKind" aria-label="Archive kind">
<option value="">All</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
</select>
<input id="archiveStart" type="date" aria-label="Start date">
<input id="archiveEnd" type="date" aria-label="End date">
<button type="submit">
<i data-lucide="database"></i>
<span>Search</span>
</button>
</form>
<div class="archive-list" id="archiveList"></div>
</section>
</section>
</main>
</div>
<dialog id="archiveDialog">
<div class="dialog-head">
<h2 id="archiveDialogTitle">Archive</h2>
<button type="button" id="closeArchiveDialog" class="round-button" title="Close">
<i data-lucide="x"></i>
</button>
</div>
<pre id="archiveDialogBody"></pre>
</dialog>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script src="assets/js/app.js"></script>
</body>
</html>
+931
View File
@@ -0,0 +1,931 @@
<?php
declare(strict_types=1);
final class WeatherService
{
private PDO $db;
/** @var array<string, string> */
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<int, string> */
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<int, array<string, mixed>>
*/
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<string, mixed> $input
* @return array<string, mixed>
*/
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<string, mixed> $filters
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $input
* @return array<string, mixed>
*/
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<string, mixed> $raw
* @return array<string, mixed>
*/
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<string, mixed> $location
*/
private function locationKey(array $location): string
{
return md5(round((float) $location['latitude'], 3) . ',' . round((float) $location['longitude'], 3));
}
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $location
* @param array<string, mixed> $forecast
* @return array<string, mixed>
*/
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<string, mixed> $location
* @param array<string, mixed> $forecast
* @param array<string, mixed> $airQuality
* @param array<string, mixed> $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<string, mixed>
*/
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<string, mixed> $data
* @param array<int, string|int> $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<string, mixed>
*/
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<string, mixed>
*/
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;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
define('APP_ROOT', dirname(__DIR__));
define('DATA_DIR', APP_ROOT . '/data');
define('DB_PATH', DATA_DIR . '/weather.sqlite');
error_reporting(E_ALL & ~E_DEPRECATED);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
if (!is_dir(DATA_DIR)) {
mkdir(DATA_DIR, 0775, true);
}
require_once __DIR__ . '/WeatherService.php';
function app_db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO('sqlite:' . DB_PATH);
$pdo->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;
}