From 088fd11058543fb14a7fa257a275f966c5373e45 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Wed, 29 Apr 2026 13:28:33 +0100 Subject: [PATCH] Remove prediction API endpoint and integrate into stations search 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. --- CLAUDE.md | 1 + app/Http/Controllers/Api/AuthController.php | 11 +- .../Controllers/Api/PredictionController.php | 37 -- .../Controllers/Api/StationController.php | 33 +- app/Http/Controllers/BillingController.php | 3 +- app/Http/Requests/Api/PredictionRequest.php | 21 -- app/Listeners/HandleStripeWebhook.php | 62 +++- app/Models/Plan.php | 35 ++ app/Models/PricePrediction.php | 17 +- app/Providers/AppServiceProvider.php | 4 + .../NationalFuelPredictionService.php | 343 ++++++++++++++++-- resources/js/components/PredictionCard.vue | 56 +-- resources/js/components/StationCard.vue | 2 +- resources/js/composables/useAuth.js | 20 + resources/js/composables/usePrediction.js | 31 -- resources/js/composables/useStations.js | 6 +- resources/js/views/Home.vue | 15 +- resources/js/views/dashboard/Overview.vue | 50 ++- .../js/views/dashboard/settings/Security.vue | 2 +- routes/api.php | 2 - tests/Feature/Api/AuthControllerTest.php | 141 +++++++ .../Feature/Api/PredictionControllerTest.php | 134 ------- tests/Feature/Api/StationControllerTest.php | 45 +++ tests/Feature/Livewire/Fuel/MapTest.php | 33 -- .../Livewire/Fuel/RecommendationTest.php | 52 --- .../Feature/Livewire/Fuel/StationListTest.php | 72 ---- tests/Feature/Livewire/FuelFinderTest.php | 9 - .../Payments/HandleStripeWebhookTest.php | 58 +++ .../NationalFuelPredictionServiceTest.php | 250 ++++++++++++- 29 files changed, 1046 insertions(+), 499 deletions(-) delete mode 100644 app/Http/Controllers/Api/PredictionController.php delete mode 100644 app/Http/Requests/Api/PredictionRequest.php delete mode 100644 resources/js/composables/usePrediction.js delete mode 100644 tests/Feature/Api/PredictionControllerTest.php delete mode 100644 tests/Feature/Livewire/Fuel/MapTest.php delete mode 100644 tests/Feature/Livewire/Fuel/RecommendationTest.php delete mode 100644 tests/Feature/Livewire/Fuel/StationListTest.php delete mode 100644 tests/Feature/Livewire/FuelFinderTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 06888d4..ccdc9d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ npm run dev # Vite asset watcher @.claude/rules/database.md @.claude/rules/notifications.md @.claude/rules/scoring.md +@.claude/rules/prediction.md @.claude/rules/payments.md @.claude/rules/tiers.md @.claude/rules/livewire.md diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php index 036b402..9740606 100644 --- a/app/Http/Controllers/Api/AuthController.php +++ b/app/Http/Controllers/Api/AuthController.php @@ -63,10 +63,19 @@ class AuthController extends Controller public function me(Request $request): JsonResponse { $user = $request->user(); + $subscription = $user->subscription(); + + $expiresAt = $subscription?->ends_at ?? $subscription?->current_period_end; return response()->json(array_merge( $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(), + ], )); } } diff --git a/app/Http/Controllers/Api/PredictionController.php b/app/Http/Controllers/Api/PredictionController.php deleted file mode 100644 index a9715ab..0000000 --- a/app/Http/Controllers/Api/PredictionController.php +++ /dev/null @@ -1,37 +0,0 @@ -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); - } -} diff --git a/app/Http/Controllers/Api/StationController.php b/app/Http/Controllers/Api/StationController.php index 49c830a..e7db66e 100644 --- a/app/Http/Controllers/Api/StationController.php +++ b/app/Http/Controllers/Api/StationController.php @@ -8,6 +8,9 @@ use App\Http\Requests\Api\NearbyStationsRequest; use App\Http\Resources\Api\StationResource; use App\Models\Search; use App\Models\Station; +use App\Models\User; +use App\Services\NationalFuelPredictionService; +use App\Services\PlanFeatures; use App\Services\PostcodeService; use Illuminate\Database\Query\JoinClause; use Illuminate\Http\JsonResponse; @@ -16,7 +19,10 @@ use Illuminate\Validation\ValidationException; 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 { @@ -115,6 +121,31 @@ class StationController extends Controller '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 + */ + 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; + } } diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php index d799e6b..349e9a3 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/BillingController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Enums\PlanTier; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Laravel\Cashier\Checkout; use Symfony\Component\HttpFoundation\Response; 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. */ - 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($cadence, ['monthly', 'annual'], true), 404); diff --git a/app/Http/Requests/Api/PredictionRequest.php b/app/Http/Requests/Api/PredictionRequest.php deleted file mode 100644 index 0dcaf32..0000000 --- a/app/Http/Requests/Api/PredictionRequest.php +++ /dev/null @@ -1,21 +0,0 @@ - ['nullable', 'numeric', 'between:-90,90'], - 'lng' => ['nullable', 'numeric', 'between:-180,180'], - ]; - } -} diff --git a/app/Listeners/HandleStripeWebhook.php b/app/Listeners/HandleStripeWebhook.php index 23cba12..25b1de9 100644 --- a/app/Listeners/HandleStripeWebhook.php +++ b/app/Listeners/HandleStripeWebhook.php @@ -3,6 +3,7 @@ namespace App\Listeners; use App\Jobs\SendPaymentFailedReminderJob; +use App\Models\Subscription; use App\Models\User; use App\Models\UserNotificationPreference; use Illuminate\Support\Carbon; @@ -28,15 +29,27 @@ final class HandleStripeWebhook match ($type) { 'customer.subscription.created', - 'customer.subscription.updated' => $this->bustPlanCache($user), - 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user), + 'customer.subscription.updated' => $this->handleSubscriptionUpserted($user, $event->payload['data']['object'] ?? []), + 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user, $event->payload['data']['object'] ?? []), 'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user), 'invoice.payment_failed' => $this->handlePaymentFailed($user), default => null, }; } - private function handleSubscriptionDeleted(User $user): void + /** + * @param array $stripeSubscription + */ + private function handleSubscriptionUpserted(User $user, array $stripeSubscription): void + { + $this->syncPeriodFromStripePayload($stripeSubscription); + $this->bustPlanCache($user); + } + + /** + * @param array $stripeSubscription + */ + private function handleSubscriptionDeleted(User $user, array $stripeSubscription): void { UserNotificationPreference::query() ->where('user_id', $user->id) @@ -45,9 +58,52 @@ final class HandleStripeWebhook $user->forceFill(['grace_period_until' => null])->save(); + $this->syncPeriodFromStripePayload($stripeSubscription); $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 $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 { $user->forceFill(['grace_period_until' => null])->save(); diff --git a/app/Models/Plan.php b/app/Models/Plan.php index 50271bb..44a98f9 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -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 { static::saved(function (): void { diff --git a/app/Models/PricePrediction.php b/app/Models/PricePrediction.php index 89fa6b5..d182777 100644 --- a/app/Models/PricePrediction.php +++ b/app/Models/PricePrediction.php @@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Carbon; #[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])] class PricePrediction extends Model @@ -39,11 +38,17 @@ class PricePrediction extends Model */ public function scopeBestFirst(Builder $query): Builder { - $priority = implode(', ', array_map( - fn (string $v) => "'$v'", - [PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value], - )); + $priority = [ + PredictionSource::LlmWithContext->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'); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e805751..f8e6951 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Listeners\HandleStripeWebhook; +use App\Models\Subscription; use App\Services\ApiLogger; use App\Services\LlmPrediction\AnthropicPredictionProvider; use App\Services\LlmPrediction\GeminiPredictionProvider; @@ -14,6 +15,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Laravel\Cashier\Cashier; use Laravel\Cashier\Events\WebhookReceived; class AppServiceProvider extends ServiceProvider @@ -41,6 +43,8 @@ class AppServiceProvider extends ServiceProvider { $this->configureDefaults(); + Cashier::useSubscriptionModel(Subscription::class); + Event::listen(WebhookReceived::class, HandleStripeWebhook::class); } diff --git a/app/Services/NationalFuelPredictionService.php b/app/Services/NationalFuelPredictionService.php index f38ff40..93f8b6b 100644 --- a/app/Services/NationalFuelPredictionService.php +++ b/app/Services/NationalFuelPredictionService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Enums\FuelType; use App\Models\StationPriceCurrent; +use Carbon\CarbonInterface; use Illuminate\Support\Facades\DB; class NationalFuelPredictionService @@ -12,6 +13,12 @@ class NationalFuelPredictionService 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; /** @@ -40,13 +47,14 @@ class NationalFuelPredictionService $dayOfWeek = $this->computeDayOfWeekSignal($fuelType); $brandBehaviour = $this->computeBrandBehaviourSignal($fuelType); $stickiness = $this->computeStickinessSignal($fuelType); + $oil = $this->computeOilSignal(); $nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions'); $regionalMomentum = $hasCoordinates ? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng) : $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); @@ -65,6 +73,8 @@ class NationalFuelPredictionService default => 'no_signal', }; + $weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope); + return [ 'fuel_type' => $fuelType->value, 'current_avg' => $currentAvg, @@ -73,10 +83,11 @@ class NationalFuelPredictionService 'confidence_score' => $confidenceScore, 'confidence_label' => $confidenceLabel, 'action' => $action, - 'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour), + 'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek), 'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS, 'region_key' => $hasCoordinates ? 'regional' : 'national', 'methodology' => 'multi_signal_live_fallback', + 'weekly_summary' => $weeklySummary, 'signals' => [ 'trend' => $trend, 'day_of_week' => $dayOfWeek, @@ -84,6 +95,7 @@ class NationalFuelPredictionService 'national_momentum' => $nationalMomentum, 'regional_momentum' => $regionalMomentum, 'price_stickiness' => $stickiness, + 'oil' => $oil, ], ]; } @@ -138,7 +150,7 @@ class NationalFuelPredictionService default => 'stable', }; $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); $detail = $direction === 'stable' ? "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(); - if ($uniqueDays < 56) { - return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)"); + if ($uniqueDays < self::DAY_OF_WEEK_MIN_DAYS) { + 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')); @@ -214,9 +226,11 @@ class NationalFuelPredictionService $todayAvg = $dowAverages->get($todayDow, $weekAvg); $cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first(); $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - $cheapestDayName = $dayNames[($cheapestDow - 1) % 7] ?? 'Unknown'; - $weekRange = round(($dowAverages->max() - $dowAverages->min()) / 100, 1); - $tomorrowDelta = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1); + $todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today'; + $tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow'; + + $todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1); + $tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1); $direction = match (true) { ($todayAvg - $weekAvg) / 100 >= 1.5 => 'up', @@ -226,11 +240,34 @@ class NationalFuelPredictionService $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 [ 'score' => $score, 'confidence' => min(1.0, $uniqueDays / 90), 'direction' => $direction, - 'detail' => "Cheapest day: {$cheapestDayName}. Weekly range: {$weekRange}p. Tomorrow typically {$tomorrowDelta}p less than today.", + 'detail' => implode(' ', $parts), 'data_points' => $uniqueDays, '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} */ private function disabledSignal(string $detail): array { @@ -400,46 +494,64 @@ class NationalFuelPredictionService } /** - * Weighted aggregate of enabled signals. - * Returns [direction string, confidence score 0-100]. + * Aggregate enabled signals into a final direction + confidence score. * - * @param array $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 $signals * @return array{0: string, 1: float} */ private function aggregateSignals(array $signals, bool $hasCoordinates = false): array { $weights = $hasCoordinates ? [ - 'regionalMomentum' => 0.50, - 'trend' => 0.20, + 'regionalMomentum' => 0.35, + 'oil' => 0.20, + 'trend' => 0.15, 'dayOfWeek' => 0.15, 'brandBehaviour' => 0.10, 'stickiness' => 0.05, ] : [ - 'trend' => 0.45, + 'trend' => 0.30, + 'oil' => 0.25, 'dayOfWeek' => 0.20, - 'brandBehaviour' => 0.25, + 'brandBehaviour' => 0.15, 'stickiness' => 0.10, ]; - $weightedSum = 0.0; - $totalWeight = 0.0; + $directionalScoreSum = 0.0; + $directionalWeightSum = 0.0; + $confidenceWeightedSum = 0.0; + $totalEnabledWeight = 0.0; foreach ($weights as $key => $weight) { $signal = $signals[$key] ?? null; - if ($signal && $signal['enabled']) { - $weightedSum += $signal['score'] * $signal['confidence'] * $weight; - $totalWeight += $weight; + if (! $signal || ! $signal['enabled']) { + continue; + } + + $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]; } - $normalised = $weightedSum / $totalWeight; - $confidenceScore = round(min(100.0, abs($normalised) * 100), 1); + $normalised = $directionalWeightSum > 0.01 + ? $directionalScoreSum / $directionalWeightSum + : 0.0; $direction = match (true) { $normalised >= 0.1 => 'up', @@ -447,9 +559,175 @@ class NationalFuelPredictionService default => 'stable', }; + $avgConfidence = $confidenceWeightedSum / $totalEnabledWeight; + $agreement = $this->computeAgreement($signals, $weights, $direction); + + $confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1); + 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 $signals + * @param array $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, + * 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, 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. * x is the array index (day number), y is the price value. @@ -491,7 +769,12 @@ class NationalFuelPredictionService 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 = []; @@ -503,8 +786,16 @@ class NationalFuelPredictionService $parts[] = $brandBehaviour['detail']; } + if ($dayOfWeek['enabled']) { + $parts[] = $dayOfWeek['detail']; + } + 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); diff --git a/resources/js/components/PredictionCard.vue b/resources/js/components/PredictionCard.vue index bf04b0d..f5f56db 100644 --- a/resources/js/components/PredictionCard.vue +++ b/resources/js/components/PredictionCard.vue @@ -29,40 +29,13 @@ -
-

Price Prediction

- -

- {{ actionLabel }} -

- -
-
-
- -

{{ prediction.reasoning }}

- -
- Avg: {{ prediction.current_avg }}p - Confidence: {{ prediction.confidence_label }} - - {{ prediction.predicted_change_pence > 0 ? '+' : '' }}{{ prediction.predicted_change_pence.toFixed(1) }}p expected - -
-
+