From a086d749d14b6b9ab7da450973c37ec1ca51f634 Mon Sep 17 00:00:00 2001 From: Brian Rogers Date: Fri, 9 Jan 2026 15:01:27 -0700 Subject: [PATCH] initial round of changes from Claude --- app/Console/Commands/IngestWeatherData.php | 188 ++++++++++++ app/Http/Controllers/WeatherController.php | 102 +++++++ app/Models/WeatherPeriod.php | 56 ++++ app/Models/WeatherReport.php | 44 +++ app/Services/WeatherGovService.php | 26 ++ database/factories/WeatherPeriodFactory.php | 58 ++++ database/factories/WeatherReportFactory.php | 42 +++ ...09_213530_create_weather_reports_table.php | 60 ++++ resources/css/app.css | 83 ++---- resources/js/pages/Weather.vue | 282 +++++++++++++++++- routes/web.php | 6 +- 11 files changed, 876 insertions(+), 71 deletions(-) create mode 100644 app/Console/Commands/IngestWeatherData.php create mode 100644 app/Http/Controllers/WeatherController.php create mode 100644 app/Models/WeatherPeriod.php create mode 100644 app/Models/WeatherReport.php create mode 100644 app/Services/WeatherGovService.php create mode 100644 database/factories/WeatherPeriodFactory.php create mode 100644 database/factories/WeatherReportFactory.php create mode 100644 database/migrations/2026_01_09_213530_create_weather_reports_table.php diff --git a/app/Console/Commands/IngestWeatherData.php b/app/Console/Commands/IngestWeatherData.php new file mode 100644 index 0000000..8f99f78 --- /dev/null +++ b/app/Console/Commands/IngestWeatherData.php @@ -0,0 +1,188 @@ +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 $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, + ]); + } + } +} diff --git a/app/Http/Controllers/WeatherController.php b/app/Http/Controllers/WeatherController.php new file mode 100644 index 0000000..72f21bd --- /dev/null +++ b/app/Http/Controllers/WeatherController.php @@ -0,0 +1,102 @@ +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', + }; + } +} diff --git a/app/Models/WeatherPeriod.php b/app/Models/WeatherPeriod.php new file mode 100644 index 0000000..b091b17 --- /dev/null +++ b/app/Models/WeatherPeriod.php @@ -0,0 +1,56 @@ + */ + 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 + */ + 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 + */ + public function report(): BelongsTo + { + return $this->belongsTo(WeatherReport::class, 'weather_report_id'); + } +} diff --git a/app/Models/WeatherReport.php b/app/Models/WeatherReport.php new file mode 100644 index 0000000..083e847 --- /dev/null +++ b/app/Models/WeatherReport.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + protected $fillable = [ + 'type', + 'reported_at', + 'generated_at', + 'latitude', + 'longitude', + 'elevation_meters', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'reported_at' => 'datetime', + 'generated_at' => 'datetime', + 'latitude' => 'decimal:7', + 'longitude' => 'decimal:7', + 'elevation_meters' => 'decimal:4', + ]; + } + + /** + * @return HasMany + */ + public function periods(): HasMany + { + return $this->hasMany(WeatherPeriod::class)->orderBy('period_number'); + } +} diff --git a/app/Services/WeatherGovService.php b/app/Services/WeatherGovService.php new file mode 100644 index 0000000..261200f --- /dev/null +++ b/app/Services/WeatherGovService.php @@ -0,0 +1,26 @@ + + */ +class WeatherPeriodFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $isDaytime = fake()->boolean(); + $startTime = fake()->dateTimeBetween('-1 day', '+7 days'); + + return [ + 'weather_report_id' => WeatherReport::factory(), + 'period_number' => fake()->numberBetween(1, 14), + 'name' => $isDaytime ? fake()->randomElement(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']) : fake()->randomElement(['Monday Night', 'Tuesday Night', 'Overnight']), + 'start_time' => $startTime, + 'end_time' => (clone $startTime)->modify('+6 hours'), + 'is_daytime' => $isDaytime, + 'temperature' => fake()->numberBetween(20, 90), + 'temperature_unit' => 'F', + 'precipitation_probability' => fake()->numberBetween(0, 100), + 'dewpoint_celsius' => fake()->randomFloat(2, -10, 20), + 'relative_humidity' => fake()->numberBetween(20, 100), + 'wind_speed' => fake()->numberBetween(1, 20).' mph', + 'wind_direction' => fake()->randomElement(['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW', 'SSE', 'SSW', 'NNE', 'NNW']), + 'icon_url' => 'https://api.weather.gov/icons/land/day/sct?size=medium', + 'short_forecast' => fake()->randomElement(['Sunny', 'Partly Cloudy', 'Mostly Cloudy', 'Light Rain', 'Cloudy', 'Chance Rain']), + 'detailed_forecast' => fake()->optional()->sentence(15), + ]; + } + + public function daytime(): static + { + return $this->state(fn (array $attributes) => [ + 'is_daytime' => true, + 'name' => fake()->randomElement(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']), + ]); + } + + public function nighttime(): static + { + return $this->state(fn (array $attributes) => [ + 'is_daytime' => false, + 'name' => fake()->randomElement(['Monday Night', 'Tuesday Night', 'Overnight']), + ]); + } +} diff --git a/database/factories/WeatherReportFactory.php b/database/factories/WeatherReportFactory.php new file mode 100644 index 0000000..049e215 --- /dev/null +++ b/database/factories/WeatherReportFactory.php @@ -0,0 +1,42 @@ + + */ +class WeatherReportFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'type' => fake()->randomElement(['hourly', 'weekly']), + 'reported_at' => fake()->dateTimeBetween('-7 days', 'now'), + 'generated_at' => fake()->dateTimeBetween('-7 days', 'now'), + 'latitude' => fake()->latitude(39, 42), + 'longitude' => fake()->longitude(-112, -110), + 'elevation_meters' => fake()->randomFloat(4, 1000, 2000), + ]; + } + + public function hourly(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'hourly', + ]); + } + + public function weekly(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'weekly', + ]); + } +} diff --git a/database/migrations/2026_01_09_213530_create_weather_reports_table.php b/database/migrations/2026_01_09_213530_create_weather_reports_table.php new file mode 100644 index 0000000..e715cc6 --- /dev/null +++ b/database/migrations/2026_01_09_213530_create_weather_reports_table.php @@ -0,0 +1,60 @@ +id(); + $table->enum('type', ['hourly', 'weekly']); + $table->dateTime('reported_at'); + $table->dateTime('generated_at'); + $table->decimal('latitude', 10, 7); + $table->decimal('longitude', 11, 7); + $table->decimal('elevation_meters', 8, 4)->nullable(); + $table->timestamps(); + + $table->index(['type', 'reported_at']); + $table->unique(['type', 'reported_at']); + }); + + Schema::create('weather_periods', function (Blueprint $table) { + $table->id(); + $table->foreignId('weather_report_id')->constrained()->cascadeOnDelete(); + $table->unsignedSmallInteger('period_number'); + $table->string('name')->nullable(); + $table->dateTime('start_time'); + $table->dateTime('end_time'); + $table->boolean('is_daytime'); + $table->smallInteger('temperature'); + $table->char('temperature_unit', 1)->default('F'); + $table->unsignedTinyInteger('precipitation_probability')->nullable(); + $table->decimal('dewpoint_celsius', 5, 2)->nullable(); + $table->unsignedTinyInteger('relative_humidity')->nullable(); + $table->string('wind_speed', 20)->nullable(); + $table->string('wind_direction', 10)->nullable(); + $table->string('icon_url')->nullable(); + $table->string('short_forecast')->nullable(); + $table->text('detailed_forecast')->nullable(); + $table->timestamps(); + + $table->index(['weather_report_id', 'start_time']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('weather_periods'); + Schema::dropIfExists('weather_reports'); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 9198d64..fc26557 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4,68 +4,43 @@ @source '../../storage/framework/views/*.php'; @theme inline { - --font-sans: Instrument Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-sans: Instrument Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; } -.page-container { display: grid; - grid-template-columns: 1fr; - grid-template-rows: 1.4fr 0.6fr; - gap: 2em 0em; - grid-auto-flow: row; - grid-template-areas: - "currentForecast" - "weeklyReport"; +.page-container { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + gap: 1.5rem; } -.currentForecast { display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: 1fr; - gap: 0px 0px; - grid-auto-flow: row; - grid-template-areas: - "forecast secondaryInfo"; - grid-area: currentForecast; +.currentForecast { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto auto; + gap: 2rem; } -.forecast { display: grid; - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr 1fr; - gap: 0px 0px; - grid-auto-flow: row; - grid-template-areas: - "shortDescription" - "longDescription" - "currentTemp"; - grid-area: forecast; +@media (min-width: 768px) { + .currentForecast { + grid-template-columns: 1.2fr 1fr; + grid-template-rows: 1fr; + } } -.shortDescription { grid-area: shortDescription; } - -.longDescription { grid-area: longDescription; } - -.currentTemp { grid-area: currentTemp; } - -.secondaryInfo { display: grid; - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - gap: 0px 0px; - grid-auto-flow: row; - grid-template-areas: - "windContainer" - "solarClock"; - grid-area: secondaryInfo; +.forecast { + display: flex; + flex-direction: column; + justify-content: center; } -.windContainer { grid-area: windContainer; } - -.solarClock { grid-area: solarClock; } - -.weeklyReport { display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; - grid-template-rows: 1fr; - gap: 0px 0px; - grid-auto-flow: row; - grid-template-areas: - ". . . . . ."; - grid-area: weeklyReport; +.secondaryInfo { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.weeklyReport { + margin-top: 1rem; } diff --git a/resources/js/pages/Weather.vue b/resources/js/pages/Weather.vue index ad50cc2..e7e3a5d 100644 --- a/resources/js/pages/Weather.vue +++ b/resources/js/pages/Weather.vue @@ -1,25 +1,281 @@ diff --git a/routes/web.php b/routes/web.php index b9d3b1e..e1241a3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,8 +1,6 @@ name('home'); +Route::get('/', [WeatherController::class, 'index'])->name('home');