81 Commits

Author SHA1 Message Date
Ovidiu U
5ca7232029 Replace Laravel favicon with beer mug icon and regenerate touch/favicon assets 2026-06-11 14:05:51 +01:00
Ovidiu U
da0db012a0 Drop duplicate 'logout' route name so route:cache works
The SPA's GET /logout and Fortify's POST /logout both carried the name
'logout', which is fine at runtime (distinct verbs, same URL) but breaks
route:cache (names must be unique for serialization). The Blade auth forms
target route('logout') = Fortify's POST route, so the custom GET route — used
by the SPA via a literal /logout URL — no longer needs the name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:34:32 +01:00
Ovidiu U
5a6967dc01 Guard HandleStripeWebhook plan-cache bust against non-taggable cache stores
bustPlanCache() called Cache::tags() unconditionally, which throws on the
`database`/`file` cache drivers. Mirror the Cache::supportsTags() idiom used in
Plan.php so Stripe webhooks work regardless of the configured cache store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:56:48 +01:00
Ovidiu U
257c09d178 VPS deploy instructions 2026-06-11 10:10:48 +01:00
Ovidiu U
f14006dc28 Cleanup 2026-06-11 09:41:03 +01:00
Ovidiu U
8fe3461adf Update legal pages with ICO registration, query logging, push notifications, and automated recommendations disclosure
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 ICO registration reference (00014395133) to privacy policy, terms of service, and refund policy
- Document search/query logging (hashed IP, location, fuel type, result count) with 24-month retention under legitimate interests
- Add push notification data section (OneSignal endpoints, encryption keys, preferences) to privacy policy
- Add new section on automated recommendations explaining fill-up timing algorithm is informational only without legal effects
- Clarify IP address collection context: security/abuse/fraud only, not individual profiling
- Update retention periods: security logs 12 months, query logs 24 months, push subscriptions until unsubscribe
- Expand data processor descriptions (OneSignal push data, Umami cookieless commitment)
- Add commercial use restrictions to terms: no scraping, mirroring, or republishing compiled data/rankings without permission
- Clarify downstream data aggregator role and upstream data feed limitations
- Add prohibition on using service while operating motor vehicle
- Remove annual billing references (monthly only)
- Add VAT status notice and downgrade-to-free-tier behavior on cancellation
- Add data controller contact details to privacy policy footer
2026-06-10 13:15:43 +01:00
Ovidiu U
ad2230728c Disable Daily and Smart pricing CTAs until features ship
Replace the basic/plus checkout links on /pricing with disabled
"Coming soon" buttons, gated by a COMING_SOON list so each tier's CTA
goes live again by removing it from that list. Free is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:15:16 +01:00
Ovidiu U
5bd909d227 Show site footer on dashboard pages and trim footer links
Render the shared SiteFooter in DashboardLayout so it appears on every
dashboard page (overview, saved stations, preferences, settings) from a
single source. Content area grows to pin the footer to the bottom on
short pages.

Trim the footer Product column to real anchors and drop the dead-link
Resources column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:40:51 +01:00
Ovidiu U
a841a7c900 Add dedicated /pricing page and lock launch tiers to Free/Daily/Smart
Consolidate pricing onto a single source. Pro is deferred from launch
(left dormant: no Stripe price, no card), so the offered set is 3 tiers.

- Extract the pricing grid and footer into shared components
  (PricingGrid.vue, landing/SiteFooter.vue); add a /pricing route
  rendering Pricing.vue; remove the pricing section from Home
- Repoint every upgrade link to the /pricing route (LandingNav and
  SiteFooter via RouterLink, UpsellBanner CTA) — no more #pricing anchors
- Bump Smart (plus) SMS daily limit 1 -> 3 (PlanSeeder + PlanFactory),
  update PlanFeaturesTest assertion
- Rewrite /pricing card bullets to match real entitlements (drop
  unbuilt promises: multi-location tracking, 14-day trend, supermarket anchor)
- Fix stale "1/day" SMS references in notifications.md, tiers.md, docs/tiers.md
- Delete unused resources/views/components/pricing-card.blade.php

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:22:19 +01:00
Ovidiu U
adf0b93a45 Coarsen GPS coordinates to 3 dp before API/URL and deduplicate runSearch triggers
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
- HeroSearch: round lat/lng to 3 decimal places (~111m precision) before emit to prevent exact location leakage in shareable URLs and server logs
- Home: move duplicate-search guard into runSearch (single choke point) instead of watcher, eliminating race between route.query sync and direct onSearch call
- Add inline documentation referencing .claude/rules/frontend.md privacy guidance
2026-06-10 11:08:55 +01:00
Ovidiu U
1ae2f1396d Update legal pages with contact details, location handling, and alert channels
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
- Expand privacy policy with detailed location-use explanations: search-only, shareable links, and saved location
2026-06-10 10:55:54 +01:00
Ovidiu U
df4ebdb7c6 Remove Livewire docs, add Vue SPA frontend docs, document log-masking runbook
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
- Delete `.claude/rules/livewire.md` (Livewire is vestigial – only starter-kit auth screens remain)
- Add `.claude/rules/frontend.md` documenting Vue 3 SPA stack, router, composables, search-URL mirroring, and Leaflet integration
- Add `docs/ops/nginx-log-masking.md` runbook for redacting lat/lng from Nginx access/error logs and PHP-FPM on IONOS VPS
- Update `CLAUDE.md` and `architecture.md` to reflect Vue SPA as primary frontend, REST API as backend contract, and Livewire's reduced role
- Note stale service names in legacy domain docs (`scoring.md`, `prediction.md`, `notifications.md`) now refactored into `Services/Forecasting/`, `Services/StationSearch/`, and `PlanFeatures`
2026-06-10 10:55:34 +01:00
Ovidiu U
7a8bd5c86a Remove placeholders and finalize contact details for launch
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
- Replace all `[PLACEHOLDER]` entries across legal pages with actual values
- Update privacy policy with ICO registration status and service provider details (Ionos, Vonage, OneSignal)
- Finalize contact email as `hello@fuel-alert.co.uk` throughout legal pages and footer
- Update legal layout to use full landing nav with auth/guest actions
- Add Umami analytics script to main app layout
- Remove "Fleet" nav link from landing navigation
- Add feature test to enforce no placeholders and correct contact domain remain in legal pages
2026-06-10 10:07:02 +01:00
Ovidiu U
ecd45588e9 Add legal policy pages and shared layout component
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 Cookie Policy view documenting essential cookies (session, CSRF, remember_me, fa_location) and cookieless Umami analytics
- Add Privacy Policy view covering UK GDPR compliance, data categories, lawful bases, processors, retention, and user rights
- Add Refund & Cancellation Policy view explaining 14-day cooling-off period under Consumer Contracts Regulations 2013 and express-consent flow
- Add Terms of Service view defining account rules, subscription billing, and governing law
- Create shared legal layout component with FuelAlert header, footer with cross-links, and consistent typography
- Add feature tests covering all four legal pages and their cross-links
- All policies include placeholders for ICO registration number, email, and hosting/email providers pending production config
2026-05-14 17:43:53 +01:00
Ovidiu U
598ef04645 Rebrand from "Fuel Price" to "Fuel Alert" and update project metadata
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
- Rename project in package.json, composer.json, and .env.example
- Update app name, URLs, and session domains to fuel-alert
- Comment out testimonials section in Home.vue
- Revise footer copy: remove "
2026-05-14 14:30:02 +01:00
Ovidiu U
07e0789044 fix(forecasting): persist LLM overlay under Tier-1 ITPM via two-call architecture
The daily forecast:llm-overlay command was being skipped because the previous
single-conversation flow consumed more than Tier-1's 50,000 input-tokens-per-
minute Anthropic bucket. The web_search tool auto-caches its results (~55k
tokens) and requires `encrypted_content` intact when those blocks are resent,
so the prior retry-on-missing-citations path either 429'd or 400'd on the
second call.

LlmOverlayService now runs two independent API calls. Phase 1 invokes the
web_search tool and we discard the transcript after harvesting the URLs +
titles from the returned web_search_tool_result blocks. Phase 2 is a fresh
conversation containing the forecast context and the harvested headlines as
plain text, with a forced submit_overlay tool call. events_cited is now
optional in the tool schema — Haiku's flaky compliance no longer matters
because citations come from the search results, not the model's transcription.
Model-tagged events (with directional impact) merge with harvested-only
entries (impact: 'neutral'), deduped by URL.

Between phases the service reads anthropic-ratelimit-input-tokens-remaining /
…-reset from Phase 1's headers and sleeps proportionally — only long enough
for the SUBMIT_TOKEN_BUDGET worth of refill, not for the full bucket reset,
capped at 65 seconds.

ApiLogger now captures usage.input_tokens, usage.output_tokens,
cache_read_input_tokens, cache_creation_input_tokens, plus the rate-limit
remaining/reset headers on every Anthropic response. New nullable columns on
api_logs make rate-limit diagnostics directly queryable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:22:42 +01:00
Ovidiu U
97e27fc057 feat(ui): mobile-first redesign — compact hero, inline submit button, map-first with collapsible list
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
- Hero: remove full-width mobile submit, add inline "Go" button next to locate
- Prediction cards: tighter mobile padding (px-3 py-3)
- Search filters: right-aligned toolbar, remove "X stations found" count and map toggle
- Map: initialize view immediately to avoid tile wiggle, skip recenter on fresh init
- Station list: hidden by default, toggled via "Stations {count}" pill above map
- Typography: hide desktop h1 on mobile, scale down section headings and spacing
- Footer: remove uppercase styling from headings and copyright line
- Filter popover: auto-close on fuel/radius/sort/brand selection

fix(llm): retry submit_overlay when events_cited is missing, extend Fuel Finder timeout with retries

- LlmOverlayService: add `minItems: 1` to events_cited schema, detect missing citations
  in submit response, inject tool_result error and retry once with explicit prompt
- Log full raw_result context when no verified citations, capturing direction/confidence/reasoning
- FuelPriceService: add 3×1s retry with 60s timeout to batch price requests (was 30s no retry)
- Tests: cover successful retry recovery and rejection when retry also omits citations
2026-05-14 13:23:52 +01:00
Ovidiu U
11a3b433ff feat(ui): consolidate map filters and rework station selection
- replace inline filter pills with a single "Filters" popover containing
  small pill buttons for fuel/radius/sort/brand (no native <select>s)
- map polish: Carto Positron tiles, hidden zoom buttons, locate-me floating
  button + accuracy ring, smooth flyTo transitions, slim ⓘ attribution
- map markers no longer open Leaflet popups; clicking a marker selects the
  station and surfaces the existing StationCard inline over the map, with
  swipe-down-to-close and a small overlay × button
- price colour now reflects deal quality (cheap / average / expensive vs
  search avg ± 3p) on both list and map — stable across sort/filter
- promote the "X ago" timestamp into the card header so it stays visible
  in the expanded state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:16:13 +01:00
Ovidiu U
8dad223d06 feat(admin): add Filament resources for the forecasting stack
Adds three resources under a new "Forecasting" navigation group: a full-CRUD
WatchedEventResource for the Layer 5 volatility detector, plus read-only
WeeklyForecastResource and BacktestResource so the ridge model output and
its calibration can be inspected without SQL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:13:05 +01:00
Ovidiu U
1c46667f56 feat(api-logs): persist response body on failed external calls
ApiLogger now stores the upstream response body to
`api_logs.response_body` whenever the call failed (non-2xx response or
a RequestException carrying a response). Successful 2xx responses
remain null so the table stays small on busy services like fuel:poll
and oil:fetch.

Truncated at 64 KB. The column is mediumText so a future cap raise
needs no schema change.

Captures:
- 4xx and 5xx response bodies verbatim
- Body extracted from RequestException via `$e->response->body()`
  when callers use `Http::throw()`

Does not capture:
- ConnectionException (no response existed)
- Generic Throwable from the closure (same reason)

Motivation: the LLM overlay's "skipped — no verified citations" path
left no forensic trail to debug. With this, the next time anything
routed through ApiLogger fails — Anthropic 429s, FRED 5xx, Fuel
Finder errors — the failed body is queryable directly:

    SELECT response_body FROM api_logs
    WHERE service = ? AND status_code >= 400
    ORDER BY id DESC LIMIT 1;

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:51:18 +01:00
Ovidiu U
203200acb9 chore: retire legacy oil prediction pipeline
Removes everything that was made redundant by the new forecasting
stack. Per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md,
this was the cleanup planned at the end of Phase 4.

Deleted services and code:
- App\Services\Prediction\Signals\* (the old six-signal aggregator —
  trend, supermarket, day-of-week, brand-behaviour, stickiness,
  regional-momentum, oil — replaced by RidgeRegressionModel).
- App\Services\NationalFuelPredictionService (the post-Phase-4 thin
  shim; StationSearchService now depends on WeeklyForecastService
  directly, set up in the previous commit).
- App\Services\LlmPrediction\* (AbstractLlmPredictionProvider plus
  the four provider implementations — Anthropic, OpenAI, Gemini, and
  the OilPredictionProvider router. Replaced by LlmOverlayService).
- App\Services\BrentPricePredictor and App\Services\Ewma. The Ewma
  helper had no callers left after BrentPricePredictor went.
- App\Models\PricePrediction and its factory.
- App\Console\Commands\PredictOilPrices (the oil:predict command).
- App\Filament\Resources\OilPredictionResource and its Pages.

Schema and dashboard:
- Drop the price_predictions table via a new migration.
- Repoint the Filament StatsOverviewWidget tile from PricePrediction
  to WeeklyForecast so the dashboard reflects the new pipeline.
- Remove the OilPredictionProvider binding from AppServiceProvider.

Test cleanup:
- Delete tests for every retired service.
- Update StatsOverviewWidgetTest to seed weekly_forecasts instead of
  price_predictions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:40:28 +01:00
Ovidiu U
ddd591ad47 feat(forecasting): build calibrated weekly forecast stack with LLM overlay and volatility detector
Replaces the implementation behind NationalFuelPredictionService — the
public JSON contract on /api/stations is preserved, but the engine is
new and honest.

Layers (per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md):
1. Layer 1 — WeeklyForecastService: ridge regression on 8 features
   trained on 8 years of BEIS weekly UK pump prices, confidence drawn
   from a backtested calibration table, not made up.
2. Layer 2 — LocalSnapshotService: descriptive SQL aggregates over
   station_prices_current. Never speaks about the future.
3. Layer 3 — verdict via rule gates, not confidence multipliers. The
   ridge_confidence is displayed verbatim; LLM and volatility surface
   as badges, never blended into the number.
4. Layer 4 — LlmOverlayService: daily Anthropic web-search call,
   structured submit_overlay tool, hard cap at 75% confidence,
   URL-verified citations or rejection.
5. Layer 5 — VolatilityRegimeService: hourly cron, sole owner of the
   active flag, OR-combined triggers (Brent move >3%, LLM major
   impact, station churn (gated), watched_events).

Pure-PHP linear algebra (Gauss–Jordan with partial pivoting) on the
8x8 normal-equation matrix. No external ML dependency. Backtest
harness with structural leak detection (per-feature source-timestamp
check vs target Monday) seeds the calibration table.

