Remove prediction API endpoint and integrate into stations search
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

Consolidate prediction functionality by merging /api/prediction endpoint into /api/stations response. Move prediction logic from PredictionController into StationController, returning prediction data alongside station results. Replace usePrediction composable with unified useStations that returns {stations, meta, prediction}. Remove PredictionRequest, related tests, and unused Vue components (FuelFinderTest, MapTest, RecommendationTest, StationListTest). Add PredictionFull component and UpsellBanner. Extend NationalFuelPredictionService to include weekly_summary (7-day series, yesterday/today averages, cheapest/priciest days) and oil signal from price_predictions table. Update Home.vue to consume prediction from stations response. Add Plan::resolveCadenceForUser helper and configure Cashier to use custom Subscription model.
This commit is contained in:
Ovidiu U
2026-04-29 13:28:33 +01:00
parent ee6de23709
commit 088fd11058
29 changed files with 1046 additions and 499 deletions

View File

@@ -31,6 +31,7 @@ npm run dev # Vite asset watcher
@.claude/rules/database.md @.claude/rules/database.md
@.claude/rules/notifications.md @.claude/rules/notifications.md
@.claude/rules/scoring.md @.claude/rules/scoring.md
@.claude/rules/prediction.md
@.claude/rules/payments.md @.claude/rules/payments.md
@.claude/rules/tiers.md @.claude/rules/tiers.md
@.claude/rules/livewire.md @.claude/rules/livewire.md

View File

@@ -63,10 +63,19 @@ class AuthController extends Controller
public function me(Request $request): JsonResponse public function me(Request $request): JsonResponse
{ {
$user = $request->user(); $user = $request->user();
$subscription = $user->subscription();
$expiresAt = $subscription?->ends_at ?? $subscription?->current_period_end;
return response()->json(array_merge( return response()->json(array_merge(
$user->toArray(), $user->toArray(),
['tier' => Plan::resolveForUser($user)->name], [
'tier' => Plan::resolveForUser($user)->name,
'subscription_cancelled' => $subscription?->canceled() ?? false,
'subscription_cadence' => Plan::resolveCadenceForUser($user),
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
'subscription_expires_at' => $expiresAt?->toIso8601String(),
],
)); ));
} }
} }

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\PredictionRequest;
use App\Services\NationalFuelPredictionService;
use App\Services\PlanFeatures;
use Illuminate\Http\JsonResponse;
class PredictionController extends Controller
{
public function __construct(
private readonly NationalFuelPredictionService $predictionService,
) {}
public function index(PredictionRequest $request): JsonResponse
{
$lat = $request->filled('lat') ? (float) $request->input('lat') : null;
$lng = $request->filled('lng') ? (float) $request->input('lng') : null;
$result = $this->predictionService->predict($lat, $lng);
$user = $request->user();
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
if (! $canSeeFull) {
return response()->json([
'fuel_type' => $result['fuel_type'],
'predicted_direction' => $result['predicted_direction'],
'tier_locked' => true,
]);
}
return response()->json($result);
}
}

View File

