Compare commits

...

14 Commits

21 changed files with 1320 additions and 77 deletions

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@ yarn-error.log
.session_id
# Directories
/.claude
/.phpunit.cache
/bootstrap/ssr
/node_modules

View File

86
Claude.Dockerfile Normal file
View File

@@ -0,0 +1,86 @@
FROM alpine:latest
# Install system packages including SSH client and dependencies
RUN apk add --no-cache \
bash \
ca-certificates \
curl \
git \
mariadb-client \
openssh-client \
openssh-client-default \
openssl \
php84 \
php84-bcmath \
php84-bz2 \
php84-curl \
php84-dom \
php84-exif \
php84-fileinfo \
php84-gd \
php84-gettext \
php84-gmp \
php84-iconv \
php84-imap \
php84-intl \
php84-mbstring \
php84-mysqlnd \
php84-pdo \
php84-pdo_mysql \
php84-pdo_pgsql \
php84-pdo_sqlite \
php84-pecl-apcu \
php84-pecl-igbinary \
php84-pecl-grpc \
php84-pecl-lzf \
php84-pecl-maxminddb \
php84-pecl-msgpack \
php84-pecl-redis \
php84-pgsql \
php84-phar \
php84-posix \
php84-session \
php84-simplexml \
php84-soap \
php84-sockets \
php84-sodium \
php84-sqlite3 \
php84-tokenizer \
php84-xml \
php84-xmlreader \
php84-xmlwriter \
php84-zip \
sqlite \
tar \
xz \
nodejs \
npm
# Install Composer (essential for Laravel)
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Install Claude Code globally
RUN npm install -g @anthropic-ai/claude-code
# Create directories for persistent data
RUN mkdir -p /root/.config/claude-code \
&& mkdir -p /root/.ssh \
&& mkdir -p /app
# Set proper permissions for SSH
RUN chmod 700 /root/.ssh
# Set environment variables
ENV HOME=/root
ENV USER=root
# Set working directory
WORKDIR /app
# Set bash as default shell
SHELL ["/bin/bash", "-c"]
#USER 1000:1000
# Default command
CMD ["claude"]

