feat: expand station cards with detailed information and add live statistics endpoint
- Add `/stats/live` endpoint returning station count and latest price timestamp with 5-minute cache - Transform StationCard into expandable component with click/keyboard interaction showing full details - Display brand label, badges (24h/Supermarket/Motorway), fuel types, amenities, opening hours, and price delta vs average - Add brand filter dropdown to StationList with dynamic brand extraction from results - Calculate and display price comparison against filtered stations average - Redesign map markers to simpler price display; move directions link to popup alongside station details - Add "locate-me" button to SearchBar for geolocation trigger - Show "Live" indicator with station count and last-update time on homepage hero - Remove standalone directions link from marker HTML; consolidate in popup with click propagation handling - Persist `avgPence` calculation across StationList and pass to cards for delta display - Add `@iconify-json/lucide` dev dependency and register collection on app mount - Stop click propagation on card action buttons (directions, remove)
This commit is contained in:
@@ -74,7 +74,6 @@ class StationController extends Controller
|
||||
: $filtered->sortBy(match ($sort) {
|
||||
'price' => fn ($s) => (int) $s->price_pence,
|
||||
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
|
||||
'brand' => fn ($s) => strtolower((string) $s->brand_name),
|
||||
default => fn ($s) => (float) $s->distance_km,
|
||||
})->values();
|
||||
|
||||
|
||||
@@ -4,11 +4,33 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Search;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPrice;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class StatsController extends Controller
|
||||
{
|
||||
public function live(): JsonResponse
|
||||
{
|
||||
$payload = Cache::remember('api:stats:live', now()->addMinutes(5), function (): array {
|
||||
$stationCount = Station::query()
|
||||
->where('permanent_closure', false)
|
||||
->count();
|
||||
|
||||
$latestPriceAt = StationPrice::query()->max('recorded_at');
|
||||
|
||||
return [
|
||||
'station_count' => $stationCount,
|
||||
'latest_price_at' => $latestPriceAt ? CarbonImmutable::parse($latestPriceAt)->toIso8601String() : null,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
public function searches(Request $request): JsonResponse
|
||||
{
|
||||
$period = $request->input('period', 'week');
|
||||
|
||||
@@ -20,12 +20,16 @@ class StationResource extends JsonResource
|
||||
'name' => $this->trading_name,
|
||||
'brand' => $this->brand_name,
|
||||
'is_supermarket' => (bool) $this->is_supermarket,
|
||||
'is_motorway' => (bool) $this->is_motorway_service_station,
|
||||
'address' => implode(', ', array_filter([$this->address_line_1, $this->city])),
|
||||
'postcode' => $this->postcode,
|
||||
'lat' => (float) $this->lat,
|
||||
'lng' => (float) $this->lng,
|
||||
'distance_km' => round((float) $this->distance_km, 2),
|
||||
'fuel_type' => $this->fuel_type,
|
||||
'fuel_types_available' => $this->fuel_types ?? [],
|
||||
'amenities' => $this->amenities ?? [],
|
||||
'open_today' => $this->openTodayPayload(),
|
||||
'price_pence' => (int) $this->price_pence,
|
||||
'price' => round((int) $this->price_pence / 100, 2),
|
||||
'price_updated_at' => $this->price_effective_at
|
||||
@@ -37,4 +41,50 @@ class StationResource extends JsonResource
|
||||
'reliability_label' => $reliability->label(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{is_24_hours: bool, open: ?string, close: ?string, is_open_now: bool}|null
|
||||
*/
|
||||
private function openTodayPayload(): ?array
|
||||
{
|
||||
$times = $this->opening_times;
|
||||
|
||||
if (! is_array($times) || empty($times['usual_days'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = Carbon::now('Europe/London');
|
||||
$dayKey = strtolower($now->format('l'));
|
||||
$today = $times['usual_days'][$dayKey] ?? null;
|
||||
|
||||
if (! is_array($today)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$is24 = (bool) ($today['is_24_hours'] ?? false);
|
||||
$open = $today['open'] ?? null;
|
||||
$close = $today['close'] ?? null;
|
||||
|
||||
return [
|
||||
'is_24_hours' => $is24,
|
||||
'open' => $open ? substr($open, 0, 5) : null,
|
||||
'close' => $close ? substr($close, 0, 5) : null,
|
||||
'is_open_now' => $this->computeIsOpenNow($is24, $open, $close, $now),
|
||||
];
|
||||
}
|
||||
|
||||
private function computeIsOpenNow(bool $is24, ?string $open, ?string $close, Carbon $now): bool
|
||||
{
|
||||
if ($is24) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $open || ! $close) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$current = $now->format('H:i:s');
|
||||
|
||||
return $current >= $open && $current < $close;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user