- 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
+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;
}