Compare commits

...

62 Commits

Author SHA1 Message Date
Ovidiu U
25cf022964 feat: add prediction rebuild design spec — Layer 1 ridge model, LLM news overlay, volatility regime detector
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
Documents complete replacement of six-signal aggregator with calibrated
ridge forecaster trained on 435 weeks of BEIS pump prices. Five-layer
architecture: weekly baseline (Layer 1), local snapshot (Layer 2),
rule-gated verdict merger (Layer 3), daily LLM news
2026-05-01 13:23:10 +01:00
Ovidiu U
e821a934a5 feat: add weekly_pump_prices migration for BEIS fuel price data
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
Created migration for storing UK weekly pump prices from BEIS publications.
Table uses Monday date as primary key and stores petrol/diesel pump prices,
duty, and VAT rates as integer pence or percentage values.
2026-05-01 13:22:50 +01:00
Ovidiu U
73de53994f fix: prevent sensitive field leaks in /me, add retry logic to Brent price sources
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
- Made `/api/auth/me` public and return explicit allowlist (name, email,
  two_factor_confirmed_at, tier, subscription fields) instead of spreading
  `$user->toArray()` which leaked is_admin, stripe_id, pm_type, pm_last_four,
  postcode. Returns `null` when unauthenticated rather than 401.
- Moved `/auth/logout` to remain behind auth:sanctum gate.
- Added 3×200ms retry with exponential backoff to EiaBrentPriceSource and
  FredBrentPriceSource on ConnectionException or 5xx responses. Timeout
  raised from 10s to 30s.
- Both sources now throw typed BrentPriceFetchException on exhausted retries
  instead of silently returning null + logging. Updated tests to assert
  exception message includes HTTP status or "connection failed".
2026-05-01 13:22:36 +01:00
Ovidiu U
df70e514e9 refactor: add hard-stop documentation and deny-list for destructive DB commands
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
Documented explicit prohibition of `migrate:fresh`, `migrate:reset`,
`db:wipe`, and raw DROP/TRUNCATE operations in CLAUDE.md. Prose rule
clarifies that user phrases like "trust me" or "do the refactor" are
not authorisation for schema rebuilds — architectural decision is
separate from operational step.

Added matching deny patterns to `.claude/settings.json` to block
direct inv
2026-04-30 09:01:20 +01:00
Ovidiu U
28061541d4 refactor: remove auto-scroll on stations loading
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
Disabled the watch hook that scrolled the viewport when station results
were loading. The 40px nudge disrupted user focus and was not needed
for usability.
2026-04-30 09:01:11 +01:00
Ovidiu U
895d55439b refactor: extract StationSearchService
Audit item #8. StationController::index was ~100 lines doing the
haversine join, in-PHP filter/sort/group, search-row write, and
prediction call — well past the "thin orchestrator" line in
.claude/rules/architecture.md.

- App\Services\StationSearch\SearchCriteria — DTO (lat/lng/fuelType/
  radiusKm/sort)
- App\Services\StationSearch\SearchResult — DTO (stations, prices
  summary, reliability counts, prediction payload)
- App\Services\StationSearch\StationSearchService::search(criteria,
  ?user, ?ipHash) — owns the haversine query, the per-row reliability
  memoisation, sort, count, search-row logging, and the tier-gated
  prediction.

The controller now resolves coordinates, builds a SearchCriteria, calls
the service, and shapes the JSON response. Down from 154 → 71 lines.
Public API contract unchanged — all 15 StationController tests pass
without modification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:20:23 +01:00
Ovidiu U
aff6dd1e0f refactor: SendScheduledWhatsAppJob — drop redundant filtering
Audit item #12. The fan-out job ran an upfront Plan query plus a
per-user tier-name comparison before checking canSendNow('whatsapp').
Both are already covered by canSendNow → canUseChannel + daily-limit
count, so the parent was duplicating filtering work that the child
DispatchUserNotificationJob would do anyway via channelsFor().

Now the parent does only the cheap pre-check (canSendNow) before
dispatching the per-user child job. Iteration uses chunkById(500) to
make the memory bound explicit. Each user remains its own queueable
unit — independent retry, no shared failure mode across the cohort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:30 +01:00
Ovidiu U
06f5f2035f refactor: extract iterateBatches helper in FuelPriceService
Audit item #9. pollPrices() and refreshStations() shared the same
do/while batch loop with token + try/catch + Log::error + clean-exit
detection but different bodies. Extracted to a private iterateBatches()
that takes the endpoint, optional since timestamp, and a process
callable. Returns [total_processed, completedCleanly] so pollPrices
can still gate its incremental-poll cache update on a clean exhaustion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:21 +01:00
Ovidiu U
69eb524e07 fix: ImportPostcodes streams into staging, swaps on success
Audit item #13. The command truncated postcodes + outcodes immediately
and then chunk-inserted from the CSV. A mid-stream failure (network
drive disconnect, malformed row) left the production tables empty
until a successful re-run.

Now streams into a postcodes_staging table created via Schema::create
(works on both MySQL and SQLite) and only swaps into the live tables
once the full CSV has been consumed. A try/finally ensures the file
handle and staging table are cleaned up on any path. Live data is now
preserved on partial failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:15 +01:00
Ovidiu U
b4ef1177b2 refactor: PollFuelPricesJob calls service directly
Audit item #11. The job was a one-line Artisan::call wrapper around
fuel:poll --full. Routing through Artisan adds output buffering and
swallows typed exceptions before they reach the queue's failed-job
handler. Now injects FuelPriceService and replicates the --full path
(refreshStations + pollPrices + PricesUpdatedEvent) directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:05 +01:00
Ovidiu U
8e29980dfe perf: memoize PriceReliability + PriceClassification per row
Audit item #10. PriceReliability::fromUpdatedAt was being invoked
~10× per station per /api/stations response — twice in the sort
comparator (once for $a, once for $b), once in the count groupBy, and
once per resource render. PriceClassification::fromUpdatedAt was
called twice inside the resource (value + label).

The controller now computes the parsed datetime + reliability +
classification once per row and stashes them on the row. Sort,
groupBy, and StationResource read the cached values; the resource
keeps a fresh-compute fallback for callers that bypass the controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:20:59 +01:00
Ovidiu U
4ce5066596 refactor: persist EWMA only on LLM failure, dedup EWMA helper
Audit items #7 and #5.

#7 — BrentPricePredictor::generatePrediction previously wrote both an
EWMA row and an LLM row to price_predictions on every run. The
downstream OilSignal already prefers llm_with_context > llm > ewma, so
the EWMA row was dead weight 95% of the time. Now we try LLM first; if
it returns null (no API key, parse failure, etc.) we compute and persist
EWMA as a real fallback. This also avoids redundant work on the success
path.

Updated the "stores both" test to "stores only LLM" — asserts no EWMA
row is written when the provider succeeds.

#5 — BrentPricePredictor and AnthropicPredictionProvider both had
byte-identical computeEwma() methods with identical EWMA_ALPHA = 0.3
constants. Extracted to App\Services\Ewma::compute() and dropped both
private methods + their alpha constants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:04:41 +01:00
Ovidiu U
c46b017b51 chore: audit nits — PlanFeatures, test boot, EIA log, static method
Audit items #15, #16, #20, #22.

#15 — AuthController::me and UserResource form/table now read tier via
PlanFeatures::for($user)->tier() instead of Plan::resolveForUser($user)
->name. Tiers.md: PlanFeatures is the single entitlement gate.

#16 — Moved SQLite GREATEST/LEAST PHP-backed function registration from
AppServiceProvider::boot to tests/TestCase::setUp. Production app boot no
longer checks the DB driver name.

#20 — FetchOilPrices: added Log::warning on EIA fallback and Log::error
on both-providers-failed so primary-source reliability can be trended
beyond the cron output buffer.

#22 — FuelPriceService::flattenEnabledFlags is now an instance method,
matching the rest of the class. No external callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:09 +01:00
Ovidiu U
7f64c42a23 refactor: extract DbDialect helper, inline ProfileValidationRules trait
Audit items #17 and #21.

#17 — DayOfWeekSignal and StickinessSignal each had their own
isSqlite ternary picking between SQLite (strftime/julianday) and
MySQL (DAYOFWEEK/DATEDIFF) date expressions. Centralised in
App\Services\Prediction\Signals\DbDialect.

