From bc9a027c9ad1d9e33d72e3e2b354b3f7a4431fc2 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Fri, 3 Apr 2026 20:39:59 +0000 Subject: [PATCH 1/4] implemented weather lookup --- .../checklists/requirements.md | 34 ++ .../contracts/api-contracts.md | 214 +++++++++ specs/011-ride-weather-data/data-model.md | 186 ++++++++ specs/011-ride-weather-data/plan.md | 186 ++++++++ specs/011-ride-weather-data/quickstart.md | 323 +++++++++++++ specs/011-ride-weather-data/research.md | 153 +++++++ specs/011-ride-weather-data/spec.md | 119 +++++ specs/011-ride-weather-data/tasks.md | 198 ++++++++ .../Rides/WeatherLookupServiceTests.cs | 232 ++++++++++ .../RidesApplicationServiceTests.cs | 284 +++++++++++- .../Rides/DeleteRideEndpointTests.cs | 11 + .../RidesEndpointsSqliteIntegrationTests.cs | 1 + .../Endpoints/RidesEndpointsTests.cs | 107 +++++ .../MigrationTestCoveragePolicyTests.cs | 4 + .../Events/RideEditedEventPayload.cs | 18 + .../Events/RideRecordedEventPayload.cs | 18 + .../Application/Rides/EditRideService.cs | 100 +++- .../Rides/GetRideDefaultsService.cs | 7 +- .../Rides/GetRideHistoryService.cs | 7 +- .../Application/Rides/RecordRideService.cs | 124 ++++- .../Application/Rides/WeatherLookupService.cs | 422 +++++++++++++++++ src/BikeTracking.Api/BikeTracking.Api.http | 37 ++ .../Contracts/RidesContracts.cs | 41 +- .../Persistence/BikeTrackingDbContext.cs | 35 ++ .../Persistence/Entities/RideEntity.cs | 12 + .../Entities/WeatherLookupEntity.cs | 30 ++ ...192400_AddWeatherFieldsToRides.Designer.cs | 426 ++++++++++++++++++ .../20260403192400_AddWeatherFieldsToRides.cs | 140 ++++++ ...03192854_AddWeatherLookupCache.Designer.cs | 426 ++++++++++++++++++ .../20260403192854_AddWeatherLookupCache.cs | 16 + .../BikeTrackingDbContextModelSnapshot.cs | 81 ++++ src/BikeTracking.Api/Program.cs | 8 + .../appsettings.Development.json | 3 + src/BikeTracking.Api/appsettings.json | 3 + .../src/pages/HistoryPage.test.tsx | 53 +++ .../src/pages/HistoryPage.tsx | 166 ++++++- .../src/pages/RecordRidePage.test.tsx | 59 +++ .../src/pages/RecordRidePage.tsx | 101 ++++- .../src/services/ridesService.ts | 23 + 39 files changed, 4366 insertions(+), 42 deletions(-) create mode 100644 specs/011-ride-weather-data/checklists/requirements.md create mode 100644 specs/011-ride-weather-data/contracts/api-contracts.md create mode 100644 specs/011-ride-weather-data/data-model.md create mode 100644 specs/011-ride-weather-data/plan.md create mode 100644 specs/011-ride-weather-data/quickstart.md create mode 100644 specs/011-ride-weather-data/research.md create mode 100644 specs/011-ride-weather-data/spec.md create mode 100644 specs/011-ride-weather-data/tasks.md create mode 100644 src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs create mode 100644 src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs create mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Entities/WeatherLookupEntity.cs create mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192400_AddWeatherFieldsToRides.Designer.cs create mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192400_AddWeatherFieldsToRides.cs create mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192854_AddWeatherLookupCache.Designer.cs create mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192854_AddWeatherLookupCache.cs diff --git a/specs/011-ride-weather-data/checklists/requirements.md b/specs/011-ride-weather-data/checklists/requirements.md new file mode 100644 index 0000000..7c9a190 --- /dev/null +++ b/specs/011-ride-weather-data/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Weather-Enriched Ride Entries + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-03 +**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 + +- Validation completed in one iteration. No unresolved issues. diff --git a/specs/011-ride-weather-data/contracts/api-contracts.md b/specs/011-ride-weather-data/contracts/api-contracts.md new file mode 100644 index 0000000..30735d7 --- /dev/null +++ b/specs/011-ride-weather-data/contracts/api-contracts.md @@ -0,0 +1,214 @@ +# API Contracts: Weather-Enriched Ride Entries + +**Feature**: 011-ride-weather-data +**Date**: 2026-04-03 +**Base path**: `/api/rides` +**Contract file**: `src/BikeTracking.Api/Contracts/RidesContracts.cs` + +--- + +## Modified Contracts + +### `RecordRideRequest` (extended) + +New optional fields added; all existing fields unchanged. + +```csharp +public sealed record RecordRideRequest( + // --- existing fields --- + [Required] DateTime RideDateTimeLocal, + [Required][Range(0.01, 200)] decimal Miles, + [Range(1, int.MaxValue)] int? RideMinutes = null, + decimal? Temperature = null, + [Range(0.01, 999.9999)] decimal? GasPricePerGallon = null, + // --- new weather fields --- + [Range(0, 500, ErrorMessage = "Wind speed must be between 0 and 500 mph")] + decimal? WindSpeedMph = null, + [Range(0, 360, ErrorMessage = "Wind direction must be between 0 and 360 degrees")] + int? WindDirectionDeg = null, + [Range(0, 100, ErrorMessage = "Relative humidity must be between 0 and 100")] + int? RelativeHumidityPercent = null, + [Range(0, 100, ErrorMessage = "Cloud cover must be between 0 and 100")] + int? CloudCoverPercent = null, + [MaxLength(50, ErrorMessage = "Precipitation type must be 50 characters or fewer")] + string? PrecipitationType = null, + bool WeatherUserOverridden = false +); +``` + +--- + +### `EditRideRequest` (extended) + +Same new fields added alongside existing fields. + +```csharp +public sealed record EditRideRequest( + // --- existing fields --- + [Required] DateTime RideDateTimeLocal, + [Required][Range(0.01, 200)] decimal Miles, + [Range(1, int.MaxValue)] int? RideMinutes, + decimal? Temperature, + [Required][Range(1, int.MaxValue)] int ExpectedVersion, + [Range(0.01, 999.9999)] decimal? GasPricePerGallon = null, + // --- new weather fields --- + [Range(0, 500)] decimal? WindSpeedMph = null, + [Range(0, 360)] int? WindDirectionDeg = null, + [Range(0, 100)] int? RelativeHumidityPercent = null, + [Range(0, 100)] int? CloudCoverPercent = null, + [MaxLength(50)] string? PrecipitationType = null, + bool WeatherUserOverridden = false +); +``` + +--- + +### `RideHistoryRow` (extended) + +New weather fields added to the read-model row for display in ride history. + +```csharp +public sealed record RideHistoryRow( + long RideId, + DateTime RideDateTimeLocal, + decimal Miles, + int? RideMinutes = null, + decimal? Temperature = null, + decimal? GasPricePerGallon = null, + // --- new weather fields --- + decimal? WindSpeedMph = null, + int? WindDirectionDeg = null, + int? RelativeHumidityPercent = null, + int? CloudCoverPercent = null, + string? PrecipitationType = null, + bool WeatherUserOverridden = false +); +``` + +--- + +### `RideDefaultsResponse` (extended) + +Pre-populates weather fields from the most recent ride so the ride form shows prior values as defaults. + +```csharp +public sealed record RideDefaultsResponse( + bool HasPreviousRide, + DateTime DefaultRideDateTimeLocal, + decimal? DefaultMiles = null, + int? DefaultRideMinutes = null, + decimal? DefaultTemperature = null, + decimal? DefaultGasPricePerGallon = null, + // --- new weather defaults --- + decimal? DefaultWindSpeedMph = null, + int? DefaultWindDirectionDeg = null, + int? DefaultRelativeHumidityPercent = null, + int? DefaultCloudCoverPercent = null, + string? DefaultPrecipitationType = null +); +``` + +--- + +## No New Endpoints + +This feature does **not** introduce a new weather API endpoint visible to the frontend. Weather +data is fetched server-side at save time inside `RecordRideService` and `EditRideService`. +The existing `GET /api/rides/gas-price` endpoint pattern is not replicated for weather because +the weather lookup is tightly coupled to save time and user location (which is server-held). + +--- + +## Frontend TypeScript Contracts + +File to extend: `src/BikeTracking.Frontend/src/` (locate existing ride service API types) + +### `RecordRideRequest` (TypeScript) + +```typescript +interface RecordRideRequest { + // existing + rideDateTimeLocal: string; // ISO 8601 + miles: number; + rideMinutes?: number; + temperature?: number; + gasPricePerGallon?: number; + // new weather fields + windSpeedMph?: number; + windDirectionDeg?: number; + relativeHumidityPercent?: number; + cloudCoverPercent?: number; + precipitationType?: string; + weatherUserOverridden?: boolean; // default false +} +``` + +### `EditRideRequest` (TypeScript) + +```typescript +interface EditRideRequest { + // existing + rideDateTimeLocal: string; + miles: number; + rideMinutes?: number; + temperature?: number; + expectedVersion: number; + gasPricePerGallon?: number; + // new weather fields + windSpeedMph?: number; + windDirectionDeg?: number; + relativeHumidityPercent?: number; + cloudCoverPercent?: number; + precipitationType?: string; + weatherUserOverridden?: boolean; +} +``` + +### `RideHistoryRow` (TypeScript) + +```typescript +interface RideHistoryRow { + // existing + rideId: number; + rideDateTimeLocal: string; + miles: number; + rideMinutes?: number; + temperature?: number; + gasPricePerGallon?: number; + // new weather fields + windSpeedMph?: number; + windDirectionDeg?: number; + relativeHumidityPercent?: number; + cloudCoverPercent?: number; + precipitationType?: string; + weatherUserOverridden?: boolean; +} +``` + +### `RideDefaultsResponse` (TypeScript) + +```typescript +interface RideDefaultsResponse { + // existing + hasPreviousRide: boolean; + defaultRideDateTimeLocal: string; + defaultMiles?: number; + defaultRideMinutes?: number; + defaultTemperature?: number; + defaultGasPricePerGallon?: number; + // new weather defaults + defaultWindSpeedMph?: number; + defaultWindDirectionDeg?: number; + defaultRelativeHumidityPercent?: number; + defaultCloudCoverPercent?: number; + defaultPrecipitationType?: string; +} +``` + +--- + +## Backwards Compatibility + +All new fields are optional with null/false defaults. Existing API callers that omit weather +fields will behave exactly as before — the server will attempt to auto-fill weather from the +API and store the result. No breaking changes to existing endpoints. diff --git a/specs/011-ride-weather-data/data-model.md b/specs/011-ride-weather-data/data-model.md new file mode 100644 index 0000000..99b7435 --- /dev/null +++ b/specs/011-ride-weather-data/data-model.md @@ -0,0 +1,186 @@ +# Data Model: Weather-Enriched Ride Entries + +**Feature**: 011-ride-weather-data +**Date**: 2026-04-03 +**Status**: Complete + +--- + +## Overview + +This feature requires two schema changes: +1. **Extend `RideEntity`** with five new nullable weather fields and a `WeatherUserOverridden` flag. +2. **Add `WeatherLookupEntity`** as a new table to cache weather API results keyed by + hour-bucket + location, following the exact same pattern as `GasPriceLookupEntity`. + +--- + +## Existing Entity: `RideEntity` (extensions only) + +File: `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` + +**Existing fields** (unchanged): + +| Field | Type | Notes | +|-------|------|-------| +| `Id` | `int` | PK | +| `RiderId` | `long` | FK to user | +| `RideDateTimeLocal` | `DateTime` | Ride time in local time | +| `Miles` | `decimal` | Required | +| `RideMinutes` | `int?` | Optional | +| `Temperature` | `decimal?` | Already exists; single value in °F | +| `GasPricePerGallon` | `decimal?` | Already exists | +| `Version` | `int` | Optimistic concurrency | +| `CreatedAtUtc` | `DateTime` | Audit | + +**New fields** (additive, all nullable): + +| Field | Type | DB Column | Notes | +|-------|------|-----------|-------| +| `WindSpeedMph` | `decimal?` | `WindSpeedMph` | mph, from Open-Meteo `wind_speed_10m` (converted) | +| `WindDirectionDeg` | `int?` | `WindDirectionDeg` | 0–360 degrees, from `wind_direction_10m` | +| `RelativeHumidityPercent` | `int?` | `RelativeHumidityPercent` | 0–100%, from `relative_humidity_2m` | +| `CloudCoverPercent` | `int?` | `CloudCoverPercent` | 0–100%, from `cloud_cover` | +| `PrecipitationType` | `string?` | `PrecipitationType` | `"rain"`, `"snow"`, `"freezing_rain"`, or null | +| `WeatherUserOverridden` | `bool` | `WeatherUserOverridden` | Default `false`; `true` when user explicitly submitted weather fields | + +**Migration approach**: New EF Core migration adding six columns to the `Rides` table. All new +columns nullable (or default `false` for `WeatherUserOverridden`) — no data loss or backfill +required. Existing rides will have nulls for new fields. + +--- + +## New Entity: `WeatherLookupEntity` + +File: `src/BikeTracking.Api/Infrastructure/Persistence/Entities/WeatherLookupEntity.cs` + +Mirrors the shape of `GasPriceLookupEntity`. One row per unique `(LookupHourUtc, LatitudeRounded, LongitudeRounded)` key. + +| Field | Type | DB Column | Notes | +|-------|------|-----------|-------| +| `WeatherLookupId` | `int` | `WeatherLookupId` | PK, auto-increment | +| `LookupHourUtc` | `DateTime` | `LookupHourUtc` | Ride time in UTC, truncated to whole hour | +| `LatitudeRounded` | `decimal` | `LatitudeRounded` | User lat rounded to 2 decimal places | +| `LongitudeRounded` | `decimal` | `LongitudeRounded` | User lon rounded to 2 decimal places | +| `Temperature` | `decimal?` | `Temperature` | °F | +| `WindSpeedMph` | `decimal?` | `WindSpeedMph` | mph | +| `WindDirectionDeg` | `int?` | `WindDirectionDeg` | 0–360° | +| `RelativeHumidityPercent` | `int?` | `RelativeHumidityPercent` | 0–100% | +| `CloudCoverPercent` | `int?` | `CloudCoverPercent` | 0–100% | +| `PrecipitationType` | `string?` | `PrecipitationType` | `"rain"`, `"snow"`, `"freezing_rain"`, or null | +| `DataSource` | `string` | `DataSource` | e.g. `"OpenMeteo_Forecast"` or `"OpenMeteo_Archive"` | +| `RetrievedAtUtc` | `DateTime` | `RetrievedAtUtc` | When the API call was made | +| `Status` | `string` | `Status` | `"success"`, `"partial"`, `"unavailable"`, `"error"` | + +**Unique constraint**: `(LookupHourUtc, LatitudeRounded, LongitudeRounded)` — prevents duplicate +cache entries; handles concurrent inserts (same as `GasPriceLookupEntity` pattern, catch +`DbUpdateException` and re-read on conflict). + +**Migration approach**: New EF Core migration creating the `WeatherLookups` table with the unique +constraint index. + +--- + +## New Service Interface: `IWeatherLookupService` + +File: `src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs` + +```text +interface IWeatherLookupService + GetOrFetchAsync( + DateTime rideTimeLocal, ← ride's local DateTime + decimal latitude, + decimal longitude, + CancellationToken + ) → Task ← null on unavailable/error +``` + +**`WeatherSnapshot` value type** (C# record, same file): + +```text +record WeatherSnapshot( + decimal? Temperature, + decimal? WindSpeedMph, + int? WindDirectionDeg, + int? RelativeHumidityPercent, + int? CloudCoverPercent, + string? PrecipitationType +) +``` + +**Internal logic**: +1. Convert `rideTimeLocal` → UTC; truncate to hour → `lookupHourUtc` +2. Round lat/lon to 2 dp → `latR`, `lonR` +3. Query `WeatherLookups` for `(lookupHourUtc, latR, lonR)` → return cached snapshot if hit +4. If miss: determine endpoint (forecast if within 92 days; archive otherwise) +5. Call Open-Meteo; parse hourly index for `lookupHourUtc`; derive precipitation type from WMO code +6. Persist `WeatherLookupEntity`; handle concurrent insert +7. Return snapshot (or null on any error) + +--- + +## Updated Event Payloads + +Both `RideRecordedEventPayload` and `RideEditedEventPayload` gain the same new fields: + +| New field | Type | Source | +|-----------|------|--------| +| `WindSpeedMph` | `decimal?` | from merged weather snapshot | +| `WindDirectionDeg` | `int?` | from merged weather snapshot | +| `RelativeHumidityPercent` | `int?` | from merged weather snapshot | +| `CloudCoverPercent` | `int?` | from merged weather snapshot | +| `PrecipitationType` | `string?` | from merged weather snapshot | +| `WeatherUserOverridden` | `bool` | from request flag | + +**Backwards compatibility**: All new fields added as optional parameters with defaults in the +`Create(…)` factory methods. Existing unit tests can continue calling `Create(…)` without +supplying weather fields. + +--- + +## DbContext Changes + +`BikeTrackingDbContext` gains: +```text +DbSet WeatherLookups +``` + +With `OnModelCreating` configuration: +- Unique index on `(LookupHourUtc, LatitudeRounded, LongitudeRounded)` +- `Status` and `DataSource` as `varchar(50)` / `varchar(100)` + +--- + +## State Transitions + +``` +RideCreateRequest received + → Server reads UserSettingsEntity (lat, lon) + → If lat/lon present AND WeatherUserOverridden = false: + → WeatherLookupService.GetOrFetchAsync(rideTime, lat, lon) + → Merge: non-null user fields win; null fields use API values + → RideEntity saved with merged weather fields + → RideRecordedEventPayload created with merged weather fields → outbox +``` + +``` +RideEditRequest received + → If RideDateTimeLocal changed from stored value AND WeatherUserOverridden = false: + → WeatherLookupService.GetOrFetchAsync(newRideTime, lat, lon) + → Merge submitted fields + API fields + → Else (time unchanged): use submitted values as-is + → RideEntity updated; RideEditedEventPayload → outbox +``` + +--- + +## Validation Rules + +| Field | Rule | Where enforced | +|-------|------|----------------| +| `WindSpeedMph` | `>= 0` if provided | API DTO `[Range]`; DB CHECK | +| `WindDirectionDeg` | `0–360` if provided | API DTO `[Range]`; DB CHECK | +| `RelativeHumidityPercent` | `0–100` if provided | API DTO `[Range]`; DB CHECK | +| `CloudCoverPercent` | `0–100` if provided | API DTO `[Range]`; DB CHECK | +| `PrecipitationType` | max 50 chars enum-like string | API DTO `[MaxLength]` | +| All weather fields | nullable; ride save not blocked by null | all layers | diff --git a/specs/011-ride-weather-data/plan.md b/specs/011-ride-weather-data/plan.md new file mode 100644 index 0000000..ebd96ac --- /dev/null +++ b/specs/011-ride-weather-data/plan.md @@ -0,0 +1,186 @@ +# Implementation Plan: Weather-Enriched Ride Entries + +**Branch**: `011-ride-weather-data` | **Date**: 2026-04-03 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/011-ride-weather-data/spec.md` + +## Summary + +Automatically capture weather conditions (temperature, wind speed/direction, humidity, cloud +cover, precipitation type) at the time of ride creation/edit by calling the Open-Meteo free API +server-side at save time. Results are cached by 1-hour bucket + user-configured location in a +new `WeatherLookups` SQLite table. New weather fields are added to `RideEntity`, both event +payloads, and all ride request/response contracts. Users can override any auto-fetched value; +ride saves always succeed even when weather data is unavailable. + +## Technical Context + +**Language/Version**: C# .NET 10 (API layer); F# .NET 10 (domain layer — no changes this feature); TypeScript/React 19 (frontend) +**Primary Dependencies**: .NET 10 Minimal API, Entity Framework Core (SQLite), Microsoft Aspire, React 19 + Vite, Open-Meteo REST API (no key required) +**Storage**: SQLite local file via EF Core; new `WeatherLookups` table; new columns on `Rides` table +**Testing**: xUnit (backend unit + integration), Vitest (frontend unit), Playwright (E2E) +**Target Platform**: Local user machine (Windows/macOS/Linux); DevContainer for development +**Project Type**: Local-first desktop web app (Aspire-orchestrated frontend + Minimal API + SQLite) +**Performance Goals**: Ride save with cold weather cache miss ≤ 5 s end-to-end; warm cache hit adds ~0 ms; API response time ≤ 500 ms p95 for all other ride endpoints (weather fetch is a background concern) +**Constraints**: No API key required for default operation; weather fetch must not block ride save on error or timeout; all new DB columns nullable; SQLite-compatible schema changes only +**Scale/Scope**: Single-user local deployment; ~5–50 rides/month expected volume; cache table expected to hold hundreds to low thousands of entries + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Gate | Status | Notes | +|------|--------|-------| +| Clean Architecture / Domain-Driven Design | ✅ PASS | Weather lookup service follows same layering as `GasPriceLookupService`; domain layer (F#) unchanged | +| Functional Programming (pure/impure sandwich) | ✅ PASS | `WeatherLookupService` is impure I/O at boundary; merge logic is a pure function; no side effects in domain | +| Event Sourcing & CQRS | ✅ PASS | Weather fields stored in `RideRecordedEventPayload` and `RideEditedEventPayload`; immutable append-only events preserved | +| Quality-First / TDD | ✅ PASS | Test plan defined in quickstart.md; red-green-refactor cycle required; 10 unit tests + 4 integration tests + 4 E2E tests identified | +| UX Consistency & Accessibility | ✅ PASS | New optional form fields follow existing ride form patterns; all nullable; no blocking UX | +| Performance / Observability | ✅ PASS | 5 s timeout on weather HTTP client; OpenTelemetry auto-captures outbound HTTP calls via Aspire | +| Data Validation & Integrity | ✅ PASS | `[Range]` and `[MaxLength]` on new DTO fields; DB CHECK constraints via EF Core; all nullable at DB layer | +| Experimentation / Security | ✅ PASS | API key never exposed to browser (FR-001b); key stored in configuration not in source; no secrets committed | +| Modularity / Contract-First | ✅ PASS | `IWeatherLookupService` interface defined before implementation; contracts documented; no direct coupling to Open-Meteo HTTP details from services | +| TDD Mandate (mandatory gate) | ✅ PASS | Failing tests must be written and user-confirmed before implementation begins; event payload `Create()` optional params are backwards-compatible | +| Migration test coverage policy | ✅ PASS | Two new migrations → two entries required in `MigrationTestCoveragePolicyTests.cs` | +| Spec completion gate | ✅ PASS | Migrations applied + E2E tests pass before spec marked done | + +**Constitution Check post-design**: No violations. All principles satisfied by the additive, +pattern-consistent approach (extending `GasPriceLookupService` pattern; new nullable columns; +optional event payload params; `IWeatherLookupService` interface boundary). + +## Project Structure + +### Documentation (this feature) + +```text +specs/011-ride-weather-data/ +├── plan.md ← this file +├── research.md ← weather API choice, caching strategy, merge logic +├── data-model.md ← entity changes, new WeatherLookupEntity, state transitions +├── quickstart.md ← implementation order, test plan, Open-Meteo reference +├── contracts/ +│ └── api-contracts.md ← C# and TypeScript contract diffs +└── tasks.md ← generated by /speckit.tasks (not yet created) +``` + +### Source Code Layout + +```text +src/BikeTracking.Api/ +├── Application/ +│ ├── Events/ +│ │ ├── RideRecordedEventPayload.cs ← extend: add 6 weather params to Create() +│ │ └── RideEditedEventPayload.cs ← extend: same +│ └── Rides/ +│ ├── WeatherLookupService.cs ← NEW: IWeatherLookupService + OpenMeteoWeatherLookupService +│ ├── RecordRideService.cs ← extend: inject IWeatherLookupService; merge weather +│ ├── EditRideService.cs ← extend: inject IWeatherLookupService; re-fetch on time change +│ ├── GetRideHistoryService.cs ← extend: map new weather columns to RideHistoryRow +│ └── GetRideDefaultsService.cs ← extend: map weather fields to RideDefaultsResponse +├── Contracts/ +│ └── RidesContracts.cs ← extend: 6 new fields on RecordRideRequest, EditRideRequest, +│ RideHistoryRow, RideDefaultsResponse +├── Infrastructure/ +│ └── Persistence/ +│ ├── Entities/ +│ │ ├── RideEntity.cs ← extend: add 6 new nullable fields +│ │ └── WeatherLookupEntity.cs ← NEW entity +│ ├── Migrations/ +│ │ ├── {timestamp}_AddWeatherFieldsToRides.cs ← NEW migration +│ │ └── {timestamp}_AddWeatherLookupCache.cs ← NEW migration +│ └── BikeTrackingDbContext.cs ← add DbSet + unique index +└── Program.cs ← register IWeatherLookupService + new HttpClient + +src/BikeTracking.Api.Tests/ +├── Application/ +│ ├── Rides/ +│ │ └── WeatherLookupServiceTests.cs ← NEW: 10 unit tests +│ └── RidesApplicationServiceTests.cs ← extend: 8 new weather integration unit tests +└── Infrastructure/ + ├── RidesPersistenceTests.cs ← extend: weather field round-trip tests + └── MigrationTestCoveragePolicyTests.cs ← extend: 2 new migration entries + +src/BikeTracking.Frontend/src/ +├── (locate RecordRide / CreateRide component) ← extend: add 6 weather fields +├── (locate EditRide component) ← extend: pre-populate + editable weather fields +├── (locate ride service / API types) ← extend: TypeScript interfaces +└── (locate RideHistoryRow component) ← extend: display weather fields +``` + +**Structure Decision**: Web application (Option 2 equivalent) — Aspire-orchestrated React +frontend + .NET Minimal API backend. All backend changes in `src/BikeTracking.Api/`; all +frontend changes in `src/BikeTracking.Frontend/src/`. No new projects added. + +## Implementation Phases + +### Phase 1 — Backend (Schema + Service + Integration) + +**Slice 1.1 — Schema** (minimal first: just the DB changes) +- Create `WeatherLookupEntity` + configure in `BikeTrackingDbContext` +- Add 6 weather columns + `WeatherUserOverridden` to `RideEntity` +- Generate and verify two EF Core migrations +- Add migration coverage policy entries +- **Tests**: migration apply test; entity round-trip tests (write & confirm red → implement → green) + +**Slice 1.2 — Weather Lookup Service** (isolated, no ride changes yet) +- Implement `IWeatherLookupService` + `OpenMeteoWeatherLookupService` +- Register in `Program.cs` with named `HttpClient` (5 s timeout) +- **Tests**: 10 unit tests for cache hit/miss, API routing, precip mapping, error handling + (write & confirm red → implement → green) + +**Slice 1.3 — Event Payload Extensions** (backwards-compatible) +- Add optional weather params to `RideRecordedEventPayload.Create()` and `RideEditedEventPayload.Create()` +- **Tests**: verify existing payload tests still pass (no new tests needed — optional params only) + +**Slice 1.4 — Contract Extensions** (additive) +- Extend `RecordRideRequest`, `EditRideRequest`, `RideHistoryRow`, `RideDefaultsResponse` in `RidesContracts.cs` +- **Tests**: verify existing contract serialization tests still pass + +**Slice 1.5 — RecordRideService Integration** +- Inject `IWeatherLookupService`; read user lat/lon; apply merge logic +- **Tests**: 5 unit tests for RecordRideService weather behavior + (write & confirm red → implement → green) + +**Slice 1.6 — EditRideService Integration** +- Inject `IWeatherLookupService`; apply edit-specific re-fetch rules (FR-002, FR-012) +- **Tests**: 3 unit tests for EditRideService weather behavior + (write & confirm red → implement → green) + +**Slice 1.7 — Read Path Updates** +- Map new `RideEntity` columns in `GetRideHistoryService` → `RideHistoryRow` +- Map weather defaults in `GetRideDefaultsService` → `RideDefaultsResponse` +- **Tests**: extend existing service tests to cover new fields in output + +### Phase 2 — Frontend (Form fields + Display) + +**Slice 2.1 — TypeScript Types** +- Extend ride API interface types to include weather fields +- **Tests**: TypeScript compilation + lint (no new unit tests required for type-only changes) + +**Slice 2.2 — Ride Create Form** +- Add 6 weather input fields (all optional); set `weatherUserOverridden = true` when user edits any +- **Tests**: Vitest unit tests for form component; Playwright E2E for create-with-weather flow + +**Slice 2.3 — Ride Edit Form** +- Pre-populate weather fields from stored ride values; same override logic +- **Tests**: Playwright E2E for edit-with-weather flow + +**Slice 2.4 — Ride History Display** +- Show weather fields in `RideHistoryRow` (optional columns) +- **Tests**: Vitest unit test for row rendering with/without weather fields + +## Test Plan Summary + +| Category | Count | Location | +|----------|-------|----------| +| Unit — WeatherLookupService | 10 | `Application/Rides/WeatherLookupServiceTests.cs` | +| Unit — RecordRideService (weather) | 5 | `Application/RidesApplicationServiceTests.cs` | +| Unit — EditRideService (weather) | 3 | `Application/RidesApplicationServiceTests.cs` | +| Integration — migrations + entity round-trip | 3 | `Infrastructure/RidesPersistenceTests.cs` | +| Migration coverage policy | 2 | `Infrastructure/MigrationTestCoveragePolicyTests.cs` | +| Frontend unit | 3 | frontend test files co-located with components | +| E2E (Playwright) | 4 | frontend E2E suite | +| **Total** | **30** | | + +## Complexity Tracking + +No constitution violations. All changes are additive and follow established patterns. diff --git a/specs/011-ride-weather-data/quickstart.md b/specs/011-ride-weather-data/quickstart.md new file mode 100644 index 0000000..91b4680 --- /dev/null +++ b/specs/011-ride-weather-data/quickstart.md @@ -0,0 +1,323 @@ +# Developer Quickstart: Weather-Enriched Ride Entries + +**Feature**: 011-ride-weather-data +**Branch**: `011-ride-weather-data` +**Date**: 2026-04-03 + +--- + +## Overview + +This feature adds automatic weather capture (temperature, wind speed/direction, humidity, cloud +cover, precipitation type) to ride create and edit events. Weather is fetched server-side at +save time using the Open-Meteo free API, with no API key required by default. Results are cached +in SQLite by hour-bucket + location to minimize external calls. + +--- + +## Prerequisites + +- DevContainer running (mandatory — all tooling pre-configured) +- App running: `dotnet run --project src/BikeTracking.AppHost` +- User has configured Latitude/Longitude in Settings (otherwise weather fetch is skipped gracefully) + +--- + +## Implementation Order + +Follow the TDD red-green-refactor cycle at each step. Get user confirmation of failing tests +before implementing. + +### Step 1 — Schema (migrations) + +**Files to create/modify:** + +``` +src/BikeTracking.Api/Infrastructure/Persistence/Entities/ + WeatherLookupEntity.cs ← new + RideEntity.cs ← extend with 6 new fields + +src/BikeTracking.Api/Infrastructure/Persistence/ + BikeTrackingDbContext.cs ← add DbSet +``` + +**Create migrations** (run after entity changes): +```bash +cd src/BikeTracking.Api +dotnet ef migrations add AddWeatherFieldsToRides +dotnet ef migrations add AddWeatherLookupCache +``` + +> Each migration needs a corresponding test entry in `MigrationTestCoveragePolicyTests.cs`. + +--- + +### Step 2 — Weather Lookup Service + +**Files to create:** + +``` +src/BikeTracking.Api/Application/Rides/ + WeatherLookupService.cs ← IWeatherLookupService + OpenMeteoWeatherLookupService +``` + +**Pattern to follow**: `GasPriceLookupService.cs` (exact same structure) + +Key logic inside `GetOrFetchAsync`: +```text +1. Convert rideTimeLocal → UTC; truncate to hour +2. Round lat/lon to 2 decimal places +3. Query WeatherLookups cache table +4. If miss: pick endpoint (forecast if ≤ 92 days old; archive otherwise) +5. GET Open-Meteo; parse hourly array; derive PrecipitationType from weather_code +6. INSERT WeatherLookupEntity; catch DbUpdateException for concurrent insert +7. Return WeatherSnapshot (or null on error) +``` + +**Register in `Program.cs`:** +```csharp +builder.Services.AddScoped(); +builder.Services.AddHttpClient("OpenMeteo", client => +{ + client.Timeout = TimeSpan.FromSeconds(5); + // BaseAddress not set — implementation switches between two base URLs +}); +``` + +**Configuration** (optional — no key needed for Open-Meteo free tier): +```json +// appsettings.Development.json (for future commercial tier / override) +"WeatherLookup": { + "ApiKey": "" +} +``` + +--- + +### Step 3 — Update Event Payloads + +**Files:** +``` +src/BikeTracking.Api/Application/Events/ + RideRecordedEventPayload.cs ← add 6 new optional params to Create() + RideEditedEventPayload.cs ← same +``` + +Add to both records and `Create(…)` factory signatures: +```csharp +decimal? WindSpeedMph = null, +int? WindDirectionDeg = null, +int? RelativeHumidityPercent = null, +int? CloudCoverPercent = null, +string? PrecipitationType = null, +bool WeatherUserOverridden = false +``` + +All new params are optional with defaults → **no existing test breakage**. + +--- + +### Step 4 — Update API Contracts + +**File:** `src/BikeTracking.Api/Contracts/RidesContracts.cs` + +Extend `RecordRideRequest`, `EditRideRequest`, `RideHistoryRow`, and `RideDefaultsResponse` +as defined in [contracts/api-contracts.md](./contracts/api-contracts.md). + +--- + +### Step 5 — Integrate Weather into RecordRideService + +**File:** `src/BikeTracking.Api/Application/Rides/RecordRideService.cs` + +Inject `IWeatherLookupService` and `UserSettingsService` (or access lat/lon via a lightweight +read). At save time: + +```text +1. Read user lat/lon from UserSettingsEntity +2. If lat/lon available AND request.WeatherUserOverridden == false: + a. Call IWeatherLookupService.GetOrFetchAsync(rideTime, lat, lon) + b. Merge: for each weather field, user-submitted non-null value wins; null → use API value +3. Build RideEntity with merged weather fields +4. Create RideRecordedEventPayload with merged weather fields +``` + +--- + +### Step 6 — Integrate Weather into EditRideService + +**File:** `src/BikeTracking.Api/Application/Rides/EditRideService.cs` + +Same inject pattern. Additional edit-specific rule: + +```text +If request.RideDateTimeLocal == stored ride.RideDateTimeLocal: + → skip weather re-fetch (FR-012); use submitted values as-is +If request.RideDateTimeLocal changed AND request.WeatherUserOverridden == false: + → fetch weather for new time; merge as per Step 5 +``` + +--- + +### Step 7 — Update `GetRideHistoryService` + `GetRideDefaultsService` + +**Files:** +``` +src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs +src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs +``` + +Map new `RideEntity` weather columns to `RideHistoryRow` and `RideDefaultsResponse`. + +--- + +### Step 8 — Frontend: Ride Create/Edit Form + +**Files** (locate existing ride form components): +``` +src/BikeTracking.Frontend/src/ + (find RecordRide or CreateRide component) + (find EditRide component) + (find ride service / API client types) +``` + +Add optional weather fields to both forms: +- Temperature (already exists — verify rendering) +- Wind Speed (mph) +- Wind Direction (degrees, optional compass hint) +- Relative Humidity (%) +- Cloud Cover (%) +- Precipitation Type (text input or simple select: none / rain / snow / freezing_rain) + +**UI behavior**: +- All weather fields are optional — user can leave them empty +- Pre-populated on edit from the stored ride values +- When user manually changes any field, set `weatherUserOverridden = true` before submit +- Show in RideHistoryRow in the history table + +**TypeScript type contracts**: see [contracts/api-contracts.md](./contracts/api-contracts.md) + +--- + +## Verification Commands + +After each step, run the appropriate verification tier: + +```bash +# Backend changes +dotnet test BikeTracking.slnx + +# Frontend changes +cd src/BikeTracking.Frontend && npm run lint && npm run build && npm run test:unit + +# Cross-layer (auth/contract/E2E) +cd src/BikeTracking.Frontend && npm run test:e2e +``` + +Notes: +- In this repository, code formatting is run with `dotnet csharpier format .`. +- E2E may log warnings about missing `GasPriceLookup:EiaApiKey`; this is expected and does not fail weather-related scenarios. + +--- + +## Test Plan (TDD — write & confirm failing first) + +### Unit Tests (new file: `Application/Rides/WeatherLookupServiceTests.cs`) + +| Test | What it proves | +|------|----------------| +| Returns cached WeatherSnapshot when cache hit exists | Cache reuse works | +| Calls Open-Meteo API when no cache entry exists | API integration fires on miss | +| Returns null and logs warning on HTTP error | Graceful degradation | +| Returns null when lat/lon are null (location not configured) | FR-001a | +| Uses forecast endpoint for a ride time within 92 days | Routing logic | +| Uses archive endpoint for a ride time older than 92 days | Routing logic | +| Correctly derives PrecipitationType from WMO weather_code rain range | Precip mapping | +| Correctly derives PrecipitationType from WMO weather_code snow range | Precip mapping | +| Correctly returns null PrecipitationType for clear weather | Precip mapping | +| Handles concurrent cache insert (DbUpdateException) gracefully | Race condition safe | + +### Unit Tests (extend `Application/RidesApplicationServiceTests.cs`) + +| Test | What it proves | +|------|----------------| +| RecordRideService auto-fills null weather fields from lookup result | FR-001 | +| RecordRideService preserves non-null user-submitted weather fields | FR-008 | +| RecordRideService completes save when weather lookup returns null | FR-009 | +| RecordRideService skips lookup when WeatherUserOverridden = true | Override flag works | +| RecordRideService skips lookup when user has no location configured | FR-001a | +| EditRideService re-fetches weather when RideDateTimeLocal changes | FR-002 | +| EditRideService skips weather re-fetch when RideDateTimeLocal unchanged | FR-012 | +| EditRideService respects WeatherUserOverridden = true on edit | FR-008 | + +### Integration Tests (extend `Infrastructure/RidesPersistenceTests.cs`) + +| Test | What it proves | +|------|----------------| +| New migrations apply cleanly to SQLite | Schema change safe | +| WeatherLookupEntity persisted and re-read correctly | Entity mapping correct | +| RideEntity with all weather fields round-trips through EF Core | Column mapping correct | + +### E2E Tests (Playwright — extend existing ride E2E suite) + +| Test | What it proves | +|------|----------------| +| Ride create form shows weather fields | FR-011 | +| Weather fields appear in ride history row | FR-011 | +| User can manually enter weather on create and values are saved | FR-007/FR-008 | +| Editing a ride pre-populates weather fields from stored values | UX correctness | + +### Migration Coverage Policy + +Add entries to `MigrationTestCoveragePolicyTests.cs` for: +- `AddWeatherFieldsToRides` +- `AddWeatherLookupCache` + +--- + +## Open-Meteo Quick Reference + +**Forecast endpoint** (rides ≤ 92 days old or future): +``` +GET https://api.open-meteo.com/v1/forecast + ?latitude={lat}&longitude={lon} + &hourly=temperature_2m,wind_speed_10m,wind_direction_10m, + relative_humidity_2m,cloud_cover,precipitation,snowfall,weather_code + &temperature_unit=fahrenheit + &wind_speed_unit=mph + &timezone=auto + &past_days={daysDiff} ← how many days back + &forecast_days=1 +``` + +**Archive endpoint** (rides > 92 days old): +``` +GET https://archive-api.open-meteo.com/v1/archive + ?latitude={lat}&longitude={lon} + &start_date={yyyy-MM-dd}&end_date={yyyy-MM-dd} + &hourly=temperature_2m,wind_speed_10m,wind_direction_10m, + relative_humidity_2m,cloud_cover,precipitation,snowfall,weather_code + &temperature_unit=fahrenheit + &wind_speed_unit=mph + &timezone=auto +``` + +**Response shape** (both endpoints): +```json +{ + "hourly": { + "time": ["2026-04-03T07:00", "2026-04-03T08:00", ...], + "temperature_2m": [52.1, 53.4, ...], + "wind_speed_10m": [8.2, 9.1, ...], + "wind_direction_10m": [270, 265, ...], + "relative_humidity_2m": [72, 70, ...], + "cloud_cover": [40, 35, ...], + "precipitation": [0.0, 0.0, ...], + "snowfall": [0.0, 0.0, ...], + "weather_code": [2, 2, ...] + } +} +``` + +**Match hourly index**: find `time` entry equal to `lookupHourUtc` formatted as `yyyy-MM-ddTHH:mm` +(local time in the timezone parameters). Use that index for all field arrays. diff --git a/specs/011-ride-weather-data/research.md b/specs/011-ride-weather-data/research.md new file mode 100644 index 0000000..2b66ba0 --- /dev/null +++ b/specs/011-ride-weather-data/research.md @@ -0,0 +1,153 @@ +# Research: Weather-Enriched Ride Entries + +**Feature**: 011-ride-weather-data +**Date**: 2026-04-03 +**Status**: Complete — all NEEDS CLARIFICATION resolved + +--- + +## Decision 1: Weather API Provider + +**Decision**: Use [Open-Meteo](https://open-meteo.com/) as the weather provider. + +**Rationale**: +- Completely free and open — no API key required for default use, yet the application configures a key + slot (graceful fallback when unconfigured satisfies FR-001a with zero setup friction). +- Full historical archive from 1940 to present via `archive-api.open-meteo.com`, plus + near-real-time current weather via `api.open-meteo.com` with `past_days` parameter. +- Returns hourly data for all six required fields in a single request: temperature, wind speed, + wind direction, relative humidity, cloud cover, and precipitation amount; WMO weather codes + disambiguate precipitation type (rain vs. snow vs. sleet). +- No rate-limit concerns for a local-first single-user app. +- REST/JSON; no SDK or paid subscription required. + +**Alternatives considered**: +- **WeatherAPI.com** — free tier (1M calls/month) with key; good historical coverage from 2010. + Ruled out because Open-Meteo covers earlier dates, needs no key by default, and produces simpler + responses. +- **OpenWeatherMap** — free tier covers current and 5-day forecast only; historical data requires + paid "History" API. Ruled out. +- **Visual Crossing** — free tier (1000 records/day) with key; excellent historical coverage. + Viable fallback if Open-Meteo becomes unavailable, but has lower free-tier limits. + +**API Endpoints used**: + +| Scenario | Endpoint | +|----------|----------| +| Ride within last 92 days or today/future | `https://api.open-meteo.com/v1/forecast?past_days=N` | +| Ride older than 92 days | `https://archive-api.open-meteo.com/v1/archive` | + +**Fields requested**: +``` +hourly=temperature_2m,wind_speed_10m,wind_direction_10m, + relative_humidity_2m,cloud_cover,precipitation,snowfall,weather_code +&temperature_unit=fahrenheit +&wind_speed_unit=mph +&timezone=auto +``` + +**Precipitation type derivation** from WMO `weather_code`: +- Codes 51–67, 80–82: Rain / drizzle / rain showers → `"rain"` +- Codes 71–77, 85–86: Snow / snow showers → `"snow"` +- Codes 56–57, 66–67: Freezing rain → `"freezing_rain"` +- Code 0: Clear → `null` (empty) +- No precipitation (code <40 or `precipitation == 0 && snowfall == 0`) → `null` + +--- + +## Decision 2: Cache Key Design + +**Decision**: Cache by `(LookupHourUtc, LatitudeRounded, LongitudeRounded)`. + +| Component | Rule | +|-----------|------| +| `LookupHourUtc` | Convert ride's local `DateTime` to UTC; truncate to whole hour | +| `LatitudeRounded` | Round user's `Latitude` to 2 decimal places (~1 km precision) | +| `LongitudeRounded` | Round user's `Longitude` to 2 decimal places | + +**Rationale**: One-hour granularity matches Open-Meteo's hourly data resolution and the +constitution-approved cache strategy. Two-decimal rounding gives ~1 km precision — accurate +enough for commute weather while ensuring identical coordinates for the same configured location +always hit the same cache entry (floating-point equality safe). + +**Alternatives considered**: +- Exact UTC minute: too fine; no two ride times share a cache entry, defeating reuse objective. +- Calendar day: too coarse; riding at 7 AM in clear weather vs. 5 PM in rain would share a record. + +--- + +## Decision 3: Server-Side Fetch + User Override Merge Strategy + +**Decision**: Weather fetch is server-side at save time. User-submitted field values take precedence +over API-fetched values on a per-field basis, controlled by a `WeatherUserOverridden` flag. + +**Merge rules at save**: + +| Scenario | Server action | +|----------|---------------| +| `WeatherUserOverridden = false` AND field is `null` in request | Fetch from API (or cache); use API value | +| `WeatherUserOverridden = false` AND field is non-null in request | User typed a value — use the user value, skip fetch for that field | +| `WeatherUserOverridden = true` | Use all submitted values as-is; skip weather fetch entirely | +| Location (lat/lon) not configured in user settings | Skip fetch; all weather fields remain as submitted (empty unless user typed) | +| API unreachable / timeout / error | Log warning; all null weather fields remain null; save proceeds | + +**Edit-specific rule**: +- If `RideDateTimeLocal` is unchanged from the stored ride, skip weather re-fetch (satisfies FR-012). +- If `RideDateTimeLocal` changed, re-apply merge rules above using new timestamp. + +**Rationale**: Keeps the API key server-side only. Front-end form never makes external calls. +Aligns with existing `GasPriceLookupService` pattern where the service is called from within the +application layer at the appropriate integration point. + +--- + +## Decision 4: F# Domain vs. C# for Weather Fields + +**Decision**: Weather fields remain in C# (API layer) for this feature, consistent with the +existing pattern for `Temperature` and `GasPricePerGallon` in `RideEntity` and the event +payloads. + +**Rationale**: The F# domain layer currently contains user event types (`UserEvents.fs`) and +core calculations. Weather is a data-capture concern (not a business-rule/calculation concern) +at this stage; no railroad-oriented decision logic is required to store or merge the fields. +If future features add weather-based recommendations or eligibility rules, those calculations +belong in the F# domain layer at that time. + +--- + +## Decision 5: New Fields vs. Existing `Temperature` + +**Decision**: `Temperature` remains as its own standalone field on `RideEntity` and request DTOs +(it already exists). The five new weather fields are **additions** alongside `Temperature`, not +a replacement or restructuring. + +**Rationale**: Backwards compatibility — all existing rides have `Temperature` stored separately. +Changing the shape of `Temperature` would require a migration and a data-loss risk. Adding new +nullable columns is a safe, additive schema change. + +--- + +## Decision 6: Timeout Budget for External Weather Calls + +**Decision**: 5-second HTTP client timeout for Open-Meteo calls, consistent with the existing +`EiaGasPrice` HttpClient timeout (10 seconds); weather is simpler and should respond faster. + +**Rationale**: Constitution Principle VI mandates `<500ms p95` API response time. Weather fetch +adds latency at save time; a 5-second cap ensures the request fails fast on a hanging connection +without blocking the user indefinitely. Cached responses add zero latency after the first lookup. + +--- + +## Resolved Clarifications + +| Question | Answer | +|----------|--------| +| Location source | Single user-configured lat/lon from `UserSettingsEntity` | +| Historical vs current | Both — archive API (>92 days) + forecast API (≤92 days) | +| API key | Open-Meteo needs no key by default; key slot configurable (`WeatherLookup:ApiKey`) for commercial tier fallback | +| Cache granularity | 1-hour bucket, rounded lat/lon to 2 decimal places | +| Fetch timing | Server-side at save; API key never sent to browser | +| Timeout | 5 seconds | +| Precipitation type | Derived from WMO `weather_code` | +| Temperature unit | Fahrenheit (consistent with existing `Temperature` field) | +| Wind speed unit | mph (consistent with existing US-centric app) | diff --git a/specs/011-ride-weather-data/spec.md b/specs/011-ride-weather-data/spec.md new file mode 100644 index 0000000..7fe8226 --- /dev/null +++ b/specs/011-ride-weather-data/spec.md @@ -0,0 +1,119 @@ +# Feature Specification: Weather-Enriched Ride Entries + +**Feature Branch**: `011-ride-weather-data` +**Created**: 2026-04-03 +**Status**: Draft +**Input**: User description: "Find and call a free API to get the weather (temp, wind speed, wind direction, humidity, cloud cover, precip type if any) 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 the weather in the future to avoid calls. Show the weather fields on the ride creation/edit page. If there is an error or it is not available leave it empty. The user can overwrite the value." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Auto-fill weather for ride entries (Priority: P1) + +As a rider creating or editing a ride entry, I want weather details for the ride time to be automatically populated so I can avoid manual lookups and keep ride context accurate. + +**Why this priority**: Automatic weather enrichment is the core feature and primary user value. + +**Independent Test**: Can be fully tested by creating or editing a ride with a valid ride timestamp and confirming weather values are automatically populated and saved with the ride event. + +**Acceptance Scenarios**: + +1. **Given** a user submits a new ride with a valid ride timestamp and weather data is available, **When** the server processes the save request, **Then** temperature, wind speed, wind direction, humidity, cloud cover, and precipitation type are fetched server-side and stored with the ride created event. +2. **Given** a user edits an existing ride and updates the ride timestamp, **When** they save, **Then** the server fetches weather for the new time and stores the refreshed values with the ride updated event. + +--- + +### User Story 2 - Manual override of weather values (Priority: P2) + +As a rider, I want to manually adjust weather fields when automatic values are incorrect or missing so my ride record reflects what I observed. + +**Why this priority**: Data quality depends on user trust and correction ability, especially when weather sources are incomplete. + +**Independent Test**: Can be tested by accepting auto-populated values, changing one or more weather fields, and confirming the edited values are saved and shown later. + +**Acceptance Scenarios**: + +1. **Given** a ride has been saved and its weather values are shown on the ride edit page, **When** the user changes one or more weather fields and saves, **Then** the user-provided values are stored as authoritative and the server does not overwrite them with a new weather fetch. +2. **Given** automatic weather retrieval fails and a ride is saved with empty weather fields, **When** the user re-opens the ride for editing and enters weather values manually and saves, **Then** those values are stored and displayed for that ride. + +--- + +### User Story 3 - Reuse historical weather lookups (Priority: P3) + +As a rider repeatedly adding or editing rides for the same date/time context, I want previously retrieved weather data reused so entries save faster and external weather calls are reduced. + +**Why this priority**: Reuse lowers dependency on external availability and reduces unnecessary third-party calls. + +**Independent Test**: Can be tested by creating two rides for the same lookup context, confirming the second save uses stored weather lookup data without requiring a new external lookup. + +**Acceptance Scenarios**: + +1. **Given** a weather lookup has already been stored for a ride timestamp context, **When** a new ride uses the same context, **Then** the system reuses stored weather data. +2. **Given** reused weather data is found, **When** the user saves the ride, **Then** the ride event still contains complete weather fields. + +### Edge Cases + +- Weather provider has no record for the ride timestamp; ride save still succeeds and weather fields remain empty unless user enters values. +- Weather provider returns only partial weather data; available fields are populated and unavailable fields remain empty. +- Weather provider is unreachable or times out during server-side fetch; ride save completes and weather fields are stored empty. +- User manually enters or clears a weather field on the edit form; the submitted value (including blank) is preserved and the server does not overwrite it with a fresh weather fetch. +- Ride time is changed during edit; the server re-fetches weather for the new time at save, but only for fields the user has not manually provided. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The server MUST perform a weather lookup at the time the ride create request is received; the weather fetch MUST support both historical ride times and current/live ride times. +- **FR-001a**: The weather provider API key MUST be configurable by the user or administrator in the application's settings; the app MUST behave gracefully (empty weather fields, no error blocking save) when no API key is configured. +- **FR-001b**: Weather fetches MUST be performed server-side only; the API key MUST NOT be exposed to or used by the frontend. +- **FR-002**: The server MUST perform a weather lookup at the time the ride update request is received when the ride timestamp has changed; the same historical/current support applies. +- **FR-003**: System MUST capture the following weather fields for ride events when available: temperature, wind speed, wind direction, humidity, cloud cover, and precipitation type. +- **FR-004**: System MUST store weather values inside ride created and ride updated events so they are available for downstream calculations. +- **FR-005**: System MUST store weather lookup results keyed by the ride timestamp rounded to the nearest hour combined with the user's configured location, so future ride entries within the same hour can reuse previously retrieved weather data and avoid unnecessary external calls. +- **FR-006**: System MUST reuse stored weather lookup data when a matching hourly bucket and location key exists. +- **FR-007**: System MUST display editable weather fields on ride create and ride edit pages so users can manually enter or correct weather values before saving. +- **FR-008**: System MUST treat any weather field value explicitly submitted by the user as authoritative; the server MUST NOT overwrite a user-submitted weather field with an auto-fetched value. +- **FR-009**: System MUST allow ride save to complete when weather retrieval fails or returns no data. +- **FR-010**: System MUST leave unavailable weather fields empty rather than blocking save or inserting fabricated values. +- **FR-011**: System MUST display weather fields on both ride creation and ride edit pages. +- **FR-012**: System MUST preserve existing ride weather values when a ride is edited without changing weather fields and without requiring a new weather lookup. + +### Key Entities *(include if feature involves data)* + +- **Ride Event Weather Snapshot**: Weather attributes stored directly on ride created and ride updated events; includes temperature, wind speed, wind direction, humidity, cloud cover, precipitation type, plus indication of whether values were user-overridden. +- **Weather Lookup Record**: Reusable stored weather result keyed by ride timestamp rounded to the nearest hour and the user's configured location; includes retrieved weather fields, lookup timestamp, and retrieval status (success/partial/unavailable/error). All ride times within the same hour at the same location share one cache entry. +- **Ride Entry Form Weather Fields**: Editable fields shown on both create and edit pages. On create, fields start empty and are optionally filled by the user before saving; server auto-fills only unsubmitted fields. On edit, fields are pre-populated with the previously saved weather values so the user can review and override before saving. + +## Clarifications + +### Session 2026-04-03 + +- Q: What location information should be used when calling the weather API? → A: A single fixed user location configured in app settings (e.g., home city or coordinates) +- Q: Does the app need historical weather (data for past ride times), or only current/forecast weather? → A: Both — historical for past ride times and current/live for rides logged in real time +- Q: What kind of API access is acceptable for the weather provider? → A: Free tier with API key acceptable — user or admin registers for a key and configures it in the app +- Q: How granular should the weather lookup cache key be? → A: 1-hour buckets — ride times rounded to the nearest hour for cache keying +- Q: When does the weather fetch happen — client-side during form fill or server-side at save? → A: Server-side at save — the API fetches weather when the ride create/update request is received; the API key never reaches the browser + +## Assumptions + +- Weather lookups use a single fixed user location (e.g., home city or coordinates) configured once in the application's user settings; per-ride GPS is not required. +- Ride entries already include sufficient context for determining a weather lookup (at minimum ride time); location is provided by the user's configured fixed location setting. +- A suitable free-tier weather source exists that can provide both historical weather data for past ride times and current/live weather data for rides logged in real time. +- The user or administrator is responsible for obtaining and configuring a free-tier API key; the app does not provision keys automatically. +- If no precipitation is reported, precipitation type remains empty. +- Empty weather fields are acceptable for analytics consumers and future calculations must handle missing values. + +## Dependencies + +- Continued availability and acceptable usage limits of the selected free-tier weather provider. +- User or administrator has registered for and configured a valid API key for the weather provider. +- Existing ride create/edit workflows and event persistence remain in place and are extended by this feature. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In at least 90% of ride saves with reachable weather data for the ride time, weather fields are auto-populated without manual user entry. +- **SC-002**: 100% of ride create and ride update events produced by the feature include weather fields (with either values or explicit empties). +- **SC-003**: In at least 80% of repeated ride entries sharing the same lookup context, stored weather lookup data is reused instead of requesting new external data. +- **SC-004**: 100% of ride create/edit attempts remain savable when weather data is unavailable or lookup errors occur. +- **SC-005**: At least 95% of users can complete ride create/edit with weather review or override in a single attempt during acceptance testing. diff --git a/specs/011-ride-weather-data/tasks.md b/specs/011-ride-weather-data/tasks.md new file mode 100644 index 0000000..6446438 --- /dev/null +++ b/specs/011-ride-weather-data/tasks.md @@ -0,0 +1,198 @@ +# Tasks: Weather-Enriched Ride Entries + +**Feature**: `011-ride-weather-data` +**Input**: Design documents from `/specs/011-ride-weather-data/` +**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, data-model.md ✓, contracts/ ✓, quickstart.md ✓ + +**Tests**: Included and required by constitution/TDD workflow. Red tests must be written and user-confirmed before implementation. + +## 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–US3) +- All paths are relative to repository root + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Baseline config and wiring before schema/service work. + +- [X] T001 Add `WeatherLookup:ApiKey` configuration key (empty default) to `src/BikeTracking.Api/appsettings.json` and `src/BikeTracking.Api/appsettings.Development.json` +- [X] T002 Register named HttpClient for weather provider in `src/BikeTracking.Api/Program.cs` with 5-second timeout for server-side lookup calls +- [X] T003 [P] Add weather API sample requests to `src/BikeTracking.Api/BikeTracking.Api.http` (create/edit ride payloads with weather fields) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core schema/contracts/entities all stories rely on. + +**⚠️ CRITICAL**: No user story work begins until this phase is complete. + +- [X] T004 Create `src/BikeTracking.Api/Infrastructure/Persistence/Entities/WeatherLookupEntity.cs` with fields from data-model (`WeatherLookupId`, `LookupHourUtc`, `LatitudeRounded`, `LongitudeRounded`, weather snapshot fields, `DataSource`, `RetrievedAtUtc`, `Status`) +- [X] T005 Extend `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` with new nullable weather fields (`WindSpeedMph`, `WindDirectionDeg`, `RelativeHumidityPercent`, `CloudCoverPercent`, `PrecipitationType`) and `WeatherUserOverridden` +- [X] T006 Extend `src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs`: add `DbSet WeatherLookups`; configure mapping and unique index on (`LookupHourUtc`, `LatitudeRounded`, `LongitudeRounded`) +- [X] T007 Generate EF Core migration `AddWeatherFieldsToRides` in `src/BikeTracking.Api/Infrastructure/Persistence/Migrations/` +- [X] T008 Generate EF Core migration `AddWeatherLookupCache` in `src/BikeTracking.Api/Infrastructure/Persistence/Migrations/` +- [X] T009 [P] Extend `src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs` with optional weather fields and `WeatherUserOverridden` in record + `Create(...)` +- [X] T010 [P] Extend `src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs` with optional weather fields and `WeatherUserOverridden` in record + `Create(...)` +- [X] T011 Extend `src/BikeTracking.Api/Contracts/RidesContracts.cs`: add weather fields + validation attributes to `RecordRideRequest` and `EditRideRequest`; add weather fields to `RideHistoryRow` and `RideDefaultsResponse` +- [X] T012 [P] Extend frontend ride DTOs/interfaces with new weather fields in `src/BikeTracking.Frontend/src/services/ridesService.ts` +- [X] T013 Update migration-policy mapping in `src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs` for both new migrations + +**Checkpoint**: Foundation complete; user stories can proceed. + +--- + +## Phase 3: User Story 1 - Auto-fill weather for ride entries (Priority: P1) 🎯 MVP + +**Goal**: On create and edit-save, server fetches weather for ride time and stores it in ride/event data. + +**Independent Test**: Create or edit a ride with valid timestamp/location and verify weather fields are auto-populated and persisted in events/rows. + +### US1 - Tests (write first, TDD-RED gate) + +- [X] T014 [P] [US1] Add failing unit tests for weather lookup cache/API behavior in `src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs` (cache hit/miss, endpoint routing, timeout/error fallback, precipitation type derivation) +- [X] T015 [P] [US1] Add failing service tests for create/edit auto-fetch behavior in `src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs` +- [X] T016 [P] [US1] Add failing endpoint/integration coverage for weather persistence in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs` and `src/BikeTracking.Api.Tests/Infrastructure/RidesPersistenceTests.cs` +- [X] T017 [US1] Run failing US1 backend test set and capture red output for user confirmation + +### US1 - Implementation (TDD-GREEN gate) + +- [X] T018 [US1] Create `src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs` with `IWeatherLookupService`, `OpenMeteoWeatherLookupService`, and `WeatherSnapshot` model +- [X] T019 [US1] Implement hour-bucket cache key logic (UTC hour + rounded lat/lon), persistence, and concurrent insert handling in `src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs` +- [X] T020 [US1] Implement Open-Meteo HTTP parsing and precipitation type mapping in `src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs` +- [X] T021 [US1] Wire weather lookup dependency registration in `src/BikeTracking.Api/Program.cs` +- [X] T022 [US1] Extend `src/BikeTracking.Api/Application/Rides/RecordRideService.cs` to fetch weather server-side on create and merge values for save/event payload +- [X] T023 [US1] Extend `src/BikeTracking.Api/Application/Rides/EditRideService.cs` to re-fetch weather when ride timestamp changes and merge values for save/event payload +- [X] T024 [US1] Extend `src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs` and related mapping to include new weather fields +- [X] T025 [US1] Extend `src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs` to include weather defaults in response +- [X] T026 [US1] Run US1 backend tests to green (`WeatherLookupServiceTests`, `RidesApplicationServiceTests`, endpoint/persistence tests) + +**Checkpoint**: US1 is independently functional and demoable. + +--- + +## Phase 4: User Story 2 - Manual override of weather values (Priority: P2) + +**Goal**: User can manually enter/override weather values and those values remain authoritative. + +**Independent Test**: Edit weather fields on create/edit forms, save, and verify saved values are exactly user-provided even when auto-fetch data exists. + +### US2 - Tests (write first, TDD-RED gate) + +- [X] T027 [P] [US2] Add failing backend tests for override precedence (`WeatherUserOverridden`, field-level precedence) in `src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs` +- [X] T028 [P] [US2] Add failing frontend tests for weather field editing + override flag behavior in create/edit pages (`RecordRidePage.test.tsx`, `HistoryPage.test.tsx`) +- [X] T029 [US2] Run failing US2 tests and capture red output for user confirmation + +### US2 - Implementation (TDD-GREEN gate) + +- [X] T030 [US2] Update create/edit merge logic in `src/BikeTracking.Api/Application/Rides/RecordRideService.cs` and `src/BikeTracking.Api/Application/Rides/EditRideService.cs` so user-submitted values always win over fetched values +- [X] T031 [US2] Extend ride create form with editable weather fields and override flag handling in `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx` +- [X] T032 [US2] Extend ride edit form with editable weather fields and override flag handling in `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx` +- [X] T033 [US2] Add/update weather input validation messaging in form components and styles (co-located frontend files) +- [X] T034 [US2] Run US2 backend/frontend tests to green + +**Checkpoint**: US2 is independently functional and demoable. + +--- + +## Phase 5: User Story 3 - Reuse historical weather lookups (Priority: P3) + +**Goal**: Avoid redundant external weather calls by reusing persisted weather lookup records. + +**Independent Test**: Two saves for same hour/location should perform only one external call and reuse cached weather on the second save. + +### US3 - Tests (write first, TDD-RED gate) + +- [X] T035 [P] [US3] Add failing cache reuse tests (same hour/location no second HTTP call) in `src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs` +- [X] T036 [P] [US3] Add failing durability test showing reuse after service restart/new scope in `src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs` +- [X] T037 [US3] Run failing US3 tests and capture red output for user confirmation + +### US3 - Implementation (TDD-GREEN gate) + +- [X] T038 [US3] Finalize cache lookup/read-through behavior and status persistence in `src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs` +- [X] T039 [US3] Add structured logging for cache hit/miss and graceful weather lookup failure paths in `src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs` +- [X] T040 [US3] Run US3 tests to green and verify no regression in US1/US2 behavior + +**Checkpoint**: US3 is independently functional and demoable. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Full verification matrix and final consistency checks. + +- [X] T041 [P] Update quickstart verification notes and any changed examples in `specs/011-ride-weather-data/quickstart.md` +- [X] T042 [P] Run `csharpier format .` and resolve formatting issues in changed C# files +- [X] T043 [P] Run frontend lint/build checks from `src/BikeTracking.Frontend` (`npm run lint`, `npm run build`) and resolve issues +- [X] T044 Run backend test suite: `dotnet test BikeTracking.slnx` +- [X] T045 Run frontend unit tests: `cd src/BikeTracking.Frontend && npm run test:unit` +- [X] T046 Run frontend E2E tests: `cd src/BikeTracking.Frontend && npm run test:e2e` (with Aspire stack running) + +--- + +## Dependencies + +```text +Phase 1 (T001-T003) + -> Phase 2 (T004-T013) + -> Phase 3 / US1 (T014-T026) MVP + -> Phase 4 / US2 (T027-T034) + -> Phase 5 / US3 (T035-T040) + -> Phase 6 (T041-T046) +``` + +- US1 depends on foundational schema/contracts/service wiring. +- US2 depends on US1 weather fetch pipeline and form weather fields. +- US3 depends on US1 lookup service and cache table. +- Polish depends on all completed story work. + +## Parallel Opportunities + +- Phase 1: T003 can run in parallel with T001-T002. +- Phase 2: T009, T010, T012 can run in parallel after T004-T006 starts. +- US1 tests: T014, T015, T016 can be authored in parallel. +- US2 tests: T027 and T028 can be authored in parallel. +- US3 tests: T035 and T036 can be authored in parallel. +- Final checks: T042 and T043 can run in parallel before T044-T046. + +## Parallel Example: US1 Test Authoring + +```text +T014 src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs +T015 src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs +T016 src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs + Infrastructure/RidesPersistenceTests.cs +``` + +## Implementation Strategy + +### MVP First (US1 only) + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 backend auto-fetch + persistence path. +3. Validate US1 independently before continuing. + +### Incremental Delivery + +1. Add US2 user override behavior after US1 green. +2. Add US3 cache-reuse hardening and durability checks. +3. Finish with Phase 6 verification matrix. + +### Suggested Commit Boundaries (TDD) + +1. `TDD-RED: 011 weather lookup/service tests` +2. `TDD-GREEN: 011 backend auto-fetch and persistence` +3. `TDD-RED: 011 override tests` +4. `TDD-GREEN: 011 create/edit override behavior` +5. `TDD-RED: 011 cache reuse tests` +6. `TDD-GREEN: 011 cache reuse implementation` +7. `CI-GREEN: 011 weather final verification` + +## Notes + +- [P] tasks touch separate files or independent areas. +- [US#] labels map each task to a user story for traceability. +- Keep each story independently testable and demoable. +- Do not skip the red-test user confirmation gate before implementation. \ No newline at end of file diff --git a/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs new file mode 100644 index 0000000..6ddc2c1 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs @@ -0,0 +1,232 @@ +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.Rides; + +public sealed class WeatherLookupServiceTests +{ + [Fact] + public void OpenMeteoWeatherLookupService_Type_ShouldExist() + { + var type = Type.GetType( + "BikeTracking.Api.Application.Rides.OpenMeteoWeatherLookupService, BikeTracking.Api" + ); + + Assert.NotNull(type); + } + + [Fact] + public void IWeatherLookupService_Interface_ShouldExist() + { + var type = Type.GetType( + "BikeTracking.Api.Application.Rides.IWeatherLookupService, BikeTracking.Api" + ); + + Assert.NotNull(type); + } + + [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(); + + var lookupHour = new DateTime(2026, 4, 1, 12, 0, 0, DateTimeKind.Utc); + context.WeatherLookups.Add( + new WeatherLookupEntity + { + LookupHourUtc = lookupHour, + LatitudeRounded = 40.71m, + LongitudeRounded = -74.01m, + Temperature = 63.2m, + WindSpeedMph = 11.3m, + WindDirectionDeg = 250, + RelativeHumidityPercent = 62, + CloudCoverPercent = 25, + PrecipitationType = "rain", + DataSource = "OpenMeteo", + RetrievedAtUtc = DateTime.UtcNow, + Status = "success", + } + ); + 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)); + var config = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + var service = new OpenMeteoWeatherLookupService( + context, + factory, + config, + NullLogger.Instance + ); + + var result = await service.GetOrFetchAsync(40.7128m, -74.0060m, lookupHour); + + Assert.NotNull(result); + Assert.Equal(63.2m, result!.Temperature); + Assert.Equal(11.3m, result.WindSpeedMph); + Assert.Equal(0, handler.CallCount); + } + + [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( + CreateHourlyResponseJson(), + Encoding.UTF8, + "application/json" + ), + }); + + var factory = new StubHttpClientFactory(new HttpClient(handler)); + var config = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + var service = new OpenMeteoWeatherLookupService( + context, + factory, + config, + NullLogger.Instance + ); + + var lookupTime = new DateTime(2026, 4, 2, 9, 34, 0, DateTimeKind.Utc); + + var first = await service.GetOrFetchAsync(40.7128m, -74.0060m, lookupTime); + var second = await service.GetOrFetchAsync(40.7128m, -74.0060m, lookupTime); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal(first!.Temperature, second!.Temperature); + Assert.Equal(1, handler.CallCount); + + var cacheCount = await context.WeatherLookups.CountAsync(); + Assert.Equal(1, cacheCount); + } + + [Fact] + public async Task GetOrFetchAsync_AfterServiceRestart_UsesPersistedCacheWithoutHttp() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var lookupHour = new DateTime(2026, 4, 3, 7, 0, 0, DateTimeKind.Utc); + + await using (var setupContext = CreateSqliteContext(connection)) + { + await setupContext.Database.EnsureCreatedAsync(); + setupContext.WeatherLookups.Add( + new WeatherLookupEntity + { + LookupHourUtc = lookupHour, + LatitudeRounded = 37.77m, + LongitudeRounded = -122.42m, + Temperature = 58.4m, + WindSpeedMph = 6.2m, + WindDirectionDeg = 210, + RelativeHumidityPercent = 70, + CloudCoverPercent = 45, + PrecipitationType = null, + DataSource = "OpenMeteo", + RetrievedAtUtc = DateTime.UtcNow, + Status = "success", + } + ); + await setupContext.SaveChangesAsync(); + } + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + CreateHourlyResponseJson(), + Encoding.UTF8, + "application/json" + ), + }); + var factory = new StubHttpClientFactory(new HttpClient(handler)); + var config = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + await using var restartedContext = CreateSqliteContext(connection); + var restartedService = new OpenMeteoWeatherLookupService( + restartedContext, + factory, + config, + NullLogger.Instance + ); + + var result = await restartedService.GetOrFetchAsync(37.7749m, -122.4194m, lookupHour); + + Assert.NotNull(result); + Assert.Equal(58.4m, result!.Temperature); + Assert.Equal(6.2m, result.WindSpeedMph); + Assert.Equal(0, handler.CallCount); + } + + private static BikeTrackingDbContext CreateSqliteContext(SqliteConnection connection) + { + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + return new BikeTrackingDbContext(options); + } + + private static string CreateHourlyResponseJson() + { + return """ + { + "hourly": { + "time": ["2026-04-02T09:00"], + "temperature_2m": [60.5], + "wind_speed_10m": [9.2], + "wind_direction_10m": [240], + "relative_humidity_2m": [57], + "cloud_cover": [35], + "precipitation": [0.2], + "snowfall": [0.0], + "weather_code": [61] + } + } + """; + } + + 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++; + return Task.FromResult(handler(request)); + } + } +} diff --git a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs index 0bb9731..d2cd74e 100644 --- a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs @@ -4,6 +4,7 @@ using BikeTracking.Api.Infrastructure.Persistence.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace BikeTracking.Api.Tests.Application; @@ -23,9 +24,11 @@ public async Task RecordRideService_WithValidRequest_PersistsRideAndCreatesEvent context.Users.Add(user); await context.SaveChangesAsync(); - var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - var service = new RecordRideService(context, logger); + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); var request = new RecordRideRequest(DateTime.Now, 10.5m, 45, 72m); var (rideId, eventPayload) = await service.ExecuteAsync(user.UserId, request); @@ -44,6 +47,57 @@ public async Task RecordRideService_WithValidRequest_PersistsRideAndCreatesEvent Assert.Equal(10.5m, persistedRide.Miles); } + [Fact] + public async Task RecordRideService_WithWeatherFields_PersistsWeatherAndEventPayload() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Weather Rider", + NormalizedName = "weather rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + var request = new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 12.5m, + RideMinutes: 41, + Temperature: 62m, + GasPricePerGallon: 3.1999m, + WindSpeedMph: 7.5m, + WindDirectionDeg: 250, + RelativeHumidityPercent: 60, + CloudCoverPercent: 45, + PrecipitationType: "rain", + WeatherUserOverridden: true + ); + + var (rideId, eventPayload) = await service.ExecuteAsync(user.UserId, request); + + var persistedRide = await context.Rides.FindAsync(rideId); + Assert.NotNull(persistedRide); + Assert.Equal(7.5m, persistedRide.WindSpeedMph); + Assert.Equal(250, persistedRide.WindDirectionDeg); + Assert.Equal(60, persistedRide.RelativeHumidityPercent); + Assert.Equal(45, persistedRide.CloudCoverPercent); + Assert.Equal("rain", persistedRide.PrecipitationType); + Assert.True(persistedRide.WeatherUserOverridden); + + Assert.Equal(7.5m, eventPayload.WindSpeedMph); + Assert.Equal(250, eventPayload.WindDirectionDeg); + Assert.Equal(60, eventPayload.RelativeHumidityPercent); + Assert.Equal(45, eventPayload.CloudCoverPercent); + Assert.Equal("rain", eventPayload.PrecipitationType); + Assert.True(eventPayload.WeatherUserOverridden); + } + [Fact] public async Task RecordRideService_ValidatesMillesGreaterThanZero() { @@ -57,7 +111,11 @@ public async Task RecordRideService_ValidatesMillesGreaterThanZero() context.Users.Add(user); await context.SaveChangesAsync(); - var service = new RecordRideService(context, null!); + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); var request = new RecordRideRequest(DateTime.Now, 0m); await Assert.ThrowsAsync(() => @@ -78,7 +136,11 @@ public async Task RecordRideService_ValidatesRideMinutesGreaterThanZeroWhenProvi context.Users.Add(user); await context.SaveChangesAsync(); - var service = new RecordRideService(context, null!); + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); var request = new RecordRideRequest(DateTime.Now, 10m, -5); await Assert.ThrowsAsync(() => @@ -99,7 +161,11 @@ public async Task RecordRideService_ValidatesMilesLessThanOrEqualToTwoHundred() context.Users.Add(user); await context.SaveChangesAsync(); - var service = new RecordRideService(context, null!); + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); var request = new RecordRideRequest(DateTime.Now, 201m); await Assert.ThrowsAsync(() => @@ -167,6 +233,48 @@ public async Task GetRideDefaultsService_ReturnsLastRideDefaults() Assert.Equal(72m, defaults.DefaultTemperature); } + [Fact] + public async Task GetRideDefaultsService_ReturnsLatestWeatherDefaults() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Weather Defaults", + NormalizedName = "weather defaults", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + context.Rides.Add( + new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = DateTime.Now.AddMinutes(-10), + Miles = 4.2m, + RideMinutes = 15, + Temperature = 58m, + WindSpeedMph = 9.1m, + WindDirectionDeg = 245, + RelativeHumidityPercent = 67, + CloudCoverPercent = 30, + PrecipitationType = "snow", + WeatherUserOverridden = true, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var service = new GetRideDefaultsService(context); + var defaults = await service.ExecuteAsync(user.UserId); + + Assert.Equal(9.1m, defaults.DefaultWindSpeedMph); + Assert.Equal(245, defaults.DefaultWindDirectionDeg); + Assert.Equal(67, defaults.DefaultRelativeHumidityPercent); + Assert.Equal(30, defaults.DefaultCloudCoverPercent); + Assert.Equal("snow", defaults.DefaultPrecipitationType); + } + [Fact] public async Task GetQuickRideOptionsService_ReturnsOnlyAuthenticatedRiderOptions() { @@ -522,7 +630,7 @@ public async Task EditRideService_WithValidRequest_UpdatesRideVersionAndQueuesOu var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); var logger = loggerFactory.CreateLogger(); - var service = new EditRideService(context, logger); + var service = new EditRideService(context, new StubWeatherLookupService(), logger); var request = new EditRideRequest( RideDateTimeLocal: DateTime.Now, @@ -584,7 +692,7 @@ public async Task GetRideHistoryService_RecalculatesSummariesAfterRideEdit() var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); var editLogger = loggerFactory.CreateLogger(); - var editService = new EditRideService(context, editLogger); + var editService = new EditRideService(context, new StubWeatherLookupService(), editLogger); var editResult = await editService.ExecuteAsync( user.UserId, @@ -608,6 +716,140 @@ public async Task GetRideHistoryService_RecalculatesSummariesAfterRideEdit() Assert.Equal(9.5m, afterEdit.Rides[0].Miles); } + [Fact] + public async Task RecordRideService_WhenUserSuppliesWeather_UsesUserValuesOverFetchedData() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Mina", + NormalizedName = "mina", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + context.UserSettings.Add( + new UserSettingsEntity + { + UserId = user.UserId, + Latitude = 40.71m, + Longitude = -74.01m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var weatherLookup = new TrackingWeatherLookupService( + new WeatherData(72m, 15m, 320, 55, 80, "rain") + ); + var service = new RecordRideService( + context, + weatherLookup, + NullLogger.Instance + ); + + var (_, payload) = await service.ExecuteAsync( + user.UserId, + new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 10m, + RideMinutes: 35, + Temperature: 66m, + WindSpeedMph: 9m, + WindDirectionDeg: 260, + RelativeHumidityPercent: 60, + CloudCoverPercent: 45, + PrecipitationType: "snow", + WeatherUserOverridden: false + ) + ); + + var persistedRide = await context.Rides.OrderByDescending(r => r.Id).FirstAsync(); + Assert.Equal(1, weatherLookup.CallCount); + Assert.Equal(66m, persistedRide.Temperature); + Assert.Equal(9m, persistedRide.WindSpeedMph); + Assert.Equal(260, persistedRide.WindDirectionDeg); + Assert.Equal(60, persistedRide.RelativeHumidityPercent); + Assert.Equal(45, persistedRide.CloudCoverPercent); + Assert.Equal("snow", persistedRide.PrecipitationType); + Assert.False(persistedRide.WeatherUserOverridden); + + Assert.Equal(66m, payload.Temperature); + Assert.Equal(9m, payload.WindSpeedMph); + Assert.Equal(260, payload.WindDirectionDeg); + Assert.Equal("snow", payload.PrecipitationType); + } + + [Fact] + public async Task EditRideService_WhenTimestampUnchanged_DoesNotRefetchWeather() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Nora", + NormalizedName = "nora", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var originalDate = DateTime.Now.AddHours(-2); + var ride = new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = originalDate, + Miles = 12m, + RideMinutes = 40, + Temperature = 61m, + WindSpeedMph = 8m, + WindDirectionDeg = 250, + RelativeHumidityPercent = 63, + CloudCoverPercent = 30, + PrecipitationType = "rain", + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + }; + context.Rides.Add(ride); + await context.SaveChangesAsync(); + + var weatherLookup = new TrackingWeatherLookupService( + new WeatherData(80m, 20m, 300, 45, 10, "snow") + ); + var service = new EditRideService( + context, + weatherLookup, + NullLogger.Instance + ); + + var result = await service.ExecuteAsync( + user.UserId, + ride.Id, + new EditRideRequest( + RideDateTimeLocal: originalDate, + Miles: 12.2m, + RideMinutes: 41, + Temperature: null, + ExpectedVersion: 1, + WindSpeedMph: null, + WindDirectionDeg: null, + RelativeHumidityPercent: null, + CloudCoverPercent: null, + PrecipitationType: null, + WeatherUserOverridden: false + ) + ); + + Assert.True(result.IsSuccess); + Assert.Equal(0, weatherLookup.CallCount); + + var persistedRide = await context.Rides.SingleAsync(r => r.Id == ride.Id); + Assert.Equal(61m, persistedRide.Temperature); + Assert.Equal(8m, persistedRide.WindSpeedMph); + Assert.Equal(250, persistedRide.WindDirectionDeg); + Assert.Equal("rain", persistedRide.PrecipitationType); + } + private static BikeTrackingDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() @@ -617,3 +859,29 @@ private static BikeTrackingDbContext CreateDbContext() return new BikeTrackingDbContext(options); } } + +internal sealed class StubWeatherLookupService : IWeatherLookupService +{ + public Task GetOrFetchAsync( + decimal latitude, + decimal longitude, + DateTime dateTimeUtc, + CancellationToken cancellationToken = default + ) => Task.FromResult(null); +} + +internal sealed class TrackingWeatherLookupService(WeatherData? response) : IWeatherLookupService +{ + public int CallCount { get; private set; } + + public Task GetOrFetchAsync( + decimal latitude, + decimal longitude, + DateTime dateTimeUtc, + CancellationToken cancellationToken = default + ) + { + CallCount++; + return Task.FromResult(response); + } +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs index 8a72260..e30a0f3 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs @@ -122,6 +122,7 @@ public static async Task StartAsync() builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); var app = builder.Build(); app.UseAuthentication(); @@ -190,6 +191,16 @@ internal sealed record DeleteRideSuccessResponse( bool IsIdempotent = false ); +internal sealed class StubWeatherLookupService : IWeatherLookupService +{ + public Task GetOrFetchAsync( + decimal latitude, + decimal longitude, + DateTime dateTimeUtc, + CancellationToken cancellationToken = default + ) => Task.FromResult(null); +} + internal class TestAuthenticationSchemeOptions : Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions { } diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs index 0727c94..274ec45 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs @@ -75,6 +75,7 @@ public static async Task StartAsync() builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); var app = builder.Build(); diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs index 1619eec..e3647f3 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs @@ -150,6 +150,47 @@ await host.RecordRideAsync( Assert.Equal(3.4999m, payload.DefaultGasPricePerGallon); } + [Fact] + public async Task GetRideDefaults_WithWeatherOnPreviousRide_ReturnsWeatherDefaults() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("WeatherDefaults"); + + using (var scope = host.App.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Rides.Add( + new RideEntity + { + RiderId = userId, + RideDateTimeLocal = DateTime.Now.AddHours(-3), + Miles = 6.6m, + RideMinutes = 24, + Temperature = 61m, + WindSpeedMph = 10.3m, + WindDirectionDeg = 255, + RelativeHumidityPercent = 71, + CloudCoverPercent = 48, + PrecipitationType = "snow", + WeatherUserOverridden = true, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + } + + var response = await host.Client.GetWithAuthAsync("/api/rides/defaults", userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal(10.3m, payload.DefaultWindSpeedMph); + Assert.Equal(255, payload.DefaultWindDirectionDeg); + Assert.Equal(71, payload.DefaultRelativeHumidityPercent); + Assert.Equal(48, payload.DefaultCloudCoverPercent); + Assert.Equal("snow", payload.DefaultPrecipitationType); + } + [Fact] public async Task GetGasPrice_WithValidDate_ReturnsShape() { @@ -219,6 +260,44 @@ public async Task PostRecordRide_WithGasPrice_PersistsGasPrice() Assert.Equal(3.2777m, ride.GasPricePerGallon); } + [Fact] + public async Task PostRecordRide_WithWeatherFields_PersistsWeatherSnapshot() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("WeatherPersist"); + + var request = new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 9.4m, + RideMinutes: 34, + Temperature: 57m, + GasPricePerGallon: 3.1010m, + WindSpeedMph: 12.2m, + WindDirectionDeg: 275, + RelativeHumidityPercent: 64, + CloudCoverPercent: 52, + PrecipitationType: "rain", + WeatherUserOverridden: true + ); + + 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(12.2m, ride.WindSpeedMph); + Assert.Equal(275, ride.WindDirectionDeg); + Assert.Equal(64, ride.RelativeHumidityPercent); + Assert.Equal(52, ride.CloudCoverPercent); + Assert.Equal("rain", ride.PrecipitationType); + Assert.True(ride.WeatherUserOverridden); + } + [Fact] public async Task PostRecordRide_WithNullGasPrice_PersistsNull() { @@ -655,6 +734,7 @@ public static async Task StartAsync() builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); var app = builder.Build(); app.UseAuthentication(); @@ -812,3 +892,30 @@ internal sealed class StubGasPriceLookupService : IGasPriceLookupService return Task.FromResult(null); } } + +internal sealed class StubWeatherLookupService : IWeatherLookupService +{ + public Task GetOrFetchAsync( + decimal latitude, + decimal longitude, + DateTime dateTimeUtc, + CancellationToken cancellationToken = default + ) + { + if (latitude == 40.71m && longitude == -74.01m) + { + return Task.FromResult( + new WeatherData( + Temperature: 72.5m, + WindSpeedMph: 10.3m, + WindDirectionDeg: 250, + RelativeHumidityPercent: 65, + CloudCoverPercent: 30, + PrecipitationType: null + ) + ); + } + + return Task.FromResult(null); + } +} diff --git a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs index 7207b82..0027a17 100644 --- a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs +++ b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs @@ -24,6 +24,10 @@ public sealed class MigrationTestCoveragePolicyTests "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.", + ["20260403192400_AddWeatherFieldsToRides"] = + "Added test: rides persistence tests validate weather columns round-trip after schema migration.", + ["20260403192854_AddWeatherLookupCache"] = + "Added test: weather lookup service tests validate cache read/write through weather lookup table.", }; [Fact] diff --git a/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs b/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs index e289b69..0781e7d 100644 --- a/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs +++ b/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs @@ -13,6 +13,12 @@ public sealed record RideEditedEventPayload( int? RideMinutes, decimal? Temperature, decimal? GasPricePerGallon, + decimal? WindSpeedMph, + int? WindDirectionDeg, + int? RelativeHumidityPercent, + int? CloudCoverPercent, + string? PrecipitationType, + bool WeatherUserOverridden, string Source ) { @@ -29,6 +35,12 @@ public static RideEditedEventPayload Create( int? rideMinutes = null, decimal? temperature = null, decimal? gasPricePerGallon = null, + decimal? windSpeedMph = null, + int? windDirectionDeg = null, + int? relativeHumidityPercent = null, + int? cloudCoverPercent = null, + string? precipitationType = null, + bool weatherUserOverridden = false, DateTime? occurredAtUtc = null ) { @@ -45,6 +57,12 @@ public static RideEditedEventPayload Create( RideMinutes: rideMinutes, Temperature: temperature, GasPricePerGallon: gasPricePerGallon, + WindSpeedMph: windSpeedMph, + WindDirectionDeg: windDirectionDeg, + RelativeHumidityPercent: relativeHumidityPercent, + CloudCoverPercent: cloudCoverPercent, + PrecipitationType: precipitationType, + WeatherUserOverridden: weatherUserOverridden, Source: SourceName ); } diff --git a/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs b/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs index a2484dc..ec58b8a 100644 --- a/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs +++ b/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs @@ -10,6 +10,12 @@ public sealed record RideRecordedEventPayload( int? RideMinutes, decimal? Temperature, decimal? GasPricePerGallon, + decimal? WindSpeedMph, + int? WindDirectionDeg, + int? RelativeHumidityPercent, + int? CloudCoverPercent, + string? PrecipitationType, + bool WeatherUserOverridden, string Source ) { @@ -23,6 +29,12 @@ public static RideRecordedEventPayload Create( int? rideMinutes = null, decimal? temperature = null, decimal? gasPricePerGallon = null, + decimal? windSpeedMph = null, + int? windDirectionDeg = null, + int? relativeHumidityPercent = null, + int? cloudCoverPercent = null, + string? precipitationType = null, + bool weatherUserOverridden = false, DateTime? occurredAtUtc = null ) { @@ -36,6 +48,12 @@ public static RideRecordedEventPayload Create( RideMinutes: rideMinutes, Temperature: temperature, GasPricePerGallon: gasPricePerGallon, + WindSpeedMph: windSpeedMph, + WindDirectionDeg: windDirectionDeg, + RelativeHumidityPercent: relativeHumidityPercent, + CloudCoverPercent: cloudCoverPercent, + PrecipitationType: precipitationType, + WeatherUserOverridden: weatherUserOverridden, Source: SourceName ); } diff --git a/src/BikeTracking.Api/Application/Rides/EditRideService.cs b/src/BikeTracking.Api/Application/Rides/EditRideService.cs index 22e0f76..bf459f5 100644 --- a/src/BikeTracking.Api/Application/Rides/EditRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/EditRideService.cs @@ -10,6 +10,7 @@ namespace BikeTracking.Api.Application.Rides; public sealed class EditRideService( BikeTrackingDbContext dbContext, + IWeatherLookupService weatherLookupService, ILogger logger ) { @@ -85,11 +86,50 @@ public async Task ExecuteAsync( ); } + var existingRideDateTimeLocal = ride.RideDateTimeLocal; + var rideDateTimeChanged = request.RideDateTimeLocal != existingRideDateTimeLocal; + + WeatherData? fetchedWeather = null; + if (!request.WeatherUserOverridden && rideDateTimeChanged) + { + var userSettings = await dbContext + .UserSettings.AsNoTracking() + .SingleOrDefaultAsync(settings => settings.UserId == riderId, cancellationToken); + + if ( + userSettings?.Latitude is decimal latitude + && userSettings.Longitude is decimal longitude + ) + { + fetchedWeather = await weatherLookupService.GetOrFetchAsync( + latitude, + longitude, + request.RideDateTimeLocal.ToUniversalTime(), + cancellationToken + ); + } + } + + var ( + temperature, + windSpeedMph, + windDirectionDeg, + relativeHumidityPercent, + cloudCoverPercent, + precipitationType + ) = MergeWeatherForEdit(ride, request, fetchedWeather, rideDateTimeChanged); + ride.RideDateTimeLocal = request.RideDateTimeLocal; ride.Miles = request.Miles; ride.RideMinutes = request.RideMinutes; - ride.Temperature = request.Temperature; + ride.Temperature = temperature; ride.GasPricePerGallon = request.GasPricePerGallon; + ride.WindSpeedMph = windSpeedMph; + ride.WindDirectionDeg = windDirectionDeg; + ride.RelativeHumidityPercent = relativeHumidityPercent; + ride.CloudCoverPercent = cloudCoverPercent; + ride.PrecipitationType = precipitationType; + ride.WeatherUserOverridden = request.WeatherUserOverridden; ride.Version = currentVersion + 1; var utcNow = DateTime.UtcNow; @@ -102,8 +142,14 @@ public async Task ExecuteAsync( rideDateTimeLocal: ride.RideDateTimeLocal, miles: ride.Miles, rideMinutes: ride.RideMinutes, - temperature: ride.Temperature, + temperature: temperature, gasPricePerGallon: ride.GasPricePerGallon, + windSpeedMph: windSpeedMph, + windDirectionDeg: windDirectionDeg, + relativeHumidityPercent: relativeHumidityPercent, + cloudCoverPercent: cloudCoverPercent, + precipitationType: precipitationType, + weatherUserOverridden: request.WeatherUserOverridden, occurredAtUtc: utcNow ); @@ -186,4 +232,54 @@ public async Task ExecuteAsync( return null; } + + private static ( + decimal? temperature, + decimal? windSpeedMph, + int? windDirectionDeg, + int? relativeHumidityPercent, + int? cloudCoverPercent, + string? precipitationType + ) MergeWeatherForEdit( + RideEntity existingRide, + EditRideRequest request, + WeatherData? fetchedWeather, + bool rideDateTimeChanged + ) + { + if (request.WeatherUserOverridden) + { + return ( + request.Temperature, + request.WindSpeedMph, + request.WindDirectionDeg, + request.RelativeHumidityPercent, + request.CloudCoverPercent, + request.PrecipitationType + ); + } + + if (rideDateTimeChanged) + { + return ( + temperature: request.Temperature ?? fetchedWeather?.Temperature, + windSpeedMph: request.WindSpeedMph ?? fetchedWeather?.WindSpeedMph, + windDirectionDeg: request.WindDirectionDeg ?? fetchedWeather?.WindDirectionDeg, + relativeHumidityPercent: request.RelativeHumidityPercent + ?? fetchedWeather?.RelativeHumidityPercent, + cloudCoverPercent: request.CloudCoverPercent ?? fetchedWeather?.CloudCoverPercent, + precipitationType: request.PrecipitationType ?? fetchedWeather?.PrecipitationType + ); + } + + return ( + temperature: request.Temperature ?? existingRide.Temperature, + windSpeedMph: request.WindSpeedMph ?? existingRide.WindSpeedMph, + windDirectionDeg: request.WindDirectionDeg ?? existingRide.WindDirectionDeg, + relativeHumidityPercent: request.RelativeHumidityPercent + ?? existingRide.RelativeHumidityPercent, + cloudCoverPercent: request.CloudCoverPercent ?? existingRide.CloudCoverPercent, + precipitationType: request.PrecipitationType ?? existingRide.PrecipitationType + ); + } } diff --git a/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs b/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs index e00221c..ee7c304 100644 --- a/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs +++ b/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs @@ -38,7 +38,12 @@ public async Task ExecuteAsync( DefaultMiles: lastRide.Miles, DefaultRideMinutes: lastRide.RideMinutes, DefaultTemperature: lastRide.Temperature, - DefaultGasPricePerGallon: lastRide.GasPricePerGallon + DefaultGasPricePerGallon: lastRide.GasPricePerGallon, + DefaultWindSpeedMph: lastRide.WindSpeedMph, + DefaultWindDirectionDeg: lastRide.WindDirectionDeg, + DefaultRelativeHumidityPercent: lastRide.RelativeHumidityPercent, + DefaultCloudCoverPercent: lastRide.CloudCoverPercent, + DefaultPrecipitationType: lastRide.PrecipitationType ); } } diff --git a/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs b/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs index e6fb4ad..577ab49 100644 --- a/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs +++ b/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs @@ -117,7 +117,12 @@ public async Task GetRideHistoryAsync( Miles: r.Miles, RideMinutes: r.RideMinutes, Temperature: r.Temperature, - GasPricePerGallon: r.GasPricePerGallon + GasPricePerGallon: r.GasPricePerGallon, + WindSpeedMph: r.WindSpeedMph, + WindDirectionDeg: r.WindDirectionDeg, + RelativeHumidityPercent: r.RelativeHumidityPercent, + CloudCoverPercent: r.CloudCoverPercent, + PrecipitationType: r.PrecipitationType )) .ToList(); diff --git a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs index 52907a9..23d9802 100644 --- a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs @@ -2,65 +2,143 @@ using BikeTracking.Api.Contracts; using BikeTracking.Api.Infrastructure.Persistence; using BikeTracking.Api.Infrastructure.Persistence.Entities; -using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; namespace BikeTracking.Api.Application.Rides; -public class RecordRideService(BikeTrackingDbContext dbContext, ILogger logger) +public sealed class RecordRideService( + BikeTrackingDbContext dbContext, + IWeatherLookupService weatherLookupService, + ILogger logger +) { - private readonly BikeTrackingDbContext _dbContext = dbContext; - private readonly ILogger _logger = logger; - - /// - /// Records a ride and creates an event payload for outbox publishing. - /// public async Task<(int rideId, RideRecordedEventPayload eventPayload)> ExecuteAsync( long riderId, RecordRideRequest request, CancellationToken cancellationToken = default ) { - // Validation if (request.Miles <= 0) - throw new ArgumentException("Miles must be greater than 0"); + { + throw new ArgumentException("Miles must be greater than 0", nameof(request)); + } if (request.Miles > 200) - throw new ArgumentException("Miles must be less than or equal to 200"); + { + throw new ArgumentException("Miles must be less than or equal to 200", nameof(request)); + } + + if (request.RideMinutes.HasValue && request.RideMinutes.Value <= 0) + { + throw new ArgumentException( + "Ride minutes must be greater than 0 when provided", + nameof(request) + ); + } + + WeatherData? weatherData = null; + if (!request.WeatherUserOverridden) + { + var userSettings = await dbContext + .UserSettings.AsNoTracking() + .SingleOrDefaultAsync(settings => settings.UserId == riderId, cancellationToken); - if (request.RideMinutes.HasValue && request.RideMinutes <= 0) - throw new ArgumentException("Ride minutes must be greater than 0"); + if ( + userSettings?.Latitude is decimal latitude + && userSettings.Longitude is decimal longitude + ) + { + weatherData = await weatherLookupService.GetOrFetchAsync( + latitude, + longitude, + request.RideDateTimeLocal.ToUniversalTime(), + cancellationToken + ); + } + } + + var temperature = request.Temperature ?? weatherData?.Temperature; + var ( + windSpeedMph, + windDirectionDeg, + relativeHumidityPercent, + cloudCoverPercent, + precipitationType + ) = MergeWeatherFields( + weatherData, + request.WindSpeedMph, + request.WindDirectionDeg, + request.RelativeHumidityPercent, + request.CloudCoverPercent, + request.PrecipitationType + ); - // Create ride entity var rideEntity = new RideEntity { RiderId = riderId, RideDateTimeLocal = request.RideDateTimeLocal, Miles = request.Miles, RideMinutes = request.RideMinutes, - Temperature = request.Temperature, + Temperature = temperature, GasPricePerGallon = request.GasPricePerGallon, + WindSpeedMph = windSpeedMph, + WindDirectionDeg = windDirectionDeg, + RelativeHumidityPercent = relativeHumidityPercent, + CloudCoverPercent = cloudCoverPercent, + PrecipitationType = precipitationType, + WeatherUserOverridden = request.WeatherUserOverridden, CreatedAtUtc = DateTime.UtcNow, }; - _dbContext.Rides.Add(rideEntity); - await _dbContext.SaveChangesAsync(cancellationToken); + dbContext.Rides.Add(rideEntity); + await dbContext.SaveChangesAsync(cancellationToken); - // Create event payload var eventPayload = RideRecordedEventPayload.Create( riderId: riderId, rideDateTimeLocal: request.RideDateTimeLocal, miles: request.Miles, rideMinutes: request.RideMinutes, - temperature: request.Temperature, - gasPricePerGallon: request.GasPricePerGallon + temperature: temperature, + gasPricePerGallon: request.GasPricePerGallon, + windSpeedMph: windSpeedMph, + windDirectionDeg: windDirectionDeg, + relativeHumidityPercent: relativeHumidityPercent, + cloudCoverPercent: cloudCoverPercent, + precipitationType: precipitationType, + weatherUserOverridden: request.WeatherUserOverridden ); - _logger.LogInformation( - "Recorded ride {RideId} for rider {RiderId}", + logger.LogInformation( + "Recorded ride {RideId} for rider {RiderId} at {RideDateTimeLocal}", rideEntity.Id, - riderId + riderId, + request.RideDateTimeLocal ); return (rideEntity.Id, eventPayload); } + + private static ( + decimal? windSpeedMph, + int? windDirectionDeg, + int? relativeHumidityPercent, + int? cloudCoverPercent, + string? precipitationType + ) MergeWeatherFields( + WeatherData? weatherData, + decimal? userWindSpeed, + int? userWindDir, + int? userHumidity, + int? userCloudCover, + string? userPrecipType + ) + { + return ( + windSpeedMph: userWindSpeed ?? weatherData?.WindSpeedMph, + windDirectionDeg: userWindDir ?? weatherData?.WindDirectionDeg, + relativeHumidityPercent: userHumidity ?? weatherData?.RelativeHumidityPercent, + cloudCoverPercent: userCloudCover ?? weatherData?.CloudCoverPercent, + precipitationType: userPrecipType ?? weatherData?.PrecipitationType + ); + } } diff --git a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs new file mode 100644 index 0000000..4534ca6 --- /dev/null +++ b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs @@ -0,0 +1,422 @@ +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; + +/// +/// Weather data snapshot for a specific location and time. +/// +public sealed record WeatherData( + decimal? Temperature, + decimal? WindSpeedMph, + int? WindDirectionDeg, + int? RelativeHumidityPercent, + int? CloudCoverPercent, + string? PrecipitationType +); + +public interface IWeatherLookupService +{ + /// + /// Get or fetch weather data for the specified location and UTC hour. + /// Uses server-side caching keyed by hour-bucket and rounded coordinates. + /// Returns null on error (graceful degradation). + /// + Task GetOrFetchAsync( + decimal latitude, + decimal longitude, + DateTime dateTimeUtc, + CancellationToken cancellationToken = default + ); +} + +public sealed class OpenMeteoWeatherLookupService( + BikeTrackingDbContext dbContext, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger +) : IWeatherLookupService +{ + private const string DataSourceName = "OpenMeteo"; + + public async Task GetOrFetchAsync( + decimal latitude, + decimal longitude, + DateTime dateTimeUtc, + CancellationToken cancellationToken = default + ) + { + // Compute cache key + var lookupHourUtc = new DateTime( + dateTimeUtc.Year, + dateTimeUtc.Month, + dateTimeUtc.Day, + dateTimeUtc.Hour, + 0, + 0, + DateTimeKind.Utc + ); + var latRounded = Math.Round(latitude, 2); + var lonRounded = Math.Round(longitude, 2); + + // Check cache first + var cached = await dbContext + .WeatherLookups.AsNoTracking() + .SingleOrDefaultAsync( + x => + x.LookupHourUtc == lookupHourUtc + && x.LatitudeRounded == latRounded + && x.LongitudeRounded == lonRounded, + cancellationToken + ); + + if (cached is not null && cached.Status == "success") + { + logger.LogInformation( + "Weather cache hit for {LatitudeRounded},{LongitudeRounded} at {LookupHourUtc}", + latRounded, + lonRounded, + lookupHourUtc + ); + return new WeatherData( + cached.Temperature, + cached.WindSpeedMph, + cached.WindDirectionDeg, + cached.RelativeHumidityPercent, + cached.CloudCoverPercent, + cached.PrecipitationType + ); + } + + logger.LogInformation( + "Weather cache miss for {LatitudeRounded},{LongitudeRounded} at {LookupHourUtc}", + latRounded, + lonRounded, + lookupHourUtc + ); + + // Determine which API to call (forecast vs. archive) + var daysDiff = (int)(DateTime.UtcNow.Date - dateTimeUtc.Date).TotalDays; + var isHistorical = daysDiff > 92; + var endpoint = isHistorical + ? "https://archive-api.open-meteo.com/v1/archive" + : "https://api.open-meteo.com/v1/forecast"; + + var apiKey = configuration["WeatherLookup:ApiKey"]; + var apiKeyParam = string.IsNullOrWhiteSpace(apiKey) + ? string.Empty + : $"&apikey={Uri.EscapeDataString(apiKey)}"; + + // Build query parameters + var pastDaysParam = isHistorical ? "" : $"&past_days={Math.Min(daysDiff + 1, 92)}"; + var queryParams = + $"?latitude={Uri.EscapeDataString(latitude.ToString(CultureInfo.InvariantCulture))}" + + $"&longitude={Uri.EscapeDataString(longitude.ToString(CultureInfo.InvariantCulture))}" + + $"&start_date={dateTimeUtc.Date:yyyy-MM-dd}" + + $"&end_date={dateTimeUtc.Date:yyyy-MM-dd}" + + $"{pastDaysParam}" + + "&hourly=temperature_2m,wind_speed_10m,wind_direction_10m,relative_humidity_2m,cloud_cover,precipitation,weather_code" + + "&temperature_unit=fahrenheit" + + "&wind_speed_unit=mph" + + "&timezone=auto" + + apiKeyParam; + + try + { + var client = httpClientFactory.CreateClient("OpenMeteo"); + using var response = await client.GetAsync( + $"{endpoint}{queryParams}", + cancellationToken + ); + + if (!response.IsSuccessStatusCode) + { + logger.LogWarning( + "Open-Meteo lookup failed for {Latitude},{Longitude} at {UtcHour} with status {StatusCode}", + latitude, + longitude, + dateTimeUtc, + response.StatusCode + ); + + // Cache failure for short period to avoid hammering API + await TryCacheFailure(lookupHourUtc, latRounded, lonRounded, cancellationToken); + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var jsonDoc = await JsonDocument.ParseAsync( + stream, + cancellationToken: cancellationToken + ); + + if ( + !TryReadWeatherAtHour( + jsonDoc.RootElement, + dateTimeUtc.Hour, + out var temp, + out var windSpeed, + out var windDir, + out var humidity, + out var cloudCover, + out var precipType + ) + ) + { + logger.LogWarning( + "Open-Meteo response missing or malformed data for {Latitude},{Longitude} at {UtcHour}", + latitude, + longitude, + dateTimeUtc + ); + await TryCacheFailure(lookupHourUtc, latRounded, lonRounded, cancellationToken); + return null; + } + + // Write successful result to cache + var entry = new WeatherLookupEntity + { + LookupHourUtc = lookupHourUtc, + LatitudeRounded = latRounded, + LongitudeRounded = lonRounded, + Temperature = temp, + WindSpeedMph = windSpeed, + WindDirectionDeg = windDir, + RelativeHumidityPercent = humidity, + CloudCoverPercent = cloudCover, + PrecipitationType = precipType, + DataSource = DataSourceName, + RetrievedAtUtc = DateTime.UtcNow, + Status = "success", + }; + + try + { + dbContext.WeatherLookups.Add(entry); + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Weather cache populated for {LatitudeRounded},{LongitudeRounded} at {LookupHourUtc}", + latRounded, + lonRounded, + lookupHourUtc + ); + } + catch (DbUpdateException ex) + { + // Race condition: another request already cached this entry. Query and return it. + logger.LogDebug( + ex, + "Concurrent weather cache insert for {Latitude},{Longitude} at {UtcHour}; using cached entry", + latitude, + longitude, + dateTimeUtc + ); + + var raceWinnerCache = await dbContext + .WeatherLookups.AsNoTracking() + .SingleOrDefaultAsync( + x => + x.LookupHourUtc == lookupHourUtc + && x.LatitudeRounded == latRounded + && x.LongitudeRounded == lonRounded, + cancellationToken + ); + + if (raceWinnerCache is not null && raceWinnerCache.Status == "success") + { + logger.LogInformation( + "Weather cache recovered after concurrent insert for {LatitudeRounded},{LongitudeRounded} at {LookupHourUtc}", + latRounded, + lonRounded, + lookupHourUtc + ); + return new WeatherData( + raceWinnerCache.Temperature, + raceWinnerCache.WindSpeedMph, + raceWinnerCache.WindDirectionDeg, + raceWinnerCache.RelativeHumidityPercent, + raceWinnerCache.CloudCoverPercent, + raceWinnerCache.PrecipitationType + ); + } + } + + return new WeatherData(temp, windSpeed, windDir, humidity, cloudCover, precipType); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Open-Meteo lookup threw for {Latitude},{Longitude} at {UtcHour}", + latitude, + longitude, + dateTimeUtc + ); + return null; + } + } + + private async Task TryCacheFailure( + DateTime lookupHourUtc, + decimal latRounded, + decimal lonRounded, + CancellationToken cancellationToken + ) + { + try + { + var failureEntry = new WeatherLookupEntity + { + LookupHourUtc = lookupHourUtc, + LatitudeRounded = latRounded, + LongitudeRounded = lonRounded, + DataSource = DataSourceName, + RetrievedAtUtc = DateTime.UtcNow, + Status = "error", + }; + + dbContext.WeatherLookups.Add(failureEntry); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to cache error entry for weather lookup"); + } + } + + private static bool TryReadWeatherAtHour( + JsonElement root, + int hourOfDay, + out decimal? temperature, + out decimal? windSpeed, + out int? windDirection, + out int? relativeHumidity, + out int? cloudCover, + out string? precipitationType + ) + { + temperature = null; + windSpeed = null; + windDirection = null; + relativeHumidity = null; + cloudCover = null; + precipitationType = null; + + if (!root.TryGetProperty("hourly", out var hourly)) + { + return false; + } + + // Parse hourly data arrays + if ( + !hourly.TryGetProperty("time", out var times) + || times.ValueKind != JsonValueKind.Array + || hourly.TryGetProperty("temperature_2m", out var temps) == false + || hourly.TryGetProperty("wind_speed_10m", out var windSpeeds) == false + || hourly.TryGetProperty("wind_direction_10m", out var windDirs) == false + || hourly.TryGetProperty("relative_humidity_2m", out var humidities) == false + || hourly.TryGetProperty("cloud_cover", out var cloudCovers) == false + || hourly.TryGetProperty("weather_code", out var weatherCodes) == false + ) + { + return false; + } + + // Find the index for the requested hour + int? targetIndex = null; + for (int i = 0; i < times.GetArrayLength(); i++) + { + var timeStr = times[i].GetString(); + if ( + timeStr != null + && timeStr.EndsWith($"T{hourOfDay:D2}:00", StringComparison.Ordinal) + ) + { + targetIndex = i; + break; + } + } + + if (targetIndex is null) + { + return false; + } + + var idx = targetIndex.Value; + + // Extract values at the target hour + if (idx < temps.GetArrayLength() && temps[idx].ValueKind == JsonValueKind.Number) + { + temperature = temps[idx].GetDecimal(); + } + + if (idx < windSpeeds.GetArrayLength() && windSpeeds[idx].ValueKind == JsonValueKind.Number) + { + windSpeed = windSpeeds[idx].GetDecimal(); + } + + if (idx < windDirs.GetArrayLength() && windDirs[idx].ValueKind == JsonValueKind.Number) + { + windDirection = windDirs[idx].GetInt32(); + } + + if (idx < humidities.GetArrayLength() && humidities[idx].ValueKind == JsonValueKind.Number) + { + relativeHumidity = humidities[idx].GetInt32(); + } + + if ( + idx < cloudCovers.GetArrayLength() + && cloudCovers[idx].ValueKind == JsonValueKind.Number + ) + { + cloudCover = cloudCovers[idx].GetInt32(); + } + + // Determine precipitation type from WMO code + if ( + hourly.TryGetProperty("precipitation", out var precipitationValues) + && hourly.TryGetProperty("snowfall", out var snowfallValues) + && idx < weatherCodes.GetArrayLength() + && weatherCodes[idx].ValueKind == JsonValueKind.Number + ) + { + var code = weatherCodes[idx].GetInt32(); + var hasSnow = + idx < snowfallValues.GetArrayLength() + && snowfallValues[idx].ValueKind == JsonValueKind.Number + && snowfallValues[idx].GetDecimal() > 0; + var hasPrecip = + idx < precipitationValues.GetArrayLength() + && precipitationValues[idx].ValueKind == JsonValueKind.Number + && precipitationValues[idx].GetDecimal() > 0; + + precipitationType = DeterminePrecipitationType(code, hasPrecip, hasSnow); + } + + return true; + } + + private static string? DeterminePrecipitationType(int code, bool hasPrecip, bool hasSnow) + { + // WMO interpretation + return code switch + { + // Snow / snow showers + >= 71 and <= 77 => "snow", + 85 or 86 => "snow", + // Freezing rain + 56 or 57 => "freezing_rain", + 66 or 67 => "freezing_rain", + // Rain / drizzle / rain showers + >= 51 and <= 67 when code < 70 => "rain", + >= 80 and <= 82 => "rain", + // Clear or no precipitation + 0 => null, + _ => hasPrecip ? (hasSnow ? "snow" : "rain") : null, + }; + } +} diff --git a/src/BikeTracking.Api/BikeTracking.Api.http b/src/BikeTracking.Api/BikeTracking.Api.http index cdb1004..498dd5e 100644 --- a/src/BikeTracking.Api/BikeTracking.Api.http +++ b/src/BikeTracking.Api/BikeTracking.Api.http @@ -17,6 +17,24 @@ X-User-Id: {{RiderId}} "temperature": 54 } +### Record a ride (weather fields) +POST {{ApiService_HostAddress}}/api/rides +Content-Type: application/json +X-User-Id: {{RiderId}} + +{ + "rideDateTimeLocal": "2026-03-26T07:30:00", + "miles": 18.4, + "rideMinutes": 58, + "temperature": 54, + "windSpeedMph": 8.2, + "windDirectionDeg": 265, + "relativeHumidityPercent": 70, + "cloudCoverPercent": 35, + "precipitationType": "rain", + "weatherUserOverridden": true +} + ### Get ride defaults GET {{ApiService_HostAddress}}/api/rides/defaults Accept: application/json @@ -97,6 +115,25 @@ X-User-Id: {{RiderId}} "expectedVersion": 1 } +### Edit ride (weather fields) +PUT {{ApiService_HostAddress}}/api/rides/1 +Content-Type: application/json +X-User-Id: {{RiderId}} + +{ + "rideDateTimeLocal": "2026-03-26T08:00:00", + "miles": 19.5, + "rideMinutes": 62, + "temperature": 56, + "windSpeedMph": 11.4, + "windDirectionDeg": 250, + "relativeHumidityPercent": 66, + "cloudCoverPercent": 40, + "precipitationType": "snow", + "weatherUserOverridden": true, + "expectedVersion": 1 +} + ### Edit ride (validation error: miles <= 0) PUT {{ApiService_HostAddress}}/api/rides/1 Content-Type: application/json diff --git a/src/BikeTracking.Api/Contracts/RidesContracts.cs b/src/BikeTracking.Api/Contracts/RidesContracts.cs index ab3defb..fe457a3 100644 --- a/src/BikeTracking.Api/Contracts/RidesContracts.cs +++ b/src/BikeTracking.Api/Contracts/RidesContracts.cs @@ -15,7 +15,18 @@ public sealed record RecordRideRequest( int? RideMinutes = null, decimal? Temperature = null, [property: Range(0.01, 999.9999, ErrorMessage = "Gas price must be between 0.01 and 999.9999")] - decimal? GasPricePerGallon = null + decimal? GasPricePerGallon = null, + [property: Range(0, 500, ErrorMessage = "Wind speed must be between 0 and 500 mph")] + decimal? WindSpeedMph = null, + [property: Range(0, 360, ErrorMessage = "Wind direction must be between 0 and 360 degrees")] + int? WindDirectionDeg = null, + [property: Range(0, 100, ErrorMessage = "Relative humidity must be between 0 and 100")] + int? RelativeHumidityPercent = null, + [property: Range(0, 100, ErrorMessage = "Cloud cover must be between 0 and 100")] + int? CloudCoverPercent = null, + [property: MaxLength(50, ErrorMessage = "Precipitation type must be 50 characters or fewer")] + string? PrecipitationType = null, + bool WeatherUserOverridden = false ); public sealed record RecordRideSuccessResponse( @@ -31,7 +42,12 @@ public sealed record RideDefaultsResponse( decimal? DefaultMiles = null, int? DefaultRideMinutes = null, decimal? DefaultTemperature = null, - decimal? DefaultGasPricePerGallon = null + decimal? DefaultGasPricePerGallon = null, + decimal? DefaultWindSpeedMph = null, + int? DefaultWindDirectionDeg = null, + int? DefaultRelativeHumidityPercent = null, + int? DefaultCloudCoverPercent = null, + string? DefaultPrecipitationType = null ); public sealed record GasPriceResponse( @@ -67,7 +83,18 @@ public sealed record EditRideRequest( 999.9999, ErrorMessage = "Gas price must be greater than 0.01 and less than or equal to 999.9999" )] - decimal? GasPricePerGallon = null + decimal? GasPricePerGallon = null, + [property: Range(0, 500, ErrorMessage = "Wind speed must be between 0 and 500 mph")] + decimal? WindSpeedMph = null, + [property: Range(0, 360, ErrorMessage = "Wind direction must be between 0 and 360 degrees")] + int? WindDirectionDeg = null, + [property: Range(0, 100, ErrorMessage = "Relative humidity must be between 0 and 100")] + int? RelativeHumidityPercent = null, + [property: Range(0, 100, ErrorMessage = "Cloud cover must be between 0 and 100")] + int? CloudCoverPercent = null, + [property: MaxLength(50, ErrorMessage = "Precipitation type must be 50 characters or fewer")] + string? PrecipitationType = null, + bool WeatherUserOverridden = false ); public sealed record EditRideResponse(long RideId, int NewVersion, string Message); @@ -92,7 +119,13 @@ public sealed record RideHistoryRow( decimal Miles, int? RideMinutes = null, decimal? Temperature = null, - decimal? GasPricePerGallon = null + decimal? GasPricePerGallon = null, + decimal? WindSpeedMph = null, + int? WindDirectionDeg = null, + int? RelativeHumidityPercent = null, + int? CloudCoverPercent = null, + string? PrecipitationType = null, + bool WeatherUserOverridden = false ); /// diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs index 88101b4..77f5931 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs @@ -12,6 +12,7 @@ public sealed class BikeTrackingDbContext(DbContextOptions OutboxEvents => Set(); public DbSet Rides => Set(); public DbSet GasPriceLookups => Set(); + public DbSet WeatherLookups => Set(); public DbSet UserSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -101,6 +102,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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.WindSpeedMph).HasPrecision(10, 4); + entity.Property(static x => x.WindDirectionDeg); + entity.Property(static x => x.RelativeHumidityPercent); + entity.Property(static x => x.CloudCoverPercent); + entity.Property(static x => x.PrecipitationType).HasMaxLength(50); + entity.Property(static x => x.WeatherUserOverridden).HasDefaultValue(false); entity .Property(static x => x.Version) .IsRequired() @@ -136,6 +143,34 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(static x => x.PriceDate).IsUnique(); }); + modelBuilder.Entity(static entity => + { + entity.ToTable("WeatherLookups"); + entity.HasKey(static x => x.WeatherLookupId); + + entity.Property(static x => x.LookupHourUtc).IsRequired(); + entity.Property(static x => x.LatitudeRounded).IsRequired().HasPrecision(8, 2); + entity.Property(static x => x.LongitudeRounded).IsRequired().HasPrecision(8, 2); + entity.Property(static x => x.Temperature).HasPrecision(10, 4); + entity.Property(static x => x.WindSpeedMph).HasPrecision(10, 4); + entity.Property(static x => x.WindDirectionDeg); + entity.Property(static x => x.RelativeHumidityPercent); + entity.Property(static x => x.CloudCoverPercent); + entity.Property(static x => x.PrecipitationType).HasMaxLength(50); + entity.Property(static x => x.DataSource).IsRequired().HasMaxLength(100); + entity.Property(static x => x.RetrievedAtUtc).IsRequired(); + entity.Property(static x => x.Status).IsRequired().HasMaxLength(50); + + entity + .HasIndex(static x => new + { + x.LookupHourUtc, + x.LatitudeRounded, + x.LongitudeRounded, + }) + .IsUnique(); + }); + modelBuilder.Entity(static entity => { entity.ToTable( diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs index bbc6556..55cf80b 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs @@ -16,6 +16,18 @@ public sealed class RideEntity public decimal? GasPricePerGallon { get; set; } + public decimal? WindSpeedMph { get; set; } + + public int? WindDirectionDeg { get; set; } + + public int? RelativeHumidityPercent { get; set; } + + public int? CloudCoverPercent { get; set; } + + public string? PrecipitationType { get; set; } + + public bool WeatherUserOverridden { get; set; } + public int Version { get; set; } = 1; public DateTime CreatedAtUtc { get; set; } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/WeatherLookupEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/WeatherLookupEntity.cs new file mode 100644 index 0000000..ea77ea4 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/WeatherLookupEntity.cs @@ -0,0 +1,30 @@ +namespace BikeTracking.Api.Infrastructure.Persistence.Entities; + +public sealed class WeatherLookupEntity +{ + public int WeatherLookupId { get; set; } + + public DateTime LookupHourUtc { get; set; } + + public decimal LatitudeRounded { get; set; } + + public decimal LongitudeRounded { get; set; } + + public decimal? Temperature { get; set; } + + public decimal? WindSpeedMph { get; set; } + + public int? WindDirectionDeg { get; set; } + + public int? RelativeHumidityPercent { get; set; } + + public int? CloudCoverPercent { get; set; } + + public string? PrecipitationType { get; set; } + + public required string DataSource { get; set; } + + public DateTime RetrievedAtUtc { get; set; } + + public required string Status { get; set; } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192400_AddWeatherFieldsToRides.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192400_AddWeatherFieldsToRides.Designer.cs new file mode 100644 index 0000000..c3e800a --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192400_AddWeatherFieldsToRides.Designer.cs @@ -0,0 +1,426 @@ +// +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("20260403192400_AddWeatherFieldsToRides")] + partial class AddWeatherFieldsToRides + { + /// + 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.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") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("GasPricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + 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.Property("WeatherUserOverridden") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .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", "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.Entities.WeatherLookupEntity", b => + { + b.Property("WeatherLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LatitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LongitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LookupHourUtc") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("WeatherLookupId"); + + b.HasIndex("LookupHourUtc", "LatitudeRounded", "LongitudeRounded") + .IsUnique(); + + b.ToTable("WeatherLookups", (string)null); + }); + + 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/20260403192400_AddWeatherFieldsToRides.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192400_AddWeatherFieldsToRides.cs new file mode 100644 index 0000000..1b66954 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192400_AddWeatherFieldsToRides.cs @@ -0,0 +1,140 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddWeatherFieldsToRides : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CloudCoverPercent", + table: "Rides", + type: "INTEGER", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "PrecipitationType", + table: "Rides", + type: "TEXT", + maxLength: 50, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "RelativeHumidityPercent", + table: "Rides", + type: "INTEGER", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "WeatherUserOverridden", + table: "Rides", + type: "INTEGER", + nullable: false, + defaultValue: false + ); + + migrationBuilder.AddColumn( + name: "WindDirectionDeg", + table: "Rides", + type: "INTEGER", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "WindSpeedMph", + table: "Rides", + type: "TEXT", + precision: 10, + scale: 4, + nullable: true + ); + + migrationBuilder.CreateTable( + name: "WeatherLookups", + columns: table => new + { + WeatherLookupId = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + LookupHourUtc = table.Column(type: "TEXT", nullable: false), + LatitudeRounded = table.Column( + type: "TEXT", + precision: 8, + scale: 2, + nullable: false + ), + LongitudeRounded = table.Column( + type: "TEXT", + precision: 8, + scale: 2, + nullable: false + ), + Temperature = table.Column( + type: "TEXT", + precision: 10, + scale: 4, + nullable: true + ), + WindSpeedMph = table.Column( + type: "TEXT", + precision: 10, + scale: 4, + nullable: true + ), + WindDirectionDeg = table.Column(type: "INTEGER", nullable: true), + RelativeHumidityPercent = table.Column(type: "INTEGER", nullable: true), + CloudCoverPercent = table.Column(type: "INTEGER", nullable: true), + PrecipitationType = table.Column( + type: "TEXT", + maxLength: 50, + nullable: true + ), + DataSource = table.Column( + type: "TEXT", + maxLength: 100, + nullable: false + ), + RetrievedAtUtc = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "TEXT", maxLength: 50, nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_WeatherLookups", x => x.WeatherLookupId); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_WeatherLookups_LookupHourUtc_LatitudeRounded_LongitudeRounded", + table: "WeatherLookups", + columns: new[] { "LookupHourUtc", "LatitudeRounded", "LongitudeRounded" }, + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "WeatherLookups"); + + migrationBuilder.DropColumn(name: "CloudCoverPercent", table: "Rides"); + + migrationBuilder.DropColumn(name: "PrecipitationType", table: "Rides"); + + migrationBuilder.DropColumn(name: "RelativeHumidityPercent", table: "Rides"); + + migrationBuilder.DropColumn(name: "WeatherUserOverridden", table: "Rides"); + + migrationBuilder.DropColumn(name: "WindDirectionDeg", table: "Rides"); + + migrationBuilder.DropColumn(name: "WindSpeedMph", table: "Rides"); + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192854_AddWeatherLookupCache.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192854_AddWeatherLookupCache.Designer.cs new file mode 100644 index 0000000..330b0e8 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192854_AddWeatherLookupCache.Designer.cs @@ -0,0 +1,426 @@ +// +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("20260403192854_AddWeatherLookupCache")] + partial class AddWeatherLookupCache + { + /// + 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.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") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("GasPricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + 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.Property("WeatherUserOverridden") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .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", "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.Entities.WeatherLookupEntity", b => + { + b.Property("WeatherLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LatitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LongitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LookupHourUtc") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("WeatherLookupId"); + + b.HasIndex("LookupHourUtc", "LatitudeRounded", "LongitudeRounded") + .IsUnique(); + + b.ToTable("WeatherLookups", (string)null); + }); + + 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/20260403192854_AddWeatherLookupCache.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192854_AddWeatherLookupCache.cs new file mode 100644 index 0000000..5187549 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260403192854_AddWeatherLookupCache.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddWeatherLookupCache : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) { } + + /// + protected override void Down(MigrationBuilder migrationBuilder) { } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs index 1156a17..368f5ce 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs @@ -79,6 +79,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + b.Property("CreatedAtUtc") .HasColumnType("TEXT"); @@ -89,6 +92,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Miles") .HasColumnType("TEXT"); + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + b.Property("RideDateTimeLocal") .HasColumnType("TEXT"); @@ -107,6 +117,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("INTEGER") .HasDefaultValue(1); + b.Property("WeatherUserOverridden") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("RiderId", "CreatedAtUtc") @@ -169,6 +191,65 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.WeatherLookupEntity", b => + { + b.Property("WeatherLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LatitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LongitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LookupHourUtc") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("WeatherLookupId"); + + b.HasIndex("LookupHourUtc", "LatitudeRounded", "LongitudeRounded") + .IsUnique(); + + b.ToTable("WeatherLookups", (string)null); + }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => { b.Property("OutboxEventId") diff --git a/src/BikeTracking.Api/Program.cs b/src/BikeTracking.Api/Program.cs index 6bd0ccb..95b919b 100644 --- a/src/BikeTracking.Api/Program.cs +++ b/src/BikeTracking.Api/Program.cs @@ -42,6 +42,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient( "EiaGasPrice", @@ -51,6 +52,13 @@ client.Timeout = TimeSpan.FromSeconds(10); } ); +builder.Services.AddHttpClient( + "OpenMeteo", + client => + { + client.Timeout = TimeSpan.FromSeconds(5); + } +); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/BikeTracking.Api/appsettings.Development.json b/src/BikeTracking.Api/appsettings.Development.json index 1f9c0d4..2e0e6a7 100644 --- a/src/BikeTracking.Api/appsettings.Development.json +++ b/src/BikeTracking.Api/appsettings.Development.json @@ -2,6 +2,9 @@ "GasPriceLookup": { "EiaApiKey": "DEV_PLACEHOLDER_SET_WITH_USER_SECRETS" }, + "WeatherLookup": { + "ApiKey": "" + }, "Identity": { "Outbox": { "PollIntervalSeconds": 2, diff --git a/src/BikeTracking.Api/appsettings.json b/src/BikeTracking.Api/appsettings.json index b044849..54d9eec 100644 --- a/src/BikeTracking.Api/appsettings.json +++ b/src/BikeTracking.Api/appsettings.json @@ -5,6 +5,9 @@ "GasPriceLookup": { "EiaApiKey": "" }, + "WeatherLookup": { + "ApiKey": "" + }, "Identity": { "PinPolicy": { "MinLength": 4, diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx index 70c48c8..0414f78 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx @@ -869,4 +869,57 @@ describe('HistoryPage', () => { }) }) }) + + it('should allow editing weather fields in history and send weatherUserOverridden', async () => { + mockGetRideHistory.mockResolvedValue({ + summaries: { + thisMonth: { miles: 10, rideCount: 1, period: 'thisMonth' }, + thisYear: { miles: 10, rideCount: 1, period: 'thisYear' }, + allTime: { miles: 10, rideCount: 1, period: 'allTime' }, + }, + filteredTotal: { miles: 10, rideCount: 1, period: 'filtered' }, + rides: [ + { + rideId: 44, + rideDateTimeLocal: '2026-03-20T10:30:00', + miles: 10, + rideMinutes: 30, + temperature: 70, + }, + ], + page: 1, + pageSize: 25, + totalRows: 1, + }) + mockEditRide.mockResolvedValue({ + ok: true, + value: { + rideId: 44, + newVersion: 2, + message: 'Ride updated successfully.', + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.change(screen.getByLabelText(/wind speed/i), { + target: { value: '14.1' }, + }) + fireEvent.click(screen.getByRole('button', { name: /save/i })) + + await waitFor(() => { + expect(mockEditRide).toHaveBeenCalledWith( + 44, + expect.objectContaining({ + windSpeedMph: 14.1, + weatherUserOverridden: true, + }) + ) + }) + }) }) diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx index 7c49acb..ae46392 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx @@ -27,11 +27,23 @@ function HistoryTable({ editingRideId, editedRideDateTimeLocal, editedMiles, + editedTemperature, + editedWindSpeedMph, + editedWindDirectionDeg, + editedRelativeHumidityPercent, + editedCloudCoverPercent, + editedPrecipitationType, editedGasPrice, editedGasPriceSource, onStartEdit, onEditedRideDateTimeLocalChange, onEditedMilesChange, + onEditedTemperatureChange, + onEditedWindSpeedMphChange, + onEditedWindDirectionDegChange, + onEditedRelativeHumidityPercentChange, + onEditedCloudCoverPercentChange, + onEditedPrecipitationTypeChange, onEditedGasPriceChange, onSaveEdit, onCancelEdit, @@ -41,11 +53,23 @@ function HistoryTable({ editingRideId: number | null editedRideDateTimeLocal: string editedMiles: string + editedTemperature: string + editedWindSpeedMph: string + editedWindDirectionDeg: string + editedRelativeHumidityPercent: string + editedCloudCoverPercent: string + editedPrecipitationType: string editedGasPrice: string editedGasPriceSource: string onStartEdit: (ride: RideHistoryRow) => void onEditedRideDateTimeLocalChange: (value: string) => void onEditedMilesChange: (value: string) => void + onEditedTemperatureChange: (value: string) => void + onEditedWindSpeedMphChange: (value: string) => void + onEditedWindDirectionDegChange: (value: string) => void + onEditedRelativeHumidityPercentChange: (value: string) => void + onEditedCloudCoverPercentChange: (value: string) => void + onEditedPrecipitationTypeChange: (value: string) => void onEditedGasPriceChange: (value: string) => void onSaveEdit: (ride: RideHistoryRow) => void onCancelEdit: () => void @@ -104,7 +128,60 @@ function HistoryTable({ )} {formatRideDuration(ride.rideMinutes) || 'N/A'} - {formatTemperature(ride.temperature) || 'N/A'} + + {editingRideId === ride.rideId ? ( +
+ + onEditedTemperatureChange(event.target.value)} + /> + + onEditedWindSpeedMphChange(event.target.value)} + /> + + onEditedWindDirectionDegChange(event.target.value)} + /> + + + onEditedRelativeHumidityPercentChange(event.target.value) + } + /> + + onEditedCloudCoverPercentChange(event.target.value)} + /> + + onEditedPrecipitationTypeChange(event.target.value)} + /> +
+ ) : ( + formatTemperature(ride.temperature) || 'N/A' + )} + {editingRideId === ride.rideId ? (
@@ -172,6 +249,13 @@ export function HistoryPage() { const [editingRideId, setEditingRideId] = useState(null) const [editedRideDateTimeLocal, setEditedRideDateTimeLocal] = useState('') const [editedMiles, setEditedMiles] = useState('') + const [editedTemperature, setEditedTemperature] = useState('') + const [editedWindSpeedMph, setEditedWindSpeedMph] = useState('') + const [editedWindDirectionDeg, setEditedWindDirectionDeg] = useState('') + const [editedRelativeHumidityPercent, setEditedRelativeHumidityPercent] = useState('') + const [editedCloudCoverPercent, setEditedCloudCoverPercent] = useState('') + const [editedPrecipitationType, setEditedPrecipitationType] = useState('') + const [weatherEditedManually, setWeatherEditedManually] = useState(false) const [editedGasPrice, setEditedGasPrice] = useState('') const [editedGasPriceSource, setEditedGasPriceSource] = useState('') const [ridePendingDelete, setRidePendingDelete] = useState(null) @@ -212,6 +296,19 @@ export function HistoryPage() { setEditingRideId(ride.rideId) setEditedRideDateTimeLocal(ride.rideDateTimeLocal.slice(0, 16)) setEditedMiles(ride.miles.toFixed(1)) + setEditedTemperature(ride.temperature != null ? ride.temperature.toString() : '') + setEditedWindSpeedMph(ride.windSpeedMph != null ? ride.windSpeedMph.toString() : '') + setEditedWindDirectionDeg( + ride.windDirectionDeg != null ? ride.windDirectionDeg.toString() : '' + ) + setEditedRelativeHumidityPercent( + ride.relativeHumidityPercent != null ? ride.relativeHumidityPercent.toString() : '' + ) + setEditedCloudCoverPercent( + ride.cloudCoverPercent != null ? ride.cloudCoverPercent.toString() : '' + ) + setEditedPrecipitationType(ride.precipitationType ?? '') + setWeatherEditedManually(false) setEditedGasPrice( ride.gasPricePerGallon != null ? ride.gasPricePerGallon.toFixed(4) : '' ) @@ -221,6 +318,13 @@ export function HistoryPage() { setEditingRideId(null) setEditedRideDateTimeLocal('') setEditedMiles('') + setEditedTemperature('') + setEditedWindSpeedMph('') + setEditedWindDirectionDeg('') + setEditedRelativeHumidityPercent('') + setEditedCloudCoverPercent('') + setEditedPrecipitationType('') + setWeatherEditedManually(false) setEditedGasPrice('') setEditedGasPriceSource('') } @@ -283,12 +387,33 @@ export function HistoryPage() { } } + const temperatureValue = + editedTemperature.length > 0 ? Number(editedTemperature) : undefined + const windSpeedMphValue = + editedWindSpeedMph.length > 0 ? Number(editedWindSpeedMph) : undefined + const windDirectionDegValue = + editedWindDirectionDeg.length > 0 ? Number(editedWindDirectionDeg) : undefined + const relativeHumidityPercentValue = + editedRelativeHumidityPercent.length > 0 + ? Number(editedRelativeHumidityPercent) + : undefined + const cloudCoverPercentValue = + editedCloudCoverPercent.length > 0 ? Number(editedCloudCoverPercent) : undefined + const precipitationTypeValue = + editedPrecipitationType.length > 0 ? editedPrecipitationType : undefined + const result = await editRide(ride.rideId, { rideDateTimeLocal: editedRideDateTimeLocal || ride.rideDateTimeLocal, miles: milesValue, rideMinutes: ride.rideMinutes, - temperature: ride.temperature, + temperature: temperatureValue, gasPricePerGallon: gasPriceValue, + windSpeedMph: windSpeedMphValue, + windDirectionDeg: windDirectionDegValue, + relativeHumidityPercent: relativeHumidityPercentValue, + cloudCoverPercent: cloudCoverPercentValue, + precipitationType: precipitationTypeValue, + weatherUserOverridden: weatherEditedManually, // Version tokens are added to history rows in later tasks; use baseline v1 for now. expectedVersion: 1, }) @@ -308,6 +433,13 @@ export function HistoryPage() { setEditingRideId(null) setEditedRideDateTimeLocal('') setEditedMiles('') + setEditedTemperature('') + setEditedWindSpeedMph('') + setEditedWindDirectionDeg('') + setEditedRelativeHumidityPercent('') + setEditedCloudCoverPercent('') + setEditedPrecipitationType('') + setWeatherEditedManually(false) setEditedGasPrice('') setEditedGasPriceSource('') @@ -440,11 +572,41 @@ export function HistoryPage() { editingRideId={editingRideId} editedRideDateTimeLocal={editedRideDateTimeLocal} editedMiles={editedMiles} + editedTemperature={editedTemperature} + editedWindSpeedMph={editedWindSpeedMph} + editedWindDirectionDeg={editedWindDirectionDeg} + editedRelativeHumidityPercent={editedRelativeHumidityPercent} + editedCloudCoverPercent={editedCloudCoverPercent} + editedPrecipitationType={editedPrecipitationType} editedGasPrice={editedGasPrice} editedGasPriceSource={editedGasPriceSource} onStartEdit={handleStartEdit} onEditedRideDateTimeLocalChange={setEditedRideDateTimeLocal} onEditedMilesChange={setEditedMiles} + onEditedTemperatureChange={(value) => { + setEditedTemperature(value) + setWeatherEditedManually(true) + }} + onEditedWindSpeedMphChange={(value) => { + setEditedWindSpeedMph(value) + setWeatherEditedManually(true) + }} + onEditedWindDirectionDegChange={(value) => { + setEditedWindDirectionDeg(value) + setWeatherEditedManually(true) + }} + onEditedRelativeHumidityPercentChange={(value) => { + setEditedRelativeHumidityPercent(value) + setWeatherEditedManually(true) + }} + onEditedCloudCoverPercentChange={(value) => { + setEditedCloudCoverPercent(value) + setWeatherEditedManually(true) + }} + onEditedPrecipitationTypeChange={(value) => { + setEditedPrecipitationType(value) + setWeatherEditedManually(true) + }} onEditedGasPriceChange={(value) => { setEditedGasPrice(value) setEditedGasPriceSource('') diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx index 59ca545..b780490 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx @@ -572,4 +572,63 @@ describe('RecordRidePage', () => { expect(screen.queryByRole('heading', { name: /quick ride options/i })).not.toBeInTheDocument() }) }) + + it('should render editable weather fields for manual override on create', async () => { + mockGetRideDefaults.mockResolvedValue({ + hasPreviousRide: false, + defaultRideDateTimeLocal: new Date().toISOString(), + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByLabelText(/wind speed/i)).toBeInTheDocument() + expect(screen.getByLabelText(/wind direction/i)).toBeInTheDocument() + expect(screen.getByLabelText(/relative humidity/i)).toBeInTheDocument() + expect(screen.getByLabelText(/cloud cover/i)).toBeInTheDocument() + expect(screen.getByLabelText(/precipitation type/i)).toBeInTheDocument() + }) + }) + + it('should send weatherUserOverridden when weather fields are manually edited', async () => { + mockGetRideDefaults.mockResolvedValue({ + hasPreviousRide: false, + defaultRideDateTimeLocal: new Date().toISOString(), + }) + mockRecordRide.mockResolvedValue({ + rideId: 321, + riderId: 1, + savedAtUtc: new Date().toISOString(), + eventStatus: 'Queued', + }) + + render( + + + + ) + + await waitFor(() => { + fireEvent.change(screen.getByLabelText(/miles/i), { target: { value: '12.5' } }) + }) + + fireEvent.change(screen.getByLabelText(/wind speed/i), { + target: { value: '11.2' }, + }) + + fireEvent.click(screen.getByRole('button', { name: /record ride/i })) + + await waitFor(() => { + expect(mockRecordRide).toHaveBeenCalledWith( + expect.objectContaining({ + windSpeedMph: 11.2, + weatherUserOverridden: true, + }) + ) + }) + }) }) diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx index cf1b8a7..becceff 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx @@ -14,6 +14,12 @@ export function RecordRidePage() { const [miles, setMiles] = useState('') const [rideMinutes, setRideMinutes] = useState('') const [temperature, setTemperature] = useState('') + const [windSpeedMph, setWindSpeedMph] = useState('') + const [windDirectionDeg, setWindDirectionDeg] = useState('') + const [relativeHumidityPercent, setRelativeHumidityPercent] = useState('') + const [cloudCoverPercent, setCloudCoverPercent] = useState('') + const [precipitationType, setPrecipitationType] = useState('') + const [weatherEdited, setWeatherEdited] = useState(false) const [gasPrice, setGasPrice] = useState('') const [gasPriceSource, setGasPriceSource] = useState('') const [quickRideOptions, setQuickRideOptions] = useState([]) @@ -50,6 +56,16 @@ export function RecordRidePage() { setRideMinutes(defaults.defaultRideMinutes.toString()) if (defaults.defaultTemperature) setTemperature(defaults.defaultTemperature.toString()) + if (defaults.defaultWindSpeedMph) + setWindSpeedMph(defaults.defaultWindSpeedMph.toString()) + if (defaults.defaultWindDirectionDeg) + setWindDirectionDeg(defaults.defaultWindDirectionDeg.toString()) + if (defaults.defaultRelativeHumidityPercent) + setRelativeHumidityPercent(defaults.defaultRelativeHumidityPercent.toString()) + if (defaults.defaultCloudCoverPercent) + setCloudCoverPercent(defaults.defaultCloudCoverPercent.toString()) + if (defaults.defaultPrecipitationType) + setPrecipitationType(defaults.defaultPrecipitationType) if (defaults.defaultGasPricePerGallon) setGasPrice(defaults.defaultGasPricePerGallon.toString()) } @@ -152,6 +168,14 @@ export function RecordRidePage() { miles: milesNum, rideMinutes: rideMinutes ? parseInt(rideMinutes) : undefined, temperature: temperature ? parseFloat(temperature) : undefined, + windSpeedMph: windSpeedMph ? parseFloat(windSpeedMph) : undefined, + windDirectionDeg: windDirectionDeg ? parseInt(windDirectionDeg) : undefined, + relativeHumidityPercent: relativeHumidityPercent + ? parseInt(relativeHumidityPercent) + : undefined, + cloudCoverPercent: cloudCoverPercent ? parseInt(cloudCoverPercent) : undefined, + precipitationType: precipitationType || undefined, + weatherUserOverridden: weatherEdited, gasPricePerGallon: gasPrice ? parseFloat(gasPrice) : undefined, } @@ -165,6 +189,12 @@ export function RecordRidePage() { setMiles('') setRideMinutes('') setTemperature('') + setWindSpeedMph('') + setWindDirectionDeg('') + setRelativeHumidityPercent('') + setCloudCoverPercent('') + setPrecipitationType('') + setWeatherEdited(false) setGasPrice('') setGasPriceSource('') setSuccessMessage('') @@ -255,7 +285,76 @@ export function RecordRidePage() { type="number" step="0.1" value={temperature} - onChange={(e) => setTemperature(e.target.value)} + onChange={(e) => { + setTemperature(e.target.value) + setWeatherEdited(true) + }} + /> +
+ +
+ + { + setWindSpeedMph(e.target.value) + setWeatherEdited(true) + }} + /> +
+ +
+ + { + setWindDirectionDeg(e.target.value) + setWeatherEdited(true) + }} + /> +
+ +
+ + { + setRelativeHumidityPercent(e.target.value) + setWeatherEdited(true) + }} + /> +
+ +
+ + { + setCloudCoverPercent(e.target.value) + setWeatherEdited(true) + }} + /> +
+ +
+ + { + setPrecipitationType(e.target.value) + setWeatherEdited(true) + }} />
diff --git a/src/BikeTracking.Frontend/src/services/ridesService.ts b/src/BikeTracking.Frontend/src/services/ridesService.ts index 2185428..6cf9e55 100644 --- a/src/BikeTracking.Frontend/src/services/ridesService.ts +++ b/src/BikeTracking.Frontend/src/services/ridesService.ts @@ -4,6 +4,12 @@ export interface RecordRideRequest { rideMinutes?: number; temperature?: number; gasPricePerGallon?: number; + windSpeedMph?: number; + windDirectionDeg?: number; + relativeHumidityPercent?: number; + cloudCoverPercent?: number; + precipitationType?: string; + weatherUserOverridden?: boolean; } export interface RecordRideSuccessResponse { @@ -19,6 +25,11 @@ export interface RideDefaultsResponse { defaultRideMinutes?: number; defaultTemperature?: number; defaultGasPricePerGallon?: number; + defaultWindSpeedMph?: number; + defaultWindDirectionDeg?: number; + defaultRelativeHumidityPercent?: number; + defaultCloudCoverPercent?: number; + defaultPrecipitationType?: string; defaultRideDateTimeLocal: string; } @@ -46,6 +57,12 @@ export interface EditRideRequest { rideMinutes?: number; temperature?: number; gasPricePerGallon?: number; + windSpeedMph?: number; + windDirectionDeg?: number; + relativeHumidityPercent?: number; + cloudCoverPercent?: number; + precipitationType?: string; + weatherUserOverridden?: boolean; expectedVersion: number; } @@ -106,6 +123,12 @@ export interface RideHistoryRow { rideMinutes?: number; temperature?: number; gasPricePerGallon?: number; + windSpeedMph?: number; + windDirectionDeg?: number; + relativeHumidityPercent?: number; + cloudCoverPercent?: number; + precipitationType?: string; + weatherUserOverridden?: boolean; } /** From a5df95a1114b42f91e25d6572ad1f2598d115300 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Fri, 3 Apr 2026 20:45:14 +0000 Subject: [PATCH 2/4] git practices --- .specify/memory/constitution.md | 116 ++++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 6 deletions(-) diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index c6050e3..26aeb21 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -10,9 +10,10 @@ 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.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. +Current Update (v1.13.0): Added Principle X — Trunk-Based Development, Continuous Integration & Delivery. Codified branching strategy (short-lived feature branches, git worktrees, PR-gated merges with validation builds), feature flag governance (max 5 active, mandatory cleanup), and PR completion policy (owner-only completion, GitHub issue linkage required). +Previous 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 Updates: +- 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. - 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. - v1.10.1: Clarified the local deployment approach for end-user machines by standardizing SQLite local-file storage as the default profile and documenting safety expectations for storage path and upgrades. @@ -43,6 +44,7 @@ Previous Updates: - Why Minimal API? Lightweight, performant, integrates seamlessly with Aspire and domain layers - Why local-first architecture? Users own their data locally; cloud deployment optional for sharing/collaboration - Why SQLite local-file default for user-machine installs? No separate database install, reliable offline operation, and simpler support/backup through a single user-owned database file +- Why Trunk-Based Development? Short-lived branches with continuous integration keep `main` always releasable, reduce merge pain, and enable continuous delivery; feature flags decouple deployment from release - **Why DevContainer (mandatory)?** Eliminates "works on my machine" problems; ensures identical development environment across all contributors; pre-configures all tooling (C#, F#, Node.js, npm, CSharpier); supports seamless onboarding; enables reproducible builds and tests; backend and frontend dependencies coexist without system-level pollution. **All development MUST occur inside the DevContainer**; no exceptions during active development. For detailed amendment history, see [DECISIONS.md](./DECISIONS.md). @@ -118,6 +120,36 @@ System capabilities must be split into cohesive modules with explicit ownership **Rationale**: Strong module boundaries reduce coordination overhead, minimize merge conflicts, and allow teams to move in parallel without blocking each other. Contract-first integration preserves system cohesion as complexity grows and enables safer incremental delivery. +### X. Trunk-Based Development, Continuous Integration & Delivery + +All development follows **Trunk-Based Development (TBD)** with short-lived feature branches. The `main` branch must always be in a **releasable state** — no broken builds, no failing tests, no incomplete features visible to users. + +**Branching Strategy**: +- All work happens on short-lived feature branches created from `main`. No long-lived branches. +- Use **git worktrees** for parallel work streams; merge branches back to `main` as soon as possible (ideally within 1–2 days; never longer than a few days). +- Direct pushes to `main` are **prohibited**. All changes enter `main` via Pull Request (PR) only. +- Every PR must reference a **GitHub issue** that describes the work being done. +- PR validation builds are **mandatory**: all tests (unit, integration, E2E) and code quality checks (linting, formatting, build) must pass before a PR can be completed. +- **PR completion policy**: Only the repository owner may complete (merge) a PR. Squad team members may review, provide feedback, request changes, and approve — but they **cannot** complete the PR. This ensures the owner maintains final merge authority. + +**Continuous Integration**: +- Every push to a feature branch triggers the full CI validation pipeline (build, test, lint, format). +- Branches must be up-to-date with `main` before merge (rebase or merge from `main` required). +- Merge conflicts must be resolved before PR completion; never merge broken code. + +**Feature Flags**: +- Use feature flags to hide in-progress work that is merged to `main` but not yet ready for users. This decouples **deployment** (code in `main`) from **release** (feature visible to users). +- Feature flags must be implemented at the minimum viable scope — wrap only the entry points to new features, not deep internal logic. +- **Maximum 5 active feature flags** at any time to limit complexity and cognitive overhead. If the limit is reached, existing flags must be cleaned up before new ones are introduced. +- After a feature behind a flag is deemed production-ready and the flag is permanently enabled, the flag, its conditional branches, the old code path, and any flag-specific tests must be **removed** in a dedicated cleanup PR. Feature flag debt is not tolerated. +- Feature flag state is managed via configuration (appsettings, environment variables); never hard-coded in source. + +**Continuous Delivery**: +- `main` is always deployable. Any commit on `main` can be released to production at any time. +- Deployment and release are separate concerns: code reaches `main` via PR; features reach users via feature flag enablement or configuration change. + +**Rationale**: Trunk-Based Development minimizes integration risk by keeping branches short-lived and merging frequently. Feature flags enable continuous delivery without exposing incomplete work. PR-gated merges with mandatory validation builds ensure `main` never breaks. Owner-only PR completion provides a final quality gate. Git worktrees enable efficient parallel work without branch-switching overhead. + ## Technology Stack Requirements ### Development Environment (Mandatory) @@ -239,6 +271,66 @@ For features spanning multiple modules, delivery must be organized for parallel This contract-first workflow complements vertical slices and the TDD gates; it does not replace them. +### Branching Strategy & Continuous Integration + +All development follows Trunk-Based Development with git worktrees for parallel work: + +**Branch Lifecycle**: +1. Create a GitHub issue describing the work +2. Create a short-lived feature branch from `main` (e.g., `feature/issue-42-record-ride`) +3. Use `git worktree add` to work on the branch in a separate directory when parallel work is needed +4. Commit frequently with meaningful messages; push to remote regularly +5. Open a PR referencing the GitHub issue (e.g., "Closes #42") as soon as the first commit is ready (draft PR for work-in-progress) +6. Keep the branch up-to-date with `main` via rebase +7. Once CI passes and review feedback is addressed, the owner completes the PR +8. Remove the worktree and delete the merged branch: `git worktree remove && git branch -d ` + +**CI Validation Pipeline** (runs on every PR and push to feature branches): +``` +dotnet build BikeTracking.slnx +csharpier format . --check +dotnet test BikeTracking.slnx +cd src/BikeTracking.Frontend && npm run lint && npm run build && npm run test:unit +``` +All checks must pass before a PR can be completed. E2E tests run on PRs targeting `main`. + +**Git Worktree Conventions**: +- Worktree directories placed in `../-worktrees/` (outside the main repo directory) +- Each worktree shares the same `.git` object store — no duplicate clones +- Clean up worktrees immediately after the branch is merged +- Never leave orphaned worktrees; run `git worktree list` periodically to audit + +**PR Requirements**: +- Must reference a GitHub issue (linked via "Closes #N" or "Relates to #N") +- Must have a passing validation build (CI green) +- Must have at least one reviewer's feedback acknowledged +- Only the repository owner can complete (merge) the PR +- Squad members review, approve, or request changes — they cannot merge +- Use squash merge to keep `main` history clean + +### Feature Flag Management + +Feature flags decouple deployment from release, allowing incomplete work to be merged to `main` safely: + +**Implementation**: +- Feature flags are boolean configuration values read from `appsettings.json` or environment variables +- Backend: Use an `IFeatureFlagService` (or equivalent) injected via DI to check flag state +- Frontend: Feature flags passed via API configuration endpoint or environment build variables +- Wrap only the **entry point** to a new feature (route registration, menu item, endpoint mapping) — do not scatter flag checks deep in business logic + +**Lifecycle**: +1. **Create**: Add flag with `false` default in configuration; document the flag in a `FEATURE_FLAGS.md` file with owner, issue reference, and expected removal date +2. **Develop**: All code behind the flag merged to `main` continuously; flag remains `false` in production config +3. **Test**: Enable flag in test/staging environments; run E2E tests with flag on and off +4. **Release**: Set flag to `true` in production configuration to enable for users +5. **Cleanup**: Once the feature is stable in production, create a dedicated cleanup PR that removes the flag, the conditional branches, the old code path, and any flag-specific tests. Update `FEATURE_FLAGS.md` + +**Hard Limits**: +- **Maximum 5 active feature flags** at any time across the entire codebase +- Before adding a new flag when at the limit, an existing flag must be cleaned up first +- Feature flags older than 30 days without a cleanup plan must be escalated for review +- `FEATURE_FLAGS.md` is the single source of truth for all active flags + ### Post-Change Verification Matrix (Mandatory After Any Change) After **every** code change, run verification commands based on the changed scope. These checks are required before merge and before phase transitions. @@ -297,6 +389,10 @@ A vertical slice is **production-ready** only when all items are verified: - [ ] 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 - [ ] SAMPLE_/DEMO_ data cleaned up; no test data committed to main branch +- [ ] PR created from feature branch referencing GitHub issue; CI validation build passes +- [ ] PR completed (merged) by repository owner only, after review feedback addressed +- [ ] Feature flags used for any in-progress work visible in `main`; flag count ≤5 +- [ ] Feature flag cleanup PR created after feature is production-ready (removes flag, old code, flag-specific tests) - [ ] Deployed to Azure staging environment via GitHub Actions + azd - [ ] Pipeline deployment checks pass for the target environment - [ ] Security review completed; identified vulnerabilities are explained and fixed (or formally approved risk acceptance) @@ -402,7 +498,7 @@ Tests suggested by agent must receive explicit user approval before implementati ### Compliance Audit Checklist #### Per-Specification Audit -- [ ] Spec references all nine core principles in acceptance criteria +- [ ] Spec references all ten core principles in acceptance criteria - [ ] Event schema defined; backwards compatibility verified if updating existing events - [ ] Data validation implemented at three layers: client (React), API (Minimal API), database (constraints) - [ ] Test coverage for domain logic ≥85%; F# discriminated unions and ROP patterns tested @@ -420,6 +516,11 @@ Tests suggested by agent must receive explicit user approval before implementati - [ ] Secrets NOT committed; `.gitignore` verified; pre-commit hook prevents credential leakage - [ ] Validation rule consistency: if field required in React form, enforced in API DTOs and database constraints - [ ] OAuth isolation verified: user only accesses their data; public data clearly marked +- [ ] All changes entered `main` via PR referencing a GitHub issue; no direct pushes +- [ ] PR validation build passed (build, tests, lint, format) before merge +- [ ] PR completed by repository owner only +- [ ] Feature flags used for in-progress work; active flag count ≤5; `FEATURE_FLAGS.md` up-to-date +- [ ] Feature flag cleanup completed for any flags permanently enabled during this spec #### Monthly Technology Audit - [ ] NuGet packages checked via `mcp_nuget_get-latest-package-version` for security patches @@ -464,10 +565,13 @@ Breaking these guarantees causes architectural decay and technical debt accrual: - **SAMPLE_/DEMO_ data never in production** — automated linting prevents prefixed data from deploying. Merge blocked if test data detected. - **Database provider abstraction** — application code must work across SQLite (local), SQL Server LocalDB (local), and Azure SQL (cloud) without provider-specific queries. Use EF Core abstractions; avoid raw SQL unless necessary and provider-agnostic. - **User-machine local data safety** — local deployments on end-user machines default to SQLite local-file storage in a user-writable app-data path; do not require a separate DB server for single-user installs. Before schema migration, create a backup copy of the SQLite file. +- **No direct pushes to `main`** — all changes enter `main` via Pull Request only. Every PR must reference a GitHub issue, pass the full CI validation build (build, test, lint, format), and be completed (merged) exclusively by the repository owner. Squad team members may review, approve, and request changes but cannot complete a PR. Violations are treated as process failures requiring immediate revert. +- **Feature flags are mandatory for in-progress work on `main`** — any incomplete feature merged to `main` must be behind a feature flag set to `false` by default. Maximum 5 active feature flags at any time. After a feature is production-ready and the flag is permanently enabled, a dedicated cleanup PR must remove the flag, old code paths, and flag-specific tests. Feature flag debt is not tolerated. +- **Branches must be short-lived** — feature branches must be merged back to `main` as quickly as possible (target 1–2 days). Long-lived branches are prohibited; they create merge pain and integration risk. Use git worktrees for parallel work; clean up worktrees immediately after merge. ### Onboarding Checklist for New Contributors -1. **Read constitution** (~20 min): Understand mission, nine core principles, technology stack, development workflow +1. **Read constitution** (~20 min): Understand mission, ten core principles, technology stack, development workflow 2. **Review decision history** (~15 min): [DECISIONS.md](./DECISIONS.md) explains why F#, why Event Sourcing, why Aspire, why Aurelia 2 3. **Clone repo and bootstrap** (~5 min): `git clone` → `dotnet tool install --global specify-cli` → `dotnet run` (Aspire orchestrates frontend, API, database) 4. **Explore specification examples** (~30 min): Review `/specs/` directory; read 2–3 completed specifications to understand vertical slice completeness @@ -508,7 +612,7 @@ Breaking these guarantees causes architectural decay and technical debt accrual: ## Governance ### Constitution as Governing Document -This constitution supersedes all other project guidance. All architectural decisions, code reviews, deployment approvals, and spec acceptance gates must verify compliance with these nine core principles and technology stack requirements. +This constitution supersedes all other project guidance. All architectural decisions, code reviews, deployment approvals, and spec acceptance gates must verify compliance with these ten core principles and technology stack requirements. ### Amendment Procedure Amendments must: @@ -548,5 +652,5 @@ Always commit at each TDD gate and before continuing to a new phase. --- -**Version**: 1.12.2 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-27 +**Version**: 1.13.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-04-03 From 7d14d6b4824547d7ea3ae5a2c1d55b70e2ff3446 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Mon, 6 Apr 2026 14:09:27 +0000 Subject: [PATCH 3/4] load weather with button --- .specify/memory/constitution.md | 2 +- .../contracts/api-contracts.md | 29 ++++++ specs/011-ride-weather-data/quickstart.md | 9 ++ specs/011-ride-weather-data/spec.md | 15 ++- specs/011-ride-weather-data/tasks.md | 15 +++ .../Endpoints/RidesEndpointsTests.cs | 73 ++++++++++++++ src/BikeTracking.Api/BikeTracking.Api.http | 5 + .../Contracts/RidesContracts.cs | 11 +++ .../Endpoints/RidesEndpoints.cs | 98 +++++++++++++++++++ .../src/pages/HistoryPage.test.tsx | 64 ++++++++++++ .../src/pages/HistoryPage.tsx | 56 +++++++++++ .../src/pages/RecordRidePage.test.tsx | 51 ++++++++++ .../src/pages/RecordRidePage.tsx | 52 ++++++++++ .../src/services/ridesService.ts | 31 ++++++ .../tests/e2e/edit-ride-history.spec.ts | 52 +++++++++- .../tests/e2e/record-ride.spec.ts | 40 +++++++- .../tests/e2e/support/auth-helpers.ts | 12 +++ 17 files changed, 607 insertions(+), 8 deletions(-) diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 26aeb21..a1790cd 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -279,7 +279,7 @@ All development follows Trunk-Based Development with git worktrees for parallel 1. Create a GitHub issue describing the work 2. Create a short-lived feature branch from `main` (e.g., `feature/issue-42-record-ride`) 3. Use `git worktree add` to work on the branch in a separate directory when parallel work is needed -4. Commit frequently with meaningful messages; push to remote regularly +4. Commit frequently with meaningful messages using `semantic commits or conventional commits` format; push to remote regularly 5. Open a PR referencing the GitHub issue (e.g., "Closes #42") as soon as the first commit is ready (draft PR for work-in-progress) 6. Keep the branch up-to-date with `main` via rebase 7. Once CI passes and review feedback is addressed, the owner completes the PR diff --git a/specs/011-ride-weather-data/contracts/api-contracts.md b/specs/011-ride-weather-data/contracts/api-contracts.md index 30735d7..3ad8d14 100644 --- a/specs/011-ride-weather-data/contracts/api-contracts.md +++ b/specs/011-ride-weather-data/contracts/api-contracts.md @@ -117,6 +117,35 @@ data is fetched server-side at save time inside `RecordRideService` and `EditRid The existing `GET /api/rides/gas-price` endpoint pattern is not replicated for weather because the weather lookup is tightly coupled to save time and user location (which is server-held). +## Weather Preview Endpoint + +The explicit load-weather action uses a server-side preview endpoint so the browser never talks to +Open-Meteo directly. + +### `GET /api/rides/weather?rideDateTimeLocal={iso}` + +Returns the weather snapshot for the authenticated rider's configured location and the supplied +ride timestamp. + +```csharp +public sealed record RideWeatherResponse( + DateTime RideDateTimeLocal, + decimal? Temperature, + decimal? WindSpeedMph, + int? WindDirectionDeg, + int? RelativeHumidityPercent, + int? CloudCoverPercent, + string? PrecipitationType, + bool IsAvailable +); +``` + +Behavior: +- Returns `200` with `IsAvailable = true` when weather data is found. +- Returns `200` with null weather fields and `IsAvailable = false` when location is missing or no weather is available. +- Returns `400` when `rideDateTimeLocal` is missing or invalid. +- Returns `401` when the caller is unauthenticated. + --- ## Frontend TypeScript Contracts diff --git a/specs/011-ride-weather-data/quickstart.md b/specs/011-ride-weather-data/quickstart.md index 91b4680..2998cd9 100644 --- a/specs/011-ride-weather-data/quickstart.md +++ b/specs/011-ride-weather-data/quickstart.md @@ -171,6 +171,14 @@ Map new `RideEntity` weather columns to `RideHistoryRow` and `RideDefaultsRespon --- +### Step 7.5 — Add Explicit Weather Preview Endpoint + +Add an authenticated endpoint that accepts `rideDateTimeLocal` and returns weather fields for the +authenticated rider's configured location. This endpoint is used by create/edit form buttons to +fill weather values before save while keeping the weather provider server-side. + +--- + ### Step 8 — Frontend: Ride Create/Edit Form **Files** (locate existing ride form components): @@ -192,6 +200,7 @@ Add optional weather fields to both forms: **UI behavior**: - All weather fields are optional — user can leave them empty - Pre-populated on edit from the stored ride values +- Add a `Load Weather` button on create and edit that fetches weather for the currently selected ride timestamp - When user manually changes any field, set `weatherUserOverridden = true` before submit - Show in RideHistoryRow in the history table diff --git a/specs/011-ride-weather-data/spec.md b/specs/011-ride-weather-data/spec.md index 7fe8226..6d72d68 100644 --- a/specs/011-ride-weather-data/spec.md +++ b/specs/011-ride-weather-data/spec.md @@ -17,8 +17,9 @@ As a rider creating or editing a ride entry, I want weather details for the ride **Acceptance Scenarios**: -1. **Given** a user submits a new ride with a valid ride timestamp and weather data is available, **When** the server processes the save request, **Then** temperature, wind speed, wind direction, humidity, cloud cover, and precipitation type are fetched server-side and stored with the ride created event. -2. **Given** a user edits an existing ride and updates the ride timestamp, **When** they save, **Then** the server fetches weather for the new time and stores the refreshed values with the ride updated event. +1. **Given** a user opens the create ride page or edits a ride and chooses to load weather for the selected ride timestamp, **When** the server processes that explicit load-weather request, **Then** temperature, wind speed, wind direction, humidity, cloud cover, and precipitation type are fetched server-side and returned to the form so the fields can be filled before save. +2. **Given** a user submits a new ride with a valid ride timestamp and weather data is available, **When** the server processes the save request, **Then** temperature, wind speed, wind direction, humidity, cloud cover, and precipitation type are fetched server-side and stored with the ride created event. +3. **Given** a user edits an existing ride and updates the ride timestamp, **When** they save, **Then** the server fetches weather for the new time and stores the refreshed values with the ride updated event. --- @@ -32,8 +33,9 @@ As a rider, I want to manually adjust weather fields when automatic values are i **Acceptance Scenarios**: -1. **Given** a ride has been saved and its weather values are shown on the ride edit page, **When** the user changes one or more weather fields and saves, **Then** the user-provided values are stored as authoritative and the server does not overwrite them with a new weather fetch. -2. **Given** automatic weather retrieval fails and a ride is saved with empty weather fields, **When** the user re-opens the ride for editing and enters weather values manually and saves, **Then** those values are stored and displayed for that ride. +1. **Given** a ride create or edit form is visible, **When** the user clicks the load-weather button and weather data is available, **Then** the returned weather values populate the weather fields without requiring a save. +2. **Given** a ride has been saved and its weather values are shown on the ride edit page, **When** the user changes one or more weather fields and saves, **Then** the user-provided values are stored as authoritative and the server does not overwrite them with a new weather fetch. +3. **Given** automatic weather retrieval fails and a ride is saved with empty weather fields, **When** the user re-opens the ride for editing and enters weather values manually and saves, **Then** those values are stored and displayed for that ride. --- @@ -55,6 +57,7 @@ As a rider repeatedly adding or editing rides for the same date/time context, I - Weather provider has no record for the ride timestamp; ride save still succeeds and weather fields remain empty unless user enters values. - Weather provider returns only partial weather data; available fields are populated and unavailable fields remain empty. - Weather provider is unreachable or times out during server-side fetch; ride save completes and weather fields are stored empty. +- Weather preview lookup triggered from the form is unreachable or returns no data; the form remains usable and weather fields stay empty. - User manually enters or clears a weather field on the edit form; the submitted value (including blank) is preserved and the server does not overwrite it with a fresh weather fetch. - Ride time is changed during edit; the server re-fetches weather for the new time at save, but only for fields the user has not manually provided. @@ -63,6 +66,8 @@ As a rider repeatedly adding or editing rides for the same date/time context, I ### Functional Requirements - **FR-001**: The server MUST perform a weather lookup at the time the ride create request is received; the weather fetch MUST support both historical ride times and current/live ride times. +- **FR-001c**: The system MUST provide an explicit user action on ride create and ride edit forms to load weather for the currently selected ride timestamp before save. +- **FR-001d**: The explicit load-weather action MUST call the server, not the frontend directly, and MUST fill the weather form fields with returned values when available. - **FR-001a**: The weather provider API key MUST be configurable by the user or administrator in the application's settings; the app MUST behave gracefully (empty weather fields, no error blocking save) when no API key is configured. - **FR-001b**: Weather fetches MUST be performed server-side only; the API key MUST NOT be exposed to or used by the frontend. - **FR-002**: The server MUST perform a weather lookup at the time the ride update request is received when the ride timestamp has changed; the same historical/current support applies. @@ -81,7 +86,7 @@ As a rider repeatedly adding or editing rides for the same date/time context, I - **Ride Event Weather Snapshot**: Weather attributes stored directly on ride created and ride updated events; includes temperature, wind speed, wind direction, humidity, cloud cover, precipitation type, plus indication of whether values were user-overridden. - **Weather Lookup Record**: Reusable stored weather result keyed by ride timestamp rounded to the nearest hour and the user's configured location; includes retrieved weather fields, lookup timestamp, and retrieval status (success/partial/unavailable/error). All ride times within the same hour at the same location share one cache entry. -- **Ride Entry Form Weather Fields**: Editable fields shown on both create and edit pages. On create, fields start empty and are optionally filled by the user before saving; server auto-fills only unsubmitted fields. On edit, fields are pre-populated with the previously saved weather values so the user can review and override before saving. +- **Ride Entry Form Weather Fields**: Editable fields shown on both create and edit pages. On create, fields may start empty or with prior ride defaults and can be explicitly filled by a load-weather action before saving; server auto-fills only unsubmitted fields at save. On edit, fields are pre-populated with the previously saved weather values so the user can review, reload weather for the current timestamp, and override before saving. ## Clarifications diff --git a/specs/011-ride-weather-data/tasks.md b/specs/011-ride-weather-data/tasks.md index 6446438..3ff7c74 100644 --- a/specs/011-ride-weather-data/tasks.md +++ b/specs/011-ride-weather-data/tasks.md @@ -133,6 +133,21 @@ --- +## Phase 7: Explicit Weather Load Actions + +**Purpose**: Add a user-triggered weather fetch button on create and edit forms that fills weather fields before save. + +- [X] T047 [P] [US1] Update the feature spec for explicit load-weather actions on create and edit forms in `specs/011-ride-weather-data/spec.md` +- [X] T048 [P] [US1] Add backend/frontend contract coverage for a weather preview endpoint in `src/BikeTracking.Api/Contracts/RidesContracts.cs` and `src/BikeTracking.Frontend/src/services/ridesService.ts` +- [X] T049 [P] [US1] Add endpoint coverage for explicit weather loading in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs` +- [X] T050 [P] [US2] Add frontend tests for load-weather buttons in `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx` and `src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx` +- [X] T051 [US1] Add authenticated weather preview endpoint in `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs` +- [X] T052 [US1] Add create-page load-weather action in `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx` +- [X] T053 [US2] Add edit-page load-weather action in `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx` +- [X] T054 [US1] Update supporting docs/examples for the preview endpoint in `specs/011-ride-weather-data/quickstart.md`, `specs/011-ride-weather-data/contracts/api-contracts.md`, and `src/BikeTracking.Api/BikeTracking.Api.http` + +--- + ## Dependencies ```text diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs index e3647f3..06efb0b 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs @@ -234,6 +234,61 @@ public async Task GetGasPrice_WithoutAuth_Returns401() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task GetRideWeather_WithConfiguredLocation_ReturnsWeatherData() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("WeatherPreview"); + await host.SeedUserSettingsAsync(userId, latitude: 40.71m, longitude: -74.01m); + + var response = await host.Client.GetWithAuthAsync( + "/api/rides/weather?rideDateTimeLocal=2026-03-20T10:30:00", + userId + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.True(payload.IsAvailable); + Assert.Equal(72.5m, payload.Temperature); + Assert.Equal(10.3m, payload.WindSpeedMph); + Assert.Equal(250, payload.WindDirectionDeg); + Assert.Equal(65, payload.RelativeHumidityPercent); + Assert.Equal(30, payload.CloudCoverPercent); + } + + [Fact] + public async Task GetRideWeather_WithInvalidDateTime_Returns400() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("WeatherPreviewBadDate"); + + var response = await host.Client.GetWithAuthAsync( + "/api/rides/weather?rideDateTimeLocal=not-a-date-time", + userId + ); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetRideWeather_WithoutLocation_ReturnsUnavailableShape() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("WeatherPreviewNoLocation"); + + var response = await host.Client.GetWithAuthAsync( + "/api/rides/weather?rideDateTimeLocal=2026-03-20T10:30:00", + userId + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.False(payload.IsAvailable); + Assert.Null(payload.Temperature); + } + [Fact] public async Task PostRecordRide_WithGasPrice_PersistsGasPrice() { @@ -763,6 +818,24 @@ public async Task SeedUserAsync(string displayName) return user.UserId; } + public async Task SeedUserSettingsAsync(long userId, decimal latitude, decimal longitude) + { + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.UserSettings.Add( + new UserSettingsEntity + { + UserId = userId, + Latitude = latitude, + Longitude = longitude, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + + await dbContext.SaveChangesAsync(); + } + public async Task RecordRideAsync( long userId, decimal miles, diff --git a/src/BikeTracking.Api/BikeTracking.Api.http b/src/BikeTracking.Api/BikeTracking.Api.http index 498dd5e..fb8460b 100644 --- a/src/BikeTracking.Api/BikeTracking.Api.http +++ b/src/BikeTracking.Api/BikeTracking.Api.http @@ -40,6 +40,11 @@ GET {{ApiService_HostAddress}}/api/rides/defaults Accept: application/json X-User-Id: {{RiderId}} +### Load weather for create/edit form +GET {{ApiService_HostAddress}}/api/rides/weather?rideDateTimeLocal=2026-03-26T07:30:00 +Accept: application/json +X-User-Id: {{RiderId}} + ### Get quick ride options GET {{ApiService_HostAddress}}/api/rides/quick-options Accept: application/json diff --git a/src/BikeTracking.Api/Contracts/RidesContracts.cs b/src/BikeTracking.Api/Contracts/RidesContracts.cs index fe457a3..6ae1f21 100644 --- a/src/BikeTracking.Api/Contracts/RidesContracts.cs +++ b/src/BikeTracking.Api/Contracts/RidesContracts.cs @@ -57,6 +57,17 @@ public sealed record GasPriceResponse( string? DataSource ); +public sealed record RideWeatherResponse( + DateTime RideDateTimeLocal, + decimal? Temperature, + decimal? WindSpeedMph, + int? WindDirectionDeg, + int? RelativeHumidityPercent, + int? CloudCoverPercent, + string? PrecipitationType, + bool IsAvailable +); + public sealed record QuickRideOption(decimal Miles, int RideMinutes, DateTime LastUsedAtLocal); public sealed record QuickRideOptionsResponse( diff --git a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs index 00fb71a..a48b42f 100644 --- a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs +++ b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs @@ -1,6 +1,8 @@ using BikeTracking.Api.Application.Rides; using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace BikeTracking.Api.Endpoints; @@ -38,6 +40,15 @@ public static IEndpointRouteBuilder MapRidesEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status401Unauthorized) .RequireAuthorization(); + group + .MapGet("/weather", GetRideWeather) + .WithName("GetRideWeather") + .WithSummary("Get weather preview for a ride timestamp") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .RequireAuthorization(); + group .MapGet("/quick-options", GetQuickRideOptions) .WithName("GetQuickRideOptions") @@ -191,6 +202,93 @@ CancellationToken cancellationToken } } + private static async Task GetRideWeather( + HttpContext context, + [FromQuery] string? rideDateTimeLocal, + [FromServices] BikeTrackingDbContext dbContext, + [FromServices] IWeatherLookupService weatherLookupService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + return Results.Unauthorized(); + + if ( + string.IsNullOrWhiteSpace(rideDateTimeLocal) + || !DateTime.TryParse(rideDateTimeLocal, out var parsedRideDateTimeLocal) + ) + { + return Results.BadRequest( + new ErrorResponse( + "INVALID_REQUEST", + "rideDateTimeLocal query parameter is required and must be a valid date time." + ) + ); + } + + try + { + var userSettings = await dbContext + .UserSettings.AsNoTracking() + .SingleOrDefaultAsync(settings => settings.UserId == riderId, cancellationToken); + + if ( + userSettings?.Latitude is not decimal latitude + || userSettings.Longitude is not decimal longitude + ) + { + return Results.Ok( + new RideWeatherResponse( + RideDateTimeLocal: parsedRideDateTimeLocal, + Temperature: null, + WindSpeedMph: null, + WindDirectionDeg: null, + RelativeHumidityPercent: null, + CloudCoverPercent: null, + PrecipitationType: null, + IsAvailable: false + ) + ); + } + + var weather = await weatherLookupService.GetOrFetchAsync( + latitude, + longitude, + parsedRideDateTimeLocal.ToUniversalTime(), + cancellationToken + ); + + return Results.Ok( + new RideWeatherResponse( + RideDateTimeLocal: parsedRideDateTimeLocal, + Temperature: weather?.Temperature, + WindSpeedMph: weather?.WindSpeedMph, + WindDirectionDeg: weather?.WindDirectionDeg, + RelativeHumidityPercent: weather?.RelativeHumidityPercent, + CloudCoverPercent: weather?.CloudCoverPercent, + PrecipitationType: weather?.PrecipitationType, + IsAvailable: weather is not null + ) + ); + } + catch + { + return Results.Ok( + new RideWeatherResponse( + RideDateTimeLocal: parsedRideDateTimeLocal, + Temperature: null, + WindSpeedMph: null, + WindDirectionDeg: null, + RelativeHumidityPercent: null, + CloudCoverPercent: null, + PrecipitationType: null, + IsAvailable: false + ) + ); + } + } + private static async Task GetRideHistory( HttpContext context, GetRideHistoryService historyService, diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx index 0414f78..5ef1246 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx @@ -8,12 +8,14 @@ vi.mock('../services/ridesService', () => ({ editRide: vi.fn(), deleteRide: vi.fn(), getGasPrice: vi.fn(), + getRideWeather: 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) +const mockGetRideWeather = vi.mocked(ridesService.getRideWeather) describe('HistoryPage', () => { beforeEach(() => { @@ -33,6 +35,16 @@ describe('HistoryPage', () => { isIdempotent: false, }, }) + mockGetRideWeather.mockResolvedValue({ + rideDateTimeLocal: '2026-03-20T10:30:00', + temperature: undefined, + windSpeedMph: undefined, + windDirectionDeg: undefined, + relativeHumidityPercent: undefined, + cloudCoverPercent: undefined, + precipitationType: undefined, + isAvailable: false, + }) }) it('should render summary tiles for thisMonth, thisYear, and allTime', async () => { @@ -922,4 +934,56 @@ describe('HistoryPage', () => { ) }) }) + + it('should load weather into edit fields when Load Weather is clicked', async () => { + mockGetRideHistory.mockResolvedValue({ + summaries: { + thisMonth: { miles: 10, rideCount: 1, period: 'thisMonth' }, + thisYear: { miles: 10, rideCount: 1, period: 'thisYear' }, + allTime: { miles: 10, rideCount: 1, period: 'allTime' }, + }, + filteredTotal: { miles: 10, rideCount: 1, period: 'filtered' }, + rides: [ + { + rideId: 45, + rideDateTimeLocal: '2026-03-20T10:30:00', + miles: 10, + rideMinutes: 30, + temperature: 70, + }, + ], + page: 1, + pageSize: 25, + totalRows: 1, + }) + mockGetRideWeather.mockResolvedValue({ + rideDateTimeLocal: '2026-03-20T10:30:00', + temperature: 51.5, + windSpeedMph: 8.4, + windDirectionDeg: 195, + relativeHumidityPercent: 77, + cloudCoverPercent: 66, + precipitationType: 'snow', + isAvailable: true, + }) + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.click(screen.getByRole('button', { name: /load weather/i })) + + await waitFor(() => { + expect(mockGetRideWeather).toHaveBeenCalledWith('2026-03-20T10:30') + expect((screen.getByLabelText(/temperature/i) as HTMLInputElement).value).toBe('51.5') + expect((screen.getByLabelText(/wind speed/i) as HTMLInputElement).value).toBe('8.4') + expect((screen.getByLabelText(/wind direction/i) as HTMLInputElement).value).toBe('195') + expect((screen.getByLabelText(/relative humidity/i) as HTMLInputElement).value).toBe('77') + expect((screen.getByLabelText(/cloud cover/i) as HTMLInputElement).value).toBe('66') + expect((screen.getByLabelText(/precipitation type/i) as HTMLInputElement).value).toBe('snow') + }) + }) }) diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx index ae46392..7ac09d4 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx @@ -9,6 +9,7 @@ import { editRide, getGasPrice, getRideHistory, + getRideWeather, } from '../services/ridesService' import { RideDeleteDialog } from '../components/RideDeleteDialog/RideDeleteDialog' import { MileageSummaryCard } from '../components/mileage-summary-card/mileage-summary-card' @@ -35,6 +36,7 @@ function HistoryTable({ editedPrecipitationType, editedGasPrice, editedGasPriceSource, + loadingWeather, onStartEdit, onEditedRideDateTimeLocalChange, onEditedMilesChange, @@ -45,6 +47,7 @@ function HistoryTable({ onEditedCloudCoverPercentChange, onEditedPrecipitationTypeChange, onEditedGasPriceChange, + onLoadWeather, onSaveEdit, onCancelEdit, onStartDelete, @@ -61,6 +64,7 @@ function HistoryTable({ editedPrecipitationType: string editedGasPrice: string editedGasPriceSource: string + loadingWeather: boolean onStartEdit: (ride: RideHistoryRow) => void onEditedRideDateTimeLocalChange: (value: string) => void onEditedMilesChange: (value: string) => void @@ -71,6 +75,7 @@ function HistoryTable({ onEditedCloudCoverPercentChange: (value: string) => void onEditedPrecipitationTypeChange: (value: string) => void onEditedGasPriceChange: (value: string) => void + onLoadWeather: () => void onSaveEdit: (ride: RideHistoryRow) => void onCancelEdit: () => void onStartDelete: (ride: RideHistoryRow) => void @@ -177,6 +182,9 @@ function HistoryTable({ value={editedPrecipitationType} onChange={(event) => onEditedPrecipitationTypeChange(event.target.value)} /> + ) : ( formatTemperature(ride.temperature) || 'N/A' @@ -258,8 +266,34 @@ export function HistoryPage() { const [weatherEditedManually, setWeatherEditedManually] = useState(false) const [editedGasPrice, setEditedGasPrice] = useState('') const [editedGasPriceSource, setEditedGasPriceSource] = useState('') + const [loadingWeather, setLoadingWeather] = useState(false) const [ridePendingDelete, setRidePendingDelete] = useState(null) + function applyLoadedWeather(weather: { + temperature?: number + windSpeedMph?: number + windDirectionDeg?: number + relativeHumidityPercent?: number + cloudCoverPercent?: number + precipitationType?: string + }): void { + setEditedTemperature(weather.temperature != null ? weather.temperature.toString() : '') + setEditedWindSpeedMph(weather.windSpeedMph != null ? weather.windSpeedMph.toString() : '') + setEditedWindDirectionDeg( + weather.windDirectionDeg != null ? weather.windDirectionDeg.toString() : '' + ) + setEditedRelativeHumidityPercent( + weather.relativeHumidityPercent != null + ? weather.relativeHumidityPercent.toString() + : '' + ) + setEditedCloudCoverPercent( + weather.cloudCoverPercent != null ? weather.cloudCoverPercent.toString() : '' + ) + setEditedPrecipitationType(weather.precipitationType ?? '') + setWeatherEditedManually(false) + } + async function loadHistory(params: GetRideHistoryParams): Promise { setIsLoading(true) setError('') @@ -329,6 +363,26 @@ export function HistoryPage() { setEditedGasPriceSource('') } + async function handleLoadWeather(): Promise { + if (!editedRideDateTimeLocal) { + setError('Ride date/time is required to load weather') + return + } + + setLoadingWeather(true) + setError('') + + try { + const weather = await getRideWeather(editedRideDateTimeLocal) + applyLoadedWeather(weather) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load weather' + setError(message) + } finally { + setLoadingWeather(false) + } + } + useEffect(() => { if (editingRideId === null || !editedRideDateTimeLocal) { return @@ -580,6 +634,7 @@ export function HistoryPage() { editedPrecipitationType={editedPrecipitationType} editedGasPrice={editedGasPrice} editedGasPriceSource={editedGasPriceSource} + loadingWeather={loadingWeather} onStartEdit={handleStartEdit} onEditedRideDateTimeLocalChange={setEditedRideDateTimeLocal} onEditedMilesChange={setEditedMiles} @@ -611,6 +666,7 @@ export function HistoryPage() { setEditedGasPrice(value) setEditedGasPriceSource('') }} + onLoadWeather={() => void handleLoadWeather()} 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 b780490..b8d727a 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx @@ -7,6 +7,7 @@ import { RecordRidePage } from '../pages/RecordRidePage' vi.mock('../services/ridesService', () => ({ getRideDefaults: vi.fn(), getGasPrice: vi.fn(), + getRideWeather: vi.fn(), getQuickRideOptions: vi.fn(), recordRide: vi.fn(), })) @@ -15,6 +16,7 @@ import * as ridesService from '../services/ridesService' const mockGetRideDefaults = vi.mocked(ridesService.getRideDefaults) const mockGetGasPrice = vi.mocked(ridesService.getGasPrice) +const mockGetRideWeather = vi.mocked(ridesService.getRideWeather) const mockGetQuickRideOptions = vi.mocked(ridesService.getQuickRideOptions) const mockRecordRide = vi.mocked(ridesService.recordRide) @@ -31,6 +33,16 @@ describe('RecordRidePage', () => { options: [], generatedAtUtc: new Date().toISOString(), }) + mockGetRideWeather.mockResolvedValue({ + rideDateTimeLocal: new Date().toISOString(), + temperature: undefined, + windSpeedMph: undefined, + windDirectionDeg: undefined, + relativeHumidityPercent: undefined, + cloudCoverPercent: undefined, + precipitationType: undefined, + isAvailable: false, + }) }) it('should render form fields', async () => { @@ -631,4 +643,43 @@ describe('RecordRidePage', () => { ) }) }) + + it('should load weather into fields when Load Weather is clicked', async () => { + mockGetRideDefaults.mockResolvedValue({ + hasPreviousRide: false, + defaultRideDateTimeLocal: new Date().toISOString(), + }) + mockGetRideWeather.mockResolvedValue({ + rideDateTimeLocal: '2026-04-03T08:00:00', + temperature: 58.2, + windSpeedMph: 12.4, + windDirectionDeg: 240, + relativeHumidityPercent: 81, + cloudCoverPercent: 72, + precipitationType: 'rain', + isAvailable: true, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /load weather/i })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /load weather/i })) + + await waitFor(() => { + expect(mockGetRideWeather).toHaveBeenCalled() + expect((screen.getByLabelText(/temperature/i) as HTMLInputElement).value).toBe('58.2') + expect((screen.getByLabelText(/wind speed/i) as HTMLInputElement).value).toBe('12.4') + expect((screen.getByLabelText(/wind direction/i) as HTMLInputElement).value).toBe('240') + expect((screen.getByLabelText(/relative humidity/i) as HTMLInputElement).value).toBe('81') + expect((screen.getByLabelText(/cloud cover/i) as HTMLInputElement).value).toBe('72') + expect((screen.getByLabelText(/precipitation type/i) as HTMLInputElement).value).toBe('rain') + }) + }) }) diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx index becceff..3cb0b9c 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import type { QuickRideOption, RecordRideRequest } from '../services/ridesService' import { getGasPrice, + getRideWeather, getQuickRideOptions, recordRide, getRideDefaults, @@ -26,9 +27,54 @@ export function RecordRidePage() { const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) + const [loadingWeather, setLoadingWeather] = useState(false) const [successMessage, setSuccessMessage] = useState('') const [errorMessage, setErrorMessage] = useState('') + const applyLoadedWeather = (weather: { + temperature?: number + windSpeedMph?: number + windDirectionDeg?: number + relativeHumidityPercent?: number + cloudCoverPercent?: number + precipitationType?: string + }) => { + setTemperature(weather.temperature != null ? weather.temperature.toString() : '') + setWindSpeedMph(weather.windSpeedMph != null ? weather.windSpeedMph.toString() : '') + setWindDirectionDeg( + weather.windDirectionDeg != null ? weather.windDirectionDeg.toString() : '' + ) + setRelativeHumidityPercent( + weather.relativeHumidityPercent != null + ? weather.relativeHumidityPercent.toString() + : '' + ) + setCloudCoverPercent( + weather.cloudCoverPercent != null ? weather.cloudCoverPercent.toString() : '' + ) + setPrecipitationType(weather.precipitationType ?? '') + setWeatherEdited(false) + } + + const handleLoadWeather = async () => { + if (!rideDateTimeLocal) { + setErrorMessage('Ride date/time is required to load weather') + return + } + + setLoadingWeather(true) + setErrorMessage('') + + try { + const weather = await getRideWeather(rideDateTimeLocal) + applyLoadedWeather(weather) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Failed to load weather') + } finally { + setLoadingWeather(false) + } + } + const loadQuickRideOptions = async () => { try { const quickOptionsResponse = await getQuickRideOptions() @@ -358,6 +404,12 @@ export function RecordRidePage() { /> +
+ +
+
{ return response.json(); } +export async function getRideWeather( + rideDateTimeLocal: string, +): Promise { + const response = await fetch( + `${API_BASE_URL}/api/rides/weather?rideDateTimeLocal=${encodeURIComponent(rideDateTimeLocal)}`, + { + method: "GET", + headers: getAuthHeaders(), + }, + ); + + if (!response.ok) { + throw new Error( + await parseErrorMessage(response, "Failed to fetch ride weather"), + ); + } + + 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 c87457a..b5ff950 100644 --- a/src/BikeTracking.Frontend/tests/e2e/edit-ride-history.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/edit-ride-history.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from "@playwright/test"; -import { createAndLoginUser, uniqueUser } from "./support/auth-helpers"; +import { + createAndLoginUser, + saveUserLocation, + uniqueUser, +} from "./support/auth-helpers"; import { recordRide } from "./support/ride-helpers"; // E2E scenarios for spec-006-edit-ride-history @@ -166,4 +170,50 @@ test.describe("006-edit-ride-history e2e", () => { page.getByLabel("Visible total miles").getByText("10.0 mi"), ).toBeVisible(); }); + + test("loads weather into edit form before save", async ({ page }) => { + const userName = uniqueUser("e2e-edit-load-weather"); + await createAndLoginUser(page, userName, "87654321"); + await saveUserLocation(page, "40.71", "-74.01"); + + await page.route("**/api/rides/weather**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + rideDateTimeLocal: "2026-03-20T10:30:00", + temperature: 51.5, + windSpeedMph: 8.4, + windDirectionDeg: 195, + relativeHumidityPercent: 77, + cloudCoverPercent: 66, + precipitationType: "snow", + isAvailable: true, + }), + }); + }); + + const now = new Date(); + const rideDateTimeLocal = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-02T10:30`; + + await recordRide(page, { + rideDateTimeLocal, + miles: "5.0", + rideMinutes: "30", + }); + + await page.goto("/rides/history"); + await expect( + page.getByRole("table", { name: /ride history table/i }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Edit" }).first().click(); + await page.getByLabel("Temperature").first().fill(""); + await page.getByLabel("Wind Speed").first().fill(""); + + await page.getByRole("button", { name: "Load Weather" }).click(); + + await expect(page.getByLabel("Temperature").first()).toHaveValue("51.5"); + await expect(page.getByLabel("Wind Speed").first()).toHaveValue("8.4"); + }); }); diff --git a/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts b/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts index d17bf2d..342cee4 100644 --- a/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from "@playwright/test"; -import { createAndLoginUser, uniqueUser } from "./support/auth-helpers"; +import { + createAndLoginUser, + saveUserLocation, + uniqueUser, +} from "./support/auth-helpers"; import { recordRide, selectQuickRideOption } from "./support/ride-helpers"; const TEST_PIN = "87654321"; @@ -83,4 +87,38 @@ test.describe("004-record-ride e2e", () => { await expect(page).toHaveURL(/\/rides\/history$/); await expect(page.getByText("$3.4567").first()).toBeVisible(); }); + + test("loads weather into create form before save", async ({ page }) => { + const userName = uniqueUser("e2e-load-weather-create"); + await createAndLoginUser(page, userName, TEST_PIN); + await saveUserLocation(page, "40.71", "-74.01"); + + await page.route("**/api/rides/weather**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + rideDateTimeLocal: "2026-04-03T08:00:00", + temperature: 58.2, + windSpeedMph: 12.4, + windDirectionDeg: 240, + relativeHumidityPercent: 81, + cloudCoverPercent: 72, + precipitationType: "rain", + isAvailable: true, + }), + }); + }); + + await page.goto("/rides/record"); + await expect(page).toHaveURL("/rides/record"); + + await page.locator("#temperature").fill(""); + await page.locator("#windSpeedMph").fill(""); + + await page.getByRole("button", { name: "Load Weather" }).click(); + + await expect(page.locator("#temperature")).toHaveValue("58.2"); + await expect(page.locator("#windSpeedMph")).toHaveValue("12.4"); + }); }); diff --git a/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts b/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts index 30cd064..c2cbddf 100644 --- a/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts +++ b/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts @@ -36,3 +36,15 @@ export async function createAndLoginUser( await signupUser(page, userName, pin); await loginUser(page, userName, pin); } + +export async function saveUserLocation( + page: Page, + latitude: string, + longitude: string, +): Promise { + await page.goto("/settings"); + await page.locator("#latitude").fill(latitude); + await page.locator("#longitude").fill(longitude); + await page.getByRole("button", { name: "Save Settings" }).click(); + await expect(page.getByText(/settings saved successfully/i)).toBeVisible(); +} From 455851e2acebe1f69ec5d674467e70e88b2c5a66 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 6 Apr 2026 10:23:15 -0500 Subject: [PATCH 4/4] Potential fix for pull request finding 'CodeQL / Exposure of private information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../Application/Rides/WeatherLookupService.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs index 4534ca6..c861507 100644 --- a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs +++ b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs @@ -135,9 +135,9 @@ ILogger logger if (!response.IsSuccessStatusCode) { logger.LogWarning( - "Open-Meteo lookup failed for {Latitude},{Longitude} at {UtcHour} with status {StatusCode}", - latitude, - longitude, + "Open-Meteo lookup failed for rounded {LatitudeRounded},{LongitudeRounded} at {UtcHour} with status {StatusCode}", + latRounded, + lonRounded, dateTimeUtc, response.StatusCode ); @@ -250,9 +250,9 @@ out var precipType { logger.LogWarning( ex, - "Open-Meteo lookup threw for {Latitude},{Longitude} at {UtcHour}", - latitude, - longitude, + "Open-Meteo lookup threw for rounded {LatitudeRounded},{LongitudeRounded} at {UtcHour}", + latRounded, + lonRounded, dateTimeUtc ); return null;