From 77378f052d9d2ef502369b562953e7077a097fc1 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Tue, 31 Mar 2026 13:33:53 +0000 Subject: [PATCH 01/20] plan --- .vscode/settings.json | 3 +- .../checklists/requirements.md | 35 ++++ .../contracts/api-contracts.md | 168 ++++++++++++++++++ specs/010-gas-price-lookup/data-model.md | 158 ++++++++++++++++ specs/010-gas-price-lookup/plan.md | 104 +++++++++++ specs/010-gas-price-lookup/quickstart.md | 80 +++++++++ specs/010-gas-price-lookup/research.md | 120 +++++++++++++ specs/010-gas-price-lookup/spec.md | 128 +++++++++++++ specs/010-gas-price-lookup/tasks.md | 154 ++++++++++++++++ 9 files changed, 949 insertions(+), 1 deletion(-) create mode 100644 specs/010-gas-price-lookup/checklists/requirements.md create mode 100644 specs/010-gas-price-lookup/contracts/api-contracts.md create mode 100644 specs/010-gas-price-lookup/data-model.md create mode 100644 specs/010-gas-price-lookup/plan.md create mode 100644 specs/010-gas-price-lookup/quickstart.md create mode 100644 specs/010-gas-price-lookup/research.md create mode 100644 specs/010-gas-price-lookup/spec.md create mode 100644 specs/010-gas-price-lookup/tasks.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 5ef51fe..9f226dc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,8 @@ "/^cd /workspaces/neCodeBikeTracking && pwsh \\.specify/scripts/powershell/check-prerequisites\\.ps1 -Json$/": { "approve": true, "matchCommandLine": true - } + }, + "mkdir": true }, "sqltools.connections": [ { diff --git a/specs/010-gas-price-lookup/checklists/requirements.md b/specs/010-gas-price-lookup/checklists/requirements.md new file mode 100644 index 0000000..b64b77d --- /dev/null +++ b/specs/010-gas-price-lookup/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Gas Price Lookup at Ride Entry + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-31 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain — FR-012 resolved: EIA API with team-managed key; secret storage deferred to a separate concern +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- FR-010 resolved: EIA API (U.S. government source) with a free team-managed API key. Secret storage mechanism (e.g., KeyVault, environment variable) is deferred and out of scope for this feature. +- All items pass. The spec is ready for `/speckit.plan`. diff --git a/specs/010-gas-price-lookup/contracts/api-contracts.md b/specs/010-gas-price-lookup/contracts/api-contracts.md new file mode 100644 index 0000000..374ffc7 --- /dev/null +++ b/specs/010-gas-price-lookup/contracts/api-contracts.md @@ -0,0 +1,168 @@ +# API Contract: Gas Price Lookup Endpoint + +**Feature**: 010-gas-price-lookup +**Owner**: BikeTracking.Api +**Consumer**: BikeTracking.Frontend + +--- + +## GET /api/rides/gas-price + +Retrieves the national average retail gasoline price for a given date, using a local durable cache backed by the EIA API. + +### Request + +``` +GET /api/rides/gas-price?date=YYYY-MM-DD +Authorization: Bearer {token} +``` + +**Query Parameters** + +| Parameter | Type | Required | Constraints | Notes | +|---|---|---|---|---| +| `date` | string (YYYY-MM-DD) | Yes | Valid ISO date format | The ride date to look up the gas price for. | + +### Response: 200 OK + +```json +{ + "date": "2026-03-31", + "pricePerGallon": 3.1860, + "isAvailable": true, + "dataSource": "EIA_EPM0_NUS_Weekly" +} +``` + +**When unavailable** (API down, no data, future date with no coverage): +```json +{ + "date": "2100-01-01", + "pricePerGallon": null, + "isAvailable": false, + "dataSource": null +} +``` + +### Response: 400 Bad Request + +Returned when `date` is missing or not a valid date string. + +```json +{ + "error": "invalid_request", + "message": "date query parameter is required and must be a valid date in YYYY-MM-DD format." +} +``` + +### Response: 401 Unauthorized + +Returned when no valid bearer token is present. + +### Notes + +- This endpoint never returns a 5xx for EIA lookup failures. EIA failures are absorbed and reflected as `isAvailable: false` with `pricePerGallon: null`. +- The response is deterministic for any given date: once a price is cached, the same value is always returned for that date. +- The `date` parameter is used as the cache key. The actual EIA period date (the Monday of the survey week) may differ; it is not exposed in this contract. + +--- + +## Modified Contract: GET /api/rides/defaults + +Extends the existing defaults endpoint to include the most recent ride's gas price. + +### Response: 200 OK (extended) + +Adds `defaultGasPricePerGallon` to the existing response: + +```json +{ + "hasPreviousRide": true, + "defaultRideDateTimeLocal": "2026-03-31T07:30:00", + "defaultMiles": 5.2, + "defaultRideMinutes": 22, + "defaultTemperature": 58.0, + "defaultGasPricePerGallon": 3.1860 +} +``` + +When no previous ride exists, or the most recent ride has no gas price: +```json +{ + "hasPreviousRide": false, + "defaultRideDateTimeLocal": "2026-03-31T08:00:00", + "defaultGasPricePerGallon": null +} +``` + +**Backwards compatibility**: `defaultGasPricePerGallon` is a new nullable field. Existing clients that ignore it continue to work. + +--- + +## Modified Contract: POST /api/rides (Record Ride) + +Adds `gasPricePerGallon` to the existing request body. + +### Request (extended) + +```json +{ + "rideDateTimeLocal": "2026-03-31T07:30:00", + "miles": 5.2, + "rideMinutes": 22, + "temperature": 58.0, + "gasPricePerGallon": 3.1860 +} +``` + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `gasPricePerGallon` | number | No | Must be > 0 and ≤ 999.9999 when provided. Null/omitted means unavailable. | + +**Backwards compatibility**: Existing requests that omit `gasPricePerGallon` continue to work; the field defaults to null. + +--- + +## Modified Contract: PUT /api/rides/{rideId} (Edit Ride) + +Adds `gasPricePerGallon` to the existing request body. + +### Request (extended) + +```json +{ + "rideDateTimeLocal": "2026-03-31T07:30:00", + "miles": 5.2, + "rideMinutes": 22, + "temperature": 58.0, + "gasPricePerGallon": 3.1860, + "expectedVersion": 2 +} +``` + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `gasPricePerGallon` | number | No | Must be > 0 and ≤ 999.9999 when provided. Null/omitted means price not available. | + +**Backwards compatibility**: Existing clients that omit `gasPricePerGallon` continue to work. + +--- + +## Modified Contract: GET /api/rides/history (Ride History Row) + +Adds `gasPricePerGallon` to each ride row in the history response. + +### RideHistoryRow (extended) + +```json +{ + "rideId": 42, + "rideDateTimeLocal": "2026-03-31T07:30:00", + "miles": 5.2, + "rideMinutes": 22, + "temperature": 58.0, + "gasPricePerGallon": 3.1860 +} +``` + +**Backwards compatibility**: New nullable field; existing consumers that ignore it continue to work. diff --git a/specs/010-gas-price-lookup/data-model.md b/specs/010-gas-price-lookup/data-model.md new file mode 100644 index 0000000..eecedad --- /dev/null +++ b/specs/010-gas-price-lookup/data-model.md @@ -0,0 +1,158 @@ +# Data Model: Gas Price Lookup at Ride Entry + +**Feature**: 010-gas-price-lookup +**Branch**: `010-gas-price-lookup` +**Date**: 2026-03-31 + +--- + +## New Entity: GasPriceLookup + +The durable cache for EIA gas price responses. One row per calendar date. Immutable after creation. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `GasPriceLookupId` | `INTEGER` PK | NOT NULL, AUTOINCREMENT | Surrogate key | +| `PriceDate` | `TEXT` (date) | NOT NULL, UNIQUE | Calendar date the price applies to (YYYY-MM-DD). Unique index — one price per date. | +| `PricePerGallon` | `DECIMAL(10,4)` | NOT NULL | National average retail price in USD per gallon; 4 decimal places. | +| `DataSource` | `TEXT(64)` | NOT NULL | Identifier for the source (e.g., `"EIA_EPM0_NUS_Weekly"`) | +| `EiaPeriodDate` | `TEXT` (date) | NOT NULL | The actual EIA `period` date returned (the Monday of the surveyed week). May differ from `PriceDate` when the lookup uses the nearest prior week. | +| `RetrievedAtUtc` | `TEXT` (datetime) | NOT NULL | When the cache entry was written. | + +**Indexes**: +- `UNIQUE (PriceDate)` — enforced at DB level to prevent duplicate cache entries for the same date. + +--- + +## Modified Entity: Ride + +New column added to `Rides` table. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `GasPricePerGallon` | `DECIMAL(10,4)` | NULLABLE | The gas price per gallon in effect at the time of the ride date. Null if unavailable at time of creation/edit. | + +--- + +## Modified Domain Events + +### RideRecordedEventPayload (C# record) + +Add field: + +| Field | Type | Notes | +|---|---|---| +| `GasPricePerGallon` | `decimal?` | Optional. The gas price stored with this ride creation event. | + +### RideEditedEventPayload (C# record) + +Add field: + +| Field | Type | Notes | +|---|---|---| +| `GasPricePerGallon` | `decimal?` | Optional. The gas price stored with this ride edit event. Reflects the price for the (possibly changed) ride date. | + +--- + +## Modified API Contracts (C#) + +### RecordRideRequest + +Add field: + +| Field | Type | Validation | Notes | +|---|---|---|---| +| `GasPricePerGallon` | `decimal?` | `[Range(0.01, 999.9999)]` optional | User-submitted gas price from the form field. Null if the user left the field empty. | + +### EditRideRequest + +Add field: + +| Field | Type | Validation | Notes | +|---|---|---|---| +| `GasPricePerGallon` | `decimal?` | `[Range(0.01, 999.9999)]` optional | User-submitted gas price from the form field. Null if left empty. | + +### RideDefaultsResponse + +Add field: + +| Field | Type | Notes | +|---|---|---| +| `DefaultGasPricePerGallon` | `decimal?` | The gas price from the most recent saved ride for this user. Null if no prior rides or no prior price. | + +### New: GasPriceResponse + +Returned by `GET /api/rides/gas-price?date=YYYY-MM-DD`. + +| Field | Type | Notes | +|---|---|---| +| `Date` | `string` (YYYY-MM-DD) | The requested date. | +| `PricePerGallon` | `decimal?` | The retrieved or cached price. Null if unavailable. | +| `IsAvailable` | `bool` | `true` when a price was found; `false` otherwise. | +| `DataSource` | `string?` | Identifier for the source (e.g., `"EIA_EPM0_NUS_Weekly"`). Null when unavailable. | + +--- + +## Modified Frontend TypeScript Interfaces + +### RecordRideRequest (TypeScript) +```typescript +gasPricePerGallon?: number; +``` + +### EditRideRequest (TypeScript) +```typescript +gasPricePerGallon?: number; +``` + +### RideDefaultsResponse (TypeScript) +```typescript +defaultGasPricePerGallon?: number; +``` + +### RideHistoryRow (TypeScript) +```typescript +gasPricePerGallon?: number; +``` + +### New: GasPriceResponse (TypeScript) +```typescript +interface GasPriceResponse { + date: string; + pricePerGallon: number | null; + isAvailable: boolean; + dataSource: string | null; +} +``` + +--- + +## State Transitions + +``` +Ride form loads + ├─ Call GET /api/rides/defaults + │ └─ Returns DefaultGasPricePerGallon from last ride (or null) + │ └─ Pre-populate gas price field + │ + ├─ User changes ride date (debounced 300ms) + │ └─ Call GET /api/rides/gas-price?date=NEW_DATE + │ ├─ Cache HIT → return cached price → update field + │ ├─ Cache MISS → fetch EIA API → store in cache → return price → update field + │ └─ EIA unavailable / no data → return isAvailable=false → field unchanged (retains default/prior value) + │ + └─ User submits form + └─ gasPricePerGallon = current field value (or null) + └─ Stored in RideRecordedEvent / RideEditedEvent + RideEntity +``` + +--- + +## Database Migration + +**Migration name pattern**: `YYYYMMDDHHMMSS_AddGasPriceToRidesAndLookupCache` + +Changes: +1. Create `GasPriceLookups` table with columns above. +2. Add `GasPricePerGallon DECIMAL(10,4) NULL` column to `Rides` table. +3. Create unique index on `GasPriceLookups(PriceDate)`. diff --git a/specs/010-gas-price-lookup/plan.md b/specs/010-gas-price-lookup/plan.md new file mode 100644 index 0000000..9447775 --- /dev/null +++ b/specs/010-gas-price-lookup/plan.md @@ -0,0 +1,104 @@ +# Implementation Plan: Gas Price Lookup at Ride Entry + +**Branch**: `010-gas-price-lookup` | **Date**: 2026-03-31 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/010-gas-price-lookup/spec.md` + +## Summary + +When a user creates or edits a ride, the form shows a pre-populated, editable **Gas Price ($/gal)** field. The price is fetched from the EIA Open Data API (U.S. national weekly average, team-managed free API key) and stored in a durable local SQLite cache (`GasPriceLookups` table) so each date is only ever looked up once. If unavailable, the field falls back to the most recent ride's price. Whatever value the user leaves in the field at submit time is stored in the ride record and domain events (`GasPricePerGallon`) for future cost calculations. + +## Technical Context + +**Language/Version**: C# (.NET 10 Minimal API) + F# (domain layer) + TypeScript/React 19 (frontend) +**Primary Dependencies**: EF Core (SQLite), .NET `HttpClient` (EIA API v2), Vite, react-router-dom v7 +**Storage**: SQLite (local file) via EF Core Code-First migrations +**Testing**: xUnit (backend), Vitest (frontend unit), Playwright (E2E) +**Target Platform**: Local user machine (Windows/macOS/Linux), DevContainer for development +**Project Type**: Local-first web app (full-stack vertical slice) +**Performance Goals**: API response <500ms p95; gas price lookup adds no perceptible UI delay (debounced 300ms, cache-first) +**Constraints**: Offline-capable (ride saves when EIA unreachable); no end-user API key setup; weekly price granularity acceptable +**Scale/Scope**: Single-user local deployment; one new DB table; extensions to 4 existing C# records and 4 existing TypeScript interfaces + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|---|---|---| +| I. Clean Architecture / DDD | PASS | `IGasPriceLookupService` interface keeps EIA HTTP client behind an abstraction; domain events extended cleanly | +| II. Functional / Pure-Impure Sandwich | PASS | EIA HTTP call is impure edge; cache read/write is infrastructure; domain event creation remains pure | +| III. Event Sourcing | PASS | `GasPricePerGallon` added to `RideRecordedEventPayload` and `RideEditedEventPayload`; immutable once stored | +| IV. TDD mandatory | PASS | Test plan below covers all layers; red-green gate required before implementation | +| V. UX / React patterns | PASS | Gas price field follows existing `temperature` field pattern; debounced date-change handler | +| VI. Performance <500ms p95 | PASS | Cache-first lookup; EIA call only on cache miss; debounced frontend trigger | +| VII. Three-layer validation | PASS | Client-side (`> 0`, numeric), server-side `[Range]` annotation, DB `DECIMAL` constraint | +| VIII. Secure secret handling | PASS | API key via configuration/user-secrets in dev; production storage deferred (recorded in spec) | +| IX. Contract-first modularity | PASS | New endpoint contract and modified contracts documented in `contracts/api-contracts.md` before implementation | + +**No constitution violations. Pre-implementation gate: CLEAR.** + +--- + +## Project Structure + +### Documentation (this feature) + +```text +specs/010-gas-price-lookup/ +├── plan.md ← this file +├── research.md ← EIA API findings, decisions +├── data-model.md ← entity changes, migration summary +├── quickstart.md ← developer how-to +├── contracts/ +│ └── api-contracts.md ← all new and modified API contracts +└── tasks.md ← generated by /speckit.tasks (not yet) +``` + +### Source Code (repository root) + +```text +src/ +├── BikeTracking.Api/ +│ ├── Application/ +│ │ ├── Events/ +│ │ │ ├── RideRecordedEventPayload.cs ← EXTEND: add GasPricePerGallon +│ │ │ └── RideEditedEventPayload.cs ← EXTEND: add GasPricePerGallon +│ │ └── Rides/ +│ │ ├── RecordRideService.cs ← EXTEND: accept + persist GasPricePerGallon +│ │ ├── EditRideService.cs ← EXTEND: accept + persist GasPricePerGallon +│ │ ├── GetRideDefaultsService.cs ← EXTEND: return DefaultGasPricePerGallon +│ │ └── GasPriceLookupService.cs ← NEW: cache-first EIA lookup +│ ├── Contracts/ +│ │ └── RidesContracts.cs ← EXTEND: add GasPricePerGallon to records +│ ├── Endpoints/ +│ │ └── RidesEndpoints.cs ← EXTEND: add GET /api/rides/gas-price +│ └── Infrastructure/ +│ └── Persistence/ +│ ├── Entities/ +│ │ ├── GasPriceLookupEntity.cs ← NEW: cache entity +│ │ └── RideEntity.cs ← EXTEND: add GasPricePerGallon +│ ├── BikeTrackingDbContext.cs ← EXTEND: add GasPriceLookups DbSet + model config +│ └── Migrations/ +│ └── {timestamp}_AddGasPriceToRidesAndLookupCache.cs ← NEW +│ +├── BikeTracking.Api.Tests/ +│ ├── Application/ +│ │ └── GasPriceLookupServiceTests.cs ← NEW: cache hit/miss/failure tests +│ └── Endpoints/ +│ └── RidesEndpointsTests.cs ← EXTEND: gas price field in record/edit tests +│ +└── BikeTracking.Frontend/ + └── src/ + ├── services/ + │ └── ridesService.ts ← EXTEND: add GasPriceResponse type + getGasPrice() + ├── pages/ + │ ├── RecordRidePage.tsx ← EXTEND: add gas price field + date-change handler + │ └── HistoryPage.tsx ← EXTEND: add gas price to inline edit form + history table + └── pages/ + ├── RecordRidePage.test.tsx ← EXTEND: gas price field tests + └── HistoryPage.test.tsx ← EXTEND: gas price in history rows +``` + +## Complexity Tracking + +No constitution violations. No complexity justification required. diff --git a/specs/010-gas-price-lookup/quickstart.md b/specs/010-gas-price-lookup/quickstart.md new file mode 100644 index 0000000..5a85415 --- /dev/null +++ b/specs/010-gas-price-lookup/quickstart.md @@ -0,0 +1,80 @@ +# Quickstart: Gas Price Lookup at Ride Entry + +**Feature**: 010-gas-price-lookup +**Branch**: `010-gas-price-lookup` + +--- + +## What This Feature Does + +When a user creates or edits a ride, the form now shows a **Gas Price ($/gal)** field pre-populated with the national average retail gasoline price for the ride's date. The price is fetched from the EIA API (U.S. government data) and cached locally so the same date is never looked up twice. The user can overwrite the value before saving. The stored price travels with the ride record for future cost calculations. + +--- + +## How to Test It Locally + +1. Start the full stack: + ```bash + dotnet run --project src/BikeTracking.AppHost + ``` + +2. Open the Aspire Dashboard and launch the frontend. + +3. Log in and open the **Record Ride** page. + - The **Gas Price ($/gal)** field should be pre-populated with either: + - The price from your most recent ride (fallback), or + - The EIA price for today's date (if available and no prior ride exists). + +4. Change the ride date — the gas price field should update to reflect the price for the new date. + +5. Optionally edit the gas price field and save. Navigate to **Ride History** to confirm the recorded price is shown in the ride row. + +6. Edit an existing ride and change its date — confirm the gas price refreshes. + +--- + +## EIA API Key Setup (Development) + +The EIA API key is required for the lookup to succeed. In development, set it via .NET User Secrets: + +```bash +cd src/BikeTracking.Api +dotnet user-secrets set "GasPriceLookup:EiaApiKey" "YOUR_EIA_KEY_HERE" +``` + +Get a free key at: https://www.eia.gov/opendata/register.php + +Without the key, the field will fall back to the last ride's gas price (or remain empty for new users). The ride can still be saved — gas price is always optional. + +--- + +## What Was Changed + +### Backend (C# / F#) +- New `GasPriceLookups` table — durable cache, keyed by date, SQLite-persisted. +- `Rides` table gains a nullable `GasPricePerGallon` column. +- New endpoint: `GET /api/rides/gas-price?date=YYYY-MM-DD` — returns EIA price (cached). +- `GET /api/rides/defaults` extended — includes `DefaultGasPricePerGallon` from last ride. +- `POST /api/rides` and `PUT /api/rides/{id}` accept and store `gasPricePerGallon`. +- `RideRecordedEventPayload` and `RideEditedEventPayload` carry `GasPricePerGallon`. + +### Frontend (React / TypeScript) +- **Record Ride page**: gas price field shown, pre-populated from defaults, refreshed on date change (debounced 300ms). +- **History page** inline edit form: gas price field shown and editable; refreshes on date change. +- History table shows gas price column (or "N/A"). + +### Database Migration +- Migration: `AddGasPriceToRidesAndLookupCache` +- Applied automatically on startup (existing migration auto-apply behavior). + +--- + +## Offline / Degraded Behavior + +| Scenario | Behavior | +|---|---| +| EIA API unreachable | Field keeps the fallback value (last ride's price, or empty) | +| Date has no EIA data | Same as above | +| User clears the field | Ride saves with `gasPricePerGallon = null` | +| No prior rides and EIA down | Field is empty; ride saves without a price | +| Cached price exists for date | Returned instantly; no EIA call made | diff --git a/specs/010-gas-price-lookup/research.md b/specs/010-gas-price-lookup/research.md new file mode 100644 index 0000000..61d572c --- /dev/null +++ b/specs/010-gas-price-lookup/research.md @@ -0,0 +1,120 @@ +# Research: Gas Price Lookup at Ride Entry + +**Feature**: 010-gas-price-lookup +**Branch**: `010-gas-price-lookup` +**Date**: 2026-03-31 + +--- + +## Decision 1: External Gas Price Data Source + +**Decision**: Use the EIA (U.S. Energy Information Administration) Open Data API v2 for weekly national average retail gasoline prices. + +**Rationale**: The EIA is a U.S. government source with data back to 1990. It is free, reliable, and published weekly. The team-managed free API key avoids any end-user setup. No third-party dependency risk. + +**Alternatives considered**: +- Keyless web scraping / unofficial aggregators — rejected: brittle, legally uncertain, no stability guarantee. +- GasBuddy or other commercial APIs — rejected: paid tiers, account management overhead, no government-grade reliability. +- Embedding a static price table — rejected: goes stale and requires manual maintenance. + +**EIA API Details**: + +| Field | Value | +|---|---| +| Endpoint | `https://api.eia.gov/v2/petroleum/pri/gnd/data` | +| Series | `EMM_EPM0_PTE_NUS_DPG` (All Grades, All Formulations, National U.S.) | +| Key param | `?api_key=YOUR_KEY` (query string, not header) | +| Date field | `period` (YYYY-MM-DD string, Monday of survey week) | +| Price field | `value` (string, $/GAL — parse as decimal) | +| Frequency | Weekly (Mondays) | +| Coverage | 1990-08-20 → present; ~1-2 hour lag after Monday 4PM ET | +| Rate limit | ~9,000 req/hr sustained, ~5 req/sec burst | + +**Lookup strategy for arbitrary date**: Query with `end=TARGET_DATE`, `sort[0][column]=period`, `sort[0][direction]=desc`, `length=1` → returns the most recent available weekly price on or before the given date. + +**Caveats**: +- Weekly granularity only — the returned price may be from up to 7 days before the target date (up to ~2 weeks during holiday gaps). This is acceptable per the spec's "weekly granularity is fine" assumption. +- For future dates (beyond latest available data), the API returns the most recent available point — this is acceptable fallback behavior. + +--- + +## Decision 2: Where Gas Price Lookup Happens (Client vs. Server) + +**Decision**: Server-side lookup via a new `GET /api/rides/gas-price?date=YYYY-MM-DD` endpoint. The frontend calls this endpoint; the API handles EIA communication and caching. + +**Rationale**: +- The EIA API key must not be shipped to or accessible from the client (browser). +- The durable cache (`GasPriceLookupEntity` in SQLite) lives on the server, where it is shared across all form loads. +- Keeps the frontend contract simple: one endpoint call per date change, returns a nullable decimal. + +**Alternatives considered**: +- Frontend calls EIA directly — rejected: exposes the API key in browser network traffic. +- Embed cache in frontend `localStorage` — rejected: per-browser, not durable across reinstalls, inconsistent across multiple users on the same machine. + +--- + +## Decision 3: Gas Price Cache Strategy + +**Decision**: Durable SQLite table (`GasPriceLookups`) keyed by calendar date (not datetime). One row per date. Once a price is stored for a date, it is never re-fetched from EIA. If the EIA call fails or returns no data, nothing is written to the cache — the next request will retry the EIA lookup. + +**Rationale**: Immutable cache per date (week) matches EIA's weekly granularity. The cache doubles as a history of prices seen, useful for future calculations. Not caching failures means the system will retry on transient outages without manual intervention. + +**Alternatives considered**: +- Time-based cache expiry (TTL) — rejected: weekly prices don't change after publication; TTL adds complexity with no benefit. +- In-memory cache (IMemoryCache) — rejected: doesn't survive app restarts; defeats the durable reuse requirement. + +--- + +## Decision 4: Gas Price Field on Forms — UX Pattern + +**Decision**: +1. The gas price field is shown on both the RecordRide and Edit Ride forms. It is pre-populated via the existing `GET /api/rides/defaults` endpoint (extended to include `DefaultGasPricePerGallon` from the user's last ride). +2. When the user changes the ride date, the frontend calls `GET /api/rides/gas-price?date=YYYY-MM-DD` to refresh the gas price field for the new date. This call is debounced (300ms) to avoid excessive API calls during rapid date edits. +3. The user may overwrite the field at any time. Whatever is in the field at submit time is sent in the request. +4. If the gas price endpoint returns null/unavailable, the field retains whatever value it had (from defaults, from the previous date's price, or whatever the user entered). This means on initial load, if EIA is unavailable, the fallback from `defaults` (last ride's price) is used. + +**Rationale**: Parallels the existing `temperature` field pattern (pre-populated, user-overridable, nullable). The `defaults` endpoint already provides a natural entry point for the initial fallback. The date-change fetch makes the form feel responsive without blocking save. + +**Alternatives considered**: +- Fetch gas price at save time only (server-side, not shown to user before submit) — rejected: spec explicitly requires the price to be shown and editable before submit. +- Always re-fetch on every date keystroke — rejected: excessive API calls; debounce is the right pattern. + +--- + +## Decision 5: Existing Code Integration Points + +**Decision**: The following existing code is extended (not replaced): + +| File | Change | +|---|---| +| `RideEntity` | Add `GasPricePerGallon decimal?` column | +| `RecordRideRequest` | Add `GasPricePerGallon decimal?` optional parameter | +| `EditRideRequest` | Add `GasPricePerGallon decimal?` optional parameter | +| `RideDefaultsResponse` | Add `DefaultGasPricePerGallon decimal?` | +| `RideRecordedEventPayload` | Add `GasPricePerGallon decimal?` | +| `RideEditedEventPayload` | Add `GasPricePerGallon decimal?` | +| `RideHistoryRow` (frontend TS) | Add `gasPricePerGallon?: number` | +| `RecordRideRequest` (frontend TS) | Add `gasPricePerGallon?: number` | +| `EditRideRequest` (frontend TS) | Add `gasPricePerGallon?: number` | +| `RideDefaultsResponse` (frontend TS) | Add `defaultGasPricePerGallon?: number` | +| `RecordRidePage.tsx` | Add gas price field, date-change handler | +| `HistoryPage.tsx` | Add gas price field to inline edit form | + +**New additions**: + +| New File/Entity | Purpose | +|---|---| +| `GasPriceLookupEntity` | EF Core entity; the durable cache table | +| `GasPriceLookupContract` (TS interface) | Frontend type for `/api/rides/gas-price` response | +| `IGasPriceLookupService` (C#) | Interface to allow EIA client to be mocked in tests | +| `EiaGasPriceLookupService` (C#) | Calls EIA API, reads/writes the cache table | +| `GasPriceController endpoint` | `GET /api/rides/gas-price?date=YYYY-MM-DD` | +| Migration | Add `GasPriceLookups` table; add `GasPricePerGallon` to `Rides` | + +--- + +## Decision 6: EIA API Key Configuration + +**Decision**: The EIA API key is stored in `appsettings.json` / `appsettings.Development.json` under a `GasPriceLookup:EiaApiKey` configuration key. In development, it can be overridden via `dotnet user-secrets`. The secure production storage mechanism (Azure Key Vault, environment variable injection) is deferred and out of scope for this feature. + +**Rationale**: Follows the existing pattern already in use in this app for other secrets (e.g., auth configuration). No new secret infrastructure is introduced. diff --git a/specs/010-gas-price-lookup/spec.md b/specs/010-gas-price-lookup/spec.md new file mode 100644 index 0000000..9d60d27 --- /dev/null +++ b/specs/010-gas-price-lookup/spec.md @@ -0,0 +1,128 @@ +# Feature Specification: Gas Price Lookup at Ride Entry + +**Feature Branch**: `010-gas-price-lookup` +**Created**: 2026-03-31 +**Status**: Draft +**Input**: User description: "Find and call a free API to get the average gas per gallon price at the time of the entry. Store that in the ride created/updated events for future calculations. Store the API calls for those dates so we can reuse those prices in the future." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Gas Price Displayed and Editable on Ride Creation (Priority: P1) + +When a user opens the ride creation form, the gas price field is pre-populated with the national average regular unleaded price for the selected ride date, fetched automatically. The user can see this value and may overwrite it with their own number before saving. Whatever value is in the field when the user submits — whether the fetched price, a fallback, or a manually entered value — is stored with the ride record. + +**Why this priority**: This is the foundation — gas price must be captured at creation and the user must be able to correct it before saving. Without this all other stories have nothing to build on. + +**Independent Test**: Can be fully tested by opening the ride creation form, confirming a pre-populated gas price appears, editing it, saving, and confirming the saved ride record reflects the user-entered value. + +**Acceptance Scenarios**: + +1. **Given** a user opens the ride creation form for today's date and the EIA price is available, **When** the form loads, **Then** the gas price field is pre-populated with the national average price per gallon for that date. +2. **Given** a user opens the ride creation form for a past date and the EIA price is available, **When** the date is selected, **Then** the gas price field updates to reflect the price for that specific past date. +3. **Given** a pre-populated gas price is shown, **When** the user clears and types a different value, **Then** the user-entered value is stored on save (not the fetched value). +4. **Given** a user creates a ride entry for a date with no available gas price data (e.g. a future date), **When** the ride is saved, **Then** the ride is saved successfully and the gas price field is recorded as absent. + +--- + +### User Story 2 - Fallback to Last Ride's Gas Price When Lookup Unavailable (Priority: P2) + +When the gas price for the selected date cannot be fetched (external service unavailable, no data for that date, no internet connection), the gas price field is pre-populated with the gas price from the user's most recently saved ride instead of being left blank. The user can still overwrite this fallback value before saving. + +**Why this priority**: A blank or zero gas price is less useful than a recent price the user already approved. The fallback provides a sensible default and reduces manual entry burden in offline or degraded scenarios. + +**Independent Test**: Can be fully tested by disabling network access, opening the ride creation form, and confirming the gas price field is pre-populated with the price from the user's last saved ride. + +**Acceptance Scenarios**: + +1. **Given** the EIA API is unavailable and the user has at least one prior saved ride with a gas price, **When** the ride creation form loads, **Then** the gas price field is pre-populated with the gas price from the user's most recent ride. +2. **Given** the EIA API returns no data for the selected date and the user has at least one prior ride with a gas price, **When** the form loads or the date changes, **Then** the fallback price from the most recent ride is shown. +3. **Given** a fallback price is shown, **When** the user edits the field to a different value and saves, **Then** the user-entered value is stored in the ride record. +4. **Given** the EIA API is unavailable and the user has no prior saved rides with a gas price, **When** the ride creation form loads, **Then** the gas price field is empty and the user may enter a value manually or leave it blank. + +--- + +### User Story 3 - Gas Price Displayed and Editable on Ride Edit (Priority: P3) + +When a user opens the edit form for an existing ride, the gas price field is pre-populated with the gas price already stored on that ride. If the user changes the ride date, the gas price field is refreshed to show the fetched price for the new date (cache-first), or the fallback price if unavailable. The user may overwrite the gas price field at any time before saving. + +**Why this priority**: Edit parity with creation — the price field must be visible and editable on the edit page too, and must react to date changes. + +**Independent Test**: Can be fully tested by editing an existing ride, changing its date, confirming the gas price field updates, overwriting it, and verifying the saved record reflects the user-entered value. + +**Acceptance Scenarios**: + +1. **Given** a user opens the edit form for a ride, **When** the form loads, **Then** the gas price field is pre-populated with the gas price stored on that ride. +2. **Given** the user changes the ride date to a new date with an available EIA price, **When** the date changes, **Then** the gas price field updates to the price for the new date. +3. **Given** the user changes the ride date to a date with no EIA price available, **When** the date changes, **Then** the gas price field falls back to the most recent prior ride's gas price. +4. **Given** the user changes the ride date and the gas price field updates, **When** the user manually overwrites the field and saves, **Then** the user-entered value is stored in the ride updated event. +5. **Given** the user edits a ride without changing the date, **When** the form is submitted, **Then** the existing gas price value is preserved unchanged. + +--- + +### User Story 4 - Gas Price Cache Prevents Redundant API Calls (Priority: P4) + +When a gas price has already been fetched for a given date, any subsequent form loads or date selections for that same date reuse the cached price without calling the external service. + +**Why this priority**: Caching ensures data consistency across rides on the same date and prevents unnecessary external API calls. + +**Independent Test**: Can be fully tested by loading the ride creation form for the same date twice and confirming the external API was called only once. + +**Acceptance Scenarios**: + +1. **Given** a gas price has already been retrieved and cached for a specific date, **When** the form loads or date changes to that same date again, **Then** the cached price is used and no new external lookup is performed. +2. **Given** a cached price exists for a date, **When** the app is restarted and a ride form is opened for that same date, **Then** the cached price is still used (cache is durable across restarts). + +--- + +### Edge Cases + +- What happens when the external gas price API returns an error or times out? → The fallback (last ride's gas price) is shown in the field; the user may edit before saving. +- What happens when no price data exists for a date (future date, data gap)? → The fallback is shown; if no fallback exists the field is empty. +- What happens when a ride is created or edited while the app has no internet access? → Fallback price is shown; ride saves successfully with whatever the user leaves in the field. +- What happens if the same date is looked up concurrently from two form loads simultaneously? → Only one external call is made; the result is shared via the cache. +- What happens when a ride's date is not changed during an edit? → The existing stored gas price is pre-populated; no new lookup is triggered. +- What happens if the user clears the gas price field entirely and saves? → The ride is saved with no gas price recorded (absent, treated the same as a lookup failure). +- What happens when the user enters a non-numeric or negative value in the gas price field? → Client-side validation prevents submission; the user is prompted to enter a valid positive number or leave it blank. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The ride creation form MUST display a gas price field (USD per gallon) pre-populated with the national average regular unleaded price for the selected ride date, fetched automatically on form load and whenever the date changes. +- **FR-002**: The ride edit form MUST display the gas price field pre-populated with the gas price stored on the existing ride. If the user changes the date, the field MUST refresh to the fetched price for the new date. +- **FR-003**: The gas price field MUST be editable — the user can overwrite the pre-populated value before saving. +- **FR-004**: The value stored in the ride created/updated domain events MUST be whatever value is in the gas price field at the time of submission (fetched, fallback, or user-entered). +- **FR-005**: If the gas price for the selected date cannot be fetched (API unavailable, no data, no network), the gas price field MUST be pre-populated with the gas price from the user's most recently saved ride as a fallback. +- **FR-006**: If the gas price cannot be fetched and no prior ride has a gas price, the gas price field MUST be left empty; the user may enter a value manually or submit without one. +- **FR-007**: If the user clears the gas price field and saves, the ride MUST be saved successfully with the gas price recorded as absent. +- **FR-008**: The gas price field MUST only accept a valid positive decimal number or be empty; the form MUST prevent submission with an invalid (non-numeric or negative) gas price value. +- **FR-009**: The system MUST store each successfully fetched gas price in a durable local cache keyed by date, so the same date is never looked up from the external service more than once. +- **FR-010**: If a gas price for the requested date is already in the local cache, the system MUST use the cached value without calling the external service. +- **FR-011**: The local gas price cache MUST be durable — persisted to the same local storage as ride data so it survives app restarts. +- **FR-012**: The external gas price data source MUST be the U.S. Energy Information Administration (EIA) public API, using a team-managed free API key stored in application configuration. End users do not need to supply or manage any API key. The mechanism for securely storing and distributing the key is out of scope for this feature and will be addressed separately. + +### Key Entities + +- **GasPriceLookup**: Represents a single gas price retrieved for a calendar date. Key attributes: date (calendar date), price per gallon (decimal, USD), data source identifier, retrieved-at timestamp. One record per date; acts as the durable cache entry. +- **RideCreatedEvent**: Extended to include an optional gas price per gallon (USD) at the time of the ride's date. +- **RideUpdatedEvent**: Extended to include an optional gas price per gallon (USD) reflecting the price for the (possibly new) ride date. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of newly created or edited ride records include a gas price value for any date within the past 5 years, either from the EIA lookup, the fallback, or a user-entered value. +- **SC-002**: The same date's gas price is never fetched from the external source more than once across the lifetime of the application's local data store. +- **SC-003**: Ride creation and editing complete successfully even when the gas price service is unavailable — zero ride save failures attributable to price lookup failures. +- **SC-004**: The gas price field is visible and pre-populated on the ride creation and edit forms before the user submits. +- **SC-005**: Invalid gas price input (non-numeric, negative) is rejected before submission with a clear validation message. +- **SC-006**: Historical gas prices stored in the cache remain unchanged over time — a price recorded for a specific date never changes after the first successful retrieval. + +## Assumptions + +- The app targets users in the United States; national average retail regular unleaded price (sourced from a U.S. government or equivalent public data provider) is a sufficient default. +- Weekly granularity from the data source is acceptable — if a daily price is unavailable, the price for the nearest available reporting period for that date is used. +- The gas price field is displayed and editable on both the ride creation and edit forms. It is also stored in events for future calculation features (e.g., commute savings, cost comparison). +- An EIA API key (free, self-registered) is manageable by the development team; end users do not supply a key. Secure key storage is deferred to a separate concern. +- Gas prices are stored in USD per gallon to four decimal places of precision. +- There is no requirement to update or backfill gas prices for historical rides that were created before this feature was introduced. diff --git a/specs/010-gas-price-lookup/tasks.md b/specs/010-gas-price-lookup/tasks.md new file mode 100644 index 0000000..6e77b51 --- /dev/null +++ b/specs/010-gas-price-lookup/tasks.md @@ -0,0 +1,154 @@ +# Tasks: Gas Price Lookup at Ride Entry + +**Feature**: `010-gas-price-lookup` +**Input**: Design documents from `/specs/010-gas-price-lookup/` +**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, data-model.md ✓, contracts/ ✓, quickstart.md ✓ + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (no dependency on incomplete tasks in same phase) +- **[Story]**: User story this task belongs to (US1–US4) +- All paths are relative to repository root + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Configuration baseline needed before any implementation can proceed. + +- [ ] T001 Add `GasPriceLookup:EiaApiKey` configuration key to `src/BikeTracking.Api/appsettings.json` (empty default) and `src/BikeTracking.Api/appsettings.Development.json` (dev placeholder) +- [ ] T002 Register `HttpClient` named client for EIA API in `src/BikeTracking.Api/Program.cs` (base address `https://api.eia.gov`, timeout 10s) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: DB schema and backend entities that ALL user stories depend on. Must be complete before any story work begins. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [ ] T003 Create `src/BikeTracking.Api/Infrastructure/Persistence/Entities/GasPriceLookupEntity.cs` with all columns per data-model.md (`GasPriceLookupId`, `PriceDate`, `PricePerGallon`, `DataSource`, `EiaPeriodDate`, `RetrievedAtUtc`) +- [ ] T004 Add `GasPricePerGallon decimal?` column to `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` +- [ ] T005 Extend `src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs`: add `DbSet GasPriceLookups`, configure table mapping with unique index on `PriceDate`, add `GasPricePerGallon` mapping for `Rides` table +- [ ] T006 Generate EF Core migration `AddGasPriceToRidesAndLookupCache` in `src/BikeTracking.Api/Infrastructure/Persistence/Migrations/` (creates `GasPriceLookups` table, adds `GasPricePerGallon` column to `Rides`) +- [ ] T007 [P] Add `GasPricePerGallon decimal?` to `RideRecordedEventPayload` record and its `Create` factory method in `src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs` +- [ ] T008 [P] Add `GasPricePerGallon decimal?` to `RideEditedEventPayload` record and its `Create` factory method in `src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs` +- [ ] T009 Extend `src/BikeTracking.Api/Contracts/RidesContracts.cs`: add `GasPricePerGallon decimal?` with `[Range(0.01, 999.9999)]` to `RecordRideRequest` and `EditRideRequest`; add `DefaultGasPricePerGallon decimal?` to `RideDefaultsResponse`; add new `GasPriceResponse` record with `Date`, `PricePerGallon`, `IsAvailable`, `DataSource` +- [ ] T010 [P] Add `gasPricePerGallon?: number` to `RecordRideRequest`, `EditRideRequest`, `RideDefaultsResponse`, and `RideHistoryRow` TypeScript interfaces in `src/BikeTracking.Frontend/src/services/ridesService.ts`; add `GasPriceResponse` interface + +**Checkpoint**: Foundation is ready — all user story phases can now begin. + +--- + +## Phase 3: User Story 1 — Gas Price on Ride Creation (Priority: P1) 🎯 MVP + +**Goal**: The gas price field is shown and pre-populated with the EIA price for today's date on the Record Ride form. The user can edit it, and the saved ride record includes whatever value they submitted. + +**Independent Test**: Open Record Ride page → confirm gas price field is pre-populated → edit the value → save → confirm ride history shows the user-entered gas price. + +- [ ] T011 [US1] Create `src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs`: `IGasPriceLookupService` interface with `GetOrFetchAsync(DateOnly date)` returning `decimal?`; concrete `EiaGasPriceLookupService` registered as `Scoped` — checks `GasPriceLookups` cache first, calls EIA API v2 (`/v2/petroleum/pri/gnd/data?facets[duoarea][]=NUS&facets[product][]=EPM0&frequency=weekly&end=DATE&sort[0][column]=period&sort[0][direction]=desc&length=1&api_key=KEY`) on miss, stores result in cache, returns `null` on failure +- [ ] T012 [US1] Register `EiaGasPriceLookupService` (as `IGasPriceLookupService`) and `IHttpClientFactory` in `src/BikeTracking.Api/Program.cs` +- [ ] T013 [US1] Extend `src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs`: return `DefaultGasPricePerGallon` from the most recent ride's `GasPricePerGallon` value (null if no prior rides or no prior price) +- [ ] T014 [US1] Add `GET /api/rides/gas-price` endpoint handler to `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs`: accepts `date` query param (YYYY-MM-DD), validates format, calls `IGasPriceLookupService`, returns `GasPriceResponse`; returns 400 on invalid/missing date, 401 on unauthenticated, never 5xx on EIA failure +- [ ] T015 [US1] Extend `src/BikeTracking.Api/Application/Rides/RecordRideService.cs`: accept `GasPricePerGallon decimal?` from `RecordRideRequest`, persist it to `RideEntity`, pass it to `RideRecordedEventPayload.Create` +- [ ] T016 [US1] Add `getGasPrice(date: string): Promise` function to `src/BikeTracking.Frontend/src/services/ridesService.ts` calling `GET /api/rides/gas-price?date={date}` +- [ ] T017 [US1] Extend `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx`: add `gasPrice` state (`string`), pre-populate from `defaults.defaultGasPricePerGallon` on load, add gas price `` field with label "Gas Price ($/gal) (optional)", pass `gasPricePerGallon` in `RecordRideRequest` on submit (parse as `parseFloat`, undefined if empty) +- [ ] T018 [US1] Add client-side gas price validation in `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx`: if field is non-empty and value ≤ 0, show validation error and block submit +- [ ] T019 [US1] Write failing tests for `GasPriceLookupService` in `src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs`: cache hit returns stored value without HTTP call; cache miss calls EIA and stores result; EIA HTTP failure returns null and does not write cache entry; second call for same date after cache miss returns cached value +- [ ] T020 [US1] Extend `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs`: `GET /api/rides/gas-price` with valid date returns 200 with price shape; invalid date returns 400; unauthenticated returns 401; `POST /api/rides` with `gasPricePerGallon` stores value in ride record; `GET /api/rides/defaults` returns `defaultGasPricePerGallon` from last ride +- [ ] T021 [US1] Extend `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx`: gas price field renders; pre-populated from defaults; user can edit value; submit passes user-entered value; negative value shows validation error and blocks submit + +--- + +## Phase 4: User Story 2 — Fallback to Last Ride's Price (Priority: P2) + +**Goal**: When EIA lookup returns unavailable (`isAvailable: false`), the gas price field retains the fallback from `DefaultGasPricePerGallon` (last ride). If no prior ride has a price, field is empty. + +**Independent Test**: Set EIA key to invalid (force unavailable), open Record Ride form with a prior ride that had a gas price → confirm gas price field shows the prior ride's price, not empty. + +- [ ] T022 [US2] Add date-change handler to `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx`: when `rideDateTimeLocal` changes, debounce 300ms then call `getGasPrice(date)` and update `gasPrice` state only if `isAvailable`; if `isAvailable=false`, leave `gasPrice` unchanged (fallback retained from defaults or prior date call) +- [ ] T023 [US2] Write failing Vitest test in `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx`: when `getGasPrice` returns `isAvailable=false`, gas price field still shows the default fallback value from `getRideDefaults` +- [ ] T024 [US2] Write failing Vitest test in `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx`: when `getGasPrice` returns `isAvailable=false` and no default exists, gas price field is empty + +--- + +## Phase 5: User Story 3 — Gas Price on Ride Edit (Priority: P3) + +**Goal**: The inline edit form on the History page shows the gas price for the selected ride, pre-populated. Date changes refresh it. User can overwrite. Saved edit stores the user's value. + +**Independent Test**: Edit an existing ride on the History page → confirm gas price field shows the stored value → change the date → confirm gas price refreshes → overwrite → save → confirm history row shows updated price. + +- [ ] T025 [US3] Extend `src/BikeTracking.Api/Application/Rides/EditRideService.cs`: accept `GasPricePerGallon decimal?` from `EditRideRequest`, persist to `RideEntity`, pass to `RideEditedEventPayload.Create` +- [ ] T026 [US3] Extend `RideHistoryRow` API mapping in `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs` (or the query service): include `GasPricePerGallon` in the history row projection from `RideEntity` +- [ ] T027 [US3] Extend `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx`: add `gasPricePerGallon` to inline edit form state, pre-populate from `ride.gasPricePerGallon` when edit opens, add gas price `` field with label "Gas Price ($/gal)", pass `gasPricePerGallon` in `EditRideRequest` on submit +- [ ] T028 [US3] Add date-change handler to inline edit form in `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx`: when date field changes, debounce 300ms then call `getGasPrice(newDate)`; update gas price field if `isAvailable`, retain current value if not +- [ ] T029 [US3] Add gas price column to the ride history table in `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx`: display `gasPricePerGallon` in the history table (format as `$X.XXXX` or "N/A") +- [ ] T030 [US3] Extend `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs`: `PUT /api/rides/{id}` with `gasPricePerGallon` stores updated value; `GET /api/rides/history` response rows include `gasPricePerGallon` +- [ ] T031 [US3] Extend `src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx`: gas price column renders in history table; edit form shows pre-populated gas price; date change triggers gas price refresh; user can overwrite gas price; submit passes user-entered value + +--- + +## Phase 6: User Story 4 — Cache Prevents Redundant EIA Calls (Priority: P4) + +**Goal**: Once a price is cached for a date, no further EIA HTTP calls are made for that date — across form loads and app restarts. + +**Independent Test**: Call `GET /api/rides/gas-price?date=X` twice for the same date; confirm EIA API is only hit once (inspectable via test mock call count or integration log). + +- [ ] T032 [US4] Write failing test in `src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs`: two sequential calls for the same date result in exactly one EIA HTTP request (second call hits cache) +- [ ] T033 [US4] Write failing test in `src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs`: after app restart (new service instance, same DbContext with existing cache row), lookup returns cached value without HTTP call + +--- + +## Final Phase: Polish & Cross-Cutting Concerns + +**Purpose**: Validation completeness, formatting, and CI verification. + +- [ ] T034 [P] Add `gasPricePerGallon` to `RecordRideApiHost.RecordRideAsync` test helper in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs` so existing seeding helpers can set gas price on test rides +- [ ] T035 [P] Run `csharpier format .` from repo root and fix any formatting issues in new/modified C# files +- [ ] T036 [P] Run `cd src/BikeTracking.Frontend && npm run lint` and fix any ESLint/Stylelint issues in new/modified TypeScript/CSS files +- [ ] T037 Run `dotnet test BikeTracking.slnx` — confirm all backend tests pass +- [ ] T038 Run `cd src/BikeTracking.Frontend && npm run test:unit` — confirm all frontend unit tests pass +- [ ] T039 Run `cd src/BikeTracking.Frontend && npm run build` — confirm no TypeScript compilation errors + +--- + +## Dependencies + +``` +Phase 1 (T001–T002) + └─ Phase 2 (T003–T010) [T007, T008, T010 parallelizable within phase] + ├─ Phase 3 / US1 (T011–T021) 🎯 MVP — implement first + │ └─ Phase 4 / US2 (T022–T024) [depends on US1 date field + getGasPrice()] + │ └─ Phase 5 / US3 (T025–T031) [depends on US1 backend + US1 getGasPrice()] + │ └─ Phase 6 / US4 (T032–T033) [depends on GasPriceLookupService from T011] + └─ Final Phase (T034–T039) [after all stories complete] +``` + +**Parallel opportunities within US1 (Phase 3)**: +- T011 + T013 (service + defaults extension) can run in parallel with T016 + T018 (frontend service + validation) +- T019 + T020 (backend tests) can run in parallel with T021 (frontend tests) + +**Parallel opportunities within US3 (Phase 5)**: +- T025 + T026 (API backend) can run in parallel with T027 + T028 + T029 (frontend) +- T030 (backend tests) can run in parallel with T031 (frontend tests) + +--- + +## Implementation Strategy + +**Start with Phase 3 (US1) only** — it is the complete MVP: +- `GasPriceLookupService` + `GET /api/rides/gas-price` endpoint +- `RecordRide` saving gas price +- Gas price field on Record Ride page with fallback from defaults + +**US2 and US3 can be implemented independently after US1** — US2 adds the date-change refresh behavior to the existing field; US3 extends the pattern to the edit form. + +**US4 (cache correctness tests) can be written alongside US1** since `GasPriceLookupService` is created in T011. + +**Suggested commit boundaries**: +1. `TDD-RED: 010 gas price cache + endpoint failing tests` (after T019, T020) +2. `TDD-GREEN: 010 gas price backend — cache + EIA + endpoint` (after T011–T015) +3. `TDD-RED: 010 gas price frontend failing tests` (after T021) +4. `TDD-GREEN: 010 gas price frontend Record Ride field` (after T016–T018) +5. `TDD-GREEN: 010 gas price fallback + edit form + history table` (after US2, US3) +6. `TDD-GREEN: 010 gas price cache redundancy tests` (after US4) From e601e63fc7e198ac3db5e2a89ab26232d12bdfe2 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Tue, 31 Mar 2026 14:36:34 +0000 Subject: [PATCH 02/20] feat: Add gas price tracking to ride records and implement gas price lookup service - Enhanced the HistoryPage to include gas price input and display. - Updated RecordRidePage to support gas price entry and validation. - Implemented a new GasPriceLookupService for fetching gas prices from an external API. - Created a GasPriceLookupEntity for caching gas prices in the database. - Added migrations to include gas price fields in the Rides table and create a GasPriceLookups table. - Updated tests to cover gas price functionality in both RecordRidePage and HistoryPage. --- specs/010-gas-price-lookup/spec.md | 6 +- specs/010-gas-price-lookup/tasks.md | 141 +++++--- .../Application/GasPriceLookupServiceTests.cs | 330 ++++++++++++++++++ .../Endpoints/RidesEndpointsTests.cs | 214 +++++++++++- .../Events/RideEditedEventPayload.cs | 3 + .../Events/RideRecordedEventPayload.cs | 3 + .../Application/Rides/EditRideService.cs | 2 + .../Rides/GasPriceLookupService.cs | 165 +++++++++ .../Rides/GetRideDefaultsService.cs | 3 +- .../Rides/GetRideHistoryService.cs | 3 +- .../Application/Rides/RecordRideService.cs | 4 +- .../Contracts/RidesContracts.cs | 25 +- .../Endpoints/RidesEndpoints.cs | 55 +++ .../Persistence/BikeTrackingDbContext.cs | 16 + .../Entities/GasPriceLookupEntity.cs | 16 + .../Persistence/Entities/RideEntity.cs | 2 + .../20260323134325_AddRidesTable.Designer.cs | 246 ------------- ...7165005_AddRideMilesUpperBound.Designer.cs | 252 ------------- ...330202303_AddUserSettingsTable.Designer.cs | 309 ---------------- ...135119_AddGasPriceToRidesAndLookupCache.cs | 63 ++++ .../BikeTrackingDbContextModelSnapshot.cs | 36 ++ src/BikeTracking.Api/Program.cs | 10 + .../appsettings.Development.json | 3 + src/BikeTracking.Api/appsettings.json | 3 + .../src/pages/HistoryPage.test.tsx | 134 +++++++ .../src/pages/HistoryPage.tsx | 107 +++++- .../src/pages/RecordRidePage.test.tsx | 168 +++++++++ .../src/pages/RecordRidePage.tsx | 76 +++- .../src/services/ridesService.ts | 29 ++ .../tests/e2e/record-ride.spec.ts | 26 ++ .../tests/e2e/support/ride-helpers.ts | 5 + 31 files changed, 1571 insertions(+), 884 deletions(-) create mode 100644 src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs create mode 100644 src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs create mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Entities/GasPriceLookupEntity.cs delete mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260323134325_AddRidesTable.Designer.cs delete mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260327165005_AddRideMilesUpperBound.Designer.cs delete mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.Designer.cs create mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260331135119_AddGasPriceToRidesAndLookupCache.cs diff --git a/specs/010-gas-price-lookup/spec.md b/specs/010-gas-price-lookup/spec.md index 9d60d27..20e267a 100644 --- a/specs/010-gas-price-lookup/spec.md +++ b/specs/010-gas-price-lookup/spec.md @@ -53,7 +53,7 @@ When a user opens the edit form for an existing ride, the gas price field is pre 1. **Given** a user opens the edit form for a ride, **When** the form loads, **Then** the gas price field is pre-populated with the gas price stored on that ride. 2. **Given** the user changes the ride date to a new date with an available EIA price, **When** the date changes, **Then** the gas price field updates to the price for the new date. -3. **Given** the user changes the ride date to a date with no EIA price available, **When** the date changes, **Then** the gas price field falls back to the most recent prior ride's gas price. +3. **Given** the user changes the ride date to a date with no EIA price available, **When** the date changes, **Then** the gas price field retains its current value (the previously shown price, whether from a prior EIA lookup, the fallback, or a user edit). 4. **Given** the user changes the ride date and the gas price field updates, **When** the user manually overwrites the field and saves, **Then** the user-entered value is stored in the ride updated event. 5. **Given** the user edits a ride without changing the date, **When** the form is submitted, **Then** the existing gas price value is preserved unchanged. @@ -104,8 +104,8 @@ When a gas price has already been fetched for a given date, any subsequent form ### Key Entities - **GasPriceLookup**: Represents a single gas price retrieved for a calendar date. Key attributes: date (calendar date), price per gallon (decimal, USD), data source identifier, retrieved-at timestamp. One record per date; acts as the durable cache entry. -- **RideCreatedEvent**: Extended to include an optional gas price per gallon (USD) at the time of the ride's date. -- **RideUpdatedEvent**: Extended to include an optional gas price per gallon (USD) reflecting the price for the (possibly new) ride date. +- **RideRecordedEventPayload**: Extended to include an optional gas price per gallon (USD) at the time of the ride's date. +- **RideEditedEventPayload**: Extended to include an optional gas price per gallon (USD) reflecting the price for the (possibly new) ride date. ## Success Criteria *(mandatory)* diff --git a/specs/010-gas-price-lookup/tasks.md b/specs/010-gas-price-lookup/tasks.md index 6e77b51..39e0822 100644 --- a/specs/010-gas-price-lookup/tasks.md +++ b/specs/010-gas-price-lookup/tasks.md @@ -16,8 +16,8 @@ **Purpose**: Configuration baseline needed before any implementation can proceed. -- [ ] T001 Add `GasPriceLookup:EiaApiKey` configuration key to `src/BikeTracking.Api/appsettings.json` (empty default) and `src/BikeTracking.Api/appsettings.Development.json` (dev placeholder) -- [ ] T002 Register `HttpClient` named client for EIA API in `src/BikeTracking.Api/Program.cs` (base address `https://api.eia.gov`, timeout 10s) +- [X] T001 Add `GasPriceLookup:EiaApiKey` configuration key to `src/BikeTracking.Api/appsettings.json` (empty default) and `src/BikeTracking.Api/appsettings.Development.json` (dev placeholder) +- [X] T002 Register `HttpClient` named client for EIA API in `src/BikeTracking.Api/Program.cs` (base address `https://api.eia.gov`, timeout 10s) --- @@ -27,14 +27,14 @@ **⚠️ CRITICAL**: No user story work can begin until this phase is complete. -- [ ] T003 Create `src/BikeTracking.Api/Infrastructure/Persistence/Entities/GasPriceLookupEntity.cs` with all columns per data-model.md (`GasPriceLookupId`, `PriceDate`, `PricePerGallon`, `DataSource`, `EiaPeriodDate`, `RetrievedAtUtc`) -- [ ] T004 Add `GasPricePerGallon decimal?` column to `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` -- [ ] T005 Extend `src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs`: add `DbSet GasPriceLookups`, configure table mapping with unique index on `PriceDate`, add `GasPricePerGallon` mapping for `Rides` table -- [ ] T006 Generate EF Core migration `AddGasPriceToRidesAndLookupCache` in `src/BikeTracking.Api/Infrastructure/Persistence/Migrations/` (creates `GasPriceLookups` table, adds `GasPricePerGallon` column to `Rides`) -- [ ] T007 [P] Add `GasPricePerGallon decimal?` to `RideRecordedEventPayload` record and its `Create` factory method in `src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs` -- [ ] T008 [P] Add `GasPricePerGallon decimal?` to `RideEditedEventPayload` record and its `Create` factory method in `src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs` -- [ ] T009 Extend `src/BikeTracking.Api/Contracts/RidesContracts.cs`: add `GasPricePerGallon decimal?` with `[Range(0.01, 999.9999)]` to `RecordRideRequest` and `EditRideRequest`; add `DefaultGasPricePerGallon decimal?` to `RideDefaultsResponse`; add new `GasPriceResponse` record with `Date`, `PricePerGallon`, `IsAvailable`, `DataSource` -- [ ] T010 [P] Add `gasPricePerGallon?: number` to `RecordRideRequest`, `EditRideRequest`, `RideDefaultsResponse`, and `RideHistoryRow` TypeScript interfaces in `src/BikeTracking.Frontend/src/services/ridesService.ts`; add `GasPriceResponse` interface +- [X] T003 Create `src/BikeTracking.Api/Infrastructure/Persistence/Entities/GasPriceLookupEntity.cs` with all columns per data-model.md (`GasPriceLookupId`, `PriceDate`, `PricePerGallon`, `DataSource`, `EiaPeriodDate`, `RetrievedAtUtc`) +- [X] T004 Add `GasPricePerGallon decimal?` column to `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` +- [X] T005 Extend `src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs`: add `DbSet GasPriceLookups`, configure table mapping with unique index on `PriceDate`, add `GasPricePerGallon` mapping for `Rides` table +- [X] T006 Generate EF Core migration `AddGasPriceToRidesAndLookupCache` in `src/BikeTracking.Api/Infrastructure/Persistence/Migrations/` (creates `GasPriceLookups` table, adds `GasPricePerGallon` column to `Rides`) +- [X] T007 [P] Add `GasPricePerGallon decimal?` to `RideRecordedEventPayload` record and its `Create` factory method in `src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs` +- [X] T008 [P] Add `GasPricePerGallon decimal?` to `RideEditedEventPayload` record and its `Create` factory method in `src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs` +- [X] T009 Extend `src/BikeTracking.Api/Contracts/RidesContracts.cs`: add `GasPricePerGallon decimal?` with `[Range(0.01, 999.9999)]` to `RecordRideRequest` and `EditRideRequest`; add `DefaultGasPricePerGallon decimal?` to `RideDefaultsResponse`; add new `GasPriceResponse` record with `Date`, `PricePerGallon`, `IsAvailable`, `DataSource` +- [X] T010 [P] Add `gasPricePerGallon?: number` to `RecordRideRequest`, `EditRideRequest`, `RideDefaultsResponse`, and `RideHistoryRow` TypeScript interfaces in `src/BikeTracking.Frontend/src/services/ridesService.ts`; add `GasPriceResponse` interface **Checkpoint**: Foundation is ready — all user story phases can now begin. @@ -46,17 +46,26 @@ **Independent Test**: Open Record Ride page → confirm gas price field is pre-populated → edit the value → save → confirm ride history shows the user-entered gas price. -- [ ] T011 [US1] Create `src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs`: `IGasPriceLookupService` interface with `GetOrFetchAsync(DateOnly date)` returning `decimal?`; concrete `EiaGasPriceLookupService` registered as `Scoped` — checks `GasPriceLookups` cache first, calls EIA API v2 (`/v2/petroleum/pri/gnd/data?facets[duoarea][]=NUS&facets[product][]=EPM0&frequency=weekly&end=DATE&sort[0][column]=period&sort[0][direction]=desc&length=1&api_key=KEY`) on miss, stores result in cache, returns `null` on failure -- [ ] T012 [US1] Register `EiaGasPriceLookupService` (as `IGasPriceLookupService`) and `IHttpClientFactory` in `src/BikeTracking.Api/Program.cs` -- [ ] T013 [US1] Extend `src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs`: return `DefaultGasPricePerGallon` from the most recent ride's `GasPricePerGallon` value (null if no prior rides or no prior price) -- [ ] T014 [US1] Add `GET /api/rides/gas-price` endpoint handler to `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs`: accepts `date` query param (YYYY-MM-DD), validates format, calls `IGasPriceLookupService`, returns `GasPriceResponse`; returns 400 on invalid/missing date, 401 on unauthenticated, never 5xx on EIA failure -- [ ] T015 [US1] Extend `src/BikeTracking.Api/Application/Rides/RecordRideService.cs`: accept `GasPricePerGallon decimal?` from `RecordRideRequest`, persist it to `RideEntity`, pass it to `RideRecordedEventPayload.Create` -- [ ] T016 [US1] Add `getGasPrice(date: string): Promise` function to `src/BikeTracking.Frontend/src/services/ridesService.ts` calling `GET /api/rides/gas-price?date={date}` -- [ ] T017 [US1] Extend `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx`: add `gasPrice` state (`string`), pre-populate from `defaults.defaultGasPricePerGallon` on load, add gas price `` field with label "Gas Price ($/gal) (optional)", pass `gasPricePerGallon` in `RecordRideRequest` on submit (parse as `parseFloat`, undefined if empty) -- [ ] T018 [US1] Add client-side gas price validation in `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx`: if field is non-empty and value ≤ 0, show validation error and block submit -- [ ] T019 [US1] Write failing tests for `GasPriceLookupService` in `src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs`: cache hit returns stored value without HTTP call; cache miss calls EIA and stores result; EIA HTTP failure returns null and does not write cache entry; second call for same date after cache miss returns cached value -- [ ] T020 [US1] Extend `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs`: `GET /api/rides/gas-price` with valid date returns 200 with price shape; invalid date returns 400; unauthenticated returns 401; `POST /api/rides` with `gasPricePerGallon` stores value in ride record; `GET /api/rides/defaults` returns `defaultGasPricePerGallon` from last ride -- [ ] T021 [US1] Extend `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx`: gas price field renders; pre-populated from defaults; user can edit value; submit passes user-entered value; negative value shows validation error and blocks submit +### US1 — Tests (write first, TDD-RED gate) + +- [X] T011 [US1] Write failing tests for `GasPriceLookupService` in `src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs`: cache hit returns stored value without HTTP call; cache miss calls EIA and stores result; EIA HTTP failure returns null and does not write cache entry; second call for same date after cache miss returns cached value (assert HTTP handler invoked exactly once across both calls); concurrent insert on duplicate `PriceDate` is handled gracefully (returns cached row, no unhandled exception) +- [X] T012 [US1] Extend `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs`: `GET /api/rides/gas-price` with valid date returns 200 with price shape (assert JSON fields: `date`, `pricePerGallon`, `isAvailable`, `dataSource`); invalid date returns 400; unauthenticated returns 401; `POST /api/rides` with `gasPricePerGallon` stores value in ride record; `POST /api/rides` with null `gasPricePerGallon` saves ride successfully with null stored; `GET /api/rides/defaults` returns `defaultGasPricePerGallon` from last ride +- [X] T013 [US1] Extend `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx`: gas price field renders; pre-populated from defaults; on initial load also calls `getGasPrice` for today's date and updates field if `isAvailable`; user can edit value; submit passes user-entered value; submit with empty gas price field succeeds (gasPricePerGallon omitted); negative value shows validation error and blocks submit + +**TDD-RED checkpoint**: Run all tests above, confirm they fail for behavioral reasons. Commit: `TDD-RED: 010 gas price cache + endpoint + form tests` + +### US1 — Implementation (TDD-GREEN gate) + +- [X] T014 [US1] Create `src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs`: `IGasPriceLookupService` interface with `GetOrFetchAsync(DateOnly date)` returning `decimal?`; concrete `EiaGasPriceLookupService` registered as `Scoped` — checks `GasPriceLookups` cache first, calls EIA API v2 (`/v2/petroleum/pri/gnd/data?facets[duoarea][]=NUS&facets[product][]=EPM0&frequency=weekly&end=DATE&sort[0][column]=period&sort[0][direction]=desc&length=1&api_key=KEY`) on miss, stores result in cache, returns `null` on failure. On `DbUpdateException` from duplicate `PriceDate` insert (concurrent race), catch, re-query cache, and return existing value +- [X] T015 [US1] Register `EiaGasPriceLookupService` (as `IGasPriceLookupService`) and `IHttpClientFactory` in `src/BikeTracking.Api/Program.cs` +- [X] T016 [US1] Extend `src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs`: return `DefaultGasPricePerGallon` from the most recent ride's `GasPricePerGallon` value (null if no prior rides or no prior price) +- [X] T017 [US1] Add `GET /api/rides/gas-price` endpoint handler to `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs`: accepts `date` query param (YYYY-MM-DD), validates format, calls `IGasPriceLookupService`, returns `GasPriceResponse`; returns 400 on invalid/missing date, 401 on unauthenticated, never 5xx on EIA failure +- [X] T018 [US1] Extend `src/BikeTracking.Api/Application/Rides/RecordRideService.cs`: accept `GasPricePerGallon decimal?` from `RecordRideRequest`, persist it to `RideEntity`, pass it to `RideRecordedEventPayload.Create` +- [X] T019 [US1] Add `getGasPrice(date: string): Promise` function to `src/BikeTracking.Frontend/src/services/ridesService.ts` calling `GET /api/rides/gas-price?date={date}` +- [X] T020 [US1] Extend `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx`: add `gasPrice` state (`string`), pre-populate from `defaults.defaultGasPricePerGallon` on load, then call `getGasPrice(today)` and update `gasPrice` if `isAvailable` (EIA price overrides defaults fallback when available); add gas price `` field with label "Gas Price ($/gal) (optional)", pass `gasPricePerGallon` in `RecordRideRequest` on submit (parse as `parseFloat`, undefined if empty) +- [X] T021 [US1] Add client-side gas price validation in `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx`: if field is non-empty and value ≤ 0, show validation error and block submit + +**TDD-GREEN checkpoint**: All US1 tests pass. Commit: `TDD-GREEN: 010 gas price backend + frontend Record Ride` --- @@ -66,9 +75,18 @@ **Independent Test**: Set EIA key to invalid (force unavailable), open Record Ride form with a prior ride that had a gas price → confirm gas price field shows the prior ride's price, not empty. -- [ ] T022 [US2] Add date-change handler to `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx`: when `rideDateTimeLocal` changes, debounce 300ms then call `getGasPrice(date)` and update `gasPrice` state only if `isAvailable`; if `isAvailable=false`, leave `gasPrice` unchanged (fallback retained from defaults or prior date call) -- [ ] T023 [US2] Write failing Vitest test in `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx`: when `getGasPrice` returns `isAvailable=false`, gas price field still shows the default fallback value from `getRideDefaults` -- [ ] T024 [US2] Write failing Vitest test in `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx`: when `getGasPrice` returns `isAvailable=false` and no default exists, gas price field is empty +### US2 — Tests (write first, TDD-RED gate) + +- [X] T022 [US2] Write failing Vitest test in `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx`: when date changes and `getGasPrice` returns `isAvailable=false`, gas price field still shows the default fallback value from `getRideDefaults` +- [X] T023 [US2] Write failing Vitest test in `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx`: when date changes and `getGasPrice` returns `isAvailable=false` and no default exists, gas price field is empty + +**TDD-RED checkpoint**: Run tests, confirm failures. Commit: `TDD-RED: 010 gas price fallback tests` + +### US2 — Implementation (TDD-GREEN gate) + +- [X] T024 [US2] Add date-change handler to `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx`: when `rideDateTimeLocal` changes, debounce 300ms then call `getGasPrice(date)` and update `gasPrice` state only if `isAvailable`; if `isAvailable=false`, leave `gasPrice` unchanged (fallback retained from defaults or prior date call) + +**TDD-GREEN checkpoint**: All US2 tests pass. Commit: `TDD-GREEN: 010 gas price date-change fallback` --- @@ -78,13 +96,22 @@ **Independent Test**: Edit an existing ride on the History page → confirm gas price field shows the stored value → change the date → confirm gas price refreshes → overwrite → save → confirm history row shows updated price. -- [ ] T025 [US3] Extend `src/BikeTracking.Api/Application/Rides/EditRideService.cs`: accept `GasPricePerGallon decimal?` from `EditRideRequest`, persist to `RideEntity`, pass to `RideEditedEventPayload.Create` -- [ ] T026 [US3] Extend `RideHistoryRow` API mapping in `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs` (or the query service): include `GasPricePerGallon` in the history row projection from `RideEntity` -- [ ] T027 [US3] Extend `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx`: add `gasPricePerGallon` to inline edit form state, pre-populate from `ride.gasPricePerGallon` when edit opens, add gas price `` field with label "Gas Price ($/gal)", pass `gasPricePerGallon` in `EditRideRequest` on submit -- [ ] T028 [US3] Add date-change handler to inline edit form in `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx`: when date field changes, debounce 300ms then call `getGasPrice(newDate)`; update gas price field if `isAvailable`, retain current value if not -- [ ] T029 [US3] Add gas price column to the ride history table in `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx`: display `gasPricePerGallon` in the history table (format as `$X.XXXX` or "N/A") -- [ ] T030 [US3] Extend `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs`: `PUT /api/rides/{id}` with `gasPricePerGallon` stores updated value; `GET /api/rides/history` response rows include `gasPricePerGallon` -- [ ] T031 [US3] Extend `src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx`: gas price column renders in history table; edit form shows pre-populated gas price; date change triggers gas price refresh; user can overwrite gas price; submit passes user-entered value +### US3 — Tests (write first, TDD-RED gate) + +- [X] T025 [US3] Extend `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs`: `PUT /api/rides/{id}` with `gasPricePerGallon` stores updated value; `PUT /api/rides/{id}` with null `gasPricePerGallon` saves successfully with null stored; `GET /api/rides/history` response rows include `gasPricePerGallon` +- [X] T026 [US3] Extend `src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx`: gas price column renders in history table; edit form shows pre-populated gas price; date change triggers gas price refresh; user can overwrite gas price; submit passes user-entered value; negative value shows validation error and blocks submit + +**TDD-RED checkpoint**: Run tests, confirm failures. Commit: `TDD-RED: 010 gas price edit + history tests` + +### US3 — Implementation (TDD-GREEN gate) + +- [X] T027 [US3] Extend `src/BikeTracking.Api/Application/Rides/EditRideService.cs`: accept `GasPricePerGallon decimal?` from `EditRideRequest`, persist to `RideEntity`, pass to `RideEditedEventPayload.Create` +- [X] T028 [US3] Extend `RideHistoryRow` API mapping in `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs` (or the query service): include `GasPricePerGallon` in the history row projection from `RideEntity` +- [X] T029 [US3] Extend `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx`: add `gasPricePerGallon` to inline edit form state, pre-populate from `ride.gasPricePerGallon` when edit opens, add gas price `` field with label "Gas Price ($/gal)", pass `gasPricePerGallon` in `EditRideRequest` on submit; if non-empty and value ≤ 0 show validation error and block submit +- [X] T030 [US3] Add date-change handler to inline edit form in `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx`: when date field changes, debounce 300ms then call `getGasPrice(newDate)`; update gas price field if `isAvailable`, retain current field value if not +- [X] T031 [US3] Add gas price column to the ride history table in `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx`: display `gasPricePerGallon` in the history table (format as `$X.XXXX` or "N/A") + +**TDD-GREEN checkpoint**: All US3 tests pass. Commit: `TDD-GREEN: 010 gas price edit form + history table` --- @@ -94,8 +121,9 @@ **Independent Test**: Call `GET /api/rides/gas-price?date=X` twice for the same date; confirm EIA API is only hit once (inspectable via test mock call count or integration log). -- [ ] T032 [US4] Write failing test in `src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs`: two sequential calls for the same date result in exactly one EIA HTTP request (second call hits cache) -- [ ] T033 [US4] Write failing test in `src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs`: after app restart (new service instance, same DbContext with existing cache row), lookup returns cached value without HTTP call +- [X] T032 [US4] Write failing test in `src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs`: after app restart (new service instance, same DbContext with existing cache row), lookup returns cached value without HTTP call + +**Note**: The "two sequential calls → one HTTP request" scenario was merged into T011 (cache-hit test already asserts HTTP handler invoked exactly once). T032 now only covers the restart-durability dimension. --- @@ -103,12 +131,13 @@ **Purpose**: Validation completeness, formatting, and CI verification. -- [ ] T034 [P] Add `gasPricePerGallon` to `RecordRideApiHost.RecordRideAsync` test helper in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs` so existing seeding helpers can set gas price on test rides -- [ ] T035 [P] Run `csharpier format .` from repo root and fix any formatting issues in new/modified C# files -- [ ] T036 [P] Run `cd src/BikeTracking.Frontend && npm run lint` and fix any ESLint/Stylelint issues in new/modified TypeScript/CSS files -- [ ] T037 Run `dotnet test BikeTracking.slnx` — confirm all backend tests pass -- [ ] T038 Run `cd src/BikeTracking.Frontend && npm run test:unit` — confirm all frontend unit tests pass -- [ ] T039 Run `cd src/BikeTracking.Frontend && npm run build` — confirm no TypeScript compilation errors +- [X] T033 [P] Add `gasPricePerGallon` to `RecordRideApiHost.RecordRideAsync` test helper in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs` so existing seeding helpers can set gas price on test rides +- [X] T034 [P] Run `csharpier format .` from repo root and fix any formatting issues in new/modified C# files +- [X] T035 [P] Run `cd src/BikeTracking.Frontend && npm run lint` and fix any ESLint/Stylelint issues in new/modified TypeScript/CSS files +- [X] T036 Run `dotnet test BikeTracking.slnx` — confirm all backend tests pass +- [X] T037 Run `cd src/BikeTracking.Frontend && npm run test:unit` — confirm all frontend unit tests pass +- [X] T038 Run `cd src/BikeTracking.Frontend && npm run build` — confirm no TypeScript compilation errors +- [X] T039 Write Playwright E2E test: create a ride → confirm gas price field is visible and pre-populated → submit → navigate to ride history → confirm the gas price is displayed in the history row. Run with `cd src/BikeTracking.Frontend && npm run test:e2e` (requires Aspire running) --- @@ -117,20 +146,20 @@ ``` Phase 1 (T001–T002) └─ Phase 2 (T003–T010) [T007, T008, T010 parallelizable within phase] - ├─ Phase 3 / US1 (T011–T021) 🎯 MVP — implement first - │ └─ Phase 4 / US2 (T022–T024) [depends on US1 date field + getGasPrice()] - │ └─ Phase 5 / US3 (T025–T031) [depends on US1 backend + US1 getGasPrice()] - │ └─ Phase 6 / US4 (T032–T033) [depends on GasPriceLookupService from T011] - └─ Final Phase (T034–T039) [after all stories complete] + ├─ Phase 3 / US1 (T011–T021) 🎯 MVP — tests first (T011–T013), then impl (T014–T021) + │ └─ Phase 4 / US2 (T022–T024) [tests T022–T023, then impl T024] + │ └─ Phase 5 / US3 (T025–T031) [tests T025–T026, then impl T027–T031] + │ └─ Phase 6 / US4 (T032) [depends on GasPriceLookupService from T014] + └─ Final Phase (T033–T039) [after all stories complete] ``` **Parallel opportunities within US1 (Phase 3)**: -- T011 + T013 (service + defaults extension) can run in parallel with T016 + T018 (frontend service + validation) -- T019 + T020 (backend tests) can run in parallel with T021 (frontend tests) +- T014 + T016 (backend service + defaults) can run in parallel with T019 + T021 (frontend service + validation) +- T011 (backend tests) can run in parallel with T013 (frontend tests) **Parallel opportunities within US3 (Phase 5)**: -- T025 + T026 (API backend) can run in parallel with T027 + T028 + T029 (frontend) -- T030 (backend tests) can run in parallel with T031 (frontend tests) +- T027 + T028 (API backend) can run in parallel with T029 + T030 + T031 (frontend) +- T025 (backend tests) can run in parallel with T026 (frontend tests) --- @@ -145,10 +174,12 @@ Phase 1 (T001–T002) **US4 (cache correctness tests) can be written alongside US1** since `GasPriceLookupService` is created in T011. -**Suggested commit boundaries**: -1. `TDD-RED: 010 gas price cache + endpoint failing tests` (after T019, T020) -2. `TDD-GREEN: 010 gas price backend — cache + EIA + endpoint` (after T011–T015) -3. `TDD-RED: 010 gas price frontend failing tests` (after T021) -4. `TDD-GREEN: 010 gas price frontend Record Ride field` (after T016–T018) -5. `TDD-GREEN: 010 gas price fallback + edit form + history table` (after US2, US3) -6. `TDD-GREEN: 010 gas price cache redundancy tests` (after US4) +**Suggested commit boundaries** (TDD-RED before TDD-GREEN per constitution): +1. `TDD-RED: 010 gas price cache + endpoint + form tests` (after T011–T013) +2. `TDD-GREEN: 010 gas price backend + frontend Record Ride` (after T014–T021) +3. `TDD-RED: 010 gas price fallback tests` (after T022–T023) +4. `TDD-GREEN: 010 gas price date-change fallback` (after T024) +5. `TDD-RED: 010 gas price edit + history tests` (after T025–T026) +6. `TDD-GREEN: 010 gas price edit form + history table` (after T027–T031) +7. `TDD-GREEN: 010 gas price cache restart durability test` (after T032) +8. `CI-GREEN: 010 gas price polish + E2E` (after T033–T039) diff --git a/src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs b/src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs new file mode 100644 index 0000000..6969987 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/GasPriceLookupServiceTests.cs @@ -0,0 +1,330 @@ +using System.Net; +using System.Text; +using BikeTracking.Api.Application.Rides; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; + +namespace BikeTracking.Api.Tests.Application; + +public sealed class GasPriceLookupServiceTests +{ + [Fact] + public async Task GetOrFetchAsync_CacheHit_DoesNotCallHttp() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + await using var context = CreateSqliteContext(connection); + await context.Database.EnsureCreatedAsync(); + context.GasPriceLookups.Add( + new GasPriceLookupEntity + { + PriceDate = new DateOnly(2026, 3, 31), + PricePerGallon = 3.1999m, + DataSource = "EIA_EPM0_NUS_Weekly", + EiaPeriodDate = new DateOnly(2026, 3, 30), + RetrievedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + }); + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") } + ); + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary { ["GasPriceLookup:EiaApiKey"] = "fake-key" } + ) + .Build(); + + var service = new EiaGasPriceLookupService( + context, + factory, + config, + NullLogger.Instance + ); + + var result = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31)); + + Assert.Equal(3.1999m, result); + Assert.Equal(0, handler.CallCount); + } + + [Fact] + public async Task GetOrFetchAsync_CacheMiss_FetchesAndStores() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + await using var context = CreateSqliteContext(connection); + await context.Database.EnsureCreatedAsync(); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + {"response":{"data":[{"period":"2026-03-30","value":"3.125"}]}} + """, + Encoding.UTF8, + "application/json" + ), + }); + + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") } + ); + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary { ["GasPriceLookup:EiaApiKey"] = "fake-key" } + ) + .Build(); + + var service = new EiaGasPriceLookupService( + context, + factory, + config, + NullLogger.Instance + ); + + var result = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31)); + + Assert.Equal(3.125m, result); + Assert.Equal(1, handler.CallCount); + + var cached = await context.GasPriceLookups.SingleAsync(); + Assert.Equal(new DateOnly(2026, 3, 31), cached.PriceDate); + Assert.Equal(3.125m, cached.PricePerGallon); + } + + [Fact] + public async Task GetOrFetchAsync_OnHttpFailure_ReturnsNullAndDoesNotWrite() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + await using var context = CreateSqliteContext(connection); + await context.Database.EnsureCreatedAsync(); + + var handler = new StubHandler(_ => new HttpResponseMessage( + HttpStatusCode.InternalServerError + )); + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") } + ); + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary { ["GasPriceLookup:EiaApiKey"] = "fake-key" } + ) + .Build(); + + var service = new EiaGasPriceLookupService( + context, + factory, + config, + NullLogger.Instance + ); + + var result = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31)); + + Assert.Null(result); + Assert.Equal(0, await context.GasPriceLookups.CountAsync()); + } + + [Fact] + public async Task GetOrFetchAsync_SecondCall_UsesCacheAndCallsHttpOnce() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + await using var context = CreateSqliteContext(connection); + await context.Database.EnsureCreatedAsync(); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + {"response":{"data":[{"period":"2026-03-30","value":"3.125"}]}} + """, + Encoding.UTF8, + "application/json" + ), + }); + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") } + ); + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary { ["GasPriceLookup:EiaApiKey"] = "fake-key" } + ) + .Build(); + + var service = new EiaGasPriceLookupService( + context, + factory, + config, + NullLogger.Instance + ); + + var first = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31)); + var second = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31)); + + Assert.Equal(3.125m, first); + Assert.Equal(3.125m, second); + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task GetOrFetchAsync_WhenDuplicateDateInsertedConcurrently_ReturnsCachedValue() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + await using (var setupContext = CreateSqliteContext(connection)) + { + await setupContext.Database.EnsureCreatedAsync(); + } + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + {"response":{"data":[{"period":"2026-03-30","value":"3.125"}]}} + """, + Encoding.UTF8, + "application/json" + ), + }); + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") } + ); + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary { ["GasPriceLookup:EiaApiKey"] = "fake-key" } + ) + .Build(); + + await using var context = CreateSqliteContext(connection); + var service = new EiaGasPriceLookupService( + context, + factory, + config, + NullLogger.Instance + ); + + context.SavingChanges += (_, _) => + { + if (context.GasPriceLookups.Local.Any(x => x.PriceDate == new DateOnly(2026, 3, 31))) + { + using var concurrentContext = CreateSqliteContext(connection); + concurrentContext.GasPriceLookups.Add( + new GasPriceLookupEntity + { + PriceDate = new DateOnly(2026, 3, 31), + PricePerGallon = 3.2222m, + DataSource = "EIA_EPM0_NUS_Weekly", + EiaPeriodDate = new DateOnly(2026, 3, 30), + RetrievedAtUtc = DateTime.UtcNow, + } + ); + concurrentContext.SaveChanges(); + } + }; + + var result = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31)); + + Assert.Equal(3.2222m, result); + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task GetOrFetchAsync_AfterServiceRestart_UsesPersistedCacheWithoutHttp() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + await using (var setupContext = CreateSqliteContext(connection)) + { + await setupContext.Database.EnsureCreatedAsync(); + setupContext.GasPriceLookups.Add( + new GasPriceLookupEntity + { + PriceDate = new DateOnly(2026, 3, 31), + PricePerGallon = 3.4567m, + DataSource = "EIA_EPM0_NUS_Weekly", + EiaPeriodDate = new DateOnly(2026, 3, 30), + RetrievedAtUtc = DateTime.UtcNow, + } + ); + await setupContext.SaveChangesAsync(); + } + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + {"response":{"data":[{"period":"2026-03-30","value":"3.125"}]}} + """, + Encoding.UTF8, + "application/json" + ), + }); + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") } + ); + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary { ["GasPriceLookup:EiaApiKey"] = "fake-key" } + ) + .Build(); + + await using var restartedContext = CreateSqliteContext(connection); + var restartedService = new EiaGasPriceLookupService( + restartedContext, + factory, + config, + NullLogger.Instance + ); + + var result = await restartedService.GetOrFetchAsync(new DateOnly(2026, 3, 31)); + + Assert.Equal(3.4567m, result); + Assert.Equal(0, handler.CallCount); + } + + private static BikeTrackingDbContext CreateSqliteContext(SqliteConnection connection) + { + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + return new BikeTrackingDbContext(options); + } + + private sealed class StubHttpClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + private sealed class StubHandler(Func handler) + : HttpMessageHandler + { + public int CallCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + CallCount += 1; + return Task.FromResult(handler(request)); + } + } +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs index 85b699d..dd2ea4e 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs @@ -130,7 +130,13 @@ public async Task GetRideDefaults_WithPriorRides_ReturnsLastDefaults() var userId = await host.SeedUserAsync("Frank"); // Record a ride - await host.RecordRideAsync(userId, miles: 10.5m, rideMinutes: 45, temperature: 72m); + await host.RecordRideAsync( + userId, + miles: 10.5m, + rideMinutes: 45, + temperature: 72m, + gasPricePerGallon: 3.4999m + ); var response = await host.Client.GetWithAuthAsync("/api/rides/defaults", userId); @@ -141,6 +147,101 @@ public async Task GetRideDefaults_WithPriorRides_ReturnsLastDefaults() Assert.Equal(10.5m, payload.DefaultMiles); Assert.Equal(45, payload.DefaultRideMinutes); Assert.Equal(72m, payload.DefaultTemperature); + Assert.Equal(3.4999m, payload.DefaultGasPricePerGallon); + } + + [Fact] + public async Task GetGasPrice_WithValidDate_ReturnsShape() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("GasPriceUser"); + + var response = await host.Client.GetWithAuthAsync( + "/api/rides/gas-price?date=2026-03-31", + userId + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal("2026-03-31", payload.Date); + } + + [Fact] + public async Task GetGasPrice_WithInvalidDate_Returns400() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("GasPriceBadDate"); + + var response = await host.Client.GetWithAuthAsync( + "/api/rides/gas-price?date=not-a-date", + userId + ); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetGasPrice_WithoutAuth_Returns401() + { + await using var host = await RecordRideApiHost.StartAsync(); + + var response = await host.Client.GetAsync("/api/rides/gas-price?date=2026-03-31"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostRecordRide_WithGasPrice_PersistsGasPrice() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("GasPricePersist"); + + var request = new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 10.5m, + RideMinutes: 45, + Temperature: 72m, + GasPricePerGallon: 3.2777m + ); + + var response = await host.Client.PostWithAuthAsync("/api/rides", request, userId); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + + using var scope = host.App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var ride = await dbContext.Rides.SingleAsync(r => r.Id == payload.RideId); + Assert.Equal(3.2777m, ride.GasPricePerGallon); + } + + [Fact] + public async Task PostRecordRide_WithNullGasPrice_PersistsNull() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("GasPriceNull"); + + var request = new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 5.0m, + RideMinutes: null, + Temperature: null, + GasPricePerGallon: null + ); + + var response = await host.Client.PostWithAuthAsync("/api/rides", request, userId); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + + using var scope = host.App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var ride = await dbContext.Rides.SingleAsync(r => r.Id == payload.RideId); + Assert.Null(ride.GasPricePerGallon); } [Fact] @@ -332,6 +433,58 @@ public async Task PutEditRide_WithValidRequest_Returns200AndUpdatedVersion() Assert.Equal(2, payload.NewVersion); } + [Fact] + public async Task PutEditRide_WithGasPrice_StoresGasPrice() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("GasPriceEdit"); + var rideId = await host.RecordRideAsync(userId, miles: 8.5m, gasPricePerGallon: 3.0000m); + + var request = new EditRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 10.25m, + RideMinutes: 39, + Temperature: 68m, + ExpectedVersion: 1, + GasPricePerGallon: 3.5555m + ); + + var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var scope = host.App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var ride = await dbContext.Rides.SingleAsync(r => r.Id == rideId); + Assert.Equal(3.5555m, ride.GasPricePerGallon); + } + + [Fact] + public async Task PutEditRide_WithNullGasPrice_StoresNull() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("GasPriceEditNull"); + var rideId = await host.RecordRideAsync(userId, miles: 8.5m, gasPricePerGallon: 3.0000m); + + var request = new EditRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 9.25m, + RideMinutes: 33, + Temperature: 68m, + ExpectedVersion: 1, + GasPricePerGallon: null + ); + + var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var scope = host.App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var ride = await dbContext.Rides.SingleAsync(r => r.Id == rideId); + Assert.Null(ride.GasPricePerGallon); + } + [Fact] public async Task PutEditRide_WithInvalidPayload_Returns400() { @@ -456,9 +609,35 @@ public async Task PutEditRide_ThenGetHistory_ReturnsEditedMilesInRowsAndTotals() Assert.Equal(10.25m, payload.Summaries.AllTime.Miles); } - private sealed class RecordRideApiHost(WebApplication app) : IAsyncDisposable + [Fact] + public async Task GetRideHistory_ContainsGasPricePerGallon() { - public HttpClient Client { get; } = app.GetTestClient(); + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("GasPriceHistory"); + var rideId = await host.RecordRideAsync(userId, miles: 6.0m, gasPricePerGallon: 3.4444m); + + var response = await host.Client.GetWithAuthAsync("/api/rides/history", userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + var ride = Assert.Single(payload.Rides, r => r.RideId == rideId); + Assert.Equal(3.4444m, ride.GasPricePerGallon); + } + + private sealed class RecordRideApiHost : IAsyncDisposable + { + private readonly WebApplication _app; + + public RecordRideApiHost(WebApplication app) + { + _app = app; + App = app; + Client = app.GetTestClient(); + } + + public WebApplication App { get; } + public HttpClient Client { get; } public static async Task StartAsync() { @@ -483,6 +662,7 @@ public static async Task StartAsync() builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); var app = builder.Build(); app.UseAuthentication(); @@ -495,7 +675,7 @@ public static async Task StartAsync() public async Task SeedUserAsync(string displayName) { - using var scope = app.Services.CreateScope(); + using var scope = _app.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var user = new UserEntity @@ -515,10 +695,11 @@ public async Task RecordRideAsync( long userId, decimal miles, int? rideMinutes = null, - decimal? temperature = null + decimal? temperature = null, + decimal? gasPricePerGallon = null ) { - using var scope = app.Services.CreateScope(); + using var scope = _app.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var ride = new RideEntity @@ -528,6 +709,7 @@ public async Task RecordRideAsync( Miles = miles, RideMinutes = rideMinutes, Temperature = temperature, + GasPricePerGallon = gasPricePerGallon, CreatedAtUtc = DateTime.UtcNow, }; @@ -539,8 +721,8 @@ public async Task RecordRideAsync( public async ValueTask DisposeAsync() { Client.Dispose(); - await app.StopAsync(); - await app.DisposeAsync(); + await _app.StopAsync(); + await _app.DisposeAsync(); } } } @@ -622,3 +804,19 @@ long userId return await client.SendAsync(request); } } + +internal sealed class StubGasPriceLookupService : IGasPriceLookupService +{ + public Task GetOrFetchAsync( + DateOnly date, + CancellationToken cancellationToken = default + ) + { + if (date == new DateOnly(2026, 3, 31)) + { + return Task.FromResult(3.1860m); + } + + return Task.FromResult(null); + } +} diff --git a/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs b/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs index 0f56e9d..e289b69 100644 --- a/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs +++ b/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs @@ -12,6 +12,7 @@ public sealed record RideEditedEventPayload( decimal Miles, int? RideMinutes, decimal? Temperature, + decimal? GasPricePerGallon, string Source ) { @@ -27,6 +28,7 @@ public static RideEditedEventPayload Create( decimal miles, int? rideMinutes = null, decimal? temperature = null, + decimal? gasPricePerGallon = null, DateTime? occurredAtUtc = null ) { @@ -42,6 +44,7 @@ public static RideEditedEventPayload Create( Miles: miles, RideMinutes: rideMinutes, Temperature: temperature, + GasPricePerGallon: gasPricePerGallon, Source: SourceName ); } diff --git a/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs b/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs index 13e0dd3..a2484dc 100644 --- a/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs +++ b/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs @@ -9,6 +9,7 @@ public sealed record RideRecordedEventPayload( decimal Miles, int? RideMinutes, decimal? Temperature, + decimal? GasPricePerGallon, string Source ) { @@ -21,6 +22,7 @@ public static RideRecordedEventPayload Create( decimal miles, int? rideMinutes = null, decimal? temperature = null, + decimal? gasPricePerGallon = null, DateTime? occurredAtUtc = null ) { @@ -33,6 +35,7 @@ public static RideRecordedEventPayload Create( Miles: miles, RideMinutes: rideMinutes, Temperature: temperature, + GasPricePerGallon: gasPricePerGallon, Source: SourceName ); } diff --git a/src/BikeTracking.Api/Application/Rides/EditRideService.cs b/src/BikeTracking.Api/Application/Rides/EditRideService.cs index 469cc01..22e0f76 100644 --- a/src/BikeTracking.Api/Application/Rides/EditRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/EditRideService.cs @@ -89,6 +89,7 @@ public async Task ExecuteAsync( ride.Miles = request.Miles; ride.RideMinutes = request.RideMinutes; ride.Temperature = request.Temperature; + ride.GasPricePerGallon = request.GasPricePerGallon; ride.Version = currentVersion + 1; var utcNow = DateTime.UtcNow; @@ -102,6 +103,7 @@ public async Task ExecuteAsync( miles: ride.Miles, rideMinutes: ride.RideMinutes, temperature: ride.Temperature, + gasPricePerGallon: ride.GasPricePerGallon, occurredAtUtc: utcNow ); diff --git a/src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs b/src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs new file mode 100644 index 0000000..e3e9277 --- /dev/null +++ b/src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs @@ -0,0 +1,165 @@ +using System.Globalization; +using System.Text.Json; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Application.Rides; + +public interface IGasPriceLookupService +{ + Task GetOrFetchAsync(DateOnly date, CancellationToken cancellationToken = default); +} + +public sealed class EiaGasPriceLookupService( + BikeTrackingDbContext dbContext, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger +) : IGasPriceLookupService +{ + private const string DataSourceName = "EIA_EPM0_NUS_Weekly"; + + public async Task GetOrFetchAsync( + DateOnly date, + CancellationToken cancellationToken = default + ) + { + var cached = await dbContext + .GasPriceLookups.AsNoTracking() + .SingleOrDefaultAsync(x => x.PriceDate == date, cancellationToken); + + if (cached is not null) + { + return cached.PricePerGallon; + } + + var apiKey = configuration["GasPriceLookup:EiaApiKey"]; + if (string.IsNullOrWhiteSpace(apiKey)) + { + logger.LogWarning("EIA API key missing; skipping gas price lookup for {Date}", date); + return null; + } + + var client = httpClientFactory.CreateClient("EiaGasPrice"); + var requestUri = + $"/v2/petroleum/pri/gnd/data?api_key={Uri.EscapeDataString(apiKey)}&data[]=value" + + "&facets[duoarea][]=NUS" + + "&facets[product][]=EPM0" + + "&frequency=weekly" + + $"&end={date:yyyy-MM-dd}" + + "&sort[0][column]=period" + + "&sort[0][direction]=desc" + + "&length=1"; + + try + { + using var response = await client.GetAsync(requestUri, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning( + "EIA lookup failed for {Date} with status {StatusCode}", + date, + response.StatusCode + ); + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var jsonDoc = await JsonDocument.ParseAsync( + stream, + cancellationToken: cancellationToken + ); + + if ( + !TryReadPrice(jsonDoc.RootElement, out var eiaPeriodDate, out var pricePerGallon) + || pricePerGallon <= 0 + ) + { + return null; + } + + var entry = new GasPriceLookupEntity + { + PriceDate = date, + PricePerGallon = pricePerGallon, + DataSource = DataSourceName, + EiaPeriodDate = eiaPeriodDate, + RetrievedAtUtc = DateTime.UtcNow, + }; + + dbContext.GasPriceLookups.Add(entry); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // Another request may have inserted the same date concurrently. + var existing = await dbContext + .GasPriceLookups.AsNoTracking() + .SingleOrDefaultAsync(x => x.PriceDate == date, cancellationToken); + + if (existing is not null) + { + return existing.PricePerGallon; + } + + throw; + } + + return pricePerGallon; + } + catch (Exception ex) + { + logger.LogWarning(ex, "EIA lookup threw for {Date}", date); + return null; + } + } + + private static bool TryReadPrice( + JsonElement root, + out DateOnly eiaPeriodDate, + out decimal pricePerGallon + ) + { + eiaPeriodDate = default; + pricePerGallon = default; + + if ( + !root.TryGetProperty("response", out var response) + || !response.TryGetProperty("data", out var data) + || data.ValueKind != JsonValueKind.Array + || data.GetArrayLength() == 0 + ) + { + return false; + } + + var first = data[0]; + if ( + !first.TryGetProperty("period", out var period) + || period.ValueKind != JsonValueKind.String + || !DateOnly.TryParse(period.GetString(), out eiaPeriodDate) + ) + { + return false; + } + + if ( + !first.TryGetProperty("value", out var value) + || value.ValueKind != JsonValueKind.String + || !decimal.TryParse( + value.GetString(), + NumberStyles.Number, + CultureInfo.InvariantCulture, + out pricePerGallon + ) + ) + { + return false; + } + + return true; + } +} diff --git a/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs b/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs index efb8ae5..e00221c 100644 --- a/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs +++ b/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs @@ -37,7 +37,8 @@ public async Task ExecuteAsync( DefaultRideDateTimeLocal: DateTime.Now, DefaultMiles: lastRide.Miles, DefaultRideMinutes: lastRide.RideMinutes, - DefaultTemperature: lastRide.Temperature + DefaultTemperature: lastRide.Temperature, + DefaultGasPricePerGallon: lastRide.GasPricePerGallon ); } } diff --git a/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs b/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs index b899589..e6fb4ad 100644 --- a/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs +++ b/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs @@ -116,7 +116,8 @@ public async Task GetRideHistoryAsync( RideDateTimeLocal: r.RideDateTimeLocal, Miles: r.Miles, RideMinutes: r.RideMinutes, - Temperature: r.Temperature + Temperature: r.Temperature, + GasPricePerGallon: r.GasPricePerGallon )) .ToList(); diff --git a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs index 7c57ece..52907a9 100644 --- a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs @@ -38,6 +38,7 @@ public class RecordRideService(BikeTrackingDbContext dbContext, ILogger diff --git a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs index 29932a0..54d59f2 100644 --- a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs +++ b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs @@ -27,6 +27,15 @@ public static IEndpointRouteBuilder MapRidesEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status401Unauthorized) .RequireAuthorization(); + group + .MapGet("/gas-price", GetGasPrice) + .WithName("GetGasPrice") + .WithSummary("Get gas price lookup for a date") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .RequireAuthorization(); + group .MapGet("/quick-options", GetQuickRideOptions) .WithName("GetQuickRideOptions") @@ -134,6 +143,52 @@ CancellationToken cancellationToken } } + private static async Task GetGasPrice( + HttpContext context, + [FromQuery] string? date, + [FromServices] IGasPriceLookupService gasPriceLookupService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + return Results.Unauthorized(); + + if (string.IsNullOrWhiteSpace(date) || !DateOnly.TryParse(date, out var parsedDate)) + { + return Results.BadRequest( + new ErrorResponse( + "INVALID_REQUEST", + "date query parameter is required and must be a valid date in YYYY-MM-DD format." + ) + ); + } + + try + { + var price = await gasPriceLookupService.GetOrFetchAsync(parsedDate, cancellationToken); + return Results.Ok( + new GasPriceResponse( + Date: parsedDate.ToString("yyyy-MM-dd"), + PricePerGallon: price, + IsAvailable: price.HasValue, + DataSource: price.HasValue ? "EIA_EPM0_NUS_Weekly" : null + ) + ); + } + catch + { + return Results.Ok( + new GasPriceResponse( + Date: parsedDate.ToString("yyyy-MM-dd"), + PricePerGallon: null, + IsAvailable: false, + DataSource: null + ) + ); + } + } + private static async Task GetRideHistory( HttpContext context, GetRideHistoryService historyService, diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs index 9e6099a..88101b4 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs @@ -11,6 +11,7 @@ public sealed class BikeTrackingDbContext(DbContextOptions AuthAttemptStates => Set(); public DbSet OutboxEvents => Set(); public DbSet Rides => Set(); + public DbSet GasPriceLookups => Set(); public DbSet UserSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -99,6 +100,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(static x => x.RiderId).IsRequired(); entity.Property(static x => x.RideDateTimeLocal).IsRequired(); entity.Property(static x => x.Miles).IsRequired(); + entity.Property(static x => x.GasPricePerGallon).HasPrecision(10, 4); entity .Property(static x => x.Version) .IsRequired() @@ -120,6 +122,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(static entity => + { + entity.ToTable("GasPriceLookups"); + entity.HasKey(static x => x.GasPriceLookupId); + + entity.Property(static x => x.PriceDate).IsRequired(); + entity.Property(static x => x.PricePerGallon).IsRequired().HasPrecision(10, 4); + entity.Property(static x => x.DataSource).IsRequired().HasMaxLength(64); + entity.Property(static x => x.EiaPeriodDate).IsRequired(); + entity.Property(static x => x.RetrievedAtUtc).IsRequired(); + + entity.HasIndex(static x => x.PriceDate).IsUnique(); + }); + modelBuilder.Entity(static entity => { entity.ToTable( diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/GasPriceLookupEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/GasPriceLookupEntity.cs new file mode 100644 index 0000000..a09ae72 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/GasPriceLookupEntity.cs @@ -0,0 +1,16 @@ +namespace BikeTracking.Api.Infrastructure.Persistence.Entities; + +public sealed class GasPriceLookupEntity +{ + public int GasPriceLookupId { get; set; } + + public DateOnly PriceDate { get; set; } + + public decimal PricePerGallon { get; set; } + + public required string DataSource { get; set; } + + public DateOnly EiaPeriodDate { get; set; } + + public DateTime RetrievedAtUtc { get; set; } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs index 1583047..bbc6556 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs @@ -14,6 +14,8 @@ public sealed class RideEntity public decimal? Temperature { get; set; } + public decimal? GasPricePerGallon { get; set; } + public int Version { get; set; } = 1; public DateTime CreatedAtUtc { get; set; } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260323134325_AddRidesTable.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260323134325_AddRidesTable.Designer.cs deleted file mode 100644 index 8b86326..0000000 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260323134325_AddRidesTable.Designer.cs +++ /dev/null @@ -1,246 +0,0 @@ -// -using System; -using BikeTracking.Api.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace BikeTracking.Api.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(BikeTrackingDbContext))] - [Migration("20260323134325_AddRidesTable")] - partial class AddRidesTable - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => - { - b.Property("UserId") - .HasColumnType("INTEGER"); - - b.Property("ConsecutiveWrongCount") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0); - - b.Property("DelayUntilUtc") - .HasColumnType("TEXT"); - - b.Property("LastSuccessfulAuthUtc") - .HasColumnType("TEXT"); - - b.Property("LastWrongAttemptUtc") - .HasColumnType("TEXT"); - - b.HasKey("UserId"); - - b.ToTable("AuthAttemptStates", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("Miles") - .HasColumnType("TEXT"); - - b.Property("RideDateTimeLocal") - .HasColumnType("TEXT"); - - b.Property("RideMinutes") - .HasColumnType("INTEGER"); - - b.Property("RiderId") - .HasColumnType("INTEGER"); - - b.Property("Temperature") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("RiderId", "CreatedAtUtc") - .IsDescending(false, true) - .HasDatabaseName("IX_Rides_RiderId_CreatedAtUtc_Desc"); - - b.ToTable("Rides", null, t => - { - t.HasCheckConstraint("CK_Rides_Miles_GreaterThanZero", "\"Miles\" > 0"); - - t.HasCheckConstraint("CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); - }); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => - { - b.Property("OutboxEventId") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AggregateId") - .HasColumnType("INTEGER"); - - b.Property("AggregateType") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("EventPayloadJson") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("LastError") - .HasMaxLength(2048) - .HasColumnType("TEXT"); - - b.Property("NextAttemptUtc") - .HasColumnType("TEXT"); - - b.Property("OccurredAtUtc") - .HasColumnType("TEXT"); - - b.Property("PublishedAtUtc") - .HasColumnType("TEXT"); - - b.Property("RetryCount") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0); - - b.HasKey("OutboxEventId"); - - b.HasIndex("AggregateType", "AggregateId"); - - b.HasIndex("PublishedAtUtc", "NextAttemptUtc"); - - b.ToTable("OutboxEvents", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => - { - b.Property("UserCredentialId") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CredentialVersion") - .HasColumnType("INTEGER"); - - b.Property("HashAlgorithm") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("IterationCount") - .HasColumnType("INTEGER"); - - b.Property("PinHash") - .IsRequired() - .HasColumnType("BLOB"); - - b.Property("PinSalt") - .IsRequired() - .HasColumnType("BLOB"); - - b.Property("UpdatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("INTEGER"); - - b.HasKey("UserCredentialId"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("UserCredentials", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => - { - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("TEXT"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("NormalizedName") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("TEXT"); - - b.HasKey("UserId"); - - b.HasIndex("NormalizedName") - .IsUnique(); - - b.ToTable("Users", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => - { - b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") - .WithOne("AuthAttemptState") - .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => - { - b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) - .WithMany() - .HasForeignKey("RiderId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => - { - b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") - .WithOne("Credential") - .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => - { - b.Navigation("AuthAttemptState"); - - b.Navigation("Credential"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260327165005_AddRideMilesUpperBound.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260327165005_AddRideMilesUpperBound.Designer.cs deleted file mode 100644 index befacf4..0000000 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260327165005_AddRideMilesUpperBound.Designer.cs +++ /dev/null @@ -1,252 +0,0 @@ -// -using System; -using BikeTracking.Api.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace BikeTracking.Api.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(BikeTrackingDbContext))] - [Migration("20260327165005_AddRideMilesUpperBound")] - partial class AddRideMilesUpperBound - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => - { - b.Property("UserId") - .HasColumnType("INTEGER"); - - b.Property("ConsecutiveWrongCount") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0); - - b.Property("DelayUntilUtc") - .HasColumnType("TEXT"); - - b.Property("LastSuccessfulAuthUtc") - .HasColumnType("TEXT"); - - b.Property("LastWrongAttemptUtc") - .HasColumnType("TEXT"); - - b.HasKey("UserId"); - - b.ToTable("AuthAttemptStates", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("Miles") - .HasColumnType("TEXT"); - - b.Property("RideDateTimeLocal") - .HasColumnType("TEXT"); - - b.Property("RideMinutes") - .HasColumnType("INTEGER"); - - b.Property("RiderId") - .HasColumnType("INTEGER"); - - b.Property("Temperature") - .HasColumnType("TEXT"); - - b.Property("Version") - .IsConcurrencyToken() - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1); - - b.HasKey("Id"); - - b.HasIndex("RiderId", "CreatedAtUtc") - .IsDescending(false, true) - .HasDatabaseName("IX_Rides_RiderId_CreatedAtUtc_Desc"); - - b.ToTable("Rides", null, t => - { - t.HasCheckConstraint("CK_Rides_Miles_GreaterThanZero", "\"Miles\" > 0 AND \"Miles\" <= 200"); - - t.HasCheckConstraint("CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); - }); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => - { - b.Property("OutboxEventId") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AggregateId") - .HasColumnType("INTEGER"); - - b.Property("AggregateType") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("EventPayloadJson") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("LastError") - .HasMaxLength(2048) - .HasColumnType("TEXT"); - - b.Property("NextAttemptUtc") - .HasColumnType("TEXT"); - - b.Property("OccurredAtUtc") - .HasColumnType("TEXT"); - - b.Property("PublishedAtUtc") - .HasColumnType("TEXT"); - - b.Property("RetryCount") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0); - - b.HasKey("OutboxEventId"); - - b.HasIndex("AggregateType", "AggregateId"); - - b.HasIndex("PublishedAtUtc", "NextAttemptUtc"); - - b.ToTable("OutboxEvents", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => - { - b.Property("UserCredentialId") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CredentialVersion") - .HasColumnType("INTEGER"); - - b.Property("HashAlgorithm") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("IterationCount") - .HasColumnType("INTEGER"); - - b.Property("PinHash") - .IsRequired() - .HasColumnType("BLOB"); - - b.Property("PinSalt") - .IsRequired() - .HasColumnType("BLOB"); - - b.Property("UpdatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("INTEGER"); - - b.HasKey("UserCredentialId"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("UserCredentials", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => - { - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("TEXT"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("NormalizedName") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("TEXT"); - - b.HasKey("UserId"); - - b.HasIndex("NormalizedName") - .IsUnique(); - - b.ToTable("Users", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => - { - b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") - .WithOne("AuthAttemptState") - .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => - { - b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) - .WithMany() - .HasForeignKey("RiderId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => - { - b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") - .WithOne("Credential") - .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => - { - b.Navigation("AuthAttemptState"); - - b.Navigation("Credential"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.Designer.cs deleted file mode 100644 index 311a626..0000000 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.Designer.cs +++ /dev/null @@ -1,309 +0,0 @@ -// -using System; -using BikeTracking.Api.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace BikeTracking.Api.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(BikeTrackingDbContext))] - [Migration("20260330202303_AddUserSettingsTable")] - partial class AddUserSettingsTable - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => - { - b.Property("UserId") - .HasColumnType("INTEGER"); - - b.Property("ConsecutiveWrongCount") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0); - - b.Property("DelayUntilUtc") - .HasColumnType("TEXT"); - - b.Property("LastSuccessfulAuthUtc") - .HasColumnType("TEXT"); - - b.Property("LastWrongAttemptUtc") - .HasColumnType("TEXT"); - - b.HasKey("UserId"); - - b.ToTable("AuthAttemptStates", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("Miles") - .HasColumnType("TEXT"); - - b.Property("RideDateTimeLocal") - .HasColumnType("TEXT"); - - b.Property("RideMinutes") - .HasColumnType("INTEGER"); - - b.Property("RiderId") - .HasColumnType("INTEGER"); - - b.Property("Temperature") - .HasColumnType("TEXT"); - - b.Property("Version") - .IsConcurrencyToken() - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1); - - b.HasKey("Id"); - - b.HasIndex("RiderId", "CreatedAtUtc") - .IsDescending(false, true) - .HasDatabaseName("IX_Rides_RiderId_CreatedAtUtc_Desc"); - - b.ToTable("Rides", null, t => - { - t.HasCheckConstraint("CK_Rides_Miles_GreaterThanZero", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); - - t.HasCheckConstraint("CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); - }); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => - { - b.Property("UserId") - .HasColumnType("INTEGER"); - - b.Property("AverageCarMpg") - .HasColumnType("TEXT"); - - b.Property("Latitude") - .HasColumnType("TEXT"); - - b.Property("LocationLabel") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Longitude") - .HasColumnType("TEXT"); - - b.Property("MileageRateCents") - .HasColumnType("TEXT"); - - b.Property("OilChangePrice") - .HasColumnType("TEXT"); - - b.Property("UpdatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("YearlyGoalMiles") - .HasColumnType("TEXT"); - - b.HasKey("UserId"); - - b.ToTable("UserSettings", null, t => - { - t.HasCheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"); - - t.HasCheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"); - - t.HasCheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"); - - t.HasCheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"); - - t.HasCheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"); - - t.HasCheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"); - }); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => - { - b.Property("OutboxEventId") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AggregateId") - .HasColumnType("INTEGER"); - - b.Property("AggregateType") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("EventPayloadJson") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("LastError") - .HasMaxLength(2048) - .HasColumnType("TEXT"); - - b.Property("NextAttemptUtc") - .HasColumnType("TEXT"); - - b.Property("OccurredAtUtc") - .HasColumnType("TEXT"); - - b.Property("PublishedAtUtc") - .HasColumnType("TEXT"); - - b.Property("RetryCount") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0); - - b.HasKey("OutboxEventId"); - - b.HasIndex("AggregateType", "AggregateId"); - - b.HasIndex("PublishedAtUtc", "NextAttemptUtc"); - - b.ToTable("OutboxEvents", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => - { - b.Property("UserCredentialId") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CredentialVersion") - .HasColumnType("INTEGER"); - - b.Property("HashAlgorithm") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("IterationCount") - .HasColumnType("INTEGER"); - - b.Property("PinHash") - .IsRequired() - .HasColumnType("BLOB"); - - b.Property("PinSalt") - .IsRequired() - .HasColumnType("BLOB"); - - b.Property("UpdatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("INTEGER"); - - b.HasKey("UserCredentialId"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("UserCredentials", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => - { - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("TEXT"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("NormalizedName") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("TEXT"); - - b.HasKey("UserId"); - - b.HasIndex("NormalizedName") - .IsUnique(); - - b.ToTable("Users", (string)null); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => - { - b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") - .WithOne("AuthAttemptState") - .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => - { - b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) - .WithMany() - .HasForeignKey("RiderId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => - { - b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => - { - b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") - .WithOne("Credential") - .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => - { - b.Navigation("AuthAttemptState"); - - b.Navigation("Credential"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260331135119_AddGasPriceToRidesAndLookupCache.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260331135119_AddGasPriceToRidesAndLookupCache.cs new file mode 100644 index 0000000..fbaa185 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260331135119_AddGasPriceToRidesAndLookupCache.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddGasPriceToRidesAndLookupCache : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GasPricePerGallon", + table: "Rides", + type: "TEXT", + precision: 10, + scale: 4, + nullable: true + ); + + migrationBuilder.CreateTable( + name: "GasPriceLookups", + columns: table => new + { + GasPriceLookupId = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PriceDate = table.Column(type: "TEXT", nullable: false), + PricePerGallon = table.Column( + type: "TEXT", + precision: 10, + scale: 4, + nullable: false + ), + DataSource = table.Column(type: "TEXT", maxLength: 64, nullable: false), + EiaPeriodDate = table.Column(type: "TEXT", nullable: false), + RetrievedAtUtc = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_GasPriceLookups", x => x.GasPriceLookupId); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_GasPriceLookups_PriceDate", + table: "GasPriceLookups", + column: "PriceDate", + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "GasPriceLookups"); + + migrationBuilder.DropColumn(name: "GasPricePerGallon", table: "Rides"); + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs index d0f4fc0..1156a17 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs @@ -41,6 +41,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AuthAttemptStates", (string)null); }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.GasPriceLookupEntity", b => + { + b.Property("GasPriceLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EiaPeriodDate") + .HasColumnType("TEXT"); + + b.Property("PriceDate") + .HasColumnType("TEXT"); + + b.Property("PricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("GasPriceLookupId"); + + b.HasIndex("PriceDate") + .IsUnique(); + + b.ToTable("GasPriceLookups", (string)null); + }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => { b.Property("Id") @@ -50,6 +82,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAtUtc") .HasColumnType("TEXT"); + b.Property("GasPricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + b.Property("Miles") .HasColumnType("TEXT"); diff --git a/src/BikeTracking.Api/Program.cs b/src/BikeTracking.Api/Program.cs index b41f1b2..9a0268c 100644 --- a/src/BikeTracking.Api/Program.cs +++ b/src/BikeTracking.Api/Program.cs @@ -41,6 +41,16 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddHttpClient( + "EiaGasPrice", + client => + { + client.BaseAddress = new Uri("https://api.eia.gov"); + client.Timeout = TimeSpan.FromSeconds(10); + } +); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/BikeTracking.Api/appsettings.Development.json b/src/BikeTracking.Api/appsettings.Development.json index 6320386..1f9c0d4 100644 --- a/src/BikeTracking.Api/appsettings.Development.json +++ b/src/BikeTracking.Api/appsettings.Development.json @@ -1,4 +1,7 @@ { + "GasPriceLookup": { + "EiaApiKey": "DEV_PLACEHOLDER_SET_WITH_USER_SECRETS" + }, "Identity": { "Outbox": { "PollIntervalSeconds": 2, diff --git a/src/BikeTracking.Api/appsettings.json b/src/BikeTracking.Api/appsettings.json index c0cb968..b044849 100644 --- a/src/BikeTracking.Api/appsettings.json +++ b/src/BikeTracking.Api/appsettings.json @@ -2,6 +2,9 @@ "ConnectionStrings": { "BikeTracking": "Data Source=biketracking.local.db" }, + "GasPriceLookup": { + "EiaApiKey": "" + }, "Identity": { "PinPolicy": { "MinLength": 4, diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx index 746526c..e46d06f 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx @@ -7,15 +7,23 @@ vi.mock('../services/ridesService', () => ({ getRideHistory: vi.fn(), editRide: vi.fn(), deleteRide: vi.fn(), + getGasPrice: vi.fn(), })) const mockGetRideHistory = vi.mocked(ridesService.getRideHistory) const mockEditRide = vi.mocked(ridesService.editRide) const mockDeleteRide = vi.mocked(ridesService.deleteRide) +const mockGetGasPrice = vi.mocked(ridesService.getGasPrice) describe('HistoryPage', () => { beforeEach(() => { vi.clearAllMocks() + mockGetGasPrice.mockResolvedValue({ + date: '2026-03-20', + pricePerGallon: null, + isAvailable: false, + dataSource: null, + }) mockDeleteRide.mockResolvedValue({ ok: true, value: { @@ -277,6 +285,7 @@ describe('HistoryPage', () => { miles: 5, rideMinutes: 30, temperature: 70, + gasPricePerGallon: 3.1111, }, ], page: 1, @@ -295,6 +304,131 @@ describe('HistoryPage', () => { await waitFor(() => { expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + const gasPriceInput = screen.getByRole('spinbutton', { + name: /gas price/i, + }) as HTMLInputElement + expect(gasPriceInput.value).toBe('3.1111') + }) + }) + + it('should render gas price column values in history table', async () => { + mockGetRideHistory.mockResolvedValue({ + summaries: { + thisMonth: { miles: 5, rideCount: 1, period: 'thisMonth' }, + thisYear: { miles: 5, rideCount: 1, period: 'thisYear' }, + allTime: { miles: 5, rideCount: 1, period: 'allTime' }, + }, + filteredTotal: { miles: 5, rideCount: 1, period: 'filtered' }, + rides: [ + { + rideId: 21, + rideDateTimeLocal: '2026-03-20T10:30:00', + miles: 5, + rideMinutes: 30, + temperature: 70, + gasPricePerGallon: 3.5555, + }, + ], + page: 1, + pageSize: 25, + totalRows: 1, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('$3.5555')).toBeInTheDocument() + }) + }) + + it('should block save and show validation message for invalid gas price', async () => { + mockGetRideHistory.mockResolvedValue({ + summaries: { + thisMonth: { miles: 5, rideCount: 1, period: 'thisMonth' }, + thisYear: { miles: 5, rideCount: 1, period: 'thisYear' }, + allTime: { miles: 5, rideCount: 1, period: 'allTime' }, + }, + filteredTotal: { miles: 5, rideCount: 1, period: 'filtered' }, + rides: [ + { + rideId: 32, + rideDateTimeLocal: '2026-03-20T10:30:00', + miles: 5, + rideMinutes: 30, + temperature: 70, + gasPricePerGallon: 3.1111, + }, + ], + page: 1, + pageSize: 25, + totalRows: 1, + }) + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + + const gasPriceInput = screen.getByRole('spinbutton', { + name: /gas price/i, + }) as HTMLInputElement + fireEvent.change(gasPriceInput, { target: { value: '-1' } }) + fireEvent.click(screen.getByRole('button', { name: /save/i })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/gas price must be greater than 0/i) + expect(mockEditRide).not.toHaveBeenCalled() + }) + }) + + it('should refresh gas price on date change in edit mode when lookup is available', async () => { + mockGetRideHistory.mockResolvedValue({ + summaries: { + thisMonth: { miles: 5, rideCount: 1, period: 'thisMonth' }, + thisYear: { miles: 5, rideCount: 1, period: 'thisYear' }, + allTime: { miles: 5, rideCount: 1, period: 'allTime' }, + }, + filteredTotal: { miles: 5, rideCount: 1, period: 'filtered' }, + rides: [ + { + rideId: 33, + rideDateTimeLocal: '2026-03-20T10:30:00', + miles: 5, + rideMinutes: 30, + temperature: 70, + gasPricePerGallon: 3.1111, + }, + ], + page: 1, + pageSize: 25, + totalRows: 1, + }) + mockGetGasPrice.mockResolvedValue({ + date: '2026-01-01', + pricePerGallon: 3.9999, + isAvailable: true, + dataSource: 'EIA_EPM0_NUS_Weekly', + }) + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + + const dateInput = screen.getByLabelText(/^Date$/i) as HTMLInputElement + fireEvent.change(dateInput, { target: { value: '2026-01-01T09:00' } }) + + await waitFor(() => { + const gasPriceInput = screen.getByRole('spinbutton', { + name: /gas price/i, + }) as HTMLInputElement + expect(gasPriceInput.value).toBe('3.9999') }) }) diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx index c52421f..015e916 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx @@ -4,7 +4,12 @@ import type { RideHistoryResponse, RideHistoryRow, } from '../services/ridesService' -import { deleteRide, editRide, getRideHistory } from '../services/ridesService' +import { + deleteRide, + editRide, + getGasPrice, + getRideHistory, +} from '../services/ridesService' import { RideDeleteDialog } from '../components/RideDeleteDialog/RideDeleteDialog' import { MileageSummaryCard } from '../components/mileage-summary-card/mileage-summary-card' import { @@ -18,18 +23,26 @@ import './HistoryPage.css' function HistoryTable({ rides, editingRideId, + editedRideDateTimeLocal, editedMiles, + editedGasPrice, onStartEdit, + onEditedRideDateTimeLocalChange, onEditedMilesChange, + onEditedGasPriceChange, onSaveEdit, onCancelEdit, onStartDelete, }: { rides: RideHistoryRow[] editingRideId: number | null + editedRideDateTimeLocal: string editedMiles: string + editedGasPrice: string onStartEdit: (ride: RideHistoryRow) => void + onEditedRideDateTimeLocalChange: (value: string) => void onEditedMilesChange: (value: string) => void + onEditedGasPriceChange: (value: string) => void onSaveEdit: (ride: RideHistoryRow) => void onCancelEdit: () => void onStartDelete: (ride: RideHistoryRow) => void @@ -46,13 +59,30 @@ function HistoryTable({ Miles Duration Temperature + Gas Price Actions {rides.map((ride) => ( - {formatRideDate(ride.rideDateTimeLocal)} + + {editingRideId === ride.rideId ? ( +
+ + + onEditedRideDateTimeLocalChange(event.target.value) + } + /> +
+ ) : ( + formatRideDate(ride.rideDateTimeLocal) + )} + {editingRideId === ride.rideId ? (
@@ -71,6 +101,25 @@ function HistoryTable({ {formatRideDuration(ride.rideMinutes) || 'N/A'} {formatTemperature(ride.temperature) || 'N/A'} + + {editingRideId === ride.rideId ? ( +
+ + onEditedGasPriceChange(event.target.value)} + /> +
+ ) : ride.gasPricePerGallon != null ? ( + `$${ride.gasPricePerGallon.toFixed(4)}` + ) : ( + 'N/A' + )} + {editingRideId === ride.rideId ? (
@@ -114,7 +163,9 @@ export function HistoryPage() { const [fromDate, setFromDate] = useState('') const [toDate, setToDate] = useState('') const [editingRideId, setEditingRideId] = useState(null) + const [editedRideDateTimeLocal, setEditedRideDateTimeLocal] = useState('') const [editedMiles, setEditedMiles] = useState('') + const [editedGasPrice, setEditedGasPrice] = useState('') const [ridePendingDelete, setRidePendingDelete] = useState(null) async function loadHistory(params: GetRideHistoryParams): Promise { @@ -151,14 +202,44 @@ export function HistoryPage() { function handleStartEdit(ride: RideHistoryRow): void { setEditingRideId(ride.rideId) + setEditedRideDateTimeLocal(ride.rideDateTimeLocal.slice(0, 16)) setEditedMiles(ride.miles.toFixed(1)) + setEditedGasPrice( + ride.gasPricePerGallon != null ? ride.gasPricePerGallon.toFixed(4) : '' + ) } function handleCancelEdit(): void { setEditingRideId(null) + setEditedRideDateTimeLocal('') setEditedMiles('') + setEditedGasPrice('') } + useEffect(() => { + if (editingRideId === null || !editedRideDateTimeLocal) { + return + } + + const timerId = setTimeout(async () => { + const dateOnly = editedRideDateTimeLocal.slice(0, 10) + if (!dateOnly) { + return + } + + try { + const lookup = await getGasPrice(dateOnly) + if (lookup.isAvailable && lookup.pricePerGallon !== null) { + setEditedGasPrice(lookup.pricePerGallon.toString()) + } + } catch { + // Keep current gas price value if lookup fails. + } + }, 300) + + return () => clearTimeout(timerId) + }, [editingRideId, editedRideDateTimeLocal]) + async function handleSaveEdit(ride: RideHistoryRow): Promise { const milesValue = Number(editedMiles) if (!Number.isFinite(milesValue) || milesValue <= 0) { @@ -171,11 +252,25 @@ export function HistoryPage() { return } + let gasPriceValue: number | undefined + if (editedGasPrice.length > 0) { + gasPriceValue = Number(editedGasPrice) + if ( + !Number.isFinite(gasPriceValue) || + gasPriceValue < 0.01 || + gasPriceValue > 999.9999 + ) { + setError('Gas price must be between 0.01 and 999.9999') + return + } + } + const result = await editRide(ride.rideId, { - rideDateTimeLocal: ride.rideDateTimeLocal, + rideDateTimeLocal: editedRideDateTimeLocal || ride.rideDateTimeLocal, miles: milesValue, rideMinutes: ride.rideMinutes, temperature: ride.temperature, + gasPricePerGallon: gasPriceValue, // Version tokens are added to history rows in later tasks; use baseline v1 for now. expectedVersion: 1, }) @@ -193,7 +288,9 @@ export function HistoryPage() { setError('') setEditingRideId(null) + setEditedRideDateTimeLocal('') setEditedMiles('') + setEditedGasPrice('') await loadHistory({ from: fromDate || undefined, @@ -322,9 +419,13 @@ export function HistoryPage() { void handleSaveEdit(ride)} onCancelEdit={handleCancelEdit} onStartDelete={handleStartDelete} diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx index 8cec693..ca8ea76 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx @@ -6,6 +6,7 @@ import { RecordRidePage } from '../pages/RecordRidePage' // Mock the ridesService vi.mock('../services/ridesService', () => ({ getRideDefaults: vi.fn(), + getGasPrice: vi.fn(), getQuickRideOptions: vi.fn(), recordRide: vi.fn(), })) @@ -13,12 +14,19 @@ vi.mock('../services/ridesService', () => ({ import * as ridesService from '../services/ridesService' const mockGetRideDefaults = vi.mocked(ridesService.getRideDefaults) +const mockGetGasPrice = vi.mocked(ridesService.getGasPrice) const mockGetQuickRideOptions = vi.mocked(ridesService.getQuickRideOptions) const mockRecordRide = vi.mocked(ridesService.recordRide) describe('RecordRidePage', () => { beforeEach(() => { vi.clearAllMocks() + mockGetGasPrice.mockResolvedValue({ + date: new Date().toISOString().slice(0, 10), + pricePerGallon: null, + isAvailable: false, + dataSource: null, + }) mockGetQuickRideOptions.mockResolvedValue({ options: [], generatedAtUtc: new Date().toISOString(), @@ -74,6 +82,7 @@ describe('RecordRidePage', () => { defaultMiles: 10.5, defaultRideMinutes: 45, defaultTemperature: 72, + defaultGasPricePerGallon: 3.1111, }) render( @@ -91,6 +100,165 @@ describe('RecordRidePage', () => { const tempInput = screen.getByLabelText(/temperature/i) as HTMLInputElement expect(tempInput.value).toBe('72') + + const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement + expect(gasPriceInput.value).toBe('3.1111') + }) + }) + + it('should call getGasPrice on initial load and use available value', async () => { + mockGetRideDefaults.mockResolvedValue({ + hasPreviousRide: true, + defaultRideDateTimeLocal: new Date().toISOString(), + defaultGasPricePerGallon: 3.1111, + }) + mockGetGasPrice.mockResolvedValue({ + date: new Date().toISOString().slice(0, 10), + pricePerGallon: 3.2222, + isAvailable: true, + dataSource: 'EIA_EPM0_NUS_Weekly', + }) + + render( + + + + ) + + await waitFor(() => { + expect(mockGetGasPrice).toHaveBeenCalled() + const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement + expect(gasPriceInput.value).toBe('3.2222') + }) + }) + + it('should allow empty gas price and omit it on submit', async () => { + mockGetRideDefaults.mockResolvedValue({ + hasPreviousRide: false, + defaultRideDateTimeLocal: new Date().toISOString(), + }) + mockRecordRide.mockResolvedValue({ + rideId: 50, + riderId: 1, + savedAtUtc: new Date().toISOString(), + eventStatus: 'Queued', + }) + + render( + + + + ) + + await waitFor(() => { + const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement + fireEvent.change(milesInput, { target: { value: '10' } }) + }) + + const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement + fireEvent.change(gasPriceInput, { target: { value: '' } }) + + const submitButton = screen.getByRole('button', { name: /record ride/i }) + fireEvent.click(submitButton) + + await waitFor(() => { + expect(mockRecordRide).toHaveBeenCalledWith( + expect.not.objectContaining({ gasPricePerGallon: expect.anything() }) + ) + }) + }) + + it('should block submit when gas price is negative', async () => { + mockGetRideDefaults.mockResolvedValue({ + hasPreviousRide: false, + defaultRideDateTimeLocal: new Date().toISOString(), + }) + + render( + + + + ) + + await waitFor(() => { + const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement + fireEvent.change(milesInput, { target: { value: '10' } }) + }) + + const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement + fireEvent.change(gasPriceInput, { target: { value: '-1' } }) + + const submitButton = screen.getByRole('button', { name: /record ride/i }) + fireEvent.click(submitButton) + + await waitFor(() => { + expect(mockRecordRide).not.toHaveBeenCalled() + expect(screen.getByText(/gas price must be greater than 0/i)).toBeInTheDocument() + }) + }) + + it('should retain fallback gas price when date-change lookup is unavailable', async () => { + mockGetRideDefaults.mockResolvedValue({ + hasPreviousRide: true, + defaultRideDateTimeLocal: new Date().toISOString(), + defaultGasPricePerGallon: 3.1111, + }) + mockGetGasPrice.mockResolvedValue({ + date: new Date().toISOString().slice(0, 10), + pricePerGallon: null, + isAvailable: false, + dataSource: null, + }) + + render( + + + + ) + + await waitFor(() => { + const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement + expect(gasPriceInput.value).toBe('3.1111') + }) + + const dateInput = screen.getByLabelText(/date & time/i) as HTMLInputElement + fireEvent.change(dateInput, { target: { value: '2026-01-01T09:00' } }) + + await waitFor(() => { + const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement + expect(gasPriceInput.value).toBe('3.1111') + }) + }) + + it('should keep gas price empty when lookup unavailable and no fallback exists', async () => { + mockGetRideDefaults.mockResolvedValue({ + hasPreviousRide: false, + defaultRideDateTimeLocal: new Date().toISOString(), + }) + mockGetGasPrice.mockResolvedValue({ + date: new Date().toISOString().slice(0, 10), + pricePerGallon: null, + isAvailable: false, + dataSource: null, + }) + + render( + + + + ) + + await waitFor(() => { + const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement + expect(gasPriceInput.value).toBe('') + }) + + const dateInput = screen.getByLabelText(/date & time/i) as HTMLInputElement + fireEvent.change(dateInput, { target: { value: '2026-01-01T09:00' } }) + + await waitFor(() => { + const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement + expect(gasPriceInput.value).toBe('') }) }) diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx index b18c623..204cb62 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx @@ -1,12 +1,18 @@ import { useEffect, useState } from 'react' import type { QuickRideOption, RecordRideRequest } from '../services/ridesService' -import { getQuickRideOptions, recordRide, getRideDefaults } from '../services/ridesService' +import { + getGasPrice, + getQuickRideOptions, + recordRide, + getRideDefaults, +} from '../services/ridesService' export function RecordRidePage() { const [rideDateTimeLocal, setRideDateTimeLocal] = useState('') const [miles, setMiles] = useState('') const [rideMinutes, setRideMinutes] = useState('') const [temperature, setTemperature] = useState('') + const [gasPrice, setGasPrice] = useState('') const [quickRideOptions, setQuickRideOptions] = useState([]) const [loading, setLoading] = useState(true) @@ -41,6 +47,18 @@ export function RecordRidePage() { setRideMinutes(defaults.defaultRideMinutes.toString()) if (defaults.defaultTemperature) setTemperature(defaults.defaultTemperature.toString()) + if (defaults.defaultGasPricePerGallon) + setGasPrice(defaults.defaultGasPricePerGallon.toString()) + } + + try { + const today = localIso.slice(0, 10) + const lookup = await getGasPrice(today) + if (lookup.isAvailable && lookup.pricePerGallon !== null) { + setGasPrice(lookup.pricePerGallon.toString()) + } + } catch (error) { + console.error('Failed to load gas price:', error) } } catch (error) { console.error('Failed to load defaults:', error) @@ -56,6 +74,31 @@ export function RecordRidePage() { initializeDefaults() }, []) + useEffect(() => { + if (!rideDateTimeLocal) { + return + } + + const timerId = setTimeout(async () => { + const dateOnly = rideDateTimeLocal.slice(0, 10) + if (!dateOnly) { + return + } + + try { + const lookup = await getGasPrice(dateOnly) + if (lookup.isAvailable && lookup.pricePerGallon !== null) { + setGasPrice(lookup.pricePerGallon.toString()) + } + } catch (error) { + // Keep the existing gas price value as fallback if lookup fails. + console.error('Failed to refresh gas price for date change:', error) + } + }, 300) + + return () => clearTimeout(timerId) + }, [rideDateTimeLocal]) + const applyQuickRideOption = (option: QuickRideOption) => { setMiles(option.miles.toString()) setRideMinutes(option.rideMinutes.toString()) @@ -83,6 +126,14 @@ export function RecordRidePage() { return } + if (gasPrice) { + const gasPriceNum = parseFloat(gasPrice) + if (Number.isNaN(gasPriceNum) || gasPriceNum < 0.01 || gasPriceNum > 999.9999) { + setErrorMessage('Gas price must be a number between 0.01 and 999.9999') + return + } + } + setSubmitting(true) try { const request: RecordRideRequest = { @@ -90,6 +141,7 @@ export function RecordRidePage() { miles: milesNum, rideMinutes: rideMinutes ? parseInt(rideMinutes) : undefined, temperature: temperature ? parseFloat(temperature) : undefined, + gasPricePerGallon: gasPrice ? parseFloat(gasPrice) : undefined, } const response = await recordRide(request) @@ -102,6 +154,7 @@ export function RecordRidePage() { setMiles('') setRideMinutes('') setTemperature('') + setGasPrice('') setSuccessMessage('') }, 3000) } catch (error) { @@ -194,6 +247,27 @@ export function RecordRidePage() { />
+
+ + { + setGasPrice(e.target.value) + if (errorMessage.length > 0) { + setErrorMessage('') + } + }} + onInvalid={(e) => { + e.preventDefault() + setErrorMessage('Gas price must be greater than 0') + }} + /> +
+ diff --git a/src/BikeTracking.Frontend/src/services/ridesService.ts b/src/BikeTracking.Frontend/src/services/ridesService.ts index e8e812c..2185428 100644 --- a/src/BikeTracking.Frontend/src/services/ridesService.ts +++ b/src/BikeTracking.Frontend/src/services/ridesService.ts @@ -3,6 +3,7 @@ export interface RecordRideRequest { miles: number; rideMinutes?: number; temperature?: number; + gasPricePerGallon?: number; } export interface RecordRideSuccessResponse { @@ -17,9 +18,17 @@ export interface RideDefaultsResponse { defaultMiles?: number; defaultRideMinutes?: number; defaultTemperature?: number; + defaultGasPricePerGallon?: number; defaultRideDateTimeLocal: string; } +export interface GasPriceResponse { + date: string; + pricePerGallon: number | null; + isAvailable: boolean; + dataSource: string | null; +} + export interface QuickRideOption { miles: number; rideMinutes: number; @@ -36,6 +45,7 @@ export interface EditRideRequest { miles: number; rideMinutes?: number; temperature?: number; + gasPricePerGallon?: number; expectedVersion: number; } @@ -95,6 +105,7 @@ export interface RideHistoryRow { miles: number; rideMinutes?: number; temperature?: number; + gasPricePerGallon?: number; } /** @@ -194,6 +205,24 @@ export async function getRideDefaults(): Promise { return response.json(); } +export async function getGasPrice(date: string): Promise { + const response = await fetch( + `${API_BASE_URL}/api/rides/gas-price?date=${encodeURIComponent(date)}`, + { + method: "GET", + headers: getAuthHeaders(), + }, + ); + + if (!response.ok) { + throw new Error( + await parseErrorMessage(response, "Failed to fetch gas price"), + ); + } + + return response.json(); +} + export async function getQuickRideOptions(): Promise { const response = await fetch(`${API_BASE_URL}/api/rides/quick-options`, { method: "GET", diff --git a/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts b/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts index d0cf3d1..d17bf2d 100644 --- a/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts @@ -57,4 +57,30 @@ test.describe("004-record-ride e2e", () => { await page.getByRole("button", { name: "Record Ride" }).click(); await expect(page.getByText(/ride recorded successfully/i)).toBeVisible(); }); + + test("shows gas price, prepopulates it, and displays it in history", async ({ + page, + }) => { + const userName = uniqueUser("e2e-gas-price"); + await createAndLoginUser(page, userName, TEST_PIN); + + await recordRide(page, { + miles: "10.00", + rideMinutes: "30", + temperature: "64", + gasPrice: "3.4567", + }); + + await page.goto("/rides/record"); + await expect(page.locator("#gasPrice")).toBeVisible(); + await expect(page.locator("#gasPrice")).toHaveValue("3.4567"); + + await page.locator("#miles").fill("11.00"); + await page.getByRole("button", { name: "Record Ride" }).click(); + await expect(page.getByText(/ride recorded successfully/i)).toBeVisible(); + + await page.getByRole("link", { name: "Ride History" }).click(); + await expect(page).toHaveURL(/\/rides\/history$/); + await expect(page.getByText("$3.4567").first()).toBeVisible(); + }); }); diff --git a/src/BikeTracking.Frontend/tests/e2e/support/ride-helpers.ts b/src/BikeTracking.Frontend/tests/e2e/support/ride-helpers.ts index 783f71f..4540e42 100644 --- a/src/BikeTracking.Frontend/tests/e2e/support/ride-helpers.ts +++ b/src/BikeTracking.Frontend/tests/e2e/support/ride-helpers.ts @@ -5,6 +5,7 @@ export interface RideFormInput { miles: string; rideMinutes?: string; temperature?: string; + gasPrice?: string; } export function toDateTimeLocalValue(date: Date): string { @@ -34,6 +35,10 @@ export async function recordRide( await page.locator("#temperature").fill(input.temperature); } + if (input.gasPrice !== undefined) { + await page.locator("#gasPrice").fill(input.gasPrice); + } + await page.getByRole("button", { name: "Record Ride" }).click(); await expect(page.getByText(/ride recorded successfully/i)).toBeVisible(); } From 356bd206af1c7a249bad213cdaeae01c5b9cfdcf Mon Sep 17 00:00:00 2001 From: aligneddev Date: Tue, 31 Mar 2026 16:59:45 +0000 Subject: [PATCH 03/20] feat: Enhance gas price tracking with EIA source integration and update tests --- specs/010-gas-price-lookup/research.md | 2 + .../RidesEndpointsSqliteIntegrationTests.cs | 193 ++++++++++++++++++ .../Endpoints/RidesEndpointsTests.cs | 24 +-- .../Endpoints/RidesEndpoints.cs | 4 +- .../20260323134325_AddRidesTable.cs | 4 + .../20260327165005_AddRideMilesUpperBound.cs | 6 +- .../20260330202303_AddUserSettingsTable.cs | 51 +++-- ...135119_AddGasPriceToRidesAndLookupCache.cs | 4 + .../src/pages/HistoryPage.test.tsx | 7 +- .../src/pages/HistoryPage.tsx | 18 +- .../src/pages/RecordRidePage.test.tsx | 5 +- .../src/pages/RecordRidePage.tsx | 14 ++ 12 files changed, 298 insertions(+), 34 deletions(-) create mode 100644 src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs diff --git a/specs/010-gas-price-lookup/research.md b/specs/010-gas-price-lookup/research.md index 61d572c..4503cb0 100644 --- a/specs/010-gas-price-lookup/research.md +++ b/specs/010-gas-price-lookup/research.md @@ -18,6 +18,8 @@ - Embedding a static price table — rejected: goes stale and requires manual maintenance. **EIA API Details**: +Get a key at https://www.eia.gov/opendata/ + | Field | Value | |---|---| diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs new file mode 100644 index 0000000..b8e36f9 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs @@ -0,0 +1,193 @@ +using System.Net; +using System.Net.Http.Json; +using BikeTracking.Api.Application.Rides; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Endpoints; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Tests.Endpoints; + +public sealed class RidesEndpointsSqliteIntegrationTests +{ + private static readonly string[] SqliteUnsupportedConstraintMigrations = + [ + "20260327165005_AddRideMilesUpperBound", + "20260327171355_FixRideMilesUpperBoundNumericComparison", + ]; + + [Fact] + public async Task GetRideHistory_WithSqliteMigrationsApplied_ReturnsGasPricePerGallon() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var appliedMigrations = await host.GetAppliedMigrationsAsync(); + Assert.Contains( + appliedMigrations, + migration => + migration.Contains("AddGasPriceToRidesAndLookupCache", StringComparison.Ordinal) + ); + + var userId = await host.SeedUserAsync("SqliteHistory"); + var rideId = await host.RecordRideAsync(userId, miles: 6.25m, gasPricePerGallon: 3.4567m); + + var response = await host.Client.GetWithAuthAsync("/api/rides/history", userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + + var ride = Assert.Single(payload.Rides, r => r.RideId == rideId); + Assert.Equal(3.4567m, ride.GasPricePerGallon); + } + + private sealed class SqliteRidesApiHost(WebApplication app, string databasePath) + : IAsyncDisposable + { + public WebApplication App { get; } = app; + public HttpClient Client { get; } = app.GetTestClient(); + private string DatabasePath { get; } = databasePath; + + public static async Task StartAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var databasePath = Path.Combine( + Path.GetTempPath(), + $"biketracking-api-tests-{Guid.NewGuid():N}.db" + ); + + builder.Services.AddDbContext(options => + options.UseSqlite($"Data Source={databasePath}") + ); + + builder + .Services.AddAuthentication("test") + .AddScheme( + "test", + _ => { } + ); + builder.Services.AddAuthorization(); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + var app = builder.Build(); + + await using (var scope = app.Services.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // SQLite cannot execute DropCheckConstraint migrations. Apply the + // supported migrations, mark those legacy migrations as applied, + // then continue migration so endpoint queries run on migrated schema. + await dbContext.Database.MigrateAsync("20260327000000_AddRideVersion"); + + var applied = (await dbContext.Database.GetAppliedMigrationsAsync()).ToHashSet(); + foreach (var migration in SqliteUnsupportedConstraintMigrations) + { + if (applied.Contains(migration)) + { + continue; + } + + await dbContext.Database.ExecuteSqlRawAsync( + "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES ({0}, {1})", + migration, + "10.0.5" + ); + } + + await dbContext.Database.MigrateAsync(); + } + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapRidesEndpoints(); + await app.StartAsync(); + + return new SqliteRidesApiHost(app, databasePath); + } + + public async Task> GetAppliedMigrationsAsync() + { + await using var scope = App.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var migrations = await dbContext.Database.GetAppliedMigrationsAsync(); + return migrations.ToArray(); + } + + public async Task SeedUserAsync(string displayName) + { + await using var scope = App.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var user = new UserEntity + { + DisplayName = displayName, + NormalizedName = displayName.ToLowerInvariant(), + CreatedAtUtc = DateTime.UtcNow, + IsActive = true, + }; + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + return user.UserId; + } + + public async Task RecordRideAsync( + long userId, + decimal miles, + int? rideMinutes = null, + decimal? temperature = null, + decimal? gasPricePerGallon = null + ) + { + await using var scope = App.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var ride = new RideEntity + { + RiderId = userId, + RideDateTimeLocal = DateTime.Now, + Miles = miles, + RideMinutes = rideMinutes, + Temperature = temperature, + GasPricePerGallon = gasPricePerGallon, + CreatedAtUtc = DateTime.UtcNow, + }; + + dbContext.Rides.Add(ride); + await dbContext.SaveChangesAsync(); + return ride.Id; + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await App.StopAsync(); + await App.DisposeAsync(); + + if (File.Exists(DatabasePath)) + { + try + { + File.Delete(DatabasePath); + } + catch (IOException) + { + // Ignore cleanup failure from transient file locks in test teardown. + } + } + } + } +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs index dd2ea4e..1619eec 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs @@ -166,6 +166,7 @@ public async Task GetGasPrice_WithValidDate_ReturnsShape() var payload = await response.Content.ReadFromJsonAsync(); Assert.NotNull(payload); Assert.Equal("2026-03-31", payload.Date); + Assert.Equal("Source: U.S. Energy Information Administration (EIA)", payload.DataSource); } [Fact] @@ -625,19 +626,10 @@ public async Task GetRideHistory_ContainsGasPricePerGallon() Assert.Equal(3.4444m, ride.GasPricePerGallon); } - private sealed class RecordRideApiHost : IAsyncDisposable + private sealed class RecordRideApiHost(WebApplication app) : IAsyncDisposable { - private readonly WebApplication _app; - - public RecordRideApiHost(WebApplication app) - { - _app = app; - App = app; - Client = app.GetTestClient(); - } - - public WebApplication App { get; } - public HttpClient Client { get; } + public WebApplication App { get; } = app; + public HttpClient Client { get; } = app.GetTestClient(); public static async Task StartAsync() { @@ -675,7 +667,7 @@ public static async Task StartAsync() public async Task SeedUserAsync(string displayName) { - using var scope = _app.Services.CreateScope(); + using var scope = app.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var user = new UserEntity @@ -699,7 +691,7 @@ public async Task RecordRideAsync( decimal? gasPricePerGallon = null ) { - using var scope = _app.Services.CreateScope(); + using var scope = app.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var ride = new RideEntity @@ -721,8 +713,8 @@ public async Task RecordRideAsync( public async ValueTask DisposeAsync() { Client.Dispose(); - await _app.StopAsync(); - await _app.DisposeAsync(); + await app.StopAsync(); + await app.DisposeAsync(); } } } diff --git a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs index 54d59f2..00fb71a 100644 --- a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs +++ b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs @@ -6,6 +6,8 @@ namespace BikeTracking.Api.Endpoints; public static class RidesEndpoints { + private const string EiaGasPriceSource = "Source: U.S. Energy Information Administration (EIA)"; + public static IEndpointRouteBuilder MapRidesEndpoints(this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/api/rides"); @@ -172,7 +174,7 @@ CancellationToken cancellationToken Date: parsedDate.ToString("yyyy-MM-dd"), PricePerGallon: price, IsAvailable: price.HasValue, - DataSource: price.HasValue ? "EIA_EPM0_NUS_Weekly" : null + DataSource: price.HasValue ? EiaGasPriceSource : null ) ); } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260323134325_AddRidesTable.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260323134325_AddRidesTable.cs index 7426a73..00c747a 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260323134325_AddRidesTable.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260323134325_AddRidesTable.cs @@ -1,4 +1,6 @@ using System; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -6,6 +8,8 @@ namespace BikeTracking.Api.Infrastructure.Persistence.Migrations { /// + [DbContext(typeof(BikeTrackingDbContext))] + [Migration("20260323134325_AddRidesTable")] public partial class AddRidesTable : Migration { /// diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260327165005_AddRideMilesUpperBound.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260327165005_AddRideMilesUpperBound.cs index b85e9a1..6cc6684 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260327165005_AddRideMilesUpperBound.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260327165005_AddRideMilesUpperBound.cs @@ -1,10 +1,14 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace BikeTracking.Api.Infrastructure.Persistence.Migrations { /// + [DbContext(typeof(BikeTrackingDbContext))] + [Migration("20260327165005_AddRideMilesUpperBound")] public partial class AddRideMilesUpperBound : Migration { /// diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.cs index 31a52a4..925e916 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.cs @@ -1,4 +1,6 @@ using System; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -6,6 +8,8 @@ namespace BikeTracking.Api.Infrastructure.Persistence.Migrations { /// + [DbContext(typeof(BikeTrackingDbContext))] + [Migration("20260330202303_AddUserSettingsTable")] public partial class AddUserSettingsTable : Migration { /// @@ -20,34 +24,57 @@ protected override void Up(MigrationBuilder migrationBuilder) YearlyGoalMiles = table.Column(type: "TEXT", nullable: true), OilChangePrice = table.Column(type: "TEXT", nullable: true), MileageRateCents = table.Column(type: "TEXT", nullable: true), - LocationLabel = table.Column(type: "TEXT", maxLength: 200, nullable: true), + LocationLabel = table.Column( + type: "TEXT", + maxLength: 200, + nullable: true + ), Latitude = table.Column(type: "TEXT", nullable: true), Longitude = table.Column(type: "TEXT", nullable: true), - UpdatedAtUtc = table.Column(type: "TEXT", nullable: false) + UpdatedAtUtc = table.Column(type: "TEXT", nullable: false), }, constraints: table => { table.PrimaryKey("PK_UserSettings", x => x.UserId); - table.CheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"); - table.CheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"); - table.CheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"); - table.CheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"); - table.CheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"); - table.CheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"); + table.CheckConstraint( + "CK_UserSettings_AverageCarMpg_Positive", + "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0" + ); + table.CheckConstraint( + "CK_UserSettings_Latitude_Range", + "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)" + ); + table.CheckConstraint( + "CK_UserSettings_Longitude_Range", + "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)" + ); + table.CheckConstraint( + "CK_UserSettings_MileageRateCents_Positive", + "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0" + ); + table.CheckConstraint( + "CK_UserSettings_OilChangePrice_Positive", + "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0" + ); + table.CheckConstraint( + "CK_UserSettings_YearlyGoalMiles_Positive", + "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0" + ); table.ForeignKey( name: "FK_UserSettings_Users_UserId", column: x => x.UserId, principalTable: "Users", principalColumn: "UserId", - onDelete: ReferentialAction.Cascade); - }); + onDelete: ReferentialAction.Cascade + ); + } + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable( - name: "UserSettings"); + migrationBuilder.DropTable(name: "UserSettings"); } } } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260331135119_AddGasPriceToRidesAndLookupCache.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260331135119_AddGasPriceToRidesAndLookupCache.cs index fbaa185..9bcbac0 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260331135119_AddGasPriceToRidesAndLookupCache.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260331135119_AddGasPriceToRidesAndLookupCache.cs @@ -1,4 +1,6 @@ using System; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -6,6 +8,8 @@ namespace BikeTracking.Api.Infrastructure.Persistence.Migrations { /// + [DbContext(typeof(BikeTrackingDbContext))] + [Migration("20260331135119_AddGasPriceToRidesAndLookupCache")] public partial class AddGasPriceToRidesAndLookupCache : Migration { /// diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx index e46d06f..f03c66d 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx @@ -379,7 +379,7 @@ describe('HistoryPage', () => { fireEvent.click(screen.getByRole('button', { name: /save/i })) await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/gas price must be greater than 0/i) + expect(screen.getByRole('alert')).toHaveTextContent(/gas price must be between 0.01 and 999.9999/i) expect(mockEditRide).not.toHaveBeenCalled() }) }) @@ -410,7 +410,7 @@ describe('HistoryPage', () => { date: '2026-01-01', pricePerGallon: 3.9999, isAvailable: true, - dataSource: 'EIA_EPM0_NUS_Weekly', + dataSource: 'Source: U.S. Energy Information Administration (EIA)', }) render() @@ -429,6 +429,9 @@ describe('HistoryPage', () => { name: /gas price/i, }) as HTMLInputElement expect(gasPriceInput.value).toBe('3.9999') + expect( + screen.getByText('Source: U.S. Energy Information Administration (EIA)') + ).toBeInTheDocument() }) }) diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx index 015e916..08c742b 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx @@ -20,12 +20,15 @@ import { } from './miles/history-page.helpers' import './HistoryPage.css' +const EIA_GAS_PRICE_SOURCE = 'Source: U.S. Energy Information Administration (EIA)' + function HistoryTable({ rides, editingRideId, editedRideDateTimeLocal, editedMiles, editedGasPrice, + editedGasPriceSource, onStartEdit, onEditedRideDateTimeLocalChange, onEditedMilesChange, @@ -39,6 +42,7 @@ function HistoryTable({ editedRideDateTimeLocal: string editedMiles: string editedGasPrice: string + editedGasPriceSource: string onStartEdit: (ride: RideHistoryRow) => void onEditedRideDateTimeLocalChange: (value: string) => void onEditedMilesChange: (value: string) => void @@ -113,6 +117,7 @@ function HistoryTable({ value={editedGasPrice} onChange={(event) => onEditedGasPriceChange(event.target.value)} /> + {editedGasPriceSource ?

{editedGasPriceSource}

: null}
) : ride.gasPricePerGallon != null ? ( `$${ride.gasPricePerGallon.toFixed(4)}` @@ -166,6 +171,7 @@ export function HistoryPage() { const [editedRideDateTimeLocal, setEditedRideDateTimeLocal] = useState('') const [editedMiles, setEditedMiles] = useState('') const [editedGasPrice, setEditedGasPrice] = useState('') + const [editedGasPriceSource, setEditedGasPriceSource] = useState('') const [ridePendingDelete, setRidePendingDelete] = useState(null) async function loadHistory(params: GetRideHistoryParams): Promise { @@ -214,6 +220,7 @@ export function HistoryPage() { setEditedRideDateTimeLocal('') setEditedMiles('') setEditedGasPrice('') + setEditedGasPriceSource('') } useEffect(() => { @@ -231,8 +238,12 @@ export function HistoryPage() { const lookup = await getGasPrice(dateOnly) if (lookup.isAvailable && lookup.pricePerGallon !== null) { setEditedGasPrice(lookup.pricePerGallon.toString()) + setEditedGasPriceSource(lookup.dataSource ?? EIA_GAS_PRICE_SOURCE) + } else { + setEditedGasPriceSource('') } } catch { + setEditedGasPriceSource('') // Keep current gas price value if lookup fails. } }, 300) @@ -291,6 +302,7 @@ export function HistoryPage() { setEditedRideDateTimeLocal('') setEditedMiles('') setEditedGasPrice('') + setEditedGasPriceSource('') await loadHistory({ from: fromDate || undefined, @@ -422,10 +434,14 @@ export function HistoryPage() { editedRideDateTimeLocal={editedRideDateTimeLocal} editedMiles={editedMiles} editedGasPrice={editedGasPrice} + editedGasPriceSource={editedGasPriceSource} onStartEdit={handleStartEdit} onEditedRideDateTimeLocalChange={setEditedRideDateTimeLocal} onEditedMilesChange={setEditedMiles} - onEditedGasPriceChange={setEditedGasPrice} + onEditedGasPriceChange={(value) => { + setEditedGasPrice(value) + setEditedGasPriceSource('') + }} onSaveEdit={(ride) => void handleSaveEdit(ride)} onCancelEdit={handleCancelEdit} onStartDelete={handleStartDelete} diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx index ca8ea76..59ca545 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx @@ -116,7 +116,7 @@ describe('RecordRidePage', () => { date: new Date().toISOString().slice(0, 10), pricePerGallon: 3.2222, isAvailable: true, - dataSource: 'EIA_EPM0_NUS_Weekly', + dataSource: 'Source: U.S. Energy Information Administration (EIA)', }) render( @@ -129,6 +129,9 @@ describe('RecordRidePage', () => { expect(mockGetGasPrice).toHaveBeenCalled() const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement expect(gasPriceInput.value).toBe('3.2222') + expect( + screen.getByText('Source: U.S. Energy Information Administration (EIA)') + ).toBeInTheDocument() }) }) diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx index 204cb62..cf1b8a7 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx @@ -7,12 +7,15 @@ import { getRideDefaults, } from '../services/ridesService' +const EIA_GAS_PRICE_SOURCE = 'Source: U.S. Energy Information Administration (EIA)' + export function RecordRidePage() { const [rideDateTimeLocal, setRideDateTimeLocal] = useState('') const [miles, setMiles] = useState('') const [rideMinutes, setRideMinutes] = useState('') const [temperature, setTemperature] = useState('') const [gasPrice, setGasPrice] = useState('') + const [gasPriceSource, setGasPriceSource] = useState('') const [quickRideOptions, setQuickRideOptions] = useState([]) const [loading, setLoading] = useState(true) @@ -56,8 +59,12 @@ export function RecordRidePage() { const lookup = await getGasPrice(today) if (lookup.isAvailable && lookup.pricePerGallon !== null) { setGasPrice(lookup.pricePerGallon.toString()) + setGasPriceSource(lookup.dataSource ?? EIA_GAS_PRICE_SOURCE) + } else { + setGasPriceSource('') } } catch (error) { + setGasPriceSource('') console.error('Failed to load gas price:', error) } } catch (error) { @@ -89,8 +96,12 @@ export function RecordRidePage() { const lookup = await getGasPrice(dateOnly) if (lookup.isAvailable && lookup.pricePerGallon !== null) { setGasPrice(lookup.pricePerGallon.toString()) + setGasPriceSource(lookup.dataSource ?? EIA_GAS_PRICE_SOURCE) + } else { + setGasPriceSource('') } } catch (error) { + setGasPriceSource('') // Keep the existing gas price value as fallback if lookup fails. console.error('Failed to refresh gas price for date change:', error) } @@ -155,6 +166,7 @@ export function RecordRidePage() { setRideMinutes('') setTemperature('') setGasPrice('') + setGasPriceSource('') setSuccessMessage('') }, 3000) } catch (error) { @@ -257,6 +269,7 @@ export function RecordRidePage() { value={gasPrice} onChange={(e) => { setGasPrice(e.target.value) + setGasPriceSource('') if (errorMessage.length > 0) { setErrorMessage('') } @@ -266,6 +279,7 @@ export function RecordRidePage() { setErrorMessage('Gas price must be greater than 0') }} /> + {gasPriceSource ?

{gasPriceSource}

: null}