From e4ee2148689d133c395945e936b27b91ee90fb41 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Thu, 27 Aug 2026 20:13:20 +0000 Subject: [PATCH 1/3] spec --- .specify/feature.json | 2 +- .../checklists/requirements.md | 36 +++++ specs/030-gas-price-grade-cache/spec.md | 126 ++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 specs/030-gas-price-grade-cache/checklists/requirements.md create mode 100644 specs/030-gas-price-grade-cache/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index 243a723..6829c62 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/029-co2-savings-dashboard" + "feature_directory": "specs/030-gas-price-grade-cache" } diff --git a/specs/030-gas-price-grade-cache/checklists/requirements.md b/specs/030-gas-price-grade-cache/checklists/requirements.md new file mode 100644 index 0000000..782edc3 --- /dev/null +++ b/specs/030-gas-price-grade-cache/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Gas Price Grade Selection & Cache Refresh Policy + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-27 +**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 +- [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 + +- No [NEEDS CLARIFICATION] markers were needed: reasonable defaults were available for the two open questions (default grade = "Regular"; 3-day duration interpreted as a rolling freshness window measured from retrieval time) and are documented in the Assumptions section. +- The "Current Behavior (Investigation Findings)" section is additional context (not part of the standard template) documenting the codebase audit performed before writing requirements, including the discrepancy that the existing cache has no expiry at all (not merely a wrong duration) and that the data source is "all grades," not "premium" or "regular unleaded" as previously documented. +- All items pass; spec is ready for `/speckit.clarify` (optional) or `/speckit.plan`. diff --git a/specs/030-gas-price-grade-cache/spec.md b/specs/030-gas-price-grade-cache/spec.md new file mode 100644 index 0000000..bc66d5b --- /dev/null +++ b/specs/030-gas-price-grade-cache/spec.md @@ -0,0 +1,126 @@ +# Feature Specification: Gas Price Grade Selection & Cache Refresh Policy + +**Feature Branch**: `030-gas-price-grade-cache` + +**Created**: 2026-08-27 + +**Status**: Draft + +**Input**: User description: "The current gas price lookup is always at premium and doesn't reflect my local gas price. Let the user choose regular or premium. Verify the current gas price lookup caching is correct and modify it to cache to every 3 days." + +## Current Behavior (Investigation Findings) + + + +- The gas price lookup calls the U.S. Energy Information Administration (EIA) Open Data API v2 using series `EMM_EPM0_PTE_NUS_DPG` — the **"All Grades, All Formulations"** national average, not a premium-specific series. There is no per-grade selection today; the system always returns the same all-grades average regardless of the grade the rider actually buys. The original feature spec (010-gas-price-lookup) describes this as "regular unleaded," which is itself inaccurate — the series used is an all-grades blend, not the regular-grade-only series. This explains why the displayed price can feel skewed toward premium-like (higher) values relative to a rider's local regular-grade price. +- Gas prices are cached durably in a SQLite table (`GasPriceLookups`), one row per calendar week (keyed by `WeekStartDate`, the Sunday of the ISO week containing the ride date). Once a row exists for a week, it is **never re-fetched or expired** — the cache is intentionally immutable/permanent by design (see `research.md` Decision 3 for feature 010: "Time-based cache expiry (TTL) — rejected: weekly prices don't change after publication; TTL adds complexity with no benefit"). +- **Discrepancy identified**: The request describes the current caching as needing correction to "every 3 days," implying an existing but wrong refresh interval. In fact, there is no refresh interval at all today — cached entries persist forever once written. This feature introduces the first time-based refresh policy for this cache; it is not a fix to a misconfigured existing duration. +- The cache today has no concept of "grade" — the table has one row per week, with a single `PricePerGallon` value and a single `DataSource`. Adding grade selection requires the cache key and stored data to become grade-aware, since regular and premium prices differ and must not overwrite one another. +- Gas price is fetched via `GET /api/rides/gas-price?date=YYYY-MM-DD` and displayed/editable on the ride creation and edit forms; the fetched value is only a pre-filled suggestion the rider can overwrite before saving. Per-user settings (`UserSettingsEntity`) already store rider-specific preferences (e.g., `EiaGasApiKey`, `LocationLabel`) and are exposed via the Settings page — an established pattern for adding a new rider-level preference such as a preferred gas grade. + +## Clarifications + +### Session 2026-08-27 + +- Q: What happens to existing pre-feature ungraded cache rows once grade becomes part of the cache key? → A: Leave existing rows as-is (untouched, unmatched by grade-aware queries); they become inert history. First lookup for any historical week+grade after deployment triggers a fresh external call. +- Q: Should the gas-price lookup endpoint derive the grade solely from the rider's saved settings preference, or also accept an optional explicit grade query parameter that overrides the saved preference? → A: Endpoint accepts an optional `grade` query parameter that, if present, overrides the rider's saved preference for that single call (e.g., for previews/testing); when omitted, the rider's saved preference is used. +- Q: For riders who already have a settings row before this feature ships, how should their gas grade preference be established? → A: Migration backfills the new column to "Premium" explicitly for all existing rider settings rows (column is non-nullable), matching the app's current de facto all-grades/premium-like pricing behavior, so existing riders' suggested prices don't silently change at deployment. The "Regular" default (FR-002) applies only to riders who set up their settings after this feature ships and have never made an explicit choice. +- Q: When two concurrent requests both find the same cached (week, grade) entry stale at the same time, how should the refresh be handled, given SC-003 requires no more than one external lookup per (week, grade) per 3-day window? → A: Serialize/de-duplicate concurrent stale-refresh attempts for the same (week, grade) so only one external call is made; other concurrent requests wait for or reuse that in-flight refresh's result. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Choose Preferred Gas Grade (Priority: P1) + +A rider who buys regular-grade gasoline currently sees a suggested gas price that reflects an all-grades national average, which runs higher than what they actually pay. The rider wants to set their preferred grade (regular or premium) once, so that every future ride's suggested gas price reflects prices for that grade instead of the current all-grades blend. + +**Why this priority**: This is the core complaint driving the feature — without grade selection, the suggested price remains inaccurate for the majority of riders (who buy regular), undermining trust in the auto-filled value and the fuel-cost-savings calculations that depend on it. + +**Independent Test**: Can be fully tested by setting a gas grade preference to "Regular" in Settings, then opening the ride creation form and confirming the suggested gas price is fetched using the regular-grade price series (verifiable by inspecting the stored `DataSource`/grade on the resulting cache entry), and separately confirming a "Premium" preference yields a fetched price from the premium-grade series. + +**Acceptance Scenarios**: + +1. **Given** a rider signs up (or otherwise gets a settings row created) after this feature ships and has not yet set a gas grade preference, **When** they open Settings, **Then** they see a gas grade selector defaulted to "Regular" (the most common grade, and the closest match to typical local pump prices). +1a. **Given** a rider's settings row already existed before this feature shipped, **When** the feature is deployed, **Then** their gas grade preference is backfilled to "Premium" (matching the app's historical all-grades/premium-like pricing behavior) rather than "Regular", so their suggested price does not silently change at deployment; they see this "Premium" value pre-selected in Settings until they change it. +2. **Given** a rider sets their gas grade preference to "Premium" and saves it, **When** they open the ride creation form for any date, **Then** the pre-filled gas price reflects the premium-grade national average price for that date. +3. **Given** a rider previously had rides recorded using one grade's price, **When** they change their gas grade preference, **Then** only future gas price lookups use the new grade — previously recorded rides' stored gas prices are not altered retroactively. +4. **Given** a rider changes their gas grade preference and then reopens a ride form for a date already cached under the old grade, **When** the form loads, **Then** the system fetches (or reuses a cache entry for) the price for the newly selected grade, not the old grade's cached price. + +--- + +### User Story 2 - Gas Price Cache Refreshes Every 3 Days (Priority: P2) + +An administrator/operator wants gas price data to stay reasonably current without hammering the external EIA API on every request. Today the cache never refreshes once a week's price is stored. This story introduces a 3-day refresh policy so that cached prices are re-validated periodically while still avoiding redundant external calls for the same short window. + +**Why this priority**: Correctness of the caching policy is important but secondary to the grade-selection fix — the cache already "works" in the sense of preventing duplicate calls; this story tightens the freshness guarantee, without which prices could theoretically go stale if the underlying EIA published a revision. It is independently valuable and independently testable regardless of whether grade selection ships. + +**Independent Test**: Can be fully tested by seeding a cache entry with a `RetrievedAtUtc` timestamp older than 3 days and confirming the next lookup for that date triggers a fresh external call and updates the cache entry, while seeding an entry retrieved less than 3 days ago confirms the cached value is reused without a new external call. + +**Acceptance Scenarios**: + +1. **Given** a cached gas price entry (for a given date/week and grade) was retrieved less than 3 days ago, **When** a rider requests the gas price for a covered date, **Then** the cached value is returned and no external API call is made. +2. **Given** a cached gas price entry was retrieved 3 or more days ago, **When** a rider requests the gas price for a covered date, **Then** the system performs a fresh external lookup, and — if the lookup succeeds — replaces the stale cache entry with the new price and a new retrieval timestamp. +3. **Given** a cached entry is stale (3+ days old) and the refresh attempt fails or the external service is unavailable, **When** the rider requests the price, **Then** the system falls back to returning the last known (stale) cached price rather than showing no price, consistent with the existing graceful-degradation behavior for gas price lookups. +4. **Given** the app is restarted, **When** a ride form is opened for a date with a cache entry younger than 3 days, **Then** the cached price is still used (the 3-day freshness window survives restarts, since it is measured from the durable `RetrievedAtUtc` timestamp). + +--- + +### Edge Cases + +- What happens if a rider has never set a gas grade preference and the system has no default configured? → The system defaults to "Regular" (see Assumptions). +- What happens if the external EIA API does not have a distinct series for the selected grade for the requested week? → The lookup fails gracefully for that grade the same way an all-grades lookup fails today (no price, no cache write), and the existing manual-entry/fallback behavior applies. +- What happens when two different grades are requested for the same week concurrently? → Each grade is cached independently; a concurrent duplicate request for the same date+grade follows the existing "insert races to a unique constraint, re-read on conflict" pattern already used for the current cache. +- What happens when two concurrent requests both find the same cached (week, grade) entry stale at the same moment? → Stale-refresh attempts for the same (week, grade) are serialized/de-duplicated so only one external call is made; concurrent requesters wait for or reuse that in-flight refresh's result instead of each independently calling the external API, preserving the SC-003 guarantee of at most one external lookup per (week, grade) per 3-day window. +- What happens to gas prices already recorded on existing rides when this feature ships? → They are left untouched; only future lookups are affected by grade selection and the 3-day refresh policy. +- What happens to pre-feature cache rows in the `GasPriceLookups` table that predate the grade column (i.e., ungraded historical rows) once grade becomes part of the cache key? → They are left as-is, untouched and unmatched by any grade-aware query; they become inert history rather than being migrated, deleted, or backfilled with an assumed grade. The first grade-aware lookup for any historical week (regardless of grade) after deployment finds no matching row and triggers a fresh external call, which then creates a new grade-keyed cache entry for that week. +- What happens if a stale cache entry's refresh returns a price for a different grade or an implausible value (e.g., zero or negative)? → The existing validation (reject non-positive prices) applies before overwriting the cache entry; the stale entry is kept if the refreshed value is invalid. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Riders MUST be able to set a preferred gas grade — "Regular" or "Premium" — as a personal setting, alongside their other rider-level preferences. +- **FR-002**: The system MUST default a rider's gas grade preference to "Regular" for any rider settings row created after this feature ships, until that rider explicitly changes it. +- **FR-002a**: For rider settings rows that already existed before this feature ships, the deployment migration MUST explicitly backfill the gas grade preference to "Premium" (not "Regular"), reflecting the all-grades/premium-like pricing the app has always shown; the gas grade preference column MUST be non-nullable after migration. +- **FR-003**: When fetching a gas price for a ride date, the system MUST use the rider's currently selected gas grade to determine which price series to request from the external gas price source. +- **FR-004**: The system MUST cache gas prices per distinct combination of price week and gas grade, so that regular and premium prices for the same week are stored and retrieved independently without overwriting one another. +- **FR-004a**: Pre-feature cache rows that predate grade-awareness (created before this feature shipped) MUST be left untouched and MUST NOT be migrated, deleted, or matched by any grade-aware lookup; they remain as inert historical rows. A grade-aware lookup for a week only previously cached without a grade MUST be treated as a cache miss and trigger a fresh external fetch, writing a new grade-keyed entry. +- **FR-005**: Changing a rider's gas grade preference MUST NOT modify gas prices already stored on previously recorded rides; it MUST only affect gas prices fetched for future lookups. +- **FR-006**: The system MUST treat a cached gas price entry as fresh for 3 days from the time it was retrieved, and MUST reuse it without an external call during that window. +- **FR-007**: The system MUST treat a cached gas price entry as stale once 3 days have elapsed since it was retrieved, and MUST attempt a fresh external lookup the next time that date/grade is requested. +- **FR-007a**: When multiple concurrent requests for the same (price week, grade) find the entry stale at the same time, the system MUST serialize or de-duplicate the refresh so only a single external lookup is performed; concurrent requesters MUST wait for or reuse that in-flight refresh's result rather than each independently calling the external source. +- **FR-008**: When a stale cache entry is successfully refreshed, the system MUST replace the stored price and update the retrieval timestamp, while preserving the price history behavior riders and reporting depend on (i.e., prior successfully-recorded ride prices remain unaffected, per FR-005). +- **FR-009**: When a refresh attempt for a stale cache entry fails (external service unavailable or returns no usable data), the system MUST continue to serve the last known cached price rather than returning no price, matching the existing degraded-mode behavior. +- **FR-010**: The system MUST continue to reject non-positive or otherwise invalid fetched prices, discarding them without overwriting an existing valid cache entry. +- **FR-011**: The gas price lookup endpoint and underlying service MUST expose/accept the grade being requested so the correct cached or freshly-fetched price is returned for the rider's selected grade. The endpoint MUST accept an optional `grade` query parameter that, when present, overrides the rider's saved gas grade preference for that single request (e.g., for previews or testing); when the parameter is omitted, the endpoint MUST use the rider's currently saved gas grade preference. + +### Key Entities *(include if feature involves data)* + +- **Gas Grade Preference**: A rider-level setting indicating which grade ("Regular" or "Premium") should be used for that rider's gas price lookups. Stored alongside existing rider settings (e.g., API keys, location). +- **Gas Price Lookup (Cache Entry)**: Represents a single gas price retrieved for a calendar week and a specific gas grade. Key attributes: price week start date, gas grade, price per gallon (decimal, USD), data source identifier, retrieval timestamp. Distinguishing attribute from the current design: previously one entry existed per week; going forward, one entry exists per (week, grade) pair, and each entry now has a defined freshness window (3 days) rather than being permanently immutable. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Riders who select "Regular" as their gas grade see a suggested gas price sourced from the regular-grade price series, distinct from the previous all-grades value, for 100% of new gas price lookups. +- **SC-002**: Riders can change their gas grade preference and see the new grade reflected in the next gas price suggestion within the same session, with no restart or additional action required. +- **SC-003**: No more than one external gas price lookup occurs per unique (price week, grade) combination within any 3-day window, verified across repeated ride-form loads for the same date and grade, including under concurrent requests (concurrent stale-refresh attempts for the same combination are de-duplicated to a single external call, per FR-007a). +- **SC-004**: A cached gas price older than 3 days is refreshed on next use in 100% of cases where the external data source is reachable and returns valid data. +- **SC-005**: When the external data source is unreachable during a refresh attempt, riders still see a previously cached price rather than a blank/unavailable price in 100% of observed cases. +- **SC-006**: Gas prices already stored on rides recorded before this feature ships remain unchanged after the feature is deployed. + +## Assumptions + +- Riders buy either regular or premium fuel; mid-grade is out of scope for this feature and can be added later following the same pattern if needed. +- "Regular" is a reasonable default grade preference for newly-created rider settings rows because it is the most commonly purchased grade and the closest approximation to typical local pump prices for most riders, addressing the core complaint without requiring upfront configuration. This default does not apply retroactively: riders whose settings row already existed before this feature shipped are migrated to an explicit "Premium" value (see FR-002a) to avoid silently changing their previously-experienced pricing at deployment. +- The external gas price data source (EIA Open Data API) publishes distinct weekly price series for regular and premium grades comparable in structure to the all-grades series currently used; if a directly equivalent series is unavailable, the closest available regular/premium series is used. +- "Cache every 3 days" is interpreted as a freshness/refresh window measured from each entry's retrieval timestamp (i.e., an entry is reused for up to 3 days, then eligible for refresh on next access), consistent with how the existing cache is read (lazy, on-demand) rather than via a scheduled background job. +- The 3-day freshness window applies uniformly regardless of gas grade. +- This feature does not change the existing behavior that a manually entered gas price on a ride form always overrides the fetched/cached suggestion when the rider submits the form. From d649868713308fa43a13af509dbc20d403cfe590 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Thu, 27 Aug 2026 20:33:45 +0000 Subject: [PATCH 2/3] tasks --- .github/copilot-instructions.md | 2 +- .../gas-price-grade-cache-contract.md | 117 +++++++++ specs/030-gas-price-grade-cache/data-model.md | 74 ++++++ specs/030-gas-price-grade-cache/plan.md | 108 +++++++++ specs/030-gas-price-grade-cache/quickstart.md | 79 +++++++ specs/030-gas-price-grade-cache/research.md | 45 ++++ specs/030-gas-price-grade-cache/tasks.md | 223 ++++++++++++++++++ 7 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 specs/030-gas-price-grade-cache/contracts/gas-price-grade-cache-contract.md create mode 100644 specs/030-gas-price-grade-cache/data-model.md create mode 100644 specs/030-gas-price-grade-cache/plan.md create mode 100644 specs/030-gas-price-grade-cache/quickstart.md create mode 100644 specs/030-gas-price-grade-cache/research.md create mode 100644 specs/030-gas-price-grade-cache/tasks.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3ffe223..4db9b72 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -137,5 +137,5 @@ From `src/BikeTracking.Frontend`: For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan: -[specs/029-co2-savings-dashboard/plan.md](../specs/029-co2-savings-dashboard/plan.md) +[specs/030-gas-price-grade-cache/plan.md](../specs/030-gas-price-grade-cache/plan.md) diff --git a/specs/030-gas-price-grade-cache/contracts/gas-price-grade-cache-contract.md b/specs/030-gas-price-grade-cache/contracts/gas-price-grade-cache-contract.md new file mode 100644 index 0000000..2c3ed61 --- /dev/null +++ b/specs/030-gas-price-grade-cache/contracts/gas-price-grade-cache-contract.md @@ -0,0 +1,117 @@ +# Contract: Gas Price Grade Selection & Cache Refresh Policy + +This feature modifies two existing endpoints/resources — it does not introduce a new endpoint. + +## 1. `GET /api/rides/gas-price` + +**Existing route, extended.** + +### Request + +| Parameter | Location | Type | Required | Notes | +|-----------|----------|------|----------|-------| +| `date` | query | `string` (`YYYY-MM-DD`) | yes | unchanged | +| `grade` | query | `string` (`"Regular"` \| `"Premium"`, case-insensitive) | **no (NEW)** | When present and valid, overrides the rider's saved `GasGrade` preference for this call only. When omitted, the rider's saved preference is used (or `"Regular"` if the rider has no settings row). | + +### Response `200 OK` — `GasPriceResponse` (extended) + +```json +{ + "date": "2026-08-24", + "pricePerGallon": 3.219, + "isAvailable": true, + "dataSource": "Source: U.S. Energy Information Administration (EIA)", + "grade": "Regular" +} +``` + +| Field | Type | Change | Notes | +|-------|------|--------|-------| +| `date` | `string` | unchanged | | +| `pricePerGallon` | `number \| null` | unchanged | | +| `isAvailable` | `boolean` | unchanged | | +| `dataSource` | `string \| null` | unchanged | | +| `grade` | `string` | **NEW** | The grade actually used to resolve this response (`"Regular"` or `"Premium"`) — either the `grade` query-param override or the rider's saved preference. Always present, even when `isAvailable` is `false`, so callers/tests can confirm which grade was attempted. | + +### Response `400 Bad Request` — `ErrorResponse` (extended condition) + +- Existing: missing/invalid `date` → `INVALID_REQUEST`. +- **New**: `grade` present but not `"Regular"`/`"Premium"` (case-insensitive) → `400 INVALID_REQUEST` with the same `ErrorResponse` shape, e.g.: + ```json + { "code": "INVALID_REQUEST", "message": "grade query parameter, if provided, must be 'Regular' or 'Premium'." } + ``` + +### Behavioral Rules + +1. Effective grade resolution order: `grade` query param (if valid) → rider's saved `UserSettingsEntity.GasGrade` → `"Regular"` (no-settings-row default). This mirrors the existing `apiKey` resolution precedent (`userSettings?.EiaGasApiKey` → app-config fallback) already in `GetGasPrice`. +2. Cache lookup/write always keys on `(WeekStartDate, effectiveGrade)` — never on `date`/`WeekStartDate` alone (FR-004). +3. A cached row younger than 3 days (`RetrievedAtUtc`) for the effective `(week, grade)` is returned without any external call (FR-006). +4. A cached row 3+ days old triggers a de-duplicated refresh attempt (FR-007/FR-007a); on success the new price/timestamp replace the row; on failure the prior stale price is still returned (FR-009). +5. A pre-feature legacy row (`Grade = NULL`) for the same `WeekStartDate` is never returned or matched — a lookup that only finds a legacy row is treated as a full cache miss and triggers a fresh external fetch (FR-004a). +6. Manually overriding `grade` via the query parameter never persists to `UserSettingsEntity.GasGrade` (FR-011 — "overrides... for that single request"). + +## 2. `GET /api/users/settings` and `PUT /api/users/settings` (existing settings endpoints backing `UserSettingsService`) + +> Route names as currently exposed by `UsersEndpoints`/equivalent; only the payload shape changes here. + +### `UserSettingsView` / `UserSettingsResponse` (GET) — extended + +```json +{ + "hasSettings": true, + "settings": { + "averageCarMpg": 32.5, + "yearlyGoalMiles": 3000, + "oilChangePrice": 45, + "mileageRateCents": 67, + "locationLabel": "Downtown", + "latitude": 39.1, + "longitude": -84.5, + "dashboardGallonsAvoidedEnabled": true, + "dashboardGoalProgressEnabled": true, + "updatedAtUtc": "2026-08-27T12:00:00Z", + "weatherApiKey": null, + "eiaGasApiKey": null, + "gasGrade": "Premium" + } +} +``` + +| Field | Type | Change | Notes | +|-------|------|--------|-------| +| `gasGrade` | `string` | **NEW** | Always present, always `"Regular"` or `"Premium"`. `"Regular"` for a rider with `hasSettings: false` (no row yet) or a post-feature settings row that has never set it explicitly; `"Premium"` for any rider whose settings row existed before this feature's migration ran. | + +### `UserSettingsUpsertRequest` (PUT) — extended + +```json +{ + "gasGrade": "Regular" +} +``` + +| Field | Type | Change | Notes | +|-------|------|--------|-------| +| `gasGrade` | `string?` | **NEW** | Optional on the wire, following the existing partial-update convention (`providedFields`). When provided, MUST be `"Regular"` or `"Premium"` (case handling left to the same validation approach used for other constrained fields) or the request is rejected with the existing `UsersErrorCodes.ValidationFailed` shape. When omitted, the rider's existing `GasGrade` is left unchanged. | + +### Behavioral Rules + +1. Changing `gasGrade` never retroactively alters gas prices already stored on previously recorded rides (FR-005) — it only affects the *next* `GET /api/rides/gas-price` call's default grade resolution. +2. The migration backfills `gasGrade = "Premium"` for every `UserSettings` row that exists at migration time; the application-level default of `"Regular"` applies only to rows created via `UserSettingsService` after the migration has run (FR-002 vs. FR-002a). + +## Consumer Rules for Spec #030 + +1. `RidesEndpointsTests` (backend) must assert: (a) omitting `grade` uses the rider's saved preference, (b) a valid `grade` override changes the returned `grade`/price series without persisting to settings, (c) an invalid `grade` value returns `400 INVALID_REQUEST`, (d) the response always includes `grade` even when `isAvailable` is `false`. +2. `GasPriceLookupServiceTests` (backend) must assert: (a) a fresh (`< 3 days`) cached row for `(week, grade)` is returned without an HTTP call, (b) a stale (`>= 3 days`) row triggers exactly one HTTP call even under simulated concurrent callers for the same `(week, grade)`, (c) a failed refresh returns the prior stale price rather than `null`, (d) a legacy `Grade = NULL` row is never returned for a grade-aware query and instead triggers a fresh fetch, (e) `Regular` and `Premium` requests for the same week produce two independent cache rows. +3. `UserSettingsServiceTests` (backend) must assert: (a) a rider with no settings row sees `gasGrade: "Regular"` as the read-side default, (b) saving `gasGrade` persists and round-trips, (c) an invalid `gasGrade` value is rejected. +4. Migration tests / manual verification must confirm all pre-existing `UserSettings` rows read `gasGrade: "Premium"` immediately after the migration runs, with no manual intervention. +5. Frontend `SettingsPage` tests must assert the grade selector renders, defaults per the above rules, and saves via `users-api.ts`. + +## Formula/Policy Requirements (Spec Source of Truth) + +- Freshness window: `now - RetrievedAtUtc < 3 days` ⇒ fresh (reuse, no external call); `>= 3 days` ⇒ stale (attempt refresh) (FR-006/FR-007). +- Cache key: `(WeekStartDate, Grade)`, `Grade ∈ {"Regular", "Premium"}` for all post-feature rows (FR-004). +- Concurrency: at most one external call per `(week, grade)` per staleness event (FR-007a/SC-003). +- Legacy rows (`Grade = NULL`): permanently inert, never matched, never migrated (FR-004a). +- Settings defaults: new rows → `"Regular"` (FR-002); pre-existing rows at migration time → `"Premium"` (FR-002a). + +Backend contracts (`RidesContracts.cs`, `UsersContracts.cs`) and frontend TypeScript models (`ridesService.ts`, `users-api.ts`) must stay synchronized for these new fields in the same change. diff --git a/specs/030-gas-price-grade-cache/data-model.md b/specs/030-gas-price-grade-cache/data-model.md new file mode 100644 index 0000000..1c4698f --- /dev/null +++ b/specs/030-gas-price-grade-cache/data-model.md @@ -0,0 +1,74 @@ +# Phase 1 Data Model: Gas Price Grade Selection & Cache Refresh Policy + +## Modified Entities + +### `GasPriceLookupEntity` (table: `GasPriceLookups`) + +| Field | Type | Change | Rules | +|-------|------|--------|-------| +| `GasPriceLookupId` | `int` (PK) | unchanged | identity | +| `PriceDate` | `DateOnly` | unchanged | required | +| `WeekStartDate` | `DateOnly` | unchanged | required; Sunday-of-ISO-week per `GasPriceWeekKeyHelper` | +| `Grade` | `string?` | **NEW** | nullable at DB level; `NULL` only ever appears on pre-feature legacy rows. Every row written by post-feature code sets this to the literal `"Regular"` or `"Premium"` — never `NULL`, never any other value. | +| `PricePerGallon` | `decimal` | unchanged | required, precision (10,4), must be `> 0` (existing validation) | +| `DataSource` | `string` | unchanged | required, max length 64 (existing value: `"EIA_EPM0_NUS_Weekly"` for legacy rows; new rows continue to use a source label reflecting the grade-specific series, e.g. `"EIA_EPMR_NUS_Weekly"` / `"EIA_EPMP_NUS_Weekly"`) | +| `EiaPeriodDate` | `DateOnly` | unchanged | required | +| `RetrievedAtUtc` | `DateTime` | unchanged (usage extended) | required; now also read (not just written) — drives the 3-day freshness check via `TimeProvider.GetUtcNow()` | + +**Index changes**: +- Remove: `HasIndex(x => x.PriceDate).IsUnique()` — **unchanged, kept as-is** (still present; grade does not change the price-date uniqueness expectation for a *given* week+grade... actually `PriceDate` unique index predates grade-awareness and is superseded — see note below). +- Remove: `HasIndex(x => x.WeekStartDate).IsUnique()`. +- Add: `HasIndex(x => new { x.WeekStartDate, x.Grade }).IsUnique()` — the new cache key. Because SQLite indexes treat each `NULL` as distinct, multiple legacy rows (`Grade = NULL`) for different (or even the same) `WeekStartDate` do not violate this unique index amongst themselves, and never collide with new graded rows. + + > **Note on the existing `PriceDate` unique index**: The current schema has *two* unique indexes (`PriceDate` and `WeekStartDate` independently), which is stricter than necessary now that the true cache key is `(WeekStartDate, Grade)` — a given `PriceDate` could legitimately need two rows (one per grade) once grade-awareness lands. The migration MUST drop the standalone unique index on `PriceDate` (replacing enforcement of "one row per priced day" with the new composite key), since keeping it would block writing both a Regular and a Premium row that happen to share the same representative `PriceDate` for a given lookup. `PriceDate` remains a required, non-unique column. + +**Validation/Rules**: +- `Grade`, when non-null, MUST be one of `"Regular"` / `"Premium"` (validated in `EiaGasPriceLookupService`, mirroring how `UserSettingsService` validates `GasGrade`). +- A row is considered **fresh** iff `TimeProvider.GetUtcNow().UtcDateTime - RetrievedAtUtc < TimeSpan.FromDays(3)`; **stale** otherwise (FR-006/FR-007). +- A stale row is never deleted outright — it is only replaced in-place (same `GasPriceLookupId`, updated `PricePerGallon`/`DataSource`/`EiaPeriodDate`/`RetrievedAtUtc`) when a refresh succeeds and returns a valid (`> 0`) price (FR-008/FR-010); on refresh failure, the stale row is returned unchanged (FR-009). + +### `UserSettingsEntity` (table: `UserSettings`) + +| Field | Type | Change | Rules | +|-------|------|--------|-------| +| `GasGrade` | `string` | **NEW** | non-nullable; allowed values `"Regular"` / `"Premium"`; CLR default `"Regular"` for newly-constructed rows (FR-002); pre-existing rows backfilled to `"Premium"` by the migration (FR-002a) | + +**New CHECK constraint** (mirroring existing `CK_UserSettings_*` pattern): +```sql +CK_UserSettings_GasGrade_Valid: "GasGrade" IN ('Regular', 'Premium') +``` + +**Validation/Rules**: +- `UserSettingsService.SaveAsync` treats `GasGrade` like other provided-fields-aware settings (only updated when explicitly included in `providedFields`, per the existing partial-update convention), rejecting any value outside `{"Regular", "Premium"}` with the existing validation-failure result shape (`UserSettingsResult`/`UsersErrorCodes.ValidationFailed`). +- `UserSettingsService.GetAsync` for a rider with no existing settings row continues to report `HasSettings: false`; the *view's* `GasGrade` in that no-row case is `"Regular"` (the FR-002 default), never `null` and never `"Premium"` (the `"Premium"` backfill only applies to rows that already existed at migration time — a rider who signs up after the feature ships and has never saved settings sees `"Regular"`). + +## Migration: `AddGasGradeAndCacheRefreshPolicy` (name illustrative; follow existing `yyyyMMddHHmmss_Description` convention) + +1. `AddColumn("Grade")` on `GasPriceLookups`, nullable, no default (legacy rows become `NULL`). +2. `DropIndex` on `GasPriceLookups.PriceDate` (unique) and `GasPriceLookups.WeekStartDate` (unique). +3. `CreateIndex` unique on `GasPriceLookups (WeekStartDate, Grade)`. +4. `AddColumn("GasGrade")` on `UserSettings`, non-nullable, with a migration-time default of `'Premium'` applied via the column-add default (or an explicit `UPDATE "UserSettings" SET "GasGrade" = 'Premium'` immediately after adding the column with a temporary default), so every row that existed before this migration ends up with `"Premium"` explicitly (FR-002a), and the column's ongoing application-level default for rows inserted afterward is `"Regular"` (enforced in `UserSettingsService`, not as a changing DB default, to avoid a second migration if the default logic is later revisited). +5. Add CHECK constraint `CK_UserSettings_GasGrade_Valid`. + +**Rollback consideration**: Down-migration removes the CHECK constraint, drops `GasGrade`, drops the composite unique index, drops `Grade`, and restores the two prior standalone unique indexes on `GasPriceLookups` — acceptable since this is a reversible schema change with no destructive data loss beyond the (already-inert) `Grade` values. + +## Contract Shape Changes + +### `GasPriceResponse` (`src/BikeTracking.Api/Contracts/RidesContracts.cs`) +- **Add**: `Grade: string` — the grade actually used for this lookup (resolved from the query-param override or the rider's saved preference), so the frontend/tests can confirm which grade produced the returned price, even when `IsAvailable` is `false`. + +### `GetGasPrice` endpoint (`GET /api/rides/gas-price`) +- **Add**: optional query parameter `grade` (`string`, `"Regular"` or `"Premium"`, case-insensitive). When present and valid, overrides the rider's saved `GasGrade` for this single call only (not persisted). When omitted, the rider's saved `UserSettingsEntity.GasGrade` is used (defaulting to `"Regular"` if the rider has no settings row at all — same default as Settings). An invalid `grade` value (anything other than `"Regular"`/`"Premium"`) returns the existing `400 INVALID_REQUEST` shape. + +### `UserSettingsUpsertRequest` / `UserSettingsView` (`src/BikeTracking.Api/Contracts/UsersContracts.cs`) +- **Add**: `GasGrade: string?` on both records, following the same optional/partial-update convention as `WeatherApiKey`/`EiaGasApiKey` (nullable on the wire; validated/defaulted server-side). + +## Presentation State Rules (Frontend) + +- `SettingsPage` renders a "Gas Grade" selector (e.g., a two-option radio group or ` setGasGrade(event.target.value as 'Regular' | 'Premium')} + > + + + + +
{ await expect(ridesService.recordRide(request)).rejects.toThrow(); }); + it("getGasPrice includes optional grade query parameter when provided", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + date: "2026-03-31", + pricePerGallon: 3.486, + isAvailable: true, + dataSource: "Source: U.S. Energy Information Administration (EIA)", + grade: "Premium", + }, + true, + ), + ); + + const result = await ridesService.getGasPrice("2026-03-31", "Premium"); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("/api/rides/gas-price"); + expect(url).toContain("date=2026-03-31"); + expect(url).toContain("grade=Premium"); + expect(result.grade).toBe("Premium"); + }); + it("should return ride presets from GET /api/rides/presets", async () => { const response = { presets: [ diff --git a/src/BikeTracking.Frontend/src/services/ridesService.ts b/src/BikeTracking.Frontend/src/services/ridesService.ts index 8410351..9190a02 100644 --- a/src/BikeTracking.Frontend/src/services/ridesService.ts +++ b/src/BikeTracking.Frontend/src/services/ridesService.ts @@ -50,6 +50,7 @@ export interface GasPriceResponse { pricePerGallon: number | null; isAvailable: boolean; dataSource: string | null; + grade: "Regular" | "Premium"; } export interface RideWeatherResponse { @@ -295,14 +296,19 @@ export async function recordRide( return response.json(); } -export async function getGasPrice(date: string): Promise { - const response = await fetch( - `${getApiBaseUrl()}/api/rides/gas-price?date=${encodeURIComponent(date)}`, - { - method: "GET", - headers: getAuthHeaders(), - }, - ); +export async function getGasPrice( + date: string, + grade?: "Regular" | "Premium", +): Promise { + const params = new URLSearchParams({ date }); + if (grade) { + params.set("grade", grade); + } + + const response = await fetch(`${getApiBaseUrl()}/api/rides/gas-price?${params.toString()}`, { + method: "GET", + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error( diff --git a/src/BikeTracking.Frontend/src/services/users-api.test.ts b/src/BikeTracking.Frontend/src/services/users-api.test.ts index 6573d59..df7fe8f 100644 --- a/src/BikeTracking.Frontend/src/services/users-api.test.ts +++ b/src/BikeTracking.Frontend/src/services/users-api.test.ts @@ -221,6 +221,44 @@ describe("users-api transport", () => { expect(result.ok).toBe(true); }); + it("saveUserSettings includes gasGrade in payload and response", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + hasSettings: true, + settings: { + averageCarMpg: 31.5, + yearlyGoalMiles: 1800, + oilChangePrice: 89.99, + mileageRateCents: 67.5, + locationLabel: null, + latitude: null, + longitude: null, + dashboardGallonsAvoidedEnabled: true, + dashboardGoalProgressEnabled: true, + weatherApiKey: null, + eiaGasApiKey: null, + gasGrade: "Premium", + updatedAtUtc: "2026-03-30T10:00:00Z", + }, + }, + 200, + ), + ); + + const result = await saveUserSettings({ gasGrade: "Premium" }); + + expect(fetchMock).toHaveBeenCalledWith( + `${url}/users/me/settings`, + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ gasGrade: "Premium" }), + }), + ); + expect(result.ok).toBe(true); + expect(result.data?.settings.gasGrade).toBe("Premium"); + }); + it("settings requests include auth header when session user exists", async () => { sessionStorage.setItem( "bike_tracking_auth_session", diff --git a/src/BikeTracking.Frontend/src/services/users-api.ts b/src/BikeTracking.Frontend/src/services/users-api.ts index d9af886..0cf5c1e 100644 --- a/src/BikeTracking.Frontend/src/services/users-api.ts +++ b/src/BikeTracking.Frontend/src/services/users-api.ts @@ -47,6 +47,7 @@ export interface UserSettingsUpsertRequest { dashboardGoalProgressEnabled?: boolean | null; weatherApiKey?: string | null; eiaGasApiKey?: string | null; + gasGrade?: "Regular" | "Premium" | null; } export interface UserSettingsView { @@ -62,6 +63,7 @@ export interface UserSettingsView { updatedAtUtc: string | null; weatherApiKey: string | null; eiaGasApiKey: string | null; + gasGrade: "Regular" | "Premium"; } export interface UserSettingsResponse { diff --git a/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts b/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts index e4e573a..4a55259 100644 --- a/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts @@ -30,4 +30,33 @@ test.describe("009-settings e2e", () => { await expect(page.locator("#averageCarMpg")).toHaveValue(""); await expect(page.locator("#yearlyGoalMiles")).toHaveValue(""); }); + + test("saving gas grade preference is reflected in ride-form gas lookup", async ({ + page, + }) => { + const rider = uniqueUser("e2e-settings-gas-grade"); + await createAndLoginUser(page, rider, TEST_PIN); + + await page.goto("/settings"); + await page.locator("#gasGrade").selectOption("Premium"); + await page.getByRole("button", { name: "Save Settings" }).click(); + await expect(page.getByText(/settings saved successfully/i)).toBeVisible(); + + await page.goto("/rides/record"); + const response = await page.waitForResponse((candidate) => + candidate.url().includes("/api/rides/gas-price"), + ); + + const payload = (await response.json()) as { + grade?: string; + isAvailable?: boolean; + pricePerGallon?: number | null; + }; + + expect(payload.grade).toBe("Premium"); + + if (payload.isAvailable && payload.pricePerGallon !== null) { + await expect(page.locator("#gasPrice")).toHaveValue(payload.pricePerGallon.toString()); + } + }); });