Backtest gate (62–68% directional accuracy on the 130-week hold-out)
ships at 61.98% with MAE 0.48 p/L — beats the naive zero-change
baseline by ~30pp on real data.

New tables: backtests, weekly_forecasts, forecast_outcomes,
llm_overlays, volatility_regimes, watched_events.

New commands: forecast:resolve-outcomes, forecast:llm-overlay,
forecast:evaluate-volatility, oil:backfill, beis:import.

Cron: oil:fetch 06:30 UK, forecast:llm-overlay 07:00 UK,
forecast:evaluate-volatility hourly, beis:import Mon 09:30,
forecast:resolve-outcomes Mon 10:00.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:40:05 +01:00
Ovidiu U
d13a29df01 docs: amend prediction rebuild spec with implementation defaults and changelog v3
Adds two sections to the spec:
- Implementation defaults: pins the four open decisions settled before
  Phase 1 (naive baseline = zero-change, math = inline pure PHP,
  coefficients on the backtests row, BEIS retrain = manual CSV + cron)
  plus the namespace, scaler, and Pest conventions.
- Changelog v3: records the verdict-via-rule-gates architecture (gates
  not multipliers), removal of weeks_since_duty_change as a feature,
  lower 62% backtest gate, structural leak detector promoted to primary.

Captured here so a future session can resume implementation without
re-deriving them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:39:26 +01:00
Ovidiu U
c2c237a1b3 chore: stop tracking .DS_Store
Add .DS_Store to .gitignore and untrack the two committed copies
(.DS_Store at the root and .claude/.DS_Store). macOS noise that
shouldn't have been versioned in the first place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:39:16 +01:00
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
307 changed files with 17761 additions and 6817 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
.claude/.DS_Store vendored

Binary file not shown.

View File

@@ -1,57 +1,118 @@
# Architecture
## Shape: Vue SPA ⇄ REST API ⇄ fat Laravel Services
The frontend is a **Vue 3 single-page app** (`resources/js/`). It talks to the
backend exclusively over a **REST API** (`routes/api.php`
`app/Http/Controllers/Api/`). Controllers stay thin; all business logic lives in
**Service classes** (`app/Services/`). Blade is reduced to a one-line SPA shell,
server-rendered legal pages, transactional emails, and the Filament admin panel.
```
Browser
└── Vue SPA (resources/js, mounted on #app via app.blade.php)
└── axios (resources/js/axios.js — baseURL '/api', cookie + XSRF auth)
└── REST API (routes/api.php → Http/Controllers/Api/*)
└── Services (app/Services/*) ← all business logic
└── Models / Jobs / Events / Notifications
```
## Core principle: fat Services, thin everything else
All business logic lives in Service classes. Controllers, Livewire components,
and console commands are thin orchestrators — they call Services and return results.
This keeps the app API-extractable later without a rewrite.
All business logic lives in Service classes. API controllers, console commands,
jobs, listeners, and Filament resources are thin orchestrators — they call
Services and return results. Never put domain logic in a controller or a Vue
component.
## Directory structure
## Directory structure (actual)
```
app/
├── Console/Commands/ # Scheduler commands (PollFuelPrices, PredictOilPrices, RunScoringEngine)
├── Http/Controllers/ # Minimal — auth + Stripe webhook only
├── Livewire/ # Classic two-file Livewire components
├── Models/ # Eloquent models
├── Notifications/ # Laravel Notification classes (multi-channel)
├── Services/ # ALL business logic lives here
│ ├── FuelPriceService.php # Fuel Finder API polling + storage
│ ├── AlertScoringService.php # Fill-up timing recommendation engine
── StationTaggingService.php # Supermarket brand detection
│ ├── NotificationDispatchService.php # Tier-aware notification routing
│ ├── SubscriptionService.php # Cashier/tier helpers
│ ├── PostcodeService.php # Resolves postcodes/outcodes/place names → lat/lng
│ ├── OilPriceService.php # FRED fetch + EWMA/LLM Brent crude prediction
│ ├── LocationResult.php # DTO returned by PostcodeService
└── ApiLogger.php # Wraps all outbound HTTP calls, logs to api_logs
── Jobs/ # Queued jobs (dispatch notifications per user)
├── Console/Commands/ # Scheduler: PollFuelPrices, FetchOilPrices, BackfillOilPrices,
│ # RunLlmOverlay, EvaluateVolatilityRegime, ResolveForecastOutcomes,
# ArchiveOldPricesCommand, ImportBeisFuelPrices, ImportPostcodes
├── Http/
│ ├── Controllers/Api/ # AuthController, StationController, StatsController, UserController
│ ├── Controllers/ # BillingController (Stripe Checkout/Portal redirects)
│ ├── Middleware/ # VerifyApiKey (API-key gate), RequiresFeature (tier gate)
│ ├── Requests/ # Form requests
── Resources/Api/ # StationResource (API output shaping)
├── Actions/Fortify/ # Fortify auth actions (CreateNewUser, …)
├── Services/ # ALL business logic — see below
├── Models/ # Eloquent models
├── Notifications/ # FuelPriceAlert + custom Channels (OneSignal, Vonage WA/SMS)
├── Jobs/ # DispatchUserNotificationJob, PollFuelPricesJob,
# SendScheduledWhatsAppJob, SendPaymentFailedReminderJob
── Events/ # PricesUpdatedEvent
├── Listeners/ # HandleStripeWebhook (Cashier WebhookReceived)
├── Filament/ # Admin panel (Resources, Widgets) + Providers/Filament
├── Enums/ # FuelType, PlanTier, …
└── Livewire/Actions/ # Logout only — Livewire is otherwise vestigial
resources/views/
├── livewire/ # Livewire Blade templates
── emails/ # Mailable templates
app/Services/
├── FuelPriceService.php # Fuel Finder API polling + storage
── StationTaggingService.php # Supermarket brand detection
├── PostcodeService.php # postcode/outcode/place → lat/lng (+ LocationResult DTO)
├── PlanFeatures.php # Single source of tier-entitlement truth (see tiers.md)
├── HaversineQuery.php # Distance SQL helper
├── ApiLogger.php # Wraps outbound HTTP, logs to api_logs
├── StationSearch/ # StationSearchService (+ SearchCriteria, SearchResult DTOs)
├── BrentPriceSources/ # FRED + EIA Brent sources (+ BrentPriceFetcher)
└── Forecasting/ # Weekly forecast subsystem — WeeklyForecastService,
# LlmOverlayService, BacktestRunner, OutcomeResolver,
# VolatilityRegimeService, feature/leak tooling, …
resources/
├── js/ # Vue 3 SPA — see .claude/rules/frontend.md
└── views/
├── app.blade.php # SPA shell (mounts #app)
├── legal/ # Server-rendered legal pages
├── emails/ # Mailable templates
└── livewire/auth/ # Starter-kit Volt auth screens (left untouched)
routes/
├── web.php # All web routes (Livewire pages)
└── api.php # Empty for now — API added later if needed
├── web.php # SPA shell + catch-all, legal pages, billing redirects, server logout
└── api.php # REST API consumed by the SPA (see below)
```
> **Stale service names elsewhere.** Domain rule files (`scoring.md`,
> `prediction.md`, `notifications.md`) still name services that have since been
> refactored away — e.g. `AlertScoringService`, `OilPriceService`,
> `NationalFuelPredictionService`, `NotificationDispatchService`,
> `SubscriptionService`. Those concerns now live under `Services/Forecasting/`,
> `Services/StationSearch/`, and `PlanFeatures`. Always verify a service name
> against `app/Services/` before trusting it from those files.
## Service class conventions
- Constructor injection only — no facade usage inside Services
- Services are bound in AppServiceProvider if they need interfaces
- Each Service has one responsibility — do not merge concerns
- Return typed DTOs or Eloquent collections — never raw arrays from Services
- Services never dispatch jobs directly — that's the controller/command's job
- Services never dispatch jobs directly — that's the controller/command/listener's job
## No API yet
## The REST API
`routes/api.php` stays empty for v1. Do not create API controllers or Sanctum
token auth. The app is Livewire-only until subscriber count justifies a native app.
When the API is added later, it will reuse the same Service classes.
`routes/api.php` is the SPA's backend. Three access tiers:
## Livewire components (classic only)
- **Public** (no auth): `POST /api/auth/register`, `POST /api/auth/login`,
`GET /api/auth/me`, `GET /api/fuel-types`, `GET /api/stats/live`
- **API-key** (`VerifyApiKey` + `throttle:60,1`): `GET /api/stations`,
`GET /api/stats/searches`
- **Sanctum** (`auth:sanctum`): `POST /api/auth/logout`, `/api/user/*`
(preferences, saved stations, profile, password, delete account)
Use two-file classic Livewire components. Do NOT use Volt single-file syntax.
Volt files from the starter kit (auth screens) are left as-is — do not convert them.
New components go in `app/Livewire/` with corresponding Blade in `resources/views/livewire/`.
Conventions:
- Shape responses with API Resources (`app/Http/Resources/Api/`), never raw arrays.
- The fuel **prediction is embedded in the `/api/stations` response** under the
`prediction` key — there is no standalone prediction endpoint (see `prediction.md`).
- Tier-gate routes with the `feature` middleware (`RequiresFeature`); never
inline entitlement checks (see `tiers.md`).
- Auth is cookie/session via Sanctum SPA mode (axios sends XSRF + credentials);
the API-key gate protects the expensive station search from scraping.
## Frontend
The Vue SPA is documented in `.claude/rules/frontend.md`. Do **not** build new
Livewire components — Livewire/Flux remain only for the starter-kit auth screens
and the `Logout` action.

74
.claude/rules/frontend.md Normal file
View File

@@ -0,0 +1,74 @@
# Frontend — Vue 3 SPA
The entire user-facing app (landing, station search, dashboard, settings) is a
**Vue 3 single-page application** under `resources/js/`. It is served from the
Blade shell `resources/views/app.blade.php` via the SPA catch-all in
`routes/web.php` and consumes the REST API (`routes/api.php`). There is **no
Livewire in the product UI** — do not add Livewire / Volt / Alpine components for
new features. Build Vue.
## Stack
- Vue 3.5 with `<script setup>` (Composition API), Vue Router 4 (`createWebHistory`)
- Vite 8 (`npm run dev` / `npm run build`), `@vitejs/plugin-vue`
- Tailwind CSS v4 (`@tailwindcss/vite`)
- Leaflet for maps, iconify-icon + Lucide for icons, axios for HTTP
## Layout
```
resources/js/
├── app.js # Entry — createApp(App).use(router).mount('#app')
├── App.vue # Root — <RouterView/>, fetches the user on mount
├── axios.js # Configured axios instance (baseURL '/api', XSRF + credentials)
├── router/index.js # Routes + requiresAuth guard (redirects to /login)
├── views/
│ ├── Home.vue # Landing + station search (mirrors state to the URL query)
│ └── dashboard/ # Overview, SavedStations, Preferences, settings/{Profile,Security,Appearance}
├── components/ # LeafletMap, StationCard, StationList, PredictionCard/Full,
│ # PostSearchFilters, UpsellBanner, landing/*
├── composables/ # useAuth, useStations, useSavedStations
└── constants/ # fuelTypes.js — shared fuel-type source of truth (front + back)
```
## Conventions
- **Composition API + `<script setup>` only.** No Options API.
- **All server state goes through composables** that call the configured `api`
axios instance (`axios.js`) — never `fetch()` or a bare `axios` call inside a
component.
- **Auth is cookie/session (Sanctum SPA mode).** axios is configured with
`withCredentials` + `withXSRFToken`; don't add bearer-token handling.
- Route guards live in `router/index.js` (`meta.requiresAuth`). Hard navigations
(`/login`, `/logout`) use `window.location.href`, not the router.
- Keep business logic in the API / Services. Vue components render and orchestrate.
## Search & the shareable URL
`Home.vue` is the search surface. Search params (postcode **or** lat/lng,
`fuel_type`, `radius`, `sort`) are mirrored into the **URL query** via
`router.push` (`queryFromParams`) so searches are shareable and bookmarkable, and
restored on load (`paramsFromQuery`). Because the URL is shareable, **any
coordinates written to it must be coarsened** — a precise GPS pair from "Use my
location" (`HeroSearch.vue` → browser Geolocation) would otherwise broadcast the
sharer's exact position to everyone they send the link to.
## Maps (Leaflet)
`components/LeafletMap.vue` wraps Leaflet directly (Leaflet is a plain JS library,
not a Vue plugin). Station and user markers and the Google-Maps directions links
are built from `station.lat` / `station.lng`; the user marker comes from the
browser Geolocation API.
## Prediction
The fuel prediction ships **inside** the `/api/stations` response under the
`prediction` key and is rendered by `PredictionCard.vue` / `PredictionFull.vue`
(`useStations` reads `response.data.prediction`). See `prediction.md` for the
payload shape and the tier gate.
---
paths:
- "resources/js/**/*.vue"
- "resources/js/**/*.js"
---

View File

@@ -1,61 +0,0 @@
# Livewire Components
## Classic two-file components only
Do NOT use Volt single-file syntax for new components.
Volt files created by the Livewire starter kit (auth screens) are left untouched.
## Component locations
```
app/Livewire/
├── Dashboard/
│ ├── FuelRecommendation.php # Main fill-up/wait card
│ ├── NearbyStations.php # Map + station list
│ └── PriceHistory.php # 14-day trend chart
├── Account/
│ ├── NotificationSettings.php # Channel prefs + WhatsApp OTP
│ ├── SubscriptionManager.php # Upgrade/downgrade UI
│ └── FuelPreferences.php # Fuel type, postcode
└── Public/
└── PriceWatchdog.php # Public-facing local price watchdog
```
## Component conventions
- Component properties that are shown in the view must be `public`
- Use `#[Computed]` attribute for derived values — not re-computed on every render
- Validate with `#[Validate]` attribute on properties, not in separate rules array
- Never put Service instantiation in the component — inject via method parameter or mount()
- Dispatch browser events with `$this->dispatch()` not `$this->emit()` (Livewire 3 syntax)
- Use `wire:loading` on all buttons that trigger server actions
## Alpine.js usage
Alpine.js handles local UI state only: dropdowns, modals, tab switching, copy-to-clipboard.
Do not replicate server state in Alpine — use Livewire for anything that needs PHP.
Alpine components stay inline in Blade (`x-data="{}"`), not in separate JS files unless reused 3+ times.
## Map (Leaflet.js)
Leaflet is a plain JS drop-in, not a Livewire component.
Station data is fetched from a dedicated Livewire endpoint and passed to Leaflet via Alpine:
```blade
<div
x-data="stationMap(@entangle('stations'))"
id="map"
style="height: 400px"
></div>
```
Map initialisation lives in `resources/js/maps/station-map.js`.
## Page routing
Livewire full-page components are mounted in `routes/web.php` using `Route::get()->component()`.
No separate view files for pages — the Livewire component IS the page.
---
paths:
- "app/Livewire/**/*.php"
- "resources/views/livewire/**/*.blade.php"
---

View File