@@ -8,6 +8,9 @@ use App\Http\Requests\Api\NearbyStationsRequest;
use App\Http\Resources\Api\StationResource; use App\Http\Resources\Api\StationResource;
use App\Models\Search; use App\Models\Search;
use App\Models\Station; use App\Models\Station;
use App\Models\User;
use App\Services\NationalFuelPredictionService;
use App\Services\PlanFeatures;
use App\Services\PostcodeService; use App\Services\PostcodeService;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -16,7 +19,10 @@ use Illuminate\Validation\ValidationException;
class StationController extends Controller class StationController extends Controller
{ {
public function __construct(private readonly PostcodeService $postcodeService) {} public function __construct(
private readonly PostcodeService $postcodeService,
private readonly NationalFuelPredictionService $predictionService,
) {}
public function index(NearbyStationsRequest $request): JsonResponse public function index(NearbyStationsRequest $request): JsonResponse
{ {
@@ -115,6 +121,31 @@ class StationController extends Controller
'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0), 'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0),
], ],
], ],
'prediction' => $this->predictionFor($request->user(), $lat, $lng),
]); ]);
} }
/**
* Returns the prediction payload for embedding in the search response.
* Free/guest users get a stripped teaser; users with the ai_predictions
* feature get the full multi-signal payload.
*
* @return array<string, mixed>
*/
private function predictionFor(?User $user, float $lat, float $lng): array
{
$result = $this->predictionService->predict($lat, $lng);
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
if (! $canSeeFull) {
return [
'fuel_type' => $result['fuel_type'],
'predicted_direction' => $result['predicted_direction'],
'tier_locked' => true,
];
}
return $result;
}
} }

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Enums\PlanTier; use App\Enums\PlanTier;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Laravel\Cashier\Checkout;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class BillingController extends Controller class BillingController extends Controller
@@ -12,7 +13,7 @@ class BillingController extends Controller
/** /**
* Redirect the user to a Stripe Checkout session for the requested plan + cadence. * Redirect the user to a Stripe Checkout session for the requested plan + cadence.
*/ */
public function checkout(Request $request, string $tier, string $cadence): Response|RedirectResponse public function checkout(Request $request, string $tier, string $cadence): Response|RedirectResponse|Checkout
{ {
abort_unless(in_array($tier, [PlanTier::Basic->value, PlanTier::Plus->value, PlanTier::Pro->value], true), 404); abort_unless(in_array($tier, [PlanTier::Basic->value, PlanTier::Plus->value, PlanTier::Pro->value], true), 404);
abort_unless(in_array($cadence, ['monthly', 'annual'], true), 404); abort_unless(in_array($cadence, ['monthly', 'annual'], true), 404);

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class PredictionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'lat' => ['nullable', 'numeric', 'between:-90,90'],
'lng' => ['nullable', 'numeric', 'between:-180,180'],
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Listeners; namespace App\Listeners;
use App\Jobs\SendPaymentFailedReminderJob; use App\Jobs\SendPaymentFailedReminderJob;
use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use App\Models\UserNotificationPreference; use App\Models\UserNotificationPreference;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -28,15 +29,27 @@ final class HandleStripeWebhook
match ($type) { match ($type) {
'customer.subscription.created', 'customer.subscription.created',
'customer.subscription.updated' => $this->bustPlanCache($user), 'customer.subscription.updated' => $this->handleSubscriptionUpserted($user, $event->payload['data']['object'] ?? []),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user), 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user, $event->payload['data']['object'] ?? []),
'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user), 'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user),
'invoice.payment_failed' => $this->handlePaymentFailed($user), 'invoice.payment_failed' => $this->handlePaymentFailed($user),
default => null, default => null,
}; };
} }
private function handleSubscriptionDeleted(User $user): void /**
* @param array<string, mixed> $stripeSubscription
*/
private function handleSubscriptionUpserted(User $user, array $stripeSubscription): void
{
$this->syncPeriodFromStripePayload($stripeSubscription);
$this->bustPlanCache($user);
}
/**
* @param array<string, mixed> $stripeSubscription
*/
private function handleSubscriptionDeleted(User $user, array $stripeSubscription): void
{ {
UserNotificationPreference::query() UserNotificationPreference::query()
->where('user_id', $user->id) ->where('user_id', $user->id)
@@ -45,9 +58,52 @@ final class HandleStripeWebhook
$user->forceFill(['grace_period_until' => null])->save(); $user->forceFill(['grace_period_until' => null])->save();
$this->syncPeriodFromStripePayload($stripeSubscription);
$this->bustPlanCache($user); $this->bustPlanCache($user);
} }
/**
* Mirror current_period_start / current_period_end from a Stripe subscription
* payload onto our local row so we don't depend on Stripe at read time.
*
* Stripe API 2024-11-19 places the period fields at the root of the
* subscription; later versions move them to items.data[0]. We accept either.
*
* @param array<string, mixed> $stripeSubscription
*/
private function syncPeriodFromStripePayload(array $stripeSubscription): void
{
$stripeId = $stripeSubscription['id'] ?? null;
if ($stripeId === null) {
return;
}
$subscription = Subscription::where('stripe_id', $stripeId)->first();
if ($subscription === null) {
return;
}
$start = $stripeSubscription['current_period_start']
?? ($stripeSubscription['items']['data'][0]['current_period_start'] ?? null);
$end = $stripeSubscription['current_period_end']
?? ($stripeSubscription['items']['data'][0]['current_period_end'] ?? null);
$subscription->stripe_data = $stripeSubscription;
if ($start !== null) {
$subscription->current_period_start = Carbon::createFromTimestamp($start);
}
if ($end !== null) {
$subscription->current_period_end = Carbon::createFromTimestamp($end);
}
$subscription->save();
}
private function handlePaymentSucceeded(User $user): void private function handlePaymentSucceeded(User $user): void
{ {
$user->forceFill(['grace_period_until' => null])->save(); $user->forceFill(['grace_period_until' => null])->save();

View File

@@ -80,6 +80,41 @@ class Plan extends Model
]); ]);
} }
/**
* Resolve the active subscription cadence for a user.
* Returns 'monthly' | 'annual', or null if the user has no paid subscription.
*/
public static function resolveCadenceForUser(User $user): ?string
{
if (! method_exists($user, 'subscriptions')) {
return null;
}
$priceId = $user->subscriptions()->active()->value('stripe_price');
if ($priceId === null) {
return null;
}
$plan = static::where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_annual', $priceId)
->first();
if ($plan === null) {
return null;
}
if ($plan->stripe_price_id_monthly === $priceId) {
return 'monthly';
}
if ($plan->stripe_price_id_annual === $priceId) {
return 'annual';
}
return null;
}
protected static function booted(): void protected static function booted(): void
{ {
static::saved(function (): void { static::saved(function (): void {

View File

@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])] #[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model class PricePrediction extends Model
@@ -39,11 +38,17 @@ class PricePrediction extends Model
*/ */
public function scopeBestFirst(Builder $query): Builder public function scopeBestFirst(Builder $query): Builder
{ {
$priority = implode(', ', array_map( $priority = [
fn (string $v) => "'$v'", PredictionSource::LlmWithContext->value,
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value], PredictionSource::Llm->value,
)); PredictionSource::Ewma->value,
];
return $query->orderByRaw("FIELD(source, $priority)"); $cases = '';
foreach ($priority as $rank => $source) {
$cases .= " WHEN '$source' THEN $rank";
}
return $query->orderByRaw("CASE source$cases ELSE ".count($priority).' END');
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use App\Listeners\HandleStripeWebhook; use App\Listeners\HandleStripeWebhook;
use App\Models\Subscription;
use App\Services\ApiLogger; use App\Services\ApiLogger;
use App\Services\LlmPrediction\AnthropicPredictionProvider; use App\Services\LlmPrediction\AnthropicPredictionProvider;
use App\Services\LlmPrediction\GeminiPredictionProvider; use App\Services\LlmPrediction\GeminiPredictionProvider;
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived; use Laravel\Cashier\Events\WebhookReceived;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -41,6 +43,8 @@ class AppServiceProvider extends ServiceProvider
{ {
$this->configureDefaults(); $this->configureDefaults();
Cashier::useSubscriptionModel(Subscription::class);
Event::listen(WebhookReceived::class, HandleStripeWebhook::class); Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
} }

View File

@@ -4,6 +4,7 @@ namespace App\Services;
use App\Enums\FuelType; use App\Enums\FuelType;
use App\Models\StationPriceCurrent; use App\Models\StationPriceCurrent;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class NationalFuelPredictionService class NationalFuelPredictionService
@@ -12,6 +13,12 @@ class NationalFuelPredictionService
private const float SLOPE_THRESHOLD_PENCE = 0.3; private const float SLOPE_THRESHOLD_PENCE = 0.3;
/** Slope (pence/day) at which trend score saturates to ±1.0. */
private const float SLOPE_SATURATION_PENCE = 0.5;
/** Minimum unique days of history for the day-of-week signal to activate. */
private const int DAY_OF_WEEK_MIN_DAYS = 21;
private const int PREDICTION_HORIZON_DAYS = 7; private const int PREDICTION_HORIZON_DAYS = 7;
/** /**
@@ -40,13 +47,14 @@ class NationalFuelPredictionService
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType); $dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType); $brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
$stickiness = $this->computeStickinessSignal($fuelType); $stickiness = $this->computeStickinessSignal($fuelType);
$oil = $this->computeOilSignal();
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions'); $nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
$regionalMomentum = $hasCoordinates $regionalMomentum = $hasCoordinates
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng) ? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
: $this->disabledSignal('No coordinates provided for regional momentum analysis'); : $this->disabledSignal('No coordinates provided for regional momentum analysis');
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness'); $signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates); [$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
@@ -65,6 +73,8 @@ class NationalFuelPredictionService
default => 'no_signal', default => 'no_signal',
}; };
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
return [ return [
'fuel_type' => $fuelType->value, 'fuel_type' => $fuelType->value,
'current_avg' => $currentAvg, 'current_avg' => $currentAvg,
@@ -73,10 +83,11 @@ class NationalFuelPredictionService
'confidence_score' => $confidenceScore, 'confidence_score' => $confidenceScore,
'confidence_label' => $confidenceLabel, 'confidence_label' => $confidenceLabel,
'action' => $action, 'action' => $action,
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour), 'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek),
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS, 'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
'region_key' => $hasCoordinates ? 'regional' : 'national', 'region_key' => $hasCoordinates ? 'regional' : 'national',
'methodology' => 'multi_signal_live_fallback', 'methodology' => 'multi_signal_live_fallback',
'weekly_summary' => $weeklySummary,
'signals' => [ 'signals' => [
'trend' => $trend, 'trend' => $trend,
'day_of_week' => $dayOfWeek, 'day_of_week' => $dayOfWeek,
@@ -84,6 +95,7 @@ class NationalFuelPredictionService
'national_momentum' => $nationalMomentum, 'national_momentum' => $nationalMomentum,
'regional_momentum' => $regionalMomentum, 'regional_momentum' => $regionalMomentum,
'price_stickiness' => $stickiness, 'price_stickiness' => $stickiness,
'oil' => $oil,
], ],
]; ];
} }
@@ -138,7 +150,7 @@ class NationalFuelPredictionService
default => 'stable', default => 'stable',
}; };
$absSlope = abs($slope); $absSlope = abs($slope);
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / 2.0) * ($slope > 0 ? 1 : -1); $score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / self::SLOPE_SATURATION_PENCE) * ($slope > 0 ? 1 : -1);
$projected = round($slope * $lookbackDays, 1); $projected = round($slope * $lookbackDays, 1);
$detail = $direction === 'stable' $detail = $direction === 'stable'
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})" ? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
@@ -204,8 +216,8 @@ class NationalFuelPredictionService
$uniqueDays = $rows->pluck('day')->unique()->count(); $uniqueDays = $rows->pluck('day')->unique()->count();
if ($uniqueDays < 56) { if ($uniqueDays < self::DAY_OF_WEEK_MIN_DAYS) {
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)"); return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::DAY_OF_WEEK_MIN_DAYS.')');
} }
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price')); $dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
@@ -214,9 +226,11 @@ class NationalFuelPredictionService
$todayAvg = $dowAverages->get($todayDow, $weekAvg); $todayAvg = $dowAverages->get($todayDow, $weekAvg);
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first(); $cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
$cheapestDayName = $dayNames[($cheapestDow - 1) % 7] ?? 'Unknown'; $todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
$weekRange = round(($dowAverages->max() - $dowAverages->min()) / 100, 1); $tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
$tomorrowDelta = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
$direction = match (true) { $direction = match (true) {
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up', ($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
@@ -226,11 +240,34 @@ class NationalFuelPredictionService
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0); $score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
$parts = [];
$parts[] = abs($todayDeltaPence) < 0.1
? "Today ({$todayName}) is typically in line with the weekly average."
: sprintf(
'Today (%s) is typically %sp %s the weekly average.',
$todayName,
number_format(abs($todayDeltaPence), 1),
$todayDeltaPence > 0 ? 'above' : 'below',
);
$parts[] = abs($tomorrowDeltaPence) < 0.1
? "Tomorrow ({$tomorrowName}) is typically the same."
: sprintf(
'Tomorrow (%s) is typically %sp %s.',
$tomorrowName,
number_format(abs($tomorrowDeltaPence), 1),
$tomorrowDeltaPence < 0 ? 'cheaper' : 'pricier',
);
if ($cheapestDow === $todayDow) {
$parts[] = 'Today is historically the cheapest day of the week.';
}
return [ return [
'score' => $score, 'score' => $score,
'confidence' => min(1.0, $uniqueDays / 90), 'confidence' => min(1.0, $uniqueDays / 90),
'direction' => $direction, 'direction' => $direction,
'detail' => "Cheapest day: {$cheapestDayName}. Weekly range: {$weekRange}p. Tomorrow typically {$tomorrowDelta}p less than today.", 'detail' => implode(' ', $parts),
'data_points' => $uniqueDays, 'data_points' => $uniqueDays,
'enabled' => true, 'enabled' => true,
]; ];
@@ -386,6 +423,63 @@ class NationalFuelPredictionService
]; ];
} }
/**
* Reads the most recent Brent crude prediction (LLM preferred, EWMA fallback)
* covering today or later. Sourced from price_predictions, which OilPriceService
* populates daily.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeOilSignal(): array
{
$prediction = null;
foreach (['llm_with_context', 'llm', 'ewma'] as $source) {
$prediction = DB::table('price_predictions')
->where('source', $source)
->where('predicted_for', '>=', now()->toDateString())
->orderByDesc('predicted_for')
->orderByDesc('generated_at')
->first();
if ($prediction !== null) {
break;
}
}
if ($prediction === null) {
return $this->disabledSignal('No oil price prediction available');
}
$direction = match ($prediction->direction) {
'rising' => 'up',
'falling' => 'down',
default => 'stable',
};
$score = match ($direction) {
'up' => 1.0,
'down' => -1.0,
default => 0.0,
};
$confidence = round(((float) $prediction->confidence) / 100, 2);
return [
'score' => $score,
'confidence' => $confidence,
'direction' => $direction,
'detail' => sprintf(
'Brent crude %s (%s, %d%% confidence)',
$prediction->direction,
$prediction->source,
(int) $prediction->confidence,
),
'data_points' => 1,
'enabled' => true,
];
}
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */ /** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
private function disabledSignal(string $detail): array private function disabledSignal(string $detail): array
{ {
@@ -400,46 +494,64 @@ class NationalFuelPredictionService
} }
/** /**
* Weighted aggregate of enabled signals. * Aggregate enabled signals into a final direction + confidence score.
* Returns [direction string, confidence score 0-100].
* *
* @param array<string, array{score: float, confidence: float, enabled: bool}> $signals * Direction: weighted vote across signals that have a non-stable direction.
* stable signals do NOT dilute the directional vote.
*
* Confidence: weighted average of enabled signals' own confidence values,
* multiplied by an agreement coefficient (0..1) measuring how the signals
* line up with the chosen direction.
*
* @param array<string, array{score: float, confidence: float, direction: string, enabled: bool}> $signals
* @return array{0: string, 1: float} * @return array{0: string, 1: float}
*/ */
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
{ {
$weights = $hasCoordinates $weights = $hasCoordinates
? [ ? [
'regionalMomentum' => 0.50, 'regionalMomentum' => 0.35,
'trend' => 0.20, 'oil' => 0.20,
'trend' => 0.15,
'dayOfWeek' => 0.15, 'dayOfWeek' => 0.15,
'brandBehaviour' => 0.10, 'brandBehaviour' => 0.10,
'stickiness' => 0.05, 'stickiness' => 0.05,
] ]
: [ : [
'trend' => 0.45, 'trend' => 0.30,
'oil' => 0.25,
'dayOfWeek' => 0.20, 'dayOfWeek' => 0.20,
'brandBehaviour' => 0.25, 'brandBehaviour' => 0.15,
'stickiness' => 0.10, 'stickiness' => 0.10,
]; ];
$weightedSum = 0.0; $directionalScoreSum = 0.0;
$totalWeight = 0.0; $directionalWeightSum = 0.0;
$confidenceWeightedSum = 0.0;
$totalEnabledWeight = 0.0;
foreach ($weights as $key => $weight) { foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null; $signal = $signals[$key] ?? null;
if ($signal && $signal['enabled']) { if (! $signal || ! $signal['enabled']) {
$weightedSum += $signal['score'] * $signal['confidence'] * $weight; continue;
$totalWeight += $weight; }
$totalEnabledWeight += $weight;
$confidenceWeightedSum += $signal['confidence'] * $weight;
if ($signal['direction'] !== 'stable') {
$directionalScoreSum += $signal['score'] * $signal['confidence'] * $weight;
$directionalWeightSum += $weight;
} }
} }
if ($totalWeight < 0.01) { if ($totalEnabledWeight < 0.01) {
return ['stable', 0.0]; return ['stable', 0.0];
} }
$normalised = $weightedSum / $totalWeight; $normalised = $directionalWeightSum > 0.01
$confidenceScore = round(min(100.0, abs($normalised) * 100), 1); ? $directionalScoreSum / $directionalWeightSum
: 0.0;
$direction = match (true) { $direction = match (true) {
$normalised >= 0.1 => 'up', $normalised >= 0.1 => 'up',
@@ -447,9 +559,175 @@ class NationalFuelPredictionService
default => 'stable', default => 'stable',
}; };
$avgConfidence = $confidenceWeightedSum / $totalEnabledWeight;
$agreement = $this->computeAgreement($signals, $weights, $direction);
$confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1);
return [$direction, $confidenceScore]; return [$direction, $confidenceScore];
} }
/**
* How well the enabled signals line up with the chosen direction.
* - aligned signal: full credit (signal_confidence × weight)
* - one side stable, other directional: half credit
* - opposing signals: no credit
*
* Range: 0 (full disagreement) 1 (unanimous).
*
* @param array<string, array{confidence: float, direction: string, enabled: bool}> $signals
* @param array<string, float> $weights
*/
private function computeAgreement(array $signals, array $weights, string $finalDirection): float
{
$finalDir = match ($finalDirection) {
'up' => 1,
'down' => -1,
default => 0,
};
$credit = 0.0;
$maxCredit = 0.0;
foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null;
if (! $signal || ! $signal['enabled']) {
continue;
}
$maxCredit += $signal['confidence'] * $weight;
$signalDir = match ($signal['direction']) {
'up' => 1,
'down' => -1,
default => 0,
};
if ($signalDir === $finalDir) {
$credit += $signal['confidence'] * $weight;
} elseif ($signalDir === 0 || $finalDir === 0) {
$credit += 0.5 * $signal['confidence'] * $weight;
}
}
return $maxCredit > 0.0 ? $credit / $maxCredit : 0.0;
}
/**
* Yesterday / today / tomorrow snapshot + last-7-days series.
* Regional (50km) when coordinates are given, with national fallback when
* regional data is empty.
*
* @return array{
* yesterday_avg: ?float,
* today_avg: float,
* tomorrow_estimated_avg: ?float,
* yesterday_today_delta_pence: ?float,
* last_7_days_series: array<int, array{date: string, avg: float}>,
* last_7_days_change_pence: ?float,
* cheapest_day: ?array{date: string, avg: float},
* priciest_day: ?array{date: string, avg: float},
* is_regional: bool
* }
*/
private function computeWeeklySummary(FuelType $fuelType, ?float $lat, ?float $lng, float $todayAvg, float $slope): array
{
$yesterdayAvg = $this->getDailyAverage($fuelType, now()->subDay(), $lat, $lng);
[$series, $usedRegional] = $this->getDailySeries($fuelType, 7, $lat, $lng);
$tomorrowEstimated = $todayAvg > 0.0 ? round($todayAvg + $slope, 1) : null;
$yesterdayTodayDelta = $yesterdayAvg !== null ? round($todayAvg - $yesterdayAvg, 1) : null;
$cheapestDay = null;
$priciestDay = null;
$weekChange = null;
if (count($series) >= 2) {
$byPrice = $series;
usort($byPrice, fn ($a, $b) => $a['avg'] <=> $b['avg']);
$cheapestDay = $byPrice[0];
$priciestDay = $byPrice[count($byPrice) - 1];
$weekChange = round(end($series)['avg'] - $series[0]['avg'], 1);
}
return [
'yesterday_avg' => $yesterdayAvg,
'today_avg' => $todayAvg,
'tomorrow_estimated_avg' => $tomorrowEstimated,
'yesterday_today_delta_pence' => $yesterdayTodayDelta,
'last_7_days_series' => $series,
'last_7_days_change_pence' => $weekChange,
'cheapest_day' => $cheapestDay,
'priciest_day' => $priciestDay,
'is_regional' => $usedRegional,
];
}
private function getDailyAverage(FuelType $fuelType, CarbonInterface $date, ?float $lat, ?float $lng): ?float
{
$dateString = $date->toDateString();
if ($lat !== null && $lng !== null) {
$regional = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->whereDate('station_prices.price_effective_at', $dateString)
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
->avg('station_prices.price_pence');
if ($regional !== null) {
return round((float) $regional / 100, 1);
}
}
$national = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->whereDate('price_effective_at', $dateString)
->avg('price_pence');
return $national !== null ? round((float) $national / 100, 1) : null;
}
/**
* @return array{0: array<int, array{date: string, avg: float}>, 1: bool}
*/
private function getDailySeries(FuelType $fuelType, int $days, ?float $lat, ?float $lng): array
{
$rows = collect();
$usedRegional = false;
if ($lat !== null && $lng !== null) {
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays($days)->startOfDay())
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
$usedRegional = $rows->isNotEmpty();
}
if ($rows->isEmpty()) {
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays($days)->startOfDay())
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
}
$series = $rows->map(fn ($r): array => [
'date' => (string) $r->day,
'avg' => round((float) $r->avg_price / 100, 1),
])->values()->all();
return [$series, $usedRegional];
}
/** /**
* Least-squares linear regression. * Least-squares linear regression.
* x is the array index (day number), y is the price value. * x is the array index (day number), y is the price value.
@@ -491,7 +769,12 @@ class NationalFuelPredictionService
return ['slope' => $slope, 'r_squared' => $rSquared]; return ['slope' => $slope, 'r_squared' => $rSquared];
} }
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour): string /**
* @param array{enabled: bool, detail: string, direction: string} $trend
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
* @param array{enabled: bool, detail: string, direction: string} $dayOfWeek
*/
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour, array $dayOfWeek): string
{ {
$parts = []; $parts = [];
@@ -503,8 +786,16 @@ class NationalFuelPredictionService
$parts[] = $brandBehaviour['detail']; $parts[] = $brandBehaviour['detail'];
} }
if ($dayOfWeek['enabled']) {
$parts[] = $dayOfWeek['detail'];
}
if (empty($parts)) { if (empty($parts)) {
return 'No clear pattern — fill up at the cheapest station near you now.'; return match ($direction) {
'up' => 'Mild upward signals — top up soon if you\'re nearby.',
'down' => 'Mild downward signals — wait a day or two if your tank can hold.',
default => 'No clear pattern — fill up at the cheapest station near you now.',
};
} }
return implode(' ', $parts); return implode(' ', $parts);