#21 — ProfileValidationRules was a trait with one consumer
(CreateNewUser); inlined the rules into the action and deleted the
trait. Also dropped PasswordValidationRules::currentPasswordRules()
which was unused. PasswordValidationRules trait stays (two consumers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:01 +01:00
Ovidiu U
4d9df1ee19 chore: add OneSignal and Vonage env keys to .env.example
Follow-up to the FuelPriceAlert notification wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:21 +01:00
Ovidiu U
5369b4a5a0 feat: build FuelPriceAlert notification with multi-channel adapters
The DispatchUserNotificationJob has been logging sent=true for every
allowed channel without actually sending anything — a TODO marker covered
by the existing test suite, which only asserted log rows. The downstream
"missed today" widget read those rows and reported falsely. This commit
makes the telemetry truthful by wiring the real send.

- App\Notifications\FuelPriceAlert — Notification class with via() that
  returns the per-tier-filtered channel list passed in by the dispatcher.
  Implements toMail / toOneSignal / toVonageWhatsApp / toVonageSms.
  ShouldQueue on the 'notifications' queue.
- App\Notifications\Channels\OneSignalChannel — raw HTTP to OneSignal
  REST API, gated on services.onesignal.{app_id,api_key} + user
  push_token. Logs every call to api_logs via ApiLogger.
- App\Notifications\Channels\VonageWhatsAppChannel — raw HTTP to Vonage
  Messages API, gated on whatsapp_verified_at + whatsapp_number.
- App\Notifications\Channels\VonageSmsChannel — raw HTTP to Vonage SMS
  API, gated on whatsapp_number.
- DispatchUserNotificationJob now calls $user->notify(new
  FuelPriceAlert(...)) before logging.
- New tests: assert the notification IS dispatched with the right
  channels, and that nothing is dispatched when no channels are allowed.

Channels gracefully no-op when their credentials are unset (logging at
info level), so existing tests without a Notification::fake() still
pass — the channels just early-return on missing config.

No new composer dependencies — Vonage SDK avoided in favour of raw HTTP
through the existing ApiLogger pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:10 +01:00
Ovidiu U
27c82ef103 refactor: extract 6 prediction signals into Signal classes
The 803-line NationalFuelPredictionService had six private compute*Signal
methods, a private linearRegression helper, and a private disabledSignal
shape factory all crammed together. Each signal is now an independently
testable class.

- App\Services\Prediction\Signals\Signal — interface
- App\Services\Prediction\Signals\SignalContext — input value object
  (FuelType + optional lat/lng + hasCoordinates() helper)
- App\Services\Prediction\Signals\AbstractSignal — shared
  disabledSignal() and linearRegression() helpers
- TrendSignal, DayOfWeekSignal, BrandBehaviourSignal, StickinessSignal,
  RegionalMomentumSignal, OilSignal — one class each, extending
  AbstractSignal

NationalFuelPredictionService receives the 6 signal classes via constructor
injection and orchestrates them. The lat/lng null-guard for regional
momentum now lives inside RegionalMomentumSignal::compute() so the
coordinator no longer branches on coordinate presence.

Aggregation, weekly summary, and reasoning helpers stay in the service
for now — they are coupled to the public predict() output shape and are
candidates for a follow-up extraction once a stable API is locked in.

Service: 803 → 414 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:43:28 +01:00
Ovidiu U
e39618f5df refactor: extract AbstractLlmPredictionProvider for shared boilerplate
Anthropic, Gemini, and OpenAi providers each repeated: API-key gate,
chronological price-list building, response validation
(direction/confidence/reasoning), TrendDirection::tryFrom, confidence
cap at 85, and the top-level try/catch + Log::error.

Now in AbstractLlmPredictionProvider:
- LLM_MAX_CONFIDENCE constant
- buildPriceList(Collection) helper
- buildPrediction(input, ?source) — handles direction validation,
  confidence cap, model construction
- defaultPrompt(priceList) — shared by Gemini and OpenAi
- Default predict() flow (apiKey + callProvider + buildPrediction +
  try/catch). Gemini and OpenAi only implement apiKey() and
  callProvider(). Anthropic overrides predict() because of its
  multi-phase web-search + forced-tool flow but reuses the helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:35:57 +01:00
Ovidiu U
00d0f7c8ec refactor: extract HaversineQuery helper, fix LL bind quirk
The 5 haversine SQL fragments duplicated across StationController and
NationalFuelPredictionService disagreed on float-clamping (LEAST only,
GREATEST/LEAST, vs. CASE WHEN). Centralised in App\Services\HaversineQuery
with the safe GREATEST(-1.0, LEAST(1.0, …)) form everywhere.

withinKm() embeds the radius as a numeric literal (sprintf %F) because
PDO + SQLite binds float parameters as strings by default, which breaks
numeric comparison against the haversine expression — a NULL filter would
silently match all rows. Coordinates remain bound positionally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:33:07 +01:00
Ovidiu U
48af2083f3 feat: add fuel:archive command and monthly scheduler entry
Completes Tasks 12 + 13 from docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md.

- ArchiveOldPricesCommand moves station_prices rows older than 12 months
  into station_prices_archive in chunks of 1000, wrapping each chunk's
  insert + delete in a DB::transaction so a partial failure can't
  duplicate rows.
- StationPriceArchive: add $table = 'station_prices_archive'; without it
  Eloquent infers 'station_price_archives' and the insert would fail.
- routes/console.php: register fuel:archive monthly on the 1st at 04:00
  UTC, alongside the other fuel/oil scheduler entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:33:05 +01:00
Ovidiu U
783297694c fix: model audit cleanups (primaryKey, fuel_type cast, cadence cache)
- StationPriceCurrent: $primaryKey was null; set to 'station_id' + keyType
  string so Eloquent has a sensible default for save() / find() paths.
- UserNotificationPreference: add FuelType enum cast on fuel_type so it
  hydrates as an enum like every other price model.
- Plan::resolveCadenceForUser: cache for 1h under the same plans tag as
  resolveForUser; HandleStripeWebhook busts both keys on subscription
  events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:32:55 +01:00
Ovidiu U
775e076bb7 Add current_period tracking to subscriptions, document prediction engine, and refactor station list UI
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
Add current_period_start, current_period_end, and stripe_data columns to subscriptions table via migration. Extend Subscription model with datetime casts for new fields. Create comprehensive prediction engine documentation covering signals, aggregation, confidence calibration, and weekly summary logic. Add PredictionFull Vue component displaying action label, reasoning, and 7-day context. Refactor StationList to collapse outdated stations behind expandable section. Add UpsellBanner component with station count formatting. Create .claude/settings.json denying .env file access. Add todo.md tracking Stripe dashboard setup, production deployment steps, and E2E QA checklist. Update .env.example with fuel-finder credentials, Anthropic config, and complete Stripe price IDs.
2026-04-29 18:14:03 +01:00
Ovidiu U
8695d5ec95 refactor: flatten plans.features JSON to typed columns
The features JSON column required defensive fallback stubs in three
places (Plan::resolveForUser, PlanFeatures::__construct, PlanSeeder)
and silently swallowed misspelled keys. Typed columns give Eloquent
type-safe reads, simplify the Filament form (no more dotted JSON
paths), and let resolveForUser fail loud when the free row is
missing.

PlanFeatures public API is unchanged so consumers (jobs, middleware)
need no rewrites — one missed JSON read in SendScheduledWhatsAppJob
was caught and converted to a typed where() query.

tests/Pest.php seeds PlanSeeder in beforeEach so any feature test
that resolves a plan finds the free row, mirroring production where
plans always exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:13:26 +01:00
Ovidiu U
088fd11058 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.
2026-04-29 13:28:33 +01:00
Ovidiu U
ee6de23709 feat: gate full prediction by ai_predictions feature flag
Add a prediction box above filter results on the homepage.
Server returns the full payload only when PlanFeatures::can(
'ai_predictions') — currently plus and pro. Other tiers and
guests get a trimmed {fuel_type, predicted_direction,
tier_locked: true} response so the gate is enforced server-side.

Frontend renders a compact one-liner with the national trend
direction for trimmed responses, full card for unlocked.

Hide the Pro plan card from the pricing section (pro plan
disabled in DB pending real Stripe price ids), and only show
the bottom signup CTA when the visitor is a guest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:29 +01:00
Ovidiu U
2ff3aeba4d fix: admin tier assignment when stripe price env vars are empty
env() returns an empty string (not null) when a STRIPE_PRICE_*
var is set but blank, so the ?? fallback never fired and the
synthetic subscription was created with stripe_price = '' —
which then resolved back to free in Plan::resolveForUser.

Switch to ?: so empty strings also fall back to the synthetic
price_admin_{tier}_{cadence} id, and backfill the matching Plan
row's stripe_price_id_{cadence} when empty so resolution succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:21 +01:00
Ovidiu U
b8adb81c79 chore: gitignore ONSPD source CSV
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:07 +01:00
Ovidiu U
3224b186b2 Merge branch 'feat/stripe-lifecycle'
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
Stripe subscription lifecycle implementation:
- Consolidated HandleStripeWebhook listener (5 event types)
- users.grace_period_until column for past-due state
- Branded Day-3 / Day-5 payment-failure reminder mailables
- SendPaymentFailedReminderJob with grace-guard self-cancel
- Past-due dashboard banner
- Deletion of old DowngradeUserOnSubscriptionDeleted listener

Spec: docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md
Plan: docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:00:19 +01:00
Ovidiu U
36444cde05 feat: add past-due payment banner to dashboard
Show an amber banner to logged-in users whose grace_period_until is set,
linking to the Stripe Customer Portal to update their card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:59:51 +01:00
Ovidiu U
b7175169f0 feat: handle invoice.payment_failed — set grace period and queue reminders
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:56:26 +01:00
Ovidiu U
5b17f4cae4 feat: add SendPaymentFailedReminderJob with grace guard 2026-04-23 10:51:13 +01:00
Ovidiu U
c127cc379e feat: add day-5 branded payment-failure reminder mailable 2026-04-23 10:48:22 +01:00
Ovidiu U
de2499636f feat: add day-3 branded payment-failure reminder mailable 2026-04-23 10:44:37 +01:00
Ovidiu U
2078c4b83e feat: clear grace period on invoice.payment_succeeded
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:37:50 +01:00
Ovidiu U
b9d457578c feat: fold subscription deletion handling into HandleStripeWebhook 2026-04-23 10:34:05 +01:00
Ovidiu U
25b79f095b feat: bust plan cache on customer.subscription.updated 2026-04-23 10:31:07 +01:00
Ovidiu U
a39d4b1b94 feat: consolidate stripe webhook handling into HandleStripeWebhook listener 2026-04-23 10:27:23 +01:00
Ovidiu U
f1c1a1c572 feat: add grace_period_until to users table 2026-04-23 10:23:27 +01:00
Ovidiu U
bf013926c0 docs: add stripe subscription lifecycle spec + implementation plan
Captures the agreed design for Stripe webhook handling, 5-day grace
period with branded day-3/day-5 reminders, and Stripe Customer Portal
as the single subscription-management surface. Updates payments rules
to match and ignores .worktrees/ for isolated implementation work.
2026-04-23 10:05:50 +01:00
Ovidiu U
19fc61a0a3 feat: accept ArcGIS ONSPD column aliases (PCD7/PCD8/PCDS) in postcodes:import 2026-04-22 13:31:27 +01:00
Ovidiu U
13fc227619 docs: link ONSPD attribution to source dataset page 2026-04-22 13:28:39 +01:00
Ovidiu U
d8f87f964d Merge branch 'feat/self-hosted-postcodes'
Self-hosted UK postcode lookup: ONS Postcode Directory loaded into
local postcodes/outcodes tables; postcodes.io retained as fallback
for place names and unknown postcodes, with successful fallback
results persisted back to the local tables.
2026-04-22 13:19:39 +01:00
Ovidiu U
975a1522cf docs: plan for self-hosted UK postcodes 2026-04-22 13:19:33 +01:00
Ovidiu U
29ba2f3d86 docs: add ONS/Royal Mail/OS attribution required by OGL v3 2026-04-22 12:39:11 +01:00
Ovidiu U
3ec7cda790 feat: derive outcode centroids from postcodes during import 2026-04-22 12:36:39 +01:00
Ovidiu U
d01a634f0b test: cover terminated + blank-coord skip paths for postcodes:import 2026-04-22 12:34:19 +01:00
Ovidiu U
9ad62538b9 fix: harden postcodes:import against duplicate headers and test collisions 2026-04-22 12:33:10 +01:00
Ovidiu U
4a60298606 feat: add postcodes:import command for loading ONSPD CSV 2026-04-22 12:28:08 +01:00
Ovidiu U
5426722c71 refactor: scope postcode cache to place names, DB is authoritative for postcodes 2026-04-22 12:23:50 +01:00
Ovidiu U
d460de1850 fix: guard malformed postcodes.io responses and isolate persist errors from HTTP success 2026-04-22 12:22:15 +01:00
Ovidiu U
45bf1c0d24 feat: persist postcodes.io fallback results into local DB 2026-04-22 12:18:20 +01:00
Ovidiu U
1e3b246172 feat: resolve outcodes from local DB before HTTP 2026-04-22 12:13:52 +01:00
Ovidiu U
9fa9ea7835 feat: resolve full postcodes from local DB before HTTP 2026-04-22 12:09:19 +01:00
Ovidiu U
55c81fab7b style: align Postcode/Outcode models with house Fillable+casts convention 2026-04-22 12:07:23 +01:00
Ovidiu U
64a7cc3de5 feat: add Postcode and Outcode Eloquent models 2026-04-22 12:04:39 +01:00
Ovidiu U
7c114c72e4 style: add void return type to postcodes migration closures 2026-04-22 12:03:51 +01:00
Ovidiu U
2fe9c3ef77 feat: add postcodes and outcodes tables for self-hosted lookup 2026-04-22 12:00:53 +01:00
Ovidiu U
b4bd78ab4c Rename SearchBar to PostSearchFilters, add sort controls and brand filter, relocate station count display
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
- Move SearchBar.vue to PostSearchFilters.vue and expand to include sort buttons, brand filter dropdown, and station count
- Integrate sort controls (Reliable/Price/Distance/Updated) with icons into filter bar
- Add brand filter dropdown with dynamic brand list from parent, emit update events
- Move station count from StationList to PostSearchFilters, display as "X station(s) found"
- Remove sort tabs and brand filter from StationList component
- Add force-new-line div for mobile layout between Refine and Sort groups
- Include brand filter in hasActive check and resetFilters function
- Update Home.vue to pass brands/brandFilter props and handle brandFilter updates
- Add reset() method to useStations composable to clear state on empty query
- Clear search state when route query is empty instead of attempting search
- Update Fuel Finder API base URL to include /api/v1 path
- Adjust map zoom levels for 10-15 mile radius range
- Update API token request to use retry and increase timeout to 60s
2026-04-22 11:50:59 +01:00
Ovidiu U
8335f49fd6 Redesign station cards with compact layout, improved typography, and expandable details
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
- Reduce header size and weight, convert all-caps brand names to title case
- Replace address line with distance-only in collapsed state, move brand label to expanded section
- Apply monospace font to pricing, reduce size and weight across labels
- Move badge list and full details into expandable section
- Normalize font weights throughout (semibold for headings, medium for labels)
- Create `.pill` component class with `.is-active` state for consistent filter styling
- Apply pill styling to SearchBar filters, StationList sort buttons, and brand filter
- Add `name` attributes to fuel type and radius selects
- Update package dependencies (@tailwindcss/node, @tailwindcss/oxide, @rolldown/*)
2026-04-22 11:23:05 +01:00
Ovidiu U
dd9bd95657 Redesign search UI with unified input, expandable filters, and integrated map controls
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 HeroSearch into single responsive form with inline geolocation button and submit actions
- Transform SearchBar into pill-based filter bar with visual state indicators (active filters highlighted)
- Move map toggle from separate component into SearchBar with open/close state management
- Redesign StationList sort controls as pills with icons, move brand filter inline, add result count
- Expand LeafletMap to full-width panel (96 viewport height) controlled by parent open state
- Remove nested mobile/desktop layouts in HeroSearch in favor of single adaptive form
- Add "Refine" and "Sort" labels to filter groups, implement clear-all filters button
- Show verdict card only before first search on mobile, hide after results load
- Position StatsRow within hero gradient, move results section into same gradient container
- Update map initialization to only occur when panel is open, destroy on close
- Add accessibility labels (aria-expanded, aria-controls) to map toggle button
2026-04-22 09:38:23 +01:00
Ovidiu U
afe459f248 Lazy-load dashboard and settings views to reduce initial bundle size
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
Convert dashboard and settings component imports from static to dynamic imports using arrow functions, improving initial load performance by code-splitting these routes.
2026-04-20 20:38:04 +01:00
Ovidiu U
d822b77fb0 feat: redesign homepage with responsive hero, verdict card preview, and modular landing components
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
- Extract LandingNav, LiveTicker, StatsRow, VerdictCard, and HeroSearch into reusable landing components
- Implement responsive two-layout strategy: mobile stacked (hero search + verdict card + CTA) vs desktop inline pill input with verdict card sidebar
- Add serif/mono font tokens and live-dot pulse animation to CSS
- Move verdict card above search input on mobile, to right sidebar on desktop
- Replace hero "fill up now" mockup with dynamic VerdictCard showing top stations, pricing, and recommendation
- Simplify navigation with uppercase tracking, add Fleet anchor, and gate CTA by auth state
- Lazy-load LeafletMap with defineAsyncComponent to reduce initial bundle
- Relocate SearchBar below hero on search attempt for persistent filter UI
- Add meta description for SEO
2026-04-20 20:27:02 +01:00
151 changed files with 9396 additions and 2498 deletions

View File

@@ -80,7 +80,7 @@ for that `(station_id, fuel_type)` combination. Avoids row explosion on unchange
```
FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
```
## Postcodes.io — location resolution

View File

@@ -4,51 +4,121 @@
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
## Stripe products
## Source-of-truth spec
`docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md`
defines the full subscription lifecycle. This file is a quick-reference; the
spec document is authoritative on any contradiction.
## Stripe products & prices
Three recurring subscription products, each with monthly and annual prices:
Three recurring subscription products (monthly):
- `basic` — £0.99/mo
- `plus` — £2.49/mo
- `pro` — £3.99/mo
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from .env:
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from `.env`:
```
STRIPE_PRICES_BASIC=price_xxx
STRIPE_PRICES_PLUS=price_xxx
STRIPE_PRICES_PRO=price_xxx
STRIPE_PRICE_BASIC_MONTHLY=price_xxx
STRIPE_PRICE_BASIC_ANNUAL=price_xxx
STRIPE_PRICE_PLUS_MONTHLY=price_xxx
STRIPE_PRICE_PLUS_ANNUAL=price_xxx
STRIPE_PRICE_PRO_MONTHLY=price_xxx
STRIPE_PRICE_PRO_ANNUAL=price_xxx
```
## Tier helpers (SubscriptionService)
Resolution from a Cashier subscription's Stripe price ID to a plan row is done
in `Plan::resolveForUser()` — never hand-code tier lookups elsewhere.
```php
public function tier(User $user): string
// Returns 'free' | 'basic' | 'plus' | 'pro'
## Tier resolution
public function canReceiveSms(User $user): bool
// true if tier is plus or pro
public function smsRemainingThisMonth(User $user): int
// checks alerts table count for current month
```
Never check tier inline in components or notification classes — always use SubscriptionService.
Use `PlanFeatures::for($user)->tier()` — returns `'free' | 'basic' | 'plus' | 'pro'`.
Never inspect `$user->subscribed(...)` directly in components, notifications, or
jobs. `PlanFeatures` is the single source of entitlement truth.
## Cashier conventions
- Billable model: `User` (add `use Billable` trait)
- Webhook route: `POST /stripe/webhook`handled by Cashier automatically
- Billable model: `User` (uses `Billable` trait)
- Webhook route: `POST /stripe/webhook`auto-registered by Cashier
- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET`
- Always handle `customer.subscription.deleted` to downgrade user to free tier
- Trial: none for v1
- `STRIPE_KEY` and `STRIPE_SECRET` also required
- `CASHIER_CURRENCY=gbp`
- Trial period: none
## Upgrade / downgrade flow
## User-facing flows — all via Stripe Customer Portal
- User upgrades in account settings Livewire component
- Swap plan with `$user->subscription()->swap($newPriceId)`
- Cashier handles proration automatically
- On downgrade to free: cancel subscription, remove WhatsApp/SMS notification preference
**The Stripe-hosted Customer Billing Portal handles every subscription
management action.** Do not build custom Livewire upgrade/downgrade UIs.
| Flow | Path |
|---|---|
| Sign up for paid tier | Pricing page → `GET /billing/checkout/{tier}/{cadence}` → Stripe Checkout |
| Upgrade | Pricing page → `GET /billing/portal` → Stripe Portal → pick higher plan → Stripe prorates, charges difference immediately |
| Downgrade | Stripe Portal → pick lower plan → Stripe schedules change at period end |
| Cancel | Stripe Portal → cancel → `cancel_at_period_end=true`; features stay until period end |
| Update card | Stripe Portal, or hosted link in Stripe's transactional dunning email |
| Reactivate after cancel / post-grace | Pricing page → Checkout (new subscription) |
Annual downgrades take effect at the end of the year — no mid-term refunds.
## Webhook handling
Single consolidated listener `HandleStripeWebhook` bound to Cashier's
`WebhookReceived` event in `AppServiceProvider`. Routes on `$event->payload['type']`:
| Event | Behaviour |
|---|---|
| `customer.subscription.created` | Bust `plan_for_user_{id}` cache |
| `customer.subscription.updated` | Bust cache |
| `customer.subscription.deleted` | Downgrade to free, disable WhatsApp + SMS prefs, clear `grace_period_until`, bust cache |
| `invoice.payment_succeeded` | Clear `grace_period_until`, bust cache |
| `invoice.payment_failed` | Set `grace_period_until = now()->addDays(5)`, queue day-3 + day-5 branded reminder mailables |
All branches must be idempotent — Stripe retries failed webhook deliveries.
`invoice.upcoming` is intentionally not handled.
## Payment failure & grace period
5-day grace window. Stripe is configured (dashboard) to retry on days 1, 3, 5
and **cancel the subscription** after the final failure.
- Features stay ON during grace — `past_due` is treated as subscribed by
Cashier, so `PlanFeatures::tier()` keeps returning the paid tier.
- After day 5 Stripe cancels → `customer.subscription.deleted` → downgrade.
- User can pay at any time via Stripe's dunning email link or the Customer
Portal — on success, grace is cleared automatically by the webhook.
## Dunning emails
- **Stripe sends:** payment-failed "update your card", successful-payment
receipts, upcoming-renewal reminders. Configure in Stripe dashboard.
- **We send:** branded reminder mailables on day 3 and day 5 after a
payment failure. Both mailables self-cancel by checking
`$this->user->grace_period_until === null` before sending — simpler than
cancelling queued jobs when payment recovers.
## Data model additions
- `users.grace_period_until` — nullable timestamp. Set on
`invoice.payment_failed`, cleared on `invoice.payment_succeeded` or
`customer.subscription.deleted`. Drives the dashboard past-due banner.
No other schema additions. Cashier + Stripe are the source of truth for
subscription state.
## VAT / Stripe Tax
Not enabled for v1. Revisit before £90k/year turnover (~£1.88k/month at
£3.99 avg, or ~470 paying pro users).
## Stripe test mode
Use Stripe test keys in local `.env`. Never commit real Stripe keys.
Test cards: 4242424242424242 (success), 4000000000000002 (decline).
Test cards:
- `4242 4242 4242 4242` — success
- `4000 0000 0000 0002` — generic decline
- `4000 0000 0000 0341` — renewal charge fails (use to test dunning flow)

211
.claude/rules/prediction.md Normal file
View File

@@ -0,0 +1,211 @@
# Prediction Engine
The "should I fill up now or wait?" recommendation that drives the headline,
notifications, and the entire product. Lives in `app/Services/NationalFuelPredictionService.php`
and is called from `Api\PredictionController`.
> The prediction is the product's selling point. Confidence calibration matters
> as much as direction — a "Wait — prices falling" headline at 30% confidence is
> worse than no recommendation at all.
## Output
`predict(?float $lat, ?float $lng): array` returns:
| Key | Type | Notes |
|---|---|---|
| `fuel_type` | string | currently always `'e10'` |
| `current_avg` | float | current price avg in pence (regional 50km if coords given, else national) |
| `predicted_direction` | `'up' | 'down' | 'stable'` | aggregated vote |
| `predicted_change_pence` | float | `slope × 7` — pence change projected over the prediction horizon |
| `confidence_score` | float (0100) | see "Confidence" below |
| `confidence_label` | `'low' | 'medium' | 'high'` | bucketing of `confidence_score` |
| `action` | `'fill_now' | 'wait' | 'no_signal'` | UI action mapped from direction |
| `reasoning` | string | concatenation of enabled signal `detail` fields, or action-aware fallback |
| `prediction_horizon_days` | int | `7` |
| `region_key` | `'national' | 'regional'` | depends on whether coords were passed |
| `methodology` | string | identifier for backtesting/auditing |
| `weekly_summary` | object | yesterday/today/tomorrow + 7-day series (see below) |
| `signals` | object | per-signal breakdown (see below) |
## Signals
Each signal returns `{score, confidence, direction, detail, data_points, enabled}`.
| Signal | Source | Enabled when | Score formula |
|---|---|---|---|
| `trend` | regression on daily national avg, 5-day adaptive → 14-day | ≥2 daily averages and R² ≥ 0.5 | `min(1, |slope| / SLOPE_SATURATION_PENCE) × sign(slope)` (saturates at `0.5p/day`) |
| `day_of_week` | weekday averages over last 90 days | `unique_days ≥ DAY_OF_WEEK_MIN_DAYS` (21) | `±1` if today ≥1.5p above/below week avg, else `0`; confidence scales with `unique_days/90` |
| `brand_behaviour` | supermarket vs major regression slopes over 7 days | both groups have ≥2 data points and divergence ≥1.0p | `±1` if leader is up/down |
| `regional_momentum` | regression on stations within 50km, 14 days | coords provided + ≥3 daily averages within radius | `±0.7` |
| `price_stickiness` | mean station hold duration over 30 days | ≥10 stations with ≥2 changes | `±0.1` confidence modifier |
| `oil` | latest `price_predictions` row covering today or later | a row exists | `±1` if rising/falling, `0` if flat; confidence = stored `confidence/100` |
| `national_momentum` | reserved | always disabled today | n/a |
### Oil signal — source preference
`computeOilSignal()` picks the freshest row in this order:
1. `source = 'llm_with_context'`
2. `source = 'llm'`
3. `source = 'ewma'`
`OilPriceService` (in `app/Services/OilPriceService.php` and friends) populates
this table daily at 7am via the scheduler. Cap: LLM confidence is capped at 85,
EWMA at 65 (see `.claude/rules/api-data.md`).
The Brent oil signal is the **single biggest unlock** for confidence — it
captures world-news context (OPEC, geopolitical) that pure local price history
can't see.
### Day-of-week threshold
The original spec said 56 days. Lowered to 21 because:
- The signal's `confidence` is already `min(1, unique_days / 90)` — a 21-day
signal naturally contributes only `~0.23` confidence and lifts as more data
accumulates.
- 56 days delays the signal so long it might as well not exist for new users.
## Aggregator
`aggregateSignals(signals, hasCoordinates)` returns `[direction, confidence_score]`.
### Weights
```
National (no coords):
trend 0.30
oil 0.25
dayOfWeek 0.20
brandBehaviour 0.15
stickiness 0.10
----
1.00
Regional (with coords):
regionalMomentum 0.35
oil 0.20
trend 0.15
dayOfWeek 0.15
brandBehaviour 0.10
stickiness 0.05
----
1.00
```
### Direction
```
directional_score = Σ(score × signal_confidence × weight) // only signals with direction ≠ stable
directional_weight = Σ(weight) // only signals with direction ≠ stable
normalised = directional_score / directional_weight (0 if directional_weight ≈ 0)
direction = 'up' if normalised >= 0.1
'down' if normalised <= -0.1
'stable' otherwise
```
**Stable signals do not dilute the direction vote.** They are excluded from both
the numerator and denominator. This is a key fix — previously a single weak
trend signal could be cancelled out by three "stable" signals adding weight
without contributing direction.
### Confidence
```
avg_signal_confidence = Σ(signal_confidence × weight) / Σ(weight) // all enabled signals
agreement = computeAgreement(signals, weights, final_direction) // 0..1
confidence_score = avg_signal_confidence × agreement × 100 (capped at 100)
```
**`avg_signal_confidence`** is how confident the individual signals are in
their own readings (R², sample size, model confidence). Stable signals DO
contribute here — knowing prices are stable is itself a confident answer.
**`agreement`** measures how the 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
- final score: `Σ credit / Σ max_credit`
This separation is the second key fix. Previously `confidence = |normalised| × 100`
conflated "the signals point strongly somewhere" with "we're sure". Now:
- Strong signals all agreeing → high `confidence_score`
- Strong signals disagreeing → low `confidence_score`
- Weak signals → low `confidence_score` (via low individual confidences)
### Confidence labels
| `confidence_score` | `confidence_label` | UI behaviour |
|---|---|---|
| ≥ 70 | `high` | fire notification when allowed |
| 4069 | `medium` | dashboard only |
| < 40 | `low` | dashboard only |
## Reasoning
`buildReasoning()` joins `detail` strings from enabled signals. If none have
material content, it falls back to an **action-aware** sentence:
| `direction` / `action` | Fallback |
|---|---|
| `up` / `fill_now` | "Mild upward signals — top up soon if you're nearby." |
| `down` / `wait` | "Mild downward signals — wait a day or two if your tank can hold." |
| `stable` / `no_signal` | "No clear pattern — fill up at the cheapest station near you now." |
The earlier hard-coded "fill up" fallback contradicted "Wait — prices falling"
headlines and is no longer used.
## Weekly summary
`computeWeeklySummary()` returns the Y/T/T strip + last-7-days numbers:
| Field | Meaning |
|---|---|
| `yesterday_avg` / `today_avg` | regional (50km) → national fallback |
| `tomorrow_estimated_avg` | `today_avg + trend.slope` (slope is 0 if trend disabled) |
| `yesterday_today_delta_pence` | `today yesterday`; sign tells you which was cheaper |
| `last_7_days_series` | array of `{date, avg}`, one entry per day with data |
| `last_7_days_change_pence` | `series[last].avg series[0].avg` |
| `cheapest_day` / `priciest_day` | min/max of the series |
| `is_regional` | `true` only if regional data was actually used; `false` after national fallback |
## API gate
The prediction is **embedded in the `/api/stations` response** under the
`prediction` key — there is no standalone prediction endpoint. The same payload
shape ships back regardless of route, but the gate runs server-side:
`PlanFeatures::for($user)->can('ai_predictions')`.
- ai_predictions allowed (plus, pro): full multi-signal payload
(`fuel_type`, `current_avg`, `predicted_direction`, `confidence_score`,
`reasoning`, `weekly_summary`, `signals`, …)
- otherwise (free, basic, guest): stripped teaser
`{fuel_type, predicted_direction, tier_locked: true}` for the upsell card
Bundling into `/api/stations` ties prediction availability to a real station
search — there is no way to scrape the prediction independently. Don't add a
separate prediction route or accept a request body without coords; the
prediction is always computed alongside a search.
## What never to do
- Don't introduce a new signal without giving it `enabled`, `confidence`, and a
weight in both national + regional weight maps.
- Don't read `brent_prices` directly from the prediction service — go through
`price_predictions`. The prediction table is the source of truth for
oil-direction-as-a-signal.
- Don't reintroduce a confidence formula that uses `|directional_score|` — that
conflates magnitude with sureness.
- Don't add a stable-direction signal to `directional_weight` — stable signals
must not dilute direction.
---
paths:
- "app/Services/NationalFuelPredictionService.php"
- "app/Http/Controllers/Api/StationController.php"
- "tests/Unit/Services/NationalFuelPredictionServiceTest.php"
- "tests/Feature/Api/StationControllerTest.php"
---

30
.claude/settings.json Normal file
View File

@@ -0,0 +1,30 @@
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(.env)",
"Bash(cat .env)",
"Bash(cat ./.env)",
"Bash(head .env)",
"Bash(head ./.env)",
"Bash(tail .env)",
"Bash(tail ./.env)",
"Bash(less .env)",
"Bash(less ./.env)",
"Bash(more .env)",
"Bash(more ./.env)",
"Bash(grep * .env)",
"Bash(grep * ./.env)",
"Bash(rg * .env)",
"Bash(rg * ./.env)",
"Bash(awk * .env)",
"Bash(awk * ./.env)",
"Bash(php artisan migrate:fresh)",
"Bash(php artisan migrate:fresh *)",
"Bash(php artisan migrate:reset)",
"Bash(php artisan migrate:reset *)",
"Bash(php artisan db:wipe)",
"Bash(php artisan db:wipe *)"
]
}
}

View File

@@ -1,8 +1,8 @@
APP_NAME=Laravel
APP_NAME="Fuel Finder"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=http://fuel-price.test
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -20,18 +20,18 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=fuel-price
DB_USERNAME=fuel-price
DB_PASSWORD=password
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SESSION_DOMAIN=.fuel-price.test
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@@ -64,19 +64,37 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
FUELALERT_API_KEY=
FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-haiku-4-5
FRED_API_KEY=
EIA_API_KEY= # US EIA Open Data API key — register free at eia.gov/opendata
ONESIGNAL_APP_ID=
ONESIGNAL_API_KEY=
VONAGE_KEY=
VONAGE_SECRET=
VONAGE_WHATSAPP_FROM=
VONAGE_SMS_FROM=FuelAlert
API_SECRET_KEY=
EIA_API_KEY=
LLM_PREDICTION_PROVIDER=anthropic
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
CASHIER_CURRENCY=gbp
STRIPE_PRICE_BASIC_MONTHLY=
STRIPE_PRICE_BASIC_ANNUAL=
STRIPE_PRICE_PLUS_MONTHLY=
STRIPE_PRICE_PLUS_ANNUAL=
STRIPE_PRICE_BASIC_MONTHLY=price_1TM3cwJuhjW3IKHlJCHz0xmU
STRIPE_PRICE_BASIC_ANNUAL=price_1TM3nlJuhjW3IKHlwcHF5W9v
STRIPE_PRICE_PLUS_MONTHLY=price_1TM3oqJuhjW3IKHlbQUMhrnm
STRIPE_PRICE_PLUS_ANNUAL=price_1TM3pXJuhjW3IKHlfQenHsf1
STRIPE_PRICE_PRO_MONTHLY=
STRIPE_PRICE_PRO_ANNUAL=
SANCTUM_STATEFUL_DOMAINS=fuel-price.test

2
.gitignore vendored
View File

@@ -22,3 +22,5 @@ yarn-error.log
/.vscode
/.zed
/.tmp/
/.worktrees/
/ONSPD_Online_Latest_Centroids_*.csv

View File

@@ -3,6 +3,20 @@
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
based on local price trends. Built solo by a PHP/Laravel developer.
## Destructive DB operations — HARD STOP
**Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves:
- `php artisan migrate:fresh` (with any flags, including `--seed`)
- `php artisan migrate:reset`
- `php artisan db:wipe`
- Raw `DROP TABLE`, `DROP DATABASE`, or `TRUNCATE` via tinker, `database-query`, or any MCP tool
- Any sequence that effectively rebuilds the schema or drops tables
These are also blocked at the harness level via `.claude/settings.json` deny rules, but the prose rule applies everywhere the block doesn't reach (compound shell commands, MCP tools, etc.).
A user saying "trust me", "do the refactor", "clean up the mess", or "I want it in db" is **not** authorisation for these — the architectural decision is separate from the operational step. If a migration is awkward to apply in-place, propose the in-place version (read JSON → populate new columns → drop the old column) instead of suggesting a rebuild. Asking once at the start of a task does not authorise repeat wipes later in the session.
## Project overview
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
@@ -31,6 +45,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

View File

@@ -3,14 +3,14 @@
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
@@ -20,7 +20,8 @@ class CreateNewUser implements CreatesNewUsers
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
'password' => $this->passwordRules(),
])->validate();

View File

@@ -16,14 +16,4 @@ trait PasswordValidationRules
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Models\StationPrice;
use App\Models\StationPriceArchive;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ArchiveOldPricesCommand extends Command
{
protected $signature = 'fuel:archive';
protected $description = 'Move station price history older than 12 months to the archive table';
public function handle(): int
{
$cutoff = Carbon::now()->subMonths(12);
$count = StationPrice::where('price_effective_at', '<', $cutoff)->count();
if ($count === 0) {
$this->info('No prices to archive.');
return self::SUCCESS;
}
$this->info("Archiving {$count} price record(s) older than {$cutoff->toDateString()}...");
StationPrice::where('price_effective_at', '<', $cutoff)
->chunkById(1000, function ($prices): void {
$rows = $prices->map(fn (StationPrice $price): array => [
'station_id' => $price->station_id,
'fuel_type' => $price->fuel_type->value,
'price_pence' => $price->price_pence,
'price_effective_at' => $price->price_effective_at,
'price_reported_at' => $price->price_reported_at,
'recorded_at' => $price->recorded_at,
])->all();
DB::transaction(function () use ($rows, $prices): void {
StationPriceArchive::insert($rows);
StationPrice::whereIn('id', $prices->pluck('id'))->delete();
});
});
$this->info('Archive complete.');
return self::SUCCESS;
}
}

View File

@@ -7,6 +7,7 @@ use App\Services\BrentPriceSources\BrentPriceFetchException;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
#[Signature('oil:fetch')]
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
@@ -20,6 +21,7 @@ class FetchOilPrices extends Command
return self::SUCCESS;
} catch (BrentPriceFetchException $e) {
Log::warning('FetchOilPrices: EIA fetch failed, falling back to FRED', ['error' => $e->getMessage()]);
$this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...');
}
@@ -29,6 +31,7 @@ class FetchOilPrices extends Command
return self::SUCCESS;
} catch (BrentPriceFetchException $e) {
Log::error('FetchOilPrices: both EIA and FRED failed', ['error' => $e->getMessage()]);
$this->error('Both EIA and FRED failed: '.$e->getMessage());
return self::FAILURE;

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Throwable;
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
final class ImportPostcodes extends Command
{
private const int CHUNK_SIZE = 1000;
public function handle(): int
{
$file = $this->option('file');
if ($file === null || ! is_readable($file)) {
$this->error('--file is required and must be a readable path to an ONSPD CSV.');
return self::FAILURE;
}
$handle = fopen($file, 'r');
if ($handle === false) {
$this->error("Unable to open {$file}.");
return self::FAILURE;
}
$header = fgetcsv($handle);
if ($header === false) {
$this->error('CSV is empty.');
fclose($handle);
return self::FAILURE;
}
$headerCounts = array_count_values(array_map('strtolower', $header));
$columns = array_change_key_case(array_flip($header), CASE_LOWER);
$pcdColumn = null;
foreach (['pcd', 'pcds', 'pcd7', 'pcd8'] as $candidate) {
if (isset($columns[$candidate])) {
$pcdColumn = $candidate;
break;
}
}
if ($pcdColumn === null) {
$this->error('Missing required postcode column (expected one of: pcd, pcds, pcd7, pcd8).');
fclose($handle);
return self::FAILURE;
}
foreach ([$pcdColumn, 'lat', 'long'] as $required) {
if (($headerCounts[$required] ?? 0) > 1) {
$this->error("Column '{$required}' appears more than once — refusing to import.");
fclose($handle);
return self::FAILURE;
}
}
foreach (['lat', 'long'] as $required) {
if (! isset($columns[$required])) {
$this->error("Missing required column '{$required}'.");
fclose($handle);
return self::FAILURE;
}
}
$hasDoterm = isset($columns['doterm']);
// Stream into a staging table first. Only swap into the live
// postcodes / outcodes tables once the full CSV has been consumed —
// a mid-stream failure leaves production data untouched.
Schema::dropIfExists('postcodes_staging');
Schema::create('postcodes_staging', function (Blueprint $table): void {
$table->string('postcode', 7);
$table->string('outcode', 4);
$table->decimal('lat', 10, 7);
$table->decimal('lng', 10, 7);
});
$buffer = [];
$imported = 0;
try {
while (($row = fgetcsv($handle)) !== false) {
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
continue;
}
$lat = trim((string) ($row[$columns['lat']] ?? ''));
$lng = trim((string) ($row[$columns['long']] ?? ''));
if ($lat === '' || $lng === '') {
continue;
}
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
if ($pcd === '' || strlen($pcd) < 5) {
continue;
}
$buffer[] = [
'postcode' => $pcd,
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
'lat' => (float) $lat,
'lng' => (float) $lng,
];
if (count($buffer) >= self::CHUNK_SIZE) {
DB::table('postcodes_staging')->insert($buffer);
$imported += count($buffer);
$buffer = [];
}
}
if ($buffer !== []) {
DB::table('postcodes_staging')->insert($buffer);
$imported += count($buffer);
}
// Swap: empty live tables, copy from staging, derive outcodes.
DB::table('outcodes')->truncate();
DB::table('postcodes')->truncate();
DB::statement(
'INSERT INTO postcodes (postcode, outcode, lat, lng)
SELECT postcode, outcode, lat, lng FROM postcodes_staging'
);
DB::statement(
'INSERT INTO outcodes (outcode, lat, lng)
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
);
} catch (Throwable $e) {
$this->error('Import failed — live tables left untouched: '.$e->getMessage());
return self::FAILURE;
} finally {
fclose($handle);
Schema::dropIfExists('postcodes_staging');
}
$this->info("Imported {$imported} postcodes.");
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
return self::SUCCESS;
}
}

View File

@@ -16,7 +16,7 @@ class PlanForm
->components([
Section::make('Fuel Types')
->schema([
TextInput::make('features.fuel_types.max')
TextInput::make('max_fuel_types')
->label('Max fuel types')
->helperText('Leave blank for unlimited.')
->numeric()
@@ -28,9 +28,9 @@ class PlanForm
Section::make('Email')
->columns(2)
->schema([
Toggle::make('features.email.enabled')
Toggle::make('email_enabled')
->label('Enabled'),
Select::make('features.email.frequency')
Select::make('email_frequency')
->label('Frequency')
->options([
'weekly_digest' => 'Weekly digest',
@@ -42,9 +42,9 @@ class PlanForm
Section::make('Push')
->columns(2)
->schema([
Toggle::make('features.push.enabled')
Toggle::make('push_enabled')
->label('Enabled'),
Select::make('features.push.frequency')
Select::make('push_frequency')
->label('Frequency')
->options([
'none' => 'None (disabled)',
@@ -56,15 +56,15 @@ class PlanForm
Section::make('WhatsApp')
->columns(3)
->schema([
Toggle::make('features.whatsapp.enabled')
Toggle::make('whatsapp_enabled')
->label('Enabled'),
TextInput::make('features.whatsapp.daily_limit')
TextInput::make('whatsapp_daily_limit')
->label('Daily limit')
->numeric()
->integer()
->minValue(0)
->required(),
TextInput::make('features.whatsapp.scheduled_updates')
TextInput::make('whatsapp_scheduled_updates')
->label('Scheduled updates per day')
->numeric()
->integer()
@@ -75,9 +75,9 @@ class PlanForm
Section::make('SMS')
->columns(2)
->schema([
Toggle::make('features.sms.enabled')
Toggle::make('sms_enabled')
->label('Enabled'),
TextInput::make('features.sms.daily_limit')
TextInput::make('sms_daily_limit')
->label('Daily limit')
->numeric()
->integer()
@@ -87,11 +87,11 @@ class PlanForm
Section::make('Features')
->schema([
Toggle::make('features.ai_predictions')
Toggle::make('ai_predictions')
->label('AI predictions'),
Toggle::make('features.price_threshold')
Toggle::make('price_threshold')
->label('Price threshold alerts'),
Toggle::make('features.score_alerts')
Toggle::make('score_alerts')
->label('Score change alerts'),
]),
]);

View File

@@ -17,16 +17,16 @@ class PlansTable
->label('Tier')
->badge()
->sortable(),
TextColumn::make('features.email.frequency')
TextColumn::make('email_frequency')
->label('Email')
->placeholder('—'),
TextColumn::make('features.sms.daily_limit')
TextColumn::make('sms_daily_limit')
->label('SMS/day')
->placeholder('—'),
TextColumn::make('features.whatsapp.daily_limit')
TextColumn::make('whatsapp_daily_limit')
->label('WhatsApp/day')
->placeholder('—'),
TextColumn::make('features.fuel_types.max')
TextColumn::make('max_fuel_types')
->label('Fuel types')
->placeholder('Unlimited'),
IconColumn::make('active')

View File

@@ -9,6 +9,7 @@ use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Models\Plan;
use App\Models\User;
use App\Services\PlanFeatures;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
@@ -75,7 +76,7 @@ class UserResource extends Resource
->live()
->dehydrated(false)
->afterStateHydrated(fn (Select $component, ?User $record) => $component
->state($record ? Plan::resolveForUser($record)->name : PlanTier::Free->value)),
->state($record ? PlanFeatures::for($record)->tier() : PlanTier::Free->value)),
Select::make('cadence')
->label('Billing Cadence')
->options([
@@ -131,7 +132,7 @@ class UserResource extends Resource
TextColumn::make('postcode')->placeholder('—'),
TextColumn::make('tier')
->label('Tier')
->state(fn (User $record): string => Plan::resolveForUser($record)->name)
->state(fn (User $record): string => PlanFeatures::for($record)->tier())
->badge()
->colors([
'gray' => 'free',
@@ -182,7 +183,14 @@ class UserResource extends Resource
return;
}
$priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?? "price_admin_{$tier}_{$cadence}";
$priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?: "price_admin_{$tier}_{$cadence}";
$planColumn = $cadence === 'annual' ? 'stripe_price_id_annual' : 'stripe_price_id_monthly';
$plan = Plan::where('name', $tier)->first();
if ($plan && empty($plan->{$planColumn})) {
$plan->update([$planColumn => $priceId]);
}
$user->subscriptions()->create([
'type' => 'default',

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Plan;
use App\Models\User;
use App\Services\PlanFeatures;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -64,9 +65,23 @@ class AuthController extends Controller
{
$user = $request->user();
return response()->json(array_merge(
$user->toArray(),
['tier' => Plan::resolveForUser($user)->name],
));
if ($user === null) {
return new JsonResponse('null', json: true);
}
$subscription = $user->subscription();
$expiresAt = $subscription?->ends_at ?? $subscription?->current_period_end;
return response()->json([
'name' => $user->name,
'email' => $user->email,
'two_factor_confirmed_at' => $user->two_factor_confirmed_at?->toIso8601String(),
'tier' => PlanFeatures::for($user)->tier(),
'subscription_cancelled' => $subscription?->canceled() ?? false,
'subscription_cadence' => Plan::resolveCadenceForUser($user),
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
'subscription_expires_at' => $expiresAt?->toIso8601String(),
]);
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\PredictionRequest;
use App\Services\NationalFuelPredictionService;
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);
return response()->json($result);
}
}

View File

@@ -2,23 +2,60 @@
namespace App\Http\Controllers\Api;
use App\Enums\PriceReliability;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\NearbyStationsRequest;
use App\Http\Resources\Api\StationResource;
use App\Models\Search;
use App\Models\Station;
use App\Services\PostcodeService;
use Illuminate\Database\Query\JoinClause;
use App\Services\StationSearch\SearchCriteria;
use App\Services\StationSearch\StationSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
class StationController extends Controller
{
public function __construct(private readonly PostcodeService $postcodeService) {}
public function __construct(
private readonly PostcodeService $postcodeService,
private readonly StationSearchService $searchService,
) {}
public function index(NearbyStationsRequest $request): JsonResponse
{
[$lat, $lng] = $this->resolveCoordinates($request);
$criteria = new SearchCriteria(
lat: $lat,
lng: $lng,
fuelType: $request->fuelType(),
radiusKm: $request->radius(),
sort: $request->sort(),
);
$result = $this->searchService->search(
$criteria,
$request->user(),
hash('sha256', $request->ip() ?? ''),
);
return response()->json([
'data' => StationResource::collection($result->stations),
'meta' => [
'count' => $result->stations->count(),
'fuel_type' => $criteria->fuelType->value,
'radius_km' => $criteria->radiusKm,
'lat' => $criteria->lat,
'lng' => $criteria->lng,
'lowest_pence' => $result->pricesSummary['lowest'],
'highest_pence' => $result->pricesSummary['highest'],
'cheapest_price_pence' => $result->pricesSummary['lowest'],
'avg_pence' => $result->pricesSummary['avg'],
'reliability_counts' => $result->reliabilityCounts,
],
'prediction' => $result->prediction,
]);
}
/** @return array{0: float, 1: float} */
private function resolveCoordinates(NearbyStationsRequest $request): array
{
if ($request->filled('postcode')) {
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
@@ -27,94 +64,9 @@ class StationController extends Controller
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
}
$lat = $location->lat;
$lng = $location->lng;
} else {
$lat = (float) $request->input('lat');
$lng = (float) $request->input('lng');
return [$location->lat, $location->lng];
}
$fuelType = $request->fuelType();
$radius = $request->radius();
$sort = $request->sort();
$all = Station::query()
->selectRaw(
'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at,
(6371 * acos(GREATEST(-1.0, LEAST(1.0,
cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?))
+ sin(radians(?)) * sin(radians(lat))
)))) AS distance_km',
[$lat, $lng, $lat],
)
->join('station_prices_current as spc', function (JoinClause $join) use ($fuelType): void {
$join->on('stations.node_id', '=', 'spc.station_id')
->where('spc.fuel_type', '=', $fuelType->value);
})
->where('stations.temporary_closure', false)
->where('stations.permanent_closure', false)
->get();
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $radius);
$stations = $sort === 'reliable'
? $filtered
->sort(function ($a, $b) {
$weightA = PriceReliability::fromUpdatedAt(
$a->price_effective_at ? Carbon::parse($a->price_effective_at) : null
)->weight();
$weightB = PriceReliability::fromUpdatedAt(
$b->price_effective_at ? Carbon::parse($b->price_effective_at) : null
)->weight();
return $weightA <=> $weightB
?: ((int) $a->price_pence <=> (int) $b->price_pence)
?: ((float) $a->distance_km <=> (float) $b->distance_km);
})
->values()
: $filtered->sortBy(match ($sort) {
'price' => fn ($s) => (int) $s->price_pence,
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
default => fn ($s) => (float) $s->distance_km,
})->values();
$prices = $stations->pluck('price_pence');
$reliabilityCounts = $stations
->groupBy(fn ($s) => PriceReliability::fromUpdatedAt(
$s->price_effective_at ? Carbon::parse($s->price_effective_at) : null
)->value)
->map->count();
Search::create([
'lat_bucket' => round($lat, 2),
'lng_bucket' => round($lng, 2),
'fuel_type' => $fuelType->value,
'results_count' => $stations->count(),
'lowest_pence' => $prices->min(),
'highest_pence' => $prices->max(),
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
'searched_at' => now(),
'ip_hash' => hash('sha256', $request->ip() ?? ''),
]);
return response()->json([
'data' => StationResource::collection($stations),
'meta' => [
'count' => $stations->count(),
'fuel_type' => $fuelType->value,
'radius_km' => $radius,
'lat' => $lat,
'lng' => $lng,
'lowest_pence' => $prices->min(),
'highest_pence' => $prices->max(),
'cheapest_price_pence' => $prices->min(),
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
'reliability_counts' => [
'reliable' => (int) $reliabilityCounts->get(PriceReliability::Reliable->value, 0),
'stale' => (int) $reliabilityCounts->get(PriceReliability::Stale->value, 0),
'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0),
],
],
]);
return [(float) $request->input('lat'), (float) $request->input('lng')];
}
}