@@ -6,7 +6,7 @@
|------------|--------|-------|------------|----------|-----------|
| Free | £0 | ✓ weekly digest | ✗ | ✗ | ✗ |
| Basic | £0.99 | ✓ daily | ✓ daily | ✓ daily | ✗ |
| Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 1/day triggered |
| Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
| Pro | £3.99 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
## NotificationDispatchService
@@ -41,8 +41,8 @@ Subject line / message copy adapts based on `recommendation` and `confidence`.
- Same Vonage client, SMS channel
- Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it
- Plus: max 8 SMS/month (enforced via alerts table count)
- Pro: max 30 SMS/month
- Plus (Smart): max 3 SMS/day (enforced via notification_log count)
- Pro: deferred from launch — see `tiers.md`
- Cost: ~3.5p per message UK
## Email

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"
---

View File

@@ -50,7 +50,7 @@ All six are available to all tiers. The restriction is quantity only:
| email | weekly digest | daily | ✓ triggered | ✓ triggered |
| push | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
| whatsapp | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
| sms | ✗ | ✗ | ✓ max 1/day | ✓ max 3/day |
| sms | ✗ | ✗ | ✓ max 3/day | ✓ max 3/day |
WhatsApp also supports scheduled updates (morning + evening) independent of
price triggers — available to any tier that has WhatsApp enabled.

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 +0,0 @@
../../.agents/skills/antfu

View File

@@ -1 +0,0 @@
../../.agents/skills/nuxt

View File

@@ -1 +0,0 @@
../../.agents/skills/pinia

View File

@@ -1 +0,0 @@
../../.agents/skills/pnpm

View File

@@ -1 +0,0 @@
../../.agents/skills/slidev

View File

@@ -1 +0,0 @@
../../.agents/skills/tsdown

View File

@@ -1 +0,0 @@
../../.agents/skills/turborepo

View File

@@ -1 +0,0 @@
../../.agents/skills/unocss

View File

@@ -1 +0,0 @@
../../.agents/skills/vite

View File

@@ -1 +0,0 @@
../../.agents/skills/vitepress

View File

@@ -1 +0,0 @@
../../.agents/skills/vitest

View File

@@ -1 +0,0 @@
../../.agents/skills/vue

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-router-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-testing-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vueuse-functions

View File

@@ -1 +0,0 @@
../../.agents/skills/web-design-guidelines

View File

@@ -1,8 +1,8 @@
APP_NAME=Laravel
APP_NAME="Fuel Alert"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=http://fuel-alert.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-alert.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-alert.test

View File

@@ -1,50 +0,0 @@
name: linter
on:
push:
branches:
- develop
- main
- master
- workos
pull_request:
branches:
- develop
- main
- master
- workos
permissions:
contents: write
jobs:
quality:
runs-on: ubuntu-latest
environment: Testing
steps:
- uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: composer lint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v7
# with:
# commit_message: fix code style
# commit_options: '--no-verify'
# file_pattern: |
# **/*
# !.github/workflows/*

View File

@@ -1,60 +0,0 @@
name: tests
on:
push:
branches:
- develop
- main
- master
- workos
pull_request:
branches:
- develop
- main
- master
- workos
jobs:
ci:
runs-on: ubuntu-latest
environment: Testing
strategy:
matrix:
php-version: ['8.3', '8.4', '8.5']
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install Node Dependencies
run: npm i
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Build Assets
run: npm run build
- name: Run Tests
run: ./vendor/bin/pest

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.DS_Store
/.phpunit.cache
/node_modules
/public/build
@@ -22,3 +23,5 @@ yarn-error.log
/.vscode
/.zed
/.tmp/
/.worktrees/
/ONSPD_Online_Latest_Centroids_*.csv

View File

@@ -1 +0,0 @@
../../.agents/skills/antfu

View File

@@ -1 +0,0 @@
../../.agents/skills/nuxt

View File

@@ -1 +0,0 @@
../../.agents/skills/pinia

View File

@@ -1 +0,0 @@
../../.agents/skills/pnpm

View File

@@ -1 +0,0 @@
../../.agents/skills/slidev

View File

@@ -1 +0,0 @@
../../.agents/skills/tsdown

View File

@@ -1 +0,0 @@
../../.agents/skills/turborepo

View File

@@ -1 +0,0 @@
../../.agents/skills/unocss

View File

@@ -1 +0,0 @@
../../.agents/skills/vite

View File

@@ -1 +0,0 @@
../../.agents/skills/vitepress

View File

@@ -1 +0,0 @@
../../.agents/skills/vitest

View File

@@ -1 +0,0 @@
../../.agents/skills/vue

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-router-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-testing-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vueuse-functions

View File

@@ -1 +0,0 @@
../../.agents/skills/web-design-guidelines

View File