View File

@@ -29,40 +29,13 @@
</div> </div>
<!-- Paid: full prediction --> <!-- Paid: full prediction -->
<div <PredictionFull v-else :prediction="prediction" />
v-else-if="prediction"
class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-4"
>
<p class="text-xs font-bold uppercase tracking-widest text-zinc-500">Price Prediction</p>
<h3
:class="['text-2xl font-black', actionTextColor]"
>
{{ actionLabel }}
</h3>
<div class="w-full h-2 bg-zinc-200 rounded-full overflow-hidden">
<div
:class="['h-full rounded-full transition-all', actionBarColor]"
:style="{ width: prediction.confidence_score + '%' }"
></div>
</div>
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-zinc-500 font-medium">
<span>Avg: {{ prediction.current_avg }}p</span>
<span>Confidence: {{ prediction.confidence_label }}</span>
<span v-if="prediction.predicted_change_pence">
{{ prediction.predicted_change_pence > 0 ? '+' : '' }}{{ prediction.predicted_change_pence.toFixed(1) }}p expected
</span>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import PredictionFull from './PredictionFull.vue'
const props = defineProps({ const props = defineProps({
prediction: { type: Object, default: null }, prediction: { type: Object, default: null },
@@ -70,31 +43,6 @@ const props = defineProps({
isPaidTier: { type: Boolean, default: false }, isPaidTier: { type: Boolean, default: false },
}) })
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 actionTextColor = computed(() => {
if (!props.prediction) return 'text-zinc-800'
return {
fill_now: 'text-mauve',
wait: 'text-teal',
}[props.prediction.action] ?? 'text-tan'
})
const actionBarColor = computed(() => {
if (!props.prediction) return 'bg-zinc-400'
return {
fill_now: 'bg-mauve',
wait: 'bg-teal',
}[props.prediction.action] ?? 'bg-tan'
})
const direction = computed(() => props.prediction?.predicted_direction ?? 'stable') const direction = computed(() => props.prediction?.predicted_direction ?? 'stable')
const genericSentence = computed(() => ({ const genericSentence = computed(() => ({

View File

@@ -57,7 +57,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<div v-if="expanded" class="border-t border-zinc-200 pt-3 space-y-3"> <div v-if="expanded" class="border-t border-zinc-200 pt-3 space-y-3">
<p v-if="brandLabel" class="text-[10px] font-black uppercase tracking-widest text-zinc-500"> <p v-if="brandLabel" class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
{{ brandLabel }} {{ brandLabel }}
</p> </p>

View File

@@ -19,6 +19,22 @@ export function useAuth() {
return ['basic', 'plus', 'pro'].includes(userTier.value) return ['basic', 'plus', 'pro'].includes(userTier.value)
}) })
const subscriptionCancelled = computed(() => {
return user.value?.subscription_cancelled ?? false
})
const subscriptionCadence = computed(() => {
return user.value?.subscription_cadence ?? null
})
const subscribedAt = computed(() => {
return user.value?.subscribed_at ?? null
})
const subscriptionExpiresAt = computed(() => {
return user.value?.subscription_expires_at ?? null
})
async function fetchUser() { async function fetchUser() {
if (fetched.value) { if (fetched.value) {
return return
@@ -68,6 +84,10 @@ export function useAuth() {
isAuthenticated, isAuthenticated,
userTier, userTier,
isPaidTier, isPaidTier,
subscriptionCancelled,
subscriptionCadence,
subscribedAt,
subscriptionExpiresAt,
fetchUser, fetchUser,
clearUser, clearUser,
logout, logout,

View File

@@ -1,31 +0,0 @@
import { ref } from 'vue'
import api from '../axios.js'
export function usePrediction() {
const prediction = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetch({ lat, lng } = {}) {
loading.value = true
error.value = null
prediction.value = null
const params = {}
if (lat && lng) {
params.lat = lat
params.lng = lng
}
try {
const response = await api.get('/prediction', { params })
prediction.value = response.data
} catch (err) {
error.value = 'Unable to load prediction.'
} finally {
loading.value = false
}
}
return { prediction, loading, error, fetch }
}

View File

@@ -4,6 +4,7 @@ import api from '../axios.js'
export function useStations() { export function useStations() {
const stations = ref([]) const stations = ref([])
const meta = ref(null) const meta = ref(null)
const prediction = ref(null)
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
@@ -12,6 +13,7 @@ export function useStations() {
error.value = null error.value = null
stations.value = [] stations.value = []
meta.value = null meta.value = null
prediction.value = null
const params = { fuel_type: fuelType, radius, sort } const params = { fuel_type: fuelType, radius, sort }
@@ -26,6 +28,7 @@ export function useStations() {
const response = await api.get('/stations', { params }) const response = await api.get('/stations', { params })
stations.value = response.data.data stations.value = response.data.data
meta.value = response.data.meta meta.value = response.data.meta
prediction.value = response.data.prediction ?? null
} catch (err) { } catch (err) {
error.value = err.response?.data?.errors error.value = err.response?.data?.errors
?? { general: ['Unable to load stations. Please try again.'] } ?? { general: ['Unable to load stations. Please try again.'] }
@@ -37,9 +40,10 @@ export function useStations() {
function reset() { function reset() {
stations.value = [] stations.value = []
meta.value = null meta.value = null
prediction.value = null
error.value = null error.value = null
loading.value = false loading.value = false
} }
return { stations, meta, loading, error, search, reset } return { stations, meta, prediction, loading, error, search, reset }
} }

View File

@@ -39,7 +39,7 @@
<!-- Prediction box (sits above filter results) --> <!-- Prediction box (sits above filter results) -->
<PredictionCard <PredictionCard
:is-paid-tier="showFullPrediction" :is-paid-tier="showFullPrediction"
:loading="predictionLoading" :loading="loading"
:prediction="prediction" :prediction="prediction"
/> />
@@ -81,6 +81,7 @@
:radius-miles="radiusMiles" :radius-miles="radiusMiles"
:stations="filteredStations" :stations="filteredStations"
/> />
<UpsellBanner :station-count="liveStats.stationCount" />
<StationList <StationList
:current-sort="sort" :current-sort="sort"
:origin="searchOrigin" :origin="searchOrigin"
@@ -236,7 +237,7 @@
</div> </div>
</div> </div>
<ul class="space-y-4 mb-8 flex-1"> <ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Ad-free Experience</li> <li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Buy-or-Wait Score</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li> <li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li> <li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li>
</ul> </ul>
@@ -385,7 +386,7 @@ import api from '../axios.js'
import PostSearchFilters from '../components/PostSearchFilters.vue' import PostSearchFilters from '../components/PostSearchFilters.vue'
import PredictionCard from '../components/PredictionCard.vue' import PredictionCard from '../components/PredictionCard.vue'
import StationList from '../components/StationList.vue' import StationList from '../components/StationList.vue'
import { usePrediction } from '../composables/usePrediction.js' import UpsellBanner from '../components/UpsellBanner.vue'
const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue')) const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue'))
import LandingNav from '../components/landing/LandingNav.vue' import LandingNav from '../components/landing/LandingNav.vue'
@@ -395,8 +396,6 @@ import HeroSearch from '../components/landing/HeroSearch.vue'
import StatsRow from '../components/landing/StatsRow.vue' import StatsRow from '../components/landing/StatsRow.vue'
const { isAuthenticated, userTier } = useAuth() const { isAuthenticated, userTier } = useAuth()
const { prediction, loading: predictionLoading, fetch: fetchPrediction } = usePrediction()
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
const liveStats = ref({ stationCount: null, latestPriceAt: null }) const liveStats = ref({ stationCount: null, latestPriceAt: null })
@@ -446,7 +445,8 @@ const PRICES = {
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' }, annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
} }
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' } const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
const { stations, meta, loading, error, search, reset } = useStations() const { stations, meta, prediction, loading, error, search, reset } = useStations()
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
watch(loading, (isLoading) => { watch(loading, (isLoading) => {
if (!isLoading) return if (!isLoading) return
@@ -557,9 +557,6 @@ async function runSearch(params) {
radiusMiles.value = params.radius ?? radiusMiles.value radiusMiles.value = params.radius ?? radiusMiles.value
searchAttempted.value = true searchAttempted.value = true
await search(params) await search(params)
const lat = meta.value?.lat ?? params.lat ?? null
const lng = meta.value?.lng ?? params.lng ?? null
fetchPrediction(lat && lng ? { lat, lng } : {})
} }
async function onSearch(params) { async function onSearch(params) {

View File

@@ -19,11 +19,39 @@
</div> </div>
<div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-2"> <div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-2">
<p class="text-sm font-bold uppercase tracking-widest text-zinc-500">Your plan</p> <p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Your plan</p>
<p class="text-xl font-black text-zinc-800 capitalize">{{ userTier }}</p> <p class="text-xl font-black text-zinc-800 capitalize">{{ userTier }}</p>
<a v-if="userTier === 'free'" class="inline-block text-sm font-bold text-accent hover:underline" href="/pricing"> <a v-if="userTier === 'free'" class="inline-block text-sm font-bold text-accent hover:underline" href="/pricing">
Upgrade for alerts + predictions Upgrade for alerts + predictions
</a> </a>
<dl v-if="isPaidTier" class="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-3 mt-3 border-t border-zinc-200">
<div v-if="subscribedAt">
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Subscribed</dt>
<dd class="text-sm font-semibold text-zinc-800 mt-0.5">{{ formatDate(subscribedAt) }}</dd>
</div>
<div v-if="subscriptionCadence">
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Billed</dt>
<dd class="text-sm font-semibold text-zinc-800 mt-0.5 capitalize">{{ subscriptionCadence }}</dd>
</div>
<div v-if="subscriptionExpiresAt">
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
{{ subscriptionCancelled ? 'Ends on' : 'Renews on' }}
</dt>
<dd class="text-sm font-semibold text-zinc-800 mt-0.5">{{ formatDate(subscriptionExpiresAt) }}</dd>
</div>
</dl>
<div v-if="isPaidTier && !subscriptionCancelled" class="pt-3 mt-3 border-t border-zinc-200">
<a
class="inline-flex items-center gap-1.5 text-sm font-semibold text-mauve hover:text-zinc-800 transition-colors"
href="/billing/portal"
>
<iconify-icon class="text-base" icon="lucide:circle-x"></iconify-icon>
Cancel subscription
</a>
<p class="text-xs text-zinc-500 mt-1">
You'll keep your features until the end of the billing period.
</p>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -32,7 +60,25 @@
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useAuth } from '../../composables/useAuth.js' import { useAuth } from '../../composables/useAuth.js'
const { user, userTier } = useAuth() const {
user,
userTier,
isPaidTier,
subscriptionCancelled,
subscriptionCadence,
subscribedAt,
subscriptionExpiresAt,
} = useAuth()
const dateFormatter = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
function formatDate(value) {
if (!value) {
return ''
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : dateFormatter.format(date)
}
const quickLinks = [ const quickLinks = [
{ to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark', description: 'Stations you\'ve bookmarked for quick access.' }, { to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark', description: 'Stations you\'ve bookmarked for quick access.' },

View File

@@ -76,7 +76,7 @@
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="setupData.svg" class="[&_svg]:w-40 [&_svg]:h-40"></div> <div v-html="setupData.svg" class="[&_svg]:w-40 [&_svg]:h-40"></div>
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs font-bold text-zinc-500 uppercase tracking-widest">Or enter setup key manually</p> <p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Or enter setup key manually</p>
<code class="text-xs bg-zinc-50 px-3 py-2 rounded-lg font-mono text-zinc-800 break-all block">{{ setupData.secretKey }}</code> <code class="text-xs bg-zinc-50 px-3 py-2 rounded-lg font-mono text-zinc-800 break-all block">{{ setupData.secretKey }}</code>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,6 @@
use App\Enums\FuelType; use App\Enums\FuelType;
use App\Http\Controllers\Api\AuthController; use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\PredictionController;
use App\Http\Controllers\Api\StationController; use App\Http\Controllers\Api\StationController;
use App\Http\Controllers\Api\StatsController; use App\Http\Controllers\Api\StatsController;
use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Api\UserController;
@@ -26,7 +25,6 @@ Route::get('/stats/live', [StatsController::class, 'live']);
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void { Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
Route::get('/stations', [StationController::class, 'index']); Route::get('/stations', [StationController::class, 'index']);
Route::get('/stats/searches', [StatsController::class, 'searches']); Route::get('/stats/searches', [StatsController::class, 'searches']);
Route::get('/prediction', [PredictionController::class, 'index']);
}); });
// Sanctum-authenticated endpoints // Sanctum-authenticated endpoints

View File

@@ -1,5 +1,6 @@
<?php <?php
use App\Models\Plan;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -68,6 +69,146 @@ it('returns the authenticated user on /me', function () {
->assertJsonPath('email', $user->email); ->assertJsonPath('email', $user->email);
}); });
it('reports subscription_cancelled=false for a user with no subscription', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false);
});
it('reports subscription_cancelled=false for an active paid subscription', function () {
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_active',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
]);
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false);
});
it('reports subscription_cancelled=true once the subscription is set to end at period end', function () {
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_cancelling',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
'ends_at' => now()->addDays(20),
]);
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', true);
});
it('exposes subscribed_at, cadence and renewal date for an active monthly subscription', function () {
Plan::create([
'name' => 'plus',
'stripe_price_id_monthly' => 'price_plus_monthly_test',
'stripe_price_id_annual' => 'price_plus_annual_test',
'features' => ['fuel_types' => ['max' => 1]],
'active' => true,
]);
$user = User::factory()->create();
$subscribedAt = now()->subDays(10)->startOfSecond();
$renewalAt = now()->addDays(20)->startOfSecond();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_monthly_active',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly_test',
'quantity' => 1,
'current_period_end' => $renewalAt,
'created_at' => $subscribedAt,
'updated_at' => $subscribedAt,
]);
$response = $this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false)
->assertJsonPath('subscription_cadence', 'monthly');
expect($response->json('subscribed_at'))->toStartWith($subscribedAt->toDateString());
expect($response->json('subscription_expires_at'))->toStartWith($renewalAt->toDateString());
});
it('reports cadence as annual when the active price is the annual one', function () {
Plan::create([
'name' => 'pro',
'stripe_price_id_monthly' => 'price_pro_monthly_test',
'stripe_price_id_annual' => 'price_pro_annual_test',
'features' => ['fuel_types' => ['max' => null]],
'active' => true,
]);
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_annual_active',
'stripe_status' => 'active',
'stripe_price' => 'price_pro_annual_test',
'quantity' => 1,
]);
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cadence', 'annual');
});
it('uses ends_at as the expiry date when subscription is cancelled', function () {
$user = User::factory()->create();
$endsAt = now()->addDays(15)->startOfSecond();
$renewalAt = now()->addDays(30)->startOfSecond();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_cancelling_with_period',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
'ends_at' => $endsAt,
'current_period_end' => $renewalAt,
]);
$response = $this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', true);
expect($response->json('subscription_expires_at'))->toStartWith($endsAt->toDateString());
});
it('returns null subscription metadata for users with no subscription', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false)
->assertJsonPath('subscription_cadence', null)
->assertJsonPath('subscribed_at', null)
->assertJsonPath('subscription_expires_at', null);
});
it('logs out and revokes the token', function () { it('logs out and revokes the token', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$token = $user->createToken('api')->plainTextToken; $token = $user->createToken('api')->plainTextToken;

View File

@@ -1,134 +0,0 @@
<?php
use App\Enums\FuelType;
use App\Filament\Resources\UserResource;
use App\Models\Station;
use App\Models\StationPriceCurrent;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
});
function actAsTier(string $tier): User
{
$user = User::factory()->create();
if ($tier !== 'free') {
UserResource::applyTier($user, $tier, 'monthly');
}
test()->actingAs($user->fresh());
return $user;
}
it('returns the full payload for plus users', function () {
actAsTier('plus');
$this->getJson('/api/prediction')
->assertOk()
->assertJsonStructure(['fuel_type', 'reasoning', 'signals'])
->assertJsonMissingPath('tier_locked');
});
it('returns the full payload for pro users', function () {
actAsTier('pro');
$this->getJson('/api/prediction')
->assertOk()
->assertJsonStructure([
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'confidence_score', 'confidence_label', 'action', 'reasoning',
'prediction_horizon_days', 'region_key', 'methodology',
'signals' => [
'trend' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'day_of_week' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'brand_behaviour' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'national_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'regional_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'price_stickiness' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
],
])
->assertJsonPath('fuel_type', 'e10')
->assertJsonPath('region_key', 'national');
});
it('returns only direction + tier_locked flag for guests', function () {
$response = $this->getJson('/api/prediction')->assertOk();
expect($response->json())
->toHaveKey('fuel_type')
->toHaveKey('predicted_direction')
->toHaveKey('tier_locked', true)
->not->toHaveKey('current_avg')
->not->toHaveKey('reasoning')
->not->toHaveKey('signals');
});
it('returns the trimmed payload for free users', function () {
actAsTier('free');
$this->getJson('/api/prediction')
->assertOk()
->assertJsonPath('tier_locked', true)
->assertJsonMissing(['signals' => []])
->assertJsonMissingPath('reasoning');
});
it('returns the trimmed payload for basic users', function () {
actAsTier('basic');
$this->getJson('/api/prediction')
->assertOk()
->assertJsonPath('tier_locked', true)
->assertJsonMissingPath('reasoning');
});
it('includes current average from live prices for pro users', function () {
actAsTier('pro');
$station = Station::factory()->create();
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14750,
]);
$response = $this->getJson('/api/prediction')->assertOk();
expect($response->json('current_avg'))->toBe(147.5);
});
it('returns regional prediction when lat and lng are provided to pro users', function () {
actAsTier('pro');
$this->getJson('/api/prediction?lat=52.5&lng=-0.2')
->assertOk()
->assertJsonPath('region_key', 'regional')
->assertJsonPath('fuel_type', 'e10');
});
it('returns national prediction without coordinates for pro users', function () {
actAsTier('pro');
$this->getJson('/api/prediction')
->assertOk()
->assertJsonPath('region_key', 'national');
});
it('returns 422 for invalid lat', function () {
$this->getJson('/api/prediction?lat=999&lng=0')
->assertUnprocessable()
->assertJsonValidationErrors(['lat']);
});
it('returns 422 for invalid lng', function () {
$this->getJson('/api/prediction?lat=51.5&lng=999')
->assertUnprocessable()
->assertJsonValidationErrors(['lng']);
});

