Compare commits

...

9 Commits

10 changed files with 280 additions and 34 deletions

1
.gitignore vendored
View File

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

2
ai/.gitignore vendored Normal file
View File

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

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

@@ -53,10 +53,7 @@ class WeatherController extends Controller
'detailedForecast' => $period->detailed_forecast,
]);
$background = $this->getBackgroundForForecast(
$currentPeriod?->short_forecast,
$currentPeriod?->is_daytime ?? true
);
$background = $this->getBackgroundForForecast($currentPeriod?->short_forecast);
return Inertia::render('Weather', [
'current' => $currentPeriod ? [
@@ -87,10 +84,10 @@ class WeatherController extends Controller
/**
* @return array{imageUrl: string|null, licenseHtml: string|null}
*/
private function getBackgroundForForecast(?string $forecast, bool $isDaytime): array
private function getBackgroundForForecast(?string $forecast): array
{
$folder = $this->mapForecastToFolder($forecast);
$timeOfDay = $isDaytime ? 'day' : 'night';
$timeOfDay = $this->isCurrentlyDaytime() ? 'day' : 'night';
$basePath = storage_path('app/public/backgrounds/'.$folder);
if (! File::isDirectory($basePath)) {
@@ -153,6 +150,16 @@ class WeatherController extends Controller
};
}
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) {

View File

@@ -2,25 +2,48 @@
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class WeatherGovService
{
public function fetchOfficesList($weatherOffice, $gridX, $gridY): array
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 fetchHourlyReport($weatherOffice, $gridX, $gridY): array
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 fetchDailyReport($weatherOffice, $gridX, $gridY): array
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 fetchWeeklyReport($weatherOffice, $gridX, $gridY): array
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);
});
}
}

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

@@ -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

@@ -44,3 +44,16 @@
.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

@@ -88,6 +88,53 @@ function parseWindSpeed(windSpeed: string | null): number {
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>
@@ -108,9 +155,9 @@ function parseWindSpeed(windSpeed: string | null): number {
<!-- Main content -->
<div class="relative z-10 mx-auto min-h-screen max-w-6xl p-6 lg:p-8">
<!-- Header -->
<header class="mb-8 flex items-center justify-between">
<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" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
@@ -126,26 +173,26 @@ function parseWindSpeed(windSpeed: string | null): number {
<div class="currentForecast">
<div class="forecast flex flex-col justify-center">
<div class="shortDescription">
<h1 class="text-4xl font-light tracking-wide text-white md:text-5xl">
<h1 class="text-shadow-lg text-4xl font-light tracking-wide text-white md:text-5xl">
{{ current.shortForecast }}
</h1>
<p class="mt-2 text-lg text-white/60">
<p class="text-shadow mt-2 text-lg text-white/70">
{{ current.detailedForecast || 'Current conditions' }}
</p>
</div>
<div class="currentTemp mt-8">
<div class="flex items-start">
<span class="text-8xl font-thin tracking-tighter text-white md:text-9xl">
<span class="text-shadow-lg text-8xl font-thin tracking-tighter text-white md:text-9xl">
{{ current.temperature }}
</span>
<span class="mt-4 text-4xl font-thin text-white/60">
<span class="text-shadow-lg mt-4 text-4xl font-thin text-white/70">
°{{ current.temperatureUnit }}
</span>
</div>
<div class="mt-4 flex items-center gap-4 text-white/60">
<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" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
@@ -216,19 +263,28 @@ function parseWindSpeed(windSpeed: string | null): number {
stroke-width="2"
stroke-dasharray="4 4"
/>
<!-- Sun position indicator -->
<!-- Sun position indicator (daytime) -->
<circle
:cx="current.isDaytime ? 100 : 30"
:cy="current.isDaytime ? 20 : 60"
v-if="!sunPosition.isNight"
:cx="sunPosition.cx"
:cy="sunPosition.cy"
r="8"
fill="rgb(250, 204, 21)"
class="drop-shadow-lg"
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>6:45 AM</span>
<span>5:30 PM</span>
<span>{{ formattedSunrise }}</span>
<span>{{ formattedSunset }}</span>
</div>
</div>
</div>

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