@@ -1,138 +0,0 @@
# SuperDesign Context Files for Fuel Price Application
This directory contains comprehensive design system documentation for the Fuel Price (Laravel Starter Kit) application.
## Generated Files
### 1. `init/components.md` (274 lines)
**Shared UI Primitives & Components**
Lists all reusable Blade components (7 total) with full source code:
- x-action-message: Temporary status messages
- x-app-logo: Branding component
- x-app-logo-icon: SVG logo icon
- x-auth-header: Centered auth page header
- x-auth-session-status: Session status display
- x-desktop-user-menu: User profile dropdown
- x-placeholder-pattern: Loading state pattern
Also documents Flux v2 components used throughout the application.
### 2. `init/layouts.md` (347 lines)
**Complete Layout Hierarchy**
Full content of 7 layout files:
- layouts/app.blade.php: Main app wrapper
- layouts/app/sidebar.blade.php: Authenticated layout with header/sidebar
- layouts/auth.blade.php: Auth wrapper
- layouts/auth/simple.blade.php: Centered auth layout
- layouts/auth/card.blade.php: Card-based auth layout
- layouts/auth/split.blade.php: Split-screen auth layout
- partials/head.blade.php: Shared head section
Includes usage patterns and color scheme documentation.
### 3. `init/routes.md` (120 lines)
**Route Configuration & Summary**
Complete files:
- routes/web.php: Public and authenticated routes
- routes/settings.php: Settings page routes
Plus detailed route summary table covering:
- 19 total routes (web + Fortify)
- Route grouping by protection level
- Livewire component routes mapping
### 4. `init/theme.md` (298 lines)
**Design Tokens & Configuration**
Complete content:
- resources/css/app.css: Tailwind imports and custom theme
- Color palette: Zinc neutral scale + semantic colors
- Typography: Instrument Sans font configuration
- Spacing system: 4px base unit scale
- Dark mode implementation
- Vite build configuration
- Flux theme setup
### 5. `init/pages.md` (291 lines)
**Page Dependency Trees**
Full-page component documentation with dependency trees:
1. StationSearch Livewire component (public)
2. Dashboard
3. Welcome/Home page
4. Settings pages (Profile, Security, Appearance)
5. Authentication pages (Login, Register, Password Reset, Email Verification, 2FA)
Includes data flow documentation and page transition patterns.
### 6. `init/extractable-components.md` (386 lines)
**Reusable Component Analysis**
16 components identified by category:
- 3 Form components (Search, Select, Loading Button)
- 6 Data Display components (Card, Stats, Legend)
- 2 Navigation components (Sidebar Nav, User Menu)
- 3 Layout components (Auth layouts)
- 2 Feedback components (Messages)
- 1 Map/Visualization component
- 2 Typography components
Includes reusability scoring table and extraction recommendations.
### 7. `init/design-system.md` (494 lines)
**Complete Design System**
Comprehensive design documentation:
- Brand identity & philosophy
- Color system: Zinc palette + semantic colors
- Typography: Typeface, hierarchy, weights
- Spacing & layout: Grid system, breakpoints
- Borders & radius: Border weight, color, radius scales
- Component patterns: Forms, Buttons, Cards, Navigation
- Data visualization: Map colors, marker styles
- Animations & transitions: Page transitions, component effects
- Accessibility: Contrast, interactive elements, typography
- Dark mode: Colors, images, implementation
- Responsive behavior: Breakpoints, layout adjustments
## Project Stack
- **Framework:** Laravel 11
- **UI Library:** Livewire 3 (classic components)
- **Styling:** Tailwind CSS v4
- **UI Components:** Flux UI v2
- **JavaScript:** Alpine.js
- **Font:** Instrument Sans (400, 500, 600 weights)
- **Map Library:** Leaflet
- **Map Tiles:** OpenStreetMap
## Key Features
- Dark-mode-first design with light mode support
- Responsive mobile-first architecture
- Comprehensive authentication UI (Fortify-based)
- Interactive fuel station search with Leaflet maps
- Settings/profile management
- Accessibility-focused component design
## Color Palette
### Primary
- Zinc neutral scale (50-950)
### Semantic
- Success: Green-500 (#22c55e)
- Warning: Amber-500 (#f59e0b)
- Error: Red-500 (#ef4444)
- Info: Slate-500 (#64748b)
## File Organization
All context files are in `init/` subdirectory for SuperDesign import.
Total: ~2,210 lines of comprehensive design documentation
Generated: April 6, 2026

View File

@@ -1,274 +0,0 @@
# Shared UI Components
## Blade Components (Reusable)
### 1. `x-action-message`
**Path:** `resources/views/components/action-message.blade.php`
Displays a temporary status message that auto-hides after 2 seconds using Alpine.js.
**Props:**
- `on`: Event name to listen for (Livewire event)
**Features:**
- Transition animation with fade-out
- Auto-hide timeout (2000ms)
- Default text: "Saved."
```blade
@props([
'on',
])
<div
x-data="{ shown: false, timeout: null }"
x-init="@this.on('{{ $on }}', () => { clearTimeout(timeout); shown = true; timeout = setTimeout(() => { shown = false }, 2000); })"
x-show.transition.out.opacity.duration.1500ms="shown"
x-transition:leave.opacity.duration.1500ms
style="display: none"
{{ $attributes->merge(['class' => 'text-sm']) }}
>
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
</div>
```
---
### 2. `x-app-logo`
**Path:** `resources/views/components/app-logo.blade.php`
Main app branding component. Renders as `flux:brand` (header) or `flux:sidebar.brand` (sidebar variant).
**Props:**
- `sidebar` (bool): If true, renders sidebar variant
- Inherits Flux component attributes
**Features:**
- Dynamic logo rendering based on context (header vs sidebar)
- Uses `x-app-logo-icon` for icon
- Passes through attributes to Flux components
```blade
@props([
'sidebar' => false,
])
@if($sidebar)
<flux:sidebar.brand name="Laravel Starter Kit" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:sidebar.brand>
@else
<flux:brand name="Laravel Starter Kit" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:brand>
@endif
```
---
### 3. `x-app-logo-icon`
**Path:** `resources/views/components/app-logo-icon.blade.php`
SVG icon for the application logo. Geometric design with fills.
**Features:**
- Scalable SVG
- Uses `currentColor` for dynamic styling
- Can be sized with Tailwind classes
```blade
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 42" {{ $attributes }}>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
</svg>
```
---
### 4. `x-auth-header`
**Path:** `resources/views/components/auth-header.blade.php`
Centered header for authentication pages with title and description.
**Props:**
- `title`: Main heading text
- `description`: Subheading/description text
**Features:**
- Centered layout
- Uses Flux typography components
```blade
@props([
'title',
'description',
])
<div class="flex w-full flex-col text-center">
<flux:heading size="xl">{{ $title }}</flux:heading>
<flux:subheading>{{ $description }}</flux:subheading>
</div>
```
---
### 5. `x-auth-session-status`
**Path:** `resources/views/components/auth-session-status.blade.php`
Displays session status messages (typically success messages after redirect).
**Props:**
- `status`: Status message text (if present)
- Passes through attributes
**Features:**
- Conditional rendering (only shows if status exists)
- Green success styling
```blade
@props([
'status',
])
@if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
{{ $status }}
</div>
@endif
```
---
### 6. `x-desktop-user-menu`
**Path:** `resources/views/components/desktop-user-menu.blade.php`
User profile dropdown menu with settings and logout options. Uses Flux components.
**Features:**
- Sidebar profile with dropdown
- Shows user avatar, name, and email
- Menu items: Settings (cog icon), Logout (arrow icon)
- Logout form with CSRF protection
- Data test attributes for testing
```blade
<flux:dropdown position="bottom" align="start">
<flux:sidebar.profile
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
icon:trailing="chevrons-up-down"
data-test="sidebar-menu-button"
/>
<flux:menu>
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
/>
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log out') }}
</flux:menu.item>
</form>
</flux:menu.radio.group>
</flux:menu>
</flux:dropdown>
```
---
### 7. `x-placeholder-pattern`
**Path:** `resources/views/components/placeholder-pattern.blade.php`
SVG placeholder pattern for skeleton/loading states. Diagonal line pattern.
**Props:**
- `id`: Unique pattern ID (auto-generated)
**Features:**
- Reusable SVG pattern definition
- Can be styled with Tailwind stroke classes
- Used in dashboard placeholder cards
```blade
@props([
'id' => uniqid(),
])
<svg {{ $attributes }} fill="none">
<defs>
<pattern id="pattern-{{ $id }}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
</pattern>
</defs>
<rect stroke="none" fill="url(#pattern-{{ $id }})" width="100%" height="100%"></rect>
</svg>
```
---
## Flux UI Components Used
Flux v2 components extensively used throughout the application:
- **Layout:** `flux:header`, `flux:sidebar`, `flux:main`, `flux:spacer`
- **Navigation:** `flux:navbar`, `flux:navbar.item`, `flux:sidebar.nav`, `flux:sidebar.item`, `flux:sidebar.group`
- **Forms:** `flux:input`, `flux:select`, `flux:checkbox`, `flux:button`
- **Typography:** `flux:heading`, `flux:subheading`, `flux:text`
- **Dropdowns/Menus:** `flux:dropdown`, `flux:menu`, `flux:menu.item`, `flux:menu.separator`
- **UI Elements:** `flux:badge`, `flux:avatar`, `flux:brand`, `flux:profile`
- **Other:** `flux:tooltip`, `flux:separator`, `flux:link`
---
## Flux Custom Icons
Located in `resources/views/flux/icon/`:
- `layout-grid.blade.php`
- `folder-git-2.blade.php`
- `chevrons-up-down.blade.php`
- `book-open-text.blade.php`
These extend Flux's default icon library.
---
## Alpine.js Data Objects
### `stationMap`
**Path:** `resources/js/maps/station-map.js`
Leaflet map integration for displaying fuel stations:
- Initializes map centered on UK
- Plots colored circle markers for stations
- Color coding: green (current), slate (recent), amber (stale), red (outdated)
- Popup display with station details
- Responsive bounds fitting
- Watches for data changes via Alpine

View File

@@ -1,494 +0,0 @@
# Design System
Comprehensive documentation of the design system, visual language, and brand identity for the Fuel Price application.
---
## Brand Identity
### App Name
"Fuel Price" (Laravel Starter Kit internally)
### Logo
- **Style:** Geometric, modern
- **Colors:** Adapts to light/dark mode
- **Variants:** Header logo (wider), Sidebar logo (square icon)
- **Usage:** All pages in navigation
### Design Philosophy
- Clean, minimal aesthetic
- Dark-first design (dark mode as primary)
- Accessibility-focused
- Data visualization emphasis (maps, charts)
- Utility-oriented interface
---
## Color System
### Primary Palette - Zinc (Neutral)
Used as base colors for UI, text, and borders.
```
Zinc-50: #fafafa (Lightest backgrounds)
Zinc-100: #f5f5f5 (Light backgrounds)
Zinc-200: #e5e5e5 (Light borders, dividers)
Zinc-300: #d4d4d4 (Subtle borders)
Zinc-400: #a3a3a3 (Placeholder text)
Zinc-500: #737373 (Secondary text)
Zinc-600: #525252 (Body text)
Zinc-700: #404040 (Dark text)
Zinc-800: #262626 (Page backgrounds dark)
Zinc-900: #171717 (Header/sidebar backgrounds)
Zinc-950: #0a0a0a (Darkest)
```
### Semantic Colors
#### Success
- **Primary:** `#22c55e` (Green-500)
- **Dark:** `#16a34a` (Green-600)
- **Light:** `#86efac` (Green-300)
- **Usage:** Success messages, positive indicators, current/fresh data
#### Warning
- **Primary:** `#f59e0b` (Amber-500)
- **Light:** `#fbbf24` (Amber-400)
- **Usage:** Stale data, deprecation notices
#### Error/Danger
- **Primary:** `#ef4444` (Red-500)
- **Dark:** `#dc2626` (Red-600)
- **Light:** `#fca5a5` (Red-300)
- **Usage:** Outdated data, error messages, destructive actions
#### Info
- **Primary:** `#64748b` (Slate-500)
- **Usage:** Neutral information, recent data
### Accent Colors
**Light Mode:**
- Accent: `--color-neutral-800` (dark gray)
- Accent Content: `--color-neutral-800`
- Accent Foreground: `--color-white`
**Dark Mode:**
- Accent: `--color-white`
- Accent Content: `--color-white`
- Accent Foreground: `--color-neutral-800`
Used for:
- Primary buttons
- Active navigation items
- Focus states
- Primary CTAs
---
## Typography System
### Typeface
**Primary Font:** Instrument Sans
- **Source:** Google Fonts (fonts.bunny.net)
- **Weights Available:** 400 (Regular), 500 (Medium), 600 (Semibold)
- **Category:** Sans-serif, Humanist
- **Use Case:** All body text, headings, UI labels
**Fallback Stack:**
```
'Instrument Sans',
ui-sans-serif,
system-ui,
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji'
```
### Font Sizes & Hierarchy
Via Flux components (sizes managed by Flux):
- **flux:heading size="xl":** Page titles (largest)
- **flux:heading size="lg":** Section titles
- **flux:heading:** Standard headings
- **flux:subheading:** Secondary headings, descriptions
- **flux:text:** Body text
- **flux:label:** Form labels, captions
- **flux:badge:** Small labels, tags
### Line Heights
- **Tight:** Labels, badges (`leading-tight`)
- **Normal:** Body text, headings (default)
- **Relaxed:** Long-form content (when needed)
### Weight Distribution
- **400 (Regular):** Body text, regular content
- **500 (Medium):** Secondary headings, strong emphasis, labels
- **600 (Semibold):** Primary headings, buttons, strong emphasis
---
## Spacing & Layout
### Spacing Scale
Based on 4px base unit:
```
0: 0px
0.5: 2px
1: 4px
1.5: 6px
2: 8px
2.5: 10px
3: 12px
3.5: 14px
4: 16px
5: 20px
6: 24px
7: 28px
8: 32px
9: 36px
10: 40px
11: 44px
12: 48px
...
```
### Common Spacing Patterns
- **Gap between elements:** `gap-3` (12px), `gap-4` (16px), `gap-6` (24px)
- **Form field spacing:** `gap-2` (8px) for label + input
- **Card padding:** `p-4` (16px) for compact, `p-6` (24px) for default, `p-10` (40px) for large
- **Horizontal padding:** `px-4` (16px) standard
- **Vertical padding:** `py-3` (12px) standard
### Grid System
- Flex-based layouts (no CSS Grid for most components)
- Default gap: `gap-4` (16px)
- Settings page: 2-column on desktop (220px sidebar + flex content)
- Station results: 1-column list, single-level nesting
### Responsive Breakpoints
```
Default (mobile): 0px
sm: 640px (@media max-width: 639px = mobile)
md: 768px (tablet)
lg: 1024px (desktop)
xl: 1280px (large desktop)
2xl: 1536px (extra large)
```
**Breakpoint Patterns:**
- `max-lg:` - Mobile and tablet
- `lg:` - Desktop and larger
- `max-md:` - Mobile only
- `md:` - Tablet and larger
---
## Border & Radius
### Border Weight
- **Default:** 1px (all borders)
- **Emphasis:** None (single weight used)
### Border Radius
- **Small:** `rounded-md` (6-8px) - Icons, small buttons, badges
- **Medium:** `rounded-lg` (8-10px) - Inputs, dropdowns
- **Large:** `rounded-xl` (12-16px) - Cards, modals, large components
- **None:** `rounded-none` - Rarely used
### Border Colors
**Light Mode:**
- Default border: `border-zinc-200`
- Focus ring: `ring-accent` (accent color)
**Dark Mode:**
- Default border: `border-zinc-700`
- Card/modal: `border-stone-800` or `border-neutral-800`
- Focus ring: `ring-accent` with `ring-offset-2` and `ring-offset-accent-foreground`
### Focus States
All interactive elements have:
- `ring-2 ring-accent` (focus ring)
- `ring-offset-2` (space between element and ring)
- `ring-offset-accent-foreground` (offset background color)
---
## Component Patterns
### Form Patterns
All form elements follow consistent structure:
1. Label (top)
2. Input/Select/Textarea
3. Error message (bottom, conditional)
4. Helper text (optional)
**Spacing:** `gap-2` between label and input
**States:**
- Normal: default appearance
- Focus: ring around element
- Disabled: opacity/pointer-events disabled
- Error: red text below
### Button Patterns
**Variants:**
- `variant="primary"` - Primary action (accent color background)
- `variant="secondary"` - Secondary action
- `variant="danger"` - Destructive action (red)
**States:**
- Normal: clickable
- Hover: slight opacity increase
- Disabled: `disabled` attribute via `wire:loading.attr="disabled"`
- Loading: show spinner or text change
### Card Patterns
Standard card styling:
- Border: `border border-zinc-200 dark:border-zinc-700`
- Radius: `rounded-xl`
- Background: white (light) / dark (dark mode)
- Padding: `p-4` to `p-10` depending on content density
- Shadow: `shadow-xs` for subtle lift (optional)
### Navigation Patterns
**Sidebar Navigation:**
- `flux:sidebar.nav` wrapper
- `flux:sidebar.group` for sections (with heading)
- `flux:sidebar.item` for links
- Active state: current attribute
- Icon on left, text on right
**Navbar Navigation:**
- `flux:navbar` wrapper
- `flux:navbar.item` for links
- Icons only or icon + text
- Horizontal layout
- Tooltip support
---
## Data Visualization
### Station Search Map Colors (Leaflet)
Classification-based color coding for fuel price data freshness:
- **Current (< 24h):** `#22c55e` (Green-500)
- **Recent (24-48h):** `#64748b` (Slate-500)
- **Stale (2-5 days):** `#f59e0b` (Amber-500)
- **Outdated (5+ days):** `#ef4444` (Red-500)
### Map Markers
- **Shape:** Circle
- **Radius:** 9px
- **Stroke:** White, 2px weight
- **Fill:** Classification color
- **Opacity:** 85% (0.85)
- **Click:** Shows popup with station details
### Data Table Patterns
(No explicit tables in current design, but pattern would be):
- Striped rows (alternate bg)
- Hover states for interactivity
- Clear column alignment
- Sortable headers
---
## Animations & Transitions
### Page Transitions
All navigation via `wire:navigate` (no full page reload):
- Seamless SPA-like experience
- Preserves scroll position
- No loading screen between pages
### Component Transitions
**Alpine.js Transitions:**
- Fade on message dismiss: `x-show.transition.out.opacity.duration.1500ms`
- Duration: 1500ms (1.5 seconds)
- Effect: Opacity fade
**Flux Built-in:**
- Dropdown/menu opens: instant or quick fade
- Sidebar collapse: smooth width transition
- Modal appears: fade + scale (typical Flux defaults)
### Hover & Active States
- Links: `hover:underline` (default Flux)
- Buttons: Opacity change on hover
- Navigation items: Background highlight on hover/active
- Interactive elements: Subtle color shift
---
## Accessibility
### Color Contrast
- Text on backgrounds meets WCAG AA standards (4.5:1 for normal text)
- Semantic colors (green/red) supplemented with icons/patterns
- No color-only indicators
### Interactive Elements
- Keyboard navigable (all Flux components)
- Focus indicators visible (`ring-2 ring-accent`)
- Touch-friendly sizing (minimum 44x44px recommended)
- ARIA labels where needed
### Typography
- Font sizes readable at standard distances
- Line height adequate for readability
- High contrast between text and background
---
## Dark Mode
### Implementation
- **Method:** `class="dark"` on HTML root
- **Toggle:** Managed by `@fluxAppearance` directive
- **Detection:** Optional prefers-color-scheme integration
### Dark Mode Colors
**Text:**
- Primary: `text-zinc-100` (light gray)
- Secondary: `text-zinc-400` (medium gray)
- Disabled: `text-zinc-500` (darker gray)
**Backgrounds:**
- Page: `bg-zinc-800`
- Header/Sidebar: `bg-zinc-900`
- Cards: `bg-stone-950` (darker variant)
- Inputs: `bg-zinc-900`
**Borders:**
- Primary: `border-zinc-700`
- Secondary: `border-stone-800`
- Subtle: `border-neutral-800`
**Accents:**
- Primary accent inverts: white (instead of dark gray)
### Image/SVG Handling in Dark
- Logo icon color inverts via `fill-current` and `text-*` classes
- Patterns use opacity: `stroke-neutral-100/20` (dark) vs `stroke-gray-900/20` (light)
- Leaflet map: Default OSM colors (already dark-friendly)
---
## Responsive Behavior
### Mobile-First Approach
Design starts at mobile (smallest), enhances at larger breakpoints.
### Key Breakpoints
**Mobile (0-639px):**
- Single column layouts
- Hamburger menu (sidebar collapses)
- Stacked form fields
- Full-width cards
**Tablet (640-1023px):**
- Narrower multi-column (if applicable)
- Touch-friendly spacing
- Condensed headers
**Desktop (1024px+):**
- Multi-column layouts
- Horizontal navigation
- Sidebar persistent
- Full feature set visible
### Layout Adjustments
- Header: Hidden navbar items on mobile (`max-lg:hidden`)
- Sidebar: Becomes mobile hamburger at lg breakpoint
- Forms: Stack vertically on mobile, horizontal on desktop
- Grids: 1 column mobile, 2-3 columns desktop
---
## Consistency Patterns
### Consistent Component Usage
- All buttons: `flux:button` (never raw `<button>`)
- All inputs: `flux:input` (with validation/errors)
- All selects: `flux:select`
- All headings: `flux:heading` (sizes: xl, lg, etc.)
### Consistent Spacing
- Inter-element gaps: multiples of 4px
- Card padding: consistent with gap sizes
- Consistent use of flexbox for alignment
### Consistent States
- Forms show errors below input in red
- Success messages fade out after 2 seconds
- Disabled states use opacity + pointer-events
- Loading states shown via spinner or text change
---
## Implementation Notes
### CSS Custom Properties
```css
--font-sans: 'Instrument Sans', ...
--color-accent: (theme-dependent)
--color-accent-content: (theme-dependent)
--color-accent-foreground: (theme-dependent)
--color-zinc-{50-950}: (full palette)
```
### Tailwind Integration
- Uses Tailwind CSS v4 with `@tailwindcss/vite`
- `@theme` block defines custom colors and fonts
- `@custom-variant` for dark mode
- `@layer` for CSS overrides
### Flux Integration
- Heavy reliance on Flux v2 components
- Flux provides theming, dark mode, layout components
- Custom CSS only overrides specific Flux defaults
- No Flux config file needed (uses defaults + CSS)

View File

@@ -1,386 +0,0 @@
# Extractable UI Components
This document identifies reusable UI components that could be extracted, packaged, and reused across other projects.
---
## Form Components
### 1. Search Input with Debounce
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 8-16)
**Category:** Forms
**Complexity:** Low
**Props:**
- `name`: Input field name
- `wire:model`: Livewire property binding
- `label`: Display label
- `placeholder`: Placeholder text
**Features:**
- Flux form styling
- Wire model binding support
- Error message display
**Extractable:** Yes - Generic search input wrapper
---
### 2. Multi-Option Select Dropdown
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 20-28, 34-42, 44-52)
**Category:** Forms
**Complexity:** Low
**Props:**
- `wire:model.live` or `wire:model`: Binding
- `name`: Field name
- `label`: Label text
- Options as slot
**Features:**
- Reactive updates via `wire:model.live`
- Error display
- Multiple select options
**Variants:**
- Fuel type selector (6 options)
- Radius selector (5 options)
- Sort selector (5 options)
**Extractable:** Yes - Reusable select component wrapper
---
### 3. Form Submission Button with Loading State
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 54-59)
**Category:** Forms
**Complexity:** Low
**Props:**
- `type`: button type
- `variant`: "primary", etc.
- `wire:loading.attr`: Disabled state during loading
- `wire:target`: Target action
**Features:**
- Dynamic text based on loading state
- Wire loading indicators
- Primary variant styling
**Extractable:** Yes - Generic loading button component
---
## Data Display Components
### 4. Station Result Card
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 94-128)
**Category:** Data Display
**Complexity:** Medium
**Props:**
- `station`: Object with station data
- `name`: Station name
- `address`: Street address
- `postcode`: Postcode
- `distance_km`: Distance in kilometers
- `price`: Fuel price in pence
- `price_classification`: One of (current, recent, stale, outdated)
- `price_classification_label`: Human label
- `price_updated_at`: ISO date string
- `is_supermarket`: Boolean
**Features:**
- Responsive layout (flex items-center justify-between)
- Color-coded price based on freshness
- Supermarket badge display
- Distance/address display
- Time-ago formatting
**Extractable:** Yes - Could be standalone component for displaying station data
---
### 5. Results Count Summary
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 72-76)
**Category:** Data Display
**Complexity:** Low
**Props:**
- `count`: Total stations found
- `lowest_pence`: Lowest price in pence
- `avg_pence`: Average price in pence
**Features:**
- Formatted currency display
- Singular/plural handling
- Stats display on one line
**Extractable:** Yes - Generic stats summary component
---
### 6. Classification Legend/Color Code Guide
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 84-90)
**Category:** Data Display
**Complexity:** Low
**Props:**
- Array of classification items with colors and labels
**Features:**
- Inline color swatches
- Legend item labels
- Responsive flex layout
**Extractable:** Yes - Reusable legend component
---
## Navigation Components
### 7. Settings Sidebar Navigation
**Source:** `resources/views/pages/settings/layout.blade.php` (lines 3-7)
**Category:** Navigation
**Complexity:** Low
**Props:**
- Navigation items array with:
- `label`: Display text
- `route`: Route name
- `icon`: Optional icon name
**Features:**
- flux:navlist component
- wire:navigate support
- Active state detection
- Mobile/desktop responsive
**Extractable:** Yes - Generic sidebar nav for multi-section pages
---
### 8. User Profile Dropdown Menu
**Source:** `resources/views/components/desktop-user-menu.blade.php`
**Category:** Navigation
**Complexity:** Medium
**Props:**
- User object with:
- `name`: User name
- `email`: User email
- `initials()`: Method to get initials
**Features:**
- Flux dropdown + menu
- Avatar display with initials
- Settings link
- Logout form with CSRF
- Test attributes for QA
**Extractable:** Yes - Standalone user menu component for authenticated apps
---
## Layout Components
### 9. Centered Authentication Layout Container
**Source:** `resources/views/layouts/auth/simple.blade.php`
**Category:** Layouts
**Complexity:** Low
**Props:**
- None (slot-based)
**Features:**
- Centered flex layout
- Max-width constraint (sm)
- Dark gradient background
- Logo link at top
- Responsive padding
**Extractable:** Yes - Generic centered auth layout
---
### 10. Card-Based Form Container
**Source:** `resources/views/layouts/auth/card.blade.php`
**Category:** Layouts
**Complexity:** Low
**Props:**
- None (slot-based)
**Features:**
- White card on dark background
- Rounded borders with shadow
- Dark mode support
- Centered with max-width
- Padding inside card
**Extractable:** Yes - Modal/card wrapper component
---
### 11. Split-Screen Auth Layout
**Source:** `resources/views/layouts/auth/split.blade.php`
**Category:** Layouts
**Complexity:** Medium
**Props:**
- Quote display (generated from Inspiring::quotes)
**Features:**
- Two-column grid (desktop only)
- Dark sidebar with quote/branding
- Form content on right
- Mobile-friendly (single column)
- Absolute background fill
**Extractable:** Yes - Premium auth layout component
---
## Status/Feedback Components
### 12. Temporary Action Message
**Source:** `resources/views/components/action-message.blade.php`
**Category:** Feedback
**Complexity:** Low
**Props:**
- `on`: Livewire event to listen to
- Slot for custom message (defaults to "Saved.")
**Features:**
- Alpine.js event listener
- Auto-hide after 2 seconds
- Fade transition
- Livewire integration
**Extractable:** Yes - Generic toast/message component
---
### 13. Session Status Message
**Source:** `resources/views/components/auth-session-status.blade.php`
**Category:** Feedback
**Complexity:** Low
**Props:**
- `status`: Status message text
**Features:**
- Conditional rendering
- Green success styling
- Used in auth forms
**Extractable:** Yes - Simple status display component
---
## Map/Visualization Components
### 14. Leaflet Map Integration
**Source:** `resources/js/maps/station-map.js`
**Category:** Map/Visualization
**Complexity:** High
**Props:**
- `results`: Array of station data with lat/lng/classifications
**Features:**
- Leaflet map initialization
- OpenStreetMap tiles
- Custom circle markers with colors
- Popup on marker click
- Auto-fit bounds
- Reactive to data changes
**Extractable:** Yes - Could be abstracted into reusable Leaflet component
**Note:** Currently tightly coupled to Alpine.js and station data structure. Would require prop mapping for reuse.
---
## Typography/Header Components
### 15. Auth Page Header
**Source:** `resources/views/components/auth-header.blade.php`
**Category:** Typography
**Complexity:** Low
**Props:**
- `title`: Main heading
- `description`: Subheading
**Features:**
- Centered text
- Flux typography (heading + subheading)
- Used on all auth pages
**Extractable:** Yes - Generic header for centered pages
---
### 16. Settings Page Header
**Source:** `resources/views/partials/settings-heading.blade.php`
**Category:** Typography
**Complexity:** Low
**Props:**
- None (hardcoded for settings)
**Features:**
- Page title "Settings"
- Subheading text
- Separator line
**Extractable:** Maybe - Specific to settings but pattern is reusable
---
## Reusability Summary Table
| Component | Type | Complexity | Reusable | Dependencies | Extraction Cost |
|-----------|------|-----------|----------|---|---|
| Search Input | Form | Low | High | Flux | Very Low |
| Select Dropdown | Form | Low | High | Flux | Very Low |
| Loading Button | Form | Low | High | Flux, Livewire | Very Low |
| Station Card | Display | Medium | High | Flux | Low |
| Stats Summary | Display | Low | High | None | Very Low |
| Legend | Display | Low | High | Tailwind | Very Low |
| Settings Nav | Nav | Low | High | Flux | Very Low |
| User Menu | Nav | Medium | High | Flux, Auth | Low |
| Simple Auth Layout | Layout | Low | High | Tailwind | Very Low |
| Card Container | Layout | Low | High | Tailwind | Very Low |
| Split Layout | Layout | Medium | High | Tailwind | Low |
| Action Message | Feedback | Low | High | Alpine, Livewire | Very Low |
| Status Message | Feedback | Low | High | None | Very Low |
| Leaflet Map | Visualization | High | Medium | Leaflet, Alpine | Medium |
| Auth Header | Typography | Low | High | Flux | Very Low |
| Settings Header | Typography | Low | Medium | Flux | Very Low |
---
## Top Candidates for Extraction
1. **Search Input Component** - Generic, simple, widely useful
2. **Station Card Component** - Good showcase of complex data display
3. **User Menu Component** - Authentication pattern, widely needed
4. **Loading Button Component** - Form UX pattern, commonly needed
5. **Simple Auth Layout** - Authentication flows are common
---
## Framework Package Recommendations
If extracting these components into a package:
1. Make them framework-agnostic (or have adapters)
2. Ensure Flux/Livewire are peer dependencies
3. Document required Alpine.js versions
4. Provide TypeScript declarations for props
5. Include Storybook examples
6. Consider CSS-in-JS vs Tailwind integration

