initial round of changes from Claude
This commit is contained in:
188
app/Console/Commands/IngestWeatherData.php
Normal file
188
app/Console/Commands/IngestWeatherData.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\WeatherPeriod;
|
||||
use App\Models\WeatherReport;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class IngestWeatherData extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'weather:ingest
|
||||
{--date= : Import specific date only (YYYY-MM-DD)}
|
||||
{--type= : Import specific type only (hourly or weekly)}
|
||||
{--force : Re-import existing records}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Ingest weather JSON files from storage/app/private/weather/';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$basePath = storage_path('app/private/weather');
|
||||
|
||||
if (! File::isDirectory($basePath)) {
|
||||
$this->error("Weather data directory not found: {$basePath}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$dateFilter = $this->option('date');
|
||||
$typeFilter = $this->option('type');
|
||||
$force = $this->option('force');
|
||||
|
||||
$dateFolders = File::directories($basePath);
|
||||
|
||||
if ($dateFilter) {
|
||||
$dateFolders = array_filter($dateFolders, fn ($dir) => basename($dir) === $dateFilter);
|
||||
}
|
||||
|
||||
if (empty($dateFolders)) {
|
||||
$this->warn('No date folders found to process.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Starting weather data ingestion...');
|
||||
$progressBar = $this->output->createProgressBar(count($dateFolders));
|
||||
|
||||
$totalImported = 0;
|
||||
$totalSkipped = 0;
|
||||
|
||||
foreach ($dateFolders as $dateFolder) {
|
||||
$date = basename($dateFolder);
|
||||
$files = File::files($dateFolder);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = $file->getFilename();
|
||||
|
||||
if (! str_ends_with($filename, '.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $this->getReportType($filename);
|
||||
|
||||
if ($type === null || ($typeFilter && $type !== $typeFilter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reportedAt = $this->parseReportedAt($date, $filename);
|
||||
|
||||
$existingReport = WeatherReport::query()
|
||||
->where('type', $type)
|
||||
->where('reported_at', $reportedAt)
|
||||
->first();
|
||||
|
||||
if ($existingReport && ! $force) {
|
||||
$totalSkipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$jsonContent = File::get($file->getPathname());
|
||||
$data = json_decode($jsonContent, true);
|
||||
|
||||
if (! $data) {
|
||||
$this->warn("Failed to parse JSON: {$file->getPathname()}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existingReport && $force) {
|
||||
$existingReport->delete();
|
||||
}
|
||||
|
||||
$this->importReport($type, $reportedAt, $data);
|
||||
$totalImported++;
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info("Imported: {$totalImported} reports, Skipped: {$totalSkipped} existing");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getReportType(string $filename): ?string
|
||||
{
|
||||
if (str_starts_with($filename, 'hourly_')) {
|
||||
return 'hourly';
|
||||
}
|
||||
|
||||
if (str_starts_with($filename, 'weekly_')) {
|
||||
return 'weekly';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parseReportedAt(string $date, string $filename): Carbon
|
||||
{
|
||||
preg_match('/(\d{2})-(\d{2})\.json$/', $filename, $matches);
|
||||
$hour = $matches[1] ?? '00';
|
||||
$minute = $matches[2] ?? '00';
|
||||
|
||||
return Carbon::parse("{$date} {$hour}:{$minute}:00");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function importReport(string $type, Carbon $reportedAt, array $data): void
|
||||
{
|
||||
$properties = $data['properties'] ?? [];
|
||||
$geometry = $data['geometry'] ?? [];
|
||||
|
||||
$coordinates = $geometry['coordinates'][0] ?? [];
|
||||
$centerLat = 0;
|
||||
$centerLng = 0;
|
||||
|
||||
if (! empty($coordinates)) {
|
||||
$lats = array_column($coordinates, 1);
|
||||
$lngs = array_column($coordinates, 0);
|
||||
$centerLat = array_sum($lats) / count($lats);
|
||||
$centerLng = array_sum($lngs) / count($lngs);
|
||||
}
|
||||
|
||||
$report = WeatherReport::create([
|
||||
'type' => $type,
|
||||
'reported_at' => $reportedAt,
|
||||
'generated_at' => Carbon::parse($properties['generatedAt'] ?? $properties['updateTime'] ?? $reportedAt),
|
||||
'latitude' => $centerLat,
|
||||
'longitude' => $centerLng,
|
||||
'elevation_meters' => $properties['elevation']['value'] ?? null,
|
||||
]);
|
||||
|
||||
$periods = $properties['periods'] ?? [];
|
||||
|
||||
foreach ($periods as $period) {
|
||||
WeatherPeriod::create([
|
||||
'weather_report_id' => $report->id,
|
||||
'period_number' => $period['number'],
|
||||
'name' => $period['name'] ?: null,
|
||||
'start_time' => Carbon::parse($period['startTime']),
|
||||
'end_time' => Carbon::parse($period['endTime']),
|
||||
'is_daytime' => $period['isDaytime'],
|
||||
'temperature' => $period['temperature'],
|
||||
'temperature_unit' => $period['temperatureUnit'],
|
||||
'precipitation_probability' => $period['probabilityOfPrecipitation']['value'] ?? null,
|
||||
'dewpoint_celsius' => isset($period['dewpoint']['value']) ? round($period['dewpoint']['value'], 2) : null,
|
||||
'relative_humidity' => $period['relativeHumidity']['value'] ?? null,
|
||||
'wind_speed' => $period['windSpeed'] ?? null,
|
||||
'wind_direction' => $period['windDirection'] ?? null,
|
||||
'icon_url' => $period['icon'] ?? null,
|
||||
'short_forecast' => $period['shortForecast'] ?? null,
|
||||
'detailed_forecast' => $period['detailedForecast'] ?: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
102
app/Http/Controllers/WeatherController.php
Normal file
102
app/Http/Controllers/WeatherController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\WeatherReport;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class WeatherController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$latestHourly = WeatherReport::query()
|
||||
->where('type', 'hourly')
|
||||
->with('periods')
|
||||
->orderByDesc('reported_at')
|
||||
->first();
|
||||
|
||||
$latestWeekly = WeatherReport::query()
|
||||
->where('type', 'weekly')
|
||||
->with('periods')
|
||||
->orderByDesc('reported_at')
|
||||
->first();
|
||||
|
||||
$currentPeriod = $latestHourly?->periods->first();
|
||||
|
||||
$hourlyForecast = $latestHourly?->periods
|
||||
->take(6)
|
||||
->map(fn ($period) => [
|
||||
'time' => $period->start_time->format('H:i'),
|
||||
'hour' => $period->start_time->format('H'),
|
||||
'temperature' => $period->temperature,
|
||||
'temperatureUnit' => $period->temperature_unit,
|
||||
'icon' => $this->mapIconToEmoji($period->short_forecast),
|
||||
'shortForecast' => $period->short_forecast,
|
||||
'precipitationProbability' => $period->precipitation_probability,
|
||||
]);
|
||||
|
||||
$weeklyForecast = $latestWeekly?->periods
|
||||
->filter(fn ($period) => $period->is_daytime)
|
||||
->take(7)
|
||||
->values()
|
||||
->map(fn ($period) => [
|
||||
'name' => $period->name,
|
||||
'temperature' => $period->temperature,
|
||||
'temperatureUnit' => $period->temperature_unit,
|
||||
'icon' => $this->mapIconToEmoji($period->short_forecast),
|
||||
'shortForecast' => $period->short_forecast,
|
||||
'precipitationProbability' => $period->precipitation_probability,
|
||||
'windSpeed' => $period->wind_speed,
|
||||
'windDirection' => $period->wind_direction,
|
||||
'detailedForecast' => $period->detailed_forecast,
|
||||
]);
|
||||
|
||||
return Inertia::render('Weather', [
|
||||
'current' => $currentPeriod ? [
|
||||
'temperature' => $currentPeriod->temperature,
|
||||
'temperatureUnit' => $currentPeriod->temperature_unit,
|
||||
'shortForecast' => $currentPeriod->short_forecast,
|
||||
'detailedForecast' => $currentPeriod->detailed_forecast,
|
||||
'windSpeed' => $currentPeriod->wind_speed,
|
||||
'windDirection' => $currentPeriod->wind_direction,
|
||||
'humidity' => $currentPeriod->relative_humidity,
|
||||
'dewpoint' => $currentPeriod->dewpoint_celsius,
|
||||
'precipitationProbability' => $currentPeriod->precipitation_probability,
|
||||
'isDaytime' => $currentPeriod->is_daytime,
|
||||
'icon' => $this->mapIconToEmoji($currentPeriod->short_forecast),
|
||||
] : null,
|
||||
'hourlyForecast' => $hourlyForecast ?? collect(),
|
||||
'weeklyForecast' => $weeklyForecast ?? collect(),
|
||||
'location' => [
|
||||
'latitude' => $latestHourly?->latitude,
|
||||
'longitude' => $latestHourly?->longitude,
|
||||
'name' => 'Utah County',
|
||||
],
|
||||
'reportedAt' => $latestHourly?->reported_at?->format('M d, Y H:i'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function mapIconToEmoji(?string $forecast): string
|
||||
{
|
||||
if (! $forecast) {
|
||||
return 'cloud';
|
||||
}
|
||||
|
||||
$forecast = strtolower($forecast);
|
||||
|
||||
return match (true) {
|
||||
str_contains($forecast, 'sunny') || str_contains($forecast, 'clear') => 'sun',
|
||||
str_contains($forecast, 'partly') && str_contains($forecast, 'cloud') => 'cloud-sun',
|
||||
str_contains($forecast, 'mostly cloudy') => 'cloud',
|
||||
str_contains($forecast, 'cloud') => 'cloud',
|
||||
str_contains($forecast, 'rain') && str_contains($forecast, 'snow') => 'cloud-snow',
|
||||
str_contains($forecast, 'snow') => 'cloud-snow',
|
||||
str_contains($forecast, 'rain') || str_contains($forecast, 'shower') => 'cloud-rain',
|
||||
str_contains($forecast, 'thunder') || str_contains($forecast, 'storm') => 'cloud-lightning',
|
||||
str_contains($forecast, 'fog') || str_contains($forecast, 'mist') => 'cloud-fog',
|
||||
str_contains($forecast, 'wind') => 'wind',
|
||||
default => 'cloud',
|
||||
};
|
||||
}
|
||||
}
|
||||
56
app/Models/WeatherPeriod.php
Normal file
56
app/Models/WeatherPeriod.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WeatherPeriod extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WeatherPeriodFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'weather_report_id',
|
||||
'period_number',
|
||||
'name',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'is_daytime',
|
||||
'temperature',
|
||||
'temperature_unit',
|
||||
'precipitation_probability',
|
||||
'dewpoint_celsius',
|
||||
'relative_humidity',
|
||||
'wind_speed',
|
||||
'wind_direction',
|
||||
'icon_url',
|
||||
'short_forecast',
|
||||
'detailed_forecast',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'start_time' => 'datetime',
|
||||
'end_time' => 'datetime',
|
||||
'is_daytime' => 'boolean',
|
||||
'temperature' => 'integer',
|
||||
'precipitation_probability' => 'integer',
|
||||
'dewpoint_celsius' => 'decimal:2',
|
||||
'relative_humidity' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<WeatherReport, $this>
|
||||
*/
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WeatherReport::class, 'weather_report_id');
|
||||
}
|
||||
}
|
||||
44
app/Models/WeatherReport.php
Normal file
44
app/Models/WeatherReport.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class WeatherReport extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WeatherReportFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'reported_at',
|
||||
'generated_at',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'elevation_meters',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'reported_at' => 'datetime',
|
||||
'generated_at' => 'datetime',
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
'elevation_meters' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<WeatherPeriod, $this>
|
||||
*/
|
||||
public function periods(): HasMany
|
||||
{
|
||||
return $this->hasMany(WeatherPeriod::class)->orderBy('period_number');
|
||||
}
|
||||
}
|
||||
26
app/Services/WeatherGovService.php
Normal file
26
app/Services/WeatherGovService.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class WeatherGovService
|
||||
{
|
||||
public function fetchOfficesList($weatherOffice, $gridX, $gridY): array
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function fetchHourlyReport($weatherOffice, $gridX, $gridY): array
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function fetchDailyReport($weatherOffice, $gridX, $gridY): array
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function fetchWeeklyReport($weatherOffice, $gridX, $gridY): array
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user