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.
112 lines
4.7 KiB
Vue
112 lines
4.7 KiB
Vue
<template>
|
||
<div class="space-y-3">
|
||
<!-- Grouped results when sorting by reliability -->
|
||
<template v-if="currentSort === 'reliable'">
|
||
<section v-if="reliable.length" class="space-y-2">
|
||
<header class="flex items-center gap-2 pt-2">
|
||
<iconify-icon class="text-status-good text-lg" icon="lucide:shield-check"></iconify-icon>
|
||
<h3 class="font-black text-zinc-800">Reliable</h3>
|
||
<span class="text-xs text-zinc-500 font-medium">Updated in the last 3 days</span>
|
||
</header>
|
||
<StationCard
|
||
v-for="station in reliable"
|
||
:key="station.station_id"
|
||
:avg-pence="avgPence"
|
||
:lowest-price="lowestPrice"
|
||
:origin="origin"
|
||
:station="station"
|
||
/>
|
||
</section>
|
||
|
||
<section v-if="stale.length" class="space-y-2 pt-4">
|
||
<header class="flex items-center gap-2">
|
||
<iconify-icon class="text-status-warn text-lg" icon="lucide:clock"></iconify-icon>
|
||
<h3 class="font-black text-zinc-800">Older prices</h3>
|
||
<span class="text-xs text-zinc-500 font-medium">3–7 days old — verify before driving</span>
|
||
</header>
|
||
<div class="opacity-80">
|
||
<StationCard
|
||
v-for="station in stale"
|
||
:key="station.station_id"
|
||
:avg-pence="avgPence"
|
||
:lowest-price="lowestPrice"
|
||
:origin="origin"
|
||
:station="station"
|
||
class="mb-2"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="outdated.length" class="space-y-2 pt-4">
|
||
<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 ({{ 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"
|
||
:avg-pence="avgPence"
|
||
:lowest-price="lowestPrice"
|
||
:origin="origin"
|
||
:station="station"
|
||
class="mb-2"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- Flat list for other sort modes -->
|
||
<div v-else class="space-y-2">
|
||
<StationCard
|
||
v-for="station in stations"
|
||
:key="station.station_id"
|
||
:avg-pence="avgPence"
|
||
:lowest-price="lowestPrice"
|
||
:origin="origin"
|
||
:station="station"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref } from 'vue'
|
||
import StationCard from './StationCard.vue'
|
||
|
||
const props = defineProps({
|
||
stations: { type: Array, required: true },
|
||
currentSort: { type: String, default: 'reliable' },
|
||
origin: { type: Object, default: null },
|
||
})
|
||
|
||
const reliable = computed(() => props.stations.filter(s => s.reliability === 'reliable'))
|
||
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
|
||
return Math.min(...pool.map(s => s.price_pence))
|
||
})
|
||
|
||
const avgPence = computed(() => {
|
||
const prices = props.stations.map(s => s.price_pence).filter(p => typeof p === 'number')
|
||
if (!prices.length) return null
|
||
return prices.reduce((a, b) => a + b, 0) / prices.length
|
||
})
|
||
</script>
|