feat(ui): mobile-first redesign — compact hero, inline submit button, map-first with collapsible list
- Hero: remove full-width mobile submit, add inline "Go" button next to locate
- Prediction cards: tighter mobile padding (px-3 py-3)
- Search filters: right-aligned toolbar, remove "X stations found" count and map toggle
- Map: initialize view immediately to avoid tile wiggle, skip recenter on fresh init
- Station list: hidden by default, toggled via "Stations {count}" pill above map
- Typography: hide desktop h1 on mobile, scale down section headings and spacing
- Footer: remove uppercase styling from headings and copyright line
- Filter popover: auto-close on fuel/radius/sort/brand selection
fix(llm): retry submit_overlay when events_cited is missing, extend Fuel Finder timeout with retries
- LlmOverlayService: add `minItems: 1` to events_cited schema, detect missing citations
in submit response, inject tool_result error and retry once with explicit prompt
- Log full raw_result context when no verified citations, capturing direction/confidence/reasoning
- FuelPriceService: add 3×1s retry with 60s timeout to batch price requests (was 30s no retry)
- Tests: cover successful retry recovery and rejection when retry also omits citations
This commit is contained in:
@@ -62,7 +62,13 @@ final class LlmOverlayService
|
|||||||
|
|
||||||
$verifiedEvents = $this->verifyCitedUrls($rawResult['events_cited'] ?? []);
|
$verifiedEvents = $this->verifyCitedUrls($rawResult['events_cited'] ?? []);
|
||||||
if ($verifiedEvents === []) {
|
if ($verifiedEvents === []) {
|
||||||
Log::warning('LlmOverlayService: no verified citations, rejecting overlay');
|
Log::warning('LlmOverlayService: no verified citations, rejecting overlay', [
|
||||||
|
'events_cited_count' => count($rawResult['events_cited'] ?? []),
|
||||||
|
'direction' => $rawResult['direction'] ?? null,
|
||||||
|
'confidence' => $rawResult['confidence'] ?? null,
|
||||||
|
'reasoning_short' => $rawResult['reasoning_short'] ?? null,
|
||||||
|
'raw_result' => $rawResult,
|
||||||
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -131,7 +137,10 @@ final class LlmOverlayService
|
|||||||
$messages = [['role' => 'user', 'content' => $this->prompt($context)]];
|
$messages = [['role' => 'user', 'content' => $this->prompt($context)]];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Phase 1: web search loop
|
// Phase 1: web search loop. Append the assistant turn after every
|
||||||
|
// successful response, then decide whether to keep looping —
|
||||||
|
// this guarantees the messages array stays well-formed regardless
|
||||||
|
// of whether we exit via `break` or by exhausting iterations.
|
||||||
for ($i = 0, $response = null; $i < 5; $i++) {
|
for ($i = 0, $response = null; $i < 5; $i++) {
|
||||||
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45)
|
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45)
|
||||||
->withHeaders($this->headers())
|
->withHeaders($this->headers())
|
||||||
@@ -148,14 +157,13 @@ final class LlmOverlayService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||||
|
|
||||||
if ($response->json('stop_reason') !== 'pause_turn') {
|
if ($response->json('stop_reason') !== 'pause_turn') {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
|
||||||
$messages[] = ['role' => 'user', 'content' => 'Now submit your overlay using the submit_overlay tool. Cite at least one event with a URL.'];
|
$messages[] = ['role' => 'user', 'content' => 'Now submit your overlay using the submit_overlay tool. Cite at least one event with a URL.'];
|
||||||
|
|
||||||
// Phase 2: forced structured output
|
// Phase 2: forced structured output
|
||||||
@@ -175,7 +183,18 @@ final class LlmOverlayService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->extractToolInput($submitResponse->json('content') ?? []);
|
$submitContent = $submitResponse->json('content') ?? [];
|
||||||
|
$rawResult = $this->extractToolInput($submitContent);
|
||||||
|
|
||||||
|
// Haiku sometimes calls submit_overlay without `events_cited` even
|
||||||
|
// though the schema marks it required. Confirmed in laravel.log on
|
||||||
|
// 2026-05-12: tool_use input had only direction/confidence/reasoning.
|
||||||
|
// Retry once with an explicit tool_result error.
|
||||||
|
if ($this->citationsMissing($rawResult)) {
|
||||||
|
$rawResult = $this->retrySubmitWithCitationError($messages, $submitContent) ?? $rawResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rawResult;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]);
|
Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
@@ -183,7 +202,12 @@ final class LlmOverlayService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string VERIFICATION_USER_AGENT = 'Mozilla/5.0 (compatible; FuelPriceBot/1.0; +https://fuel-price.test/bot)';
|
private function verificationUserAgent(): string
|
||||||
|
{
|
||||||
|
$appUrl = rtrim((string) config('app.url'), '/');
|
||||||
|
|
||||||
|
return "Mozilla/5.0 (compatible; FuelPriceBot/1.0; +{$appUrl}/bot)";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify each cited URL is reachable. Major news sites (Reuters, FT,
|
* Verify each cited URL is reachable. Major news sites (Reuters, FT,
|
||||||
@@ -235,7 +259,7 @@ final class LlmOverlayService
|
|||||||
/** @return array{0: bool, 1: string} [reachable, diagnostic_string] */
|
/** @return array{0: bool, 1: string} [reachable, diagnostic_string] */
|
||||||
private function urlReachable(string $url): array
|
private function urlReachable(string $url): array
|
||||||
{
|
{
|
||||||
$headers = ['User-Agent' => self::VERIFICATION_USER_AGENT];
|
$headers = ['User-Agent' => $this->verificationUserAgent()];
|
||||||
$headStatus = 'no-attempt';
|
$headStatus = 'no-attempt';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -342,6 +366,7 @@ final class LlmOverlayService
|
|||||||
'reasoning_short' => ['type' => 'string', 'description' => '1–2 sentences.'],
|
'reasoning_short' => ['type' => 'string', 'description' => '1–2 sentences.'],
|
||||||
'events_cited' => [
|
'events_cited' => [
|
||||||
'type' => 'array',
|
'type' => 'array',
|
||||||
|
'minItems' => 1,
|
||||||
'items' => [
|
'items' => [
|
||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
'properties' => [
|
'properties' => [
|
||||||
@@ -371,4 +396,57 @@ final class LlmOverlayService
|
|||||||
|
|
||||||
return $block['input'] ?? null;
|
return $block['input'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed>|null $rawResult */
|
||||||
|
private function citationsMissing(?array $rawResult): bool
|
||||||
|
{
|
||||||
|
return $rawResult === null
|
||||||
|
|| ! isset($rawResult['events_cited'])
|
||||||
|
|| ! is_array($rawResult['events_cited'])
|
||||||
|
|| $rawResult['events_cited'] === [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $messages
|
||||||
|
* @param array<int, mixed> $failedSubmitContent
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function retrySubmitWithCitationError(array $messages, array $failedSubmitContent): ?array
|
||||||
|
{
|
||||||
|
$toolUseId = collect($failedSubmitContent)->firstWhere('type', 'tool_use')['id'] ?? null;
|
||||||
|
|
||||||
|
if ($toolUseId === null) {
|
||||||
|
Log::warning('LlmOverlayService: cannot retry — no tool_use id in failed submit');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('LlmOverlayService: retrying submit with citation error', ['tool_use_id' => $toolUseId]);
|
||||||
|
|
||||||
|
$messages[] = ['role' => 'assistant', 'content' => $failedSubmitContent];
|
||||||
|
$messages[] = ['role' => 'user', 'content' => [[
|
||||||
|
'type' => 'tool_result',
|
||||||
|
'tool_use_id' => $toolUseId,
|
||||||
|
'content' => 'events_cited was missing or empty. Resubmit submit_overlay with at least one event from your earlier web search results, including its real URL, headline, source, and impact.',
|
||||||
|
'is_error' => true,
|
||||||
|
]]];
|
||||||
|
|
||||||
|
$retryResponse = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20)
|
||||||
|
->withHeaders($this->headers())
|
||||||
|
->post(self::URL, [
|
||||||
|
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||||
|
'max_tokens' => 512,
|
||||||
|
'tools' => [$this->submitOverlayTool()],
|
||||||
|
'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'],
|
||||||
|
'messages' => $messages,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (! $retryResponse->successful()) {
|
||||||
|
Log::error('LlmOverlayService: retry submit failed', ['status' => $retryResponse->status()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->extractToolInput($retryResponse->json('content') ?? []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ class FuelPriceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$logUrl = $baseUrl.'?'.http_build_query($params);
|
$logUrl = $baseUrl.'?'.http_build_query($params);
|
||||||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::retry(3, 1000)
|
||||||
|
->timeout(60)
|
||||||
->withToken($token)
|
->withToken($token)
|
||||||
->get($baseUrl, $params));
|
->get($baseUrl, $params));
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,20 @@ function initMap() {
|
|||||||
// map-polish:7 — replace default attribution control with custom ⓘ button
|
// map-polish:7 — replace default attribution control with custom ⓘ button
|
||||||
mapInstance = L.map(mapContainer.value, {zoomControl: false, attributionControl: false})
|
mapInstance = L.map(mapContainer.value, {zoomControl: false, attributionControl: false})
|
||||||
|
|
||||||
|
// Set the initial view immediately so tiles load at the correct spot from
|
||||||
|
// frame 1 — avoids the "wiggle" caused by setView running after the
|
||||||
|
// container is already laid out and tiles are mid-load.
|
||||||
|
const initialZoom = getZoomForRadius(props.radiusMiles)
|
||||||
|
const initialCenter = props.origin?.lat != null && props.origin?.lng != null
|
||||||
|
? [props.origin.lat, props.origin.lng]
|
||||||
|
: props.stations.length
|
||||||
|
? [props.stations[0].lat, props.stations[0].lng]
|
||||||
|
: null
|
||||||
|
if (initialCenter) {
|
||||||
|
mapInstance.setView(initialCenter, initialZoom)
|
||||||
|
hasInitialView = true
|
||||||
|
}
|
||||||
|
|
||||||
// map-polish:5 — Carto Positron tile (cleaner than raw OSM)
|
// map-polish:5 — Carto Positron tile (cleaner than raw OSM)
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||||
subdomains: 'abcd',
|
subdomains: 'abcd',
|
||||||
@@ -230,7 +244,7 @@ function initMap() {
|
|||||||
locateUser()
|
locateUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkers() {
|
function renderMarkers({skipRecenter = false} = {}) {
|
||||||
if (!mapInstance || !markersLayer) return
|
if (!mapInstance || !markersLayer) return
|
||||||
|
|
||||||
markersLayer.clearLayers()
|
markersLayer.clearLayers()
|
||||||
@@ -262,6 +276,8 @@ function renderMarkers() {
|
|||||||
bounds.push([station.lat, station.lng])
|
bounds.push([station.lat, station.lng])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (skipRecenter) return
|
||||||
|
|
||||||
const zoom = getZoomForRadius(props.radiusMiles)
|
const zoom = getZoomForRadius(props.radiusMiles)
|
||||||
const center = props.origin?.lat != null && props.origin?.lng != null
|
const center = props.origin?.lat != null && props.origin?.lng != null
|
||||||
? [props.origin.lat, props.origin.lng]
|
? [props.origin.lat, props.origin.lng]
|
||||||
@@ -290,9 +306,10 @@ function destroyMap() {
|
|||||||
|
|
||||||
async function openMap() {
|
async function openMap() {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
const wasFreshInit = !mapInstance
|
||||||
initMap()
|
initMap()
|
||||||
mapInstance?.invalidateSize()
|
mapInstance?.invalidateSize()
|
||||||
renderMarkers()
|
renderMarkers({skipRecenter: wasFreshInit})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="popoverRoot">
|
<div ref="popoverRoot">
|
||||||
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
|
<div class="flex flex-wrap items-center justify-end gap-2 md:gap-2.5 py-2 border-b border-zinc-200">
|
||||||
<button
|
<button
|
||||||
:aria-expanded="open"
|
:aria-expanded="open"
|
||||||
:class="{ 'is-active': activeCount > 0 || open }"
|
:class="{ 'is-active': activeCount > 0 || open }"
|
||||||
aria-controls="post-search-filters-panel"
|
aria-controls="post-search-filters-panel"
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
class="pill"
|
class="pill !rounded-xl"
|
||||||
type="button"
|
type="button"
|
||||||
@click="open = !open"
|
@click="open = !open"
|
||||||
>
|
>
|
||||||
@@ -26,21 +26,6 @@
|
|||||||
></iconify-icon>
|
></iconify-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
:aria-expanded="mapOpen"
|
|
||||||
:class="{ 'is-active': mapOpen }"
|
|
||||||
aria-controls="leaflet-map-panel"
|
|
||||||
class="pill"
|
|
||||||
type="button"
|
|
||||||
@click="emit('toggle-map')"
|
|
||||||
>
|
|
||||||
<iconify-icon :icon="mapOpen ? 'lucide:map' : 'lucide:map-off'" class="text-sm opacity-70"></iconify-icon>
|
|
||||||
<span class="text-sm font-medium">{{ mapOpen ? 'Hide map' : 'Show map' }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span class="ml-auto text-sm text-zinc-500 font-medium">
|
|
||||||
{{ stationCount }} station{{ stationCount !== 1 ? 's' : '' }} found
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -114,7 +99,7 @@
|
|||||||
class="pill-sm"
|
class="pill-sm"
|
||||||
role="radio"
|
role="radio"
|
||||||
type="button"
|
type="button"
|
||||||
@click="emit('update:brandFilter', '')"
|
@click="selectBrand('')"
|
||||||
>
|
>
|
||||||
All brands
|
All brands
|
||||||
</button>
|
</button>
|
||||||
@@ -126,7 +111,7 @@
|
|||||||
class="pill-sm"
|
class="pill-sm"
|
||||||
role="radio"
|
role="radio"
|
||||||
type="button"
|
type="button"
|
||||||
@click="emit('update:brandFilter', brand)"
|
@click="selectBrand(brand)"
|
||||||
>
|
>
|
||||||
{{ brand }}
|
{{ brand }}
|
||||||
</button>
|
</button>
|
||||||
@@ -178,11 +163,9 @@ const props = defineProps({
|
|||||||
initial: { type: Object, default: () => ({}) },
|
initial: { type: Object, default: () => ({}) },
|
||||||
brands: { type: Array, default: () => [] },
|
brands: { type: Array, default: () => [] },
|
||||||
brandFilter: { type: String, default: '' },
|
brandFilter: { type: String, default: '' },
|
||||||
mapOpen: { type: Boolean, default: true },
|
|
||||||
stationCount: { type: Number, default: 0 },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['search', 'toggle-map', 'update:brandFilter'])
|
const emit = defineEmits(['search', 'update:brandFilter'])
|
||||||
|
|
||||||
const postcode = ref('')
|
const postcode = ref('')
|
||||||
const coords = ref(null)
|
const coords = ref(null)
|
||||||
@@ -208,6 +191,7 @@ watch(() => props.initial, (v) => {
|
|||||||
|
|
||||||
watch([fuelType, radius, sort], () => {
|
watch([fuelType, radius, sort], () => {
|
||||||
if (hydrating) return
|
if (hydrating) return
|
||||||
|
open.value = false
|
||||||
if (postcode.value.trim() || coords.value) emitSearch()
|
if (postcode.value.trim() || coords.value) emitSearch()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -232,6 +216,12 @@ function resetFilters() {
|
|||||||
radius.value = DEFAULTS.radius
|
radius.value = DEFAULTS.radius
|
||||||
sort.value = DEFAULTS.sort
|
sort.value = DEFAULTS.sort
|
||||||
if (props.brandFilter) emit('update:brandFilter', '')
|
if (props.brandFilter) emit('update:brandFilter', '')
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBrand(brand) {
|
||||||
|
emit('update:brandFilter', brand)
|
||||||
|
open.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitSearch() {
|
function emitSearch() {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<!-- Free / guest: compact one-liner -->
|
<!-- Free / guest: compact one-liner -->
|
||||||
<div
|
<div
|
||||||
v-else-if="!isPaidTier"
|
v-else-if="!isPaidTier"
|
||||||
class="flex items-center gap-3 px-4 py-3 bg-white rounded-2xl border border-zinc-300"
|
class="flex items-center gap-3 px-3 py-3 bg-white rounded-2xl border border-zinc-300"
|
||||||
>
|
>
|
||||||
<div :class="['shrink-0 w-10 h-10 rounded-full flex items-center justify-center', accentBg]">
|
<div :class="['shrink-0 w-10 h-10 rounded-full flex items-center justify-center', accentBg]">
|
||||||
<iconify-icon :icon="genericIcon" class="text-xl text-white"></iconify-icon>
|
<iconify-icon :icon="genericIcon" class="text-xl text-white"></iconify-icon>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="prediction"
|
v-if="prediction"
|
||||||
class="p-4 sm:p-5 bg-white rounded-2xl border border-zinc-300"
|
class="p-2 sm:p-5 bg-white rounded-2xl border border-zinc-300"
|
||||||
>
|
>
|
||||||
<div class="grid gap-4 lg:grid-cols-2 lg:gap-5">
|
<div class="grid gap-4 lg:grid-cols-2 lg:gap-5">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
|
|||||||
@@ -21,6 +21,14 @@
|
|||||||
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" class="text-lg"></iconify-icon>
|
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" class="text-lg"></iconify-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Mobile-only inline "Go" submit — sits next to the locate button -->
|
||||||
|
<button
|
||||||
|
class="md:hidden h-11 px-4 -ml-1 rounded-[10px] bg-primary text-white font-medium text-sm inline-flex items-center justify-center shrink-0 hover:opacity-90 transition"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Desktop-only inline submit -->
|
<!-- Desktop-only inline submit -->
|
||||||
<button
|
<button
|
||||||
class="hidden md:inline-flex h-12 px-5 ml-1 rounded-xl bg-primary text-white font-medium text-[15px] items-center gap-2 hover:opacity-90 transition"
|
class="hidden md:inline-flex h-12 px-5 ml-1 rounded-xl bg-primary text-white font-medium text-[15px] items-center gap-2 hover:opacity-90 transition"
|
||||||
@@ -31,15 +39,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Mobile-only full-width submit -->
|
|
||||||
<button
|
|
||||||
class="md:hidden w-full mt-2.5 h-14 rounded-2xl bg-primary text-white font-medium text-base inline-flex items-center justify-center gap-2 shadow-lg hover:opacity-90 transition"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Check prices
|
|
||||||
<iconify-icon class="text-lg" icon="lucide:arrow-right"></iconify-icon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-400 justify-center md:justify-start">
|
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-400 justify-center md:justify-start">
|
||||||
<span class="hidden md:inline text-zinc-300">·</span>
|
<span class="hidden md:inline text-zinc-300">·</span>
|
||||||
<span class="hidden md:inline font-mono">Try SW1A 1AA · M1 1AD · EH1 1YZ</span>
|
<span class="hidden md:inline font-mono">Try SW1A 1AA · M1 1AD · EH1 1YZ</span>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
|
|
||||||
<div class="hero-gradient">
|
<div class="hero-gradient">
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section id="hero" class="relative pt-24 md:pt-36 pb-4 md:pb-8 px-6 overflow-hidden">
|
<section id="hero" class="relative pt-24 md:pt-36 pb-2 md:pb-8 px-3 overflow-hidden">
|
||||||
<div class="max-w-7xl mx-auto grid lg:grid-cols-[1.1fr_1fr] gap-10 lg:gap-16 items-center">
|
<div class="max-w-7xl mx-auto grid lg:grid-cols-[1.1fr_1fr] gap-10 lg:gap-16 items-center">
|
||||||
<div class="space-y-6 md:space-y-8">
|
<div class="space-y-3 md:space-y-8">
|
||||||
<LiveTicker :latest-price-at="liveStats.latestPriceAt" :station-count="liveStats.stationCount" />
|
<LiveTicker :latest-price-at="liveStats.latestPriceAt" :station-count="liveStats.stationCount" />
|
||||||
|
|
||||||
<h1 class="font-serif text-zinc-900 text-[40px] leading-[0.98] md:text-6xl lg:text-[88px] lg:leading-[0.95] max-w-[560px]">
|
<h1 class="hidden lg:block font-serif text-zinc-900 text-[40px] leading-[0.98] md:text-6xl lg:text-[88px] lg:leading-[0.95] max-w-140">
|
||||||
Know <span class="text-accent">exactly</span> when to fill up.
|
Know <span class="text-accent">exactly</span> when to fill up.
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Search Results -->
|
<!-- Search Results -->
|
||||||
<section v-if="searchAttempted" id="searchAttempted" class="px-6">
|
<section v-if="searchAttempted" id="searchAttempted" class="px-3">
|
||||||
<div class="max-w-7xl mx-auto space-y-6">
|
<div class="max-w-7xl mx-auto space-y-3">
|
||||||
|
|
||||||
<!-- Prediction box (sits above filter results) -->
|
<!-- Prediction box (sits above filter results) -->
|
||||||
<PredictionCard
|
<PredictionCard
|
||||||
@@ -48,10 +48,7 @@
|
|||||||
v-model:brand-filter="brandFilter"
|
v-model:brand-filter="brandFilter"
|
||||||
:brands="availableBrands"
|
:brands="availableBrands"
|
||||||
:initial="searchInitial"
|
:initial="searchInitial"
|
||||||
:map-open="mapOpen"
|
|
||||||
:station-count="filteredStations.length"
|
|
||||||
@search="onSearch"
|
@search="onSearch"
|
||||||
@toggle-map="mapOpen = !mapOpen"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
@@ -76,7 +73,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<LeafletMap
|
<LeafletMap
|
||||||
:is-open="mapOpen"
|
|
||||||
:origin="searchOrigin"
|
:origin="searchOrigin"
|
||||||
:radius-miles="radiusMiles"
|
:radius-miles="radiusMiles"
|
||||||
:selected-station-id="selectedStationId"
|
:selected-station-id="selectedStationId"
|
||||||
@@ -111,11 +107,28 @@
|
|||||||
</template>
|
</template>
|
||||||
</LeafletMap>
|
</LeafletMap>
|
||||||
<UpsellBanner :station-count="liveStats.stationCount" />
|
<UpsellBanner :station-count="liveStats.stationCount" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
:aria-expanded="listOpen"
|
||||||
|
:class="listOpen ? '' : 'mb-6'"
|
||||||
|
aria-controls="station-list-panel"
|
||||||
|
class="pill w-full justify-center"
|
||||||
|
type="button"
|
||||||
|
@click="listOpen = !listOpen"
|
||||||
|
>
|
||||||
|
<iconify-icon :icon="listOpen ? 'lucide:chevron-up' : 'lucide:list'" class="text-sm opacity-70"></iconify-icon>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ listOpen ? 'Hide list' : `Stations ${filteredStations.length}` }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="listOpen" id="station-list-panel">
|
||||||
<StationList
|
<StationList
|
||||||
:current-sort="sort"
|
:current-sort="sort"
|
||||||
:origin="searchOrigin"
|
:origin="searchOrigin"
|
||||||
:stations="filteredStations"
|
:stations="filteredStations"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -124,41 +137,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- How It Works -->
|
<!-- How It Works -->
|
||||||
<section id="how-it-works" class="py-12 md:py-24 px-6 bg-zinc-50">
|
<section id="how-it-works" class="py-4 md:py-24 px-3 bg-zinc-50">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
<div class="text-center mb-16 space-y-4">
|
<div class="text-center mb-4 md:mb-8 space-y-2 md:space-y-4">
|
||||||
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800">Smart Savings in 3 Steps</h2>
|
<h2 class="text-xl md:text-5xl font-black font-display text-zinc-800">Smart Savings in 3 Steps</h2>
|
||||||
<p class="text-zinc-500 text-lg max-w-2xl mx-auto">Stop guessing when to fill up. Our engine analyzes thousands of data points daily to save you money.</p>
|
<p class="text-zinc-500 text-sm md:text-lg max-w-2xl mx-auto">Stop guessing when to fill up. Our engine analyzes thousands of data points daily to save you money.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-3 gap-12">
|
<div class="grid md:grid-cols-3 gap-12">
|
||||||
<div class="text-center space-y-4">
|
<div class="text-center space-y-2 md:space-y-4">
|
||||||
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
|
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
|
||||||
<iconify-icon icon="lucide:search"></iconify-icon>
|
<iconify-icon icon="lucide:search"></iconify-icon>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-2xl font-bold font-display">1. Search</h3>
|
<h3 class="text-xl md:text-5xl font-bold font-display">1. Search</h3>
|
||||||
<p class="text-zinc-500">Enter your postcode or location to find every forecourt within a 5–20 mile radius instantly.</p>
|
<p class="text-zinc-500 text-sm md:text-lg">Enter your postcode or location to find every forecourt within a 5–20 mile radius instantly.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center space-y-4">
|
<div class="text-center space-y-2 md:space-y-4">
|
||||||
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
|
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
|
||||||
<iconify-icon icon="lucide:trending-up"></iconify-icon>
|
<iconify-icon icon="lucide:trending-up"></iconify-icon>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-2xl font-bold font-display">2. Get Advice</h3>
|
<h3 class="text-sm md:text-lg font-bold font-display">2. Get Advice</h3>
|
||||||
<p class="text-zinc-500">Our AI compares local prices against national wholesale trends to give you a Fill Up / Wait recommendation.</p>
|
<p class="text-zinc-500 text-sm md:text-lg">Our AI compares local prices against national wholesale trends to give you a Fill Up / Wait recommendation.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center space-y-4">
|
<div class="text-center space-y-2 md:space-y-4">
|
||||||
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
|
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
|
||||||
<iconify-icon icon="lucide:wallet"></iconify-icon>
|
<iconify-icon icon="lucide:wallet"></iconify-icon>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-2xl font-bold font-display">3. Fill Up Smart</h3>
|
<h3 class="text-sm md:text-lg font-bold font-display">3. Fill Up Smart</h3>
|
||||||
<p class="text-zinc-500">Navigate to the cheapest station and fill up with confidence knowing you've secured the best price.</p>
|
<p class="text-zinc-500 text-sm md:text-lg">Navigate to the cheapest station and fill up with confidence knowing you've secured the best price.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
<section id="features" class="py-12 md:py-24 px-6">
|
<section id="features" class="py-4 md:py-24 px-6">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
<div class="grid lg:grid-cols-2 gap-20 items-center">
|
<div class="grid lg:grid-cols-2 gap-20 items-center">
|
||||||
<div class="order-2 lg:order-1">
|
<div class="order-2 lg:order-1">
|
||||||
@@ -187,8 +200,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="order-1 lg:order-2 space-y-8">
|
<div class="order-1 lg:order-2 space-y-8">
|
||||||
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800">The ultimate fuel companion.</h2>
|
<h2 class="text-xl md:text-5xl font-black font-display text-zinc-800">The ultimate fuel companion.</h2>
|
||||||
<p class="text-lg text-zinc-500">Whether you're a daily commuter, a delivery professional, or just planning a weekend road trip, FuelAlert gives you the edge at the pump.</p>
|
<p class="text-sm md:text-lg text-zinc-500">Whether you're a daily commuter, a delivery professional, or just planning a weekend road trip, FuelAlert gives you the edge at the pump.</p>
|
||||||
<ul class="space-y-4">
|
<ul class="space-y-4">
|
||||||
<li class="flex items-center gap-3 font-bold">
|
<li class="flex items-center gap-3 font-bold">
|
||||||
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
|
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
|
||||||
@@ -213,7 +226,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Pricing -->
|
<!-- Pricing -->
|
||||||
<section id="pricing" class="py-12 md:py-24 px-6 bg-zinc-50">
|
<section id="pricing" class="py-4 md:py-24 px-6 bg-zinc-50">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
<div class="text-center mb-16">
|
<div class="text-center mb-16">
|
||||||
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800 mb-4">Pricing for every driver</h2>
|
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800 mb-4">Pricing for every driver</h2>
|
||||||
@@ -367,7 +380,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Product</h5>
|
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Product</h5>
|
||||||
<ul class="space-y-2 text-sm text-zinc-500">
|
<ul class="space-y-2 text-sm text-zinc-500">
|
||||||
<li><a class="hover:text-accent transition-colors" href="#pricing">Pricing</a></li>
|
<li><a class="hover:text-accent transition-colors" href="#pricing">Pricing</a></li>
|
||||||
<li><a class="hover:text-accent transition-colors" href="#features">Features</a></li>
|
<li><a class="hover:text-accent transition-colors" href="#features">Features</a></li>
|
||||||
@@ -377,7 +390,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Resources</h5>
|
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Resources</h5>
|
||||||
<ul class="space-y-2 text-sm text-zinc-500">
|
<ul class="space-y-2 text-sm text-zinc-500">
|
||||||
<li><a class="hover:text-accent transition-colors" href="#">Market Insights</a></li>
|
<li><a class="hover:text-accent transition-colors" href="#">Market Insights</a></li>
|
||||||
<li><a class="hover:text-accent transition-colors" href="#">How We Track</a></li>
|
<li><a class="hover:text-accent transition-colors" href="#">How We Track</a></li>
|
||||||
@@ -387,7 +400,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Legal</h5>
|
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Legal</h5>
|
||||||
<ul class="space-y-2 text-sm text-zinc-500">
|
<ul class="space-y-2 text-sm text-zinc-500">
|
||||||
<li><a class="hover:text-accent transition-colors" href="#">Privacy Policy</a></li>
|
<li><a class="hover:text-accent transition-colors" href="#">Privacy Policy</a></li>
|
||||||
<li><a class="hover:text-accent transition-colors" href="#">Terms of Service</a></li>
|
<li><a class="hover:text-accent transition-colors" href="#">Terms of Service</a></li>
|
||||||
@@ -396,7 +409,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
|
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] tracking-widest text-zinc-500">
|
||||||
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
|
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
|
||||||
<p>Data provided by official UK retail price transparency schemes.</p>
|
<p>Data provided by official UK retail price transparency schemes.</p>
|
||||||
<p>Postcode data from <a class="underline hover:text-accent" href="https://geoportal.statistics.gov.uk/datasets/ons::onspd-online-latest-centroids-1/about" rel="noopener" target="_blank">ONS Postcode Directory</a>: contains OS data © Crown copyright & database right, Royal Mail data © Royal Mail copyright & database right, and National Statistics data © Crown copyright & database right.</p>
|
<p>Postcode data from <a class="underline hover:text-accent" href="https://geoportal.statistics.gov.uk/datasets/ons::onspd-online-latest-centroids-1/about" rel="noopener" target="_blank">ONS Postcode Directory</a>: contains OS data © Crown copyright & database right, Royal Mail data © Royal Mail copyright & database right, and National Statistics data © Crown copyright & database right.</p>
|
||||||
@@ -501,23 +514,23 @@ const searchAttempted = ref(false)
|
|||||||
const radiusMiles = ref(10)
|
const radiusMiles = ref(10)
|
||||||
const brandFilter = ref('')
|
const brandFilter = ref('')
|
||||||
|
|
||||||
const MAP_STORAGE_KEY = 'fuel-price:map-open'
|
const LIST_STORAGE_KEY = 'fuelalert:list-open'
|
||||||
|
|
||||||
function readSavedMapOpen() {
|
function readSavedListOpen() {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(MAP_STORAGE_KEY)
|
const v = localStorage.getItem(LIST_STORAGE_KEY)
|
||||||
if (v === null) return true
|
if (v === null) return false
|
||||||
return v === '1'
|
return v === '1'
|
||||||
} catch {
|
} catch {
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapOpen = ref(readSavedMapOpen())
|
const listOpen = ref(readSavedListOpen())
|
||||||
|
|
||||||
watch(mapOpen, (v) => {
|
watch(listOpen, (v) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(MAP_STORAGE_KEY, v ? '1' : '0')
|
localStorage.setItem(LIST_STORAGE_KEY, v ? '1' : '0')
|
||||||
} catch {
|
} catch {
|
||||||
// ignore quota / privacy-mode errors
|
// ignore quota / privacy-mode errors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,91 @@ it('persists an overlay row with verified citations and capped confidence', func
|
|||||||
->and($row->events_json)->toHaveCount(1);
|
->and($row->events_json)->toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries the submit when the model omits events_cited', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*api.anthropic.com/*' => Http::sequence()
|
||||||
|
->push([
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
'content' => [['type' => 'text', 'text' => 'Search done.']],
|
||||||
|
])
|
||||||
|
->push([
|
||||||
|
'stop_reason' => 'tool_use',
|
||||||
|
'content' => [[
|
||||||
|
'type' => 'tool_use',
|
||||||
|
'id' => 'toolu_first',
|
||||||
|
'name' => 'submit_overlay',
|
||||||
|
'input' => [
|
||||||
|
'direction' => 'rising',
|
||||||
|
'confidence' => 70,
|
||||||
|
'reasoning_short' => 'Forgot citations.',
|
||||||
|
// events_cited omitted entirely — the bug we are guarding against
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
])
|
||||||
|
->push([
|
||||||
|
'stop_reason' => 'tool_use',
|
||||||
|
'content' => [[
|
||||||
|
'type' => 'tool_use',
|
||||||
|
'id' => 'toolu_retry',
|
||||||
|
'name' => 'submit_overlay',
|
||||||
|
'input' => [
|
||||||
|
'direction' => 'rising',
|
||||||
|
'confidence' => 70,
|
||||||
|
'reasoning_short' => 'With citations now.',
|
||||||
|
'events_cited' => [
|
||||||
|
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
|
||||||
|
],
|
||||||
|
'agrees_with_ridge' => true,
|
||||||
|
'major_impact_event' => false,
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
]),
|
||||||
|
'*' => Http::response('', 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||||
|
|
||||||
|
$row = $service->run();
|
||||||
|
|
||||||
|
expect($row)->not->toBeNull()
|
||||||
|
->and($row->events_json)->toHaveCount(1)
|
||||||
|
->and(LlmOverlay::query()->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the retry also omits events_cited', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*api.anthropic.com/*' => Http::sequence()
|
||||||
|
->push([
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
'content' => [['type' => 'text', 'text' => 'Search done.']],
|
||||||
|
])
|
||||||
|
->push([
|
||||||
|
'stop_reason' => 'tool_use',
|
||||||
|
'content' => [[
|
||||||
|
'type' => 'tool_use',
|
||||||
|
'id' => 'toolu_first',
|
||||||
|
'name' => 'submit_overlay',
|
||||||
|
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning_short' => 'No cites.'],
|
||||||
|
]],
|
||||||
|
])
|
||||||
|
->push([
|
||||||
|
'stop_reason' => 'tool_use',
|
||||||
|
'content' => [[
|
||||||
|
'type' => 'tool_use',
|
||||||
|
'id' => 'toolu_retry',
|
||||||
|
'name' => 'submit_overlay',
|
||||||
|
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning_short' => 'Still none.'],
|
||||||
|
]],
|
||||||
|
]),
|
||||||
|
'*' => Http::response('', 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||||
|
|
||||||
|
expect($service->run())->toBeNull()
|
||||||
|
->and(LlmOverlay::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('honors the 4-hour cooldown for event-driven calls', function (): void {
|
it('honors the 4-hour cooldown for event-driven calls', function (): void {
|
||||||
Carbon::setTestNow('2026-05-01 10:00:00');
|
Carbon::setTestNow('2026-05-01 10:00:00');
|
||||||
DB::table('llm_overlays')->insert([
|
DB::table('llm_overlays')->insert([
|
||||||
|
|||||||
@@ -342,6 +342,38 @@ it('does not cache the poll timestamp when a batch errors', function (): void {
|
|||||||
expect(Cache::has('fuel_finder_last_price_poll_at'))->toBeFalse();
|
expect(Cache::has('fuel_finder_last_price_poll_at'))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries a failing batch and recovers when the API responds successfully', function (): void {
|
||||||
|
Cache::put('fuel_finder_access_token', 'tok', 3540);
|
||||||
|
Station::factory()->create(['node_id' => 'sta1']);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'*/pfs/fuel-prices*' => Http::sequence()
|
||||||
|
->push([], 500)
|
||||||
|
->push([], 500)
|
||||||
|
->push([
|
||||||
|
[
|
||||||
|
'node_id' => 'sta1',
|
||||||
|
'fuel_prices' => [
|
||||||
|
[
|
||||||
|
'fuel_type' => 'E10',
|
||||||
|
'price' => 142.9,
|
||||||
|
'price_last_updated' => '2026-04-04T10:00:00.000Z',
|
||||||
|
'price_change_effective_timestamp' => '2026-04-04T10:00:00.000Z',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->push([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inserted = $this->service->pollPrices();
|
||||||
|
|
||||||
|
expect($inserted)->toBe(1)
|
||||||
|
->and(StationPrice::count())->toBe(1);
|
||||||
|
|
||||||
|
Http::assertSentCount(4);
|
||||||
|
});
|
||||||
|
|
||||||
it('skips price rows for stations not present in the stations table', function (): void {
|
it('skips price rows for stations not present in the stations table', function (): void {
|
||||||
Cache::put('fuel_finder_access_token', 'tok', 3540);
|
Cache::put('fuel_finder_access_token', 'tok', 3540);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user