Files
fuel-alert/resources/js/components/PredictionFull.vue
Ovidiu U 97e27fc057
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
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
2026-05-14 13:23:52 +01:00

94 lines
3.5 KiB
Vue

<template>
<div
v-if="prediction"
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">
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Price Prediction</p>
<h3 class="text-sm font-semibold text-zinc-800 leading-snug">{{ actionLabel }}</h3>
<p class="text-sm text-zinc-500 leading-snug">{{ prediction.reasoning }}</p>
<p class="text-sm text-zinc-500 leading-snug">
<span>Avg {{ prediction.current_avg }}p</span>
<span class="text-zinc-400"> · </span>
<span>Confidence {{ prediction.confidence_label }}</span>
<template v-if="prediction.predicted_change_pence">
<span class="text-zinc-400"> · </span>
<span>{{ prediction.predicted_change_pence > 0 ? '+' : '' }}{{ prediction.predicted_change_pence.toFixed(1) }}p expected</span>
</template>
</p>
</div>
<div
v-if="weeklyHeadline || todayContext"
class="space-y-1.5 pt-3 border-t border-zinc-200 lg:pt-0 lg:border-t-0 lg:border-l lg:pl-5"
>
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Last 7 days</p>
<p v-if="weeklyHeadline" class="text-sm font-semibold text-zinc-800 leading-snug">{{ weeklyHeadline }}</p>
<p v-if="todayContext" class="text-sm text-zinc-500 leading-snug">{{ todayContext }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
prediction: { type: Object, default: null },
})
const actionLabel = computed(() => {
if (!props.prediction) return ''
return {
fill_now: 'Fill up now',
wait: 'Wait — prices falling',
no_signal: 'No clear signal',
}[props.prediction.action] ?? 'Check local prices'
})
const weekly = computed(() => props.prediction?.weekly_summary ?? null)
function formatPence(value) {
if (value === null || value === undefined) return null
return Number(value).toFixed(1) + 'p'
}
function formatDateShort(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric' })
}
const weeklyHeadline = computed(() => {
const w = weekly.value
if (!w || !w.cheapest_day || !w.priciest_day || w.last_7_days_change_pence === null) {
return null
}
const change = w.last_7_days_change_pence
const lead = change > 0.05
? `Avg rose ${change.toFixed(1)}p`
: change < -0.05
? `Avg fell ${Math.abs(change).toFixed(1)}p`
: 'Avg held steady'
return `${lead} — cheapest ${formatDateShort(w.cheapest_day.date)} (${formatPence(w.cheapest_day.avg)}), priciest ${formatDateShort(w.priciest_day.date)} (${formatPence(w.priciest_day.avg)}).`
})
const todayContext = computed(() => {
const w = weekly.value
if (!w) return null
const today = formatPence(w.today_avg)
const tomorrow = formatPence(w.tomorrow_estimated_avg)
if (today && tomorrow) {
return `Today ${today}; tomorrow ≈ ${tomorrow}.`
}
if (today) {
return `Today ${today}.`
}
return null
})
</script>