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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/016-csv-expense-import"
"feature_directory": "specs/019-ride-difficulty-wind"
}
36 changes: 36 additions & 0 deletions specs/019-ride-difficulty-wind/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -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.
203 changes: 203 additions & 0 deletions specs/019-ride-difficulty-wind/contracts/csv-import-format.md
Original file line number Diff line number Diff line change
@@ -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<CompassDirection> { 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.` |
Loading
Loading