Commit Graph

213 Commits

Author SHA1 Message Date
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