diff --git a/.specify/feature.json b/.specify/feature.json index 3fd9950..506af4e 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/016-csv-expense-import" + "feature_directory": "specs/019-ride-difficulty-wind" } diff --git a/specs/019-ride-difficulty-wind/checklists/requirements.md b/specs/019-ride-difficulty-wind/checklists/requirements.md new file mode 100644 index 0000000..1742641 --- /dev/null +++ b/specs/019-ride-difficulty-wind/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Ride Difficulty & Wind Resistance Rating + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-23 +**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 + +- All checklist items pass. Spec is ready for `/speckit.plan` or `/speckit.clarify`. +- Assumption about wind resistance formula threshold constant is flagged for planning-phase resolution. +- "Most difficult months" aggregation strategy (calendar month vs. month+year) is documented as an open assumption for the planning phase. diff --git a/specs/019-ride-difficulty-wind/contracts/csv-import-format.md b/specs/019-ride-difficulty-wind/contracts/csv-import-format.md new file mode 100644 index 0000000..3d83985 --- /dev/null +++ b/specs/019-ride-difficulty-wind/contracts/csv-import-format.md @@ -0,0 +1,203 @@ +# CSV Import Format (Extended) + +**Feature**: `019-ride-difficulty-wind` +**Affected files**: `CsvValidationRules.cs`, `CsvParser.cs`, `SampleCsvGenerator.cs` +**Date**: 2026-04-24 + +This document specifies the extended CSV import column format, new validation rules, and sample file format. + +--- + +## 1. Full Column Specification + +| Column | Required | Type | Valid Values | Notes | +|--------|----------|------|-------------|-------| +| `Date` | ✅ Yes | Date string | See date format list | Existing column | +| `Miles` | ✅ Yes | Decimal | 0.01–200 | Existing column | +| `Time` | No | Int or HH:mm | > 0 minutes | Existing column | +| `Temp` | No | Decimal | Any numeric | Existing column (°F) | +| `Notes` | No | String | Max 500 chars | Existing column | +| `Difficulty` | No | Integer | 1, 2, 3, 4, or 5 | **NEW** | +| `PrimaryTravelDirection` | No | String | Accepts full names or 2-letter abbreviations (normalized to N, NE, E, SE, S, SW, W, NW) | **NEW; canonical internal column name** | + +### Notes +- Column header names are case-insensitive (existing behaviour). +- Columns may appear in any order (existing behaviour). +- When `Difficulty` and `PrimaryTravelDirection` columns are absent from the CSV entirely, the import succeeds without error (FR-018). +- When only one of `Difficulty` / `PrimaryTravelDirection` is present, the present field is imported and the absent field is null (FR-018). + +--- + +## 2. New Validation Rules + +### 2.1 Difficulty Validation + +```csharp +// Add to CsvValidationRules.ValidateRow() + +if (row.Difficulty is not null) +{ + if (!int.TryParse(row.Difficulty, out var difficulty) || difficulty < 1 || difficulty > 5) + { + errors.Add(new ImportValidationError( + row.RowNumber, + "INVALID_DIFFICULTY", + $"Difficulty '{row.Difficulty}' is not valid. Must be an integer between 1 (Very Easy) and 5 (Very Hard).", + "Difficulty" + )); + } +} +``` + +### 2.2 Direction Validation + +```csharp +// Add to CsvValidationRules.ValidateRow() + +if (row.Direction is not null) +{ + var validDirections = WindResistance.validDirectionNames; // from F# module + var isValid = validDirections.Any(d => + string.Equals(d, row.Direction.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (!isValid) + { + errors.Add(new ImportValidationError( + row.RowNumber, + "INVALID_DIRECTION", + $"Direction '{row.Direction}' is not recognised. Accepted values: {string.Join(", ", validDirections)}.", + "Direction" + )); + } +} +``` + +--- + +## 3. ParsedCsvRow Extension + +**File**: `CsvParser.cs` +Add two new optional string properties to `ParsedCsvRow`: + +```csharp +// Existing record: +public sealed record ParsedCsvRow( + int RowNumber, + string? Date, + string? Miles, + string? Time, + string? Temp, + string? Notes, + // NEW: + string? Difficulty, // raw string from CSV, validated separately + string? Direction // raw string from CSV, validated separately +); +``` + +Header name detection (case-insensitive): the parser MUST accept the canonical `PrimaryTravelDirection` header and map it to the `PrimaryTravelDirection` property on the parsed row. Legacy aliases are not supported. +```csharp +"difficulty" => (row) => row with { Difficulty = value }, +"direction" => (row) => row with { PrimaryTravelDirection = value }, +"primarytraveldirection" => (row) => row with { PrimaryTravelDirection = value }, +``` + +--- + +## 4. Import Row → RideEntity Mapping + +**File**: `CsvRideImportService.cs` / `ImportJobProcessor.cs` +When creating `RideEntity` from a valid CSV row: + +```csharp +// Parse Difficulty +int? difficulty = null; +if (row.Difficulty is not null && int.TryParse(row.Difficulty, out var d)) +{ + difficulty = d; +} + +// Parse PrimaryTravelDirection (accepts header "Direction" or "PrimaryTravelDirection") +string? primaryTravelDirection = null; +if (row.PrimaryTravelDirection is not null) +{ + var parsed = WindResistance.tryParseCompassDirection(row.PrimaryTravelDirection.Trim()); + if (parsed is FSharpOption { IsNone: false }) + { + primaryTravelDirection = /* canonical name from F# value */; + } +} + +// Compute WindResistanceRating if Direction + WindSpeed available +int? windResistanceRating = null; +if (primaryTravelDirection is not null && rideWindSpeedMph.HasValue && rideWindDirectionDeg.HasValue) +{ + var directionResult = WindResistance.tryParseCompassDirection(primaryTravelDirection); + // ... call calculateResistance via F# interop +} + +rideEntity.Difficulty = difficulty; +rideEntity.PrimaryTravelDirection = primaryTravelDirection; +rideEntity.WindResistanceRating = windResistanceRating; +``` + +--- + +## 5. Sample CSV File + +**Route**: `GET /api/rides/csv-sample` +**Generated by**: `SampleCsvGenerator.Generate()` + +```csv +# Sample CSV for bike ride import +# Legend: +# Date - required. Supported formats: yyyy-MM-dd, MM/dd/yyyy, M/d/yyyy, dd-MMM-yyyy +# Miles - required. Decimal number 0.01–200 +# Time - optional. Ride duration in minutes (45) or HH:mm (00:45) +# Temp - optional. Temperature in Fahrenheit (decimal) +# Notes - optional. Max 500 characters +# Difficulty - optional. Integer 1 (Very Easy) to 5 (Very Hard) +# PrimaryTravelDirection - optional. Primary travel direction: CSV header MUST be `PrimaryTravelDirection`; accepts full names or 2-letter abbreviations; normalized to N, NE, E, SE, S, SW, W, NW +Date,Miles,Time,Temp,Notes,Difficulty,PrimaryTravelDirection +2026-01-15,12.5,45,38,"Morning commute, light rain",3,NE +2026-01-16,12.5,43,41,,1,South +2026-01-17,12.5,,35,"Windy day, fought headwind all the way",5,North +2026-01-18,8.0,32,42,"Short route",, +2026-01-19,12.5,44,39,,2,SW +``` + +### SampleCsvGenerator Implementation Notes + +```csharp +public static class SampleCsvGenerator +{ + public static string Generate() + { + var sb = new StringBuilder(); + // Legend rows starting with '#' + sb.AppendLine("# Sample CSV for bike ride import"); + // ... legend lines ... + sb.AppendLine("Date,Miles,Time,Temp,Notes,Difficulty,PrimaryTravelDirection"); + // Example rows with realistic data + sb.AppendLine("2026-01-15,12.5,45,38,\"Morning commute, light rain\",3,NE"); + sb.AppendLine("2026-01-16,12.5,43,41,,1,South"); + sb.AppendLine("2026-01-17,12.5,,35,\"Windy day, fought headwind all the way\",5,North"); + sb.AppendLine("2026-01-18,8.0,32,42,\"Short route\",,"); + sb.AppendLine("2026-01-19,12.5,44,39,,2,SW"); + return sb.ToString(); + } +} +``` + +--- + +## 6. Import Validation Error Codes (Extended) + +| Error code | Field | Message template | +|-----------|-------|-----------------| +| `INVALID_DATE` | Date | *(existing)* | +| `INVALID_MILES` | Miles | *(existing)* | +| `INVALID_TIME` | Time | *(existing)* | +| `INVALID_TEMP` | Temp | *(existing)* | +| `NOTE_TOO_LONG` | Notes | *(existing)* | +| `INVALID_DIFFICULTY` | Difficulty | `Difficulty '{value}' is not valid. Must be an integer between 1 (Very Easy) and 5 (Very Hard).` | +| `INVALID_DIRECTION` | Direction | `Direction '{value}' is not recognised. Accepted inputs: full names or abbreviations. Accepted canonical values: N, NE, E, SE, S, SW, W, NW.` | diff --git a/specs/019-ride-difficulty-wind/contracts/dashboard-api.md b/specs/019-ride-difficulty-wind/contracts/dashboard-api.md new file mode 100644 index 0000000..5dc47fd --- /dev/null +++ b/specs/019-ride-difficulty-wind/contracts/dashboard-api.md @@ -0,0 +1,195 @@ +# API Contract: Advanced Dashboard Endpoint (Extended) + +**Feature**: `019-ride-difficulty-wind` +**File to change**: `src/BikeTracking.Api/Contracts/DashboardContracts.cs` +**Date**: 2026-04-24 + +The existing `AdvancedDashboardResponse` gains a new `DifficultySection` property. All existing fields are preserved; existing API clients receive the new field as an optional extension. + +--- + +## 1. AdvancedDashboardResponse (modified) + +```csharp +// Current shape (condensed — all existing fields unchanged): +public sealed record AdvancedDashboardResponse( + AdvancedSavingsWindows SavingsWindows, + IReadOnlyList Suggestions, + AdvancedDashboardReminders Reminders, + DateTime GeneratedAtUtc, + + // NEW — nullable; null when no qualifying ride data exists (empty state) + AdvancedDashboardDifficultySection? DifficultySection = null +); +``` + +--- + +## 2. New Record Types + +```csharp +/// +/// Difficulty analytics section of the Advanced Dashboard. +/// Null when the rider has no rides with resolvable difficulty (stored, computed from rating, +/// or computable from wind speed + direction). +/// +public sealed record AdvancedDashboardDifficultySection( + + /// + /// Overall average difficulty across all qualifying rides (1 decimal place). + /// Null when no qualifying rides exist. + /// + decimal? OverallAverageDifficulty, + + /// + /// Average difficulty by calendar month (January–December, all years combined). + /// At most 12 entries; months with no qualifying rides are omitted. + /// Sorted by month number ascending. + /// + IReadOnlyList DifficultyByMonth, + + /// + /// Same months ranked by average difficulty descending (most difficult first). + /// At most 12 entries. + /// + IReadOnlyList MostDifficultMonths, + + /// + /// Distribution of rides across wind resistance bins −4 to +4. + /// Always 9 entries (one per bin), even when count is 0. + /// Bins with count 0 are included so the chart renders correctly. + /// + IReadOnlyList WindResistanceDistribution, + + /// + /// True when the section is showing an empty state (no qualifying data). + /// Frontend renders empty state message instead of charts. + /// + bool IsEmpty +); + +/// +/// Average difficulty for a calendar month. +/// +public sealed record DifficultyByMonth( + /// Month number 1–12. + int MonthNumber, + /// Full month name, e.g. "January". + string MonthName, + /// Average difficulty for this month across all years (1 decimal place). + decimal AverageDifficulty, + /// Number of qualifying rides in this month group. + int RideCount +); + +/// +/// Count of rides at a given wind resistance level. +/// +public sealed record WindResistanceBin( + /// Wind resistance rating (−4 to +4). + int Rating, + /// Number of rides with this stored WindResistanceRating. + int RideCount, + /// Label for display: "−4 (strong tailwind)" … "+4 (strong headwind)". + string Label, + /// True when rating is negative (tailwind/assisted). Used for visual distinction (FR-024). + bool IsAssisted +); +``` + +--- + +## 3. GET /api/advanced-dashboard — Response Shape + +**Route**: `GET /api/advanced-dashboard` +**Auth**: Required +**Change type**: Additive — new `difficultySection` field added to response JSON + +**Full extended response (JSON)**: + +```json +{ + "savingsWindows": { ... }, + "suggestions": [ ... ], + "reminders": { ... }, + "generatedAtUtc": "2026-04-24T12:00:00Z", + "difficultySection": { + "overallAverageDifficulty": 3.2, + "difficultyByMonth": [ + { "monthNumber": 1, "monthName": "January", "averageDifficulty": 3.8, "rideCount": 12 }, + { "monthNumber": 2, "monthName": "February", "averageDifficulty": 4.1, "rideCount": 9 } + ], + "mostDifficultMonths": [ + { "monthNumber": 2, "monthName": "February", "averageDifficulty": 4.1, "rideCount": 9 }, + { "monthNumber": 1, "monthName": "January", "averageDifficulty": 3.8, "rideCount": 12 } + ], + "windResistanceDistribution": [ + { "rating": -4, "rideCount": 3, "label": "−4 (strong tailwind)", "isAssisted": true }, + { "rating": -3, "rideCount": 7, "label": "−3 (tailwind)", "isAssisted": true }, + { "rating": -2, "rideCount": 14, "label": "−2 (tailwind)", "isAssisted": true }, + { "rating": -1, "rideCount": 21, "label": "−1 (light tailwind)", "isAssisted": true }, + { "rating": 0, "rideCount": 35, "label": "0 (neutral)", "isAssisted": false }, + { "rating": 1, "rideCount": 28, "label": "+1 (light headwind)", "isAssisted": false }, + { "rating": 2, "rideCount": 19, "label": "+2 (headwind)", "isAssisted": false }, + { "rating": 3, "rideCount": 8, "label": "+3 (headwind)", "isAssisted": false }, + { "rating": 4, "rideCount": 2, "label": "+4 (strong headwind)", "isAssisted": false } + ], + "isEmpty": false + } +} +``` + +**Empty state response** (FR-025: no difficulty data, no wind data): + +```json +{ + "difficultySection": { + "overallAverageDifficulty": null, + "difficultyByMonth": [], + "mostDifficultMonths": [], + "windResistanceDistribution": [ + { "rating": -4, "rideCount": 0, "label": "−4 (strong tailwind)", "isAssisted": true }, + ... + { "rating": 4, "rideCount": 0, "label": "+4 (strong headwind)", "isAssisted": false } + ], + "isEmpty": true + } +} +``` + +--- + +## 4. Frontend Types (advanced-dashboard-api.ts) + +```typescript +export interface DifficultyByMonth { + monthNumber: number; + monthName: string; + averageDifficulty: number; + rideCount: number; +} + +export interface WindResistanceBin { + rating: number; // −4 to +4 + rideCount: number; + label: string; + isAssisted: boolean; +} + +export interface AdvancedDashboardDifficultySection { + overallAverageDifficulty: number | null; + difficultyByMonth: DifficultyByMonth[]; + mostDifficultMonths: DifficultyByMonth[]; + windResistanceDistribution: WindResistanceBin[]; + isEmpty: boolean; +} + +// Extended AdvancedDashboardResponse +export interface AdvancedDashboardResponse { + savingsWindows: AdvancedSavingsWindows; + suggestions: AdvancedDashboardSuggestion[]; + reminders: AdvancedDashboardReminders; + generatedAtUtc: string; + difficultySection: AdvancedDashboardDifficultySection | null; // NEW +} +``` diff --git a/specs/019-ride-difficulty-wind/contracts/rides-api.md b/specs/019-ride-difficulty-wind/contracts/rides-api.md new file mode 100644 index 0000000..e739db7 --- /dev/null +++ b/specs/019-ride-difficulty-wind/contracts/rides-api.md @@ -0,0 +1,179 @@ +# API Contract: Rides Endpoints (Extended) + +**Feature**: `019-ride-difficulty-wind` +**File to change**: `src/BikeTracking.Api/Contracts/RidesContracts.cs` +**Date**: 2026-04-24 + +All existing fields are preserved. Changes are purely additive (new optional fields). Existing API clients remain compatible. + +--- + +## 1. RecordRideRequest (modified) + +```csharp +public sealed record RecordRideRequest( + // ... all existing fields unchanged ... + + // NEW optional fields (append to end of parameter list): + + /// + /// Optional rider-supplied difficulty (1 = Very Easy … 5 = Very Hard). + /// If provided, this value is stored as-is. If not provided, server computes + /// a suggested value from wind data (which the client may have already shown + /// as a suggestion) — but does NOT auto-store it; Difficulty is only stored + /// when the client explicitly sends it. + /// + [property: Range(1, 5, ErrorMessage = "Difficulty must be between 1 and 5")] + int? Difficulty = null, + + /// + /// Rider's primary travel direction, used to compute WindResistanceRating. + /// Accepted inputs: full compass names (e.g., "North", "Northeast") or 2-letter abbreviations. + /// Values are normalized server-side to canonical 2-letter abbreviations: `N, NE, E, SE, S, SW, W, NW`. + /// + [property: MaxLength(2, ErrorMessage = "Primary travel direction must be 2 characters or fewer")] + string? PrimaryTravelDirection = null +); +``` + +**Server-side behaviour**: +- `WindResistanceRating` is **not** in the request; it is computed server-side in `RecordRideService`. +-- If `PrimaryTravelDirection` is provided and `WindSpeedMph` + `WindDirectionDeg` are available, `WindResistanceRating` is computed via `WindResistance.calculateDifficulty` and persisted. +-- `PrimaryTravelDirection` must be parsed via `WindResistance.tryParseCompassDirection`; if invalid, return `400 Bad Request` with error message listing accepted values. + +--- + +## 2. RecordRideSuccessResponse (unchanged) + +No changes required — `WindResistanceRating` is an internal computed value, not returned in the record response. Clients that want to read it back use `GET /api/rides/history`. + +--- + +## 3. EditRideRequest (modified) + +```csharp +public sealed record EditRideRequest( + // ... all existing fields unchanged ... + + // NEW optional fields (append to end of parameter list): + + [property: Range(1, 5, ErrorMessage = "Difficulty must be between 1 and 5")] + int? Difficulty = null, + + [property: MaxLength(2, ErrorMessage = "Primary travel direction must be 2 characters or fewer")] + string? PrimaryTravelDirection = null +); +``` + +**Server-side behaviour** (FR-026, FR-027): +-- When `PrimaryTravelDirection` changes relative to the stored value, `EditRideService` recomputes `WindResistanceRating` using current `WindSpeedMph` and `WindDirectionDeg` and persists the new value. +-- When `PrimaryTravelDirection` is sent as `null` (direction cleared), `WindResistanceRating` is set to `null`. +- `Difficulty` is the rider's final choice — stored as-is (no silent server override). + +--- + +## 4. RideHistoryRow (modified) + +```csharp +public sealed record RideHistoryRow( + long RideId, + DateTime RideDateTimeLocal, + decimal Miles, + int? RideMinutes = null, + decimal? Temperature = null, + decimal? GasPricePerGallon = null, + decimal? WindSpeedMph = null, + int? WindDirectionDeg = null, + int? RelativeHumidityPercent = null, + int? CloudCoverPercent = null, + string? PrecipitationType = null, + string? Note = null, + bool WeatherUserOverridden = false, + + // NEW fields: + int? Difficulty = null, + string? PrimaryTravelDirection = null, + int? WindResistanceRating = null +); +``` + +--- + +## 5. New Endpoint: GET /api/rides/csv-sample + +**Purpose**: Download a sample CSV file showing all supported import columns including `Difficulty` and `Direction`. + +**Route**: `GET /api/rides/csv-sample` +**Auth**: Required (same as all other ride endpoints) +**Response**: Binary CSV download + +**Response headers**: +``` +Content-Type: text/csv; charset=utf-8 +Content-Disposition: attachment; filename="ride-import-sample.csv" +``` + +**Response body** (CSV): +```csv +# Sample CSV for bike ride import. Legend: +# Date: required. Formats: yyyy-MM-dd, MM/dd/yyyy, M/d/yyyy, dd-MMM-yyyy, MM/dd/yy +# Miles: required. Decimal, 0.01–200. +# Time: optional. Minutes (e.g., 45) or HH:mm (e.g., 00:45). +# Temp: optional. Fahrenheit (decimal). +# Notes: optional. Max 500 characters. +# Difficulty: optional. Integer 1 (Very Easy) to 5 (Very Hard). +# Direction: optional. Accepts full names or 2-letter abbreviations; normalized to N, NE, E, SE, S, SW, W, NW. +Date,Miles,Time,Temp,Notes,Difficulty,Direction +2026-01-15,12.5,45,38,"Morning commute, light rain",3,NE +2026-01-16,12.5,43,41,,1,South +2026-01-17,12.5,,35,"Too windy",, +``` + +**Contract record** (for `SampleCsvGenerator`): +```csharp +// No request body; no response DTO — returns raw CSV bytes. +// Endpoint registered in RidesEndpoints.cs: +// app.MapGet("/api/rides/csv-sample", DownloadSampleCsv).RequireAuthorization(); +``` + +--- + +## 6. Validation Error Response Shape (unchanged) + +All existing `400 Bad Request` responses continue to use the existing problem details / validation error format. New field errors follow the same pattern: + +```json +{ + "errors": { + "Difficulty": ["Difficulty must be between 1 and 5"], + "PrimaryTravelDirection": ["Primary travel direction must be one of: N, NE, E, SE, S, SW, W, NW (accepts full names or abbreviations)"] + } +} +``` + +--- + +## 7. Updated RideEditedEventPayload + +`src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs` + +Add new fields to the event payload record and the `Create` factory method: + +```csharp +public sealed record RideEditedEventPayload( + // ... existing fields ... + int? Difficulty, + string? PrimaryTravelDirection, + int? WindResistanceRating +) { ... } +``` + +Matching change to `RideRecordedEventPayload`: +```csharp +public sealed record RideRecordedEventPayload( + // ... existing fields ... + int? Difficulty, + string? PrimaryTravelDirection, + int? WindResistanceRating +) { ... } +``` diff --git a/specs/019-ride-difficulty-wind/data-model.md b/specs/019-ride-difficulty-wind/data-model.md new file mode 100644 index 0000000..5353088 --- /dev/null +++ b/specs/019-ride-difficulty-wind/data-model.md @@ -0,0 +1,502 @@ +# Data Model: Ride Difficulty & Wind Resistance Rating + +**Feature**: `019-ride-difficulty-wind` +**Phase**: 1 — Design & Contracts +**Date**: 2026-04-24 +**Prerequisite**: research.md complete ✅ + +--- + +## 1. Entity Changes + +### 1.1 RideEntity (extended) + +**File**: `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` +**Change type**: Additive — 3 new nullable columns + +```csharp +// Existing columns unchanged. New columns added: + +/// +/// Rider-supplied or auto-calculated difficulty rating (1 = Very Easy … 5 = Very Hard). +/// Null when neither manually set nor calculable from wind data. +/// +public int? Difficulty { get; set; } + +/// +/// Primary travel direction selected by the rider at record or import time. +/// Stored as canonical 2-letter abbreviation: `N, NE, E, SE, S, SW, W, NW`. +/// Inputs (form or CSV) MAY be full names or abbreviations; the importer/API normalize to the canonical abbreviations. +/// Null when not provided. +/// +public string? PrimaryTravelDirection { get; set; } + +/// +/// Computed wind resistance rating (−4 strong tailwind … +4 strong headwind). +/// Calculated from WindSpeedMph × cos(angle) / 5, clamped and rounded to integer. +/// Persisted at save/import time; not recomputed on read. +/// Null when PrimaryTravelDirection or WindSpeedMph was not available at write time. +/// +public int? WindResistanceRating { get; set; } +``` + +**Validation rules enforced at DB layer (migration CHECK constraints)**: + +| Column | Constraint | +|--------|-----------| +| `Difficulty` | `Difficulty IS NULL OR (Difficulty >= 1 AND Difficulty <= 5)` | +| `WindResistanceRating` | `WindResistanceRating IS NULL OR (WindResistanceRating >= -4 AND WindResistanceRating <= 4)` | +| `PrimaryTravelDirection` | Length ≤ 2 characters when persisted (via EF `HasMaxLength(2)`) | + +--- + +### 1.2 EF Core Migration + +**File**: `src/BikeTracking.Api/Infrastructure/Persistence/Migrations/YYYYMMDD_AddRideDifficultyAndWindRating.cs` +**Naming**: Use actual date prefix at generation time, e.g. `20260424120000_AddRideDifficultyAndWindRating`. + +**Migration operations**: + +```csharp +migrationBuilder.AddColumn( + name: "Difficulty", + table: "Rides", + type: "INTEGER", + nullable: true +); + +migrationBuilder.AddColumn( + name: "PrimaryTravelDirection", + table: "Rides", + type: "TEXT", + maxLength: 5, + nullable: true +); + +migrationBuilder.AddColumn( + name: "WindResistanceRating", + table: "Rides", + type: "INTEGER", + nullable: true +); +``` + +**Model snapshot update**: `BikeTrackingDbContextModelSnapshot` must be regenerated by EF tooling. + +> **SQLite Note**: CHECK constraints generated by EF's `HasCheckConstraint()` are included in the migration SQL. If the SQLite EF provider generates unsupported SQL for these constraints (as happened with `AddRideMilesUpperBound`), add the migration ID to `SqliteMigrationBootstrapper.UnsupportedConstraintMigrations`. + +--- + +## 2. New F# Domain Types + +### 2.1 WindResistance Module + +**File**: `src/BikeTracking.Domain.FSharp/WindResistance.fs` +**Module**: `BikeTracking.Domain.FSharp.WindResistance` +**Position in .fsproj**: Before `AdvancedDashboardCalculations.fs` + +```fsharp +module BikeTracking.Domain.FSharp.WindResistance + +open System + +// ────────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────────── + +/// Eight-point compass rose direction for rider travel or wind source. +type CompassDirection = + | North + | NE + | East + | SE + | South + | SW + | West + | NW + +/// Errors that can occur during wind resistance calculation. +type WindResistanceError = + | InvalidWindDirection of message: string + | InvalidWindSpeed of message: string + +// ────────────────────────────────────────────────────────────── +// Primitive helpers (pure, no I/O) +// ────────────────────────────────────────────────────────────── + +/// Converts a meteorological wind-from bearing (0–360°) to an 8-point compass direction. +/// Uses 22.5° bin boundaries centred on each cardinal/intercardinal point. +/// Returns Error when degrees is outside [0, 360]. +let degreesToCompass (degrees: int) : Result = + if degrees < 0 || degrees > 360 then + Error(InvalidWindDirection $"Wind direction must be 0–360°, received {degrees}") + else + let sector = (degrees + 22) % 360 / 45 + let directions = [| North; NE; East; SE; South; SW; West; NW |] + Ok directions.[sector] + +/// Returns the canonical bearing (degrees) for a given compass direction. +let compassToDegrees (direction: CompassDirection) : int = + match direction with + | North -> 0 + | NE -> 45 + | East -> 90 + | SE -> 135 + | South -> 180 + | SW -> 225 + | West -> 270 + | NW -> 315 + +/// Computes the angular difference between two bearings, returning the shorter arc in [0, 180]. +let shorterArc (bearing1: int) (bearing2: int) : int = + let diff = abs (bearing1 - bearing2) + if diff > 180 then 360 - diff else diff + +// ────────────────────────────────────────────────────────────── +// Core calculations (pure, no I/O) +// ────────────────────────────────────────────────────────────── + +/// Calculates wind resistance rating (−4 to +4) from wind speed and direction. +/// +/// Formula: resistance = clamp(round(windSpeedMph × cos(angle) / 5), −4, +4) +/// Positive = headwind (harder); Negative = tailwind (easier). +/// +/// Threshold: 5 mph — so a 20 mph direct headwind produces exactly +4. +/// Returns Error for invalid inputs. +let calculateResistance + (windSpeedMph: decimal) + (travelDirection: CompassDirection) + (windFromDeg: int) + : Result = + if windSpeedMph < 0m then + Error(InvalidWindSpeed $"Wind speed cannot be negative: {windSpeedMph}") + elif windFromDeg < 0 || windFromDeg > 360 then + Error(InvalidWindDirection $"Wind direction must be 0–360°, received {windFromDeg}") + else + let travelDeg = compassToDegrees travelDirection + let angleDeg = shorterArc travelDeg windFromDeg + let angleRad = float angleDeg * Math.PI / 180.0 + let cosAngle = Math.Cos angleRad + let raw = float windSpeedMph * cosAngle / 5.0 + let rounded = int (Math.Round(raw, MidpointRounding.AwayFromZero)) + Ok(Math.Clamp(rounded, -4, 4)) + +/// Maps a wind resistance rating (−4 to +4) to the rider difficulty scale (1–5). +/// +/// Mapping: +/// ≤ −3 → 1 (Very Easy) +/// −2/−1 → 2 (Easy) +/// 0 → 3 (Moderate) +/// +1/+2 → 4 (Hard) +/// ≥ +3 → 5 (Very Hard) +let resistanceToDifficulty (resistance: int) : int = + if resistance <= -3 then 1 + elif resistance = -2 || resistance = -1 then 2 + elif resistance = 0 then 3 + elif resistance = 1 || resistance = 2 then 4 + else 5 + +/// Main entry point: calculates (windResistanceRating, suggestedDifficulty) for a ride. +/// +/// Special rule FR-012: when windSpeedMph is None or 0, returns (0, 1) without invoking +/// the formula (calm conditions = no resistance, very easy difficulty). +/// +/// Returns (0, 1) when wind direction is unavailable (cannot calculate without direction). +let calculateDifficulty + (windSpeedMph: decimal option) + (travelDirection: CompassDirection) + (windFromDeg: int option) + : Result = + match windSpeedMph, windFromDeg with + | None, _ -> Ok(0, 1) // No wind data → calm conditions + | Some 0m, _ -> Ok(0, 1) // FR-012: zero wind → very easy + | _, None -> Ok(0, 1) // No direction data → cannot calculate + | Some speed, Some deg -> + calculateResistance speed travelDirection deg + |> Result.map (fun r -> (r, resistanceToDifficulty r)) + +// ────────────────────────────────────────────────────────────── +// Parsing helpers (used by C# API layer) +// ────────────────────────────────────────────────────────────── + +/// Parses a compass direction string (case-insensitive) to CompassDirection. +/// Accepts: "North", "NE", "NW", "South", "SE", "SW", "East", "West". +let tryParseCompassDirection (s: string) : CompassDirection option = + match s.Trim().ToUpperInvariant() with + | "NORTH" -> Some North + | "NE" -> Some NE + | "EAST" -> Some East + | "SE" -> Some SE + | "SOUTH" -> Some South + | "SW" -> Some SW + | "WEST" -> Some West + | "NW" -> Some NW + | _ -> None + +/// All valid direction strings for validation messages. +let validDirectionNames : string list = + [ "North"; "NE"; "East"; "SE"; "South"; "SW"; "West"; "NW" ] +``` + +--- + +### 2.2 F# Project File Update + +**File**: `src/BikeTracking.Domain.FSharp/BikeTracking.Domain.FSharp.fsproj` +**Change**: Add `WindResistance.fs` before `AdvancedDashboardCalculations.fs`: + +```xml + + + + + + + +``` + +--- + +## 3. Extended Domain Snapshot for Dashboard + +### 3.1 RideDifficultySnapshot + +Used as a lightweight projection for dashboard difficulty calculations, following the `RideSnapshot` pattern in `AdvancedDashboardCalculations.fs`. + +```fsharp +// Defined in AdvancedDashboardCalculations.fs or a new DifficultyCalculations.fs (preferred): + +/// Lightweight snapshot for difficulty analytics. Projected from RideEntity by the query layer. +type RideDifficultySnapshot = + { RideDate: DateTime + /// Stored difficulty rating (1–5), if the rider manually set or accepted the suggestion. + StoredDifficulty: int option + /// Persisted wind resistance rating (−4 to +4), computed at save time. + StoredWindResistanceRating: int option + /// Raw wind speed for fallback recomputation (FR-022 step 3). + WindSpeedMph: decimal option + /// Raw wind direction for fallback recomputation. + WindFromDeg: int option + /// Rider's primary travel direction for fallback recomputation. + PrimaryTravelDirection: string option } +``` + +### 3.2 Dashboard Calculation Functions + +New module or extension of `AdvancedDashboardCalculations.fs`: + +```fsharp +/// Resolves the effective difficulty for a single ride (FR-022 derivation chain). +/// Priority: stored Difficulty → stored WindResistanceRating → raw recompute → None. +let resolveDifficulty (snapshot: RideDifficultySnapshot) : int option = + match snapshot.StoredDifficulty with + | Some d -> Some d + | None -> + match snapshot.StoredWindResistanceRating with + | Some r -> Some (resistanceToDifficulty r) + | None -> + match snapshot.PrimaryTravelDirection with + | None -> None + | Some dir -> + match WindResistance.tryParseCompassDirection dir with + | None -> None + | Some compassDir -> + match WindResistance.calculateDifficulty + snapshot.WindSpeedMph + compassDir + snapshot.WindFromDeg with + | Ok (_, d) -> Some d + | Error _ -> None + +/// Calculates average difficulty per calendar month (1–12) across all years. +/// Returns a list of (monthNumber, monthName, averageDifficulty) for months with data. +/// Months with no qualifying rides are omitted (not shown as 0). +let calculateDifficultyByMonth + (snapshots: RideDifficultySnapshot list) + : (int * string * decimal) list = + let monthNames = + [| ""; "January"; "February"; "March"; "April"; "May"; "June"; + "July"; "August"; "September"; "October"; "November"; "December" |] + snapshots + |> List.choose (fun s -> + resolveDifficulty s |> Option.map (fun d -> (s.RideDate.Month, d))) + |> List.groupBy fst + |> List.map (fun (month, pairs) -> + let avg = + pairs + |> List.averageBy (fun (_, d) -> decimal d) + |> fun v -> Math.Round(v, 1, MidpointRounding.AwayFromZero) + (month, monthNames.[month], avg)) + |> List.sortBy (fun (m, _, _) -> m) + +/// Overall average difficulty across all qualifying rides (1 decimal place). +let calculateOverallAverageDifficulty (snapshots: RideDifficultySnapshot list) : decimal option = + let difficulties = + snapshots |> List.choose resolveDifficulty + match difficulties with + | [] -> None + | ds -> + ds + |> List.averageBy decimal + |> fun v -> Some (Math.Round(v, 1, MidpointRounding.AwayFromZero)) + +/// Wind resistance rating frequency distribution: counts per bin −4 to +4. +let calculateWindResistanceDistribution + (snapshots: RideDifficultySnapshot list) + : (int * int) list = + [ -4 .. 4 ] + |> List.map (fun bin -> + let count = + snapshots + |> List.filter (fun s -> s.StoredWindResistanceRating = Some bin) + |> List.length + (bin, count)) +``` + +--- + +## 4. Validation Rules Summary + +### 4.1 Three-Layer Validation Matrix + +| Field | Layer 1: React client | Layer 2: API DataAnnotations | Layer 3: DB constraint | +|-------|----------------------|------------------------------|----------------------| +| `Difficulty` | Optional; if present, integer 1–5 | `[Range(1, 5)]` optional | `CHECK (Difficulty IS NULL OR (Difficulty >= 1 AND Difficulty <= 5))` | +| `PrimaryTravelDirection` | Optional; if present, accepts full names or 2‑letter abbreviations and is normalized to canonical 2‑letter abbreviations | `[MaxLength(2)]` + custom validator | `HasMaxLength(2)` | +| `WindResistanceRating` | Not editable by user (system-computed only) | Not in request contract | `CHECK (WindResistanceRating IS NULL OR (WindResistanceRating >= -4 AND WindResistanceRating <= 4))` | + +### 4.2 CSV Import Validation + +| Column | Rule | Error code | +|--------|------|-----------| +| `Difficulty` | Optional integer 1–5 | `INVALID_DIFFICULTY` | +| `Direction` | Optional; accepts full names or 2‑letter abbreviations (case-insensitive); normalized internally to `N, NE, E, SE, S, SW, W, NW` | `INVALID_DIRECTION` | + +--- + +## 5. State Transitions + +### 5.1 WindResistanceRating Lifecycle + +``` +RIDE RECORDED (with Direction + Wind) → WindResistanceRating computed at save → stored on Ride +RIDE RECORDED (without Direction) → WindResistanceRating = null +RIDE RECORDED (without WindSpeed) → WindResistanceRating = null + +RIDE EDITED (Direction changed) → WindResistanceRating recomputed at save → stored on Ride +RIDE EDITED (Direction not changed) → WindResistanceRating unchanged +RIDE EDITED (Direction cleared) → WindResistanceRating = null + +CSV IMPORT (with Direction + Wind) → WindResistanceRating computed per row → stored on Ride +CSV IMPORT (without Direction) → WindResistanceRating = null +``` + +### 5.2 Difficulty Lifecycle (FR-026, FR-027) + +``` +RECORD FORM: Direction selected → client suggests difficulty (TypeScript formula) +RECORD FORM: Direction changed → client re-suggests difficulty (clears prior auto-value) +RECORD FORM: Rider overrides → manual value preserved +RECORD FORM: Saved → server computes WindResistanceRating; stores rider's Difficulty value + +EDIT FORM: Opens → form shows stored Difficulty (if any) +EDIT FORM: Direction changed → client re-suggests difficulty (new suggestion only) +EDIT FORM: Rider accepts / overrides → rider's choice +EDIT FORM: Saved → server recomputes WindResistanceRating; stores rider's final Difficulty + +CSV IMPORT: Difficulty column present → stored as-is (no server suggestion) +CSV IMPORT: Difficulty absent → null; WindResistanceRating computed from Direction+Wind if available +``` + +--- + +## 6. TypeScript Types + +### 6.1 New / Extended Types in `ridesService.ts` + +```typescript +// Compass direction string literal union — no `any` types +export type CompassDirection = + | "North" + | "NE" + | "NW" + | "South" + | "SE" + | "SW" + | "East" + | "West"; + +export const COMPASS_DIRECTIONS: CompassDirection[] = [ + "North", "NE", "East", "SE", "South", "SW", "West", "NW", +]; + +// RecordRideRequest — extended +export interface RecordRideRequest { + // ... existing fields ... + difficulty?: number; // optional integer 1–5 + primaryTravelDirection?: CompassDirection; // optional +} + +// EditRideRequest — extended +export interface EditRideRequest { + // ... existing fields ... + difficulty?: number; + primaryTravelDirection?: CompassDirection; +} + +// RideHistoryRow — extended +export interface RideHistoryRow { + // ... existing fields ... + difficulty?: number; + primaryTravelDirection?: CompassDirection; + windResistanceRating?: number; // −4 to +4, read-only +} +``` + +### 6.2 Client-Side Formula Mirror (`utils/windResistance.ts`) + +```typescript +// mirrors WindResistance.fs — authoritative calculation remains server-side + +const COMPASS_DEGREES: Record = { + North: 0, NE: 45, East: 90, SE: 135, South: 180, SW: 225, West: 270, NW: 315, +}; + +function shorterArc(a: number, b: number): number { + const diff = Math.abs(a - b); + return diff > 180 ? 360 - diff : diff; +} + +export function calculateWindResistance( + windSpeedMph: number, + travelDirection: CompassDirection, + windFromDeg: number +): number { + const travelDeg = COMPASS_DEGREES[travelDirection]; + const angleDeg = shorterArc(travelDeg, windFromDeg); + const angleRad = (angleDeg * Math.PI) / 180; + const raw = (windSpeedMph * Math.cos(angleRad)) / 5; + const rounded = Math.round(raw); + return Math.max(-4, Math.min(4, rounded)); +} + +export function resistanceToDifficulty(resistance: number): number { + if (resistance <= -3) return 1; + if (resistance === -2 || resistance === -1) return 2; + if (resistance === 0) return 3; + if (resistance === 1 || resistance === 2) return 4; + return 5; +} + +/** Suggests difficulty given ride wind data. Returns null when insufficient data. */ +export function suggestDifficulty( + windSpeedMph: number | undefined, + travelDirection: CompassDirection, + windFromDeg: number | undefined +): number | null { + if (!windSpeedMph || windSpeedMph === 0) return 1; // FR-012 + if (windFromDeg === undefined) return null; + const resistance = calculateWindResistance(windSpeedMph, travelDirection, windFromDeg); + return resistanceToDifficulty(resistance); +} +``` diff --git a/specs/019-ride-difficulty-wind/plan.md b/specs/019-ride-difficulty-wind/plan.md new file mode 100644 index 0000000..3b48222 --- /dev/null +++ b/specs/019-ride-difficulty-wind/plan.md @@ -0,0 +1,118 @@ +# Implementation Plan: Ride Difficulty & Wind Resistance Rating + +**Branch**: `019-ride-difficulty-wind` | **Date**: 2026-04-24 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/019-ride-difficulty-wind/spec.md` + +## Summary + +Add optional ride difficulty rating (1–5) and primary travel direction (8-point compass) fields to the record-ride and edit-ride flows. At save time, compute and persist a `WindResistanceRating` (−4 to +4) using a cosine-based formula against the ride's captured wind speed and direction. The Advanced Dashboard gains a difficulty analytics section (overall average, calendar-month breakdown, most-difficult-month ranking, wind resistance distribution chart). CSV import is extended to accept `Difficulty` and `Direction` columns. The wind resistance formula lives as a pure F# function in `BikeTracking.Domain.FSharp`; all UI suggestion pre-filling mirrors the formula in TypeScript for instant feedback without a round-trip. + +## Technical Context + +**Language/Version**: C# (.NET 10) — API layer; F# (latest stable) — domain layer; TypeScript/React 19 — frontend +**Primary Dependencies**: ASP.NET Core Minimal API, Entity Framework Core (SQLite), Recharts 3.x, React Router v7, .NET Aspire +**Storage**: SQLite via EF Core Code-First migrations (auto-applied on startup via `MigrateAsync()`) +**Testing**: xUnit 2.9.3; EF Core In-Memory for unit tests; SQLite integration tests for endpoints +**Target Platform**: .NET Aspire local-first; containerised deployment via Azure Container Apps (optional) +**Project Type**: Web service + SPA frontend +**Performance Goals**: API response <500ms p95; difficulty suggestion pre-fill <1 second (SC-002); dashboard section loads within existing dashboard budget (SC-005) +**Constraints**: No inline CSS; no TypeScript `any` types; three-layer validation (client / server / DB); outbox pattern for all ride mutations; Railway Oriented Programming in F# domain +**Scale/Scope**: Single-rider, local-first; existing ride history rows extended with 3 nullable columns; 3 new F# types; 2 new migration columns + 1 index + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Check | Status | +|-----------|-------|--------| +| **I – Ports & Adapters** | Wind resistance formula is a pure F# function in domain layer; C# service layer calls it; no business logic in API endpoints | ✅ PASS | +| **I – No god services** | Formula logic lives in `WindResistance.fs`; `RecordRideService` / `EditRideService` are orchestrators only | ✅ PASS | +| **I – ACL for third-party** | No new third-party integrations; existing `IWeatherLookupService` already wraps Open-Meteo | ✅ PASS | +| **II – Pure functions** | `WindResistance.fs` has zero side effects; same inputs always return same output; F# `Result<'T>` for error paths | ✅ PASS | +| **III – Event Sourcing** | `RideRecordedEventPayload` and `RideEditedEventPayload` extended with new fields; outbox pattern preserved | ✅ PASS | +| **IV – TDD gates** | Unit tests for F# formula module required; integration tests for RecordRide/EditRide with direction; dashboard difficulty tests required | ✅ PASS | +| **V – Three-layer validation** | React client-side (1–5 range, enum); DataAnnotations server-side; DB CHECK constraints in migration | ✅ PASS | +| **VI – C# Result types** | F# domain returns `Result`; C# unwraps via `FSharpValue.GetUnionFields` pattern | ✅ PASS | +| **VII – Frontend standards** | CSS class-only styling; TypeScript string literal union for `CompassDirection`; no `any` types | ✅ PASS | +| **VIII – Outbox** | All ride mutations (RecordRide, EditRide) publish via OutboxEventEntity; no direct event publish | ✅ PASS | +| **X – Trunk-based dev** | Short-lived feature branch `019-ride-difficulty-wind`; PR-gated merge; no long-lived branches | ✅ PASS | + +**Gate result: ALL PASS — no violations requiring justification.** + +## Project Structure + +### Documentation (this feature) + +```text +specs/019-ride-difficulty-wind/ +├── plan.md ← this file +├── research.md ← Phase 0 decisions +├── data-model.md ← entity changes + F# types +├── quickstart.md ← developer implementation guide +├── contracts/ +│ ├── rides-api.md ← modified ride endpoints +│ ├── dashboard-api.md ← extended advanced dashboard endpoint +│ └── csv-import-format.md ← extended CSV column spec +└── tasks.md ← Phase 2 output (NOT created by /speckit.plan) +``` + +### Source Code Changes (repository root) + +```text +src/ +├── BikeTracking.Domain.FSharp/ +│ └── WindResistance.fs # NEW: pure wind resistance module +│ +├── BikeTracking.Api/ +│ ├── Infrastructure/Persistence/ +│ │ ├── Entities/ +│ │ │ └── RideEntity.cs # EXTEND: +Difficulty, +PrimaryTravelDirection, +WindResistanceRating +│ │ └── Migrations/ +│ │ └── YYYYMMDD_AddRideDifficultyAndWindRating.cs # NEW +│ ├── Application/ +│ │ ├── Rides/ +│ │ │ ├── RecordRideService.cs # EXTEND: compute WindResistanceRating at save +│ │ │ └── EditRideService.cs # EXTEND: recompute on PrimaryTravelDirection change +│ │ ├── Imports/ +│ │ │ ├── CsvValidationRules.cs # EXTEND: Difficulty + Direction columns +│ │ │ ├── CsvParser.cs # EXTEND: parse new columns +│ │ │ └── SampleCsvGenerator.cs # NEW: generate sample CSV with all columns +│ │ ├── Events/ +│ │ │ ├── RideRecordedEventPayload.cs # EXTEND: +Difficulty, +PrimaryTravelDirection, +WindResistanceRating +│ │ │ └── RideEditedEventPayload.cs # EXTEND: same new fields +│ │ └── Dashboard/ +│ │ └── GetAdvancedDashboardService.cs # EXTEND: difficulty analytics section +│ ├── Contracts/ +│ │ ├── RidesContracts.cs # EXTEND: request/response records +│ │ └── DashboardContracts.cs # EXTEND: AdvancedDashboardDifficultySection +│ └── Endpoints/ +│ ├── RidesEndpoints.cs # EXTEND: wire GET /api/rides/csv-sample +│ └── DashboardEndpoints.cs # EXTEND (if separate file exists) +│ +├── BikeTracking.Frontend/src/ +│ ├── pages/ +│ │ ├── RecordRidePage.tsx # EXTEND: direction + difficulty fields + auto-suggest +│ │ ├── HistoryPage.tsx # EXTEND: direction/difficulty columns in grid +│ │ └── import-rides/ +│ │ └── ImportRidesPage.tsx # EXTEND: Difficulty/Direction error messages +│ └── pages/advanced-dashboard/ +│ ├── advanced-dashboard-page.tsx # EXTEND: render DifficultyAnalyticsSection +│ └── DifficultyAnalyticsSection.tsx # NEW: monthly chart + ranking + wind resistance chart +│ ├── services/ +│ │ └── ridesService.ts # EXTEND: types for new fields +│ └── utils/ +│ └── windResistance.ts # NEW: client-side formula mirror for instant suggestion +│ +└── BikeTracking.Api.Tests/ + ├── Application/ + │ ├── Rides/ + │ │ └── WindResistanceCalculationTests.cs # NEW + │ └── Dashboard/ + │ └── DifficultyAnalyticsTests.cs # NEW + └── Endpoints/ + └── Rides/ + └── RecordRideWithDifficultyTests.cs # NEW (integration) + +## Complexity Tracking + +> No violations — gate check clean; no complexity justification required. diff --git a/specs/019-ride-difficulty-wind/quickstart.md b/specs/019-ride-difficulty-wind/quickstart.md new file mode 100644 index 0000000..d8b698c --- /dev/null +++ b/specs/019-ride-difficulty-wind/quickstart.md @@ -0,0 +1,349 @@ +# Quickstart: Ride Difficulty & Wind Resistance Rating + +**Feature**: `019-ride-difficulty-wind` +**Branch**: `019-ride-difficulty-wind` +**Date**: 2026-04-24 + +This guide walks a developer through implementing the full feature end-to-end, in dependency order. + +--- + +## Prerequisites + +- Working in the DevContainer (mandatory per constitution) +- `main` branch compiles and all tests pass: `dotnet test` +- Node modules installed: `cd src/BikeTracking.Frontend && npm install` +- Feature branch checked out: `git checkout 019-ride-difficulty-wind` + +--- + +## Step 1 — F# Domain Module (WindResistance.fs) + +**Why first**: Every other layer depends on this pure calculation. Tests validate the formula before any persistence or API work. + +1. Create `src/BikeTracking.Domain.FSharp/WindResistance.fs` using the module spec from `data-model.md §2.1`. +2. Add it to `BikeTracking.Domain.FSharp.fsproj` before `AdvancedDashboardCalculations.fs`. +3. Write unit tests in `BikeTracking.Api.Tests/Application/Rides/WindResistanceCalculationTests.cs`: + - `degreesToCompass`: boundary cases (0°, 22°, 23°, 45°, 337°, 360°). + - `calculateResistance`: 20 mph headwind = +4; 20 mph tailwind = −4; crosswind = 0; clamp at 4/-4; negative speed → Error. + - `calculateDifficulty`: wind=0 → (0, 1); no wind data → (0, 1); full headwind = (4, 5); full tailwind = (−4, 1). + - `resistanceToDifficulty`: all 9 input values (−4 to +4). +4. Build: `dotnet build src/BikeTracking.Domain.FSharp/` +5. Run tests: `dotnet test --filter "WindResistanceCalculation"` + +**TDD gate**: Commit red baseline, confirm failures, implement, commit green. + +--- + +## Step 2 — Database Migration + +**Why second**: Entity and migration must exist before service layer changes. + +1. Add three nullable properties to `RideEntity` (see `data-model.md §1.1`): + - `int? Difficulty` + - `string? PrimaryTravelDirection` + - `int? WindResistanceRating` + +2. Add EF Core model configuration in `BikeTrackingDbContext.OnModelCreating` for `Rides`: + + ```csharp + modelBuilder.Entity() + .Property(r => r.PrimaryTravelDirection) + .HasMaxLength(5); + + modelBuilder.Entity() + .HasCheckConstraint( + "CK_Rides_Difficulty", + "Difficulty IS NULL OR (Difficulty >= 1 AND Difficulty <= 5)"); + + modelBuilder.Entity() + .HasCheckConstraint( + "CK_Rides_WindResistanceRating", + "WindResistanceRating IS NULL OR (WindResistanceRating >= -4 AND WindResistanceRating <= 4)"); + ``` + +3. Generate migration from DevContainer terminal: + + ```bash + cd src/BikeTracking.Api + dotnet ef migrations add AddRideDifficultyAndWindRating \ + --project ../BikeTracking.Api \ + --output-dir Infrastructure/Persistence/Migrations + ``` + +4. Verify generated SQL includes the three `ADD COLUMN` statements. + +5. If CHECK constraint SQL is unsupported (see `SqliteMigrationBootstrapper`), add the new migration ID to `UnsupportedConstraintMigrations`. + +6. Run migration manually to verify: `dotnet run --project src/BikeTracking.Api` and check startup logs for "Applying migrations…". + +7. Write `RidesPersistenceTests.cs` tests: + - Difficulty saved and retrieved correctly. + - WindResistanceRating saved and retrieved correctly. + - `PrimaryTravelDirection` saved and retrieved; max-length constraint. + +--- + +## Step 3 — RecordRideService (Compute at Save) + +1. Update `RecordRideRequest` in `RidesContracts.cs` to add `int? Difficulty` and `string? PrimaryTravelDirection` (see `contracts/rides-api.md §1`). + +2. Update `RecordRideService.ExecuteAsync`: + + ```csharp + // After weather merge, before entity creation: + int? windResistanceRating = null; + if (request.PrimaryTravelDirection is not null + && windSpeedMph.HasValue + && windDirectionDeg.HasValue) + { + var directionResult = WindResistance.tryParseCompassDirection(request.PrimaryTravelDirection); + if (directionResult is FSharpOption.Some dir) + { + var result = WindResistance.calculateDifficulty( + FSharpOption.Some(windSpeedMph.Value), + dir.Value, + FSharpOption.Some(windDirectionDeg.Value)); + if (result.IsOk) windResistanceRating = result.ResultValue.Item1; + } + } + + // Assign to entity: + rideEntity.Difficulty = request.Difficulty; + rideEntity.PrimaryTravelDirection = request.PrimaryTravelDirection; + rideEntity.WindResistanceRating = windResistanceRating; + ``` + +3. Update `RideRecordedEventPayload` to include the three new fields (see `contracts/rides-api.md §7`). + +4. Write tests: `RecordRideWithDifficultyTests.cs` + - Record ride with direction + wind → WindResistanceRating computed and persisted. + - Record ride without direction → WindResistanceRating null. + - Record ride with wind speed 0 → WindResistanceRating 0 and Difficulty stored if provided. + +--- + +## Step 4 — EditRideService (Recompute on Direction Change) + +1. Update `EditRideRequest` in `RidesContracts.cs` to add `int? Difficulty` and `string? PrimaryTravelDirection`. + +2. Extend `EditRideService.ExecuteAsync` to: + - Compare incoming `PrimaryTravelDirection` with `ride.PrimaryTravelDirection`. + - If direction changed or cleared, recompute `WindResistanceRating`. + - Always store `request.Difficulty` as the rider's final choice (no silent override). + - If direction is cleared (`null`), set `WindResistanceRating = null`. + +3. Update `RideEditedEventPayload` with new fields. + +4. Update `RideHistoryRow` response to include all three new fields. + +5. Write tests: `EditRideWithDifficultyTests.cs` + - Direction unchanged → WindResistanceRating unchanged. + - Direction changed → WindResistanceRating recomputed. + - Direction cleared → WindResistanceRating set to null. + - Difficulty in request stored as-is (no server-side override). + +--- + +## Step 5 — CSV Import Extension + +1. Extend `ParsedCsvRow` with `string? Difficulty` and `string? Direction` (see `contracts/csv-import-format.md §3`). + +2. Update `CsvParser` to map `difficulty` and `direction` header columns (case-insensitive) to the new row properties. + +3. Update `CsvValidationRules.ValidateRow` with `INVALID_DIFFICULTY` and `INVALID_DIRECTION` rules. + +4. Update `ImportJobProcessor` to: + - Parse and store `Difficulty` from CSV row. + - Parse `Direction`, canonicalise casing via `WindResistance.tryParseCompassDirection`. + - Compute `WindResistanceRating` when Direction + WindSpeed available. + +5. Create `SampleCsvGenerator` (see `contracts/csv-import-format.md §5`). + +6. Register `GET /api/rides/csv-sample` endpoint in `RidesEndpoints.cs`. + +7. Write import tests: + - Valid `Difficulty` values (1–5) imported correctly. + - `Difficulty` value 0 or 6 → `INVALID_DIFFICULTY` error. + - Valid `Direction` values (all 8, case-insensitive) imported correctly. + - "Northeast" → `INVALID_DIRECTION` error (listing valid values). + - Absent `Difficulty` + `Direction` columns → no error. + - Sample CSV download returns correct headers and content. + +--- + +## Step 6 — Advanced Dashboard (Difficulty Analytics) + +1. Add new record types to `DashboardContracts.cs`: `AdvancedDashboardDifficultySection`, `DifficultyByMonth`, `WindResistanceBin` (see `contracts/dashboard-api.md §2`). + +2. Extend `AdvancedDashboardResponse` with nullable `DifficultySection` property. + +3. Add F# dashboard calculation functions to `AdvancedDashboardCalculations.fs` (or new `DifficultyCalculations.fs`): `resolveDifficulty`, `calculateDifficultyByMonth`, `calculateOverallAverageDifficulty`, `calculateWindResistanceDistribution` (see `data-model.md §3.2`). + +4. Extend `GetAdvancedDashboardService.GetAsync`: + - Project rides to `RideDifficultySnapshot` list. + - Call F# calculation functions. + - Build `AdvancedDashboardDifficultySection` from results. + - Set `IsEmpty = true` and return empty section when no qualifying data. + +5. Write tests: `DifficultyAnalyticsTests.cs` + - Overall average of rides with stored difficulty. + - Monthly grouping: rides in Jan across multiple years average together. + - `MostDifficultMonths` sorted descending. + - FR-022 derivation chain: stored difficulty → stored rating → raw recompute. + - Empty state when no wind data and no difficulty. + - Wind resistance distribution: counts per bin. + +--- + +## Step 7 — Frontend: Record Ride Form + +**File**: `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx` + +1. Create `src/BikeTracking.Frontend/src/utils/windResistance.ts` with the TypeScript formula mirror (see `data-model.md §6.2`). + +2. Add state variables to `RecordRidePage`: + + ```tsx + const [difficulty, setDifficulty] = useState(""); + const [primaryTravelDirection, setPrimaryTravelDirection] = + useState(""); + const [isDifficultyAutoFilled, setIsDifficultyAutoFilled] = useState(false); + ``` + +3. Add `useEffect` for auto-suggestion when direction or wind speed changes: + + ```tsx + useEffect(() => { + if (!primaryTravelDirection) return; + const windMph = windSpeedMph ? parseFloat(windSpeedMph) : undefined; + const windDeg = windDirectionDeg ? parseInt(windDirectionDeg) : undefined; + const suggested = suggestDifficulty(windMph, primaryTravelDirection, windDeg); + if (suggested !== null) { + setDifficulty(suggested); + setIsDifficultyAutoFilled(true); + } + }, [primaryTravelDirection, windSpeedMph, windDirectionDeg]); + ``` + +4. Add form fields: + - `PrimaryTravelDirection` `` with options 1–5 (labelled Very Easy … Very Hard) + empty option. Show "(suggested)" label when `isDifficultyAutoFilled` is true. + - When rider manually changes `Difficulty`, set `isDifficultyAutoFilled = false`. + - When rider clears `PrimaryTravelDirection`, clear the suggested `Difficulty` (if it was auto-filled) and reset `isDifficultyAutoFilled`. + +5. Include new fields in the submit payload: + ```tsx + difficulty: difficulty !== "" ? difficulty : undefined, + primaryTravelDirection: primaryTravelDirection !== "" ? primaryTravelDirection : undefined, + ``` + +6. Validate in submit handler: if `difficulty !== ""`, ensure it is 1–5. + +7. Styling: use CSS classes only (no inline styles). Add classes to `RecordRidePage.css` (or the appropriate CSS module). + +--- + +## Step 8 — Frontend: Edit Ride Form + +If an edit ride form exists (separate component or inline), apply the same direction + difficulty fields as Step 7. + +Key differences from record flow: +- Pre-populate direction and difficulty from the ride's stored values (from history row). +- When direction changes, re-suggest difficulty but mark as suggestion (not authoritative) — FR-027. +- Send `difficulty` and `primaryTravelDirection` in `EditRideRequest`. + +--- + +## Step 9 — Frontend: Advanced Dashboard Difficulty Section + +**New file**: `src/BikeTracking.Frontend/src/pages/advanced-dashboard/DifficultyAnalyticsSection.tsx` + +```tsx +interface DifficultyAnalyticsSectionProps { + section: AdvancedDashboardDifficultySection; +} + +export function DifficultyAnalyticsSection({ section }: DifficultyAnalyticsSectionProps) { + if (section.isEmpty) { + return ( +
+