View File

@@ -1,347 +0,0 @@
# Layout Files
## App Layouts (For Authenticated Users)
### 1. `layouts/app.blade.php` (Main App Layout)
**Path:** `resources/views/layouts/app.blade.php`
Wrapper layout that delegates to sidebar layout. Used by authenticated routes.
```blade
<x-layouts::app.sidebar :title="$title ?? null">
<flux:main>
{{ $slot }}
</flux:main>
</x-layouts::app.sidebar>
```
---
### 2. `layouts/app/sidebar.blade.php` (Sidebar Layout with Header/Navigation)
**Path:** `resources/views/layouts/app/sidebar.blade.php`
Core authenticated layout with sidebar navigation and header. Renders app header on desktop, mobile sidebar.
**Key Features:**
- Dark mode support (HTML class="dark")
- Responsive: Sidebar on desktop, hamburger on mobile
- Desktop navbar with Dashboard link
- Mobile header with sidebar toggle
- Search, Repository, and Documentation links
- User menu in header (desktop) and mobile sidebar
- Uses Flux components extensively
**Structure:**
- HTML/head with partials.head include
- Body with min-h-screen, dark mode bg colors
- flux:header (desktop) with:
- Sidebar toggle (mobile only)
- App logo
- Desktop navbar with links
- Spacer
- Secondary navbar (Search, Repo, Docs icons)
- Desktop user menu
- flux:sidebar (mobile) with:
- App logo
- Sidebar collapse button
- Navigation group (Platform section)
- External links (Repo, Docs)
- flux:main slot for page content
- @fluxScripts directive at end
```blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white dark:bg-zinc-800">
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.toggle class="lg:hidden mr-2" icon="bars-2" inset="left" />
<x-app-logo href="{{ route('dashboard') }}" wire:navigate />
<flux:navbar class="-mb-px max-lg:hidden">
<flux:navbar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:navbar.item>
</flux:navbar>
<flux:spacer />
<flux:navbar class="me-1.5 space-x-0.5 rtl:space-x-reverse py-0!">
<flux:tooltip :content="__('Search')" position="bottom">
<flux:navbar.item class="!h-10 [&>div>svg]:size-5" icon="magnifying-glass" href="#" :label="__('Search')" />
</flux:tooltip>
<flux:tooltip :content="__('Repository')" position="bottom">
<flux:navbar.item
class="h-10 max-lg:hidden [&>div>svg]:size-5"
icon="folder-git-2"
href="https://github.com/laravel/livewire-starter-kit"
target="_blank"
:label="__('Repository')"
/>
</flux:tooltip>
<flux:tooltip :content="__('Documentation')" position="bottom">
<flux:navbar.item
class="h-10 max-lg:hidden [&>div>svg]:size-5"
icon="book-open-text"
href="https://laravel.com/docs/starter-kits#livewire"
target="_blank"
:label="__('Documentation')"
/>
</flux:tooltip>
</flux:navbar>
<x-desktop-user-menu />
</flux:header>
<!-- Mobile Menu -->
<flux:sidebar collapsible="mobile" sticky class="lg:hidden border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.header>
<x-app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
<flux:sidebar.collapse class="in-data-flux-sidebar-on-desktop:not-in-data-flux-sidebar-collapsed-desktop:-mr-2" />
</flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.group :heading="__('Platform')">
<flux:sidebar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:sidebar.item>
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
<flux:sidebar.nav>
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
{{ __('Repository') }}
</flux:sidebar.item>
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
{{ __('Documentation') }}
</flux:sidebar.item>
</flux:sidebar.nav>
</flux:sidebar>
{{ $slot }}
@fluxScripts
</body>
</html>
```
---
## Auth Layouts (For Unauthenticated Users)
### 3. `layouts/auth.blade.php` (Auth Wrapper)
**Path:** `resources/views/layouts/auth.blade.php`
Simple wrapper that delegates to simple layout. Used for auth pages (login, register, password reset, etc.).
```blade
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>
```
---
### 4. `layouts/auth/simple.blade.php` (Simple Auth Layout - Centered)
**Path:** `resources/views/layouts/auth/simple.blade.php`
Minimalist centered layout for authentication. Used for login, register, etc.
**Key Features:**
- Dark mode with gradient background (dark:from-neutral-950 to-neutral-900)
- Centered content with max-width constraint
- App logo centered with link to home
- Dark mode gradient background
- Flex column centered layout
```blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-2">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
</a>
<div class="flex flex-col gap-6">
{{ $slot }}
</div>
</div>
</div>
@fluxScripts
</body>
</html>
```
---
### 5. `layouts/auth/card.blade.php` (Card-based Auth Layout)
**Path:** `resources/views/layouts/auth/card.blade.php`
Card-based authentication layout with rounded borders and shadow. Alternative to simple layout.
**Key Features:**
- Centered card with white background and dark border
- Max-width constraint
- Padding inside card (px-10 py-8)
- Light neutral-100 background
- Dark mode: stone-950 card bg with stone-800 border
```blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-md flex-col gap-6">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
</a>
<div class="flex flex-col gap-6">
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<div class="px-10 py-8">{{ $slot }}</div>
</div>
</div>
</div>
</div>
@fluxScripts
</body>
</html>
```
---
### 6. `layouts/auth/split.blade.php` (Split-screen Auth Layout)
**Path:** `resources/views/layouts/auth/split.blade.php`
Split-screen layout with marketing content on left (desktop only) and form on right.
**Key Features:**
- Two-column layout on desktop (lg:grid-cols-2)
- Left side: Dark background (neutral-900) with motivational quote (hidden on mobile)
- Right side: Form content
- Logo centered on mobile, top-left on desktop (left side)
- Quote display with blockquote
- Uses Flux heading component
- Full viewport height (h-dvh)
```blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800">
<div class="absolute inset-0 bg-neutral-900"></div>
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
<span class="flex h-10 w-10 items-center justify-center rounded-md">
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
</span>
{{ config('app.name', 'Laravel') }}
</a>
@php
[$message, $author] = str(Illuminate\Foundation\Inspiring::quotes()->random())->explode('-');
@endphp
<div class="relative z-20 mt-auto">
<blockquote class="space-y-2">
<flux:heading size="lg">&ldquo;{{ trim($message) }}&rdquo;</flux:heading>
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer>
</blockquote>
</div>
</div>
<div class="w-full lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
</a>
{{ $slot }}
</div>
</div>
</div>
@fluxScripts
</body>
</html>
```
---
## Head/Partial Layouts
### 7. `partials/head.blade.php`
**Path:** `resources/views/partials/head.blade.php`
Shared head section included in all layouts.
**Content:**
- Meta charset and viewport
- Dynamic title generation
- Favicon setup (ico, svg, apple-touch-icon)
- Fonts: Instrument Sans from fonts.bunny.net (weights 400, 500, 600)
- Vite asset loading (CSS and JS)
- Flux appearance directive (dark mode toggle support)
```blade
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
{{ filled($title ?? null) ? $title.' - '.config('app.name', 'Laravel') : config('app.name', 'Laravel') }}
</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance
```
---
## Usage Pattern
| Route | Layout Chain | Use Case |
|-------|-----------|----------|
| `/` (home) | Custom (not using these layouts) | Landing page |
| `/dashboard` | `layouts/app``layouts/app/sidebar` | Authenticated user dashboard |
| `/login`, `/register` | `layouts/auth``layouts/auth/simple` | Simple auth forms |
| `/stations` | StationSearch Livewire component (no layout wrapper) | Public search page |
| `/settings/*` | `layouts/app``layouts/app/sidebar` | Settings pages |
---
## Color Scheme in Layouts
- **Light Mode:** white background, zinc borders
- **Dark Mode:** zinc-800 body, zinc-900 header/sidebar, zinc-700 borders
- **Auth Pages (Dark):** linear gradient from neutral-950 to neutral-900

View File