2
ai/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View 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,
]);
}
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\WeatherReport;
use Illuminate\Http\JsonResponse;
class WeatherController extends Controller
{
public function index(string $period = 'now'): JsonResponse
{
$period = in_array($period, ['now', 'daily', 'weekly']) ? $period : 'now';
return match ($period) {
'now' => $this->getCurrentConditions(),
'daily' => $this->getDailyForecast(),
'weekly' => $this->getWeeklyForecast(),
};
}
private function getCurrentConditions(): JsonResponse
{
$latestHourly = WeatherReport::query()
->where('type', 'hourly')
->with('periods')
->orderByDesc('reported_at')
->first();
$currentPeriod = $latestHourly?->periods->first();
if (! $currentPeriod) {
return response()->json(['error' => 'No current conditions available'], 404);
}
return response()->json([
'period' => 'now',
'reportedAt' => $latestHourly->reported_at->toIso8601String(),
'location' => [
'latitude' => $latestHourly->latitude,
'longitude' => $latestHourly->longitude,
],
'conditions' => [
'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,
'dewpointCelsius' => $currentPeriod->dewpoint_celsius,
'precipitationProbability' => $currentPeriod->precipitation_probability,
'isDaytime' => $currentPeriod->is_daytime,
'startTime' => $currentPeriod->start_time->toIso8601String(),
'endTime' => $currentPeriod->end_time->toIso8601String(),
],
]);
}
private function getDailyForecast(): JsonResponse
{
$latestHourly = WeatherReport::query()
->where('type', 'hourly')
->with('periods')
->orderByDesc('reported_at')
->first();
if (! $latestHourly || $latestHourly->periods->isEmpty()) {
return response()->json(['error' => 'No daily forecast available'], 404);
}
$periods = $latestHourly->periods->map(fn ($period) => [
'periodNumber' => $period->period_number,
'startTime' => $period->start_time->toIso8601String(),
'endTime' => $period->end_time->toIso8601String(),
'isDaytime' => $period->is_daytime,
'temperature' => $period->temperature,
'temperatureUnit' => $period->temperature_unit,
'shortForecast' => $period->short_forecast,
'windSpeed' => $period->wind_speed,
'windDirection' => $period->wind_direction,
'humidity' => $period->relative_humidity,
'precipitationProbability' => $period->precipitation_probability,
]);
return response()->json([
'period' => 'daily',
'reportedAt' => $latestHourly->reported_at->toIso8601String(),
'location' => [
'latitude' => $latestHourly->latitude,
'longitude' => $latestHourly->longitude,
],
'periods' => $periods,
]);
}
private function getWeeklyForecast(): JsonResponse
{
$latestWeekly = WeatherReport::query()
->where('type', 'weekly')
->with('periods')
->orderByDesc('reported_at')
->first();
if (! $latestWeekly || $latestWeekly->periods->isEmpty()) {
return response()->json(['error' => 'No weekly forecast available'], 404);
}
$periods = $latestWeekly->periods->map(fn ($period) => [
'periodNumber' => $period->period_number,
'name' => $period->name,
'startTime' => $period->start_time->toIso8601String(),
'endTime' => $period->end_time->toIso8601String(),
'isDaytime' => $period->is_daytime,
'temperature' => $period->temperature,
'temperatureUnit' => $period->temperature_unit,
'shortForecast' => $period->short_forecast,
'detailedForecast' => $period->detailed_forecast,
'windSpeed' => $period->wind_speed,
'windDirection' => $period->wind_direction,
'precipitationProbability' => $period->precipitation_probability,
]);
return response()->json([
'period' => 'weekly',
'reportedAt' => $latestWeekly->reported_at->toIso8601String(),
'location' => [
'latitude' => $latestWeekly->latitude,
'longitude' => $latestWeekly->longitude,
],
'periods' => $periods,
]);
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Http\Controllers;
use App\Models\WeatherReport;
use Illuminate\Support\Facades\File;
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,
]);
$background = $this->getBackgroundForForecast($currentPeriod?->short_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'),
'background' => $background,
]);
}
/**
* @return array{imageUrl: string|null, licenseHtml: string|null}
*/
private function getBackgroundForForecast(?string $forecast): array
{
$folder = $this->mapForecastToFolder($forecast);
$timeOfDay = $this->isCurrentlyDaytime() ? 'day' : 'night';
$basePath = storage_path('app/public/backgrounds/'.$folder);
if (! File::isDirectory($basePath)) {
return ['imageUrl' => null, 'licenseHtml' => null];
}
$files = File::files($basePath);
$pattern = '/^'.$timeOfDay.'_[a-zA-Z0-9_-]+\.jpg$/';
$imageFiles = collect($files)
->filter(fn ($file) => preg_match($pattern, $file->getFilename()))
->values();
if ($imageFiles->isEmpty()) {
$fallbackPattern = '/^(day|night)_[a-zA-Z0-9_-]+\.jpg$/';
$imageFiles = collect($files)
->filter(fn ($file) => preg_match($fallbackPattern, $file->getFilename()))
->values();
}
if ($imageFiles->isEmpty()) {
return ['imageUrl' => null, 'licenseHtml' => null];
}
$selectedImage = $imageFiles->random();
$imageFilename = $selectedImage->getFilename();
$imageUrl = '/storage/backgrounds/'.$folder.'/'.$imageFilename;
$licenseFilename = preg_replace('/\.jpg$/', '_license.html', $imageFilename);
$licensePath = $basePath.'/'.$licenseFilename;
$licenseHtml = null;
if (File::exists($licensePath)) {
$licenseHtml = trim(File::get($licensePath));
}
return [
'imageUrl' => $imageUrl,
'licenseHtml' => $licenseHtml,
];
}
private function mapForecastToFolder(?string $forecast): string
{
if (! $forecast) {
return 'cloudy';
}
$forecast = strtolower($forecast);
return match (true) {
str_contains($forecast, 'thunder') || str_contains($forecast, 'storm') => 'stormy',
str_contains($forecast, 'snow') => 'snowy',
str_contains($forecast, 'rain') || str_contains($forecast, 'shower') || str_contains($forecast, 'drizzle') => 'rainy',
str_contains($forecast, 'wind') => 'windy',
str_contains($forecast, 'sunny') || str_contains($forecast, 'clear') => 'clear',
str_contains($forecast, 'cloud') || str_contains($forecast, 'overcast') => 'cloudy',
str_contains($forecast, 'fog') || str_contains($forecast, 'mist') => 'cloudy',
default => 'cloudy',
};
}
private function isCurrentlyDaytime(): bool
{
$sunriseHour = 6.75; // 6:45 AM
$sunsetHour = 17.5; // 5:30 PM
$currentHour = now()->hour + (now()->minute / 60);
return $currentHour >= $sunriseHour && $currentHour < $sunsetHour;
}
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',
};
}
}