View File

@@ -1,8 +1,10 @@
<?php <?php
use App\Enums\FuelType; use App\Enums\FuelType;
use App\Filament\Resources\UserResource;
use App\Models\Station; use App\Models\Station;
use App\Models\StationPriceCurrent; use App\Models\StationPriceCurrent;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@@ -12,6 +14,15 @@ beforeEach(function () {
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]); $this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
}); });
function asPaidUserOnStations(string $tier = 'plus'): User
{
test()->artisan('db:seed', ['--class' => 'PlanSeeder']);
$user = User::factory()->create();
UserResource::applyTier($user, $tier, 'monthly');
return $user;
}
it('returns stations near coordinates filtered by fuel type', function () { it('returns stations near coordinates filtered by fuel type', function () {
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]); $station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
StationPriceCurrent::factory()->create([ StationPriceCurrent::factory()->create([
@@ -192,3 +203,37 @@ it('includes resolved lat and lng in meta when postcode is provided', function (
->assertJsonPath('meta.lat', 51.5010) ->assertJsonPath('meta.lat', 51.5010)
->assertJsonPath('meta.lng', -0.1415); ->assertJsonPath('meta.lng', -0.1415);
}); });
it('embeds a tier-locked prediction teaser for guest requests', function () {
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
$this->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
->assertOk()
->assertJsonPath('prediction.tier_locked', true)
->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'tier_locked']])
->assertJsonMissingPath('prediction.signals')
->assertJsonMissingPath('prediction.weekly_summary');
});
it('embeds a tier-locked teaser for free-tier authenticated users', function () {
asPaidUserOnStations('free');
$user = User::query()->latest('id')->first();
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
$this->actingAs($user, 'sanctum')
->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
->assertOk()
->assertJsonPath('prediction.tier_locked', true)
->assertJsonMissingPath('prediction.signals');
});
it('embeds the full prediction payload for plus users', function () {
$user = asPaidUserOnStations('plus');
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
$this->actingAs($user, 'sanctum')
->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
->assertOk()
->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'confidence_score', 'reasoning', 'weekly_summary', 'signals']])
->assertJsonMissingPath('prediction.tier_locked');
});