View File

@@ -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);

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

@@ -12,8 +12,13 @@ class StationResource extends JsonResource
{
public function toArray(Request $request): array
{
$updatedAt = $this->price_effective_at ? Carbon::parse($this->price_effective_at) : null;
$reliability = PriceReliability::fromUpdatedAt($updatedAt);
// The controller pre-computes _updated_at / _reliability / _classification
// per row. Falling back to fresh computation keeps the resource usable
// outside that path (e.g. tests or future callers).
$updatedAt = $this->_updated_at
?? ($this->price_effective_at ? Carbon::parse($this->price_effective_at) : null);
$reliability = $this->_reliability ?? PriceReliability::fromUpdatedAt($updatedAt);
$classification = $this->_classification ?? PriceClassification::fromUpdatedAt($updatedAt);
return [
'station_id' => $this->node_id,
@@ -32,11 +37,9 @@ class StationResource extends JsonResource
'open_today' => $this->openTodayPayload(),
'price_pence' => (int) $this->price_pence,
'price' => round((int) $this->price_pence / 100, 2),
'price_updated_at' => $this->price_effective_at
? Carbon::parse($this->price_effective_at)->toISOString()
: null,
'price_classification' => PriceClassification::fromUpdatedAt($updatedAt)->value,
'price_classification_label' => PriceClassification::fromUpdatedAt($updatedAt)->label(),
'price_updated_at' => $updatedAt?->toISOString(),
'price_classification' => $classification->value,
'price_classification_label' => $classification->label(),
'reliability' => $reliability->value,
'reliability_label' => $reliability->label(),
];

View File

@@ -5,15 +5,15 @@ namespace App\Jobs;
use App\Models\NotificationLog;
use App\Models\User;
use App\Models\UserNotificationPreference;
use App\Notifications\FuelPriceAlert;
use App\Services\PlanFeatures;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Resolves allowed notification channels for a user and trigger, sends
* notifications, and logs every outcome (sent, daily_limit, tier_restricted).
*
* Actual sending is stubbed until FuelPriceAlert notification class exists.
* Resolves allowed notification channels for a user and trigger, dispatches
* the FuelPriceAlert notification (which fans out to email + push + WhatsApp +
* SMS), and logs every outcome (sent, daily_limit, tier_restricted).
*/
final class DispatchUserNotificationJob implements ShouldQueue
{
@@ -38,9 +38,21 @@ final class DispatchUserNotificationJob implements ShouldQueue
// Step 3: channels that pass tier + user-pref + daily-limit checks
$allowed = $features->channelsFor($this->triggerType);
// Step 4: send and log sent notifications
// Step 4: dispatch the multi-channel notification — Laravel fans out
// to mail / OneSignal / Vonage WhatsApp / Vonage SMS based on via().
if ($allowed !== []) {
$this->user->notify(new FuelPriceAlert(
$this->triggerType,
$this->fuelType,
$this->price,
$allowed,
));
}
// Step 5: log a sent entry per allowed channel. The notify() call
// above queues per-channel sends; per-channel HTTP outcomes are
// captured in api_logs by the channel adapters themselves.
foreach ($allowed as $channel) {
// TODO: $this->user->notify(new FuelPriceAlert($this->triggerType, $this->fuelType, $this->price));
$this->log($channel, sent: true);
}

View File

@@ -2,16 +2,26 @@
namespace App\Jobs;
use App\Events\PricesUpdatedEvent;
use App\Services\FuelPriceService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Artisan;
/**
* Background full station refresh + price poll, dispatched from the admin
* "Trigger Full Poll" button. Mirrors the `fuel:poll --full` command but
* calls the service directly so typed exceptions surface to the queue's
* failed-job handler instead of being swallowed by Artisan output buffering.
*/
class PollFuelPricesJob implements ShouldQueue
{
use Queueable;
public function handle(): void
public function handle(FuelPriceService $service): void
{
Artisan::call('fuel:poll', ['--full' => true]);
$service->refreshStations();
$inserted = $service->pollPrices();
PricesUpdatedEvent::dispatch($inserted, true);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Jobs;
use App\Mail\PaymentFailedDay3Reminder;
use App\Mail\PaymentFailedDay5Reminder;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;
use InvalidArgumentException;
final class SendPaymentFailedReminderJob implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly int $userId,
public readonly int $day,
) {
$this->onQueue('notifications');
}
public function handle(): void
{
$user = User::find($this->userId);
if ($user === null) {
return;
}
if ($user->grace_period_until === null) {
return;
}
$mailable = match ($this->day) {
3 => new PaymentFailedDay3Reminder($user),
5 => new PaymentFailedDay5Reminder($user),
default => throw new InvalidArgumentException("Unsupported reminder day: {$this->day}"),
};
Mail::to($user->email)->send($mailable);
}
}

View File

@@ -2,16 +2,18 @@
namespace App\Jobs;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use App\Services\PlanFeatures;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Queue\Queueable;
/**
* Fan-out job for scheduled WhatsApp updates (morning / evening).
* Finds all eligible users and dispatches DispatchUserNotificationJob per user.
* Dispatches one DispatchUserNotificationJob per eligible user so each
* user's send is its own queueable unit (independent retry, no shared
* failure mode across the cohort).
*
* Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php.
*/
@@ -28,37 +30,24 @@ final class SendScheduledWhatsAppJob implements ShouldQueue
{
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
// Plans that allow scheduled WhatsApp updates
$eligiblePlanNames = Plan::where('active', true)
->get()
->filter(fn (Plan $plan): bool => ($plan->features['whatsapp']['scheduled_updates'] ?? 0) > 0)
->pluck('name')
->all();
if (empty($eligiblePlanNames)) {
return;
}
// Users who have whatsapp preference enabled
// Candidates: users who have explicitly opted in to WhatsApp.
// Per-user tier + daily-limit + scheduled-updates checks happen via
// canSendNow('whatsapp'); that single call covers tier eligibility
// (canUseChannel) AND today's notification_log count.
$userIds = UserNotificationPreference::where('channel', 'whatsapp')
->where('enabled', true)
->distinct()
->pluck('user_id');
User::whereIn('id', $userIds)
->each(function (User $user) use ($triggerType, $eligiblePlanNames): void {
$features = PlanFeatures::for($user);
->chunkById(500, function (Collection $users) use ($triggerType): void {
foreach ($users as $user) {
if (! PlanFeatures::for($user)->canSendNow('whatsapp')) {
continue;
}
// Skip if their tier isn't eligible or daily limit is hit
if (! in_array($features->tier(), $eligiblePlanNames, strict: true)) {
return;
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
}
if (! $features->canSendNow('whatsapp')) {
return;
}
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
});
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Listeners;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Events\WebhookReceived;
class DowngradeUserOnSubscriptionDeleted
{
public function handle(WebhookReceived $event): void
{
if (($event->payload['type'] ?? null) !== 'customer.subscription.deleted') {
return;
}
$stripeCustomerId = $event->payload['data']['object']['customer'] ?? null;
if (! $stripeCustomerId) {
return;
}
$user = User::where('stripe_id', $stripeCustomerId)->first();
if (! $user) {
return;
}
UserNotificationPreference::query()
->where('user_id', $user->id)
->whereIn('channel', ['whatsapp', 'sms'])
->update(['enabled' => false]);
Cache::tags(['plans'])->forget("plan_for_user_{$user->id}");
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Listeners;
use App\Jobs\SendPaymentFailedReminderJob;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Events\WebhookReceived;
final class HandleStripeWebhook
{
public function handle(WebhookReceived $event): void
{
$type = $event->payload['type'] ?? null;
$stripeCustomerId = $event->payload['data']['object']['customer'] ?? null;
if ($stripeCustomerId === null) {
return;
}
$user = User::where('stripe_id', $stripeCustomerId)->first();
if ($user === null) {
return;
}
match ($type) {
'customer.subscription.created',
'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,
};
}
/**
* @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()
->where('user_id', $user->id)
->whereIn('channel', ['whatsapp', 'sms'])
->update(['enabled' => false]);
$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<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
{
$user->forceFill(['grace_period_until' => null])->save();
$this->bustPlanCache($user);
}
private function handlePaymentFailed(User $user): void
{
// Idempotency: only the first failed-payment event in a grace window
// transitions state + dispatches reminders. Stripe may fire this event
// multiple times per billing cycle (once per failed retry attempt).
if ($user->grace_period_until !== null) {
return;
}
$user->forceFill(['grace_period_until' => Carbon::now()->addDays(5)])->save();
SendPaymentFailedReminderJob::dispatch($user->id, 3)->delay(Carbon::now()->addDays(3));
SendPaymentFailedReminderJob::dispatch($user->id, 5)->delay(Carbon::now()->addDays(5));
$this->bustPlanCache($user);
}
private function bustPlanCache(User $user): void
{
$tag = Cache::tags(['plans']);
$tag->forget("plan_for_user_{$user->id}");
$tag->forget("plan_cadence_for_user_{$user->id}");
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Mail;
use App\Models\User;
use App\Services\PlanFeatures;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
final class PaymentFailedDay3Reminder extends Mailable
{
public function __construct(public readonly User $user) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Heads up — your FuelAlert payment is retrying',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.payment-failed-day-3',
with: [
'name' => $this->user->name,
'tier' => PlanFeatures::for($this->user)->tier(),
'portalUrl' => route('billing.portal'),
],
);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Mail;
use App\Models\User;
use App\Services\PlanFeatures;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
final class PaymentFailedDay5Reminder extends Mailable
{
public function __construct(public readonly User $user) {}
public function envelope(): Envelope
{
$tier = PlanFeatures::for($this->user)->tier();
return new Envelope(
subject: 'Last chance — your '.ucfirst($tier).' features end tomorrow',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.payment-failed-day-5',
with: [
'name' => $this->user->name,
'tier' => PlanFeatures::for($this->user)->tier(),
'portalUrl' => route('billing.portal'),
],
);
}
}

26
app/Models/Outcode.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['outcode', 'lat', 'lng'])]
class Outcode extends Model
{
public $timestamps = false;
protected $primaryKey = 'outcode';
public $incrementing = false;
protected $keyType = 'string';
protected function casts(): array
{
return [
'lat' => 'float',
'lng' => 'float',
];
}
}

View File

@@ -17,7 +17,19 @@ class Plan extends Model
'name',
'stripe_price_id_monthly',
'stripe_price_id_annual',
'features',
'max_fuel_types',
'email_enabled',
'email_frequency',
'push_enabled',
'push_frequency',
'whatsapp_enabled',
'whatsapp_daily_limit',
'whatsapp_scheduled_updates',
'sms_enabled',
'sms_daily_limit',
'ai_predictions',
'price_threshold',
'score_alerts',
'active',
];
@@ -56,28 +68,50 @@ class Plan extends Model
}
);
if ($planId !== null) {
$plan = static::find($planId);
return static::findOrFail($planId);
}
if ($plan !== null) {
return $plan;
/**
* 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
{
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
return $cache->remember(
"plan_cadence_for_user_{$user->id}",
3600,
function () use ($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;
}
}
// Fallback for tests / partially-seeded environments: return a free-tier stub.
return new self([
'name' => PlanTier::Free->value,
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
]);
);
}
protected static function booted(): void
@@ -92,7 +126,17 @@ class Plan extends Model
protected function casts(): array
{
return [
'features' => 'array',
'max_fuel_types' => 'integer',
'email_enabled' => 'boolean',
'push_enabled' => 'boolean',
'whatsapp_enabled' => 'boolean',
'whatsapp_daily_limit' => 'integer',
'whatsapp_scheduled_updates' => 'integer',
'sms_enabled' => 'boolean',
'sms_daily_limit' => 'integer',
'ai_predictions' => 'boolean',
'price_threshold' => 'boolean',
'score_alerts' => 'boolean',
'active' => 'boolean',
];
}

26
app/Models/Postcode.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['postcode', 'outcode', 'lat', 'lng'])]
class Postcode extends Model
{
public $timestamps = false;
protected $primaryKey = 'postcode';
public $incrementing = false;
protected $keyType = 'string';
protected function casts(): array
{
return [
'lat' => 'float',
'lng' => 'float',
];
}
}

View File

@@ -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');
}
}

View File

@@ -10,15 +10,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
class StationPriceArchive extends Model
{
protected $table = 'station_prices_archive';
public $timestamps = false;
protected function casts(): array
{
return [
'fuel_type' => FuelType::class,
'fuel_type' => FuelType::class,
'price_effective_at' => 'datetime',
'price_reported_at' => 'datetime',
'recorded_at' => 'datetime',
'price_reported_at' => 'datetime',
'recorded_at' => 'datetime',
];
}

View File

@@ -19,7 +19,9 @@ class StationPriceCurrent extends Model
public $timestamps = false;
protected $primaryKey = null;
protected $primaryKey = 'station_id';
protected $keyType = 'string';
public $incrementing = false;

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Laravel\Cashier\Subscription as CashierSubscription;
class Subscription extends CashierSubscription
{
protected $casts = [
'ends_at' => 'datetime',
'quantity' => 'integer',
'trial_ends_at' => 'datetime',
'current_period_start' => 'datetime',
'current_period_end' => 'datetime',
'stripe_data' => 'array',
];
}

View File

@@ -17,7 +17,7 @@ use Laravel\Cashier\Billable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type'])]
#[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type', 'grace_period_until'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements FilamentUser
{
@@ -35,6 +35,7 @@ class User extends Authenticatable implements FilamentUser
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_admin' => 'boolean',
'grace_period_until' => 'datetime',
];
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\FuelType;
use Database\Factories\UserNotificationPreferenceFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -44,6 +45,7 @@ class UserNotificationPreference extends Model
{
return [
'enabled' => 'boolean',
'fuel_type' => FuelType::class,
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Notifications\Channels;
use App\Services\ApiLogger;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Sends push notifications via the OneSignal REST API.
*
* Notifications targeting this channel must implement `toOneSignal($notifiable)`
* returning ['heading' => string, 'message' => string] (or `null` to skip).
*
* No-ops when ONESIGNAL_APP_ID/API_KEY are unset, when the notifiable user has
* no `push_token`, or when toOneSignal() returns null. Each call is logged to
* api_logs through ApiLogger.
*/
final class OneSignalChannel
{
public const string NAME = 'onesignal';
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function send(mixed $notifiable, Notification $notification): void
{
$appId = config('services.onesignal.app_id');
$apiKey = config('services.onesignal.api_key');
if ($appId === null || $apiKey === null) {
Log::info('OneSignalChannel: skipped — credentials not configured');
return;
}
$playerId = $notifiable->push_token ?? null;
if ($playerId === null) {
return;
}
$payload = method_exists($notification, 'toOneSignal')
? $notification->toOneSignal($notifiable)
: null;
if ($payload === null) {
return;
}
$url = 'https://api.onesignal.com/notifications';
try {
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
->withToken($apiKey)
->acceptJson()
->post($url, [
'app_id' => $appId,
'include_player_ids' => [$playerId],
'headings' => ['en' => $payload['heading'] ?? 'Fuel Alert'],
'contents' => ['en' => $payload['message'] ?? ''],
]));
} catch (Throwable $e) {
Log::error('OneSignalChannel: send failed', ['error' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Notifications\Channels;
use App\Services\ApiLogger;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Sends SMS messages via the Vonage SMS API (raw HTTP no SDK).
*
* Notifications targeting this channel must implement `toVonageSms($notifiable)`
* returning a string body (or `null` to skip).
*
* No-ops when VONAGE_KEY/SECRET are unset or when the notifiable user has no
* phone number on `whatsapp_number` (the same verified column doubles as SMS
* destination).
*/
final class VonageSmsChannel
{
public const string NAME = 'vonage-sms';
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function send(mixed $notifiable, Notification $notification): void
{
$key = config('services.vonage.key');
$secret = config('services.vonage.secret');
$from = config('services.vonage.sms_from', 'FuelAlert');
if ($key === null || $secret === null) {
Log::info('VonageSmsChannel: skipped — credentials not configured');
return;
}
$to = $notifiable->whatsapp_number ?? null;
if ($to === null) {
return;
}
$body = method_exists($notification, 'toVonageSms')
? $notification->toVonageSms($notifiable)
: null;
if ($body === null) {
return;
}
$url = 'https://rest.nexmo.com/sms/json';
try {
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
->asForm()
->post($url, [
'api_key' => $key,
'api_secret' => $secret,
'from' => $from,
'to' => ltrim($to, '+'),
'text' => $body,
]));
} catch (Throwable $e) {
Log::error('VonageSmsChannel: send failed', ['error' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Notifications\Channels;
use App\Services\ApiLogger;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Sends WhatsApp messages via the Vonage Messages API (raw HTTP no SDK).
*
* Notifications targeting this channel must implement `toVonageWhatsApp($notifiable)`
* returning a string body (or `null` to skip).
*
* No-ops when VONAGE_KEY/SECRET/whatsapp_from are unset, when the user is not
* verified (no whatsapp_verified_at), when whatsapp_number is missing, or when
* the notification returns null.
*/
final class VonageWhatsAppChannel
{
public const string NAME = 'vonage-whatsapp';
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function send(mixed $notifiable, Notification $notification): void
{
$key = config('services.vonage.key');
$secret = config('services.vonage.secret');
$from = config('services.vonage.whatsapp_from');
if ($key === null || $secret === null || $from === null) {
Log::info('VonageWhatsAppChannel: skipped — credentials not configured');
return;
}
$to = $notifiable->whatsapp_number ?? null;
$verified = $notifiable->whatsapp_verified_at ?? null;
if ($to === null || $verified === null) {
return;
}
$body = method_exists($notification, 'toVonageWhatsApp')
? $notification->toVonageWhatsApp($notifiable)
: null;
if ($body === null) {
return;
}
$url = 'https://api.nexmo.com/v1/messages';
try {
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
->withBasicAuth($key, $secret)
->acceptJson()
->post($url, [
'message_type' => 'text',
'channel' => 'whatsapp',
'from' => $from,
'to' => $to,
'text' => $body,
]));
} catch (Throwable $e) {
Log::error('VonageWhatsAppChannel: send failed', ['error' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Notifications;
use App\Notifications\Channels\OneSignalChannel;
use App\Notifications\Channels\VonageSmsChannel;
use App\Notifications\Channels\VonageWhatsAppChannel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Multi-channel fuel price alert. The dispatching job already filters channels
* by tier, user preference, and daily limit `via()` returns exactly that
* filtered set. The notification is queued so individual channel sends don't
* block the dispatch job.
*
* Channel keys map to:
* 'email' mail (Laravel built-in)
* 'push' OneSignalChannel
* 'whatsapp' VonageWhatsAppChannel
* 'sms' VonageSmsChannel
*/
final class FuelPriceAlert extends Notification implements ShouldQueue
{
use Queueable;
/** @var array<string, class-string> */
private const array CHANNEL_MAP = [
'email' => 'mail',
'push' => OneSignalChannel::class,
'whatsapp' => VonageWhatsAppChannel::class,
'sms' => VonageSmsChannel::class,
];
/** @param string[] $channels Pre-filtered channel keys ('email', 'push', 'whatsapp', 'sms') */
public function __construct(
public readonly string $triggerType,
public readonly string $fuelType,
public readonly ?float $price,
public readonly array $channels,
) {
$this->onQueue('notifications');
}
/** @return array<int, string> */
public function via(mixed $notifiable): array
{
return array_values(array_map(
fn (string $key) => self::CHANNEL_MAP[$key] ?? $key,
$this->channels,
));
}
public function toMail(mixed $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->headline())
->greeting("Hi {$notifiable->name},")
->line($this->body())
->action('Open FuelAlert', route('dashboard'))
->line('You can change which alerts you receive in your account settings.');
}
/** @return array{heading: string, message: string} */
public function toOneSignal(mixed $notifiable): array
{
return [
'heading' => $this->headline(),
'message' => $this->body(),
];
}
public function toVonageWhatsApp(mixed $notifiable): string
{
return $this->shortBody();
}
public function toVonageSms(mixed $notifiable): string
{
return $this->shortBody();
}
private function headline(): string
{
return match ($this->triggerType) {
'price_threshold' => 'Price hit your threshold',
'score_change' => 'Fill-up signal changed',
'scheduled_morning' => 'Morning fuel update',
'scheduled_evening' => 'Evening fuel update',
default => 'Fuel alert',
};
}
private function body(): string
{
$fuel = strtoupper($this->fuelType);
$price = $this->price !== null ? number_format($this->price, 1).'p' : null;
return match ($this->triggerType) {
'price_threshold' => $price !== null
? "{$fuel} dropped to {$price} near you."
: "{$fuel} hit your alert threshold.",
'score_change' => "The {$fuel} fill-up score has changed near you.",
'scheduled_morning', 'scheduled_evening' => "Latest {$fuel} update is ready in your dashboard.",
default => "There's a new {$fuel} alert for you.",
};
}
/** SMS/WhatsApp must stay short — single line, ~160 chars max. */
private function shortBody(): string
{
return $this->headline().': '.$this->body();
}
}

View File

@@ -2,7 +2,8 @@
namespace App\Providers;
use App\Listeners\DowngradeUserOnSubscriptionDeleted;
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,7 +43,9 @@ class AppServiceProvider extends ServiceProvider
{
$this->configureDefaults();
Event::listen(WebhookReceived::class, DowngradeUserOnSubscriptionDeleted::class);
Cashier::useSubscriptionModel(Subscription::class);
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
}
/**
@@ -55,13 +59,6 @@ class AppServiceProvider extends ServiceProvider
app()->isProduction(),
);
// SQLite lacks GREATEST/LEAST scalar functions — register them for tests.
if (DB::connection()->getDriverName() === 'sqlite') {
$pdo = DB::connection()->getPdo();
$pdo->sqliteCreateFunction('GREATEST', fn (...$args) => max($args), -1);
$pdo->sqliteCreateFunction('LEAST', fn (...$args) => min($args), -1);
}
Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12)
->mixedCase()

View File

@@ -12,8 +12,6 @@ use Illuminate\Support\Facades\Log;
final class BrentPricePredictor
{
private const float EWMA_ALPHA = 0.3;
private const float EWMA_THRESHOLD_PCT = 1.5;
private const int EWMA_MAX_CONFIDENCE = 65;
@@ -33,8 +31,10 @@ final class BrentPricePredictor
}
/**
* Generate EWMA + LLM predictions, store them, and flag the latest
* brent_prices row as having a prediction generated.
* Try LLM first; persist EWMA only as a fallback when the LLM provider
* returns null. The downstream OilSignal already prefers LLM
* (llm_with_context > llm > ewma), so writing both rows on every run is
* dead weight 95% of the time. EWMA still acts as the safety net.
*/
public function generatePrediction(): ?PricePrediction
{
@@ -48,25 +48,23 @@ final class BrentPricePredictor
return null;
}
$ewma = $this->generateEwmaPrediction($prices);
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
}
$llm = $this->provider->predict($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
return $llm;
}
$result = $llm ?? $ewma;
$ewma = $this->generateEwmaPrediction($prices);
if ($result !== null) {
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
}
return $result;
return $ewma;
}
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
@@ -77,8 +75,8 @@ final class BrentPricePredictor
return null;
}
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
$ewma3 = Ewma::compute(array_slice($chronological, -3));
$ewma7 = Ewma::compute(array_slice($chronological, -7));
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
@@ -112,20 +110,6 @@ final class BrentPricePredictor
]);
}
/**
* @param float[] $prices Chronological (oldest first).
*/
private function computeEwma(array $prices): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
}
return round($ema, 4);
}
private function ewmaConfidence(float $changePct): int
{
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;

View File

@@ -3,8 +3,9 @@
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class EiaBrentPriceSource
@@ -14,12 +15,16 @@ final class EiaBrentPriceSource
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(10)
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(30)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, [
'api_key' => config('services.eia.api_key'),
'frequency' => 'daily',
@@ -29,32 +34,26 @@ final class EiaBrentPriceSource
'sort[0][direction]' => 'desc',
'length' => 30,
]));
if (! $response->successful()) {
Log::error('EiaBrentPriceSource: request failed', ['status' => $response->status()]);
return null;
}
$rows = collect($response->json('response.data') ?? [])
->filter(fn (array $row) => ($row['value'] ?? '.') !== '.')
->map(fn (array $row) => [
'date' => $row['period'],
'price_usd' => (float) $row['value'],
])
->all();
if ($rows === []) {
Log::warning('EiaBrentPriceSource: no valid observations returned');
return null;
}
return $rows;
} catch (Throwable $e) {
Log::error('EiaBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
return null;
} catch (ConnectionException $e) {
throw new BrentPriceFetchException("EIA connection failed: {$e->getMessage()}", previous: $e);
} catch (RequestException $e) {
throw new BrentPriceFetchException("EIA returned HTTP {$e->response->status()}", previous: $e);
}
$rows = collect($response->json('response.data') ?? [])
->filter(fn (array $row) => ($row['value'] ?? '.') !== '.')
->map(fn (array $row) => [
'date' => $row['period'],
'price_usd' => (float) $row['value'],
])
->all();
return $rows === [] ? null : $rows;
}
private function shouldRetry(Throwable $e): bool
{
return $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError());
}
}

View File

@@ -3,8 +3,9 @@
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class FredBrentPriceSource
@@ -14,12 +15,16 @@ final class FredBrentPriceSource
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(10)
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(30)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, [
'series_id' => 'DCOILBRENTEU',
'api_key' => config('services.fred.api_key'),
@@ -27,32 +32,26 @@ final class FredBrentPriceSource
'limit' => 30,
'file_type' => 'json',
]));
if (! $response->successful()) {
Log::error('FredBrentPriceSource: request failed', ['status' => $response->status()]);
return null;
}
$rows = collect($response->json('observations') ?? [])
->filter(fn (array $obs) => $obs['value'] !== '.')
->map(fn (array $obs) => [
'date' => $obs['date'],
'price_usd' => (float) $obs['value'],
])
->all();
if ($rows === []) {
Log::warning('FredBrentPriceSource: no valid observations returned');
return null;
}
return $rows;
} catch (Throwable $e) {
Log::error('FredBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
return null;
} catch (ConnectionException $e) {
throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e);
} catch (RequestException $e) {
throw new BrentPriceFetchException("FRED returned HTTP {$e->response->status()}", previous: $e);
}
$rows = collect($response->json('observations') ?? [])
->filter(fn (array $obs) => $obs['value'] !== '.')
->map(fn (array $obs) => [
'date' => $obs['date'],
'price_usd' => (float) $obs['value'],
])
->all();
return $rows === [] ? null : $rows;
}
private function shouldRetry(Throwable $e): bool
{
return $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError());
}
}

25
app/Services/Ewma.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
namespace App\Services;
/**
* Exponentially-weighted moving average. Pure function used by
* BrentPricePredictor for the EWMA fallback prediction and by
* AnthropicPredictionProvider to enrich the basic-flow prompt.
*/
final class Ewma
{
public const float DEFAULT_ALPHA = 0.3;
/** @param float[] $prices Chronological order (oldest first). */
public static function compute(array $prices, float $alpha = self::DEFAULT_ALPHA): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = $alpha * $price + (1 - $alpha) * $ema;
}
return round($ema, 4);
}
}

View File

@@ -45,7 +45,8 @@ class FuelPriceService
{
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
$url = config('services.fuel_finder.base_url').'/oauth/generate_access_token';
$response = $this->apiLogger->send('fuel_finder', 'POST', $url, fn () => Http::timeout(10)
$response = $this->apiLogger->send('fuel_finder', 'POST', $url, fn () => Http::retry(3, 500)
->timeout(60)
->post($url, [
'client_id' => config('services.fuel_finder.client_id'),
'client_secret' => config('services.fuel_finder.client_secret'),
@@ -66,57 +67,15 @@ class FuelPriceService
*/
public function pollPrices(): int
{
$token = $this->getAccessToken();
$inserted = 0;
$batch = 1;
$pollStartedAt = now();
$since = Cache::get(self::LAST_PRICE_POLL_CACHE_KEY);
$completedCleanly = false;
$sinceCarbon = $since instanceof CarbonInterface ? $since : null;
do {
try {
$baseUrl = config('services.fuel_finder.base_url').'/pfs/fuel-prices';
$params = ['batch-number' => $batch];
if ($since instanceof CarbonInterface) {
$params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s');
}
$logUrl = $baseUrl.'?'.http_build_query($params);
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
->withToken($token)
->get($baseUrl, $params));
if ($response->notFound()) {
$completedCleanly = true;
break;
}
if (! $response->successful()) {
Log::error('FuelPriceService: price batch returned error', [
'batch' => $batch,
'status' => $response->status(),
]);
break;
}
$stations = $response->json() ?? [];
} catch (Throwable $e) {
Log::error('FuelPriceService: price batch fetch failed', [
'batch' => $batch,
'error' => $e->getMessage(),
]);
break;
}
if (empty($stations)) {
$completedCleanly = true;
break;
}
$inserted += $this->processPriceBatch($stations);
$batch++;
} while (true);
[$inserted, $completedCleanly] = $this->iterateBatches(
'/pfs/fuel-prices',
$sinceCarbon,
fn (array $stations): int => $this->processPriceBatch($stations),
);
if ($completedCleanly) {
Cache::forever(self::LAST_PRICE_POLL_CACHE_KEY, $pollStartedAt);
@@ -130,25 +89,53 @@ class FuelPriceService
* Called on full daily refresh before pollPrices().
*/
public function refreshStations(): void
{
$this->iterateBatches('/pfs', null, function (array $stations): int {
$this->upsertStations($stations);
return 0;
});
}
/**
* Drive a paginated fuel-finder endpoint until exhausted, calling
* $process on each non-empty batch. Returns the sum of $process return
* values plus a flag indicating the loop exited cleanly (404 or empty
* body) rather than via an HTTP error or thrown exception. Callers use
* the flag to decide whether to update incremental-poll bookkeeping.
*
* @param callable(array<int, array<string, mixed>>): int $process
* @return array{0: int, 1: bool}
*/
private function iterateBatches(string $endpoint, ?CarbonInterface $since, callable $process): array
{
$token = $this->getAccessToken();
$baseUrl = config('services.fuel_finder.base_url').$endpoint;
$total = 0;
$batch = 1;
$completedCleanly = false;
do {
try {
$baseUrl = config('services.fuel_finder.base_url').'/pfs';
$params = ['batch-number' => $batch];
if ($since !== null) {
$params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s');
}
$logUrl = $baseUrl.'?'.http_build_query($params);
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
->withToken($token)
->get($baseUrl, $params));
if ($response->notFound()) {
break; // No more batches
$completedCleanly = true;
break;
}
if (! $response->successful()) {
Log::error('FuelPriceService: station batch returned error', [
Log::error('FuelPriceService: batch returned error', [
'endpoint' => $endpoint,
'batch' => $batch,
'status' => $response->status(),
]);
@@ -157,7 +144,8 @@ class FuelPriceService
$stations = $response->json() ?? [];
} catch (Throwable $e) {
Log::error('FuelPriceService: station batch fetch failed', [
Log::error('FuelPriceService: batch fetch failed', [
'endpoint' => $endpoint,
'batch' => $batch,
'error' => $e->getMessage(),
]);
@@ -165,12 +153,15 @@ class FuelPriceService
}
if (empty($stations)) {
$completedCleanly = true;
break;
}
$this->upsertStations($stations);
$total += $process($stations);
$batch++;
} while (true);
return [$total, $completedCleanly];
}
/** @param array<int, array<string, mixed>> $apiStations */
@@ -208,9 +199,9 @@ class FuelPriceService
'postcode' => $data['location']['postcode'],
'lat' => $data['location']['latitude'],
'lng' => $data['location']['longitude'],
'amenities' => self::flattenEnabledFlags($data['amenities'] ?? []),
'amenities' => $this->flattenEnabledFlags($data['amenities'] ?? []),
'opening_times' => $data['opening_times'] ?? null,
'fuel_types' => self::flattenEnabledFlags($data['fuel_types'] ?? []),
'fuel_types' => $this->flattenEnabledFlags($data['fuel_types'] ?? []),
'last_seen_at' => $now,
]);
@@ -241,7 +232,7 @@ class FuelPriceService
* @param array<string, bool>|array<int, string> $flags
* @return array<int, string>
*/
private static function flattenEnabledFlags(array $flags): array
private function flattenEnabledFlags(array $flags): array
{
if ($flags === []) {
return [];

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Services;
/**
* Builds canonical haversine SQL fragments for distance and within-radius
* filtering. Centralises the float-clamping (GREATEST/LEAST) and the column
* naming convention used across prediction and station search queries.
*
* Assumes the joined/queried table exposes columns `lat` and `lng`.
*/
final class HaversineQuery
{
private const string DISTANCE_KM_SQL =
'(6371 * acos(GREATEST(-1.0, LEAST(1.0, '
.'cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) '
.'+ sin(radians(?)) * sin(radians(lat))))))';
/**
* Bare distance-in-km expression. Caller adds aliasing or comparison.
*
* @return array{0: string, 1: array{float, float, float}}
*/
public static function distanceKm(float $lat, float $lng): array
{
return [self::DISTANCE_KM_SQL, [$lat, $lng, $lat]];
}
/**
* `<= {km}` predicate suitable for whereRaw. The radius is embedded as a
* numeric literal because PDO + SQLite's whereRaw binds floats as strings
* by default, which breaks numeric comparison against the haversine
* expression. The `float` parameter is type-checked and not user input.
*
* @return array{0: string, 1: array{float, float, float}}
*/
public static function withinKm(float $lat, float $lng, float $km): array
{
return [self::DISTANCE_KM_SQL.' <= '.sprintf('%F', $km), [$lat, $lng, $lat]];
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
abstract class AbstractLlmPredictionProvider implements OilPredictionProvider
{
protected const int LLM_MAX_CONFIDENCE = 85;
public function __construct(
protected readonly ApiLogger $apiLogger,
) {}
/**
* Default flow: gate on API key, call the provider, normalise the payload
* to a PricePrediction. Subclasses with multi-phase flows (e.g. Anthropic
* web-search) override `predict()` directly and reuse the helper methods.
*/
public function predict(Collection $prices): ?PricePrediction
{
$apiKey = $this->apiKey();
if ($apiKey === null) {
return null;
}
try {
$payload = $this->callProvider($apiKey, $this->buildPriceList($prices));
return $payload === null ? null : $this->buildPrediction($payload);
} catch (Throwable $e) {
Log::error(static::class.': predict failed', ['error' => $e->getMessage()]);
return null;
}
}
/** Returns the configured API key or null if not set. */
abstract protected function apiKey(): ?string;
/**
* Make the provider HTTP call and return the normalised payload, or null
* on failure (already logged by the implementer).
*
* @return array{direction: string, confidence: int, reasoning: string}|null
*/
abstract protected function callProvider(string $apiKey, string $priceList): ?array;
/** @param Collection<int, BrentPrice> $prices */
protected function buildPriceList(Collection $prices): string
{
return $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
}
/** @param array{direction: string, confidence: int, reasoning: string} $input */
protected function buildPrediction(array $input, PredictionSource $source = PredictionSource::Llm): ?PricePrediction
{
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
if ($direction === null) {
Log::error(static::class.': invalid direction', ['input' => $input]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => $source,
'direction' => $direction,
'confidence' => min((int) ($input['confidence'] ?? 0), self::LLM_MAX_CONFIDENCE),
'reasoning' => $input['reasoning'] ?? '',
'generated_at' => now(),
]);
}
protected function defaultPrompt(string $priceList): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
}
}

View File

@@ -3,31 +3,23 @@
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use App\Services\Ewma;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class AnthropicPredictionProvider implements OilPredictionProvider
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
{
private const int LLM_MAX_CONFIDENCE = 85;
private const float EWMA_ALPHA = 0.3;
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
/**
* Tries web-search-enriched prediction first, falls back to basic tool use.
* Overrides the parent flow because Anthropic uses two phases (web search
* loop + forced tool call) and selects the source dynamically.
*/
public function predict(Collection $prices): ?PricePrediction
{
if (! config('services.anthropic.api_key')) {
if ($this->apiKey() === null) {
return null;
}
@@ -36,10 +28,21 @@ class AnthropicPredictionProvider implements OilPredictionProvider
return $prediction ?? $this->predictBasic($prices);
}
protected function apiKey(): ?string
{
return config('services.anthropic.api_key');
}
/** {@inheritDoc} */
protected function callProvider(string $apiKey, string $priceList): ?array
{
return null;
}
/**
* Multi-turn web search phase, then a forced submit_prediction call.
* Phase 1: Let the model search for recent oil/geopolitical news (pause_turn loop).
* Phase 2: Force submit_prediction with the full conversation context.
* Phase 1: let the model search for recent oil/geopolitical news.
* Phase 2: force submit_prediction with the full conversation context.
*/
private function predictWithWebContext(Collection $prices): ?PricePrediction
{
@@ -47,7 +50,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
$url = 'https://api.anthropic.com/v1/messages';
try {
// Phase 1: web search loop
for ($i = 0, $response = null; $i < 5; $i++) {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
->withHeaders($this->headers())
@@ -59,7 +61,7 @@ class AnthropicPredictionProvider implements OilPredictionProvider
]));
if (! $response->successful()) {
Log::error('AnthropicPredictionProvider: context search request failed', ['status' => $response->status()]);
Log::error(self::class.': context search request failed', ['status' => $response->status()]);
return null;
}
@@ -71,7 +73,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
}
// Phase 2: forced submit with full context
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
@@ -86,22 +87,61 @@ class AnthropicPredictionProvider implements OilPredictionProvider
]));
if (! $submitResponse->successful()) {
Log::error('AnthropicPredictionProvider: context submit request failed', ['status' => $submitResponse->status()]);
Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]);
return null;
}
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
if ($input === null) {
Log::error('AnthropicPredictionProvider: no tool_use block in context submit response');
return $input === null
? null
: $this->buildPrediction($input, PredictionSource::LlmWithContext);
} catch (Throwable $e) {
Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Single-turn prediction using a forced submit_prediction tool call.
* Guarantees structured output no JSON parsing needed.
*/
private function predictBasic(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date');
$ewma3 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all());
$ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all());
$ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all());
$url = 'https://api.anthropic.com/v1/messages';
try {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
'max_tokens' => 256,
'tools' => [$this->submitPredictionTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
'messages' => [[
'role' => 'user',
'content' => $this->basicPrompt($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14),
]],
]));
if (! $response->successful()) {
Log::error(self::class.': basic request failed', ['status' => $response->status()]);
return null;
}
return $this->buildPrediction($input, PredictionSource::LlmWithContext);
$input = $this->extractToolInput($response->json('content') ?? []);
return $input === null ? null : $this->buildPrediction($input);
} catch (Throwable $e) {
Log::error('AnthropicPredictionProvider: predictWithWebContext failed', ['error' => $e->getMessage()]);
Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]);
return null;
}
@@ -126,18 +166,29 @@ class AnthropicPredictionProvider implements OilPredictionProvider
PROMPT;
}
private function buildPriceList(Collection $prices): string
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
{
return $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Pre-computed indicators:
- 3-day EWMA: \${$ewma3}
- 7-day EWMA: \${$ewma7}
- 14-day EWMA: \${$ewma14}
Use the submit_prediction tool to submit your answer.
PROMPT;
}
/** @return array<string, string> */
private function headers(): array
{
return [
'x-api-key' => config('services.anthropic.api_key'),
'x-api-key' => $this->apiKey(),
'anthropic-version' => '2023-06-01',
];
}
@@ -177,108 +228,4 @@ class AnthropicPredictionProvider implements OilPredictionProvider
return $block['input'] ?? null;
}
/** @param array{direction: string, confidence: int, reasoning: string} $input */
private function buildPrediction(array $input, PredictionSource $source): ?PricePrediction
{
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
if ($direction === null) {
Log::error('AnthropicPredictionProvider: invalid direction in tool input', ['input' => $input]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => $source,
'direction' => $direction,
'confidence' => min((int) $input['confidence'], self::LLM_MAX_CONFIDENCE),
'reasoning' => $input['reasoning'],
'generated_at' => now(),
]);
}
/**
* Single-turn prediction using a forced submit_prediction tool call.
* Guarantees structured output no JSON parsing needed.
*/
private function predictBasic(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date');
$ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all());
$ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all());
$ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all());
$priceList = $this->buildPriceList($prices);
$url = 'https://api.anthropic.com/v1/messages';
try {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
'max_tokens' => 256,
'tools' => [$this->submitPredictionTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
'messages' => [[
'role' => 'user',
'content' => $this->basicPrompt($priceList, $ewma3, $ewma7, $ewma14),
]],
]));
if (! $response->successful()) {
Log::error('AnthropicPredictionProvider: basic request failed', ['status' => $response->status()]);
return null;
}
$input = $this->extractToolInput($response->json('content') ?? []);
if ($input === null) {
Log::error('AnthropicPredictionProvider: no tool_use block in basic response');
return null;
}
return $this->buildPrediction($input, PredictionSource::Llm);
} catch (Throwable $e) {
Log::error('AnthropicPredictionProvider: predictBasic failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* @param float[] $prices Chronological order (oldest first)
*/
private function computeEwma(array $prices): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
}
return round($ema, 4);
}
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Pre-computed indicators:
- 3-day EWMA: \${$ewma3}
- 7-day EWMA: \${$ewma7}
- 14-day EWMA: \${$ewma14}
Use the submit_prediction tool to submit your answer.
PROMPT;
}
}

