Compare commits
7 Commits
458b9afa6b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
aaca102b32
|
|||
|
e003cb2fb1
|
|||
|
c0645c1be4
|
|||
|
4616582cd1
|
|||
|
40a7c041d1
|
|||
|
3bf399eb97
|
|||
|
2de2195223
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ yarn-error.log
|
|||||||
.session_id
|
.session_id
|
||||||
|
|
||||||
# Directories
|
# Directories
|
||||||
|
/.claude
|
||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
/bootstrap/ssr
|
/bootstrap/ssr
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|||||||
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,
|
'detailedForecast' => $period->detailed_forecast,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$background = $this->getBackgroundForForecast(
|
$background = $this->getBackgroundForForecast($currentPeriod?->short_forecast);
|
||||||
$currentPeriod?->short_forecast,
|
|
||||||
$currentPeriod?->is_daytime ?? true
|
|
||||||
);
|
|
||||||
|
|
||||||
return Inertia::render('Weather', [
|
return Inertia::render('Weather', [
|
||||||
'current' => $currentPeriod ? [
|
'current' => $currentPeriod ? [
|
||||||
@@ -87,10 +84,10 @@ class WeatherController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @return array{imageUrl: string|null, licenseHtml: string|null}
|
* @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);
|
$folder = $this->mapForecastToFolder($forecast);
|
||||||
$timeOfDay = $isDaytime ? 'day' : 'night';
|
$timeOfDay = $this->isCurrentlyDaytime() ? 'day' : 'night';
|
||||||
$basePath = storage_path('app/public/backgrounds/'.$folder);
|
$basePath = storage_path('app/public/backgrounds/'.$folder);
|
||||||
|
|
||||||
if (! File::isDirectory($basePath)) {
|
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
|
private function mapIconToEmoji(?string $forecast): string
|
||||||
{
|
{
|
||||||
if (! $forecast) {
|
if (! $forecast) {
|
||||||
|
|||||||
@@ -2,25 +2,48 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
class WeatherGovService
|
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__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
@@ -17,6 +18,14 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
AddLinkHeadersForPreloadedAssets::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 {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ class DatabaseSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
//
|
||||||
|
|
||||||
User::factory()->create([
|
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'test@example.com',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,53 @@ function parseWindSpeed(windSpeed: string | null): number {
|
|||||||
const match = windSpeed.match(/(\d+)/);
|
const match = windSpeed.match(/(\d+)/);
|
||||||
return match ? parseInt(match[1]) : 0;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -216,19 +263,28 @@ function parseWindSpeed(windSpeed: string | null): number {
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-dasharray="4 4"
|
stroke-dasharray="4 4"
|
||||||
/>
|
/>
|
||||||
<!-- Sun position indicator -->
|
<!-- Sun position indicator (daytime) -->
|
||||||
<circle
|
<circle
|
||||||
:cx="current.isDaytime ? 100 : 30"
|
v-if="!sunPosition.isNight"
|
||||||
:cy="current.isDaytime ? 20 : 60"
|
:cx="sunPosition.cx"
|
||||||
|
:cy="sunPosition.cy"
|
||||||
r="8"
|
r="8"
|
||||||
fill="rgb(250, 204, 21)"
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-xs text-white/40">
|
<div class="flex justify-between text-xs text-white/40">
|
||||||
<span>6:45 AM</span>
|
<span>{{ formattedSunrise }}</span>
|
||||||
<span>5:30 PM</span>
|
<span>{{ formattedSunset }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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