diff --git a/.devcontainer/DEVCONTAINER.md b/.devcontainer/DEVCONTAINER.md index dabf4c7..5e08cfe 100644 --- a/.devcontainer/DEVCONTAINER.md +++ b/.devcontainer/DEVCONTAINER.md @@ -136,7 +136,8 @@ Note the devcontainer.json setup with }, "mounts": [ "source=${env:SSH_AUTH_SOCK},target=/ssh-agent,type=bind", - "source=/mnt/c/Users/klogan/.ssh,target=/root/.ssh-host,type=bind,readonly" + "source=${localEnv:HOME}/.ssh,target=/root/.ssh-host,type=bind,readonly", + "source=${localEnv:HOME}/.microsoft/usersecrets,target=/root/.microsoft/usersecrets,type=bind" ] ### Secrets Management @@ -144,6 +145,20 @@ Note the devcontainer.json setup with - Local development: Use .NET User Secrets or environment variables - CI/CD: Use GitHub repository secrets or Azure Key Vault - DevContainer environment variables in `devcontainer.json` are for non-sensitive values only +- Persist User Secrets in Dev Containers by bind-mounting your host user-secrets directory to `/root/.microsoft/usersecrets` + +Example mount paths by host OS: +- Linux/macOS host: `source=${localEnv:HOME}/.microsoft/usersecrets,target=/root/.microsoft/usersecrets,type=bind` +- Windows host: `source=${localEnv:APPDATA}/Microsoft/UserSecrets,target=/root/.microsoft/usersecrets,type=bind` + +If you use Docker Compose directly, the same mapping applies: + +```yaml +services: + api: + volumes: + - ${HOME}/.microsoft/usersecrets:/root/.microsoft/usersecrets +``` ## Advanced Customization @@ -180,7 +195,8 @@ Edit `mounts` array to add more host directories (e.g., for shared data): ```json "mounts": [ - "source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/root/.ssh,readonly", + "source=${localEnv:HOME}/.ssh,target=/root/.ssh-host,type=bind,readonly", + "source=${localEnv:HOME}/.microsoft/usersecrets,target=/root/.microsoft/usersecrets,type=bind", "source=/path/on/host,target=/path/in/container" ] ``` diff --git a/.devcontainer/devcontainer.Dockerfile b/.devcontainer/devcontainer.Dockerfile index b321197..a95ae31 100644 --- a/.devcontainer/devcontainer.Dockerfile +++ b/.devcontainer/devcontainer.Dockerfile @@ -17,7 +17,10 @@ RUN curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor > /usr/s && apt-get update \ && apt-get install -y --no-install-recommends podman nodejs \ && rm -rf /var/lib/apt/lists/* \ - && npm --version + && npm --version \ + # Security hardening: delay installing very new publishes and block lifecycle scripts by default. + && npm config set --global min-release-age 1440 \ + && npm config set --global ignore-scripts true # Ensure the SDK version from global.json is available in the image. RUN if dotnet --list-sdks | grep -q "^${REQUIRED_DOTNET_SDK_VERSION}"; then \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 47dafae..1f5d0a8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "Bike Tracking - Full Stack", + "name": "Bike Commuter Tracker - Full Stack", "build": { "dockerfile": "devcontainer.Dockerfile", "context": ".." @@ -70,6 +70,7 @@ "PATH": "${containerEnv:PATH}:/usr/local/share/dotnet-tools:/root/.dotnet/tools" }, "mounts": [ - "source=/mnt/c/Users/klogan/.ssh,target=/root/.ssh-host,type=bind,readonly" + "source=${localEnv:HOME}/.ssh,target=/root/.ssh-host,type=bind,readonly", + "source=/mnt/c/Users/klogan/AppData/Roaming/Microsoft/UserSecrets,target=/root/.microsoft/usersecrets,type=bind" ] } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3ae400d..5e24335 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,7 +50,7 @@ jobs: - name: Install frontend dependencies working-directory: src/BikeTracking.Frontend - run: npm ci + run: npm ci --ignore-scripts - name: Install Playwright browser and system dependencies working-directory: src/BikeTracking.Frontend diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 649802b..c6050e3 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -10,7 +10,8 @@ Modified Sections: - Compliance Audit Checklist: Added modular boundary and contract compatibility checks - Guardrails: Added non-negotiable interface/contract boundary rules for cross-module integration Status: Approved — modular architecture and contract-first parallel delivery are now constitutional requirements -Current Update (v1.12.2): Added mandatory spec-completion gate requiring database migrations to be applied and E2E tests to pass before a spec can be marked done. +Current Update (v1.12.3): Added mandatory per-migration test coverage governance requiring each migration to include a new or updated automated test, enforced by a migration coverage policy test in CI. +Previous Update (v1.12.2): Added mandatory spec-completion gate requiring database migrations to be applied and E2E tests to pass before a spec can be marked done. Previous Updates: - v1.11.0: Strengthened TDD mandate with a strict gated red-green-refactor workflow requiring explicit user confirmation of failing tests before implementation. - v1.10.2: Codified a mandatory post-change verification command matrix so every change runs explicit checks before merge. @@ -291,6 +292,7 @@ A vertical slice is **production-ready** only when all items are verified: - [ ] Post-change verification matrix executed for the impacted scope and evidence recorded - [ ] Feature branch deployed locally via `dotnet run` (entire Aspire stack: frontend, API, database) - [ ] Integration tests pass; manual E2E test via Playwright (if critical user journey) +- [ ] Every migration introduced by the slice includes a new or updated automated test and an updated migration coverage policy mapping entry - [ ] All validation layers implemented: client-side (React validation), API (DTO DataAnnotations), database (constraints) - [ ] Events stored in event table with correct schema; projections materialized and queryable - [ ] Module boundaries preserved; cross-module interactions occur only via approved interfaces/contracts with compatibility evidence @@ -362,6 +364,7 @@ Tests suggested by agent must receive explicit user approval before implementati **Database Tests** - Migration up/down transitions +- Migration coverage policy test must map every discovered migration to a new or updated automated test action - Event table constraints (unique EventId, non-null fields) - Foreign key integrity for aggregates - DataAnnotations constraints validated at database layer @@ -394,6 +397,7 @@ Tests suggested by agent must receive explicit user approval before implementati 14. **User Acceptance**: User validates slice meets specification and data validation rules observed 15. **Phase Completion Commit**: Before starting the next phase, create a dedicated phase-completion commit that includes completed tasks and verification evidence for that phase 16. **Spec Completion Gate**: Before marking any specification as done, database migrations for that spec must be applied successfully to the target local runtime database and the spec's end-to-end (Playwright) tests must run green +17. **Migration Test Coverage Gate**: Every migration added or modified in a branch must include a new or updated automated test and must be represented in the migration coverage policy test map before merge ### Compliance Audit Checklist @@ -410,6 +414,7 @@ Tests suggested by agent must receive explicit user approval before implementati - [ ] TDD gate commits created: red baseline commit, green commit, and separate refactor commit when applicable - [ ] Phase completion commit created before moving to the next phase - [ ] Database migrations for the spec are created and applied successfully to the runtime database used for validation +- [ ] Every migration introduced or modified by the spec has a corresponding new or updated automated test and a migration-coverage policy entry - [ ] Spec-level E2E (Playwright) suite executed and passing before spec marked complete - [ ] All SAMPLE_/DEMO_ data removed from code before merge - [ ] Secrets NOT committed; `.gitignore` verified; pre-commit hook prevents credential leakage @@ -445,6 +450,7 @@ Breaking these guarantees causes architectural decay and technical debt accrual: - **TDD cycle is strictly gated and non-negotiable** — implementation code must never be written before failing tests exist, have been run, and the user has reviewed and confirmed the failures. The sequence is always: plan tests → write tests → run and prove failure → get user confirmation → implement → run after each change → verify all pass → consider refactoring. Skipping or reordering any step is prohibited. - **Commit gates are mandatory for TDD and phase transitions** — every TDD gate transition requires a commit (red, green, and refactor when performed), and every completed phase requires a dedicated phase-completion commit before proceeding. - **Spec completion requires migration + E2E gates** — a spec cannot be marked done until its database migrations are applied to the runtime database and its Playwright E2E scenarios pass. +- **Every migration requires a test update** — each migration must ship with a new or updated automated test and an updated migration coverage policy entry; changes are blocked when migration coverage is incomplete. - **Expected-flow C# logic uses Result, not exceptions** — validation, not-found, conflict, and authorization business outcomes must be returned via typed Result objects (including error code/message metadata). Throwing exceptions for these expected outcomes is prohibited; exceptions are only for truly unexpected failures. - **Cross-module work is contract-first and interface-bound** — teams must integrate through explicit interfaces and versioned contracts only; direct coupling to another module's internal implementation is prohibited. - **No Entity Framework DbContext in domain layer** — domain must remain infrastructure-agnostic. If domain needs persistence logic, use repository pattern abstracting EF. 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/aspire.config.json b/aspire.config.json new file mode 100644 index 0000000..41a9f4e --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "src/BikeTracking.AppHost/BikeTracking.AppHost.csproj" + } +} \ No newline at end of file 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..2c7bc79 --- /dev/null +++ b/specs/010-gas-price-lookup/quickstart.md @@ -0,0 +1,96 @@ +# 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 (on your local machine), set it via .NET User Secrets: + +```bash +cd src/BikeTracking.Api +dotnet user-secrets set "GasPriceLookup:EiaApiKey" "YOUR_EIA_KEY_HERE" +``` + +If you run the API in a dev container, bind-mount your host User Secrets directory so secrets persist across container rebuilds: + +```yaml +# docker-compose.yml +services: + api: + volumes: + - ${HOME}/.microsoft/usersecrets:/root/.microsoft/usersecrets +``` + +Windows host path equivalent: + +```text +%APPDATA%\Microsoft\UserSecrets -> /root/.microsoft/usersecrets +``` + +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..4503cb0 --- /dev/null +++ b/specs/010-gas-price-lookup/research.md @@ -0,0 +1,122 @@ +# 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**: +Get a key at https://www.eia.gov/opendata/ + + +| 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..20e267a --- /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 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. + +--- + +### 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. +- **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)* + +### 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..39e0822 --- /dev/null +++ b/specs/010-gas-price-lookup/tasks.md @@ -0,0 +1,185 @@ +# 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. + +- [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) + +--- + +## 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. + +- [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. + +--- + +## 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. + +### 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` + +--- + +## 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. + +### 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` + +--- + +## 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. + +### 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` + +--- + +## 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). + +- [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. + +--- + +## Final Phase: Polish & Cross-Cutting Concerns + +**Purpose**: Validation completeness, formatting, and CI verification. + +- [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) + +--- + +## Dependencies + +``` +Phase 1 (T001–T002) + └─ Phase 2 (T003–T010) [T007, T008, T010 parallelizable within phase] + ├─ 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)**: +- 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)**: +- T027 + T028 (API backend) can run in parallel with T029 + T030 + T031 (frontend) +- T025 (backend tests) can run in parallel with T026 (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** (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/RidesEndpointsSqliteIntegrationTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs new file mode 100644 index 0000000..0727c94 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs @@ -0,0 +1,173 @@ +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; +using Microsoft.Extensions.Logging.Abstractions; + +namespace BikeTracking.Api.Tests.Endpoints; + +public sealed class RidesEndpointsSqliteIntegrationTests +{ + [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(); + + await SqliteMigrationBootstrapper.ApplyCompatibilityWorkaroundsAsync( + dbContext, + NullLogger.Instance + ); + + 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 85b699d..1619eec 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,102 @@ 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); + Assert.Equal("Source: U.S. Energy Information Administration (EIA)", payload.DataSource); + } + + [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 +434,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,8 +610,25 @@ public async Task PutEditRide_ThenGetHistory_ReturnsEditedMilesInRowsAndTotals() Assert.Equal(10.25m, payload.Summaries.AllTime.Miles); } + [Fact] + public async Task GetRideHistory_ContainsGasPricePerGallon() + { + 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(WebApplication app) : IAsyncDisposable { + public WebApplication App { get; } = app; public HttpClient Client { get; } = app.GetTestClient(); public static async Task StartAsync() @@ -483,6 +654,7 @@ public static async Task StartAsync() builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); var app = builder.Build(); app.UseAuthentication(); @@ -515,7 +687,8 @@ 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(); @@ -528,6 +701,7 @@ public async Task RecordRideAsync( Miles = miles, RideMinutes = rideMinutes, Temperature = temperature, + GasPricePerGallon = gasPricePerGallon, CreatedAtUtc = DateTime.UtcNow, }; @@ -622,3 +796,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.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs new file mode 100644 index 0000000..7207b82 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs @@ -0,0 +1,75 @@ +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BikeTracking.Api.Tests.Infrastructure; + +public sealed class MigrationTestCoveragePolicyTests +{ + private static readonly IReadOnlyDictionary MigrationVerificationLedger = + new Dictionary(StringComparer.Ordinal) + { + ["202603130001_InitialUserIdentity"] = + "Updated test: user endpoint/auth integration tests validate identity schema behavior.", + ["20260323134325_AddRidesTable"] = + "Added test: rides endpoint integration tests validate ride persistence and retrieval.", + ["20260327000000_AddRideVersion"] = + "Added test: edit ride endpoint tests validate optimistic concurrency version handling.", + ["20260327165005_AddRideMilesUpperBound"] = + "Updated test: rides validation tests enforce miles upper bound behavior.", + ["20260327171355_FixRideMilesUpperBoundNumericComparison"] = + "Updated test: rides validation tests verify numeric comparison semantics in SQLite-backed flows.", + ["20260330202303_AddUserSettingsTable"] = + "Added test: user settings endpoint integration tests validate persistence and retrieval contract.", + ["20260331135119_AddGasPriceToRidesAndLookupCache"] = + "Added test: SQLite endpoint integration tests validate gas price column retrieval after migration.", + }; + + [Fact] + public void EveryMigrationMustHaveCoverageLedgerEntry() + { + using var context = CreateContext(); + var discoveredMigrations = context + .GetService() + .Migrations.Keys.OrderBy(static id => id, StringComparer.Ordinal) + .ToArray(); + + var missing = discoveredMigrations.Except(MigrationVerificationLedger.Keys).ToArray(); + var stale = MigrationVerificationLedger.Keys.Except(discoveredMigrations).ToArray(); + + Assert.True( + missing.Length == 0, + $"Missing migration coverage entries: {string.Join(", ", missing)}" + ); + Assert.True( + stale.Length == 0, + $"Ledger contains non-existent migrations: {string.Join(", ", stale)}" + ); + } + + [Fact] + public void EveryCoverageEntryMustDeclareAddedOrUpdatedTestAction() + { + var invalid = MigrationVerificationLedger + .Where(static entry => + !entry.Value.StartsWith("Added test:", StringComparison.Ordinal) + && !entry.Value.StartsWith("Updated test:", StringComparison.Ordinal) + ) + .Select(static entry => entry.Key) + .ToArray(); + + Assert.True( + invalid.Length == 0, + $"Coverage entries must start with 'Added test:' or 'Updated test:'. Invalid entries: {string.Join(", ", invalid)}" + ); + } + + private static BikeTrackingDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + return new BikeTrackingDbContext(options); + } +} 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 + net10.0 enable enable + 9922aaf0-6b3e-4713-9f45-0c47abb144b3 diff --git a/src/BikeTracking.Api/Contracts/RidesContracts.cs b/src/BikeTracking.Api/Contracts/RidesContracts.cs index 18f5911..ab3defb 100644 --- a/src/BikeTracking.Api/Contracts/RidesContracts.cs +++ b/src/BikeTracking.Api/Contracts/RidesContracts.cs @@ -13,7 +13,9 @@ public sealed record RecordRideRequest( decimal Miles, [property: Range(1, int.MaxValue, ErrorMessage = "Ride minutes must be greater than 0")] int? RideMinutes = null, - decimal? Temperature = null + decimal? Temperature = null, + [property: Range(0.01, 999.9999, ErrorMessage = "Gas price must be between 0.01 and 999.9999")] + decimal? GasPricePerGallon = null ); public sealed record RecordRideSuccessResponse( @@ -28,7 +30,15 @@ public sealed record RideDefaultsResponse( DateTime DefaultRideDateTimeLocal, decimal? DefaultMiles = null, int? DefaultRideMinutes = null, - decimal? DefaultTemperature = null + decimal? DefaultTemperature = null, + decimal? DefaultGasPricePerGallon = null +); + +public sealed record GasPriceResponse( + string Date, + decimal? PricePerGallon, + bool IsAvailable, + string? DataSource ); public sealed record QuickRideOption(decimal Miles, int RideMinutes, DateTime LastUsedAtLocal); @@ -51,7 +61,13 @@ public sealed record EditRideRequest( int? RideMinutes, decimal? Temperature, [property: Range(1, int.MaxValue, ErrorMessage = "Expected version must be at least 1")] - int ExpectedVersion + int ExpectedVersion, + [property: Range( + 0.01, + 999.9999, + ErrorMessage = "Gas price must be greater than 0.01 and less than or equal to 999.9999" + )] + decimal? GasPricePerGallon = null ); public sealed record EditRideResponse(long RideId, int NewVersion, string Message); @@ -75,7 +91,8 @@ public sealed record RideHistoryRow( DateTime RideDateTimeLocal, decimal Miles, int? RideMinutes = null, - decimal? Temperature = null + decimal? Temperature = null, + decimal? GasPricePerGallon = null ); /// diff --git a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs index 29932a0..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"); @@ -27,6 +29,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 +145,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 ? EiaGasPriceSource : 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/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.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/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.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/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 new file mode 100644 index 0000000..d867c33 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260331135119_AddGasPriceToRidesAndLookupCache.cs @@ -0,0 +1,66 @@ +using System; +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("20260331135119_AddGasPriceToRidesAndLookupCache")] + 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/Infrastructure/Persistence/SqliteMigrationBootstrapper.cs b/src/BikeTracking.Api/Infrastructure/Persistence/SqliteMigrationBootstrapper.cs new file mode 100644 index 0000000..e45ef85 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/SqliteMigrationBootstrapper.cs @@ -0,0 +1,119 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace BikeTracking.Api.Infrastructure.Persistence; + +public static class SqliteMigrationBootstrapper +{ + private static readonly string[] UnsupportedConstraintMigrations = + [ + "20260327165005_AddRideMilesUpperBound", + "20260327171355_FixRideMilesUpperBoundNumericComparison", + ]; + + public static async Task ApplyCompatibilityWorkaroundsAsync( + BikeTrackingDbContext dbContext, + ILogger logger + ) + { + if (dbContext.Database.ProviderName != "Microsoft.EntityFrameworkCore.Sqlite") + { + return; + } + + await ClearStaleMigrationLockAsync(dbContext, logger); + + var applied = (await dbContext.Database.GetAppliedMigrationsAsync()).ToHashSet(); + var requiresSqliteWorkaround = UnsupportedConstraintMigrations.Any(migration => + !applied.Contains(migration) + ); + + if (!requiresSqliteWorkaround) + { + return; + } + + await dbContext.Database.MigrateAsync("20260327000000_AddRideVersion"); + + applied = (await dbContext.Database.GetAppliedMigrationsAsync()).ToHashSet(); + var productVersion = GetEfProductVersion(); + + foreach (var migration in UnsupportedConstraintMigrations) + { + if (applied.Contains(migration)) + { + continue; + } + + await dbContext.Database.ExecuteSqlRawAsync( + "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES ({0}, {1})", + migration, + productVersion + ); + } + } + + private static async Task ClearStaleMigrationLockAsync( + BikeTrackingDbContext dbContext, + ILogger logger + ) + { + var connection = dbContext.Database.GetDbConnection(); + var shouldCloseConnection = connection.State != System.Data.ConnectionState.Open; + + if (shouldCloseConnection) + { + await connection.OpenAsync(); + } + + int hasLockTable; + await using (var command = connection.CreateCommand()) + { + command.CommandText = + "SELECT COUNT(*) FROM \"sqlite_master\" WHERE \"name\" = '__EFMigrationsLock' AND \"type\" = 'table'"; + var scalar = await command.ExecuteScalarAsync(); + hasLockTable = Convert.ToInt32(scalar); + } + + if (shouldCloseConnection) + { + await connection.CloseAsync(); + } + + if (hasLockTable == 0) + { + return; + } + + // If a previous process crashed during migration, SQLite can retain a stale + // lock row and cause all future startups to wait forever acquiring the lock. + // Only remove lock rows older than 30 seconds to avoid interfering with + // legitimate in-progress migrations. + var clearedLockRows = await dbContext.Database.ExecuteSqlRawAsync( + "DELETE FROM \"__EFMigrationsLock\" WHERE \"Id\" = 1 AND \"Timestamp\" < datetime('now', '-30 seconds')" + ); + if (clearedLockRows > 0) + { + logger.LogWarning( + "Cleared {RowCount} stale EF migration lock row(s) before startup migration.", + clearedLockRows + ); + } + } + + private static string GetEfProductVersion() + { + var infoVersion = typeof(DbContext) + .Assembly.GetCustomAttribute() + ?.InformationalVersion; + + if (!string.IsNullOrWhiteSpace(infoVersion)) + { + var plusIndex = infoVersion.IndexOf('+'); + return plusIndex > 0 ? infoVersion[..plusIndex] : infoVersion; + } + + return typeof(DbContext).Assembly.GetName().Version?.ToString() ?? "unknown"; + } +} diff --git a/src/BikeTracking.Api/Program.cs b/src/BikeTracking.Api/Program.cs index b41f1b2..6bd0ccb 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(); @@ -97,6 +107,9 @@ await using (var scope = app.Services.CreateAsyncScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); + + await SqliteMigrationBootstrapper.ApplyCompatibilityWorkaroundsAsync(dbContext, app.Logger); + await dbContext.Database.MigrateAsync(); } 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.AppHost/AppHost.cs b/src/BikeTracking.AppHost/AppHost.cs index 891d757..2db7464 100644 --- a/src/BikeTracking.AppHost/AppHost.cs +++ b/src/BikeTracking.AppHost/AppHost.cs @@ -15,6 +15,20 @@ var webFrontend = builder .AddViteApp("frontend", "../BikeTracking.Frontend") + .WithEndpoint( + "http", + endpoint => + { + endpoint.Port = 5173; + endpoint.TargetPort = 5173; + // Disabling the proxy means Aspire's dashboard will not track requests for the frontend application, and you must ensure the configured port (default 5173) does not conflict with other local Vite instances + // After restart, the Aspire dashboard will show the frontend link pointing directly to http://localhost:5173 (no proxy). At that URL, the browser connects straight to the Vite dev server and the HMR WebSocket (ws://localhost:5173) will work correctly. + + // Why it won't work at the proxy port at all: Aspire's DCP proxy handles HTTP but does not upgrade connections to WebSockets, so the ws:// HMR handshake is silently dropped regardless of what HMR options you set on the Vite side. + // Required for HMR to work with default Aspire proxy + endpoint.IsProxied = false; + } + ) .WithReference(apiService) .WaitFor(apiService); diff --git a/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj b/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj index 7d95ca7..86dd11d 100644 --- a/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj +++ b/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe net10.0 @@ -12,8 +12,8 @@ - - + + diff --git a/src/BikeTracking.Frontend/.npmrc b/src/BikeTracking.Frontend/.npmrc new file mode 100644 index 0000000..1e29778 --- /dev/null +++ b/src/BikeTracking.Frontend/.npmrc @@ -0,0 +1,4 @@ +# Project-local npm hardening. +# This complements the container's global npm config and can be tuned per-repo. +min-release-age=3 +ignore-scripts=true \ No newline at end of file diff --git a/src/BikeTracking.Frontend/playwright-report/index.html b/src/BikeTracking.Frontend/playwright-report/index.html deleted file mode 100644 index 1289116..0000000 --- a/src/BikeTracking.Frontend/playwright-report/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx index 746526c..70c48c8 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,135 @@ 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 between 0.01 and 999.9999/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: 'Source: U.S. Energy Information Administration (EIA)', + }) + + 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') + // I moved this to a tooltip + // 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 c52421f..7c49acb 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 { @@ -15,21 +20,33 @@ import { } from './miles/history-page.helpers' import './HistoryPage.css' +const EIA_GAS_PRICE_SOURCE = 'Source: (EIA)' + function HistoryTable({ rides, editingRideId, + editedRideDateTimeLocal, editedMiles, + editedGasPrice, + editedGasPriceSource, onStartEdit, + onEditedRideDateTimeLocalChange, onEditedMilesChange, + onEditedGasPriceChange, onSaveEdit, onCancelEdit, onStartDelete, }: { rides: RideHistoryRow[] editingRideId: number | null + editedRideDateTimeLocal: string editedMiles: string + editedGasPrice: string + editedGasPriceSource: 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 @@ -42,17 +59,34 @@ function HistoryTable({ - + + {rides.map((ride) => ( - + +
DateDateddddd Miles Duration TemperatureGas Price Actions
{formatRideDate(ride.rideDateTimeLocal)} + {editingRideId === ride.rideId ? ( +
+ + + onEditedRideDateTimeLocalChange(event.target.value) + } + /> +
+ ) : ( + formatRideDate(ride.rideDateTimeLocal) + )} +
{editingRideId === ride.rideId ? (
@@ -71,6 +105,28 @@ function HistoryTable({
{formatRideDuration(ride.rideMinutes) || 'N/A'} {formatTemperature(ride.temperature) || 'N/A'} + {editingRideId === ride.rideId ? ( +
+
+ + {editedGasPriceSource ? ℹ️ : null} +
+ onEditedGasPriceChange(event.target.value)} + /> +
+ ) : ride.gasPricePerGallon != null ? ( + `$${ride.gasPricePerGallon.toFixed(4)}` + ) : ( + 'N/A' + )} +
{editingRideId === ride.rideId ? (
@@ -114,7 +170,10 @@ 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 [editedGasPriceSource, setEditedGasPriceSource] = useState('') const [ridePendingDelete, setRidePendingDelete] = useState(null) async function loadHistory(params: GetRideHistoryParams): Promise { @@ -151,14 +210,54 @@ 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('') + setEditedGasPriceSource('') } + 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()) + const source = lookup.dataSource ?? EIA_GAS_PRICE_SOURCE; + if (source.includes('EIA')) { + setEditedGasPriceSource(EIA_GAS_PRICE_SOURCE) + } else { + setEditedGasPriceSource(source.substring(0, 15)) + } + } else { + setEditedGasPriceSource('') + } + } catch { + setEditedGasPriceSource('') + // 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 +270,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 +306,10 @@ export function HistoryPage() { setError('') setEditingRideId(null) + setEditedRideDateTimeLocal('') setEditedMiles('') + setEditedGasPrice('') + setEditedGasPriceSource('') await loadHistory({ from: fromDate || undefined, @@ -322,9 +438,17 @@ export function HistoryPage() { { + 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 8cec693..59ca545 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,168 @@ 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: 'Source: U.S. Energy Information Administration (EIA)', + }) + + render( + + + + ) + + await waitFor(() => { + 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() + }) + }) + + 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..cf1b8a7 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx @@ -1,12 +1,21 @@ 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' + +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) @@ -41,6 +50,22 @@ 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()) + setGasPriceSource(lookup.dataSource ?? EIA_GAS_PRICE_SOURCE) + } else { + setGasPriceSource('') + } + } catch (error) { + setGasPriceSource('') + console.error('Failed to load gas price:', error) } } catch (error) { console.error('Failed to load defaults:', error) @@ -56,6 +81,35 @@ 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()) + 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) + } + }, 300) + + return () => clearTimeout(timerId) + }, [rideDateTimeLocal]) + const applyQuickRideOption = (option: QuickRideOption) => { setMiles(option.miles.toString()) setRideMinutes(option.rideMinutes.toString()) @@ -83,6 +137,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 +152,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 +165,8 @@ export function RecordRidePage() { setMiles('') setRideMinutes('') setTemperature('') + setGasPrice('') + setGasPriceSource('') setSuccessMessage('') }, 3000) } catch (error) { @@ -194,6 +259,29 @@ export function RecordRidePage() { />
+
+ + { + setGasPrice(e.target.value) + setGasPriceSource('') + if (errorMessage.length > 0) { + setErrorMessage('') + } + }} + onInvalid={(e) => { + e.preventDefault() + setErrorMessage('Gas price must be greater than 0') + }} + /> + {gasPriceSource ?

{gasPriceSource}

: null} +
+ 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/edit-ride-history.spec.ts b/src/BikeTracking.Frontend/tests/e2e/edit-ride-history.spec.ts index 20d59cc..c87457a 100644 --- a/src/BikeTracking.Frontend/tests/e2e/edit-ride-history.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/edit-ride-history.spec.ts @@ -11,9 +11,13 @@ test.describe("006-edit-ride-history e2e", () => { const userName = uniqueUser("e2e-edit-history"); await createAndLoginUser(page, userName, "87654321"); - // Record a ride + // Record a ride on a date that is always within the current month so the + // "This Month" mileage summary card reflects the seeded ride. + const now = new Date(); + const rideDateTimeLocal = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01T10:30`; + await recordRide(page, { - rideDateTimeLocal: "2026-03-20T10:30", + rideDateTimeLocal, miles: "5.0", rideMinutes: "30", temperature: "70", @@ -123,12 +127,19 @@ test.describe("006-edit-ride-history e2e", () => { test("shows summary cards with historical totals and active filter", async ({ page, }) => { - // Apply a date filter (from starting date to end of month) + // Apply a date filter spanning the current month so the seeded ride is visible. + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const lastDay = new Date(year, now.getMonth() + 1, 0).getDate(); + const fromDate = `${year}-${month}-01`; + const toDate = `${year}-${month}-${String(lastDay).padStart(2, "0")}`; + const fromInput = page.getByLabel(/^From$/i); const toInput = page.getByLabel(/^To$/i); - await fromInput.fill("2026-03-15"); - await toInput.fill("2026-03-31"); + await fromInput.fill(fromDate); + await toInput.fill(toDate); await page.getByRole("button", { name: /apply filter/i }).click(); // Summary cards and visible total should be visible 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(); } diff --git a/src/BikeTracking.Frontend/vite.config.ts b/src/BikeTracking.Frontend/vite.config.ts index a2fe8c6..8070be4 100644 --- a/src/BikeTracking.Frontend/vite.config.ts +++ b/src/BikeTracking.Frontend/vite.config.ts @@ -4,8 +4,12 @@ import react from "@vitejs/plugin-react"; // https://vite.dev/config/ export default defineConfig({ server: { + host: true, open: !process.env.CI, - port: 49363, + port: 5173, + hmr: { + overlay: true, // Show error overlay + }, }, plugins: [react()],