View File

@@ -2,110 +2,59 @@
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class GeminiPredictionProvider implements OilPredictionProvider
class GeminiPredictionProvider extends AbstractLlmPredictionProvider
{
private const int LLM_MAX_CONFIDENCE = 85;
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function predict(Collection $prices): ?PricePrediction
protected function apiKey(): ?string
{
if (! config('services.gemini.api_key')) {
return null;
}
$priceList = $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
return config('services.gemini.api_key');
}
protected function callProvider(string $apiKey, string $priceList): ?array
{
$model = config('services.gemini.model', 'gemini-2.0-flash');
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
try {
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
->withQueryParameters(['key' => config('services.gemini.api_key')])
->post($url, [
'contents' => [[
'parts' => [['text' => $this->prompt($priceList)]],
]],
'generationConfig' => [
'responseMimeType' => 'application/json',
'responseSchema' => [
'type' => 'OBJECT',
'properties' => [
'direction' => [
'type' => 'STRING',
'enum' => ['rising', 'falling', 'flat'],
],
'confidence' => ['type' => 'INTEGER'],
'reasoning' => ['type' => 'STRING'],
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
->withQueryParameters(['key' => $apiKey])
->post($url, [
'contents' => [[
'parts' => [['text' => $this->defaultPrompt($priceList)]],
]],
'generationConfig' => [
'responseMimeType' => 'application/json',
'responseSchema' => [
'type' => 'OBJECT',
'properties' => [
'direction' => [
'type' => 'STRING',
'enum' => ['rising', 'falling', 'flat'],
],
'required' => ['direction', 'confidence', 'reasoning'],
'confidence' => ['type' => 'INTEGER'],
'reasoning' => ['type' => 'STRING'],
],
'required' => ['direction', 'confidence', 'reasoning'],
],
]));
],
]));
if (! $response->successful()) {
Log::error('GeminiPredictionProvider: request failed', ['status' => $response->status()]);
return null;
}
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
$data = json_decode($text, true);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error('GeminiPredictionProvider: unexpected response format', ['text' => $text]);
return null;
}
$direction = TrendDirection::tryFrom($data['direction']);
if ($direction === null) {
Log::error('GeminiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Llm,
'direction' => $direction,
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
'reasoning' => $data['reasoning'],
'generated_at' => now(),
]);
} catch (Throwable $e) {
Log::error('GeminiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
if (! $response->successful()) {
Log::error(self::class.': request failed', ['status' => $response->status()]);
return null;
}
}
private function prompt(string $priceList): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
$data = json_decode($text, true);
Recent Brent crude prices (USD/barrel):
{$priceList}
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error(self::class.': unexpected response format', ['text' => $text]);
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
return null;
}
return $data;
}
}

View File

@@ -2,112 +2,61 @@
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class OpenAiPredictionProvider implements OilPredictionProvider
class OpenAiPredictionProvider extends AbstractLlmPredictionProvider
{
private const int LLM_MAX_CONFIDENCE = 85;
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function predict(Collection $prices): ?PricePrediction
protected function apiKey(): ?string
{
if (! config('services.openai.api_key')) {
return null;
}
$priceList = $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
return config('services.openai.api_key');
}
protected function callProvider(string $apiKey, string $priceList): ?array
{
$url = 'https://api.openai.com/v1/chat/completions';
try {
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
->withToken(config('services.openai.api_key'))
->post($url, [
'model' => config('services.openai.model', 'gpt-4o-mini'),
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'oil_prediction',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
'confidence' => ['type' => 'integer'],
'reasoning' => ['type' => 'string'],
],
'required' => ['direction', 'confidence', 'reasoning'],
'additionalProperties' => false,
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
->withToken($apiKey)
->post($url, [
'model' => config('services.openai.model', 'gpt-4o-mini'),
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'oil_prediction',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
'confidence' => ['type' => 'integer'],
'reasoning' => ['type' => 'string'],
],
'required' => ['direction', 'confidence', 'reasoning'],
'additionalProperties' => false,
],
],
'messages' => [[
'role' => 'user',
'content' => $this->prompt($priceList),
]],
]));
],
'messages' => [[
'role' => 'user',
'content' => $this->defaultPrompt($priceList),
]],
]));
if (! $response->successful()) {
Log::error('OpenAiPredictionProvider: request failed', ['status' => $response->status()]);
return null;
}
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error('OpenAiPredictionProvider: unexpected response format', ['data' => $data]);
return null;
}
$direction = TrendDirection::tryFrom($data['direction']);
if ($direction === null) {
Log::error('OpenAiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Llm,
'direction' => $direction,
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
'reasoning' => $data['reasoning'],
'generated_at' => now(),
]);
} catch (Throwable $e) {
Log::error('OpenAiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
if (! $response->successful()) {
Log::error(self::class.': request failed', ['status' => $response->status()]);
return null;
}
}
private function prompt(string $priceList): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
Recent Brent crude prices (USD/barrel):
{$priceList}
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error(self::class.': unexpected response format', ['data' => $data]);
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
return null;
}
return $data;
}
}

View File