View 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');
}
}

View 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');
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class WeatherGovService
{
private string $apiBaseUrl = "https://api.weather.gov";
private function fetchFromRemoteApi(string $url): ?array
{
$response = Http::retry(3, 60000, throw: false)->acceptJson()->get($url);
return $response->json();
}
public function fetchOffice($latitude, $longitude): array
{
$apiUrl = "{$this->apiBaseUrl}/points/{$latitude}/{$longitude}";
return Cache::remember('lookup.offices', 86400, function () use ($apiUrl) {
return $this->fetchFromRemoteApi($apiUrl);
});
}
public function fetchHourlyReport(string $weatherOffice, int $gridX, int $gridY): array
{
$apiUrl = "{$this->apiBaseUrl}/gridpoints/{$weatherOffice}/{$gridX}/{$gridY}/forecast/hourly";
return Cache::remember('lookup.offices', 900, function () use ($apiUrl) {
return $this->fetchFromRemoteApi($apiUrl);
});
}
public function fetchDailyReport(string $weatherOffice, int $gridX, int $gridY): array
{
$apiUrl = "{$this->apiBaseUrl}/gridpoints/{$weatherOffice}/{$gridX}/{$gridY}/";
return Cache::remember('lookup.offices', 3600, function () use ($apiUrl) {
return $this->fetchFromRemoteApi($apiUrl);
});
}
public function fetchWeeklyReport(string $weatherOffice, int $gridX, int $gridY): array
{
$apiUrl = "{$this->apiBaseUrl}/gridpoints/{$weatherOffice}/{$gridX}/{$gridY}/forecast";
return Cache::remember('lookup.offices', 86400, function () use ($apiUrl) {
return $this->fetchFromRemoteApi($apiUrl);
});
}
}

3
bin/rebuild-claude-docker.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/bash
docker build --tag claude-code-php84:latest --file Claude.Dockerfile .

6
bin/run-claude-docker.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/bash
docker run -it --rm \
-v /code/php/sites/weather:/app \
--name claude-dev-weather \
claude-code-php84

View File

@@ -9,6 +9,7 @@ use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
@@ -17,6 +18,14 @@ return Application::configure(basePath: dirname(__DIR__))
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
// Ensure API routes are stateless (no sessions/cookies)
$middleware->api(remove: [
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@@ -0,0 +1,58 @@
<?php
namespace Database\Factories;
use App\Models\WeatherReport;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WeatherPeriod>
*/
class WeatherPeriodFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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']),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WeatherReport>
*/
class WeatherReportFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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',
]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('weather_reports', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -13,11 +13,6 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
//
}
}

View File