View File

@@ -1,33 +0,0 @@
<?php
use App\Livewire\Public\Fuel\Map;
use Livewire\Livewire;
it('renders the map component', function () {
Livewire::test(Map::class)
->assertStatus(200);
});
it('dispatches map-update browser event when stations-found is received', function () {
Livewire::test(Map::class)
->dispatch('stations-found',
results: [['name' => 'BP Garage']],
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 1],
radius: 5,
prediction: null
)
->assertDispatched('map-update');
});
it('passes radius in map-update payload', function () {
Livewire::test(Map::class)
->dispatch('stations-found',
results: [],
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 0],
radius: 10,
prediction: null
)
->assertDispatched('map-update', fn ($event, $params) =>
$params['radius'] === 10
);
});

View File

@@ -1,52 +0,0 @@
<?php
use App\Livewire\Public\Fuel\Recommendation;
use Livewire\Livewire;
it('renders nothing before stations-found fires', function () {
Livewire::test(Recommendation::class)
->assertStatus(200)
->assertSet('prediction', null)
->assertDontSee('Recommendation');
});
it('shows recommendation card when stations-found includes a prediction', function () {
$prediction = [
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices are rising sharply.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
];
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
->assertSet('prediction', $prediction)
->assertSee('Recommendation')
->assertSee('Fill up now');
});
it('shows nothing when stations-found has null prediction', function () {
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
->assertSet('prediction', null)
->assertDontSee('Recommendation');
});
it('clears previous prediction when new stations-found fires with null prediction', function () {
$prediction = [
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices rising.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
];
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
->assertSee('Recommendation')
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
->assertDontSee('Recommendation');
});