@@ -4,16 +4,31 @@ namespace App\Services;
use App\Enums\FuelType;
use App\Models\StationPriceCurrent;
use App\Services\Prediction\Signals\BrandBehaviourSignal;
use App\Services\Prediction\Signals\DayOfWeekSignal;
use App\Services\Prediction\Signals\OilSignal;
use App\Services\Prediction\Signals\RegionalMomentumSignal;
use App\Services\Prediction\Signals\SignalContext;
use App\Services\Prediction\Signals\StickinessSignal;
use App\Services\Prediction\Signals\TrendSignal;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
class NationalFuelPredictionService
{
private const float R_SQUARED_THRESHOLD = 0.5;
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const int PREDICTION_HORIZON_DAYS = 7;
public function __construct(
private readonly TrendSignal $trendSignal,
private readonly DayOfWeekSignal $dayOfWeekSignal,
private readonly BrandBehaviourSignal $brandBehaviourSignal,
private readonly StickinessSignal $stickinessSignal,
private readonly RegionalMomentumSignal $regionalMomentumSignal,
private readonly OilSignal $oilSignal,
) {}
/**
* @return array{
* fuel_type: string,
@@ -34,19 +49,19 @@ class NationalFuelPredictionService
{
$fuelType = FuelType::E10;
$hasCoordinates = $lat !== null && $lng !== null;
$context = new SignalContext($fuelType, $lat, $lng);
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
$trend = $this->computeTrendSignal($fuelType);
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
$stickiness = $this->computeStickinessSignal($fuelType);
$trend = $this->trendSignal->compute($context);
$dayOfWeek = $this->dayOfWeekSignal->compute($context);
$brandBehaviour = $this->brandBehaviourSignal->compute($context);
$stickiness = $this->stickinessSignal->compute($context);
$oil = $this->oilSignal->compute($context);
$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');
$regionalMomentum = $this->regionalMomentumSignal->compute($context);
$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 +80,8 @@ class NationalFuelPredictionService
default => 'no_signal',
};
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
return [
'fuel_type' => $fuelType->value,
'current_avg' => $currentAvg,
@@ -73,10 +90,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 +102,7 @@ class NationalFuelPredictionService
'national_momentum' => $nationalMomentum,
'regional_momentum' => $regionalMomentum,
'price_stickiness' => $stickiness,
'oil' => $oil,
],
];
}
@@ -91,10 +110,12 @@ class NationalFuelPredictionService
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
{
if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$avg = DB::table('station_prices_current')
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
->where('station_prices_current.fuel_type', $fuelType->value)
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
->whereRaw($radiusSql, $radiusBindings)
->avg('station_prices_current.price_pence');
if ($avg !== null) {
@@ -107,285 +128,6 @@ class NationalFuelPredictionService
return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
}
/**
* Linear regression on daily national average prices.
* Tries 5-day lookback first; falls back to 14-day if < threshold.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float}
*/
private function computeTrendSignal(FuelType $fuelType): array
{
foreach ([5, 14] as $lookbackDays) {
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 2) {
continue;
}
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
$slope = $regression['slope'];
$direction = match (true) {
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
$absSlope = abs($slope);
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / 2.0) * ($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']})"
: sprintf(
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
$slope > 0 ? 'Rising' : 'Falling',
abs(round($slope, 2)),
$lookbackDays,
round($regression['r_squared'], 2),
$projected > 0 ? '+' : '',
$projected,
self::PREDICTION_HORIZON_DAYS,
);
if ($lookbackDays === 5) {
$detail .= ' [Adaptive lookback active]';
}
return [
'score' => $score,
'confidence' => min(1.0, $regression['r_squared']),
'direction' => $direction,
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
'slope' => round($slope, 3),
'r_squared' => round($regression['r_squared'], 3),
];
}
}
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
'data_points' => 0,
'enabled' => false,
'slope' => 0.0,
'r_squared' => 0.0,
];
}
/**
* Compare today's average price against the per-weekday average over 90 days.
* Requires 56+ days of history to activate.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeDayOfWeekSignal(FuelType $fuelType): array
{
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
$dowExpr = $isSqlite
? "(CAST(strftime('%w', price_effective_at) AS INTEGER) + 1)"
: 'DAYOFWEEK(price_effective_at)';
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays(90))
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
->groupBy('dow', 'day')
->get();
$uniqueDays = $rows->pluck('day')->unique()->count();
if ($uniqueDays < 56) {
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)");
}
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
$weekAvg = $dowAverages->avg();
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
$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);
$direction = match (true) {
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
default => 'stable',
};
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
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.",
'data_points' => $uniqueDays,
'enabled' => true,
];
}
/**
* Compare supermarket vs non-supermarket 7-day price trend.
* Detects divergence where one group has moved but the other hasn't yet.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeBrandBehaviourSignal(FuelType $fuelType): array
{
$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(7))
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('stations.is_supermarket', 'day')
->orderBy('day')
->get();
$supermarket = $rows->where('is_supermarket', 1)->values();
$major = $rows->where('is_supermarket', 0)->values();
if ($supermarket->count() < 2 || $major->count() < 2) {
return $this->disabledSignal('Insufficient brand data for comparison');
}
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
$supermarketChange = round($supermarketSlope * 7, 1);
$majorChange = round($majorSlope * 7, 1);
if ($divergence < 1.0) {
return [
'score' => 0.0,
'confidence' => 0.5,
'direction' => 'stable',
'detail' => 'Supermarkets and majors moving in sync.',
'data_points' => $rows->count(),
'enabled' => true,
];
}
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
$direction = $leaderChange > 0 ? 'up' : 'down';
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
$leaderAbs = abs($leaderChange);
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
return [
'score' => $direction === 'up' ? 1.0 : -1.0,
'confidence' => min(1.0, $divergence / 5.0),
'direction' => $direction,
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
'data_points' => $rows->count(),
'enabled' => true,
];
}
/**
* Average hold duration (days between price changes) as a confidence modifier.
* Requires 30+ days of history. Returns a score between -0.1 and +0.1.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeStickinessSignal(FuelType $fuelType): array
{
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
$diffExpr = $isSqlite
? 'CAST((julianday(MAX(price_effective_at)) - julianday(MIN(price_effective_at))) AS INTEGER)'
: 'DATEDIFF(MAX(price_effective_at), MIN(price_effective_at))';
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays(30))
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
->groupBy('station_id')
->having('changes', '>', 1)
->having('span_days', '>', 0)
->get();
if ($rows->count() < 10) {
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
}
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
$avgHoldDays = round((float) $avgHoldDays, 1);
$score = match (true) {
$avgHoldDays < 2 => -0.1,
$avgHoldDays > 5 => 0.1,
default => 0.0,
};
$detail = match (true) {
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
default => "Normal hold period (avg: {$avgHoldDays} days).",
};
return [
'score' => $score,
'confidence' => min(1.0, $rows->count() / 200),
'direction' => 'stable',
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
];
}
/**
* Placeholder for regional momentum signal (requires lat/lng).
* Compares local station prices vs national average trend.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeRegionalMomentumSignal(FuelType $fuelType, float $lat, float $lng): array
{
// Regional momentum: compare trend of stations within 50km vs national trend
$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(14))
->whereRaw('(6371 * acos(CASE WHEN (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) > 1.0 THEN 1.0 ELSE (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) END)) <= 50', [$lat, $lng, $lat, $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();
if ($rows->count() < 3) {
return $this->disabledSignal('Insufficient regional data');
}
$regionalRegression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
$direction = match (true) {
$regionalRegression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
$regionalRegression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
return [
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
'confidence' => min(1.0, $regionalRegression['r_squared']),
'direction' => $direction,
'detail' => 'Regional trend: '.round($regionalRegression['slope'], 2).'p/day (R²='.round($regionalRegression['r_squared'], 2).')',
'data_points' => $rows->count(),
'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 +142,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<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}
*/
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,51 +207,185 @@ 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];
}
/**
* Least-squares linear regression.
* x is the array index (day number), y is the price value.
* 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
*
* @param float[] $values
* @return array{slope: float, r_squared: float}
* Range: 0 (full disagreement) 1 (unanimous).
*
* @param array<string, array{confidence: float, direction: string, enabled: bool}> $signals
* @param array<string, float> $weights
*/
private function linearRegression(array $values): array
private function computeAgreement(array $signals, array $weights, string $finalDirection): float
{
$n = count($values);
if ($n < 2) {
return ['slope' => 0.0, 'r_squared' => 0.0];
$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;
}
}
$xMean = ($n - 1) / 2.0;
$yMean = array_sum($values) / $n;
$numerator = 0.0;
$denominator = 0.0;
foreach ($values as $i => $y) {
$x = $i - $xMean;
$numerator += $x * ($y - $yMean);
$denominator += $x * $x;
}
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
$ssRes = 0.0;
$ssTot = 0.0;
foreach ($values as $i => $y) {
$predicted = $yMean + $slope * ($i - $xMean);
$ssRes += ($y - $predicted) ** 2;
$ssTot += ($y - $yMean) ** 2;
}
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
return ['slope' => $slope, 'r_squared' => $rSquared];
return $maxCredit > 0.0 ? $credit / $maxCredit : 0.0;
}
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour): string
/**
* 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) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$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($radiusSql, $radiusBindings)
->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) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$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($radiusSql, $radiusBindings)
->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];
}
/**
* @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 +397,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);

View File

@@ -6,32 +6,17 @@ use App\Models\NotificationLog;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Throwable;
final class PlanFeatures
{
/** @var string[] */
private const array CHANNELS = ['email', 'push', 'whatsapp', 'sms'];
private Plan $plan;
private function __construct(private readonly User $user)
{
try {
$this->plan = Plan::resolveForUser($user);
} catch (Throwable) {
// Never throw — fall back to a free-tier stub if resolution fails.
$this->plan = new Plan([
'name' => 'free',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
]);
}
$this->plan = Plan::resolveForUser($user);
}
public static function for(User $user): self
@@ -47,10 +32,9 @@ final class PlanFeatures
*/
public function channelsFor(string $triggerType): array
{
$allChannels = ['email', 'push', 'whatsapp', 'sms'];
$allowed = [];
foreach ($allChannels as $channel) {
foreach (self::CHANNELS as $channel) {
if (! $this->canUseChannel($channel)) {
continue;
}
@@ -72,24 +56,7 @@ final class PlanFeatures
/** Whether the plan allows this channel at all. */
public function canUseChannel(string $channel): bool
{
return (bool) ($this->feature($channel, 'enabled') ?? false);
}
/** Read a nested feature value, e.g. feature('sms', 'daily_limit'). */
private function feature(string $channel, string $key): mixed
{
$features = $this->plan->features ?? [];
return $features[$channel][$key] ?? null;
}
/** Whether the user has opted in to this channel for at least one fuel type. */
private function userHasEnabledChannel(string $channel): bool
{
return UserNotificationPreference::where('user_id', $this->user->id)
->where('channel', $channel)
->where('enabled', true)
->exists();
return (bool) $this->plan->{"{$channel}_enabled"};
}
/**
@@ -102,9 +69,9 @@ final class PlanFeatures
return false;
}
$dailyLimit = $this->feature($channel, 'daily_limit');
$dailyLimit = $this->dailyLimit($channel);
// null or 0 in the feature means no SMS/unlimited — treat 0 as blocked, null as unlimited
// null = unlimited; 0 = blocked even though enabled
if ($dailyLimit === null) {
return true;
}
@@ -131,9 +98,6 @@ final class PlanFeatures
return true;
}
$count = $this->trackedFuelTypeCount();
// Allow if already tracking this type (not adding a new one)
$alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id)
->where('fuel_type', $fuelType)
->exists();
@@ -142,15 +106,13 @@ final class PlanFeatures
return true;
}
return $count < $limit;
return $this->trackedFuelTypeCount() < $limit;
}
/** Maximum fuel types allowed, or null for unlimited. */
public function fuelTypeLimit(): ?int
{
$features = $this->plan->features ?? [];
return $features['fuel_types']['max'] ?? 1;
return $this->plan->max_fuel_types;
}
/** Count of distinct fuel types the user has preferences for. */
@@ -164,9 +126,7 @@ final class PlanFeatures
/** Generic boolean feature flag check. */
public function can(string $feature): bool
{
$features = $this->plan->features ?? [];
return (bool) ($features[$feature] ?? false);
return (bool) ($this->plan->{$feature} ?? false);
}
/** Count of notifications missed today on a channel. */
@@ -193,7 +153,7 @@ final class PlanFeatures
/** The resolved plan tier name. */
public function tier(): string
{
return $this->plan->name ?? 'free';
return $this->plan->name;
}
/** User-facing display label for the resolved tier (e.g. basic → "Daily"). */
@@ -201,4 +161,23 @@ final class PlanFeatures
{
return $this->plan->displayName();
}
/** Whether the user has opted in to this channel for at least one fuel type. */
private function userHasEnabledChannel(string $channel): bool
{
return UserNotificationPreference::where('user_id', $this->user->id)
->where('channel', $channel)
->where('enabled', true)
->exists();
}
/** Per-channel daily limit. Null on email/push (no cap), int on whatsapp/sms. */
private function dailyLimit(string $channel): ?int
{
return match ($channel) {
'whatsapp' => $this->plan->whatsapp_daily_limit,
'sms' => $this->plan->sms_daily_limit,
default => null,
};
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Services;
use App\Models\Outcode;
use App\Models\Postcode;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -24,7 +26,16 @@ class PostcodeService
public function resolve(string $query): ?LocationResult
{
$query = trim($query);
$cacheKey = 'postcode:'.strtolower(preg_replace('/\s+/', '', $query));
if ($this->isFullPostcode($query)) {
return $this->lookupLocalPostcode($query) ?? $this->lookupPostcode($query);
}
if ($this->isOutcode($query)) {
return $this->lookupLocalOutcode($query) ?? $this->lookupOutcode($query);
}
$cacheKey = 'place:'.strtolower(preg_replace('/\s+/', '', $query));
$cached = Cache::get($cacheKey);
@@ -32,11 +43,7 @@ class PostcodeService
return $cached;
}
$result = match (true) {
$this->isFullPostcode($query) => $this->lookupPostcode($query),
$this->isOutcode($query) => $this->lookupOutcode($query),
default => $this->lookupPlace($query),
};
$result = $this->lookupPlace($query);
if ($result !== null) {
Cache::put($cacheKey, $result, self::CACHE_TTL);
@@ -45,6 +52,11 @@ class PostcodeService
return $result;
}
private function normalisePostcode(string $value): string
{
return strtoupper(preg_replace('/\s+/', '', $value));
}
private function isFullPostcode(string $query): bool
{
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query);
@@ -55,9 +67,55 @@ class PostcodeService
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query);
}
private function lookupLocalPostcode(string $postcode): ?LocationResult
{
$normalised = $this->normalisePostcode($postcode);
$row = Postcode::find($normalised);
if ($row === null) {
return null;
}
return new LocationResult(
query: $postcode,
displayName: $this->formatPostcode($normalised),
lat: $row->lat,
lng: $row->lng,
);
}
private function lookupLocalOutcode(string $outcode): ?LocationResult
{
$normalised = strtoupper(trim($outcode));
$row = Outcode::find($normalised);
if ($row === null) {
return null;
}
return new LocationResult(
query: $outcode,
displayName: $normalised,
lat: $row->lat,
lng: $row->lng,
);
}
private function formatPostcode(string $normalised): string
{
// Insert the single space before the last 3 chars ("SW1A1AA" -> "SW1A 1AA").
if (strlen($normalised) < 5) {
return $normalised;
}
return substr($normalised, 0, -3).' '.substr($normalised, -3);
}
private function lookupPostcode(string $postcode): ?LocationResult
{
$normalised = strtoupper(preg_replace('/\s+/', '', $postcode));
$normalised = $this->normalisePostcode($postcode);
$url = self::BASE_URL.'/postcodes/'.$normalised;
try {
@@ -69,12 +127,34 @@ class PostcodeService
$data = $response->json('result');
return new LocationResult(
if (! is_array($data) || ! isset($data['postcode'], $data['latitude'], $data['longitude'])) {
return null;
}
$result = new LocationResult(
query: $postcode,
displayName: $data['postcode'],
lat: $data['latitude'],
lng: $data['longitude'],
);
try {
Postcode::updateOrCreate(
['postcode' => $normalised],
[
'outcode' => substr($normalised, 0, strlen($normalised) - 3),
'lat' => $data['latitude'],
'lng' => $data['longitude'],
],
);
} catch (Throwable $e) {
Log::warning('PostcodeService: failed to persist postcode after HTTP fallback', [
'postcode' => $normalised,
'error' => $e->getMessage(),
]);
}
return $result;
} catch (Throwable $e) {
Log::error('PostcodeService: postcode lookup failed', [
'postcode' => $postcode,
@@ -99,12 +179,33 @@ class PostcodeService
$data = $response->json('result');
return new LocationResult(
if (! is_array($data) || ! isset($data['outcode'], $data['latitude'], $data['longitude'])) {
return null;
}
$result = new LocationResult(
query: $outcode,
displayName: $data['outcode'],
lat: $data['latitude'],
lng: $data['longitude'],
);
try {
Outcode::updateOrCreate(
['outcode' => $normalised],
[
'lat' => $data['latitude'],
'lng' => $data['longitude'],
],
);
} catch (Throwable $e) {
Log::warning('PostcodeService: failed to persist outcode after HTTP fallback', [
'outcode' => $normalised,
'error' => $e->getMessage(),
]);
}
return $result;
} catch (Throwable $e) {
Log::error('PostcodeService: outcode lookup failed', [
'outcode' => $outcode,

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Services\Prediction\Signals;
abstract class AbstractSignal implements Signal
{
/** @return array{score: 0.0, confidence: 0.0, direction: 'stable', detail: string, data_points: 0, enabled: false} */
protected function disabledSignal(string $detail): array
{
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => $detail,
'data_points' => 0,
'enabled' => false,
];
}
/**
* Least-squares linear regression. x = array index, y = value.
*
* @param float[] $values
* @return array{slope: float, r_squared: float}
*/
protected function linearRegression(array $values): array
{
$n = count($values);
if ($n < 2) {
return ['slope' => 0.0, 'r_squared' => 0.0];
}
$xMean = ($n - 1) / 2.0;
$yMean = array_sum($values) / $n;
$numerator = 0.0;
$denominator = 0.0;
foreach ($values as $i => $y) {
$x = $i - $xMean;
$numerator += $x * ($y - $yMean);
$denominator += $x * $x;
}
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
$ssRes = 0.0;
$ssTot = 0.0;
foreach ($values as $i => $y) {
$predicted = $yMean + $slope * ($i - $xMean);
$ssRes += ($y - $predicted) ** 2;
$ssTot += ($y - $yMean) ** 2;
}
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
return ['slope' => $slope, 'r_squared' => $rSquared];
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class BrandBehaviourSignal extends AbstractSignal
{
public function compute(SignalContext $context): array
{
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $context->fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('stations.is_supermarket', 'day')
->orderBy('day')
->get();
$supermarket = $rows->where('is_supermarket', 1)->values();
$major = $rows->where('is_supermarket', 0)->values();
if ($supermarket->count() < 2 || $major->count() < 2) {
return $this->disabledSignal('Insufficient brand data for comparison');
}
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
$supermarketChange = round($supermarketSlope * 7, 1);
$majorChange = round($majorSlope * 7, 1);
if ($divergence < 1.0) {
return [
'score' => 0.0,
'confidence' => 0.5,
'direction' => 'stable',
'detail' => 'Supermarkets and majors moving in sync.',
'data_points' => $rows->count(),
'enabled' => true,
];
}
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
$direction = $leaderChange > 0 ? 'up' : 'down';
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
$leaderAbs = abs($leaderChange);
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
return [
'score' => $direction === 'up' ? 1.0 : -1.0,
'confidence' => min(1.0, $divergence / 5.0),
'direction' => $direction,
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class DayOfWeekSignal extends AbstractSignal
{
private const int MIN_DAYS = 21;
public function compute(SignalContext $context): array
{
$dowExpr = DbDialect::dayOfWeekExpr('price_effective_at');
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays(90))
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
->groupBy('dow', 'day')
->get();
$uniqueDays = $rows->pluck('day')->unique()->count();
if ($uniqueDays < self::MIN_DAYS) {
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::MIN_DAYS.')');
}
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
$weekAvg = $dowAverages->avg();
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
$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',
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
default => 'stable',
};
$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' => implode(' ', $parts),
'data_points' => $uniqueDays,
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
/**
* SQL dialect helpers for the small set of MySQL/SQLite differences the
* signal classes care about. Centralises the isSqlite ternaries that were
* duplicated across DayOfWeekSignal and StickinessSignal.
*/
final class DbDialect
{
private static function isSqlite(): bool
{
return DB::connection()->getDriverName() === 'sqlite';
}
/**
* Day-of-week expression returning 1=Sun..7=Sat (MySQL DAYOFWEEK convention).
* Targets a column on the queried table.
*/
public static function dayOfWeekExpr(string $column): string
{
return self::isSqlite()
? "(CAST(strftime('%w', {$column}) AS INTEGER) + 1)"
: "DAYOFWEEK({$column})";
}
/**
* Whole-day difference between MAX and MIN of a datetime column, suitable
* for use in an aggregate selectRaw.
*/
public static function maxMinDayDiffExpr(string $column): string
{
return self::isSqlite()
? "CAST((julianday(MAX({$column})) - julianday(MIN({$column}))) AS INTEGER)"
: "DATEDIFF(MAX({$column}), MIN({$column}))";
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class OilSignal extends AbstractSignal
{
/**
* Reads the most recent Brent crude prediction (LLM preferred, EWMA
* fallback) covering today or later. Sourced from price_predictions,
* which OilPriceService populates daily.
*/
public function compute(SignalContext $context): 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,
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Services\Prediction\Signals;
use App\Services\HaversineQuery;
use Illuminate\Support\Facades\DB;
final class RegionalMomentumSignal extends AbstractSignal
{
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const float REGIONAL_RADIUS_KM = 50.0;
public function compute(SignalContext $context): array
{
if (! $context->hasCoordinates()) {
return $this->disabledSignal('No coordinates provided for regional momentum analysis');
}
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($context->lat, $context->lng, self::REGIONAL_RADIUS_KM);
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $context->fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
->whereRaw($radiusSql, $radiusBindings)
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 3) {
return $this->disabledSignal('Insufficient regional data');
}
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
$direction = match (true) {
$regression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
$regression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
return [
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
'confidence' => min(1.0, $regression['r_squared']),
'direction' => $direction,
'detail' => 'Regional trend: '.round($regression['slope'], 2).'p/day (R²='.round($regression['r_squared'], 2).')',
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Services\Prediction\Signals;
interface Signal
{
/**
* Evaluate the signal against the given context.
*
* Returns the canonical signal payload. Implementations may add extra
* keys beyond the base shape (e.g. trend adds slope + r_squared).
*
* @return array{
* score: float,
* confidence: float,
* direction: string,
* detail: string,
* data_points: int,
* enabled: bool,
* ...
* }
*/
public function compute(SignalContext $context): array;
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Services\Prediction\Signals;
use App\Enums\FuelType;
/**
* Inputs required to evaluate a prediction signal. Individual signals may
* ignore fields they don't need — for example OilSignal doesn't use fuelType,
* RegionalMomentumSignal requires lat/lng to be non-null.
*/
final readonly class SignalContext
{
public function __construct(
public FuelType $fuelType,
public ?float $lat = null,
public ?float $lng = null,
) {}
public function hasCoordinates(): bool
{
return $this->lat !== null && $this->lng !== null;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class StickinessSignal extends AbstractSignal
{
public function compute(SignalContext $context): array
{
$diffExpr = DbDialect::maxMinDayDiffExpr('price_effective_at');
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays(30))
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
->groupBy('station_id')
->having('changes', '>', 1)
->having('span_days', '>', 0)
->get();
if ($rows->count() < 10) {
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
}
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
$avgHoldDays = round((float) $avgHoldDays, 1);
$score = match (true) {
$avgHoldDays < 2 => -0.1,
$avgHoldDays > 5 => 0.1,
default => 0.0,
};
$detail = match (true) {
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
default => "Normal hold period (avg: {$avgHoldDays} days).",
};
return [
'score' => $score,
'confidence' => min(1.0, $rows->count() / 200),
'direction' => 'stable',
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class TrendSignal extends AbstractSignal
{
private const float R_SQUARED_THRESHOLD = 0.5;
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const float SLOPE_SATURATION_PENCE = 0.5;
private const int PREDICTION_HORIZON_DAYS = 7;
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float} */
public function compute(SignalContext $context): array
{
foreach ([5, 14] as $lookbackDays) {
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 2) {
continue;
}
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
$slope = $regression['slope'];
$direction = match (true) {
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
$absSlope = abs($slope);
$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']})"
: sprintf(
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
$slope > 0 ? 'Rising' : 'Falling',
abs(round($slope, 2)),
$lookbackDays,
round($regression['r_squared'], 2),
$projected > 0 ? '+' : '',
$projected,
self::PREDICTION_HORIZON_DAYS,
);
if ($lookbackDays === 5) {
$detail .= ' [Adaptive lookback active]';
}
return [
'score' => $score,
'confidence' => min(1.0, $regression['r_squared']),
'direction' => $direction,
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
'slope' => round($slope, 3),
'r_squared' => round($regression['r_squared'], 3),
];
}
}
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
'data_points' => 0,
'enabled' => false,
'slope' => 0.0,
'r_squared' => 0.0,
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Services\StationSearch;
use App\Enums\FuelType;
final readonly class SearchCriteria
{
public function __construct(
public float $lat,
public float $lng,
public FuelType $fuelType,
public float $radiusKm,
public string $sort,
) {}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Services\StationSearch;
use Illuminate\Support\Collection;
final readonly class SearchResult
{
/**
* @param Collection<int, mixed> $stations Sorted station rows with _updated_at/_reliability/_classification cached
* @param array{lowest: ?int, highest: ?int, avg: ?float} $pricesSummary
* @param array{reliable: int, stale: int, outdated: int} $reliabilityCounts
* @param array<string, mixed> $prediction
*/
public function __construct(
public Collection $stations,
public array $pricesSummary,
public array $reliabilityCounts,
public array $prediction,
) {}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services\StationSearch;
use App\Enums\PriceClassification;
use App\Enums\PriceReliability;
use App\Models\Search;
use App\Models\Station;
use App\Models\User;
use App\Services\HaversineQuery;
use App\Services\NationalFuelPredictionService;
use App\Services\PlanFeatures;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
final class StationSearchService
{
public function __construct(
private readonly NationalFuelPredictionService $predictionService,
) {}
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
{
$stations = $this->fetchAndSortStations($criteria);
$prices = $stations->pluck('price_pence');
$this->logSearch($criteria, $stations->count(), $prices, $ipHash);
return new SearchResult(
stations: $stations,
pricesSummary: [
'lowest' => $prices->min(),
'highest' => $prices->max(),
'avg' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
],
reliabilityCounts: $this->countReliability($stations),
prediction: $this->buildPrediction($user, $criteria),
);
}
/** @return Collection<int, mixed> */
private function fetchAndSortStations(SearchCriteria $criteria): Collection
{
[$distanceSql, $distanceBindings] = HaversineQuery::distanceKm($criteria->lat, $criteria->lng);
$all = Station::query()
->selectRaw(
"stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, {$distanceSql} AS distance_km",
$distanceBindings,
)
->join('station_prices_current as spc', function (JoinClause $join) use ($criteria): void {
$join->on('stations.node_id', '=', 'spc.station_id')
->where('spc.fuel_type', '=', $criteria->fuelType->value);
})
->where('stations.temporary_closure', false)
->where('stations.permanent_closure', false)
->get();
// Compute reliability + classification once per row so the sort, the
// count groupBy, and the StationResource render all read cached
// values instead of re-invoking PriceReliability::fromUpdatedAt.
$all->each(function ($s): void {
$updatedAt = $s->price_effective_at ? Carbon::parse($s->price_effective_at) : null;
$s->_updated_at = $updatedAt;
$s->_reliability = PriceReliability::fromUpdatedAt($updatedAt);
$s->_classification = PriceClassification::fromUpdatedAt($updatedAt);
});
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $criteria->radiusKm);
return $this->applySort($filtered, $criteria->sort);
}
/**
* @param Collection<int, mixed> $filtered
* @return Collection<int, mixed>
*/
private function applySort(Collection $filtered, string $sort): Collection
{
if ($sort === 'reliable') {
return $filtered
->sort(function ($a, $b) {
return $a->_reliability->weight() <=> $b->_reliability->weight()
?: ((int) $a->price_pence <=> (int) $b->price_pence)
?: ((float) $a->distance_km <=> (float) $b->distance_km);
})
->values();
}
return $filtered->sortBy(match ($sort) {
'price' => fn ($s) => (int) $s->price_pence,
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
default => fn ($s) => (float) $s->distance_km,
})->values();
}
/**
* @param Collection<int, mixed> $stations
* @return array{reliable: int, stale: int, outdated: int}
*/
private function countReliability(Collection $stations): array
{
$counts = $stations->groupBy(fn ($s) => $s->_reliability->value)->map->count();
return [
'reliable' => (int) $counts->get(PriceReliability::Reliable->value, 0),
'stale' => (int) $counts->get(PriceReliability::Stale->value, 0),
'outdated' => (int) $counts->get(PriceReliability::Outdated->value, 0),
];
}
/** @param Collection<int, mixed> $prices */
private function logSearch(SearchCriteria $criteria, int $resultsCount, Collection $prices, ?string $ipHash): void
{
Search::create([
'lat_bucket' => round($criteria->lat, 2),
'lng_bucket' => round($criteria->lng, 2),
'fuel_type' => $criteria->fuelType->value,
'results_count' => $resultsCount,
'lowest_pence' => $prices->min(),
'highest_pence' => $prices->max(),
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
'searched_at' => now(),
'ip_hash' => $ipHash ?? hash('sha256', ''),
]);
}
/**
* 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 buildPrediction(?User $user, SearchCriteria $criteria): array
{
$result = $this->predictionService->predict($criteria->lat, $criteria->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

@@ -72,6 +72,18 @@ return [
'api_key' => env('FUELALERT_API_KEY'),
],
'onesignal' => [
'app_id' => env('ONESIGNAL_APP_ID'),
'api_key' => env('ONESIGNAL_API_KEY'),
],
'vonage' => [
'key' => env('VONAGE_KEY'),
'secret' => env('VONAGE_SECRET'),
'whatsapp_from' => env('VONAGE_WHATSAPP_FROM'),
'sms_from' => env('VONAGE_SMS_FROM', 'FuelAlert'),
],
'stripe' => [
'prices' => [
'basic' => [

View File

@@ -11,23 +11,25 @@ use Illuminate\Database\Eloquent\Factories\Factory;
*/
class PlanFactory extends Factory
{
private static array $defaultFeatures = [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => false, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
];
public function definition(): array
{
return [
'name' => PlanTier::Free->value,
'stripe_price_id' => null,
'features' => self::$defaultFeatures,
'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null,
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'weekly_digest',
'push_enabled' => false,
'push_frequency' => 'none',
'whatsapp_enabled' => false,
'whatsapp_daily_limit' => 0,
'whatsapp_scheduled_updates' => 0,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
'active' => true,
];
}
@@ -36,8 +38,8 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Free->value,
'stripe_price_id' => null,
'features' => self::$defaultFeatures,
'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null,
]);
}
@@ -45,17 +47,21 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Basic->value,
'stripe_price_id' => 'price_basic_test',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'daily'],
'push' => ['enabled' => true, 'frequency' => 'daily'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
],
'stripe_price_id_monthly' => 'price_basic_monthly_test',
'stripe_price_id_annual' => 'price_basic_annual_test',
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'daily',
'push_enabled' => true,
'push_frequency' => 'daily',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
]);
}
@@ -63,17 +69,21 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Plus->value,
'stripe_price_id' => 'price_plus_test',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'triggered'],
'push' => ['enabled' => true, 'frequency' => 'triggered'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => true, 'daily_limit' => 1],
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
'stripe_price_id_monthly' => 'price_plus_monthly_test',
'stripe_price_id_annual' => 'price_plus_annual_test',
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'triggered',
'push_enabled' => true,
'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 1,
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
]);
}
@@ -81,17 +91,21 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Pro->value,
'stripe_price_id' => 'price_pro_test',
'features' => [
'fuel_types' => ['max' => null],
'email' => ['enabled' => true, 'frequency' => 'triggered'],
'push' => ['enabled' => true, 'frequency' => 'triggered'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => true, 'daily_limit' => 3],
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
'stripe_price_id_monthly' => 'price_pro_monthly_test',
'stripe_price_id_annual' => 'price_pro_annual_test',
'max_fuel_types' => null,
'email_enabled' => true,
'email_frequency' => 'triggered',
'push_enabled' => true,
'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 3,
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('postcodes', function (Blueprint $table): void {
$table->string('postcode', 7)->primary()->comment('Normalised: uppercase, no spaces');
$table->string('outcode', 4)->index();
$table->decimal('lat', 10, 7);
$table->decimal('lng', 10, 7);
});
}
public function down(): void
{
Schema::dropIfExists('postcodes');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('outcodes', function (Blueprint $table): void {
$table->string('outcode', 4)->primary();
$table->decimal('lat', 10, 7);
$table->decimal('lng', 10, 7);
});
}
public function down(): void
{
Schema::dropIfExists('outcodes');
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('grace_period_until')->nullable()->after('password')
->comment('Set when invoice.payment_failed webhook fires; cleared on payment success or subscription deletion. Drives dashboard past-due banner.');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('grace_period_until');
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->timestamp('current_period_start')->nullable()->after('quantity');
$table->timestamp('current_period_end')->nullable()->after('current_period_start');
$table->json('stripe_data')->nullable()->after('current_period_end');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->dropColumn(['current_period_start', 'current_period_end', 'stripe_data']);
});
}
};

View File

@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('plans', function (Blueprint $table): void {
$table->unsignedTinyInteger('max_fuel_types')->nullable()->after('stripe_price_id_annual')
->comment('Null = unlimited');
$table->boolean('email_enabled')->default(true)->after('max_fuel_types');
$table->string('email_frequency', 20)->default('weekly_digest')->after('email_enabled')
->comment('weekly_digest | daily | triggered');
$table->boolean('push_enabled')->default(false)->after('email_frequency');
$table->string('push_frequency', 20)->default('none')->after('push_enabled')
->comment('none | daily | triggered');
$table->boolean('whatsapp_enabled')->default(false)->after('push_frequency');
$table->unsignedSmallInteger('whatsapp_daily_limit')->default(0)->after('whatsapp_enabled');
$table->unsignedTinyInteger('whatsapp_scheduled_updates')->default(0)->after('whatsapp_daily_limit');
$table->boolean('sms_enabled')->default(false)->after('whatsapp_scheduled_updates');
$table->unsignedSmallInteger('sms_daily_limit')->default(0)->after('sms_enabled');
$table->boolean('ai_predictions')->default(false)->after('sms_daily_limit');
$table->boolean('price_threshold')->default(false)->after('ai_predictions');
$table->boolean('score_alerts')->default(false)->after('price_threshold');
$table->dropColumn('features');
});
}
public function down(): void
{
Schema::table('plans', function (Blueprint $table): void {
$table->json('features')->after('stripe_price_id_annual');
$table->dropColumn([
'max_fuel_types',
'email_enabled',
'email_frequency',
'push_enabled',
'push_frequency',
'whatsapp_enabled',
'whatsapp_daily_limit',
'whatsapp_scheduled_updates',
'sms_enabled',
'sms_daily_limit',
'ai_predictions',
'price_threshold',
'score_alerts',
]);
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('weekly_pump_prices', function (Blueprint $table) {
$table->date('date')->primary()->comment('Week starting (Monday) per BEIS publication');
$table->unsignedSmallInteger('ulsp_pence')->comment('Petrol pump price × 100');
$table->unsignedSmallInteger('ulsd_pence')->comment('Diesel pump price × 100');
$table->unsignedSmallInteger('ulsp_duty_pence')->comment('Petrol duty × 100');
$table->unsignedSmallInteger('ulsd_duty_pence')->comment('Diesel duty × 100');
$table->unsignedTinyInteger('ulsp_vat_pct')->comment('VAT %');
$table->unsignedTinyInteger('ulsd_vat_pct')->comment('VAT %');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('weekly_pump_prices');
}
};

View File

@@ -14,71 +14,75 @@ class PlanSeeder extends Seeder
PlanTier::Free->value => [
'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null,
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'weekly_digest',
'push_enabled' => false,
'push_frequency' => 'none',
'whatsapp_enabled' => false,
'whatsapp_daily_limit' => 0,
'whatsapp_scheduled_updates' => 0,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
PlanTier::Basic->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.basic.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.basic.annual'),
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'daily'],
'push' => ['enabled' => true, 'frequency' => 'daily'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
],
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'daily',
'push_enabled' => true,
'push_frequency' => 'daily',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
],
PlanTier::Plus->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.plus.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.plus.annual'),
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'triggered'],
'push' => ['enabled' => true, 'frequency' => 'triggered'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => true, 'daily_limit' => 1],
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'triggered',
'push_enabled' => true,
'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 1,
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
PlanTier::Pro->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.pro.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.pro.annual'),
'features' => [
'fuel_types' => ['max' => null],
'email' => ['enabled' => true, 'frequency' => 'triggered'],
'push' => ['enabled' => true, 'frequency' => 'triggered'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => true, 'daily_limit' => 3],
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
'max_fuel_types' => null,
'email_enabled' => true,
'email_frequency' => 'triggered',
'push_enabled' => true,
'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 3,
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
];
foreach ($plans as $name => $data) {
Plan::updateOrCreate(
['name' => $name],
[
'stripe_price_id_monthly' => $data['stripe_price_id_monthly'],
'stripe_price_id_annual' => $data['stripe_price_id_annual'],
'features' => $data['features'],
'active' => true,
]
);
foreach ($plans as $name => $attributes) {
Plan::updateOrCreate(['name' => $name], [...$attributes, 'active' => true]);
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,216 @@
# Stripe Subscription Lifecycle — Design Spec
**Date:** 2026-04-23
**Status:** Approved — ready for implementation plan
## Purpose
Formalise the end-to-end Stripe subscription flow: signup, upgrade, downgrade,
cancellation, renewal, payment failure recovery, and final downgrade. Covers
webhook handling, email communication, user-facing flows, and minimal data-model
additions on top of the existing Cashier + `Plan` + `PlanFeatures` foundation.
Existing working pieces (see `docs/superpowers/specs/2026-04-15-tier-features-design.md`)
are kept as-is:
- Laravel Cashier, `Plan` model with `resolveForUser()` + cache
- `PlanFeatures` service (all entitlement decisions)
- `RequiresFeature` middleware
- `DispatchUserNotificationJob` using `PlanFeatures`
- `BillingController` (checkout + portal + success/cancel routes)
- Existing `DowngradeUserOnSubscriptionDeleted` listener (absorbed into the new
consolidated handler below)
## Decisions
| Topic | Decision |
|---|---|
| Grace period length | 5 days from first failed renewal charge |
| Retry strategy | Stripe-managed: 3 attempts on days 1, 3, 5; then cancel subscription |
| Features during grace | Stay ON until `customer.subscription.deleted` fires |
| Dunning emails | Hybrid — Stripe sends "update card" transactional; we send branded day-3 and day-5 reminders |
| Annual downgrade policy | Wait until renewal; no mid-term refunds |
| Subscription management UI | Stripe-hosted Customer Portal for everything (upgrade, downgrade, cancel, card update, invoices) |
| VAT / Stripe Tax | Skip for v1; revisit before £90k turnover |
| Post-grace reactivation | User returns via pricing page → Stripe Checkout (new subscription) |
## Webhook Event Catalogue
All Stripe events arrive via Cashier's auto-registered `/stripe/webhook` route
and fire the `Laravel\Cashier\Events\WebhookReceived` event. A single consolidated
listener `HandleStripeWebhook` routes on `$event->payload['type']`. The existing
`DowngradeUserOnSubscriptionDeleted` listener is folded into it.
| Event | Action |
|---|---|
| `customer.subscription.created` | Bust `plan_for_user_{id}` cache |
| `customer.subscription.updated` | Bust cache (catches portal plan swaps + `past_due``active` transitions) |
| `customer.subscription.deleted` | Downgrade user to free, disable WhatsApp + SMS prefs, clear `grace_period_until`, bust cache |
| `invoice.payment_succeeded` | Clear `grace_period_until`, bust cache |
| `invoice.payment_failed` | Set `grace_period_until = now + 5 days`, queue day-3 and day-5 reminder mailables with `->delay()` |
Events not listed are ignored. `invoice.upcoming` is intentionally not handled —
Stripe's default renewal-preview email covers it.
The listener is **idempotent**: every branch is safe to run twice on the same
event (Cashier does not natively deduplicate webhooks, and Stripe retries
failing deliveries). Cache busts are idempotent by nature; state writes use
`updateOrCreate` / direct column updates that converge on the same result.
## Feature Access During Grace
- When `invoice.payment_failed` fires, Stripe transitions the subscription to
`past_due`. Cashier's `$user->subscribed('plus')` returns `true` for
`past_due` subscriptions by default, so `PlanFeatures::tier()` already reports
the paid tier. **No code change needed.**
- Features only turn off when `customer.subscription.deleted` fires — which
happens after Stripe's 3rd failed retry (day 5). At that point the listener
clears the Cashier subscription and the next `PlanFeatures::for($user)` call
resolves to `free`.
- The dashboard renders a banner while `$user->subscription()->pastDue()` is
true, linking to the Stripe Portal to update the card.
## Data Model Additions
### `users` table
Add one nullable column:
| Column | Type | Purpose |
|---|---|---|
| `grace_period_until` | `timestamp` nullable | Drives the past-due banner + is used by reminder mailables as the cancellation check |
Set when `invoice.payment_failed` fires (`now()->addDays(5)`). Cleared on
`invoice.payment_succeeded` or `customer.subscription.deleted`.
No other schema changes. Stripe + Cashier tables remain the source of truth for
subscription state.
## User-Facing Flows
| Flow | Path |
|---|---|
| Sign up (paid) | Pricing page → `/billing/checkout/{tier}/{cadence}` → Stripe Checkout → dashboard |
| Upgrade | Pricing page → "Manage subscription" → Stripe Portal → select higher plan → Stripe prorates immediately → webhook updates cache |
| Downgrade | Stripe Portal → select lower plan → Stripe schedules change at period end → webhook on change day fires `customer.subscription.updated` → features swap on period rollover |
| Cancel | Stripe Portal → cancel → `cancel_at_period_end=true` → features remain until period end → `customer.subscription.deleted` on that date |
| Update card | Stripe Portal, or via the "update payment method" link in Stripe's transactional dunning email |
| Reactivate after cancel / post-grace | Pricing page → Stripe Checkout (new subscription) |
## Email Communication
### Stripe-sent (configure in Stripe dashboard)
- Successful payment receipts
- Failed payment "update your card" — includes hosted update link
- Upcoming renewal reminder (default 7 days pre-renewal)
### FuelAlert-sent (our Laravel Mailables)
| Name | Triggered by | Delay | Subject |
|---|---|---|---|
| `PaymentFailedDay3Reminder` | `invoice.payment_failed` webhook | `now + 3 days` | "Heads up — your FuelAlert payment is retrying" |
| `PaymentFailedDay5Reminder` | Same | `now + 5 days` | "Last chance — {Tier} features end tomorrow" |
Both mailables check `$this->user->grace_period_until === null` in `build()` /
`content()` and abort silently if the grace period has already been cleared
(payment recovered or subscription cancelled). This is simpler than cancelling
queued jobs.
Copy references the user's current tier by name, spells out which features
they'll lose on downgrade, and links to the Stripe Portal to update the card.
## Stripe Dashboard Configuration (one-time, manual)
1. **Billing → Automations → Subscription retry rules:**
- Switch from "Smart Retries" to **Custom**
- Retry schedule: day 1, day 3, day 5 after first failure
- After final retry: **Cancel subscription**
2. **Emails → Customer emails:**
- Enable: "Successful payments", "Failed payments", "Upcoming renewals"
3. **Settings → Branding:**
- Upload FuelAlert logo
- Set primary colour to match app accent
4. **Customer Portal settings:**
- Allow plan changes (all 3 paid tiers × monthly + annual)
- Allow cancellation (at period end only)
- Allow payment method updates, invoice history
- Hide everything else (no custom domains, no promo code input — keep
promotion codes to checkout only)
## Architecture
```
Stripe
│ webhook POST /stripe/webhook
CashierController (built-in)
│ dispatches WebhookReceived event
HandleStripeWebhook (new consolidated listener)
├── subscription.created/updated → cache bust
├── subscription.deleted → downgrade + disable prefs + cache bust
├── invoice.payment_succeeded → clear grace_period_until + cache bust
└── invoice.payment_failed → set grace_period_until + queue reminder mails
├─► PaymentFailedDay3Reminder (delay 3d)
└─► PaymentFailedDay5Reminder (delay 5d)
└─ guard: abort if grace_period_until is null
```
## Testing Strategy
Pest feature tests, one file per webhook branch, using Cashier's webhook-test
helpers (simulate `WebhookReceived` with a representative payload).
Required test cases:
- `customer.subscription.created` busts the plan cache
- `customer.subscription.updated` busts the plan cache after a portal plan swap
- `customer.subscription.deleted` downgrades to free, disables WhatsApp + SMS
prefs, clears `grace_period_until` (this folds in the existing
`DowngradeUserOnSubscriptionDeletedTest`)
- `invoice.payment_failed` sets `grace_period_until` 5 days out and queues both
reminder mailables with correct delays (use `Queue::fake()`)
- `invoice.payment_succeeded` clears `grace_period_until`
- `PaymentFailedDay3Reminder` aborts when `grace_period_until` is null
- `PaymentFailedDay5Reminder` aborts when `grace_period_until` is null
- Listener is idempotent — replaying the same event twice produces the same
final state
- Existing `BillingControllerTest` + `PlanFeaturesTest` continue to pass
Manual QA checklist (production Stripe test mode):
- Sign up on all three paid tiers × both cadences
- Upgrade basic-monthly → pro-monthly via Portal; confirm instant swap
- Downgrade pro-monthly → basic-monthly via Portal; confirm change takes effect
at next renewal
- Cancel mid-period; confirm features persist until period end
- Trigger payment failure with test card `4000 0000 0000 0341`; confirm banner
appears, day-3 + day-5 emails send, subscription cancels on day 5, user
downgrades to free
## Out of Scope (v1)
- Stripe Tax / VAT — revisit before £90k turnover
- Mid-term annual refunds — commitment model, no refunds
- Custom in-app upgrade/downgrade UI — Stripe Portal is the UI
- Trial periods — none offered
- `invoice.upcoming` handling — Stripe's default email is sufficient
- Subscription pause / skip-a-month — not in tier spec
- Multi-currency — GBP only for v1
## Open Documentation Updates
The following project docs need editing to match this spec (done as part of
implementation, not a separate task):
- `.claude/rules/payments.md` — current version doesn't mention grace period,
webhook catalogue, or decision to use Stripe Portal exclusively. Describes a
custom Livewire upgrade UI that is no longer planned.
- `.claude/rules/tiers.md` — largely accurate; check the "Notification Dispatch
Flow" section doesn't contradict any webhook behaviour here.

View File

@@ -0,0 +1,618 @@
# Prediction Rebuild — Design Spec
## Context
The current prediction service (`NationalFuelPredictionService` + six signal
classes) produces output the user has repeatedly described as "doesn't make
sense": headlines that contradict their own reasoning text, weights that
nobody can defend a number on, and confidence values that aren't grounded in
any track record. Two earlier docs (`.claude/rules/scoring.md`, `.claude/rules/prediction.md`)
disagree on the weights of the same signals, which is itself evidence that
the design has drifted.
This spec replaces the entire prediction stack from scratch around the
historical data we actually have, with a model whose confidence values are
calibrated against its own backtested track record.
Goals:
- A "fill up now or wait?" call honest about uncertainty.
- Confidence values calibrated against backtested residuals — "70%" actually
means "in 7 of every 10 cases like this, the model called direction right".
- Simple enough to debug a year from now.
- Remove the six-signal aggregator entirely.
- Recognise that pump prices, while *measured* weekly by BEIS, can *move* daily
during oil shocks (Iran, OPEC surprise cuts, Hormuz disruption). The static
weekly forecast must be backed by a daily news/event overlay so we can flag
staleness in real time rather than pretend a Monday number is still valid on
Thursday after a 6% Brent move.
---
## Inputs (audited 2026-05-01)
| Source | Status | Use in v1 |
|---|---|---|
| `weekly_pump_prices` | 435 weeks, all Mondays, 0 outliers, 1 duty change (Mar 2022, 57.95p → 52.95p), VAT stable at 20% | **Foundation** — train Layer 1 |
| `station_prices_current` | ~7,550 stations × e10, ~7,620 × b7_standard | **Layer 2** — descriptive snapshot |
| `stations` | 7,747 stations, 1,989 supermarkets, lat/lng | Layer 2 |
| `station_prices` | 75 days of changes since 2026-01-16, sample mix uneven per day | Not modelled in v1, but **used by the volatility regime detector** as a churn indicator (% stations changing price / day vs 30-day baseline). |
| `brent_prices` | 30 days only | **Backfilled in Phase 7** (8 years from FRED, single API call). Used as a Brent-move volatility trigger and as fuel for the daily LLM overlay. |
The Fuel Finder API has been confirmed empirically to have **no historical
archive** — `effective-start-timestamp` is a station-level filter on current
prices, not a time-window query. Per-station deep history can only accrue
forward from the date polling started.
---
## Architecture — five thin layers
### Layer 1 — National weekly forecaster (predictive, calibrated)
Trained once weekly on `weekly_pump_prices`. Output:
- `direction ∈ {rising, falling, flat}`
- `magnitude_pence` — predicted Δ price next week
- `ridge_confidence` (0100) — calibrated from backtested residuals, not
from the model's raw output
This is the **quantitative baseline**. It updates only when the BEIS Monday
publication arrives (so the *forecast itself* changes weekly), but its
*displayed confidence* (Layer 3) is adjusted in real time by Layers 4 and 5.
`direction = flat` whenever `|magnitude_pence| < FLAT_THRESHOLD`. Phase 3
picks `FLAT_THRESHOLD` from the backtest residual distribution; the
starting value is **0.2p / litre**.
### Layer 2 — Local snapshot (descriptive, NOT predictive)
Pure SQL aggregates against `station_prices_current` + Haversine on
`stations.lat/lng`. No ML, no history, no surprises:
- `local_avg_50km(fuel_type, lat, lng)`
- `national_avg(fuel_type)`
- `cheapest_within(km, fuel_type, lat, lng)`
- `supermarket_avg_local`, `major_avg_local`, gap
Layer 2 never speaks about the future. It describes the present.
### Layer 3 — Verdict merger (rule-based gates, no multipliers)
Single user-facing verdict ∈ {`fill_now`, `wait`, `no_signal`}. The
displayed confidence number is `ridge_confidence` itself, **untouched**.
LLM agreement and volatility status are shown as separate **badges**, not
blended into the number. Honesty over smoothing.
Gates evaluated in order, first match wins:
```
1. direction == 'flat' → no_signal
2. ridge_confidence < 40 no_signal
3. volatility_regime active → no_signal (badge: volatile)
4. LLM disagrees AND ridge_confidence < 75 no_signal (badge: conflicting)
5. rising AND ridge_confidence >= 70 → fill_now
6. falling AND ridge_confidence >= 70 → wait
7. otherwise (40 <= conf < 70, no veto from 3 or 4) dashboard-only
```
Why gates, not multipliers:
- A multiplied confidence number is a black-box blend that the user can't
audit. A 70% that used to be 90% before today's volatility hit looks
identical to a 70% that's been calibrated all along.
- Gates compose cleanly. Each rule has one job and is independently
testable.
- The verdict is binary anyway (notify / don't / silent). Smoothing
confidence under the hood doesn't help that decision — it only obscures it.
Layer 2 affects **urgency wording only** ("fill up now, *especially* in
your area at 2p above national"). It never changes the verdict. Neither
does Layer 4 or Layer 5 — they can suppress (gate 3, 4) but never flip
the direction.
### Layer 4 — Daily LLM news overlay (qualitative, news-aware)
**Single scheduled call at 07:00 UK.** Plus an event-driven refresh when
Layer 5's volatility flag flips ON (with a 4-hour cooldown so the same
event doesn't trigger repeatedly).
JSON in, JSON out. Calls Claude Haiku with web search enabled, asks for
direction + confidence + cited events with URLs. Stored in a new
`llm_overlays` table.
Layer 4 is **read-only with respect to the volatility flag**. It writes
its result row; only Layer 5 mutates `volatility_regimes.active`.
LLM confidence is hard-capped at 75 in code (web-searched LLMs are
systematically overconfident). Calls without `events_cited` are rejected.
### Layer 5 — Volatility regime detector (intra-week safety net)
Hourly cron. **Sole owner** of the `volatility_regimes.active` flag.
Reads four signals, OR-combined:
1. Daily Brent move > 3% close-to-close (FRED `DCOILBRENTEU`, Phase 7).
2. Most recent `llm_overlays.major_impact_event = true` AND at least one
verified URL.
3. `station_prices` daily churn rate > 1.5× its 30-day baseline.
4. A `watched_events` row covering today (manually flagged geopolitical
periods).
When the flag flips on:
- An event-driven LLM refresh is queued (Layer 4) if last run was > 4h ago.
- **Layer 3's gate 3 fires**: verdict forced to `no_signal` with the
`volatile` badge.
- The reasoning text appended: *"Volatility detected ({trigger}) — this
forecast may be stale within days."*
When it flips off:
- Verdict returns to whatever the gates produce on the unchanged
`ridge_confidence` (no multiplier to reset — there are none).
- Badge cleared.
- Next morning's 07:00 LLM call still runs (it always runs); no extra
refreshes are queued.
Layer 5 never changes Layer 1's *direction*. It only suppresses the
verdict via gate 3.
---
## Methodology — Layer 1
### Target
```
ΔULSP[t+1] = ULSP[t+1] ULSP[t]
```
We model the **change**, not the level. UK pump prices are non-stationary,
so regressing on levels gives spurious R² and useless coefficients.
Differencing makes the series stationary.
### Features (all stationary)
| Feature | Notes |
|---|---|
| `Δulsp_lag_0`, `Δulsp_lag_1`, `Δulsp_lag_3` | 1w / 2w / 4w momentum |
| `Δulsd_lag_0` | Diesel cross-signal as a *change* |
| `ulsp[t] ma8[t]` | **Mean-reversion term** — gap between current price and 8-week MA. Single most useful feature for 1-week-ahead UK pump forecast. |
| `week_of_year_sin`, `week_of_year_cos` | Cyclic seasonality encoding |
| `is_pre_bank_holiday` | Boolean, within 7 days of UK bank holiday |
The level only enters as the deviation from MA-8 (itself stationary).
That's the only way levels are allowed in.
**Duty change is NOT a feature.** With one event in 435 weeks, n=1 cannot
fit a meaningful coefficient. Instead, duty-change-adjacent weeks (±4
weeks of a known change) are handled in the **calibration override**
(see below) — confidence is halved and the regime flag is surfaced in
the reasoning text. A regime can be flagged. A coefficient cannot be
trained from one observation.
### Model
Ridge regression. Boring on purpose:
- 435 weekly observations is too few to beat a well-specified linear model
out-of-sample with gradient boosting or LSTM — those would just fit noise.
- Interpretable coefficients are essential for the honesty layer
(the reasoning text describes what the model used).
Upgrade to a non-linear model **only** if Phase 3 backtest demonstrates the
linear model is missing real structure.
### Training and evaluation split
- Train on weeks 1305 (~70%).
- Evaluate on weeks 306435 (~30%) with rolling-origin cross-validation
(single-split would overfit hyperparameters to one window).
### Confidence calibration
Two-stage calibration:
1. **Magnitude binning** — bin predictions by predicted `|magnitude|` and
record actual hit rate per bin. The published `confidence_score` reads
from this lookup, not from the model's raw output.
2. **Regime flag** — flag any forecast week within ±4 weeks of a known
duty change. With only one duty change in 435 weeks, statistical
stratification at n=1 is impossible. Instead:
- For flagged weeks, halve the calibrated confidence manually.
- Surface the flag in the reasoning text: *"Recent duty change —
forecast accuracy is reduced for the next several weeks."*
This is the only place v1 accepts a hand-tuned guard, and it's there
because the data can't tell us better.
---
## Methodology — Layer 2
Pure aggregates. No model.
```sql
-- Local 50km average
SELECT AVG(price_pence) FROM station_prices_current
JOIN stations ON station_prices_current.station_id = stations.node_id
WHERE fuel_type = ? AND <Haversine within 50km of (lat, lng)>;
-- National average
SELECT AVG(price_pence) FROM station_prices_current WHERE fuel_type = ?;
-- Cheapest within 25km
SELECT stations.*, station_prices_current.price_pence
FROM station_prices_current
JOIN stations ON station_prices_current.station_id = stations.node_id
WHERE fuel_type = ? AND <Haversine within 25km>
ORDER BY price_pence ASC LIMIT 5;
-- Supermarket vs major split, locally
SELECT stations.is_supermarket, AVG(price_pence)
FROM station_prices_current
JOIN stations ON station_prices_current.station_id = stations.node_id
WHERE fuel_type = ? AND <Haversine within 25km>
GROUP BY stations.is_supermarket;
```
Output is descriptive: "Your area is X p above national average right
now", "Cheapest near you: {station} at {price}", "Supermarkets near you:
{avg} vs majors: {avg}". **Never** predictive language.
---
## Methodology — Layer 3
Full gate ordering is in the Architecture section (Layer 3). Summary:
- Verdict via ordered rule gates, **not** multipliers.
- `ridge_confidence` is displayed verbatim — never multiplied.
- Volatility flag and LLM disagreement act as **suppressors with badges**
(`volatile`, `conflicting`) but never flip direction.
- `direction == 'flat'` always produces `no_signal`.
- LLM disagreement only suppresses the verdict when `ridge_confidence < 75`.
Above 75 the model's call is strong enough to stand even with a news-scan
disagreement (the LLM is hard-capped at 75 confidence anyway, so it
can't out-confidence the ridge model — only flag a tension).
Local position from Layer 2 modifies urgency wording only:
- If user's local average is materially above national (>2p), and Layer 1
says "rising", urgency increased ("fill up now, *especially* in your area").
- Layer 2 never flips Layer 1's direction.
---
## Methodology — Layer 4 (LLM news overlay)
Single scheduled call daily at 07:00 UK. Additional event-driven calls
are queued by Layer 5 when the volatility flag flips ON, with a 4-hour
cooldown enforced in code (skip the queue if the most recent
`llm_overlays.ran_at` is within 4 hours).
**Brent input** (`brent_recent_14_days`) is optional — passed as `null`
until Phase 7 backfills `brent_prices`. Phase 8 cannot ship before
Phase 7 — explicit dependency.
### Request shape (JSON)
```json
{
"input": {
"ulsp_recent_8_weeks": [...],
"brent_recent_14_days": [...],
"current_week_of_year": 18,
"days_to_next_bank_holiday": 5,
"duty_pence": 52.95,
"ridge_model_says": {
"direction": "down",
"confidence": 68,
"magnitude_pence": -0.4
}
},
"ask": "Search recent news for oil-supply, OPEC, refinery, shipping, sanctions, geopolitical events affecting UK retail fuel prices over the next 1-2 weeks. Reply ONLY in the schema below."
}
```
### Response shape (JSON, enforced)
```json
{
"direction": "rising | falling | flat",
"confidence": 0,
"reasoning_short": "1-2 sentences",
"events_cited": [
{"headline": "...", "source": "...", "url": "...", "impact": "rising|falling|neutral"}
],
"agrees_with_ridge": true,
"major_impact_event": false
}
```
### Code-level guards (not in the prompt)
1. **Cap `confidence` at 75.** Web-searched LLMs are systematically overconfident.
2. **Reject the response if `events_cited` is empty.** Forces the LLM to
ground its call in something checkable, not vibes.
3. **Verify each `url` in `events_cited` is reachable** before storing.
Catches hallucinated citations. Failed URLs blank the citation but
don't reject the call (newer URLs sometimes 404 briefly).
4. **Layer 4 does NOT mutate `volatility_regimes.active`.** It writes its
row to `llm_overlays` (with `major_impact_event` + verified URLs) and
that's it. Layer 5's hourly cron picks up the new row and decides
whether to flip the flag.
### How Layer 3 uses it
- LLM agrees → no gating effect; `agrees` badge shown next to the verdict
("News scan agrees, citing {event}").
- LLM disagrees AND `ridge_confidence < 75`**gate 4 fires**: verdict
forced to `no_signal` with the `conflicting` badge.
- LLM disagrees AND `ridge_confidence >= 75` → no suppression; the
disagreement is shown as a badge but the model's strong call stands.
- LLM neutral / flat → no gating effect.
- Direction is never flipped by the LLM.
---
## Methodology — Layer 5 (volatility regime detector)
Hourly cron. **Sole owner** of `volatility_regimes.active`. Reads four
signals, OR-combined:
1. **Brent move** — close-to-close daily Brent move > 3% on FRED
`DCOILBRENTEU`. FRED publishes with a one-day lag (today's value is
yesterday's settle), so the trigger reflects the most recent settled
day. Sufficient for v1 — we don't have a real-time Brent feed.
2. **LLM major-impact flag** — most recent `llm_overlays` row has
`major_impact_event = true` AND at least one verified URL.
3. **Station churn***gated until ≥180 days of stable polling.* The
trigger fires when the last-24h % of stations updating price exceeds
1.5× the 30-day rolling baseline. With only 75 days of uneven polling
(Jan 16 → May 1) the baseline is meaningless — sample-mix variance
would dominate any real shock signal. The trigger is implemented but
disabled in code via a feature flag; flip it on once `station_prices`
has 180+ continuous days.
4. **Manual `watched_events`** — a row covering today. Lets you flag
known geopolitical periods manually (e.g. "Iran tensions AprMay 2026").
When the flag flips on:
- An event-driven Layer 4 LLM refresh is queued (skipped if the most
recent `llm_overlays.ran_at` is within 4 hours — cooldown).
- **Layer 3's gate 3 fires**: verdict forced to `no_signal` with the
`volatile` badge for as long as the flag stays on.
- Reasoning text appended: *"Volatility detected ({trigger label}) — this
forecast may be stale within days."*
When it flips off:
- Verdict returns to whatever the gates produce on the unchanged
`ridge_confidence` (no multiplier reset needed — there are no multipliers).
- Badge cleared.
- The next morning's 07:00 LLM call still runs (always does); no extra
refreshes are queued by Layer 5.
---
## Schema deltas
### Add
```
weekly_forecasts
id BIGINT PK
forecast_for DATE — Monday the forecast covers
model_version VARCHAR(32) — links back to backtests row
direction ENUM('rising','falling','flat')
magnitude_pence SMALLINT — predicted Δ × 100, signed
ridge_confidence TINYINT UNSIGNED — 0..100, calibrated from backtested residuals. Displayed verbatim. Layer 3 gates may suppress the verdict but never modify this number.
flagged_duty_change BOOLEAN — true if forecast is within ±4 weeks of a duty change (avoids collision with Layer 5's volatility_regimes)
reasoning TEXT — generated from features actually used
generated_at DATETIME
UNIQUE (forecast_for, model_version)
INDEX (forecast_for, generated_at DESC)
forecast_outcomes
forecast_for DATE
model_version VARCHAR(32)
predicted_class ENUM('rising','falling','flat')
actual_class ENUM('rising','falling','flat')
correct BOOLEAN
abs_error_pence SMALLINT UNSIGNED
resolved_at DATETIME
PRIMARY KEY (forecast_for, model_version)
backtests
id BIGINT PK
model_version VARCHAR(32) UNIQUE
features_json JSON — feature spec
train_start DATE
train_end DATE
eval_start DATE
eval_end DATE
directional_accuracy DECIMAL(5,2)
mae_pence DECIMAL(5,2)
calibration_table JSON — {bin_low..bin_high → empirical_hit_rate}
leak_suspected BOOLEAN — secondary smell test: true if directional_accuracy > 75. Primary leak detection is structural (see Backtest section).
ran_at DATETIME
llm_overlays
id BIGINT PK
ran_at DATETIME
forecast_for_week DATE — which weekly forecast it overlays
direction ENUM('rising','falling','flat')
confidence TINYINT UNSIGNED — capped 75 in code
reasoning TEXT
events_json JSON — cited events with verified URLs
agrees_with_ridge BOOLEAN
major_impact_event BOOLEAN
volatility_flag_on BOOLEAN — was the regime flag on at run time
search_used BOOLEAN
INDEX (forecast_for_week, ran_at)
volatility_regimes
id BIGINT PK
flipped_on_at DATETIME
flipped_off_at DATETIME NULL
trigger ENUM('brent_move','llm_event','station_churn','manual')
trigger_detail TEXT — e.g. "Brent +4.2% close-to-close"
active BOOLEAN
watched_events
id BIGINT PK
label VARCHAR(128)
starts_at DATETIME
ends_at DATETIME
notes TEXT
```
### Keep
- `weekly_pump_prices` — already loaded, source of truth
- `stations`, `station_prices_current` — for Layer 2
- `station_prices` — keep collecting forward, not modelled in v1
### Deprecate (delete after Layer 1 ships)
- `price_predictions` — old LLM/EWMA store, replaced by `weekly_forecasts`
The current six-signal aggregator (`NationalFuelPredictionService` and
`app/Services/Prediction/Signals/*`) is **fully replaced**, not extended.
Same JSON output keys (`predicted_direction`, `confidence_score`,
`action`, `reasoning`) so the Vue frontend doesn't break — engine swapped,
contract preserved.
---
## Implementation phases (each ships something working)
| Phase | Scope | Ships |
|---|---|---|
| **1. Backtest harness** | `BacktestRunner` service + `backtests` table. Takes a model class, train/eval split, returns directional accuracy + MAE + calibration curve. **Structural leak detection** built in (per-feature source-timestamp check vs target Monday); accuracy>75% smell test as secondary. | A way to *prove* any future model works before shipping it. |
| **2. Naive baseline** | "Predict next week = this week" implemented as a model class. Run through harness. | A floor: any future model must beat this. |
| **3. v1 ridge model** | Features above (incl. mean-reversion term), trained once, persisted with `model_version`. `WeeklyForecastService` runs it. Backtest must clear the acceptance gate. | First real forecast. Backtested numbers visible. |
| **4. Live wiring** | Replace `NationalFuelPredictionService` internals with a thin adapter delegating to `WeeklyForecastService`. Same API shape, new engine. | Frontend keeps working, predictions now from the new model. |
| **5. Local snapshot** | `LocalSnapshotService` — pure aggregates. Wire into `/api/stations` payload alongside the headline forecast. | "Your area" descriptive cards. |
| **6. Honesty layer** | Reasoning generator describes *what the model used*: lag values, season, holiday flag. Shows backtest accuracy badge. Returns explicit "not enough data" when confidence < 40. Surfaces the duty-change-adjacent flag when set. | The "no BS" framing. |
| **7. Brent backfill + daily refresh** | One FRED call (2018→today, ~2,150 daily rows). Daily refresh cron at **06:30 UK** (must complete before Phase 8's 07:00 LLM call — sequenced so the LLM has fresh Brent context). Used by Phase 9's volatility detector and as a feature option for future model iterations (only added to the ridge model if backtested lift is ≥3 percentage points directional accuracy). | Daily Brent in DB. Foundation for volatility + LLM context. |
| **8. LLM news overlay** | `LlmOverlayService` — single scheduled call at **07:00 UK** (after Brent refresh). Plus event-driven calls when Layer 5 flips the volatility flag on, with 4h cooldown. JSON in / JSON out, web search enabled, results stored in `llm_overlays`. Feeds Layer 3's gate 4 (suppress when LLM disagrees AND ridge_confidence < 75) and the `agrees`/`conflicting` badges. URL-verification + empty-citation rejection enforced in code. **Depends on Phase 7.** | News-aware verdict suppression and badge on top of the calibrated ridge baseline. |
| **9. Volatility regime detector** | `VolatilityRegimeService` — hourly cron, sole owner of `volatility_regimes.active`. OR-combines four triggers: Brent move > 3%, LLM `major_impact_event`, station churn > 1.5× baseline (**gated until ≥180 days of stable polling**), `watched_events` row covering today. Fires Layer 3's gate 3 (verdict → `no_signal` with `volatile` badge) and the event-driven Layer 4 refresh. | The intra-week safety net for oil shocks. |
---
## Backtest acceptance gates (Phase 3 → Phase 4)
| Backtest result | Action |
|---|---|
| < 60% directional accuracy | Features are wrong. Stay in Phase 3, don't ship. |
| 6062% | Marginal. One feature iteration, then re-evaluate. |
| **6268%** | **Ship.** Realistic target for UK weekly pump direction without Brent. |
| 6875% | Excellent. Ship and watch closely. |
| > 75% | **Stop.** Run the structural leak detector. Almost certainly time leakage (e.g. using `t+1` info accidentally in `t` features). The accuracy threshold is a secondary smell test, not the primary detector. |
| MAE > 1.0p / litre | Features are noisy. Refit before shipping. |
| Target MAE | 0.40.7p / litre. |
### Structural leak detection (primary)
Built into the backtest harness. For every (training_week, feature_value)
pair, the harness verifies the data source's effective timestamp is
**strictly before** the target Monday. Any feature whose source timestamp
is on or after the target week is treated as leakage and the backtest
fails fast. This is independent of accuracy — it catches leakage even
when it doesn't translate into suspiciously high accuracy.
The `> 75% accuracy` row is a secondary smell test for leakage modes the
structural check missed (e.g. label leakage via a downstream computed
column). Primary defence is the timestamp check. These numbers are
encoded in the harness as assertions, not aspirations.
---
## Honesty rules — non-negotiables
1. Backtest accuracy is **published in the UI**. The model wears its track
record on its sleeve.
2. Below 40 confidence, the recommendation is `no_signal` and the reasoning
says "we don't have enough signal to call it" — explicitly. No filler.
3. When duty-change-adjacent weeks affect the forecast, surface the flag
("forecast may be skewed by recent duty change").
4. Reasoning text only references features the model actually used — no
narrative invention. If the mean-reversion term drove the call, say so
("Pump prices are 3.1p above their 8-week average, and prices typically
pull back from that level"). If the seasonality term drove it, say so.
5. `forecast_outcomes` is populated automatically when the next BEIS week
lands. Hit rate over the trailing 13 weeks is shown next to the headline.
6. When the **volatility regime flag** is on, the UI shows the `volatile`
badge and the trigger (e.g. "Brent up 4.2% yesterday — forecast may be
stale within days"). Verdict is suppressed visibly via gate 3, never
silently.
7. The LLM overlay is **shown separately** from the ridge model, never
blended. "Model says down (68%); news scan agrees, citing {event}" —
the `ridge_confidence` number stays calibrated and untouched, while
LLM and volatility status are presented as their own badges.
8. LLM citations with unreachable URLs are **dropped from the displayed
reasoning** but kept in `llm_overlays.events_json` for audit. We never
show a citation we haven't verified.
---
## What gets deleted at the end of Phase 4
- `app/Services/Prediction/Signals/*` (whole directory)
- `NationalFuelPredictionService` internals (kept as a thin wrapper, then
renamed when the frontend migration completes)
- `price_predictions` table — replaced by `weekly_forecasts` (ridge) +
`llm_overlays` (news layer)
- `OilPriceService::generatePrediction()`, EWMA/LLM helpers — replaced by
`LlmOverlayService` (Phase 8) which has a different contract
- `OilPriceService::fetchBrentPrices()` — kept and **expanded** in Phase 7
(backfill mode + daily refresh), not deleted
- `.claude/rules/scoring.md` retired in favour of a fresh
`.claude/rules/forecasting.md`
- `.claude/rules/prediction.md` rewritten to match the new architecture
---
## Open decisions (to confirm before Phase 1)
- **Forecast cadence** — the *forecast itself* is weekly (matches BEIS
publication). The *confidence and presentation* update daily via Layer 4
(LLM) and Layer 5 (volatility regime). This split is deliberate — we
refuse to fabricate intra-week movement, but we don't pretend a static
Monday number is reliable on Thursday after a 6% Brent move.
- **Scope** — drop the six-signal aggregator entirely, confirmed.
- **API shape** — keep existing JSON output keys so Vue keeps working,
with the engine swapped under the hood. The original `confidence_score`
field maps to `ridge_confidence` (calibrated, untouched). Add new
fields: `volatility` (`{active, trigger}`), `news_overlay`
(`{direction, agreement, events}`), and `verdict_reason` (which gate
fired, if any). The verdict itself goes in the existing `action` field.
- **Brent** — promoted to Phase 7 (was "optional, conditional"). Needed
for the volatility detector, regardless of whether it's used in the
ridge model.
- **LLM** — Anthropic Claude Haiku with web search. Single scheduled call
at 07:00 UK (after the 06:30 Brent refresh). Plus event-driven refreshes
when Layer 5 flips the volatility flag on, with a 4h cooldown. No fixed
afternoon cron — by 13:00 UK, morning users have already made their
fill-up decisions, so the value is too low to justify the extra noise.
Hard confidence cap 75. Empty-citation rejection.
---
## Changelog (substantive design decisions)
| When | Change | Why |
|---|---|---|
| 2026-05-01 v1 | Initial spec — three layers, six-signal aggregator removed, ridge model on BEIS weekly data | Replace incoherent `NationalFuelPredictionService` |
| 2026-05-01 v2 | Added Layer 4 (LLM news overlay) and Layer 5 (volatility regime detector). Pump prices can move daily during oil shocks; static weekly forecast must be backed by intra-week safety nets. | Iran/Hormuz-style shocks make a Monday-only confidence number stale by Wednesday |
| 2026-05-01 v3 | **Verdict via rule gates, not multipliers.** `ridge_confidence` displayed verbatim. LLM and volatility presented as badges. `weeks_since_duty_change` removed from features (kept as calibration override only — n=1 can't fit a coefficient). Backtest gate floor lowered 65 → 62 (realistic without Brent). Structural leak detection (per-feature timestamp check) made primary; accuracy>75% demoted to secondary smell test. `weekly_forecasts` PK changed to `(forecast_for, model_version)` to preserve audit on retrain. `forecast_outcomes` made three-class. Layer 5 station-churn trigger gated until ≥180 days of stable polling. | Multipliers obscure calibration. Gates compose cleanly and stay auditable. |
---
## References
- Alquist, Kilian, Vigfusson (2013) — *Forecasting the Price of Oil*
the academic basis for "no-change baseline beats most structural models
at <6m horizons" (which is why Phase 2 matters as a hard floor).
- BEIS *Weekly road fuel prices* CSV — the 435-week training set.
- `.claude/rules/scoring.md`, `.claude/rules/prediction.md` — the two
inconsistent rule files this spec replaces.

484
package-lock.json generated
View File

@@ -166,9 +166,9 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -184,18 +184,18 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
"cpu": [
"arm64"
],
@@ -209,9 +209,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
"cpu": [
"arm64"
],
@@ -225,9 +225,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
"cpu": [
"x64"
],
@@ -241,9 +241,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
"cpu": [
"x64"
],
@@ -257,9 +257,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
"cpu": [
"arm"
],
@@ -273,9 +273,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
"cpu": [
"arm64"
],
@@ -292,9 +292,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
"cpu": [
"arm64"
],
@@ -311,9 +311,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
"cpu": [
"ppc64"
],
@@ -330,9 +330,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
"cpu": [
"s390x"
],
@@ -349,9 +349,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
"cpu": [
"x64"
],
@@ -368,9 +368,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
"cpu": [
"x64"
],
@@ -387,9 +387,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
"cpu": [
"arm64"
],
@@ -403,9 +403,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
"cpu": [
"wasm32"
],
@@ -414,16 +414,16 @@
"dependencies": {
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.3"
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
"cpu": [
"arm64"
],
@@ -437,9 +437,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
"cpu": [
"x64"
],
@@ -453,9 +453,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
"license": "MIT"
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
@@ -475,9 +475,9 @@
]
},
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
"integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
@@ -486,36 +486,36 @@
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.2"
"tailwindcss": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
"integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-x64": "4.2.2",
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
"@tailwindcss/oxide-android-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-x64": "4.2.4",
"@tailwindcss/oxide-freebsd-x64": "4.2.4",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-x64-musl": "4.2.4",
"@tailwindcss/oxide-wasm32-wasi": "4.2.4",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
"integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
"cpu": [
"arm64"
],
@@ -529,9 +529,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
"integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
"cpu": [
"arm64"
],
@@ -545,9 +545,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
"integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
"cpu": [
"x64"
],
@@ -561,9 +561,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
"integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
"cpu": [
"x64"
],
@@ -577,9 +577,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
"integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
"cpu": [
"arm"
],
@@ -593,9 +593,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
"integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
"cpu": [
"arm64"
],
@@ -612,9 +612,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
"integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
"cpu": [
"arm64"
],
@@ -631,9 +631,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
"integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
"cpu": [
"x64"
],
@@ -650,9 +650,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
"integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
"cpu": [
"x64"
],
@@ -669,9 +669,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
"integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -698,9 +698,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
"integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
"cpu": [
"arm64"
],
@@ -714,9 +714,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
"integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
"cpu": [
"x64"
],
@@ -730,14 +730,14 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
"integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz",
"integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==",
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.2.2",
"@tailwindcss/oxide": "4.2.2",
"tailwindcss": "4.2.2"
"@tailwindcss/node": "4.2.4",
"@tailwindcss/oxide": "4.2.4",
"tailwindcss": "4.2.4"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
@@ -754,12 +754,12 @@
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
"integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==",
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.2"
"@rolldown/pluginutils": "1.0.0-rc.13"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -769,60 +769,54 @@
"vue": "^3.2.25"
}
},
"node_modules/@vitejs/plugin-vue/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
"license": "MIT"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
"integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/shared": "3.5.32",
"@vue/shared": "3.5.33",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz",
"integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.32",
"@vue/shared": "3.5.32"
"@vue/compiler-core": "3.5.33",
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
"integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.32",
"@vue/compiler-dom": "3.5.32",
"@vue/compiler-ssr": "3.5.32",
"@vue/shared": "3.5.32",
"@vue/compiler-core": "3.5.33",
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.8",
"postcss": "^8.5.10",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz",
"integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.32",
"@vue/shared": "3.5.32"
"@vue/compiler-dom": "3.5.33",
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/devtools-api": {
@@ -832,53 +826,53 @@
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz",
"integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.32"
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz",
"integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.32",
"@vue/shared": "3.5.32"
"@vue/reactivity": "3.5.33",
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz",
"integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.32",
"@vue/runtime-core": "3.5.32",
"@vue/shared": "3.5.32",
"@vue/reactivity": "3.5.33",
"@vue/runtime-core": "3.5.33",
"@vue/shared": "3.5.33",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz",
"integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.32",
"@vue/shared": "3.5.32"
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33"
},
"peerDependencies": {
"vue": "3.5.32"
"vue": "3.5.33"
}
},
"node_modules/@vue/shared": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz",
"integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
"license": "MIT"
},
"node_modules/ansi-regex": {
@@ -912,9 +906,9 @@
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
"integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
"funding": [
{
"type": "opencollective",
@@ -931,8 +925,8 @@
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001774",
"browserslist": "^4.28.2",
"caniuse-lite": "^1.0.30001787",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
@@ -948,9 +942,9 @@
}
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
@@ -959,9 +953,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.17",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz",
"integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==",
"version": "2.10.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz",
"integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -1017,9 +1011,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001787",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
"integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==",
"version": "1.0.30001790",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
"integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==",
"funding": [
{
"type": "opencollective",
@@ -1171,9 +1165,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.334",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz",
"integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
"version": "1.5.343",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz",
"integrity": "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -1285,9 +1279,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -1457,9 +1451,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -1843,9 +1837,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
"integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
"license": "MIT"
},
"node_modules/picocolors": {
@@ -1867,9 +1861,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"funding": [
{
"type": "opencollective",
@@ -1919,13 +1913,13 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "1.0.0-rc.15"
"@oxc-project/types": "=0.126.0",
"@rolldown/pluginutils": "1.0.0-rc.16"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -1934,23 +1928,29 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
"license": "MIT"
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -2023,15 +2023,15 @@
}
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
"integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2103,16 +2103,16 @@
}
},
"node_modules/vite": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15"
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.16",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@@ -2202,16 +2202,16 @@
}
},
"node_modules/vue": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.32",
"@vue/compiler-sfc": "3.5.32",
"@vue/runtime-dom": "3.5.32",
"@vue/server-renderer": "3.5.32",
"@vue/shared": "3.5.32"
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-sfc": "3.5.33",
"@vue/runtime-dom": "3.5.33",
"@vue/server-renderer": "3.5.33",
"@vue/shared": "3.5.33"
},
"peerDependencies": {
"typescript": "*"

View File

@@ -8,6 +8,16 @@
@custom-variant dark (&:where(.dark-mode-disabled));
@layer components {
.pill {
@apply relative inline-flex items-center gap-1 h-10 px-2 rounded-full border bg-white border-zinc-200 text-zinc-700 transition-colors cursor-pointer hover:border-zinc-300;
}
.pill.is-active {
@apply bg-primary/10 border-primary text-primary;
}
}
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
@@ -51,6 +61,10 @@
/* Display font */
--font-display: 'Manrope', ui-sans-serif, system-ui, sans-serif;
/* Hero type pairing — swap these tokens to upgrade to Instrument Serif / Geist Mono later */
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
@@ -66,6 +80,15 @@
backdrop-filter: blur(12px);
border: 1px solid color-mix(in oklch, var(--color-border) 60%, transparent);
}
.live-dot {
animation: live-pulse 2s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
}
@layer base {

View File

@@ -1,38 +1,28 @@
<template>
<div class="space-y-2">
<button
class="flex items-center gap-2 text-sm font-bold text-accent hover:text-accent-content transition-colors"
@click="toggleMap"
>
<iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon>
{{ isOpen ? 'Hide map' : 'Show map' }}
</button>
<div v-if="isOpen" id="leaflet-map-panel" class="space-y-2">
<div
ref="mapContainer"
class="w-full h-96 md:h-160 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
></div>
<template v-if="isOpen">
<div
ref="mapContainer"
class="w-full h-72 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
></div>
<div class="flex flex-wrap gap-3 text-xs text-zinc-500">
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-green-500"></span>
Current (&lt;24h)
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-slate-500"></span>
Recent (2448h)
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-amber-500"></span>
Stale (25 days)
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-red-500"></span>
Outdated (5+ days)
</span>
</div>
</template>
<div class="flex flex-wrap gap-3 text-xs text-zinc-500">
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-green-500"></span>
Current (&lt;24h)
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-slate-500"></span>
Recent (2448h)
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-amber-500"></span>
Stale (25 days)
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-red-500"></span>
Outdated (5+ days)
</span>
</div>
</div>
</template>
@@ -87,13 +77,12 @@ function escHtml(str) {
const props = defineProps({
stations: {type: Array, required: true},
defaultOpen: {type: Boolean, default: false},
isOpen: {type: Boolean, default: true},
radiusMiles: {type: Number, default: 10},
origin: {type: Object, default: null},
})
const mapContainer = ref(null)
const isOpen = ref(false)
let mapInstance = null
let markersLayer = null
let userMarker = null
@@ -102,8 +91,9 @@ function getZoomForRadius(radiusMiles) {
if (radiusMiles <= 1) return 16
if (radiusMiles <= 2) return 15
if (radiusMiles <= 5) return 14
if (radiusMiles <= 10) return 13
if (radiusMiles <= 10) return 11
if (radiusMiles <= 15) return 11
if (radiusMiles <= 20) return 10
if (radiusMiles <= 25) return 10
if (radiusMiles <= 50) return 9
return 8
@@ -174,6 +164,10 @@ function initMap() {
markersLayer = L.layerGroup().addTo(mapInstance)
mapInstance.on('zoomend', () => {
console.log('Map zoom:', mapInstance.getZoom())
})
locateUser()
}
@@ -238,47 +232,45 @@ function renderMarkers() {
})
const zoom = getZoomForRadius(props.radiusMiles)
const center = props.origin?.lat != null && props.origin?.lng != null
? [props.origin.lat, props.origin.lng]
: bounds[0]
if (bounds.length === 1) {
mapInstance.setView(bounds[0], zoom)
} else {
mapInstance.fitBounds(bounds, {padding: [40, 40], maxZoom: zoom})
}
mapInstance.setView(center, zoom)
}
async function toggleMap() {
isOpen.value = !isOpen.value
if (isOpen.value) {
await nextTick()
initMap()
mapInstance.invalidateSize()
renderMarkers()
}
}
onMounted(async () => {
if (props.defaultOpen) {
isOpen.value = true
await nextTick()
initMap()
mapInstance.invalidateSize()
renderMarkers()
}
})
watch(() => props.stations, () => {
if (isOpen.value) {
renderMarkers()
}
})
onUnmounted(() => {
function destroyMap() {
if (mapInstance) {
mapInstance.remove()
mapInstance = null
markersLayer = null
userMarker = null
}
}
async function openMap() {
await nextTick()
initMap()
mapInstance?.invalidateSize()
renderMarkers()
}
onMounted(() => {
if (props.isOpen) openMap()
})
watch(() => props.isOpen, (open) => {
if (open) openMap()
else destroyMap()
})
watch(() => props.stations, () => {
if (props.isOpen) {
renderMarkers()
}
})
onUnmounted(destroyMap)
</script>
<style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
<!-- Refine group -->
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mr-1">
Refine
</span>
<label :class="{ 'is-active': fuelType !== DEFAULTS.fuelType }" class="pill group">
<iconify-icon class="text-sm opacity-70" icon="lucide:fuel"></iconify-icon>
<span class="text-sm font-medium">{{ fuelLabel }}</span>
<iconify-icon class="text-sm opacity-50 group-hover:opacity-100" icon="lucide:chevron-down"></iconify-icon>
<select
v-model="fuelType"
aria-label="Fuel type"
class="absolute inset-0 opacity-0 cursor-pointer"
name="fuelType"
>
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">{{ fuel.label }}</option>
</select>
</label>
<label :class="{ 'is-active': radius !== DEFAULTS.radius }" class="pill group">
<iconify-icon class="text-sm opacity-70" icon="lucide:circle-dot"></iconify-icon>
<span class="text-sm font-medium">{{ radius }} miles</span>
<iconify-icon class="text-sm opacity-50 group-hover:opacity-100" icon="lucide:chevron-down"></iconify-icon>
<select
v-model.number="radius"
aria-label="Search radius"
class="absolute inset-0 opacity-0 cursor-pointer"
name="radius"
>
<option :value="5">5 miles</option>
<option :value="10">10 miles</option>
<option :value="20">20 miles</option>
</select>
</label>
<button
:aria-expanded="mapOpen"
:class="{ 'is-active': mapOpen }"
aria-controls="leaflet-map-panel"
class="pill"
type="button"
@click="emit('toggle-map')"
>
<iconify-icon :icon="mapOpen ? 'lucide:map' : 'lucide:map-off'" class="text-sm opacity-70"></iconify-icon>
<span class="text-sm font-medium">{{ mapOpen ? 'Hide map' : 'Show map' }}</span>
<iconify-icon
:class="mapOpen ? 'rotate-180' : ''"
class="text-sm opacity-60 transition-transform duration-200"
icon="lucide:chevron-down"
></iconify-icon>
</button>
<button
v-if="hasActive"
class="inline-flex items-center gap-1 h-10 px-3 text-sm text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
type="button"
@click="resetFilters"
>
<iconify-icon class="text-sm" icon="lucide:x"></iconify-icon>
Clear
</button>
<!-- Force Sort to a new line on mobile only -->
<div aria-hidden="true" class="basis-full md:hidden"></div>
<!-- Sort group -->
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mx-1">
Sort
</span>
<button
v-for="option in sortOptions"
:key="option.value"
:class="{ 'is-active': sort === option.value }"
class="pill"
type="button"
@click="sort = option.value"
>
<iconify-icon :icon="option.icon" class="text-sm opacity-70"></iconify-icon>
<span class="text-sm font-medium">{{ option.label }}</span>
</button>
<label
v-if="brands.length > 1"
:class="{ 'is-active': brandFilter }"
class="pill group"
>
<iconify-icon class="text-sm opacity-70" icon="lucide:tag"></iconify-icon>
<span class="text-sm font-medium">{{ brandFilter || 'All brands' }}</span>
<iconify-icon class="text-sm opacity-50 group-hover:opacity-100" icon="lucide:chevron-down"></iconify-icon>
<select
:value="brandFilter"
aria-label="Filter by brand"
class="absolute inset-0 opacity-0 cursor-pointer"
@change="emit('update:brandFilter', $event.target.value)"
>
<option value="">All brands</option>
<option v-for="brand in brands" :key="brand" :value="brand">{{ brand }}</option>
</select>
</label>
<span class="ml-auto text-sm text-zinc-500 font-medium">
{{ stationCount }} station{{ stationCount !== 1 ? 's' : '' }} found
</span>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { FUEL_TYPES } from '../constants/fuelTypes.js'
const DEFAULTS = Object.freeze({
fuelType: 'e10',
radius: 10,
sort: 'reliable',
})
const sortOptions = [
{ label: 'Reliable', value: 'reliable', icon: 'lucide:shield-check' },
{ label: 'Price', value: 'price', icon: 'lucide:pound-sterling' },
{ label: 'Distance', value: 'distance', icon: 'lucide:map-pin' },
{ label: 'Updated', value: 'updated', icon: 'lucide:clock' },
]
const props = defineProps({
initial: { type: Object, default: () => ({}) },
brands: { type: Array, default: () => [] },
brandFilter: { type: String, default: '' },
mapOpen: { type: Boolean, default: true },
stationCount: { type: Number, default: 0 },
})
const emit = defineEmits(['search', 'toggle-map', 'update:brandFilter'])
const postcode = ref('')
const coords = ref(null)
const fuelType = ref(DEFAULTS.fuelType)
const radius = ref(DEFAULTS.radius)
const sort = ref(DEFAULTS.sort)
let hydrating = false
watch(() => props.initial, (v) => {
if (!v) return
hydrating = true
if (typeof v.postcode === 'string') postcode.value = v.postcode
if (v.lat != null && v.lng != null) coords.value = { lat: v.lat, lng: v.lng }
if (v.fuelType) fuelType.value = v.fuelType
if (v.radius != null) radius.value = Number(v.radius)
if (v.sort) sort.value = v.sort
nextTick(() => { hydrating = false })
}, { immediate: true, deep: true })
watch([fuelType, radius, sort], () => {
if (hydrating) return
if (postcode.value.trim() || coords.value) emitSearch()
})
const fuelLabel = computed(() => {
return FUEL_TYPES.find(f => f.value === fuelType.value)?.label ?? 'Fuel'
})
const hasActive = computed(() => (
fuelType.value !== DEFAULTS.fuelType
|| radius.value !== DEFAULTS.radius
|| sort.value !== DEFAULTS.sort
|| Boolean(props.brandFilter)
))
function resetFilters() {
fuelType.value = DEFAULTS.fuelType
radius.value = DEFAULTS.radius
sort.value = DEFAULTS.sort
if (props.brandFilter) emit('update:brandFilter', '')
}
function emitSearch() {
const hasPostcode = postcode.value.trim().length > 0
const hasCoords = coords.value !== null
if (!hasPostcode && !hasCoords) return
emit('search', {
postcode: hasPostcode ? postcode.value.trim() : null,
lat: hasCoords ? coords.value.lat : null,
lng: hasCoords ? coords.value.lng : null,
fuelType: fuelType.value,
radius: radius.value,
sort: sort.value,
})
}
</script>

View File

@@ -1,74 +1,41 @@
<template>
<div class="relative">
<!-- Gated overlay for free/guest users -->
<div>
<!-- Loading state -->
<div
v-if="!isPaidTier"
class="absolute inset-0 z-10 rounded-2xl backdrop-blur-sm bg-white/60 flex flex-col items-center justify-center gap-3 text-center px-6"
v-if="loading"
class="p-6 bg-white rounded-2xl border border-zinc-300 animate-pulse space-y-2"
>
<iconify-icon class="text-accent text-3xl" icon="lucide:lock"></iconify-icon>
<p class="font-bold text-zinc-800">Price predictions are available on paid plans</p>
<div class="h-4 bg-zinc-200 rounded w-1/3"></div>
<div class="h-6 bg-zinc-200 rounded w-2/3"></div>
</div>
<!-- Free / guest: compact one-liner -->
<div
v-else-if="!isPaidTier"
class="flex items-center gap-3 px-4 py-3 bg-white rounded-2xl border border-zinc-300"
>
<div :class="['shrink-0 w-10 h-10 rounded-full flex items-center justify-center', accentBg]">
<iconify-icon :icon="genericIcon" class="text-xl text-white"></iconify-icon>
</div>
<p class="flex-1 text-sm text-zinc-800 font-medium leading-snug">
{{ genericSentence }}
</p>
<a
class="hidden sm:inline-flex shrink-0 text-sm font-bold text-accent hover:text-accent-content whitespace-nowrap"
href="/pricing"
class="px-6 py-2 bg-accent text-white rounded-full text-sm font-bold hover:bg-accent-content transition-colors"
>
Upgrade from £0.99/mo
See full prediction
</a>
</div>
<!-- Card content (blurred for free users, fully visible for paid) -->
<div
:class="['p-6 bg-white rounded-2xl border border-zinc-300 space-y-4', !isPaidTier && 'select-none pointer-events-none']"
>
<p class="text-xs font-bold uppercase tracking-widest text-zinc-500">Price Prediction</p>
<!-- Loading state -->
<template v-if="loading">
<div class="animate-pulse space-y-2">
<div class="h-8 bg-zinc-300 rounded w-1/2"></div>
<div class="h-4 bg-zinc-300 rounded w-3/4"></div>
</div>
</template>
<!-- Loaded state -->
<template v-else-if="prediction">
<h3
class="text-2xl font-black"
:class="prediction.action === 'fill_now' ? 'text-mauve' : prediction.action === 'wait' ? 'text-teal' : 'text-tan'"
>
{{ actionLabel }}
</h3>
<div class="w-full h-2 bg-zinc-200 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="prediction.action === 'fill_now' ? 'bg-mauve' : 'bg-teal'"
:style="{ width: prediction.confidence_score + '%' }"
></div>
</div>
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
<div class="flex items-center gap-4 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>
</template>
<!-- Empty state (placeholder for gated view) -->
<template v-else>
<h3 class="text-2xl font-black text-mauve">Fill up now</h3>
<div class="h-2 bg-zinc-200 rounded-full"><div class="h-full bg-mauve w-4/5 rounded-full"></div></div>
<p class="text-sm text-zinc-500">Prices in your area are rising best to fill up today.</p>
</template>
</div>
<!-- Paid: full prediction -->
<PredictionFull v-else :prediction="prediction" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import PredictionFull from './PredictionFull.vue'
const props = defineProps({
prediction: { type: Object, default: null },
@@ -76,12 +43,23 @@ const props = defineProps({
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 direction = computed(() => props.prediction?.predicted_direction ?? 'stable')
const genericSentence = computed(() => ({
up: 'UK fuel prices are trending upward this week.',
down: 'UK fuel prices have been falling this week.',
stable: 'UK fuel prices have been steady this week.',
})[direction.value] ?? 'UK fuel prices have been steady this week.')
const genericIcon = computed(() => ({
up: 'lucide:trending-up',
down: 'lucide:trending-down',
stable: 'lucide:minus',
})[direction.value] ?? 'lucide:minus')
const accentBg = computed(() => ({
up: 'bg-mauve',
down: 'bg-teal',
stable: 'bg-tan',
})[direction.value] ?? 'bg-tan')
</script>

View File

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

View File

@@ -1,139 +0,0 @@
<template>
<div class="flex flex-col gap-3 max-w-md w-full">
<!-- Row 1: postcode + button -->
<div class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<label for="postcode-input" class="sr-only">Postcode or city</label>
<button
:disabled="locating"
aria-label="Use my location"
class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 px-3 py-1.5
bg-primary/85
text-white rounded-sm text-sm font-semibold transition-opacity hover:opacity-80"
type="button"
@click="useMyLocation"
>
<iconify-icon icon="lucide:locate-fixed" style="font-size:16px;"></iconify-icon>
<span class="hidden sr-only">Near me</span>
</button>
<input
id="postcode-input"
v-model="postcode"
type="text"
:placeholder="coords ? 'Using your current location' : 'Enter postcode, e.g. SW1A 1AA'"
class="w-full h-14 pr-28 pl-4 bg-white border border-zinc-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent shadow-inner text-base"
@keyup.enter="onSearch"
/>
</div>
<button
@click="onSearch"
:disabled="!postcode.trim() && !coords"
class="h-14 px-8 bg-primary text-white rounded-xl font-bold text-base shadow-xl hover:bg-primary-dark transition-all disabled:cursor-not-allowed"
>
Find Prices
</button>
</div>
<!-- Row 2: fuel type + radius + sort -->
<div class="grid grid-cols-3 gap-2">
<select
v-model="fuelType"
aria-label="Fuel type"
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent truncate"
>
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">
{{ fuel.label }}
</option>
</select>
<select
v-model="radius"
aria-label="Search radius"
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
>
<!-- <option :value="2">2 miles</option> -->
<option :value="5">5 miles</option>
<option :value="10">10 miles</option>
<option :value="20">20 miles</option>
</select>
<select
v-model="sort"
aria-label="Sort by"
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="reliable">Reliable</option>
<option value="price">Price</option>
<option value="distance">Distance</option>
<option value="updated">Updated</option>
</select>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { FUEL_TYPES } from '../constants/fuelTypes.js'
const props = defineProps({
initial: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['search'])
const postcode = ref('')
const coords = ref(null)
const fuelType = ref('e10')
const radius = ref(10)
const sort = ref('reliable')
const locating = ref(false)
let hydrating = false
watch(() => props.initial, (v) => {
if (!v) return
hydrating = true
if (typeof v.postcode === 'string') postcode.value = v.postcode
if (v.lat != null && v.lng != null) coords.value = { lat: v.lat, lng: v.lng }
if (v.fuelType) fuelType.value = v.fuelType
if (v.radius != null) radius.value = Number(v.radius)
if (v.sort) sort.value = v.sort
nextTick(() => { hydrating = false })
}, { immediate: true, deep: true })
watch(postcode, () => { coords.value = null })
watch([fuelType, radius, sort], () => {
if (hydrating) return
if (postcode.value.trim() || coords.value) onSearch()
})
function useMyLocation() {
if (!navigator.geolocation) return
locating.value = true
navigator.geolocation.getCurrentPosition(
({ coords: c }) => {
coords.value = { lat: c.latitude, lng: c.longitude }
postcode.value = ''
locating.value = false
onSearch()
},
() => { locating.value = false },
{ timeout: 8000, enableHighAccuracy: false, maximumAge: 30000 },
)
}
function onSearch() {
const hasPostcode = postcode.value.trim().length > 0
const hasCoords = coords.value !== null
if (!hasPostcode && !hasCoords) return
emit('search', {
postcode: hasPostcode ? postcode.value.trim() : null,
lat: hasCoords ? coords.value.lat : null,
lng: hasCoords ? coords.value.lng : null,
fuelType: fuelType.value,
radius: radius.value,
sort: sort.value,
})
}
</script>

View File

@@ -13,18 +13,15 @@
>
<div class="flex justify-between items-start gap-3">
<div class="space-y-0.5 min-w-0 flex-1">
<p v-if="brandLabel" class="text-[10px] font-black uppercase tracking-widest text-zinc-500">
{{ brandLabel }}
</p>
<h4 class="font-bold text-lg text-zinc-800 truncate">{{ station.name }}</h4>
<h4 class="font-semibold text-sm text-zinc-800 truncate">{{ displayName }}</h4>
<template v-if="!expanded">
<p class="text-xs text-zinc-500 flex items-center gap-1">
<iconify-icon class="text-xs" icon="lucide:map-pin"></iconify-icon>
<span class="truncate">{{ locationLine }}</span>
<span>{{ distanceMiles }} mi</span>
</p>
<p v-if="updatedAgo" :class="[priceColor, 'text-xs flex items-center gap-1 font-semibold']">
<p v-if="updatedAgo" :class="[priceColor, 'text-xs flex items-center gap-1']">
<iconify-icon class="text-xs" icon="lucide:clock"></iconify-icon>
<span>Updated {{ updatedAgo }}</span>
<span>{{ updatedAgo }}</span>
</p>
</template>
</div>
@@ -38,14 +35,14 @@
>
<iconify-icon class="text-lg" icon="lucide:navigation"></iconify-icon>
</a>
<div class="text-right shrink-0">
<div :class="priceColor" class="text-xl font-black">
{{ station.price }}<span class="text-sm font-bold uppercase ml-0.5">p</span>
<div class="text-right shrink-0 font-mono font-medium">
<div :class="priceColor" class="text-zinc-900 tabular-nums">
{{ station.price }}p
</div>
<p :class="priceColor" class="text-[10px] font-bold uppercase tracking-wider">
<p :class="priceColor" class="text-[10px]">
{{ statusLabel }}
</p>
<p v-if="priceDelta" :class="priceDeltaColor" class="text-[10px] font-bold mt-0.5">
<p v-if="priceDelta" :class="priceDeltaColor" class="text-[11px] font-semibold mt-0.5">
{{ priceDelta }}
</p>
</div>
@@ -60,6 +57,10 @@
leave-to-class="opacity-0"
>
<div v-if="expanded" class="border-t border-zinc-200 pt-3 space-y-3">
<p v-if="brandLabel" class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
{{ brandLabel }}
</p>
<div v-if="badges.length" class="flex flex-wrap gap-1.5">
<span
v-for="badge in badges"
@@ -207,9 +208,10 @@ const statusLabel = computed(() => reliabilityInfo.value.label)
const distanceMiles = computed(() => (props.station.distance_km * 0.621371).toFixed(1))
const locationLine = computed(() => {
const parts = [props.station.address, `${distanceMiles.value} mi`].filter(Boolean)
return parts.join(' • ')
const displayName = computed(() => {
const name = props.station.name ?? ''
if (name !== name.toUpperCase()) return name
return name.toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
})
const fullAddress = computed(() => {

View File

@@ -1,39 +1,5 @@
<template>
<div class="space-y-3">
<!-- Sort tabs + brand filter -->
<div class="flex gap-2 flex-wrap items-center">
<button
v-for="option in sortOptions"
:key="option.value"
@click="emit('sort', option.value)"
:class="[
'h-10 px-4 rounded-xl text-sm font-bold transition-colors',
currentSort === option.value
? 'bg-accent text-white'
: 'bg-white border border-zinc-300 text-zinc-500 hover:border-accent'
]"
>
{{ option.label }}
</button>
<select
v-if="availableBrands.length > 1"
v-model="brandFilter"
aria-label="Filter by brand"
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="">All brands</option>
<option v-for="brand in availableBrands" :key="brand" :value="brand">{{ brand }}</option>
</select>
</div>
<!-- Count -->
<p class="text-sm text-zinc-500 font-medium">
{{ filteredStations.length }} station{{ filteredStations.length !== 1 ? 's' : '' }}
<span v-if="brandFilter">matching <strong>{{ brandFilter }}</strong></span>
<span v-else>found</span>
</p>
<!-- Grouped results when sorting by reliability -->
<template v-if="currentSort === 'reliable'">
<section v-if="reliable.length" class="space-y-2">
@@ -72,12 +38,22 @@
</section>
<section v-if="outdated.length" class="space-y-2 pt-4">
<header class="flex items-center gap-2">
<button
:aria-expanded="outdatedOpen"
class="flex items-center gap-2 w-full text-left py-3 px-3 rounded-lg hover:bg-zinc-100/60 transition-colors"
type="button"
@click="outdatedOpen = !outdatedOpen"
>
<iconify-icon class="text-status-bad text-lg" icon="lucide:triangle-alert"></iconify-icon>
<h3 class="font-black text-zinc-800">Outdated</h3>
<span class="text-xs text-zinc-500 font-medium">Over 7 days old likely inaccurate</span>
</header>
<div class="opacity-60">
<span class="text-xs text-zinc-500 font-medium">Over 7 days old likely inaccurate ({{ outdated.length }})</span>
<iconify-icon
:class="{ 'rotate-180': outdatedOpen }"
class="text-zinc-500 text-base ml-auto transition-transform"
icon="lucide:chevron-down"
></iconify-icon>
</button>
<div v-if="outdatedOpen" class="opacity-60">
<StationCard
v-for="station in outdated"
:key="station.station_id"
@@ -94,7 +70,7 @@
<!-- Flat list for other sort modes -->
<div v-else class="space-y-2">
<StationCard
v-for="station in filteredStations"
v-for="station in stations"
:key="station.station_id"
:avg-pence="avgPence"
:lowest-price="lowestPrice"
@@ -115,42 +91,20 @@ const props = defineProps({
origin: { type: Object, default: null },
})
const emit = defineEmits(['sort'])
const reliable = computed(() => props.stations.filter(s => s.reliability === 'reliable'))
const stale = computed(() => props.stations.filter(s => s.reliability === 'stale'))
const outdated = computed(() => props.stations.filter(s => s.reliability === 'outdated'))
const brandFilter = ref('')
const sortOptions = [
{ label: 'Reliable', value: 'reliable' },
{ label: 'Price', value: 'price' },
{ label: 'Distance', value: 'distance' },
{ label: 'Updated', value: 'updated' },
]
const availableBrands = computed(() => {
const brands = new Set()
props.stations.forEach(s => {
if (s.brand) brands.add(s.brand)
})
return [...brands].sort((a, b) => a.localeCompare(b))
})
const filteredStations = computed(() => {
if (!brandFilter.value) return props.stations
return props.stations.filter(s => s.brand === brandFilter.value)
})
const reliable = computed(() => filteredStations.value.filter(s => s.reliability === 'reliable'))
const stale = computed(() => filteredStations.value.filter(s => s.reliability === 'stale'))
const outdated = computed(() => filteredStations.value.filter(s => s.reliability === 'outdated'))
const outdatedOpen = ref(false)
const lowestPrice = computed(() => {
if (!reliable.value.length && !filteredStations.value.length) return null
const pool = reliable.value.length ? reliable.value : filteredStations.value
if (!reliable.value.length && !props.stations.length) return null
const pool = reliable.value.length ? reliable.value : props.stations
return Math.min(...pool.map(s => s.price_pence))
})
const avgPence = computed(() => {
const prices = filteredStations.value.map(s => s.price_pence).filter(p => typeof p === 'number')
const prices = props.stations.map(s => s.price_pence).filter(p => typeof p === 'number')
if (!prices.length) return null
return prices.reduce((a, b) => a + b, 0) / prices.length
})

View File

@@ -0,0 +1,52 @@
<template>
<div
v-if="!isPaidTier"
class="relative overflow-hidden rounded-2xl border border-accent/30 bg-gradient-to-r from-accent/10 via-accent/5 to-transparent p-5 sm:p-6"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-4">
<div class="shrink-0 w-11 h-11 rounded-2xl bg-accent text-white flex items-center justify-center shadow-md">
<iconify-icon class="text-xl" icon="lucide:sparkles"></iconify-icon>
</div>
<div class="space-y-1">
<h3 class="text-lg sm:text-xl font-black text-zinc-800 leading-tight">
Stop guessing. Get a buy-or-wait alert before every fill-up.
</h3>
<p class="text-sm text-zinc-500">
14-day predictions + daily price-drop alerts across
<span class="font-bold text-zinc-800">{{ stationCountLabel }}</span> UK stations.
From <span class="font-bold text-zinc-800">£0.99/mo</span>.
</p>
</div>
</div>
<a
:href="ctaHref"
class="shrink-0 inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-accent text-white text-sm font-black shadow-lg hover:bg-accent-content transition-colors"
>
{{ ctaLabel }}
<iconify-icon class="text-base" icon="lucide:arrow-right"></iconify-icon>
</a>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAuth } from '../composables/useAuth.js'
const props = defineProps({
stationCount: { type: Number, default: null },
})
const { isAuthenticated, isPaidTier } = useAuth()
const stationCountLabel = computed(() => {
if (!props.stationCount) {
return '14,500+'
}
return new Intl.NumberFormat('en-GB').format(props.stationCount)
})
const ctaHref = computed(() => isAuthenticated.value ? '#pricing' : '/register?tier=plus&cadence=monthly')
const ctaLabel = computed(() => isAuthenticated.value ? 'See plans' : 'Start saving')
</script>

View File

@@ -0,0 +1,103 @@
<template>
<form class="w-full max-w-xl" @submit.prevent="submitPostcode">
<label class="flex items-center gap-2 h-14 md:h-15 pl-3.5 md:pl-4 pr-1.5 md:pr-2 bg-white md:bg-surface border border-zinc-200 rounded-2xl focus-within:border-primary transition-colors md:shadow-[0_20px_40px_-20px_rgba(0,0,0,0.12)]">
<iconify-icon class="text-zinc-400 text-lg shrink-0" icon="lucide:map-pin"></iconify-icon>
<input
v-model="postcode"
autocomplete="postal-code"
class="flex-1 min-w-0 bg-transparent outline-none text-[15px] md:text-base placeholder:text-zinc-400"
placeholder="Postcode"
type="text"
/>
<!-- Geolocation icon-button visible on mobile AND desktop -->
<button
:disabled="locating"
aria-label="Use my location"
class="w-11 h-11 rounded-[10px] bg-zinc-100 text-primary inline-flex items-center justify-center shrink-0 hover:bg-zinc-200 transition-colors disabled:opacity-70"
type="button"
@click="useMyLocation"
>
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" class="text-lg"></iconify-icon>
</button>
<!-- Desktop-only inline submit -->
<button
class="hidden md:inline-flex h-12 px-5 ml-1 rounded-xl bg-primary text-white font-medium text-[15px] items-center gap-2 hover:opacity-90 transition"
type="submit"
>
Check prices
<iconify-icon icon="lucide:arrow-right"></iconify-icon>
</button>
</label>
<!-- Mobile-only full-width submit -->
<button
class="md:hidden w-full mt-2.5 h-14 rounded-2xl bg-primary text-white font-medium text-base inline-flex items-center justify-center gap-2 shadow-lg hover:opacity-90 transition"
type="submit"
>
Check prices
<iconify-icon class="text-lg" icon="lucide:arrow-right"></iconify-icon>
</button>
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-400 justify-center md:justify-start">
<span class="hidden md:inline text-zinc-300">·</span>
<span class="hidden md:inline font-mono">Try SW1A 1AA · M1 1AD · EH1 1YZ</span>
</div>
</form>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
initial: { type: Object, default: () => ({}) },
fuelType: { type: String, default: 'e10' },
radius: { type: Number, default: 10 },
sort: { type: String, default: 'reliable' },
})
const emit = defineEmits(['search'])
const postcode = ref('')
const locating = ref(false)
watch(() => props.initial, (v) => {
if (!v) return
if (typeof v.postcode === 'string') postcode.value = v.postcode
}, { immediate: true, deep: true })
function submitPostcode() {
const trimmed = postcode.value.trim()
if (!trimmed) return
emit('search', {
postcode: trimmed,
lat: null,
lng: null,
fuelType: props.fuelType,
radius: props.radius,
sort: props.sort,
})
}
function useMyLocation() {
if (!navigator.geolocation) return
locating.value = true
navigator.geolocation.getCurrentPosition(
({ coords }) => {
locating.value = false
postcode.value = ''
emit('search', {
postcode: null,
lat: coords.latitude,
lng: coords.longitude,
fuelType: props.fuelType,
radius: props.radius,
sort: props.sort,
})
},
() => { locating.value = false },
{ timeout: 8000, enableHighAccuracy: false, maximumAge: 30000 },
)
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<nav class="fixed top-0 w-full z-50 bg-zinc-50/90 backdrop-blur-sm border-b border-zinc-300 px-6 py-4 md:px-12">
<div class="max-w-7xl mx-auto flex items-center justify-between gap-6">
<RouterLink class="flex items-center gap-3 shrink-0" to="/">
<div class="w-9 h-9 md:w-10 md:h-10 rounded-lg bg-accent flex items-center justify-center shadow-md">
<iconify-icon class="text-white text-xl" icon="lucide:fuel"></iconify-icon>
</div>
<span class="text-xl md:text-2xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
</RouterLink>
<div class="hidden lg:flex items-center gap-8 font-mono text-[11px] uppercase tracking-widest text-zinc-600">
<a class="hover:text-accent transition-colors" href="#how-it-works">How it works</a>
<a class="hover:text-accent transition-colors" href="#features">Why it works</a>
<a class="hover:text-accent transition-colors" href="#pricing">Pricing</a>
<a class="hover:text-accent transition-colors" href="#fleet">Fleet</a>
</div>
<div class="flex items-center gap-3 md:gap-5">
<template v-if="isAuthenticated">
<RouterLink
class="bg-accent text-white px-5 py-2 rounded-full text-sm font-bold shadow-md hover:bg-primary-dark transition-all"
to="/dashboard"
>
Dashboard
</RouterLink>
</template>
<template v-else>
<a class="text-sm font-semibold text-zinc-600 hover:text-zinc-900 transition-colors" href="/login">Login</a>
<a
class="hidden sm:inline-flex bg-accent text-white px-5 py-2 rounded-full text-sm font-bold shadow-md hover:bg-primary-dark transition-all"
href="/register"
>
Get started
</a>
</template>
</div>
</div>
</nav>
</template>
<script setup>
import { RouterLink } from 'vue-router'
import { useAuth } from '../../composables/useAuth.js'
const { isAuthenticated } = useAuth()
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.15em] text-zinc-600">
<span class="size-1.5 rounded-full bg-status-good live-dot"></span>
<span class="font-medium">Live</span>
<span v-if="stationCount" aria-hidden="true">·</span>
<span v-if="stationCount">{{ formattedStationCount }} UK stations</span>
<span aria-hidden="true">·</span>
<span>updated {{ updatedAgo || '…' }}</span>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps({
stationCount: { type: Number, default: null },
latestPriceAt: { type: String, default: null },
})
const now = ref(Date.now())
let ticker = null
const formattedStationCount = computed(() => {
return props.stationCount == null ? '' : props.stationCount.toLocaleString('en-GB')
})
const updatedAgo = computed(() => {
if (!props.latestPriceAt) return ''
const diffMin = Math.floor((now.value - new Date(props.latestPriceAt).getTime()) / 60000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin} min ago`
const hours = Math.floor(diffMin / 60)
if (hours < 24) return `${hours} hr ago`
const days = Math.floor(hours / 24)
return `${days} day${days === 1 ? '' : 's'} ago`
})
onMounted(() => {
ticker = setInterval(() => { now.value = Date.now() }, 60000)
})
onUnmounted(() => {
if (ticker) clearInterval(ticker)
})
</script>

View File

@@ -0,0 +1,28 @@
<template>
<dl class="hidden md:flex items-center divide-x divide-zinc-300">
<div v-for="stat in stats" :key="stat.label" class="px-8 first:pl-0 last:pr-0">
<dt class="sr-only">{{ stat.label }}</dt>
<dd>
<span class="block font-serif text-2xl text-zinc-900 leading-none">{{ stat.value }}</span>
<span class="block font-mono text-[11px] uppercase tracking-widest text-zinc-500 mt-1.5">{{ stat.label }}</span>
</dd>
</div>
</dl>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
stationCount: { type: Number, default: null },
})
const stats = computed(() => [
{
value: props.stationCount ? props.stationCount.toLocaleString('en-GB') : '11,482',
label: 'Stations tracked',
},
{ value: '£273', label: 'Median saving / yr' },
{ value: '84%', label: 'Forecast accuracy' },
])
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div
:class="{ 'max-w-md mx-auto': variant === 'full' }"
class="bg-zinc-50 border border-zinc-300 rounded-3xl p-5 md:p-6 shadow-[0_20px_40px_-20px_rgba(74,63,59,0.25)]"
>
<!-- Header -->
<div class="flex items-center justify-between gap-3 mb-5">
<div class="min-w-0">
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
Today near {{ postcode }}
</p>
<h3
:class="variant === 'compact' ? 'text-3xl' : 'text-4xl'"
class="font-serif text-accent leading-none"
>
{{ verdict }}
</h3>
</div>
<span class="inline-flex items-center gap-1.5 shrink-0 font-mono text-[10px] uppercase tracking-widest text-zinc-600 bg-white border border-zinc-300 rounded-full px-2.5 py-1">
<span class="size-1.5 rounded-full bg-status-good live-dot"></span>
Live
</span>
</div>
<!-- Station rows -->
<ul class="space-y-2">
<li
v-for="(station, idx) in stations"
:key="station.name + idx"
class="flex items-center gap-3 bg-white border border-zinc-300 rounded-xl px-3 py-2.5"
>
<span class="font-mono text-xs text-zinc-500 tabular-nums">{{ String(idx + 1).padStart(2, '0') }}</span>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm text-zinc-800 truncate">{{ station.name }}</p>
<p class="font-mono text-[11px] text-zinc-500 mt-0.5">{{ station.distance }}</p>
</div>
<span
v-if="station.tag"
class="font-mono text-[9px] uppercase tracking-widest bg-accent text-white rounded-full px-2 py-0.5"
>
{{ station.tag }}
</span>
<span class="font-mono font-medium text-sm text-zinc-900 tabular-nums">{{ station.price }}</span>
</li>
</ul>
<!-- Footer -->
<p v-if="moreCount" class="font-mono text-[11px] text-zinc-500 mt-4 text-center">
+ {{ moreCount }} more stations within {{ radiusMiles }} miles
</p>
</div>
</template>
<script setup>
defineProps({
postcode: { type: String, default: 'SW1A 1AA' },
verdict: { type: String, default: 'Fill up today' },
stations: {
type: Array,
default: () => [
{ name: 'Tesco Victoria', distance: '0.4 mi', price: '142.9p', tag: 'Cheapest' },
{ name: "Sainsbury's Nine Elms", distance: '1.4 mi', price: '143.9p', tag: null },
],
},
moreCount: { type: Number, default: 21 },
radiusMiles: { type: Number, default: 5 },
variant: {
type: String,
default: 'full',
validator: (v) => ['full', 'compact'].includes(v),
},
})
</script>

View File

@@ -19,6 +19,22 @@ export function useAuth() {
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() {
if (fetched.value) {
return
@@ -68,6 +84,10 @@ export function useAuth() {
isAuthenticated,
userTier,
isPaidTier,
subscriptionCancelled,
subscriptionCadence,
subscribedAt,
subscriptionExpiresAt,
fetchUser,
clearUser,
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 }
}

Some files were not shown because too many files have changed in this diff Show More