Add current_period tracking to subscriptions, document prediction engine, and refactor station list UI
Add current_period_start, current_period_end, and stripe_data columns to subscriptions table via migration. Extend Subscription model with datetime casts for new fields. Create comprehensive prediction engine documentation covering signals, aggregation, confidence calibration, and weekly summary logic. Add PredictionFull Vue component displaying action label, reasoning, and 7-day context. Refactor StationList to collapse outdated stations behind expandable section. Add UpsellBanner component with station count formatting. Create .claude/settings.json denying .env file access. Add todo.md tracking Stripe dashboard setup, production deployment steps, and E2E QA checklist. Update .env.example with fuel-finder credentials, Anthropic config, and complete Stripe price IDs.
This commit is contained in:
93
resources/js/components/PredictionFull.vue
Normal file
93
resources/js/components/PredictionFull.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="prediction"
|
||||
class="p-4 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>
|
||||
@@ -38,12 +38,22 @@
|
||||
</section>
|
||||
|
||||
<section v-if="outdated.length" class="space-y-2 pt-4">
|
||||
<header class="flex items-center gap-2">
|
||||
<button
|
||||
:aria-expanded="outdatedOpen"
|
||||
class="flex items-center gap-2 w-full text-left py-3 px-3 rounded-lg hover:bg-zinc-100/60 transition-colors"
|
||||
type="button"
|
||||
@click="outdatedOpen = !outdatedOpen"
|
||||
>
|
||||
<iconify-icon class="text-status-bad text-lg" icon="lucide:triangle-alert"></iconify-icon>
|
||||
<h3 class="font-black text-zinc-800">Outdated</h3>
|
||||
<span class="text-xs text-zinc-500 font-medium">Over 7 days old — likely inaccurate</span>
|
||||
</header>
|
||||
<div class="opacity-60">
|
||||
<span class="text-xs text-zinc-500 font-medium">Over 7 days old — likely inaccurate ({{ outdated.length }})</span>
|
||||
<iconify-icon
|
||||
:class="{ 'rotate-180': outdatedOpen }"
|
||||
class="text-zinc-500 text-base ml-auto transition-transform"
|
||||
icon="lucide:chevron-down"
|
||||
></iconify-icon>
|
||||
</button>
|
||||
<div v-if="outdatedOpen" class="opacity-60">
|
||||
<StationCard
|
||||
v-for="station in outdated"
|
||||
:key="station.station_id"
|
||||
@@ -72,7 +82,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import StationCard from './StationCard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -85,6 +95,8 @@ const reliable = computed(() => props.stations.filter(s => s.reliability === 're
|
||||
const stale = computed(() => props.stations.filter(s => s.reliability === 'stale'))
|
||||
const outdated = computed(() => props.stations.filter(s => s.reliability === 'outdated'))
|
||||
|
||||
const outdatedOpen = ref(false)
|
||||
|
||||
const lowestPrice = computed(() => {
|
||||
if (!reliable.value.length && !props.stations.length) return null
|
||||
const pool = reliable.value.length ? reliable.value : props.stations
|
||||
|
||||
52
resources/js/components/UpsellBanner.vue
Normal file
52
resources/js/components/UpsellBanner.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!isPaidTier"
|
||||
class="relative overflow-hidden rounded-2xl border border-accent/30 bg-gradient-to-r from-accent/10 via-accent/5 to-transparent p-5 sm:p-6"
|
||||
>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="shrink-0 w-11 h-11 rounded-2xl bg-accent text-white flex items-center justify-center shadow-md">
|
||||
<iconify-icon class="text-xl" icon="lucide:sparkles"></iconify-icon>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-lg sm:text-xl font-black text-zinc-800 leading-tight">
|
||||
Stop guessing. Get a buy-or-wait alert before every fill-up.
|
||||
</h3>
|
||||
<p class="text-sm text-zinc-500">
|
||||
14-day predictions + daily price-drop alerts across
|
||||
<span class="font-bold text-zinc-800">{{ stationCountLabel }}</span> UK stations.
|
||||
From <span class="font-bold text-zinc-800">£0.99/mo</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
:href="ctaHref"
|
||||
class="shrink-0 inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-accent text-white text-sm font-black shadow-lg hover:bg-accent-content transition-colors"
|
||||
>
|
||||
{{ ctaLabel }}
|
||||
<iconify-icon class="text-base" icon="lucide:arrow-right"></iconify-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
|
||||
const props = defineProps({
|
||||
stationCount: { type: Number, default: null },
|
||||
})
|
||||
|
||||
const { isAuthenticated, isPaidTier } = useAuth()
|
||||
|
||||
const stationCountLabel = computed(() => {
|
||||
if (!props.stationCount) {
|
||||
return '14,500+'
|
||||
}
|
||||
return new Intl.NumberFormat('en-GB').format(props.stationCount)
|
||||
})
|
||||
|
||||
const ctaHref = computed(() => isAuthenticated.value ? '#pricing' : '/register?tier=plus&cadence=monthly')
|
||||
const ctaLabel = computed(() => isAuthenticated.value ? 'See plans' : 'Start saving')
|
||||
</script>
|
||||
Reference in New Issue
Block a user