@@ -4,68 +4,56 @@
@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;
.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";
grid-template-rows: auto auto auto;
gap: 1.5rem;
}
.currentForecast { display: grid;
grid-template-columns: 1fr 1fr;
.currentForecast {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto auto;
gap: 2rem;
}
@media (min-width: 768px) {
.currentForecast {
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas:
"forecast secondaryInfo";
grid-area: currentForecast;
}
}
.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;
.forecast {
display: flex;
flex-direction: column;
justify-content: center;
}
.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;
.secondaryInfo {
display: flex;
flex-direction: column;
gap: 1rem;
}
.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;
.weeklyReport {
margin-top: 1rem;
}
.text-shadow {
text-shadow:
0 2px 4px rgba(0, 0, 0, 0.5),
0 4px 8px rgba(0, 0, 0, 0.3);
}
.text-shadow-lg {
text-shadow:
0 2px 8px rgba(0, 0, 0, 0.6),
0 4px 16px rgba(0, 0, 0, 0.4),
0 8px 32px rgba(0, 0, 0, 0.2);
}

View File

@@ -1,25 +1,358 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
interface CurrentWeather {
temperature: number;
temperatureUnit: string;
shortForecast: string;
detailedForecast: string | null;
windSpeed: string | null;
windDirection: string | null;
humidity: number | null;
dewpoint: number | null;
precipitationProbability: number | null;
isDaytime: boolean;
icon: string;
}
interface HourlyForecast {
time: string;
hour: string;
temperature: number;
temperatureUnit: string;
icon: string;
shortForecast: string;
precipitationProbability: number | null;
}
interface WeeklyForecast {
name: string;
temperature: number;
temperatureUnit: string;
icon: string;
shortForecast: string;
precipitationProbability: number | null;
windSpeed: string | null;
windDirection: string | null;
detailedForecast: string | null;
}
interface Location {
latitude: number | null;
longitude: number | null;
name: string;
}
interface Background {
imageUrl: string | null;
licenseHtml: string | null;
}
const props = defineProps<{
current: CurrentWeather | null;
hourlyForecast: HourlyForecast[];
weeklyForecast: WeeklyForecast[];
location: Location;
reportedAt: string | null;
background: Background;
}>();
const weatherIcons: Record<string, string> = {
sun: 'M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41M12 6a6 6 0 100 12 6 6 0 000-12z',
'cloud-sun':
'M12 2v1m0 18v1m9-10h1M2 12h1m15.5-6.5l.7.7M4.2 19.8l.7-.7m0-13.4l-.7.7m15.6 13.4l-.7-.7M17 12a5 5 0 10-9.6 1.8M6.3 18h10.4a4 4 0 000-8h-.3A5.5 5.5 0 006.3 18z',
cloud: 'M3 15h.01M6 15h.01M9 18h.01M12 15h.01M15 18h.01M18 15h.01M21 15h.01M6.34 8A6 6 0 0118 10h1a4 4 0 014 4 4 4 0 01-4 4H6a4 4 0 01-4-4 4 4 0 014-4h.34z',
'cloud-rain':
'M16 13v8m-4-6v6m-4-4v4m-4-2a4 4 0 014-4h.87a5.5 5.5 0 0110.26 0H18a4 4 0 014 4 4 4 0 01-4 4',
'cloud-snow': 'M20 17.58A5 5 0 0018 8h-1.26A8 8 0 104 16.25m4 2l.5.5m3.5-.5l.5.5m-.5 3.5l.5.5m3-.5l.5.5',
'cloud-lightning': 'M19 16.9A5 5 0 0018 7h-1.26a8 8 0 10-11.62 9m7.88-4l-3 5h4l-3 5',
'cloud-fog': 'M4 14h.01M8 14h.01M12 14h.01M16 14h.01M20 14h.01M4 18h.01M8 18h.01M12 18h.01M16 18h.01M20 18h.01M6.34 6A6 6 0 0118 8h1a4 4 0 010 8H6a4 4 0 010-8h.34z',
wind: 'M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1014 16H2m15.73-8.27A2.5 2.5 0 1119.5 12H2',
};
function getIconPath(iconName: string): string {
return weatherIcons[iconName] || weatherIcons.cloud;
}
const currentTime = computed(() => {
return new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
});
const currentDate = computed(() => {
return new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
});
function parseWindSpeed(windSpeed: string | null): number {
if (!windSpeed) return 0;
const match = windSpeed.match(/(\d+)/);
return match ? parseInt(match[1]) : 0;
}
// Sunrise/sunset times (in hours, 24h format)
const sunriseHour = 6.75; // 6:45 AM
const sunsetHour = 17.5; // 5:30 PM
const sunPosition = computed(() => {
const now = new Date();
const currentHour = now.getHours() + now.getMinutes() / 60;
// Calculate progress through daylight hours (0 = sunrise, 1 = sunset)
let t = (currentHour - sunriseHour) / (sunsetHour - sunriseHour);
// Clamp t between 0 and 1 for daytime, outside for night
const isDaytime = t >= 0 && t <= 1;
if (!isDaytime) {
// Nighttime: show moon at top of arc
return { cx: 100, cy: 10, isNight: true };
}
// Quadratic bezier curve: M 10 70 Q 100 -10 190 70
// Start point (P0): (10, 70)
// Control point (P1): (100, -10)
// End point (P2): (190, 70)
const p0 = { x: 10, y: 70 };
const p1 = { x: 100, y: -10 };
const p2 = { x: 190, y: 70 };
// Quadratic bezier formula: B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2
const oneMinusT = 1 - t;
const cx = oneMinusT * oneMinusT * p0.x + 2 * oneMinusT * t * p1.x + t * t * p2.x;
const cy = oneMinusT * oneMinusT * p0.y + 2 * oneMinusT * t * p1.y + t * t * p2.y;
return { cx: Math.round(cx), cy: Math.round(cy), isNight: false };
});
const formattedSunrise = computed(() => {
const hours = Math.floor(sunriseHour);
const minutes = Math.round((sunriseHour - hours) * 60);
return `${hours}:${minutes.toString().padStart(2, '0')} AM`;
});
const formattedSunset = computed(() => {
const hours = Math.floor(sunsetHour) - 12;
const minutes = Math.round((sunsetHour - Math.floor(sunsetHour)) * 60);
return `${hours}:${minutes.toString().padStart(2, '0')} PM`;
});
</script>
<template>
<Head title="Weather">
<Head title="Weather" />
</Head>
<div class="grid min-h-screen p-6 lg:p-8">
<div class="page-container">
<div class="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<!-- Dynamic background image based on weather -->
<div
v-if="background.imageUrl"
class="absolute inset-0 bg-cover bg-center transition-opacity duration-1000"
:style="{ backgroundImage: `url(${background.imageUrl})` }"
></div>
<!-- Fallback gradient background -->
<div v-else class="absolute inset-0 bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900"></div>
<!-- Overlay for better text readability -->
<div class="absolute inset-0 bg-gradient-to-b from-black/40 via-black/50 to-black/70"></div>
<!-- Main content -->
<div class="relative z-10 mx-auto min-h-screen max-w-6xl p-6 lg:p-8">
<!-- Header -->
<header class="text-shadow mb-8 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="h-8 w-8 text-blue-400 drop-shadow-lg" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="getIconPath('cloud-sun')" />
</svg>
<span class="text-lg font-medium text-white/80">Weather</span>
</div>
<div class="text-right text-sm text-white/60">
<div>{{ currentDate }}</div>
<div class="text-lg font-medium text-white/80">{{ currentTime }}</div>
</div>
</header>
<div v-if="current" class="page-container">
<!-- Current Forecast Section -->
<div class="currentForecast">
<div class="forecast">
<div class="shortDescription"></div>
<div class="longDescription"></div>
<div class="currentTemp"></div>
<div class="forecast flex flex-col justify-center">
<div class="shortDescription">
<h1 class="text-shadow-lg text-4xl font-light tracking-wide text-white md:text-5xl">
{{ current.shortForecast }}
</h1>
<p class="text-shadow mt-2 text-lg text-white/70">
{{ current.detailedForecast || 'Current conditions' }}
</p>
</div>
<div class="secondaryInfo">
<div class="windContainer"></div>
<div class="solarClock"></div>
<div class="currentTemp mt-8">
<div class="flex items-start">
<span class="text-shadow-lg text-8xl font-thin tracking-tighter text-white md:text-9xl">
{{ current.temperature }}
</span>
<span class="text-shadow-lg mt-4 text-4xl font-thin text-white/70">
°{{ current.temperatureUnit }}
</span>
</div>
<div class="text-shadow mt-4 flex items-center gap-4 text-white/70">
<span class="flex items-center gap-1">
<svg class="h-4 w-4 drop-shadow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ location.name }}
</span>
<span v-if="current.humidity">Humidity: {{ current.humidity }}%</span>
</div>
</div>
<div class="weeklyReport"></div>
</div>
<!-- Secondary Info -->
<div class="secondaryInfo space-y-4">
<!-- Wind Card -->
<div class="windContainer rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-lg">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2 text-white/60">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="getIconPath('wind')" />
</svg>
<span>Wind Status</span>
</div>
<span class="text-xl font-semibold text-white">{{ current.windSpeed || 'N/A' }}</span>
</div>
<!-- Wind visualization -->
<div class="flex h-16 items-end justify-between gap-1">
<div
v-for="i in 20"
:key="i"
class="flex-1 rounded-t bg-gradient-to-t from-blue-500/40 to-blue-400/60"
:style="{ height: `${20 + Math.sin(i * 0.5) * 15 + Math.random() * 30}%` }"
></div>
</div>
<div class="mt-2 text-center text-sm text-white/40">
Direction: {{ current.windDirection || 'N/A' }}
</div>
</div>
<!-- Sunrise/Sunset Arc -->
<div class="solarClock rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-lg">
<div class="mb-4 flex items-center justify-between text-sm text-white/60">
<div class="flex items-center gap-1">
<svg class="h-4 w-4 text-orange-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L13.09 8.26L18 6L14.74 10.91L21 12L14.74 13.09L18 18L13.09 15.74L12 22L10.91 15.74L6 18L9.26 13.09L3 12L9.26 10.91L6 6L10.91 8.26L12 2Z" />
</svg>
<span>Sunrise</span>
</div>
<div class="flex items-center gap-1">
<span>Sunset</span>
<svg class="h-4 w-4 text-orange-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L13.09 8.26L18 6L14.74 10.91L21 12L14.74 13.09L18 18L13.09 15.74L12 22L10.91 15.74L6 18L9.26 13.09L3 12L9.26 10.91L6 6L10.91 8.26L12 2Z" />
</svg>
</div>
</div>
<!-- Sun arc -->
<div class="relative h-20">
<svg class="h-full w-full" viewBox="0 0 200 80">
<defs>
<linearGradient id="arcGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="rgb(251, 146, 60)" stop-opacity="0.3" />
<stop offset="50%" stop-color="rgb(250, 204, 21)" stop-opacity="0.6" />
<stop offset="100%" stop-color="rgb(251, 146, 60)" stop-opacity="0.3" />
</linearGradient>
</defs>
<path
d="M 10 70 Q 100 -10 190 70"
fill="none"
stroke="url(#arcGradient)"
stroke-width="2"
stroke-dasharray="4 4"
/>
<!-- Sun position indicator (daytime) -->
<circle
v-if="!sunPosition.isNight"
:cx="sunPosition.cx"
:cy="sunPosition.cy"
r="8"
fill="rgb(250, 204, 21)"
class="drop-shadow-lg transition-all duration-1000"
/>
<!-- Moon indicator (nighttime) -->
<g v-else :transform="`translate(${sunPosition.cx - 8}, ${sunPosition.cy - 8})`" class="transition-all duration-1000">
<path
d="M8 0C3.6 0 0 3.6 0 8s3.6 8 8 8c1.1 0 2.1-.2 3-.6-1.8-1.1-3-3.1-3-5.4s1.2-4.3 3-5.4C10.1.2 9.1 0 8 0z"
fill="rgb(226, 232, 240)"
class="drop-shadow-lg"
/>
</g>
</svg>
</div>
<div class="flex justify-between text-xs text-white/40">
<span>{{ formattedSunrise }}</span>
<span>{{ formattedSunset }}</span>
</div>
</div>
</div>
</div>
<!-- Hourly Forecast Strip -->
<div v-if="hourlyForecast.length > 0" class="my-6 rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur-lg">
<h3 class="mb-4 text-sm font-medium text-white/60">Hourly Forecast</h3>
<div class="flex justify-between gap-4 overflow-x-auto">
<div
v-for="(hour, index) in hourlyForecast"
:key="index"
class="flex min-w-16 flex-col items-center gap-2"
:class="{ 'rounded-xl bg-white/10 px-3 py-2': index === 0 }"
>
<span class="text-xs text-white/60">{{ index === 0 ? 'Now' : hour.hour }}</span>
<svg class="h-6 w-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" :d="getIconPath(hour.icon)" />
</svg>
<span class="text-sm font-medium text-white">{{ hour.temperature }}°</span>
</div>
</div>
</div>
<!-- Weekly Forecast -->
<div v-if="weeklyForecast.length > 0" class="weeklyReport">
<h3 class="col-span-full mb-4 text-sm font-medium text-white/60">7-Day Forecast</h3>
<div class="col-span-full grid grid-cols-7 gap-2">
<div
v-for="(day, index) in weeklyForecast"
:key="index"
class="flex flex-col items-center rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur-lg transition-all hover:bg-white/10"
>
<span class="text-sm font-medium text-white/80">{{ day.name.substring(0, 3) }}</span>
<svg class="my-3 h-8 w-8 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" :d="getIconPath(day.icon)" />
</svg>
<span class="text-xl font-semibold text-white">{{ day.temperature }}°</span>
<span v-if="day.precipitationProbability" class="mt-1 text-xs text-blue-400">
{{ day.precipitationProbability }}%
</span>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex min-h-96 flex-col items-center justify-center text-center">
<svg class="mb-4 h-16 w-16 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" :d="getIconPath('cloud')" />
</svg>
<h2 class="text-xl font-medium text-white/60">No Weather Data Available</h2>
<p class="mt-2 text-white/40">Run the ingestion command to load weather data:</p>
<code class="mt-2 rounded bg-white/10 px-3 py-1 text-sm text-white/60">php artisan weather:ingest</code>
</div>
<!-- Footer -->
<footer v-if="reportedAt" class="mt-8 text-center text-xs text-white/30">
Last updated: {{ reportedAt }}
</footer>
</div>
<!-- Image attribution (bottom right corner) -->
<div
v-if="background.licenseHtml"
class="absolute bottom-4 right-4 z-20 max-w-xs rounded-lg bg-black/40 px-3 py-2 text-xs text-white/60 backdrop-blur-sm"
>
<span v-html="background.licenseHtml"></span>
</div>
</div>
</template>

6
routes/api.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
use App\Http\Controllers\Api\WeatherController;
use Illuminate\Support\Facades\Route;
Route::get('/{period?}', [WeatherController::class, 'index'])->name('api.weather');

View File

@@ -1,8 +1,6 @@
<?php
use App\Http\Controllers\WeatherController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () {
return Inertia::render('Weather');
})->name('home');
Route::get('/', [WeatherController::class, 'index'])->name('home');