- initial
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user