@@ -1,291 +0,0 @@
# Pages & Full-Page Components
## Page Dependency Trees
Pages are organized by their view location and dependencies. Livewire components with full-page routes are documented here.
---
## 1. StationSearch (Public Livewire Component)
**Route:** `/stations`
**Component:** `App\Livewire\Public\StationSearch`
**View:** `resources/views/livewire/public/station-search.blade.php`
### Dependency Tree
```
StationSearch (Livewire Component)
├── resources/views/livewire/public/station-search.blade.php
│ ├── flux:heading (component)
│ ├── flux:subheading (component)
│ ├── form > flux:input (Fuel location input)
│ ├── form > flux:select (Fuel type selector)
│ │ └── Options: Petrol, E5, Diesel, Premium Diesel, B10, HVO
│ ├── form > flux:select (Radius selector)
│ │ └── Options: 1, 2, 5, 10, 20 miles
│ ├── form > flux:select (Sort selector)
│ │ └── Options: Price, Distance, Updated, Brand, Reliable
│ ├── form > flux:button (Search button)
│ ├── Error display (conditional)
│ ├── Results meta (count, cheapest, average)
│ ├── x-data="stationMap(...)" (Alpine.js map)
│ │ └── resources/js/maps/station-map.js
│ │ ├── Leaflet map initialization
│ │ ├── OpenStreetMap tiles
│ │ └── Circle markers (station data)
│ ├── Legend (color codes for data age)
│ └── Results list
│ └── Station cards (name, address, price, distance, age classification)
│ └── flux:badge (Supermarket tag, conditional)
```
### Data Flow
1. **PHP Class** (`App\Livewire\Public\StationSearch`):
- Properties: `$search`, `$fuelType`, `$radius`, `$sort`
- Properties: `$results[]`, `$meta[]`, `$apiError`
- Methods:
- `updatedFuelType()`: Refetch on fuel type change
- `updatedRadius()`: Refetch on radius change
- `updatedSort()`: Refetch on sort change
- `findStations()`: HTTP request to `/api/stations`
- `render()`: Returns view
2. **View Interactions**:
- `wire:model` on search input (two-way binding)
- `wire:model.live` on selectors (reactive updates)
- `wire:submit="findStations"` on form submit
- `wire:loading` for button state
- `wire:target="findStations"` for loading indicator
- `@entangle('results')` for Alpine.js map data
3. **JavaScript**:
- `stationMap(results)` Alpine data object
- Watches for `results` property changes
- Renders/updates Leaflet markers dynamically
---
## 2. Dashboard
**Route:** `/dashboard`
**View:** `resources/views/dashboard.blade.php`
**Middleware:** `auth`, `verified`
### Dependency Tree
```
dashboard.blade.php
├── Layout: x-layouts::app (authenticated layout)
│ └── layouts/app.blade.php
│ └── x-layouts::app.sidebar (sidebar layout)
│ └── layouts/app/sidebar.blade.php
│ ├── partials/head.blade.php
│ ├── flux:header (navigation)
│ │ ├── x-app-logo
│ │ ├── flux:navbar with Dashboard link
│ │ ├── Secondary icons (Search, Repo, Docs)
│ │ └── x-desktop-user-menu
│ └── flux:sidebar (mobile navigation)
├── flux:main (page content wrapper)
└── Content: Grid of placeholder cards
└── x-placeholder-pattern (diagonal line SVG pattern)
└── 3 aspect-video cards (top row)
└── 1 flex-1 card (bottom, full height)
```
### Features
- Placeholder cards for future dashboard widgets
- Responsive grid: 3 columns on desktop, stacked on mobile
- Dark mode support with adjusted stroke colors
---
## 3. Welcome/Home Page
**Route:** `/`
**View:** `resources/views/welcome.blade.php`
### Note
The welcome view is large (>15KB) and contains:
- Full HTML with embedded Tailwind CSS
- Hero section with Fuel Price branding
- CTA buttons (Search Stations, View Source)
- Feature cards
- Dark mode support
- No layout wrapper (standalone)
(Full content available in actual file due to size)
---
## 4. Settings Pages
**Route:** `/settings/*`
**Layout:** `x-layouts::app``layouts/app/sidebar.blade.php`
### 4.1 Profile Settings
**Route:** `/settings/profile`
**Livewire Component:** `pages::settings.profile`
### Dependency Tree
```
settings/profile (Livewire Component)
├── Layout: x-layouts::app
│ └── layouts/app/sidebar.blade.php
├── partials/settings-heading.blade.php
│ ├── flux:heading ("Settings")
│ ├── flux:subheading ("Manage your profile...")
│ └── flux:separator
├── pages/settings/layout.blade.php (settings sidebar layout)
│ ├── flux:navlist (navigation)
│ │ ├── flux:navlist.item (Profile - current)
│ │ ├── flux:navlist.item (Security)
│ │ └── flux:navlist.item (Appearance)
│ │
│ └── Slot content (form fields)
│ └── Profile edit form (Livewire form)
│ ├── flux:input (Name)
│ ├── flux:input (Email)
│ ├── flux:button (Save)
│ └── Action messages on update
```
### 4.2 Security Settings
**Route:** `/settings/security`
**Livewire Component:** `pages::settings.security`
- Two-factor authentication setup/management
- Recovery codes display
- Password confirmation (optional middleware)
### 4.3 Appearance Settings
**Route:** `/settings/appearance`
**Livewire Component:** `pages::settings.appearance`
- Theme selection (light/dark mode)
- Preference persistence
---
## 5. Authentication Pages
**Layout:** `x-layouts::auth` → Various auth layouts
### 5.1 Login
**Route:** `/login`
**View:** `resources/views/pages/auth/login.blade.php`
**Layout:** `layouts/auth/simple.blade.php`
### Dependency Tree
```
login.blade.php
├── Layout: x-layouts::auth
│ └── layouts/auth/simple.blade.php
│ ├── partials/head.blade.php
│ └── Centered login form
├── x-auth-header (title + description)
├── x-auth-session-status (success message display)
├── form (POST to login.store)
│ ├── csrf token
│ ├── flux:input (Email)
│ ├── flux:input (Password, viewable)
│ │ └── flux:link to password.request (forgot password)
│ ├── flux:checkbox (Remember me)
│ └── flux:button (Log in, primary)
└── Sign up link (flux:link to register)
```
### 5.2 Register
**Route:** `/register`
**View:** `resources/views/pages/auth/register.blade.php`
**Layout:** `layouts/auth/simple.blade.php`
```
register.blade.php
├── Layout: layouts/auth/simple.blade.php
├── x-auth-header
├── x-auth-session-status
├── form (POST to register.store)
│ ├── flux:input (Name)
│ ├── flux:input (Email)
│ ├── flux:input (Password, viewable)
│ ├── flux:input (Password Confirmation, viewable)
│ └── flux:button (Create account, primary)
└── Log in link
```
### 5.3 Password Reset
**Route:** `/forgot-password` and `/reset-password/{token}`
**Views:** `pages/auth/forgot-password.blade.php`, `pages/auth/reset-password.blade.php`
**Layout:** `layouts/auth/simple.blade.php`
### 5.4 Email Verification
**Route:** `/verify-email`
**View:** `pages/auth/verify-email.blade.php`
**Layout:** `layouts/auth/simple.blade.php`
### 5.5 Two-Factor Challenge
**Route:** `/two-factor-challenge`
**View:** `pages/auth/two-factor-challenge.blade.php`
**Layout:** `layouts/auth/simple.blade.php`
---
## Component Include Hierarchy
### Layout Chain (All Authenticated Pages)
```
Page View
└── x-layouts::app (wrapper)
└── x-layouts::app.sidebar (main layout)
├── partials/head (in <head>)
│ ├── CSS imports (Vite)
│ ├── @fluxAppearance
│ └── Font preload
├── flux:header (desktop navigation)
│ ├── x-app-logo
│ ├── x-desktop-user-menu
│ └── flux:navbar / flux:tooltip components
├── flux:sidebar (mobile navigation)
│ ├── x-app-logo (:sidebar="true")
│ └── Navigation items
└── flux:main (page content slot)
└── Page-specific content
```
### Layout Chain (Auth Pages)
```
Auth Page View
└── x-layouts::auth (wrapper)
└── x-layouts::auth.simple (centered layout)
├── partials/head (in <head>)
└── Centered card with form
├── x-app-logo-icon
└── x-auth-header or form content
```
---
## Page Transitions
All pages use `wire:navigate` for Livewire navigation (no full page reload).
Example:
- `wire:navigate` on `<flux:brand>` links
- `wire:navigate` on `<flux:link>` components
- `wire:navigate` on navigation items
This enables seamless SPA-like experience.

View File

@@ -1,120 +0,0 @@
# Routes
## Public Routes
### `routes/web.php`
```php
<?php
use App\Livewire\Public\StationSearch;
use Illuminate\Support\Facades\Route;
Route::view('/', 'homepage')->name('home');
Route::get('/stations', StationSearch::class)->name('stations.search');
Route::middleware(['auth', 'verified'])->group(function () {
Route::view('dashboard', 'dashboard')->name('dashboard');
});
require __DIR__.'/settings.php';
```
---
## Settings Routes
### `routes/settings.php`
```php
<?php
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Features;
Route::middleware(['auth'])->group(function () {
Route::redirect('settings', 'settings/profile');
Route::livewire('settings/profile', 'pages::settings.profile')->name('profile.edit');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::livewire('settings/appearance', 'pages::settings.appearance')->name('appearance.edit');
Route::livewire('settings/security', 'pages::settings.security')
->middleware(
when(
Features::canManageTwoFactorAuthentication()
&& Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'),
['password.confirm'],
[],
),
)
->name('security.edit');
});
```
**Note:** Auth routes (login, register, password reset, etc.) are provided by Laravel Fortify. See `config/fortify.php` for route definitions.
---
## Route Summary Table
| Method | Path | Name | Component/View | Middleware | Purpose |
|--------|------|------|---|---|---|
| GET | `/` | `home` | `welcome` view | public | Landing page |
| GET | `/stations` | `stations.search` | `StationSearch` Livewire | public | Fuel station search page |
| GET | `/dashboard` | `dashboard` | `dashboard` view | `auth`, `verified` | Authenticated user dashboard |
| GET/POST | `/login` | `login` | Fortify auth | public | User login |
| GET/POST | `/register` | `register` | Fortify auth | public | User registration |
| POST | `/logout` | `logout` | Fortify action | `auth` | Logout action |
| GET/POST | `/forgot-password` | `password.request` | Fortify auth | public | Password reset request |
| GET/POST | `/reset-password/{token}` | `password.reset` | Fortify auth | public | Password reset form |
| GET/POST | `/confirm-password` | `password.confirm` | Fortify auth | `auth` | Confirm password (before sensitive actions) |
| GET/POST | `/verify-email` | `verification.notice` | Fortify auth | `auth` | Email verification |
| GET | `/verify-email/{id}/{hash}` | `verification.verify` | Fortify action | `auth`, `signed` | Verify email action |
| GET/POST | `/two-factor-challenge` | `two-factor.login` | Fortify auth | `guest` | Two-factor challenge |
| GET | `/settings` | (redirect) | → `settings.profile` | `auth` | Redirect to profile |
| GET | `/settings/profile` | `profile.edit` | Livewire `pages::settings.profile` | `auth` | Edit user profile |
| GET | `/settings/appearance` | `appearance.edit` | Livewire `pages::settings.appearance` | `auth`, `verified` | Edit appearance preferences |
| GET | `/settings/security` | `security.edit` | Livewire `pages::settings.security` | `auth`, `verified`, (optional) `password.confirm` | Edit security settings |
---
## Route Grouping
### Public Routes
- Landing page (`/`)
- Station search (`/stations`)
- Fortify auth routes (login, register, password reset, etc.)
### Authenticated Routes
- Dashboard (`/dashboard`)
- Settings pages (`/settings/*`)
### Settings Routes (Special Handling)
- Profile editing: `auth` only
- Appearance: `auth`, `verified`
- Security: `auth`, `verified`, optional password confirmation
---
## Livewire Component Routes
| Route | Livewire Component | View | Purpose |
|-------|---|---|---|
| `/stations` | `App\Livewire\Public\StationSearch` | `livewire.public.station-search` | Interactive fuel station search with map |
| `/settings/profile` | `pages::settings.profile` | `pages.settings.profile` | User profile management |
| `/settings/appearance` | `pages::settings.appearance` | `pages.settings.appearance` | Theme/appearance preferences |
| `/settings/security` | `pages::settings.security` | `pages.settings.security` | Security and 2FA settings |
---
## Notes
- **Fortify**: The application uses Laravel Fortify for authentication scaffolding. Auth routes are auto-registered by Fortify package.
- **Livewire Routes**: Routes using `Route::livewire()` expect corresponding Livewire components in `app/Livewire/`.
- **Middleware Chain**: Routes are protected with `auth` and/or `verified` middleware as appropriate.
- **Settings Redirect**: Accessing `/settings` redirects to `/settings/profile` (first settings tab).

View File