View File

@@ -1,72 +0,0 @@
<?php
use App\Livewire\Public\Fuel\StationList;
use Livewire\Livewire;
it('renders empty state before any search', function () {
Livewire::test(StationList::class)
->assertStatus(200)
->assertSet('hasSearched', false)
->assertDontSee('Stations Nearby');
});
it('shows station cards after stations-found event', function () {
$station = [
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
];
$meta = ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0];
Livewire::test(StationList::class)
->dispatch('stations-found', results: [$station], meta: $meta, prediction: null, radius: 5)
->assertSet('hasSearched', true)
->assertSee('Stations Nearby')
->assertSee('BP Garage')
->assertSee('1 Result');
});
it('shows empty state message when stations-found has no results', function () {
Livewire::test(StationList::class)
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
->assertSet('hasSearched', true)
->assertSee('No stations found');
});
it('updates results when stations-found fires again', function () {
$station = [
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
];
Livewire::test(StationList::class)
->dispatch('stations-found', results: [$station], meta: ['count' => 1], prediction: null, radius: 5)
->assertSee('BP Garage')
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
->assertDontSee('BP Garage');
});

View File

@@ -1,9 +0,0 @@
<?php
use App\Livewire\Public\FuelFinder;
use Livewire\Livewire;
it('renders the fuel finder shell', function () {
Livewire::test(FuelFinder::class)
->assertStatus(200);
});

