Compare commits
9 Commits
b1aa29e877
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
aaca102b32
|
|||
|
e003cb2fb1
|
|||
|
c0645c1be4
|
|||
|
4616582cd1
|
|||
|
40a7c041d1
|
|||
|
3bf399eb97
|
|||
|
2de2195223
|
|||
|
458b9afa6b
|
|||
|
442529261b
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ yarn-error.log
|
||||
.session_id
|
||||
|
||||
# Directories
|
||||
/.claude
|
||||
/.phpunit.cache
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
|
||||
2
ai/.gitignore
vendored
Normal file
2
ai/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
134
app/Http/Controllers/Api/WeatherController.php
Normal file
134
app/Http/Controllers/Api/WeatherController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
//
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 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
6
routes/api.php
Normal 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');
|
||||
Reference in New Issue
Block a user