@@ -1,298 +0,0 @@
# Design System & Theme
## Tailwind Configuration
### `tailwind.config.js`
File doesn't exist. The project uses Tailwind CSS v4 with `@tailwindcss/vite` plugin for compilation.
---
## CSS Configuration
### `resources/css/app.css`
```css
@import 'leaflet/dist/leaflet.css';
@import 'tailwindcss';
@import '../../vendor/livewire/flux/dist/flux.css';
@source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../vendor/livewire/flux-pro/stubs/**/*.blade.php';
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--color-zinc-50: #fafafa;
--color-zinc-100: #f5f5f5;
--color-zinc-200: #e5e5e5;
--color-zinc-300: #d4d4d4;
--color-zinc-400: #a3a3a3;
--color-zinc-500: #737373;
--color-zinc-600: #525252;
--color-zinc-700: #404040;
--color-zinc-800: #262626;
--color-zinc-900: #171717;
--color-zinc-950: #0a0a0a;
--color-accent: var(--color-neutral-800);
--color-accent-content: var(--color-neutral-800);
--color-accent-foreground: var(--color-white);
}
@layer theme {
.dark {
--color-accent: var(--color-white);
--color-accent-content: var(--color-white);
--color-accent-foreground: var(--color-neutral-800);
}
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
[data-flux-field]:not(ui-radio, ui-checkbox) {
@apply grid gap-2;
}
[data-flux-label] {
@apply !mb-0 !leading-tight;
}
input:focus[data-flux-control],
textarea:focus[data-flux-control],
select:focus[data-flux-control] {
@apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-accent-foreground;
}
```
---
## Color Palette
### Primary Colors
- **Zinc Palette** (grays): 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950
- Used for backgrounds, borders, text
### Accent Colors
- **Dark Mode:** White/neutral-800 depending on context
- **Light Mode:** neutral-800
- **Accent Foreground:** White (light mode), neutral-800 (dark mode)
- **Accent Content:** neutral-800 (light), white (dark)
### Status Colors
- **Success:** green-500, green-600
- **Warning:** amber-500, amber-400
- **Error:** red-500, red-400, red-600
- **Info:** slate-500
### Semantic Colors (from Station Search)
- **Current Price (fresh):** green-500 (#22c55e)
- **Recent (24-48h):** slate-500 (#64748b)
- **Stale (2-5 days):** amber-500 (#f59e0b)
- **Outdated (5+ days):** red-500 (#ef4444)
---
## Typography
### Font Family
- **Primary:** 'Instrument Sans' (Weights: 400, 500, 600)
- **Fallback:** ui-sans-serif, system-ui, sans-serif
- **Emoji:** Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji
### Font Sizes & Weights (via Flux)
- **Heading (XL):** Used for page titles
- **Heading (LG):** Used for section titles
- **Subheading:** Used for descriptions
- **Text:** Standard body text
- **Label:** Form labels
- **Badge:** Small text labels
---
## Spacing System
Based on Tailwind default scale:
- **Base Unit:** 4px (1 = 4px)
- **Common Spacing:**
- `gap-2`: 8px (0.5rem)
- `gap-3`: 12px (0.75rem)
- `gap-4`: 16px (1rem)
- `gap-6`: 24px (1.5rem)
- `px-4`: 16px horizontal padding
- `py-3`: 12px vertical padding
- `p-6`: 24px all sides padding
- `p-10`: 40px all sides padding
---
## Border & Radius
### Border Colors
- **Light:** `border-zinc-200`
- **Dark:** `border-zinc-700` (primary), `border-stone-800` (cards), `border-neutral-800` (split layout)
### Border Radius
- **Input/buttons:** Default Flux radius
- **Cards:** `rounded-xl` (larger radius)
- **Icons/small:** `rounded-md`
---
## Dark Mode
### Implementation
- Uses `class="dark"` on `<html>` root
- Custom variant: `@custom-variant dark (&:where(.dark, .dark *))`
- Flux `@fluxAppearance` directive manages theme toggle
### Dark Mode Colors
- **Body BG:** `dark:bg-zinc-800`
- **Header/Sidebar BG:** `dark:bg-zinc-900`
- **Borders:** `dark:border-zinc-700`
- **Text:** `dark:text-zinc-100` (headings), `dark:text-zinc-400` (secondary)
### Accent in Dark Mode
- `--color-accent: var(--color-white)`
- `--color-accent-foreground: var(--color-neutral-800)`
---
## Responsive Design
### Breakpoints (Tailwind defaults)
- **sm:** 640px
- **md:** 768px
- **lg:** 1024px
- **xl:** 1280px
- **2xl:** 1536px
### Usage Patterns
- `max-lg:hidden` - Hide on mobile/tablet
- `lg:hidden` - Hide on desktop
- `max-md:flex-col` - Stack on small screens
- `sm:flex-row` - Row layout on small+ screens
- `md:w-[220px]` - Fixed width on medium+
---
## Component-Specific Theming
### Forms (Flux)
- `[data-flux-field]`: grid gap-2 layout
- `[data-flux-label]`: No margin-bottom, tight line-height
- Focus states: ring-2 ring-accent with ring-offset
### Flux Appearance
- Automatically applied via `@fluxAppearance` directive
- Manages light/dark mode toggle
- Integrates with browser's prefers-color-scheme
### Leaflet Map
- Uses default OSM tiles
- Custom marker colors: green (current), slate (recent), amber (stale), red (outdated)
- Markers: 9px radius, white stroke, 2px weight, 85% fill opacity
---
## Brand Identity
### Logo
- **Primary Icon:** Custom SVG (geometric design)
- **Color:** Adapts to theme (black light mode, white dark mode)
- **Name:** "Laravel Starter Kit"
- **Contexts:**
- Header/nav: `flux:brand`
- Sidebar: `flux:sidebar.brand`
### Loading/Placeholder States
- Pattern SVG with diagonal lines
- Color: `stroke-gray-900/20` (light) or `stroke-neutral-100/20` (dark)
---
## Animations & Transitions
### Alpine.js Transitions
- **Message Dismiss:** `x-show.transition.out.opacity.duration.1500ms`
- **Fade effects:** opacity transitions over 1500ms
### Flux Components
- Built-in animations for:
- Dropdown menus
- Sidebar collapse/expand
- Modal opens/closes
---
## CSS Custom Properties (Tokens)
```css
--font-sans: 'Instrument Sans', ...
--color-zinc-50 through --color-zinc-950
--color-accent
--color-accent-content
--color-accent-foreground
```
---
## Vite Build Configuration
**File:** `vite.config.js`
```javascript
import {
defineConfig
} from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
tailwindcss(),
],
server: {
cors: true,
watch: {
ignored: ['**/storage/framework/views/**'],
},
},
});
```
**Features:**
- Tailwind CSS v4 via @tailwindcss/vite plugin
- Automatic CSS/JS refresh
- CORS enabled for dev server
- Ignores Laravel view cache files
---
## Flux UI Theme Configuration
Flux v2 is configured via:
- CSS import: `@import '../../vendor/livewire/flux/dist/flux.css'`
- Content sources point to Flux stubs for autocomplete
- `@fluxAppearance` handles dark mode toggling
- `@fluxScripts` loads JavaScript enhancements
No custom Flux config file exists; uses defaults with CSS customizations.

View File

@@ -1,15 +1,51 @@
# Fuel Price — Claude Code Instructions
# Fuel Alert — Claude Code Instructions
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
based on local price trends. Built solo by a PHP/Laravel developer.
> **Stack reality check (read first).** The frontend is a **Vue 3 SPA**
> (`resources/js/`, see `frontend.md`), not Livewire. Station data is served by a
> **REST API** under `routes/api.php` (see `architecture.md`). Some domain rule
> files (`scoring.md`, `prediction.md`, `notifications.md`) still name services
> that were since refactored into `Services/Forecasting/`,
> `Services/StationSearch/`, and `PlanFeatures` — verify names against
> `app/Services/`. When docs and code disagree, the code wins.
## 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
- **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro
- **Stack**: Laravel 11 + Livewire 3 (Volt disabled — use classic components) + Alpine.js + Tailwind CSS
- **Database**: MySQL — Eloquent ORM, migrations only (no raw DDL)
- **Payments**: Stripe via Laravel Cashier
- **Backend**: Laravel 13 + PHP 8.4. MySQL (Eloquent, migrations only — no raw DDL)
- **Frontend**: **Vue 3 SPA** — Vue 3.5 + Vue Router 4, Vite 8, Tailwind CSS v4.
Entry `resources/js/app.js` mounts `App.vue`; views/components under
`resources/js/`. Served from the Blade shell `resources/views/app.blade.php`
via the SPA catch-all in `web.php`. Maps: Leaflet
(`components/LeafletMap.vue`). Icons: iconify-icon + Lucide. HTTP: axios
(`resources/js/axios.js`).
- **API**: REST API in `routes/api.php``app/Http/Controllers/Api/*`
(Auth, Station, Stats, User). Public station data is gated by an API key
(`VerifyApiKey` middleware); user/dashboard endpoints use Sanctum. The SPA is
the primary consumer.
- **Auth**: Laravel Fortify (backend auth) + Sanctum (API tokens)
- **Admin**: Filament v5 panel
- **Livewire**: v4 / Flux v2 are installed but **vestigial** — only starter-kit
Volt auth screens (`resources/views/livewire/auth`) and `app/Livewire/Actions`
remain. Do **not** build new Livewire components; build Vue.
- **Payments**: Stripe via Laravel Cashier (v16)
- **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal)
- **Queue**: Laravel queues with Redis driver (notifications and polling jobs)
- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring
@@ -22,7 +58,8 @@ php artisan queue:work # Process notification jobs
php artisan schedule:run # Run scheduled commands (cron every minute)
php artisan migrate # Run migrations
php artisan test # Run Pest test suite
npm run dev # Vite asset watcher
npm run dev # Vite dev server (Vue SPA + HMR)
npm run build # Production build — run if SPA changes don't show up
```
## Imports
@@ -31,9 +68,10 @@ 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
@.claude/rules/frontend.md
@.claude/rules/api-data.md
@.claude/rules/testing.md
@.claude/rules/code-style.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

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Services\BrentPriceFetcher;
use App\Services\BrentPriceSources\BrentPriceFetchException;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('oil:backfill {--from=2018-01-01 : ISO start date (inclusive)} {--to= : ISO end date (defaults to today, inclusive)}')]
#[Description('One-shot backfill of historical Brent crude prices from FRED into brent_prices.')]
class BackfillOilPrices extends Command
{
public function handle(BrentPriceFetcher $fetcher): int
{
$from = (string) $this->option('from');
$to = (string) ($this->option('to') ?: now()->toDateString());
$this->info("Backfilling Brent ({$from}{$to}) from FRED...");
try {
$count = $fetcher->backfillFromFred($from, $to);
$this->info(sprintf('Upserted %d Brent rows.', $count));
return self::SUCCESS;
} catch (BrentPriceFetchException $e) {
$this->error('FRED backfill failed: '.$e->getMessage());
return self::FAILURE;
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\VolatilityRegimeService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('forecast:evaluate-volatility')]
#[Description('Evaluate the volatility regime triggers and update volatility_regimes accordingly. Hourly cron.')]
class EvaluateVolatilityRegime extends Command
{
public function handle(VolatilityRegimeService $service): int
{
$regime = $service->evaluate();
if ($regime === null) {
$this->info('Volatility regime: OFF');
} else {
$this->info(sprintf(
'Volatility regime: ON (trigger=%s, since %s)',
$regime->trigger,
$regime->flipped_on_at->toIso8601String(),
));
}
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,35 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\BeisImporter;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Throwable;
#[Signature('beis:import')]
#[Description('Pull the latest gov.uk Weekly road fuel prices CSV and upsert into weekly_pump_prices.')]
class ImportBeisFuelPrices extends Command
{
public function handle(BeisImporter $importer): int
{
try {
$result = $importer->import();
} catch (Throwable $e) {
$this->error('BEIS import failed: '.$e->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'Imported %d rows from %s — latest date: %s.',
$result['parsed'],
$result['csv_url'],
$result['latest_date'],
));
$this->info('Forecast cache flushed; next API hit will retrain on the new row.');
return self::SUCCESS;
}
}

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

@@ -1,58 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\BrentPricePredictor;
use Illuminate\Console\Command;
use Throwable;
class PredictOilPrices extends Command
{
protected $signature = 'oil:predict {--force : Generate even if the latest price already has a prediction}';
protected $description = 'Generate a Brent crude oil price direction prediction';
public function handle(BrentPricePredictor $predictor): int
{
try {
$latest = $predictor->latestPrice();
if ($latest?->prediction_generated_at !== null && ! $this->option('force')) {
$message = sprintf(
'Prediction already generated for %s at %s.',
$latest->date->toDateString(),
$latest->prediction_generated_at->toDateTimeString(),
);
if (! $this->confirm($message.' Run again anyway?', default: false)) {
$this->info('Skipped.');
return self::SUCCESS;
}
}
$this->info('Generating prediction...');
$prediction = $predictor->generatePrediction();
if ($prediction === null) {
$this->error('Could not generate a prediction — not enough price data.');
return self::FAILURE;
}
$this->info(sprintf(
'Done. [%s] direction=%s confidence=%d%% — %s',
strtoupper($prediction->source->value),
$prediction->direction->value,
$prediction->confidence,
$prediction->reasoning,
));
} catch (Throwable $e) {
$this->error("Prediction failed: {$e->getMessage()}");
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\OutcomeResolver;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('forecast:resolve-outcomes')]
#[Description('Pair past weekly forecasts with the actual ULSP from BEIS data and write rows to forecast_outcomes.')]
class ResolveForecastOutcomes extends Command
{
public function handle(OutcomeResolver $resolver): int
{
$count = $resolver->resolvePending();
$this->info(sprintf('Resolved %d outstanding forecast(s).', $count));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\LlmOverlayService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('forecast:llm-overlay {--event-driven : Honor the 4h cooldown (default: false; daily 07:00 cron always runs)}')]
#[Description('Run the daily Anthropic web-search overlay on the current weekly forecast.')]
class RunLlmOverlay extends Command
{
public function handle(LlmOverlayService $service): int
{
$row = $service->run(eventDriven: (bool) $this->option('event-driven'));
if ($row === null) {
$this->warn('LLM overlay skipped (no API key, on cooldown, or rejected for empty citations).');
return self::SUCCESS;
}
$this->info(sprintf(
'Stored llm_overlays #%d — direction=%s confidence=%d major_impact=%s.',
$row->id,
$row->direction,
$row->confidence,
$row->major_impact_event ? 'YES' : 'no',
));
return self::SUCCESS;
}
}

View File

@@ -13,6 +13,8 @@ enum NavigationGroup implements HasIcon, HasLabel
case Data;
case Forecasting;
case System;
public function getLabel(): string
@@ -20,6 +22,7 @@ enum NavigationGroup implements HasIcon, HasLabel
return match ($this) {
self::Users => 'Users',
self::Data => 'Data',
self::Forecasting => 'Forecasting',
self::System => 'System',
};
}
@@ -29,6 +32,7 @@ enum NavigationGroup implements HasIcon, HasLabel
return match ($this) {
self::Users => 'heroicon-o-users',
self::Data => 'heroicon-o-circle-stack',
self::Forecasting => null,
self::System => 'heroicon-o-cog-6-tooth',
};
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\Backtests;
use App\Filament\NavigationGroup;
use App\Filament\Resources\Backtests\Pages\ListBacktests;
use App\Filament\Resources\Backtests\Pages\ViewBacktest;
use App\Filament\Resources\Backtests\Schemas\BacktestInfolist;
use App\Filament\Resources\Backtests\Tables\BacktestsTable;
use App\Models\Backtest;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
class BacktestResource extends Resource
{
protected static ?string $model = Backtest::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBeaker;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Backtests';
protected static ?int $navigationSort = 3;
public static function infolist(Schema $schema): Schema
{
return BacktestInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return BacktestsTable::configure($table);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function getPages(): array
{
return [
'index' => ListBacktests::route('/'),
'view' => ViewBacktest::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Backtests\Pages;
use App\Filament\Resources\Backtests\BacktestResource;
use Filament\Resources\Pages\ListRecords;
class ListBacktests extends ListRecords
{
protected static string $resource = BacktestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Backtests\Pages;
use App\Filament\Resources\Backtests\BacktestResource;
use Filament\Resources\Pages\ViewRecord;
class ViewBacktest extends ViewRecord
{
protected static string $resource = BacktestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Filament\Resources\Backtests\Schemas;
use App\Models\Backtest;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class BacktestInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make('Run')->columns(3)->schema([
TextEntry::make('model_version')->columnSpanFull(),
TextEntry::make('directional_accuracy')
->label('Accuracy')
->state(fn (Backtest $record): string => $record->directional_accuracy === null
? '—'
: round((float) $record->directional_accuracy, 1).'%'),
TextEntry::make('mae_pence')
->label('MAE')
->state(fn (Backtest $record): string => $record->mae_pence === null
? '—'
: number_format((float) $record->mae_pence, 2).'p'),
IconEntry::make('leak_suspected')
->label('Leak suspected')
->boolean()
->trueColor('danger'),
TextEntry::make('train_start')->date('d M Y'),
TextEntry::make('train_end')->date('d M Y'),
TextEntry::make('eval_start')->date('d M Y'),
TextEntry::make('eval_end')->date('d M Y'),
TextEntry::make('ran_at')->dateTime('d M Y H:i'),
]),
Section::make('Calibration table')
->description('Empirical hit rate per magnitude bin from the eval window.')
->schema([
KeyValueEntry::make('calibration_table')
->hiddenLabel()
->keyLabel('Magnitude bin')
->valueLabel('Empirical hit rate')
->state(fn (Backtest $record): array => collect($record->calibration_table ?? [])
->mapWithKeys(fn ($value, $key) => [$key => round((float) $value * 100, 1).'%'])
->all())
->columnSpanFull(),
]),
Section::make('Feature spec')->schema([
KeyValueEntry::make('features_json')
->hiddenLabel()
->state(fn (Backtest $record): array => self::flattenForKeyValue($record->features_json))
->columnSpanFull(),
]),
Section::make('Coefficients')
->visible(fn (Backtest $record) => $record->coefficients_json !== null)
->collapsed()
->schema([
TextEntry::make('coefficients_json')
->hiddenLabel()
->state(fn (Backtest $record): string => json_encode(
$record->coefficients_json,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
) ?: '')
->columnSpanFull(),
]),
]);
}
/**
* KeyValueEntry expects a flat string-keyed map, so collapse nested arrays
* into JSON strings rather than dropping them.
*
* @param array<string, mixed>|null $features
* @return array<string, string>
*/
protected static function flattenForKeyValue(?array $features): array
{
return collect($features ?? [])
->mapWithKeys(fn ($value, $key) => [
(string) $key => is_scalar($value)
? (string) $value
: (json_encode($value, JSON_UNESCAPED_SLASHES) ?: ''),
])
->all();
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Filament\Resources\Backtests\Tables;
use App\Models\Backtest;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class BacktestsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('model_version')
->searchable()
->limit(32)
->tooltip(fn (Backtest $record) => strlen($record->model_version) > 32 ? $record->model_version : null),
TextColumn::make('directional_accuracy')
->label('Accuracy')
->state(fn (Backtest $record): string => $record->directional_accuracy === null
? '—'
: round((float) $record->directional_accuracy, 1).'%')
->color(fn (Backtest $record) => self::accuracyColor($record))
->sortable(),
TextColumn::make('mae_pence')
->label('MAE')
->state(fn (Backtest $record): string => $record->mae_pence === null
? '—'
: number_format((float) $record->mae_pence, 2).'p')
->sortable(),
IconColumn::make('leak_suspected')
->label('Leak?')
->boolean()
->trueColor('danger'),
TextColumn::make('eval_start')
->date('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('eval_end')
->date('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('ran_at')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('ran_at', 'desc')
->filters([
Filter::make('leak_suspected')
->label('Suspicious accuracy (leak suspected)')
->toggle()
->query(fn (Builder $query) => $query->where('leak_suspected', true)),
Filter::make('below_ship_gate')
->label('Below ship gate')
->toggle()
->query(fn (Builder $query) => $query->where('directional_accuracy', '<', 62)),
])
->recordActions([
ViewAction::make(),
]);
}
protected static function accuracyColor(Backtest $record): ?string
{
if ($record->directional_accuracy === null) {
return null;
}
$accuracy = (float) $record->directional_accuracy;
if ($accuracy > 75 && $record->leak_suspected) {
return 'danger';
}
if ($accuracy < 60) {
return 'danger';
}
if ($accuracy < 62) {
return 'warning';
}
if ($accuracy <= 75) {
return 'success';
}
return null;
}
}

View File

@@ -1,141 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Filament\NavigationGroup;
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
use App\Models\PricePrediction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class OilPredictionResource extends Resource
{
protected static ?string $model = PricePrediction::class;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
protected static ?string $navigationLabel = 'Oil Predictions';
protected static ?int $navigationSort = 3;
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('predicted_for')
->date('d M Y')
->sortable(),
TextColumn::make('source')
->badge()
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'LLM',
PredictionSource::LlmWithContext => 'LLM + Context',
PredictionSource::Ewma => 'EWMA',
})
->color(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'success',
PredictionSource::LlmWithContext => 'warning',
PredictionSource::Ewma => 'info',
}),
TextColumn::make('direction')
->badge()
->color(fn (TrendDirection $state) => match ($state) {
TrendDirection::Rising => 'danger',
TrendDirection::Falling => 'success',
TrendDirection::Flat => 'gray',
}),
TextColumn::make('confidence')
->suffix('%')
->sortable(),
TextColumn::make('reasoning')
->limit(60)
->placeholder('—'),
TextColumn::make('generated_at')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('predicted_for', 'desc')
->filters([
SelectFilter::make('source')
->options([
PredictionSource::Llm->value => 'LLM',
PredictionSource::LlmWithContext->value => 'LLM + Context',
PredictionSource::Ewma->value => 'EWMA',
]),
SelectFilter::make('direction')
->options([
TrendDirection::Rising->value => 'Rising',
TrendDirection::Falling->value => 'Falling',
TrendDirection::Flat->value => 'Flat',
]),
Filter::make('predicted_for')
->schema([
DatePicker::make('from')->label('From'),
DatePicker::make('until')->label('Until'),
])
->query(function (Builder $query, array $data) {
$query
->when($data['from'], fn ($q, $d) => $q->whereDate('predicted_for', '>=', $d))
->when($data['until'], fn ($q, $d) => $q->whereDate('predicted_for', '<=', $d));
}),
])
->recordActions([
ViewAction::make(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema->components([
Section::make('Prediction')->schema([
TextEntry::make('predicted_for')->date('d M Y'),
TextEntry::make('source')
->badge()
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'LLM',
PredictionSource::LlmWithContext => 'LLM + Context',
PredictionSource::Ewma => 'EWMA',
})
->color(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'success',
PredictionSource::LlmWithContext => 'warning',
PredictionSource::Ewma => 'info',
}),
TextEntry::make('direction')
->badge()
->color(fn (TrendDirection $state) => match ($state) {
TrendDirection::Rising => 'danger',
TrendDirection::Falling => 'success',
TrendDirection::Flat => 'gray',
}),
TextEntry::make('confidence')->suffix('%'),
TextEntry::make('generated_at')->dateTime('d M Y H:i:s'),
])->columns(3),
Section::make('Reasoning')->schema([
TextEntry::make('reasoning')
->columnSpanFull()
->placeholder('No reasoning recorded'),
]),
]);
}
public static function getPages(): array
{
return [
'index' => ListOilPredictions::route('/'),
'view' => ViewOilPrediction::route('/{record}'),
];
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Filament\Resources\OilPredictionResource\Pages;
use App\Filament\Resources\OilPredictionResource;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
class ListOilPredictions extends ListRecords
{
protected static string $resource = OilPredictionResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('runPrediction')
->label('Run Prediction Now')
->icon('heroicon-o-cpu-chip')
->requiresConfirmation()
->modalHeading('Run oil price prediction?')
->modalDescription('Generates a new prediction from the stored Brent prices. Runs even if a prediction already exists for the latest price.')
->action(function () {
$result = Artisan::call('oil:predict', ['--force' => true]);
if ($result === 0) {
Notification::make()
->title('Prediction generated successfully')
->success()
->send();
} else {
Notification::make()
->title('Prediction failed')
->body('Check API Logs for details.')
->danger()
->send();
}
}),
];
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Filament\Resources\OilPredictionResource\Pages;
use App\Filament\Resources\OilPredictionResource;
use Filament\Resources\Pages\ViewRecord;
class ViewOilPrediction extends ViewRecord
{
protected static string $resource = OilPredictionResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

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

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWatchedEvent extends CreateRecord
{
protected static string $resource = WatchedEventResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWatchedEvent extends EditRecord
{
protected static string $resource = WatchedEventResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWatchedEvents extends ListRecords
{
protected static string $resource = WatchedEventResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class WatchedEventForm
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
TextInput::make('label')
->required()
->maxLength(128)
->helperText('Short geopolitical event label, e.g. "Iran tensions AprMay 2026".'),
DateTimePicker::make('starts_at')
->label('Starts at')
->required(),
DateTimePicker::make('ends_at')
->label('Ends at')
->required()
->after('starts_at'),
Textarea::make('notes')
->maxLength(2000)
->rows(4)
->columnSpanFull(),
]);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Tables;
use App\Models\WatchedEvent;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WatchedEventsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('label')
->searchable()
->sortable()
->limit(60)
->tooltip(fn (WatchedEvent $record) => strlen($record->label) > 60 ? $record->label : null),
TextColumn::make('starts_at')
->dateTime('d M Y H:i')
->sortable(),
TextColumn::make('ends_at')
->dateTime('d M Y H:i')
->sortable(),
TextColumn::make('status')
->label('Status')
->badge()
->state(fn (WatchedEvent $record): string => self::isActive($record) ? 'Active' : 'Inactive')
->color(fn (string $state) => $state === 'Active' ? 'success' : 'gray'),
TextColumn::make('notes')
->limit(50)
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('starts_at', 'desc')
->filters([
Filter::make('currently_active')
->label('Currently active')
->toggle()
->query(fn (Builder $query) => $query
->where('starts_at', '<=', now())
->where('ends_at', '>=', now())),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
protected static function isActive(WatchedEvent $record): bool
{
$now = now();
return $record->starts_at !== null
&& $record->ends_at !== null
&& $record->starts_at->lessThanOrEqualTo($now)
&& $record->ends_at->greaterThanOrEqualTo($now);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\WatchedEvents;
use App\Filament\NavigationGroup;
use App\Filament\Resources\WatchedEvents\Pages\CreateWatchedEvent;
use App\Filament\Resources\WatchedEvents\Pages\EditWatchedEvent;
use App\Filament\Resources\WatchedEvents\Pages\ListWatchedEvents;
use App\Filament\Resources\WatchedEvents\Schemas\WatchedEventForm;
use App\Filament\Resources\WatchedEvents\Tables\WatchedEventsTable;
use App\Models\WatchedEvent;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class WatchedEventResource extends Resource
{
protected static ?string $model = WatchedEvent::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedFlag;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Watched Events';
protected static ?int $navigationSort = 1;
public static function form(Schema $schema): Schema
{
return WatchedEventForm::configure($schema);
}
public static function table(Table $table): Table
{
return WatchedEventsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListWatchedEvents::route('/'),
'create' => CreateWatchedEvent::route('/create'),
'edit' => EditWatchedEvent::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Pages;
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
use Filament\Resources\Pages\ListRecords;
class ListWeeklyForecasts extends ListRecords
{
protected static string $resource = WeeklyForecastResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Pages;
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
use Filament\Resources\Pages\ViewRecord;
class ViewWeeklyForecast extends ViewRecord
{
protected static string $resource = WeeklyForecastResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Schemas;
use App\Models\WeeklyForecast;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class WeeklyForecastInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make('Forecast')->columns(3)->schema([
TextEntry::make('forecast_for')->date('d M Y'),
TextEntry::make('direction')
->badge()
->color(fn (string $state) => match ($state) {
'rising' => 'warning',
'falling' => 'success',
default => 'gray',
}),
TextEntry::make('magnitude_pence')
->label('Magnitude')
->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence)),
TextEntry::make('ridge_confidence')
->label('Confidence')
->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%')
->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null),
IconEntry::make('flagged_duty_change')
->label('Duty change adjacent')
->boolean()
->trueColor('warning'),
TextEntry::make('generated_at')->dateTime('d M Y H:i'),
]),
Section::make('Reasoning')->schema([
TextEntry::make('reasoning')
->columnSpanFull()
->placeholder('No reasoning recorded.'),
]),
Section::make('Model')
->description('Calibration table from the matching backtest determines the displayed confidence.')
->schema([
TextEntry::make('model_version')->columnSpanFull(),
]),
]);
}
protected static function formatMagnitude(?int $magnitudePence): string
{
if ($magnitudePence === null) {
return '—';
}
$pence = round($magnitudePence / 100, 1);
$sign = $pence > 0 ? '+' : '';
return $sign.$pence.'p';
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Tables;
use App\Models\WeeklyForecast;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WeeklyForecastsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('forecast_for')
->label('Forecast for')
->date('d M Y')
->sortable(),
TextColumn::make('direction')
->badge()
->color(fn (string $state) => match ($state) {
'rising' => 'warning',
'falling' => 'success',
default => 'gray',
}),
TextColumn::make('magnitude_pence')
->label('Magnitude')
->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence))
->sortable(),
TextColumn::make('ridge_confidence')
->label('Confidence')
->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%')
->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null)
->sortable(),
IconColumn::make('flagged_duty_change')
->label('Duty change')
->boolean()
->trueColor('warning'),
TextColumn::make('model_version')
->searchable()
->limit(32)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('generated_at')
->dateTime('d M Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('forecast_for', 'desc')
->filters([
SelectFilter::make('direction')
->multiple()
->options([
'rising' => 'Rising',
'falling' => 'Falling',
'flat' => 'Flat',
]),
Filter::make('high_confidence')
->label('High confidence')
->toggle()
->query(fn (Builder $query) => $query->where('ridge_confidence', '>=', 70)),
Filter::make('flagged_duty_change')
->label('Duty-change-adjacent')
->toggle()
->query(fn (Builder $query) => $query->where('flagged_duty_change', true)),
])
->recordActions([
ViewAction::make(),
]);
}
protected static function formatMagnitude(?int $magnitudePence): string
{
if ($magnitudePence === null) {
return '—';
}
$pence = round($magnitudePence / 100, 1);
$sign = $pence > 0 ? '+' : '';
return $sign.$pence.'p';
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts;
use App\Filament\NavigationGroup;
use App\Filament\Resources\WeeklyForecasts\Pages\ListWeeklyForecasts;
use App\Filament\Resources\WeeklyForecasts\Pages\ViewWeeklyForecast;
use App\Filament\Resources\WeeklyForecasts\Schemas\WeeklyForecastInfolist;
use App\Filament\Resources\WeeklyForecasts\Tables\WeeklyForecastsTable;
use App\Models\WeeklyForecast;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
class WeeklyForecastResource extends Resource
{
protected static ?string $model = WeeklyForecast::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Weekly Forecasts';
protected static ?int $navigationSort = 2;
public static function infolist(Schema $schema): Schema
{
return WeeklyForecastInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return WeeklyForecastsTable::configure($table);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function getPages(): array
{
return [
'index' => ListWeeklyForecasts::route('/'),
'view' => ViewWeeklyForecast::route('/{record}'),
];
}
}

View File

@@ -3,10 +3,10 @@
namespace App\Filament\Widgets;
use App\Models\ApiLog;
use App\Models\PricePrediction;
use App\Models\Search;
use App\Models\Station;
use App\Models\User;
use App\Models\WeeklyForecast;
use Carbon\Carbon;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
@@ -21,7 +21,7 @@ class StatsOverviewWidget extends BaseWidget
$this->usersStat(),
$this->searchesStat(),
$this->stationsStat(),
$this->oilPredictionStat(),
$this->weeklyForecastStat(),
$this->apiErrorsStat(),
];
}
@@ -56,23 +56,23 @@ class StatsOverviewWidget extends BaseWidget
->color('success');
}
private function oilPredictionStat(): Stat
private function weeklyForecastStat(): Stat
{
$prediction = PricePrediction::bestFirst()->latest('generated_at')->first();
$forecast = WeeklyForecast::query()->latest('generated_at')->first();
if ($prediction === null) {
return Stat::make('Latest oil prediction', 'None')
if ($forecast === null) {
return Stat::make('Latest weekly forecast', 'None')
->icon('heroicon-o-beaker')
->color('gray');
}
$ageHours = $prediction->generated_at->diffInHours(now());
$color = $ageHours > 24 ? 'warning' : 'success';
$value = $prediction->direction->label().' · '.$prediction->confidence.'%';
$ageHours = $forecast->generated_at->diffInHours(now());
$color = $ageHours > 168 ? 'warning' : 'success'; // weekly forecast → stale after a week
$directionLabel = ucfirst($forecast->direction);
$value = $directionLabel.' · '.$forecast->ridge_confidence.'%';
return Stat::make('Latest oil prediction', $value)
->description('Generated '.$prediction->generated_at->diffForHumans())
->url(route('filament.admin.resources.oil-predictions.index'))
return Stat::make('Latest weekly forecast', $value)
->description('For week of '.$forecast->forecast_for->toDateString())
->icon('heroicon-o-beaker')
->color($color);
}

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

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