View File

@@ -2,6 +2,7 @@
use App\Jobs\SendPaymentFailedReminderJob; use App\Jobs\SendPaymentFailedReminderJob;
use App\Listeners\HandleStripeWebhook; use App\Listeners\HandleStripeWebhook;
use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use App\Models\UserNotificationPreference; use App\Models\UserNotificationPreference;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -121,6 +122,63 @@ it('on invoice.payment_failed sets grace_period_until 5 days out and queues both
Queue::assertPushed(SendPaymentFailedReminderJob::class, 2); Queue::assertPushed(SendPaymentFailedReminderJob::class, 2);
}); });
it('persists current_period_start, current_period_end and stripe_data on subscription.updated', function (): void {
$user = User::factory()->create(['stripe_id' => 'cus_period_1']);
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_period_1',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
]);
$start = 1714377600;
$end = 1717056000;
(new HandleStripeWebhook)->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_period_1',
'customer' => 'cus_period_1',
'current_period_start' => $start,
'current_period_end' => $end,
'status' => 'active',
]],
]));
$sub = Subscription::where('stripe_id', 'sub_period_1')->first();
expect($sub->current_period_start->timestamp)->toBe($start);
expect($sub->current_period_end->timestamp)->toBe($end);
expect($sub->stripe_data)->toMatchArray(['id' => 'sub_period_1', 'status' => 'active']);
});
it('reads current_period_end from items.data[0] when not at the root (newer Stripe API)', function (): void {
$user = User::factory()->create(['stripe_id' => 'cus_period_2']);
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_period_2',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
]);
$end = 1719648000;
(new HandleStripeWebhook)->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_period_2',
'customer' => 'cus_period_2',
'items' => ['data' => [['current_period_end' => $end]]],
]],
]));
expect(Subscription::where('stripe_id', 'sub_period_2')->value('current_period_end')->timestamp)->toBe($end);
});
it('repeat invoice.payment_failed within grace does not re-dispatch reminders', function (): void { it('repeat invoice.payment_failed within grace does not re-dispatch reminders', function (): void {
Queue::fake(); Queue::fake();
$existingGrace = now()->addDays(3)->startOfSecond(); $existingGrace = now()->addDays(3)->startOfSecond();

View File

@@ -6,6 +6,7 @@ use App\Models\StationPrice;
use App\Models\StationPriceCurrent; use App\Models\StationPriceCurrent;
use App\Services\NationalFuelPredictionService; use App\Services\NationalFuelPredictionService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -78,14 +79,96 @@ it('includes all required keys in response', function () {
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence', 'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'confidence_score', 'confidence_label', 'action', 'reasoning', 'confidence_score', 'confidence_label', 'action', 'reasoning',
'prediction_horizon_days', 'region_key', 'methodology', 'prediction_horizon_days', 'region_key', 'methodology',
'signals', 'weekly_summary', 'signals',
]) ])
->and($result['signals'])->toHaveKeys([ ->and($result['signals'])->toHaveKeys([
'trend', 'day_of_week', 'brand_behaviour', 'trend', 'day_of_week', 'brand_behaviour',
'national_momentum', 'regional_momentum', 'price_stickiness', 'national_momentum', 'regional_momentum', 'price_stickiness', 'oil',
])
->and($result['weekly_summary'])->toHaveKeys([
'yesterday_avg', 'today_avg', 'tomorrow_estimated_avg',
'yesterday_today_delta_pence', 'last_7_days_series',
'last_7_days_change_pence', 'cheapest_day', 'priciest_day', 'is_regional',
]); ]);
}); });
it('weekly_summary returns null prices and empty series when there is no data', function () {
$result = app(NationalFuelPredictionService::class)->predict();
$weekly = $result['weekly_summary'];
expect($weekly['yesterday_avg'])->toBeNull()
->and($weekly['yesterday_today_delta_pence'])->toBeNull()
->and($weekly['last_7_days_series'])->toBe([])
->and($weekly['cheapest_day'])->toBeNull()
->and($weekly['priciest_day'])->toBeNull()
->and($weekly['is_regional'])->toBeFalse();
});
it('weekly_summary populates yesterday avg, today avg and 7-day series from station_prices', function () {
$station = Station::factory()->create();
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ($daysAgo * 50),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
$weekly = $result['weekly_summary'];
expect($weekly['yesterday_avg'])->toBe(140.5)
->and($weekly['today_avg'])->toBe(140.0)
->and($weekly['yesterday_today_delta_pence'])->toBe(-0.5)
->and(count($weekly['last_7_days_series']))->toBe(7)
->and($weekly['cheapest_day']['avg'])->toBe(140.0)
->and($weekly['priciest_day']['avg'])->toBe(143.0);
});
it('weekly_summary falls back from regional to national when regional data is empty', function () {
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
// Coordinates 600+ km away from any station — no regional data available.
$result = app(NationalFuelPredictionService::class)->predict(58.0, -3.0);
$weekly = $result['weekly_summary'];
expect($weekly['is_regional'])->toBeFalse()
->and(count($weekly['last_7_days_series']))->toBe(7);
});
it('weekly_summary marks is_regional true when stations exist within 50km of coordinates', function () {
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict(51.5074, -0.1278);
expect($result['weekly_summary']['is_regional'])->toBeTrue();
});
it('always returns e10 as fuel_type', function () { it('always returns e10 as fuel_type', function () {
$result = app(NationalFuelPredictionService::class)->predict(); $result = app(NationalFuelPredictionService::class)->predict();
@@ -146,3 +229,166 @@ it('disables trend signal when r_squared is below 0.5', function () {
// Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold // Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold
expect($result['signals']['trend']['data_points'])->toBeInt(); expect($result['signals']['trend']['data_points'])->toBeInt();
}); });
it('oil signal is disabled when no price_predictions row covers today or later', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['oil']['enabled'])->toBeFalse();
});
it('oil signal picks up an llm prediction over an ewma one for the same date', function () {
DB::table('price_predictions')->insert([
[
'predicted_for' => now()->toDateString(),
'source' => 'ewma',
'direction' => 'flat',
'confidence' => 60,
'reasoning' => null,
'generated_at' => now()->subHour(),
],
[
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'rising',
'confidence' => 75,
'reasoning' => 'OPEC cut',
'generated_at' => now(),
],
]);
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
expect($oil['enabled'])->toBeTrue()
->and($oil['direction'])->toBe('up')
->and($oil['score'])->toBe(1.0)
->and($oil['confidence'])->toBe(0.75);
});
it('oil signal prefers llm_with_context over plain llm', function () {
DB::table('price_predictions')->insert([
[
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'falling',
'confidence' => 70,
'reasoning' => 'baseline',
'generated_at' => now(),
],
[
'predicted_for' => now()->toDateString(),
'source' => 'llm_with_context',
'direction' => 'rising',
'confidence' => 82,
'reasoning' => 'with context',
'generated_at' => now(),
],
]);
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
expect($oil['direction'])->toBe('up')
->and($oil['confidence'])->toBe(0.82);
});
it('confidence reaches "high" when trend and oil agree strongly', function () {
$station = Station::factory()->create();
// Strong falling trend over 7 days, ~1p/day
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'falling',
'confidence' => 80,
'reasoning' => 'agree',
'generated_at' => now(),
]);
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['predicted_direction'])->toBe('down')
->and($result['confidence_score'])->toBeGreaterThanOrEqual(70)
->and($result['confidence_label'])->toBe('high');
});
it('confidence drops when trend and oil disagree', function () {
$station = Station::factory()->create();
// Strong falling trend
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
// Oil disagrees: rising
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'rising',
'confidence' => 80,
'reasoning' => 'opec',
'generated_at' => now(),
]);
$agree = app(NationalFuelPredictionService::class)->predict();
// Replace oil with one that agrees instead — confidence should be higher
DB::table('price_predictions')->update([
'direction' => 'falling',
]);
$disagreeReplaced = app(NationalFuelPredictionService::class)->predict();
expect($agree['confidence_score'])->toBeLessThan($disagreeReplaced['confidence_score']);
});
it('day-of-week signal activates at 21 days of history (no longer 56)', function () {
$station = Station::factory()->create();
for ($daysAgo = 25; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ($daysAgo % 7) * 50,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['day_of_week']['enabled'])->toBeTrue();
});
it('reasoning fallback for the wait action does not say "fill up"', function () {
// No data → trend disabled, brand disabled, oil disabled.
// Force a "down" direction by injecting an oil prediction that points down with low confidence.
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'ewma',
'direction' => 'falling',
'confidence' => 50,
'reasoning' => null,
'generated_at' => now(),
]);
$result = app(NationalFuelPredictionService::class)->predict();
if ($result['action'] === 'wait') {
expect($result['reasoning'])->not->toContain('fill up at the cheapest');
} else {
// If thresholds keep this at no_signal, still verify action-aware fallback exists
expect($result['reasoning'])->toBeString();
}
});