feat(ui): mobile-first redesign — compact hero, inline submit button, map-first with collapsible list
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

- 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:
Ovidiu U
2026-05-14 13:23:52 +01:00
parent 11a3b433ff
commit 97e27fc057
10 changed files with 302 additions and 87 deletions

View File

@@ -62,7 +62,13 @@ final class LlmOverlayService
$verifiedEvents = $this->verifyCitedUrls($rawResult['events_cited'] ?? []);
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;
}
@@ -131,7 +137,10 @@ final class LlmOverlayService
$messages = [['role' => 'user', 'content' => $this->prompt($context)]];
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++) {
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45)
->withHeaders($this->headers())
@@ -148,14 +157,13 @@ final class LlmOverlayService
return null;
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
if ($response->json('stop_reason') !== 'pause_turn') {
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.'];
// Phase 2: forced structured output
@@ -175,7 +183,18 @@ final class LlmOverlayService
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) {
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,
@@ -235,7 +259,7 @@ final class LlmOverlayService
/** @return array{0: bool, 1: string} [reachable, diagnostic_string] */
private function urlReachable(string $url): array
{
$headers = ['User-Agent' => self::VERIFICATION_USER_AGENT];
$headers = ['User-Agent' => $this->verificationUserAgent()];
$headStatus = 'no-attempt';
try {
@@ -342,6 +366,7 @@ final class LlmOverlayService
'reasoning_short' => ['type' => 'string', 'description' => '12 sentences.'],
'events_cited' => [
'type' => 'array',
'minItems' => 1,
'items' => [
'type' => 'object',
'properties' => [
@@ -371,4 +396,57 @@ final class LlmOverlayService
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') ?? []);
}
}

View File

@@ -124,7 +124,8 @@ class FuelPriceService
}
$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)
->get($baseUrl, $params));

View File

@@ -204,6 +204,20 @@ function initMap() {
// map-polish:7 — replace default attribution control with custom ⓘ button
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)
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
subdomains: 'abcd',
@@ -230,7 +244,7 @@ function initMap() {
locateUser()
}
function renderMarkers() {
function renderMarkers({skipRecenter = false} = {}) {
if (!mapInstance || !markersLayer) return
markersLayer.clearLayers()
@@ -262,6 +276,8 @@ function renderMarkers() {
bounds.push([station.lat, station.lng])
})
if (skipRecenter) return
const zoom = getZoomForRadius(props.radiusMiles)
const center = props.origin?.lat != null && props.origin?.lng != null
? [props.origin.lat, props.origin.lng]
@@ -290,9 +306,10 @@ function destroyMap() {
async function openMap() {
await nextTick()
const wasFreshInit = !mapInstance
initMap()
mapInstance?.invalidateSize()
renderMarkers()
renderMarkers({skipRecenter: wasFreshInit})
}
onMounted(() => {

View File

@@ -1,12 +1,12 @@
<template>
<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
:aria-expanded="open"
:class="{ 'is-active': activeCount > 0 || open }"
aria-controls="post-search-filters-panel"
aria-haspopup="dialog"
class="pill"
class="pill !rounded-xl"
type="button"
@click="open = !open"
>
@@ -26,21 +26,6 @@
></iconify-icon>
</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
@@ -114,7 +99,7 @@
class="pill-sm"
role="radio"
type="button"
@click="emit('update:brandFilter', '')"
@click="selectBrand('')"
>
All brands
</button>
@@ -126,7 +111,7 @@
class="pill-sm"
role="radio"
type="button"
@click="emit('update:brandFilter', brand)"
@click="selectBrand(brand)"
>
{{ brand }}
</button>
@@ -178,11 +163,9 @@ const props = defineProps({
initial: { type: Object, default: () => ({}) },
brands: { type: Array, 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 coords = ref(null)
@@ -208,6 +191,7 @@ watch(() => props.initial, (v) => {
watch([fuelType, radius, sort], () => {
if (hydrating) return
open.value = false
if (postcode.value.trim() || coords.value) emitSearch()
})
@@ -232,6 +216,12 @@ function resetFilters() {
radius.value = DEFAULTS.radius
sort.value = DEFAULTS.sort
if (props.brandFilter) emit('update:brandFilter', '')
open.value = false
}
function selectBrand(brand) {
emit('update:brandFilter', brand)
open.value = false
}
function emitSearch() {

View File

@@ -12,7 +12,7 @@
<!-- Free / guest: compact one-liner -->
<div
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]">
<iconify-icon :icon="genericIcon" class="text-xl text-white"></iconify-icon>

View File

@@ -1,7 +1,7 @@
<template>
<div
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="space-y-1.5">

View File

@@ -21,6 +21,14 @@
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" class="text-lg"></iconify-icon>
</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 -->
<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"
@@ -31,15 +39,6 @@
</button>
</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">
<span class="hidden md:inline text-zinc-300">·</span>
<span class="hidden md:inline font-mono">Try SW1A 1AA · M1 1AD · EH1 1YZ</span>

View File

@@ -5,12 +5,12 @@
<div class="hero-gradient">
<!-- 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="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" />
<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.
</h1>
@@ -33,8 +33,8 @@
</section>
<!-- Search Results -->
<section v-if="searchAttempted" id="searchAttempted" class="px-6">
<div class="max-w-7xl mx-auto space-y-6">
<section v-if="searchAttempted" id="searchAttempted" class="px-3">
<div class="max-w-7xl mx-auto space-y-3">
<!-- Prediction box (sits above filter results) -->
<PredictionCard
@@ -48,10 +48,7 @@
v-model:brand-filter="brandFilter"
:brands="availableBrands"
:initial="searchInitial"
:map-open="mapOpen"
:station-count="filteredStations.length"
@search="onSearch"
@toggle-map="mapOpen = !mapOpen"
/>
<!-- Loading -->
@@ -76,7 +73,6 @@
</div>
<template v-else>
<LeafletMap
:is-open="mapOpen"
:origin="searchOrigin"
:radius-miles="radiusMiles"
:selected-station-id="selectedStationId"
@@ -111,11 +107,28 @@
</template>
</LeafletMap>
<UpsellBanner :station-count="liveStats.stationCount" />
<StationList
:current-sort="sort"
:origin="searchOrigin"
:stations="filteredStations"
/>
<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
:current-sort="sort"
:origin="searchOrigin"
:stations="filteredStations"
/>
</div>
</template>
</template>
@@ -124,41 +137,41 @@
</div>
<!-- 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="text-center mb-16 space-y-4">
<h2 class="text-4xl 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>
<div class="text-center mb-4 md:mb-8 space-y-2 md:space-y-4">
<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-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 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">
<iconify-icon icon="lucide:search"></iconify-icon>
</div>
<h3 class="text-2xl font-bold font-display">1. Search</h3>
<p class="text-zinc-500">Enter your postcode or location to find every forecourt within a 520 mile radius instantly.</p>
<h3 class="text-xl md:text-5xl font-bold font-display">1. Search</h3>
<p class="text-zinc-500 text-sm md:text-lg">Enter your postcode or location to find every forecourt within a 520 mile radius instantly.</p>
</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">
<iconify-icon icon="lucide:trending-up"></iconify-icon>
</div>
<h3 class="text-2xl 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>
<h3 class="text-sm md:text-lg font-bold font-display">2. Get Advice</h3>
<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 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">
<iconify-icon icon="lucide:wallet"></iconify-icon>
</div>
<h3 class="text-2xl 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>
<h3 class="text-sm md:text-lg font-bold font-display">3. Fill Up Smart</h3>
<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>
</section>
<!-- 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="grid lg:grid-cols-2 gap-20 items-center">
<div class="order-2 lg:order-1">
@@ -187,8 +200,8 @@
</div>
<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>
<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>
<h2 class="text-xl md:text-5xl font-black font-display text-zinc-800">The ultimate fuel companion.</h2>
<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">
<li class="flex items-center gap-3 font-bold">
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
@@ -213,7 +226,7 @@
</section>
<!-- 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="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>
@@ -367,7 +380,7 @@
</div>
<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">
<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>
@@ -377,7 +390,7 @@
</div>
<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">
<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>
@@ -387,7 +400,7 @@
</div>
<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">
<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>
@@ -396,7 +409,7 @@
</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>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 &amp; database right, Royal Mail data © Royal Mail copyright &amp; database right, and National Statistics data © Crown copyright &amp; database right.</p>
@@ -501,23 +514,23 @@ const searchAttempted = ref(false)
const radiusMiles = ref(10)
const brandFilter = ref('')
const MAP_STORAGE_KEY = 'fuel-price:map-open'
const LIST_STORAGE_KEY = 'fuelalert:list-open'
function readSavedMapOpen() {
function readSavedListOpen() {
try {
const v = localStorage.getItem(MAP_STORAGE_KEY)
if (v === null) return true
const v = localStorage.getItem(LIST_STORAGE_KEY)
if (v === null) return false
return v === '1'
} catch {
return true
return false
}
}
const mapOpen = ref(readSavedMapOpen())
const listOpen = ref(readSavedListOpen())
watch(mapOpen, (v) => {
watch(listOpen, (v) => {
try {
localStorage.setItem(MAP_STORAGE_KEY, v ? '1' : '0')
localStorage.setItem(LIST_STORAGE_KEY, v ? '1' : '0')
} catch {
// ignore quota / privacy-mode errors
}

View File

@@ -189,6 +189,91 @@ it('persists an overlay row with verified citations and capped confidence', func
->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 {
Carbon::setTestNow('2026-05-01 10:00:00');
DB::table('llm_overlays')->insert([

View File

@@ -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();
});
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 {
Cache::put('fuel_finder_access_token', 'tok', 3540);