Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,10 @@ Thumbs.db
# Wrong lockfile: this is an npm repo (package-lock.json)
pnpm-lock.yaml
yarn.lock

# Docker and env artifacts
Dockerfile*
.dockerignore
*.log*
.env*
coverage/
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,5 +137,5 @@ From `src/BikeTracking.Frontend`:
<!-- SPECKIT START -->
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)
<!-- SPECKIT END -->
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/029-co2-savings-dashboard"
"feature_directory": "specs/030-gas-price-grade-cache"
}
36 changes: 36 additions & 0 deletions specs/030-gas-price-grade-cache/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions specs/030-gas-price-grade-cache/data-model.md
Original file line number Diff line number Diff line change
@@ -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<string>("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<string>("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 `<select>`: Regular / Premium) alongside the existing rider-level preferences (API keys, location), defaulting to whatever `UserSettingsResponse.Settings.GasGrade` returns (`"Regular"` for a rider with no settings row yet, `"Premium"` for a pre-existing rider whose settings were backfilled).
- `ridesService.getGasPrice` gains an optional `grade` parameter so a future preview/testing UI (or the ride form, if desired) can pass an explicit override; when omitted, the backend resolves the rider's saved preference — the frontend does not need to duplicate the default-resolution logic.
- Ride creation/edit forms continue to display the fetched price as a pre-filled, overridable suggestion (unchanged behavior); no new UI element is required on the ride form itself beyond continuing to call the existing gas-price endpoint (grade resolution happens server-side from the rider's saved setting).

## Relationship/Flow

`UserSettingsEntity.GasGrade` (rider preference, or `grade` query-param override) → `RidesEndpoints.GetGasPrice` (resolves effective grade) → `IGasPriceLookupService.GetOrFetchAsync(date, weekStart, grade, apiKey)` → cache read keyed by `(WeekStartDate, Grade)` → **fresh** (`< 3 days` old): return cached price as-is → **stale** (`>= 3 days` old) or **miss**: `GasPriceRefreshCoordinator` de-duplicates concurrent refreshes for the same `(week, grade)` key → EIA HTTP call using the grade-mapped product facet (`EPMR`/`EPMP`) → on success, upsert the `(WeekStartDate, Grade)` row and return the new price; on failure, return the prior stale value unchanged (or `null` if there was no prior cached row at all) → `GasPriceResponse` (including `Grade`) → frontend `getGasPrice` → ride form's pre-filled (overridable) gas price field.
Loading