Record rides with travel direction to see difficulty trends.

+
+ ); + } + + return ( +
+

Ride Difficulty

+ {/* Overall average */} + {/* Monthly bar chart using Recharts BarChart */} + {/* Most difficult months ranked list */} + {/* Wind resistance distribution chart — negative bars styled differently (FR-024) */} +
+ ); +} +``` + +**Charts**: +- Monthly difficulty: `BarChart` with month names on X-axis, `averageDifficulty` on Y-axis. +- Wind resistance distribution: `BarChart` with rating bins −4 to +4 on X-axis. Use different `fill` CSS class for `isAssisted: true` (tailwind) vs `false` (headwind) bars (FR-024). +- All chart colours via CSS custom properties / `ChartConfig` pattern (matches existing `dashboard-chart-section.tsx` pattern). + +Extend `advanced-dashboard-page.tsx` to render `` when `data.difficultySection` is not null. + +--- + +## Step 10 — Frontend: History Page & Import Page + +1. `HistoryPage.tsx`: Add `Difficulty` and `PrimaryTravelDirection` columns to the ride history table. `WindResistanceRating` can be shown as a small badge (e.g., "+3 headwind"). + +2. `ImportRidesPage.tsx`: No structural change — validation errors with `INVALID_DIFFICULTY` and `INVALID_DIRECTION` codes will display via the existing error rendering path. + +--- + +## Verification Checklist + +Before raising the PR, confirm: + +- [ ] `dotnet build` — solution compiles with no warnings +- [ ] `dotnet test` — all existing tests still pass; new tests pass +- [ ] `npm run build` — frontend builds with no TypeScript errors (`strict: true`) +- [ ] Aspire host starts: `dotnet run --project src/BikeTracking.AppHost` +- [ ] Record ride with direction + wind → difficulty auto-fills within 1 second (SC-002) +- [ ] Save ride → `WindResistanceRating` in DB (verify via sqlite3 or test) +- [ ] Edit ride, change direction → `WindResistanceRating` recomputed at save +- [ ] CSV import with `Difficulty` and `Direction` columns → rows imported correctly +- [ ] Invalid `Difficulty` (6) → row rejected with `INVALID_DIFFICULTY` error +- [ ] Invalid `Direction` ("Northeast") → row rejected with `INVALID_DIRECTION` error +- [ ] `GET /api/rides/csv-sample` → downloads CSV with all columns +- [ ] Advanced Dashboard shows difficulty section with correct averages +- [ ] Wind resistance chart visually distinguishes negative (tailwind) bars + +--- + +## Key File Locations Reference + +| What | Where | +|------|-------| +| F# wind resistance module | `src/BikeTracking.Domain.FSharp/WindResistance.fs` | +| F# project file | `src/BikeTracking.Domain.FSharp/BikeTracking.Domain.FSharp.fsproj` | +| Ride entity | `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` | +| Migration | `src/BikeTracking.Api/Infrastructure/Persistence/Migrations/` | +| Record ride service | `src/BikeTracking.Api/Application/Rides/RecordRideService.cs` | +| Edit ride service | `src/BikeTracking.Api/Application/Rides/EditRideService.cs` | +| CSV validation | `src/BikeTracking.Api/Application/Imports/CsvValidationRules.cs` | +| CSV parser | `src/BikeTracking.Api/Application/Imports/CsvParser.cs` | +| Sample CSV generator | `src/BikeTracking.Api/Application/Imports/SampleCsvGenerator.cs` | +| Rides contracts | `src/BikeTracking.Api/Contracts/RidesContracts.cs` | +| Dashboard contracts | `src/BikeTracking.Api/Contracts/DashboardContracts.cs` | +| Advanced dashboard service | `src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs` | +| Event payloads | `src/BikeTracking.Api/Application/Events/` | +| TS formula mirror | `src/BikeTracking.Frontend/src/utils/windResistance.ts` | +| Record ride page | `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx` | +| Advanced dashboard page | `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx` | +| Difficulty analytics section | `src/BikeTracking.Frontend/src/pages/advanced-dashboard/DifficultyAnalyticsSection.tsx` | +| Frontend API types | `src/BikeTracking.Frontend/src/services/ridesService.ts` | +| Advanced dashboard API types | `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` | +| Wind resistance tests | `src/BikeTracking.Api.Tests/Application/Rides/WindResistanceCalculationTests.cs` | +| Difficulty analytics tests | `src/BikeTracking.Api.Tests/Application/Dashboard/DifficultyAnalyticsTests.cs` | diff --git a/specs/019-ride-difficulty-wind/research.md b/specs/019-ride-difficulty-wind/research.md new file mode 100644 index 0000000..118e422 --- /dev/null +++ b/specs/019-ride-difficulty-wind/research.md @@ -0,0 +1,233 @@ +# Research: Ride Difficulty & Wind Resistance Rating + +**Feature**: `019-ride-difficulty-wind` +**Phase**: 0 — Outline & Research +**Date**: 2026-04-24 + +All unknowns from the Technical Context have been resolved. This document records the decision, rationale, and alternatives for each investigation area. + +--- + +## Decision 1 — Wind Direction Convention (meteorological vs. geographic) + +**Decision**: Use **meteorological convention** throughout — wind direction (degrees 0–360) means the compass bearing the wind is blowing **FROM**, not towards. `0°` = wind blowing FROM the north. + +**Rationale**: +- Open-Meteo (the weather API used since spec 011) returns `wind_direction_10m` in meteorological degrees (wind FROM bearing). Existing field `WindDirectionDeg` on `RideEntity` is already stored in this convention. +- Meteorological convention is the standard in all wind atlases, cycling apps, and weather tooling. Using geographic convention (wind-towards) would require silently inverting all stored weather data. + +**Alternatives considered**: +- Geographic ("wind-towards"): Rejected — contradicts stored data semantics; would introduce hidden inversion bug. +- Storing as 8-point compass name: Rejected — loss of precision for future 16-point or degree-level analysis. The API stores degrees; conversion to compass points happens at domain-logic boundary. + +--- + +## Decision 2 — Degree-to-8-Point-Compass Mapping + +**Decision**: Map 0–360° to an 8-point compass using **22.5° bin boundaries**, centered on each cardinal/intercardinal point. Formula: `sector = floor((degrees + 22) % 360 / 45)`. + +| Degrees | Compass | Center | +|---------|---------|--------| +| 337.5° – 22.5° | North | 0° | +| 22.5° – 67.5° | NE | 45° | +| 67.5° – 112.5° | East | 90° | +| 112.5° – 157.5° | SE | 135° | +| 157.5° – 202.5° | South | 180° | +| 202.5° – 247.5° | SW | 225° | +| 247.5° – 292.5° | West | 270° | +| 292.5° – 337.5° | NW | 315° | + +**Rationale**: 45° equal sectors are the WMO standard for 8-point compass mapping; placing boundaries at midpoints minimises directional ambiguity. The `+22` offset before modulo handles the wrap-around at north (0°/360°). + +**Alternatives considered**: +- 16-point compass: Rejected — spec explicitly uses 8-point direction names; adds UI/UX complexity for negligible precision gain given the formula's rounding to integer ratings. +- Nearest-integer truncation without offset: Rejected — misclassifies 350° as `NW` instead of `North`. + +--- + +## Decision 3 — Wind Resistance Formula + +**Decision**: Use the cosine-based formula specified in the feature spec: + +``` +rawResistance = windSpeedMph × cos(angleBetweenDirections) / 5.0 +resistance = clamp(round(rawResistance, AwayFromZero), −4, +4) +``` + +Where `angleBetweenDirections` is the **shorter arc** (0–180°) between the rider's travel direction (degrees) and the wind-FROM bearing (degrees). + +**Sign convention confirmed**: +| Scenario | Angle | cos | Resistance | Meaning | +|----------|-------|-----|-----------|---------| +| Direct headwind (travel North, wind FROM North) | 0° | +1.0 | +4 | Hardest | +| Crosswind (travel North, wind FROM East) | 90° | 0.0 | 0 | Neutral | +| Direct tailwind (travel North, wind FROM South) | 180° | −1.0 | −4 | Easiest | +| 20 mph direct headwind (spec target) | 0° | +1.0 | `round(20×1/5)` = 4 | ✅ matches spec | + +**Rationale**: +- Formula is directly specified in spec Assumptions section. This decision validates and confirms correctness. +- `MidpointRounding.AwayFromZero` aligns with the precedent set in `AdvancedDashboardCalculations.fs`. +- Shorter-arc semantics ensures a 190° difference is treated as a 170° difference from the other side — physically correct, since the closer the wind source to your travel direction (front half), the greater the headwind component. + +**Alternatives considered**: +- Sine-based formula: Rejected — `sin(0°)=0` would produce zero resistance for a direct headwind; opposite of physical reality. +- Linear step function by compass sectors (e.g., headwind=+4, crosswind=0, tailwind=−4): Rejected — loses the intermediate wind speed scaling; 3 mph crosswind should differ from 20 mph crosswind. + +--- + +## Decision 4 — Special Case: Zero Wind Speed + +**Decision**: When `windSpeedMph` is `null` or `0`, skip the formula entirely and return `(windResistanceRating = 0, suggestedDifficulty = 1)`. + +**Rationale**: FR-012 explicitly specifies this rule. Zero wind means no resistance component; difficulty 1 (Very Easy) is the correct interpretation — the rider is not fighting wind at all. The formula would also produce `0` for zero speed, but returning difficulty `3` (neutral mapping of 0) would be misleading when wind is simply absent. + +**Alternatives considered**: +- Apply formula → resistance 0 → difficulty 3 (Moderate): Rejected — contradicts FR-012 and user expectation; calm weather should not suggest "moderate" difficulty. + +--- + +## Decision 5 — Difficulty Mapping (Wind Resistance → 1–5 Scale) + +**Decision**: Use this lookup table: + +| Wind Resistance | Difficulty | Label | +|-----------------|------------|-------| +| ≤ −3 (i.e., −3 or −4) | 1 | Very Easy | +| −2 or −1 | 2 | Easy | +| 0 | 3 | Moderate | +| +1 or +2 | 4 | Hard | +| ≥ +3 (i.e., +3 or +4) | 5 | Very Hard | + +**F# match expression**: +```fsharp +let resistanceToDifficulty (r: int) : int = + if r <= -3 then 1 + elif r = -2 || r = -1 then 2 + elif r = 0 then 3 + elif r = 1 || r = 2 then 4 + else 5 +``` + +**Rationale**: +- Spec FR-011: "strong tailwind biases toward 1–2, neutral toward 3, strong headwind toward 4–5." This mapping satisfies that exactly. +- Symmetric bin sizes around 0: bins −3/−4 and +3/+4 are each size 2 (matching the 1-and-5 extremes), bins −1/−2 and +1/+2 are size 2 (matching 2 and 4), and 0 maps to 3. +- Zero wind (special case, Decision 4) returns 1, not 3 — the two rules are consistent but must be checked in order: zero-speed check fires first. + +**Alternatives considered**: +- Linear formula `round(resistance/2 + 3)`: Produces fractional difficulties for odd resistances; requires an additional round; less readable in F# match. +- 3-bucket mapping (1–2, 3, 4–5): Too coarse; loses resolution for moderate winds. + +--- + +## Decision 6 — Client-Side Suggestion vs. Server Round-Trip + +**Decision**: Mirror the wind resistance formula in TypeScript (`src/utils/windResistance.ts`) for the **client-side suggestion** (pre-fill on direction-change). The server-side F# function remains authoritative for persistence at save time. + +**Rationale**: +- SC-002 requires the auto-fill within 1 second of direction selection. A round-trip API call adds latency and complexity for a non-authoritative suggestion. +- The formula is deterministic and small — five lines of arithmetic. Duplicating it in TypeScript is low risk and directly mirrors the F# module (same lookup table, same rounding). +- The persisted value (written via F# domain layer) is what matters for analytics; the TypeScript value is display-only and will be overwritten on save with the authoritative server result. +- Constitution does not prohibit mirroring pure formulas in the frontend; it prohibits domain *logic* (state machines, validation rules) from living only in the frontend. + +**Alternatives considered**: +- New `GET /api/rides/wind-resistance-suggestion` endpoint: Rejected — adds latency, a new endpoint, and extra test surface for a display-only suggestion that is immediately replaced on save. +- Calculate only server-side, show suggestion after save: Rejected — contradicts FR-004 (must auto-populate before saving). + +--- + +## Decision 7 — Dashboard Difficulty Derivation Chain + +**Decision**: For dashboard difficulty calculations, follow this priority chain (spec FR-022): + +1. **Stored `Difficulty`** on the ride: use as-is (rider's authoritative rating). +2. **Stored `WindResistanceRating`** on the ride (not null, `Difficulty` is null): map through `resistanceToDifficulty()`. +3. **Neither stored**: derive from raw `WindSpeedMph` + `WindDirectionDeg` + `PrimaryTravelDirection` if all three are present. +4. **Insufficient data**: exclude ride from difficulty aggregations. + +**Rationale**: Minimises recomputation. The vast majority of rides will have `WindResistanceRating` after this feature ships; the raw re-derivation is only needed for rides with direction data but no persisted resistance value (edge case for historical data). The chain ensures no double-counting and no silent zeros. + +**Alternatives considered**: +- Always recompute from raw data: Rejected — ignores the rider's manual difficulty override; contradicts spec FR-019/FR-022. +- Only use stored `Difficulty`: Rejected — many rides will have direction+wind but no manually set difficulty; they would be excluded from analytics. + +--- + +## Decision 8 — Calendar-Month Aggregation for Dashboard + +**Decision**: Group rides by **calendar month number** (1=January … 12=December), averaging across all years. Produce exactly 12 possible groups, one per named month. A month is omitted from the ranking if it has zero qualifying rides. + +**Rationale**: Spec Assumption and Clarification (2026-04-24): "all Januaries averaged together regardless of year." This is also consistent with the existing Advanced Dashboard which already uses calendar-based windows. 12 groups fit neatly in a horizontal bar chart without scrolling. + +**Alternatives considered**: +- Year-month grouping (e.g., "Jan 2025", "Feb 2025"): Rejected per clarification. Produces too many groups for small datasets and contradicts spec. +- Rolling 30-day windows: Rejected per clarification. + +--- + +## Decision 9 — EF Core Migration & Column Constraints + +**Decision**: Single new migration `YYYYMMDD_AddRideDifficultyAndWindRating` with: + +```sql +ALTER TABLE Rides ADD COLUMN Difficulty INTEGER NULL; +ALTER TABLE Rides ADD COLUMN PrimaryTravelDirection TEXT NULL; +ALTER TABLE Rides ADD COLUMN WindResistanceRating INTEGER NULL; +``` + +SQLite `CHECK` constraints for valid ranges are added via `HasCheckConstraint()` in the `OnModelCreating` override. Per existing SQLite compatibility workaround (`SqliteMigrationBootstrapper`), if any generated CHECK constraint SQL is unsupported by the SQLite provider, the migration ID must be added to `UnsupportedConstraintMigrations` so startup skips re-applying it while still marking it applied. + +Column max lengths: `PrimaryTravelDirection` — max 5 chars (`TEXT` in SQLite, `NVARCHAR(5)` in schema snapshot). + +**Rationale**: Three separate nullable columns on `RideEntity` follow the existing pattern. No separate lookup table is needed — both `Difficulty` and `WindResistanceRating` are scalar computed values stored as integers. `PrimaryTravelDirection` is a short string enum value; storing as text (not int) makes the database readable and debuggable without a lookup table. + +**Alternatives considered**: +- Separate `RideDifficulty` table: Rejected — adds a join for every ride read; no separate lifecycle; spec says "persisted on the Ride record." +- Store compass direction as integer (0–7): Rejected — reduces debuggability; the text representation ("North", "NE") is already short. + +--- + +## Decision 10 — F# Module Placement & Naming + +**Decision**: Create `BikeTracking.Domain.FSharp/WindResistance.fs` as a new F# module alongside `AdvancedDashboardCalculations.fs`. Module namespace: `BikeTracking.Domain.FSharp.WindResistance`. Add it before `AdvancedDashboardCalculations.fs` in the `.fsproj` compile order. + +**Rationale**: Follows existing module naming pattern (`module BikeTracking.Domain.FSharp.AdvancedDashboardCalculations`). Keeps wind resistance logic separate from savings calculations. The module exports pure functions only — no `open System.IO`, no EF types, no HTTP clients. + +**Alternatives considered**: +- Adding to `AdvancedDashboardCalculations.fs`: Rejected — different concern; makes the file unwieldy; harder to test in isolation. +- New sub-folder `Rides/WindResistance.fs`: Rejected — existing domain files are all at root level of the F# project; sub-folders not yet established. + +--- + +## Decision 11 — CSV Import Column Validation + +**Decision**: Add `Difficulty` and `Direction` validation to `CsvValidationRules.ValidateRow()` following the existing error-per-row pattern: + +- `Difficulty`: Optional. If present, must parse as integer in [1, 5]. Error code `INVALID_DIFFICULTY`. +- `Direction`: Optional. If present, must be one of the 8 accepted values (case-insensitive on parse, stored as canonical casing). Error code `INVALID_DIRECTION`. +- Both absent: row accepted without error (FR-018). + +**Sample CSV generation**: New `SampleCsvGenerator` class produces an in-memory CSV string with a legend row and one realistic example row. Served via `GET /api/rides/csv-sample` (no auth required beyond login). Content-Disposition header triggers browser download. + +**Rationale**: Matches existing `INVALID_DATE`, `INVALID_MILES` error pattern. Case-insensitive parse (e.g., "north" → "North") reduces import friction without compromising validation. + +**Alternatives considered**: +- Strict case-sensitive matching: Rejected — users editing CSVs in Excel/Numbers frequently produce lowercase values; high friction for negligible benefit. +- Hard-coding sample CSV as embedded resource: Rejected — sample CSV must reflect the current column set; a generator keeps it in sync with code changes. + +--- + +## All Unknowns Resolved + +| Unknown | Resolution | +|---------|-----------| +| Wind direction convention | Meteorological (FROM), matches Open-Meteo stored data | +| Formula validation | Cosine-based confirmed; sign convention verified | +| Degree → compass mapping | 22.5° bin boundaries, 8-point | +| Difficulty lookup table | −3/−4→1, −1/−2→2, 0→3, +1/+2→4, +3/+4→5 | +| Zero-speed edge case | Return (0, 1) before formula; FR-012 rule | +| Auto-suggest architecture | TypeScript mirror for instant UX; F# authoritative at save | +| Dashboard derivation | 3-step chain: stored difficulty → stored rating → raw recompute | +| Monthly aggregation | Calendar month 1–12, all years combined, max 12 groups | +| DB schema | 3 nullable columns on Rides table, 1 migration | +| F# module location | `WindResistance.fs` alongside existing domain files | +| CSV validation | `INVALID_DIFFICULTY` + `INVALID_DIRECTION` error codes | diff --git a/specs/019-ride-difficulty-wind/spec.md b/specs/019-ride-difficulty-wind/spec.md new file mode 100644 index 0000000..2d68cda --- /dev/null +++ b/specs/019-ride-difficulty-wind/spec.md @@ -0,0 +1,175 @@ +# Feature Specification: Ride Difficulty & Wind Resistance Rating + +**Feature Branch**: `019-ride-difficulty-wind` +**Created**: 2026-04-23 +**Status**: Draft + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Rate Ride Difficulty When Recording a Ride (Priority: P1) + +A rider finishes a tough headwind commute and records the ride. They see optional fields for "Difficulty" (1–5 scale) and "Primary Travel Direction." Once they select a direction, the system automatically calculates and fills in a suggested difficulty rating based on the captured wind speed and direction combination. The rider can accept the suggestion or override it with their own rating before saving. + +**Why this priority**: This is the entry point for all difficulty data. Without it, no difficulty information enters the system for current or future analytics. Every other story depends on this data existing. + +**Independent Test**: Can be fully tested by recording a single ride, selecting a travel direction, observing that difficulty auto-fills, overriding the value, and confirming the ride saves with the manually entered difficulty. + +**Acceptance Scenarios**: + +1. **Given** a rider is on the Record Ride page, **When** they view the form, **Then** they see two new optional fields: a "Difficulty" dropdown (values 1–5) and a "Primary Travel Direction" dropdown (accepted inputs: full compass names or 2‑letter abbreviations; normalized internally to `N, NE, E, SE, S, SW, W, NW`). +2. **Given** the Record Ride page is displayed, **When** the rider hovers or taps the info icon next to "Primary Travel Direction," **Then** a tooltip or popover explains: "Your primary direction of travel helps us calculate wind resistance. Since rides can involve multiple directions, choose the direction you rode the most." +3. **Given** a ride already has wind speed captured from weather data, **When** the rider selects a Primary Travel Direction, **Then** the Difficulty field is automatically populated with a calculated value (1–5) based on wind speed and the headwind/tailwind relationship to the selected direction +4. **Given** the Difficulty field has been auto-calculated, **When** the rider changes the value in the Difficulty dropdown, **Then** the updated value is saved (the auto-calculated value is a suggestion only) +5. **Given** the rider does not select a Primary Travel Direction, **When** they save the ride, **Then** the Difficulty field remains empty (or retains any manually entered value) and the ride saves successfully +6. **Given** no wind speed data is available for the ride, **When** the rider selects a Primary Travel Direction, **Then** no auto-calculation is performed and the Difficulty field remains blank unless the rider manually selects a value +7. **Given** the rider leaves both Difficulty and Primary Travel Direction blank, **When** they save the ride, **Then** the ride saves successfully — both fields are optional + +--- + +### User Story 2 - Import Ride Difficulty and Direction via CSV (Priority: P1) + +A rider with historical ride data in a spreadsheet wants to import it including their manually noted difficulty ratings and primary travel directions. The system accepts these as optional columns in the CSV. The import page includes a sample CSV download link so riders know the exact format expected. + +**Why this priority**: P1 because without CSV import support for these fields, bulk historical data will be missing difficulty entirely, which would produce incomplete analytics. Parity with manual entry is important for data consistency. + +**Independent Test**: Can be fully tested by downloading the sample CSV, adding a few rows with `Difficulty` and `PrimaryTravelDirection` (or `Direction` alias) values, importing the file, and confirming those values appear on the imported rides. + +**Acceptance Scenarios**: + +1. **Given** a rider is on the CSV Import page, **When** they view the page, **Then** they see a "Download Sample CSV" link that downloads a properly formatted example file containing all supported columns including `Difficulty` and `PrimaryTravelDirection` (CSV import requires the `PrimaryTravelDirection` header) +2. **Given** the sample CSV is downloaded, **When** the rider opens it, **Then** it contains example rows with realistic data, column headers matching expected import format, and inline comments or a legend row explaining valid values (1–5 for Difficulty; compass direction names for `PrimaryTravelDirection`) +3. **Given** a CSV containing `Difficulty` and `PrimaryTravelDirection` (or `Direction`) columns with valid values, **When** the rider imports it, **Then** each ride record is created with the specified difficulty and primary travel direction values +4. **Given** a CSV containing a `Difficulty` value outside the 1–5 range (e.g., 0, 6, or "hard"), **When** the file is parsed, **Then** the system flags that row with a validation error describing the expected range, and excludes it from the import while allowing other valid rows to proceed +5. **Given** a CSV containing an unrecognized `PrimaryTravelDirection`/`Direction` value (e.g., "Northeast" instead of "NE"), **When** the file is parsed, **Then** the system flags that row with a validation error listing accepted direction values +6. **Given** a CSV where `Difficulty` and `PrimaryTravelDirection`/`Direction` columns are absent entirely, **When** imported, **Then** the import succeeds without error — these columns are optional +7. **Given** a CSV where `Difficulty` is present but `PrimaryTravelDirection`/`Direction` is absent (or vice versa), **When** imported, **Then** the ride is created with whichever field was provided, and the missing field is left blank + +--- + +### User Story 3 - View Difficulty Analytics on the Advanced Dashboard (Priority: P2) + +A rider visits the Advanced Dashboard and sees a new difficulty analytics section. It shows their average difficulty overall, average difficulty by month, a ranking of their most difficult months, and a wind resistance rating chart that visualizes headwind and tailwind impact across their rides. + +**Why this priority**: P2 because it depends on difficulty data existing (Stories 1 and 2). It delivers long-term analytical value but is not needed to start collecting the data. + +**Independent Test**: Can be tested by ensuring several rides have difficulty data (manual or imported), navigating to the Advanced Dashboard, and confirming all four visual elements render with correct values. + +**Acceptance Scenarios**: + +1. **Given** the rider has rides with difficulty ratings, **When** they view the Advanced Dashboard, **Then** they see a "Ride Difficulty" section containing: + - Overall average difficulty (numeric, 1 decimal place) + - Average difficulty broken down by calendar month + - A ranked list of most difficult months (months sorted by average difficulty, descending) +2. **Given** the rider has rides **without** difficulty ratings but **with** wind speed and direction data, **When** the Advanced Dashboard loads, **Then** the system calculates difficulty on-the-fly using the same wind speed + direction formula used during ride entry, and includes those rides in all difficulty metrics +3. **Given** the rider has a mix of rides with stored difficulty ratings and rides without, **When** difficulty metrics are displayed, **Then** stored ratings are used as-is and on-the-fly calculations fill in the gaps — both feed the same aggregations seamlessly +4. **Given** the rider views the Advanced Dashboard, **When** they look at the Wind Resistance Rating visual, **Then** they see a chart showing rides distributed across wind resistance levels: −4, −3, −2, −1 (tailwind assistance, making ride easier) and +1, +2, +3, +4 (headwind resistance, adding difficulty), with negative values clearly labeled as "wind-assisted" +5. **Given** a ride had a strong tailwind, **When** its wind resistance rating is displayed, **Then** it appears in the negative range (e.g., −2 or −3) and is visually distinguished from headwind rides (e.g., different color or bar direction) +6. **Given** the rider has no difficulty data and no wind data at all, **When** they view the Advanced Dashboard, **Then** the difficulty section shows a helpful empty state ("Record rides with travel direction to see difficulty trends") rather than errors or blank charts + +--- + +### Edge Cases + +- What happens when wind speed is 0 (calm conditions)? → Calculated difficulty should default to 1 (no wind resistance) and auto-fill accordingly +- What happens when the rider records a ride in a direction perfectly perpendicular to the wind (crosswind)? → The wind resistance calculation yields a near-zero effect; difficulty is not auto-changed by crosswind unless it exceeds a threshold +- How does the system handle rides where direction was recorded but weather data has no wind speed? → Direction is stored but no difficulty is calculated; the field stays blank +- What if a rider changes their Primary Travel Direction after difficulty was already auto-calculated? → The system recalculates and updates the difficulty suggestion; any prior manual override is cleared and the rider can override again +- What happens to WindResistanceRating when a rider edits a **saved** ride and changes PrimaryTravelDirection? → `WindResistanceRating` is automatically recalculated using the same formula as at initial save time; the updated value is persisted when the edit is saved (see FR-026 and Clarifications 2026-04-24) +- What happens to the stored Difficulty value when a rider edits a **saved** ride and changes PrimaryTravelDirection? → The edit form recalculates and pre-fills Difficulty as a **suggestion** only; the rider may accept or change it before saving; the persisted Difficulty is updated only on explicit save — no silent auto-overwrite occurs (see FR-027 and Clarifications 2026-04-24) +- What if all rides in a month lack both difficulty rating and wind data? → That month is excluded from monthly averages rather than displaying as 0 + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Record Ride Form + +- **FR-001**: The Record Ride form MUST include an optional "Difficulty" dropdown with values 1 (Very Easy), 2 (Easy), 3 (Moderate), 4 (Hard), 5 (Very Hard) +- **FR-002**: The Record Ride form MUST include an optional "Primary Travel Direction" dropdown with canonical internal values: `N, NE, E, SE, S, SW, W, NW`. The form and import endpoints MAY accept either full compass names (e.g., "North", "Northeast") or 2‑letter abbreviations; all inputs MUST be normalized to the canonical 2‑letter abbreviation before persistence or calculation. +- **FR-003**: The "Primary Travel Direction" field MUST display an info icon; activating it MUST show a description explaining that the user should choose the direction they traveled most, and that this value is used to calculate wind resistance against captured wind speed +- **FR-004**: When a rider selects a Primary Travel Direction AND wind speed data is available for the ride, the system MUST automatically calculate and populate the Difficulty field using the wind resistance formula +- **FR-005**: The auto-populated Difficulty value MUST be overridable by the rider at any time before saving +- **FR-006**: If the rider changes the selected Primary Travel Direction, the system MUST recalculate the Difficulty suggestion and update the field (clearing any prior auto-value; manual overrides are also cleared and replaced with the new calculation) +- **FR-007**: Both Difficulty and Primary Travel Direction MUST be optional; the ride MUST save successfully if either or both are blank + +#### Wind Resistance Calculation + +- **FR-008**: The system MUST calculate a wind resistance value using the angle between the rider's primary travel direction and the wind direction, weighted by wind speed in **mph** +- **FR-009**: A headwind (wind directly opposing travel) MUST produce a positive resistance rating (harder); a tailwind (wind directly behind travel) MUST produce a negative resistance rating (easier) +- **FR-010**: The wind resistance rating scale MUST range from −4 (strong tailwind assistance) to +4 (strong headwind resistance), with 0 representing calm or crosswind conditions +- **FR-011**: The Difficulty auto-calculation MUST map the wind resistance rating to the 1–5 difficulty scale: strong tailwind biases toward 1–2, neutral toward 3, strong headwind toward 4–5 + + - **Explicit mapping (authoritative)**: the system MUST use the following mapping from persisted `WindResistanceRating` (−4..+4) to `Difficulty` (1..5) when deriving or persisting suggested difficulty values: + - `WindResistanceRating <= -3` → `Difficulty = 1` (Very Easy) + - `WindResistanceRating = -2 or -1` → `Difficulty = 2` (Easy) + - `WindResistanceRating = 0` → `Difficulty = 3` (Moderate) + - `WindResistanceRating = 1 or 2` → `Difficulty = 4` (Hard) + - `WindResistanceRating >= 3` → `Difficulty = 5` (Very Hard) +- **FR-012**: When wind speed is zero, calculated difficulty MUST default to 1 + +-#### CSV Import + +- **FR-013**: The CSV Import page MUST support two new optional columns: `Difficulty` (integer 1–5) and `Direction` (accepts either full compass names or 2‑letter abbreviations). The import/validator MUST normalize accepted `Direction` inputs to the canonical 2‑letter abbreviations used internally: `N, NE, E, SE, S, SW, W, NW`. +- **FR-014**: The CSV Import page MUST display a "Download Sample CSV" link that triggers a download of an example file +- **FR-015**: The sample CSV MUST include all supported import columns (including `Difficulty` and `Direction`), realistic example data rows, and a clear legend or comment row describing valid values +- **FR-016**: The import validator MUST reject rows with `Difficulty` values outside the integer range 1–5 and display a specific error message +- **FR-017**: The import validator MUST reject rows with unrecognized `Direction` values and display an error listing accepted compass values +- **FR-018**: Rows with missing or blank `Difficulty` and/or `Direction` columns MUST be accepted without error + +#### Advanced Dashboard + +- **FR-019**: The Advanced Dashboard MUST display an overall average difficulty score across all rides that have a difficulty value (stored or calculated) +- **FR-020**: The Advanced Dashboard MUST display average difficulty grouped by **calendar month** (January through December, all years combined; exactly 12 possible month groups — e.g., all Januaries averaged together regardless of year) +- **FR-021**: The Advanced Dashboard MUST display a ranked list of **calendar month groups** ordered by average difficulty (descending), labeled as "Most Difficult Months" — at most 12 entries, one per named month +- **FR-022**: For rides without a stored difficulty rating, the dashboard MUST derive difficulty from the ride's stored `WindResistanceRating` (if present) using the same 1–5 mapping; only if `WindResistanceRating` is also absent should the formula be re-evaluated live from raw wind speed and direction data +- **FR-023**: The Advanced Dashboard MUST display a Wind Resistance Rating visual showing ride counts or frequency across the range −4 to +4 +- **FR-024**: The Wind Resistance Rating visual MUST visually distinguish negative (tailwind/assisted) values from positive (headwind/resistance) values +- **FR-025**: When no difficulty data exists and no wind data can fill the gap, the dashboard difficulty section MUST display a descriptive empty state message + +#### Edit Ride + +- **FR-026**: When a rider edits a saved ride and changes `PrimaryTravelDirection`, the system MUST automatically recalculate `WindResistanceRating` using the same formula applied at initial save time; the recalculated value MUST be persisted when the edit is saved +- **FR-027**: When a rider edits a saved ride and changes `PrimaryTravelDirection`, the system MUST recalculate the Difficulty value and pre-fill the Difficulty field in the edit form as a **suggestion only**; the rider can accept the suggested value or enter a different value before saving; the stored Difficulty MUST NOT be updated until the rider explicitly saves the edit (no silent auto-overwrite) + +### Key Entities + +- **Ride** (existing entity, extended): gains optional `Difficulty` (integer 1–5), `PrimaryTravelDirection` (enum: 8 compass values), and `WindResistanceRating` (integer −4 to +4, nullable) attributes; `WindResistanceRating` is persisted as a column on the Ride record, computed at ride-save time or import time (not recalculated at read time) +- **Wind Resistance Rating**: a value (−4 to +4) calculated from the angle between travel direction and wind direction, scaled by wind speed; **persisted on the Ride record** at write time so the dashboard reads it directly without re-deriving it +- **Difficulty Calculation**: a stateless formula mapping wind speed, wind direction, and travel direction to a 1–5 difficulty value and a −4 to +4 resistance rating + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Riders can record a ride with difficulty and travel direction in under 30 additional seconds compared to recording without those fields +- **SC-002**: When travel direction is selected and wind data is present, the difficulty field auto-fills within 1 second of selection +- **SC-003**: The sample CSV download is available immediately (no authentication required beyond being logged in) and opens correctly in common spreadsheet applications +- **SC-004**: CSV imports containing Difficulty and Direction columns complete with the same reliability and progress behavior as imports without those columns +- **SC-005**: The Advanced Dashboard difficulty section loads within the same time budget as existing dashboard widgets (no perceivable additional delay) +- **SC-006**: All difficulty analytics (overall average, monthly breakdown, most difficult months, wind resistance chart) are correct to within ±0.1 of a recalculation performed independently against the same ride data +- **SC-007**: Rides lacking stored difficulty but having wind and direction data are included in dashboard metrics — riders see no unexplained gaps in their analytics + +## Assumptions + +- Wind speed is already captured as part of the weather data during ride recording (per the existing ride-weather-data feature, spec 011) +- Wind direction is available as a compass bearing or named direction in the weather data; if the weather API returns a bearing in degrees, the system can map it to the nearest 8-point compass direction +- The existing Advanced Dashboard (spec 018) is deployed and accessible; the difficulty section is additive +- The CSV import infrastructure (spec 013) is in place; this feature extends the column set without rebuilding the import pipeline +- "Most difficult months" means **calendar months (January through December)** aggregated across all years (all Januaries together, all Februaries together, etc.); exactly 12 possible month groups with no year-level breakdown (see Clarifications 2026-04-24) +- Wind resistance rating bins (−4 to +4) are calculated using a cosine-based formula: `resistance = round(windSpeed × cos(angleBetweenDirections) / threshold)`, clamped to [−4, +4]; **threshold constant = 5 mph** (i.e., 20 mph direct headwind maps to +4); wind speed is stored and displayed in **mph** throughout the system (see Clarifications 2026-04-24) +- `WindResistanceRating` is persisted as a column on the Ride record (not computed on read); see Clarifications for the decision rationale + +## Clarifications + +### Session 2026-04-24 + +- Q: Should the Wind Resistance Rating (−4 to +4) be persisted on the Ride record, or always computed on-the-fly? → A: Persist it. Store `WindResistanceRating` as a column on the Ride record, calculated at ride-save time (or import time); dashboard reads it directly. + - Q: What wind speed should map to a +4 (maximum headwind) rating? → A: 20 mph direct headwind = +4; threshold constant = 5 mph. Wind speed is stored and displayed in mph. + - Q: How is a `WindResistanceRating` translated to `Difficulty` for persistence and analytics? → A: Use the explicit mapping table in FR-011 (≤ −3 → 1, −2/−1 → 2, 0 → 3, +1/+2 → 4, ≥ +3 → 5). This mapping is the authoritative source for dashboard aggregates and tests. +- Q: How should "most difficult months" be aggregated on the dashboard? → A: Calendar month roll-up: all Januaries averaged together, all Februaries averaged together; exactly 12 possible rows/bars (no year-level breakdown). +- Q: What happens to WindResistanceRating when a rider edits a saved ride and changes PrimaryTravelDirection? → A: Recalculate `WindResistanceRating` automatically whenever `PrimaryTravelDirection` is changed on an edit; same formula as initial save; recalculated value is persisted when the edit is saved. +- Q: What happens to the stored Difficulty value when a rider edits a saved ride and changes PrimaryTravelDirection? → A: Suggest only — recalculate and pre-fill the Difficulty field in the edit form as a new suggestion; rider can accept or change before saving; stored Difficulty updates only on save (no silent auto-overwrite). + +### Session 2026-04-27 + +- Q: Should CSV and form inputs accept 2‑letter abbreviations, full compass names, or only one format for `Direction` values? → A: Accept both abbreviations and full names on input; normalize to canonical 2‑letter abbreviations internally (N, NE, E, SE, S, SW, W, NW). diff --git a/specs/019-ride-difficulty-wind/tasks.md b/specs/019-ride-difficulty-wind/tasks.md new file mode 100644 index 0000000..e3f3dbd --- /dev/null +++ b/specs/019-ride-difficulty-wind/tasks.md @@ -0,0 +1,330 @@ +# Tasks: Ride Difficulty & Wind Resistance Rating + +**Feature**: `019-ride-difficulty-wind` +**Branch**: `019-ride-difficulty-wind` +**Spec**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md) +**Generated**: 2026-04-24 +**Design inputs**: spec.md (27 FRs, 3 user stories), plan.md, research.md, data-model.md, contracts/rides-api.md, contracts/dashboard-api.md, contracts/csv-import-format.md, quickstart.md + +## Format: `[ID] [P?] [Story?] Description — file path` + +- **[P]**: Parallelisable — touches different files, no blocking in-flight dependency +- **[US1/US2/US3]**: User Story label (maps to spec.md priorities P1/P1/P2) +- Tests follow **red → green** TDD gate: write failing test, confirm failure, implement, confirm green + +--- + +## Phase 1: Setup + +**Purpose**: Confirm baseline is clean before any changes land. One pre-flight task; all remaining phases build on it. + +- [x] T001 Verify feature branch `019-ride-difficulty-wind` is checked out, `dotnet build` compiles the full solution with zero warnings, `dotnet test` passes all existing tests, and `npm install` is current in `src/BikeTracking.Frontend/` + +--- + +## Phase 2: Foundational — F# Domain Module + Database Migration + +**Purpose**: The wind resistance formula and the three new DB columns are blocking prerequisites for every user story. No service, import, or dashboard work can proceed until both are in place. + +**⚠️ CRITICAL**: No user story phase may begin until this phase is complete. + +### F# Domain Module — TDD Cycle + +> **RED first**: Write the tests so they compile but fail before the module exists. + +- [x] T002 [P] Write failing unit tests (RED) covering all WindResistance functions — `degreesToCompass` boundary cases (0°, 22°, 23°, 45°, 337°, 360°), `calculateResistance` (20 mph headwind → +4; 20 mph tailwind → −4; crosswind → 0; clamp at ±4; negative speed → `Error`), `calculateDifficulty` (null/zero wind → `(0, 1)`; full headwind → `(4, 5)`; full tailwind → `(−4, 1)`), and `resistanceToDifficulty` for all nine inputs −4 to +4 — in `src/BikeTracking.Api.Tests/Application/Rides/WindResistanceCalculationTests.cs` +- [x] T003 Create `src/BikeTracking.Domain.FSharp/WindResistance.fs` with module `BikeTracking.Domain.FSharp.WindResistance` — types `CompassDirection` (8 values) and `WindResistanceError`; pure functions `degreesToCompass`, `compassToDegrees`, `shorterArc`, `calculateResistance`, `resistanceToDifficulty`, `calculateDifficulty`, `tryParseCompassDirection`, and `validDirectionNames` exactly as specified in `data-model.md §2.1` +- [x] T004 Add `` to `src/BikeTracking.Domain.FSharp/BikeTracking.Domain.FSharp.fsproj` in the `` immediately before `AdvancedDashboardCalculations.fs` per `data-model.md §2.2` +- [x] T005 Verify `WindResistanceCalculationTests` are GREEN — run `dotnet test --filter "WindResistanceCalculation"` and confirm all boundary cases pass including the 20 mph direct headwind → +4 spec target and the zero-speed FR-012 rule + +### Database Migration — TDD Cycle + +> **RED first**: Write persistence tests against the new columns before the migration exists. + +- [x] T006 [P] Add three nullable properties to `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs`: `public int? Difficulty { get; set; }`, `public string? PrimaryTravelDirection { get; set; }`, `public int? WindResistanceRating { get; set; }` per `data-model.md §1.1` +- [x] T007 [P] Add EF Core model configuration to `OnModelCreating` in `src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs`: `HasMaxLength(5)` for `PrimaryTravelDirection`; `HasCheckConstraint("CK_Rides_Difficulty", "Difficulty IS NULL OR (Difficulty >= 1 AND Difficulty <= 5)")` and `HasCheckConstraint("CK_Rides_WindResistanceRating", "WindResistanceRating IS NULL OR (WindResistanceRating >= -4 AND WindResistanceRating <= 4)")` on the Rides entity per `data-model.md §1.1` +- [x] T008 [P] Write failing persistence tests (RED) for the three new columns — save a `RideEntity` with each field set; read it back; assert round-trip fidelity; assert `PrimaryTravelDirection` max-length enforcement — in `src/BikeTracking.Api.Tests/Infrastructure/RidesPersistenceTests.cs` +- [x] T009 Generate EF Core migration via `dotnet ef migrations add AddRideDifficultyAndWindRating --project src/BikeTracking.Api --output-dir Infrastructure/Persistence/Migrations` from the repository root; verify the generated `.cs` file contains three `AddColumn` calls for `Difficulty`, `PrimaryTravelDirection`, and `WindResistanceRating` per `data-model.md §1.2` +- [x] T010 Inspect the migration SQL for CHECK constraint compatibility; if the SQLite EF provider generates unsupported syntax (compare with existing entries), add the new migration ID to `UnsupportedConstraintMigrations` in `src/BikeTracking.Api/Infrastructure/Persistence/SqliteMigrationBootstrapper.cs` +- [x] T011 Verify `RidesPersistenceTests` are GREEN — run `dotnet test --filter "RidesPersistence"` after the migration is applied; confirm all three columns persist and round-trip correctly + +**Checkpoint — Foundation ready**: WindResistance.fs module tested and green; three new DB columns migrated and tested. User story phases may now begin. + +--- + +## Phase 3: User Story 1 — Record Ride Difficulty Fields (Priority: P1) 🎯 MVP + +**Goal**: Riders can record a ride with an optional Difficulty (1–5) and Primary Travel Direction (8-point compass). When direction and wind data are both present, the Difficulty field auto-fills with a suggestion. The suggestion is overridable. The server persists `WindResistanceRating` computed via the F# formula. Edit Ride recomputes `WindResistanceRating` on direction change and pre-fills Difficulty as a suggestion only (FR-027). + +**Independent Test**: Check out the feature branch, start the app, record a ride in a location with wind data — select a travel direction and verify Difficulty auto-fills within 1 second, change the direction and verify it recalculates, override the value, save, and confirm the ride appears in history with the manually entered Difficulty and a non-null `WindResistanceRating`. Then edit the same ride, change direction, and confirm `WindResistanceRating` updates on save. + +### Tests for User Story 1 ⚠️ Write RED first — all must fail before implementation + +- [x] T012 [P] [US1] Write failing integration tests (RED) for RecordRide with difficulty fields — record with direction + wind data → `WindResistanceRating` computed and persisted; record without direction → `WindResistanceRating` null; record with zero wind speed → `WindResistanceRating = 0`; invalid `Difficulty` value (6) → 400; invalid direction string → 400 with accepted-values message — in `src/BikeTracking.Api.Tests/Endpoints/Rides/RecordRideWithDifficultyTests.cs` +- [x] T013 [P] [US1] Write failing integration tests (RED) for EditRide with direction-change behaviour — direction unchanged → `WindResistanceRating` unchanged; direction changed → `WindResistanceRating` recomputed and stored; direction cleared (null) → `WindResistanceRating` set to null; `Difficulty` in request stored as-is with no server-side override (FR-027) — in `src/BikeTracking.Api.Tests/Application/Rides/EditRideWithDifficultyTests.cs` + +### Implementation for User Story 1 — Contracts & Events + +- [x] T014 [P] [US1] Extend `src/BikeTracking.Api/Contracts/RidesContracts.cs` — add `[Range(1,5)] int? Difficulty = null` and `[MaxLength(5)] string? PrimaryTravelDirection = null` to `RecordRideRequest` and `EditRideRequest`; add `int? Difficulty`, `string? PrimaryTravelDirection`, `int? WindResistanceRating` to `RideHistoryRow` per `contracts/rides-api.md §1, §3, §4` +- [x] T015 [P] [US1] Extend `src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs` and `src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs` — add `int? Difficulty`, `string? PrimaryTravelDirection`, `int? WindResistanceRating` fields and update the `Create` factory methods per `contracts/rides-api.md §7` + +### Implementation for User Story 1 — Service Layer + +- [x] T016 [US1] Extend `src/BikeTracking.Api/Application/Rides/RecordRideService.cs` — after weather merge, call `WindResistance.tryParseCompassDirection` on `request.PrimaryTravelDirection`; if valid and `WindSpeedMph` + `WindDirectionDeg` are present, call `WindResistance.calculateDifficulty` via F# interop and persist the rating; assign `Difficulty`, `PrimaryTravelDirection`, and computed `WindResistanceRating` to the new `RideEntity` columns; propagate all three to `RideRecordedEventPayload` per `quickstart.md Step 3` +- [x] T017 [US1] Extend `src/BikeTracking.Api/Application/Rides/EditRideService.cs` — compare incoming `PrimaryTravelDirection` against the stored value; if changed, recompute `WindResistanceRating` using current `WindSpeedMph` and `WindDirectionDeg`; if direction cleared, set `WindResistanceRating = null`; always store `request.Difficulty` as the rider's final choice without server-side override; propagate all three fields in `RideEditedEventPayload` per `quickstart.md Step 4` and FR-026/FR-027 + +### Verify User Story 1 Backend — GREEN + +- [x] T018 [P] [US1] Verify `RecordRideWithDifficultyTests` are GREEN — run `dotnet test --filter "RecordRideWithDifficulty"` and confirm all scenarios pass +- [x] T019 [P] [US1] Verify `EditRideWithDifficultyTests` are GREEN — run `dotnet test --filter "EditRideWithDifficulty"` and confirm direction-change, direction-clear, and difficulty-as-rider-choice scenarios all pass + +### Implementation for User Story 1 — Frontend + +- [x] T020 [P] [US1] Create `src/BikeTracking.Frontend/src/utils/windResistance.ts` — implement `calculateWindResistance`, `resistanceToDifficulty`, and `suggestDifficulty` as the TypeScript formula mirror; export `COMPASS_DEGREES` lookup; match the F# logic exactly (shorter-arc, cosine, ÷5, clamp ±4, FR-012 zero-speed rule) per `data-model.md §6.2` +- [x] T021 [P] [US1] Extend `src/BikeTracking.Frontend/src/services/ridesService.ts` — add `CompassDirection` string literal union type, `COMPASS_DIRECTIONS` constant array, and optional `difficulty?: number`, `primaryTravelDirection?: CompassDirection`, `windResistanceRating?: number` fields to `RecordRideRequest`, `EditRideRequest`, and `RideHistoryRow` interfaces per `data-model.md §6.1` +- [x] T022 [US1] Update `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx` — add `primaryTravelDirection` and `difficulty` state variables; add `isDifficultyAutoFilled` boolean state; add `useEffect` that calls `suggestDifficulty` whenever direction or wind data changes and sets the difficulty field + `isDifficultyAutoFilled = true`; add `` for Difficulty (options 1 Very Easy … 5 Very Hard, empty default) showing "(suggested)" label when `isDifficultyAutoFilled`; clear auto-filled difficulty when direction is cleared; set `isDifficultyAutoFilled = false` on manual Difficulty change; include both fields in submit payload; add CSS classes to `RecordRidePage.css` (no inline styles) per `quickstart.md Step 7` +- [x] T023 [US1] Update the Edit Ride form — add direction and difficulty ` + onEditedPrimaryTravelDirectionChange(event.target.value as CompassDirection | '') + } + > + + {COMPASS_DIRECTIONS.map((dir) => ( + + ))} + + + ) : ( + ride.primaryTravelDirection ?? '—' + )} + + + {editingRideId === ride.rideId ? ( +
+ + +
+ ) : ( + ride.difficulty != null ? ride.difficulty.toString() : '—' + )} + + + {ride.windResistanceRating != null ? ( + + {ride.windResistanceRating > 0 ? `+${ride.windResistanceRating}` : ride.windResistanceRating.toString()} + + ) : ( + '—' + )} + {editingRideId === ride.rideId ? (
@@ -311,6 +376,9 @@ export function HistoryPage() { const [editedGasPriceSource, setEditedGasPriceSource] = useState('') const [loadingWeather, setLoadingWeather] = useState(false) const [ridePendingDelete, setRidePendingDelete] = useState(null) + const [editedDifficulty, setEditedDifficulty] = useState('') + const [editedPrimaryTravelDirection, setEditedPrimaryTravelDirection] = useState('') + const [difficultyAutoSuggested, setDifficultyAutoSuggested] = useState(false) function applyLoadedWeather(weather: { temperature?: number @@ -390,6 +458,9 @@ export function HistoryPage() { setEditedGasPrice( ride.gasPricePerGallon != null ? ride.gasPricePerGallon.toFixed(4) : '' ) + setEditedDifficulty(ride.difficulty != null ? ride.difficulty.toString() : '') + setEditedPrimaryTravelDirection((ride.primaryTravelDirection as CompassDirection | undefined) ?? '') + setDifficultyAutoSuggested(false) } function handleCancelEdit(): void { @@ -461,6 +532,20 @@ export function HistoryPage() { return () => clearTimeout(timerId) }, [editingRideId, editedRideDateTimeLocal]) + useEffect(() => { + if (editingRideId === null || !editedPrimaryTravelDirection) return + const suggestion = suggestDifficulty( + editedWindSpeedMph ? parseFloat(editedWindSpeedMph) : undefined, + editedPrimaryTravelDirection, + editedWindDirectionDeg ? parseInt(editedWindDirectionDeg) : undefined + ) + if (suggestion !== null && !difficultyAutoSuggested) { + setEditedDifficulty(suggestion.toString()) + setDifficultyAutoSuggested(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editedPrimaryTravelDirection]) // Only trigger on direction change, not every wind change + async function handleSaveEdit(ride: RideHistoryRow): Promise { const milesValue = Number(editedMiles) if (!Number.isFinite(milesValue) || milesValue <= 0) { @@ -519,6 +604,8 @@ export function HistoryPage() { precipitationType: precipitationTypeValue, note: editedNote, weatherUserOverridden: weatherEditedManually, + difficulty: editedDifficulty ? parseInt(editedDifficulty) : undefined, + primaryTravelDirection: editedPrimaryTravelDirection || undefined, // Version tokens are added to history rows in later tasks; use baseline v1 for now. expectedVersion: 1, }) @@ -548,6 +635,9 @@ export function HistoryPage() { setWeatherEditedManually(false) setEditedGasPrice('') setEditedGasPriceSource('') + setEditedDifficulty('') + setEditedPrimaryTravelDirection('') + setDifficultyAutoSuggested(false) await loadHistory({ from: fromDate || undefined, @@ -723,6 +813,15 @@ export function HistoryPage() { setEditedGasPrice(value) setEditedGasPriceSource('') }} + editedDifficulty={editedDifficulty} + editedPrimaryTravelDirection={editedPrimaryTravelDirection} + onEditedDifficultyChange={(value) => { + setEditedDifficulty(value) + setDifficultyAutoSuggested(false) + }} + onEditedPrimaryTravelDirectionChange={(value) => { + setEditedPrimaryTravelDirection(value) + }} onLoadWeather={() => void handleLoadWeather()} onSaveEdit={(ride) => void handleSaveEdit(ride)} onCancelEdit={handleCancelEdit} diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx index 531ad07..6f9b0d3 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx @@ -10,6 +10,11 @@ vi.mock('../services/ridesService', () => ({ getRideWeather: vi.fn(), getQuickRideOptions: vi.fn(), recordRide: vi.fn(), + COMPASS_DIRECTIONS: ['North', 'NE', 'East', 'SE', 'South', 'SW', 'West', 'NW'], +})) + +vi.mock('../utils/windResistance', () => ({ + suggestDifficulty: vi.fn().mockReturnValue(null), })) import * as ridesService from '../services/ridesService' diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx index ab9a0c9..37400de 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx @@ -1,12 +1,14 @@ import { useEffect, useState } from 'react' -import type { QuickRideOption, RecordRideRequest } from '../services/ridesService' +import type { CompassDirection, QuickRideOption, RecordRideRequest } from '../services/ridesService' import { getGasPrice, getRideWeather, getQuickRideOptions, recordRide, getRideDefaults, + COMPASS_DIRECTIONS, } from '../services/ridesService' +import { suggestDifficulty } from '../utils/windResistance' const EIA_GAS_PRICE_SOURCE = 'Source: U.S. Energy Information Administration (EIA)' @@ -26,6 +28,10 @@ export function RecordRidePage() { const [gasPriceSource, setGasPriceSource] = useState('') const [quickRideOptions, setQuickRideOptions] = useState([]) + const [primaryTravelDirection, setPrimaryTravelDirection] = useState('') + const [difficulty, setDifficulty] = useState('') + const [difficultyAutoSuggested, setDifficultyAutoSuggested] = useState(false) + const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) const [loadingWeather, setLoadingWeather] = useState(false) @@ -173,6 +179,19 @@ export function RecordRidePage() { return () => clearTimeout(timerId) }, [rideDateTimeLocal]) + useEffect(() => { + if (!primaryTravelDirection) return + const suggestion = suggestDifficulty( + windSpeedMph ? parseFloat(windSpeedMph) : undefined, + primaryTravelDirection, + windDirectionDeg ? parseInt(windDirectionDeg) : undefined + ) + if (suggestion !== null) { + setDifficulty(suggestion.toString()) + setDifficultyAutoSuggested(true) + } + }, [primaryTravelDirection, windSpeedMph, windDirectionDeg]) + const applyQuickRideOption = (option: QuickRideOption) => { setMiles(option.miles.toString()) setRideMinutes(option.rideMinutes.toString()) @@ -205,6 +224,14 @@ export function RecordRidePage() { return } + if (difficulty) { + const diffNum = parseInt(difficulty) + if (diffNum < 1 || diffNum > 5) { + setErrorMessage('Difficulty must be between 1 and 5') + return + } + } + if (gasPrice) { const gasPriceNum = parseFloat(gasPrice) if (Number.isNaN(gasPriceNum) || gasPriceNum < 0.01 || gasPriceNum > 999.9999) { @@ -230,6 +257,8 @@ export function RecordRidePage() { note: note.length > 0 ? note : undefined, weatherUserOverridden: weatherEdited, gasPricePerGallon: gasPrice ? parseFloat(gasPrice) : undefined, + difficulty: difficulty ? parseInt(difficulty) : undefined, + primaryTravelDirection: primaryTravelDirection || undefined, } const response = await recordRide(request) @@ -251,6 +280,9 @@ export function RecordRidePage() { setWeatherEdited(false) setGasPrice('') setGasPriceSource('') + setPrimaryTravelDirection('') + setDifficulty('') + setDifficultyAutoSuggested(false) setSuccessMessage('') }, 3000) } catch (error) { @@ -425,6 +457,55 @@ export function RecordRidePage() { />
+
+
+ + + ℹ️ + +
+ +
+ +
+ + +
+