diff --git a/.copilot/skills/caveman/SKILLS.MD b/.copilot/skills/caveman/SKILLS.MD new file mode 100644 index 0000000..9949fdb --- /dev/null +++ b/.copilot/skills/caveman/SKILLS.MD @@ -0,0 +1,67 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, + wenyan-lite, wenyan-full, wenyan-ultra. + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". + +Default: **full**. Switch: `/caveman lite|full|ultra`. + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +## Intensity + +| Level | What change | +|-------|------------| +| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | +| **full** | Drop articles, fragments OK, short synonyms. Classic caveman | +| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough | +| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | +| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | +| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | + +Example — "Why React component re-render?" +- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." +- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." +- ultra: "Inline obj prop → new ref → re-render. `useMemo`." +- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" +- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。" +- wenyan-ultra: "新參照→重繪。useMemo Wrap。" + +Example — "Explain database connection pooling." +- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." +- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." +- ultra: "Pool = reuse DB conn. Skip handshake → fast under load." +- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。" +- wenyan-ultra: "池reuse conn。skip handshake → fast。" + +## Auto-Clarity + +Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done. + +Example — destructive op: +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> ```sql +> DROP TABLE users; +> ``` +> Caveman resume. Verify backup exist first. + +## Boundaries + +Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end. \ No newline at end of file diff --git a/.specify/feature.json b/.specify/feature.json index 506af4e..734ec3c 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/019-ride-difficulty-wind" + "feature_directory": "specs/020-improve-ride-preset-options" } diff --git a/specs/020-improve-ride-preset-options/checklists/requirements.md b/specs/020-improve-ride-preset-options/checklists/requirements.md new file mode 100644 index 0000000..f1bd34b --- /dev/null +++ b/specs/020-improve-ride-preset-options/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Improve Ride Entry Preset Options + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-29 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass 1: all checklist items satisfied. +- This specification explicitly supersedes previous ride-entry autofill behavior from earlier quick-entry spec. +- Clarified decisions (2026-04-29): preset stores exact start time; ride-entry preset list ordered by most recently used; legacy quick-entry UI deleted for all users. diff --git a/specs/020-improve-ride-preset-options/contracts/api-contracts.md b/specs/020-improve-ride-preset-options/contracts/api-contracts.md new file mode 100644 index 0000000..379bad7 --- /dev/null +++ b/specs/020-improve-ride-preset-options/contracts/api-contracts.md @@ -0,0 +1,143 @@ +# API Contracts: Ride Preset Options + +**Feature**: 020-improve-ride-preset-options +**Date**: 2026-04-29 + +## Contract Summary + +This feature introduces rider-scoped ride preset CRUD and removes legacy quick-options API usage. + +- Added: preset CRUD endpoints +- Added: `selectedPresetId` in ride create request +- Removed: legacy quick-options endpoint from ride-entry behavior + +## 1) GET /api/rides/presets + +Returns rider-owned presets in MRU order. + +Ordering: +1. `lastUsedAtUtc` descending (`null` values last) +2. `updatedAtUtc` descending + +Response `200`: + +```json +{ + "presets": [ + { + "presetId": 42, + "name": "Morning Commute", + "primaryDirection": "SW", + "periodTag": "morning", + "exactStartTimeLocal": "07:45", + "durationMinutes": 34, + "miles": 7.2, + "lastUsedAtUtc": "2026-04-29T13:02:31Z", + "updatedAtUtc": "2026-04-29T12:50:00Z" + } + ], + "generatedAtUtc": "2026-04-29T13:05:00Z" +} +``` + +## 2) POST /api/rides/presets + +Creates a new rider-owned preset. + +Request: + +```json +{ + "name": "Afternoon Return", + "primaryDirection": "NE", + "periodTag": "afternoon", + "exactStartTimeLocal": "17:35", + "durationMinutes": 32, + "miles": 7.2 +} +``` + +Response `201`: + +```json +{ + "presetId": 43, + "name": "Afternoon Return", + "primaryDirection": "NE", + "periodTag": "afternoon", + "exactStartTimeLocal": "17:35", + "durationMinutes": 32, + "miles": 7.2, + "lastUsedAtUtc": null, + "updatedAtUtc": "2026-04-29T13:10:00Z" +} +``` + +Validation errors `400` include: +- duplicate preset name for rider +- invalid `exactStartTimeLocal` +- invalid `durationMinutes` + +## 3) PUT /api/rides/presets/{presetId} + +Updates an existing rider-owned preset. + +Request body shape matches POST. + +Response `200`: updated preset DTO. + +Error `404`: preset not found for rider. + +## 4) DELETE /api/rides/presets/{presetId} + +Deletes rider-owned preset. + +Response `200`: + +```json +{ + "presetId": 43, + "deletedAtUtc": "2026-04-29T13:20:00Z", + "message": "Preset deleted" +} +``` + +## 5) POST /api/rides (extended) + +Existing record-ride request is extended with optional `selectedPresetId`. + +Request addition: + +```json +{ + "selectedPresetId": 42 +} +``` + +Behavior: +- If `selectedPresetId` is present and belongs to rider, backend updates that preset's `lastUsedAtUtc` after successful ride save. +- If omitted, ride save behavior remains unchanged. + +## 6) Removed Legacy Contract + +Legacy behavior to remove: +- `GET /api/rides/quick-options` +- frontend quick-option fetch/apply flow from ride-entry page + +This removal applies to all riders without a feature-flag fallback. + +## Security and Isolation + +- All preset endpoints require authentication. +- Presets are rider-scoped only. +- Cross-rider access attempts return `404` or `403` depending on endpoint policy. + +## Frontend Type Notes + +Planned TypeScript additions in ride service layer: +- `RidePreset` (now includes required `miles: number`) +- `RidePresetsResponse` +- `UpsertRidePresetRequest` (now includes required `miles: number`) + +Planned request extension: +- `RecordRideRequest.selectedPresetId?: number` diff --git a/specs/020-improve-ride-preset-options/data-model.md b/specs/020-improve-ride-preset-options/data-model.md new file mode 100644 index 0000000..47b94b9 --- /dev/null +++ b/specs/020-improve-ride-preset-options/data-model.md @@ -0,0 +1,127 @@ +# Data Model: Improve Ride Preset Options + +**Feature**: 020-improve-ride-preset-options +**Date**: 2026-04-29 +**Status**: Phase 1 design complete + +--- + +## Overview + +This feature introduces explicit rider-managed presets and removes history-derived quick-entry behavior. + +Primary model changes: +1. Add persisted rider-owned `RidePreset` entity. +2. Add optional preset usage linkage on ride save request (`selectedPresetId`) to maintain MRU ordering. +3. Remove legacy quick-option projection/use from ride-entry UI and endpoint surface. + +--- + +## New Entity: RidePreset + +Planned file area: `src/BikeTracking.Api/Infrastructure/Persistence/Entities/` + +| Field | Type | Required | Validation | Notes | +|-------|------|----------|------------|-------| +| `RidePresetId` | `long` | Yes | PK | Identity key | +| `RiderId` | `long` | Yes | `> 0` | Owner scope; FK-like relationship to rider | +| `Name` | `string` | Yes | 1..80 chars, unique per rider | Display label in settings + ride entry | +| `PrimaryDirection` | `string` | Yes | compass enum used by existing rides contracts | Default direction to apply | +| `PeriodTag` | `string` | Yes | `Morning` or `Afternoon` | Drives default direction suggestion only | +| `ExactStartTimeLocal` | `TimeOnly` | Yes | valid local time | Exact time persisted in preset | +| `DurationMinutes` | `int` | Yes | `> 0` and within existing ride-minute constraints | Default duration | +| `Miles` | `decimal` | Yes | `> 0`, max 999.99 | Default miles to apply; required | +| `LastUsedAtUtc` | `DateTime?` | No | UTC | MRU sort key; updated only on successful ride save using preset | +| `CreatedAtUtc` | `DateTime` | Yes | UTC | Audit | +| `UpdatedAtUtc` | `DateTime` | Yes | UTC | Audit | +| `Version` | `int` | Yes | optimistic concurrency | For update/delete conflict handling | + +### Constraints + +- Unique index: `(RiderId, Name)`. +- Index for ride-entry ordering: `(RiderId, LastUsedAtUtc DESC, UpdatedAtUtc DESC)`. +- `PeriodTag` limited to known values (`Morning`, `Afternoon`). + +--- + +## API Contract Models + +### RidePresetDto + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `presetId` | number | Yes | server identifier | +| `name` | string | Yes | unique per rider | +| `primaryDirection` | CompassDirection | Yes | reusable from existing ride direction type | +| `periodTag` | `morning` \| `afternoon` | Yes | lowercase in JSON | +| `exactStartTimeLocal` | string (`HH:mm`) | Yes | exact preset time | +| `durationMinutes` | number | Yes | integer minutes | +| `miles` | number | Yes | positive decimal, required | +| `lastUsedAtUtc` | string \| null | No | MRU sort metadata | +| `updatedAtUtc` | string | Yes | secondary sort/tie-breaker metadata | + +### UpsertRidePresetRequest + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `name` | string | Yes | 1..80, unique per rider | +| `primaryDirection` | CompassDirection | Yes | must be valid enum | +| `periodTag` | `morning` \| `afternoon` | Yes | required | +| `exactStartTimeLocal` | string (`HH:mm`) | Yes | parseable exact time | +| `durationMinutes` | number | Yes | positive integer | +| `miles` | number | Yes | positive decimal, required | + +### RecordRideRequest (extension) + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `selectedPresetId` | number | No | if present and rider-owned, backend updates `LastUsedAtUtc` after successful ride save | + +--- + +## Relationships + +- One rider has many `RidePreset` records. +- One ride save may reference zero or one preset via `selectedPresetId`. +- Presets are never shared across riders (strict rider scope). + +--- + +## State Transitions + +### Preset CRUD + +1. Rider opens settings and fetches presets. +2. Rider creates or updates preset. +3. Backend validates unique name per rider and stores exact time/duration/direction. +4. Rider may delete preset; deletion removes it from future ride-entry choices only. + +### Preset Use in Ride Entry (MRU) + +1. Ride entry loads presets ordered by `LastUsedAtUtc DESC`, then `UpdatedAtUtc DESC`. +2. Rider selects preset; client populates direction, exact start time (time component), duration. +3. Rider can edit values manually before submit. +4. On successful `POST /api/rides` with `selectedPresetId`, backend updates that preset `LastUsedAtUtc = now`. + +--- + +## Validation Rules + +- Preset name must be unique per rider. +- `durationMinutes` must remain within existing ride validation limits. +- `exactStartTimeLocal` is mandatory and must be exact (`HH:mm`) not free-text. +- `miles` is required, must be a positive decimal (e.g., >0, max 999.99). +- Default direction suggestions: + - morning -> SW + - afternoon -> NE +- Suggestions are non-binding; persisted direction is always rider-selected value. + +--- + +## Legacy Quick-Entry Decommission + +The following behavior is removed for all riders: +- Backend `GetQuickRideOptionsService` endpoint usage path. +- Frontend "Quick Ride Options" section and associated fetch/action flow. + +Historical rides remain unchanged and continue to support analytics/reporting. diff --git a/specs/020-improve-ride-preset-options/plan.md b/specs/020-improve-ride-preset-options/plan.md new file mode 100644 index 0000000..4e76ddf --- /dev/null +++ b/specs/020-improve-ride-preset-options/plan.md @@ -0,0 +1,137 @@ +# Implementation Plan: Improve Ride Entry Preset Options + +**Branch**: `020-improve-ride-preset-options` | **Date**: 2026-04-29 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/020-improve-ride-preset-options/spec.md` + +## Summary + +Replace legacy history-based quick-entry behavior with rider-managed presets configured in settings and applied in ride entry. Presets persist rider-defined name, primary direction, period tag, exact start time, and duration. Ride-entry preset options are ordered by most-recently-used, where recency updates only after a successful ride save using that preset. Legacy quick-entry UI and backend behavior are removed for all riders. + +## Technical Context + +**Language/Version**: C# (.NET 10 Minimal API), TypeScript (React 19 + Vite), F# domain project present but not primary for this slice +**Primary Dependencies**: ASP.NET Core Minimal APIs, EF Core (SQLite), existing rider auth/session flow, React Router v7, existing settings and rides services +**Storage**: SQLite local file via EF Core; new `RidePresets` table (now includes required `Miles` field) and additive ride request field for preset usage tracking +**Testing**: xUnit (`BikeTracking.Api.Tests`), Vitest (`src/BikeTracking.Frontend`), Playwright E2E +**Target Platform**: Local-first Aspire web app in DevContainer (Linux/macOS/Windows user-machine deployment profile) +**Project Type**: Web application (React frontend + .NET Minimal API backend + SQLite) +**Performance Goals**: Preset list fetch remains within constitutional API target (<500 ms p95); preset apply in UI updates fields immediately; MRU update is part of normal ride save path +**Constraints**: Rider isolation required; preset names unique per rider; exact start time must be persisted; remove legacy quick-entry UI for all riders without fallback path; preserve historical rides/analytics +**Scale/Scope**: Single-rider local deployment profile; expected preset count per rider is small (typically 2-10), requiring simple indexed ordering + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +### Pre-Research Gate Review + +| Gate | Status | Notes | +|------|--------|-------| +| Clean Architecture / Ports-and-Adapters | PASS | New preset services stay in application layer with EF adapter boundaries in infrastructure. | +| Functional Core / Impure Edges | PASS | Preset CRUD and MRU update are I/O orchestration in API layer; no domain side effects leak into UI. | +| Event Sourcing / CQRS compatibility | PASS | Ride write path remains append-only; preset MRU update is additive metadata and does not mutate ride history. | +| Quality-first TDD workflow | PASS WITH ACTION | Tasks must enforce red-green-refactor with explicit user confirmation of failing tests before implementation. | +| UX consistency and accessibility | PASS | Changes occur in existing settings and ride-entry pages with typed React patterns and existing form semantics. | +| Data validation and integrity | PASS | Unique rider-scoped names, required exact time/duration, and server-side validation planned. | +| Performance and observability | PASS | Preset reads are simple rider-scoped indexed queries; no heavy external dependency added. | +| Contract-first modularity | PASS | Preset CRUD and ride create request extension documented in contracts before implementation. | + +No constitution violations identified. + +### Post-Design Gate Re-Check + +| Gate | Status | Notes | +|------|--------|-------| +| Architecture and modularity | PASS | Dedicated preset contracts and services isolate feature concerns from existing ride history logic. | +| Validation and safety | PASS | Exact start time, duration bounds, and unique-name enforcement documented across API and UI validation layers. | +| Contract compatibility | PASS | Additive API extensions plus explicit removal of legacy quick-options surface documented as intentional replacement. | +| UX and accessibility | PASS | Legacy quick-entry removed; replacement is explicit preset selection with manual override retained. | +| Verification matrix | PASS WITH ACTION | quickstart includes backend/unit/frontend/E2E validation commands and required TDD gates. | + +## Project Structure + +### Documentation (this feature) + +```text +specs/020-improve-ride-preset-options/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── api-contracts.md +└── tasks.md # generated by /speckit.tasks +``` + +### Source Code (repository root) + +```text +src/ +├── BikeTracking.Api/ +│ ├── Application/ +│ │ ├── Rides/ +│ │ │ ├── RecordRideService.cs +│ │ │ └── GetQuickRideOptionsService.cs # remove/replace behavior +│ │ └── Users/ +│ │ └── UserSettingsService.cs +│ ├── Contracts/ +│ │ └── RidesContracts.cs +│ ├── Endpoints/ +│ │ └── RidesEndpoints.cs +│ └── Infrastructure/Persistence/ +│ ├── BikeTrackingDbContext.cs +│ ├── Entities/ +│ │ └── (new RidePreset entity) +│ └── Migrations/ +├── BikeTracking.Api.Tests/ +│ ├── Endpoints/ +│ │ ├── RidesEndpointsTests.cs +│ │ └── RidesEndpointsSqliteIntegrationTests.cs +│ └── Application/ +│ └── (rides preset service tests) +└── BikeTracking.Frontend/src/ + ├── components/app-header/ + │ └── app-header.tsx + ├── pages/ + │ ├── RecordRidePage.tsx + │ ├── RecordRidePage.test.tsx + │ └── settings/ + │ ├── SettingsPage.tsx + │ ├── SettingsPage.css + │ └── SettingsPage.test.tsx + └── services/ + ├── ridesService.ts + └── ridesService.test.ts + + **Update 2026-05-20:** All backend, frontend, and contract flows for ride presets now include a required `miles` field. All CRUD, validation, and UI logic must support this field. Tests must cover creation, update, and application of presets with miles. +``` + +**Structure Decision**: Existing web-app split is retained. Backend introduces rider-scoped preset persistence and endpoint changes. Frontend updates settings and ride-entry pages to replace legacy quick-entry with explicit preset flows. + +## Implementation Phases + +### Phase 0 - Research (completed) + +- Exact preset start time stored as `TimeOnly` style value (`HH:mm` in contract). +- MRU ordering based on successful ride saves using a preset (`LastUsedAtUtc`). +- Legacy quick-entry endpoint/UI removed for all riders, no fallback flag. +- Dedicated preset CRUD API boundaries selected. + +### Phase 1 - Design and Contracts (completed) + +- Data model defined for `RidePreset` with rider-scoped uniqueness and MRU fields. +- API contracts defined for preset CRUD and ride create request extension (`selectedPresetId`). +- Quickstart defines TDD-first execution path and verification matrix. + +### Phase 2 - Implementation Planning (ready) + +1. Backend migration + preset services + endpoint mapping (add `Miles` field to entity, DTOs, and validation). +2. Record-ride MRU update on successful save. +3. Remove legacy quick-options endpoint/service/contract usage. +4. Frontend settings preset CRUD UX (add `miles` field to forms, validation, and display). +5. Frontend ride-entry preset apply + legacy quick-entry UI deletion (apply `miles` from preset to ride entry form). +6. Cross-layer test hardening (unit/integration/E2E) and regression verification for all flows involving `miles`. + +## Complexity Tracking + +No constitution violations requiring justification. diff --git a/specs/020-improve-ride-preset-options/quickstart.md b/specs/020-improve-ride-preset-options/quickstart.md new file mode 100644 index 0000000..fda43f9 --- /dev/null +++ b/specs/020-improve-ride-preset-options/quickstart.md @@ -0,0 +1,204 @@ +# Quickstart: Improve Ride Preset Options + +**Feature**: 020-improve-ride-preset-options +**Date**: 2026-04-29 + +## Goal + +Replace legacy quick-entry from previous rides with explicit rider-managed presets configured in settings and applied during ride entry. + +This quickstart enforces the clarified decisions: +- Presets store exact start time. +- Ride-entry preset list is MRU ordered. +- Legacy quick-entry UI is deleted for all riders. + + +## Phase 1: Baseline Results (pre-change) + +### Backend build +Build succeeded with 5 warnings in 20s + +### Backend tests +353 total, 0 failed, 351 succeeded, 2 skipped, duration: 14.9s + +### Frontend unit tests +22 test files passed, 154 tests passed, duration: 96s + +### Foundational backend persistence tests (Phase 2) +- `dotnet test src/BikeTracking.Api.Tests/BikeTracking.Api.Tests.csproj --filter "FullyQualifiedName~RidesEndpointsSqliteIntegrationTests"` +- Result: 4 total, 0 failed, 4 succeeded, duration: 8.3s + +### Ride endpoint regression sweep (Phase 2 safety) +- `dotnet test src/BikeTracking.Api.Tests/BikeTracking.Api.Tests.csproj --filter "FullyQualifiedName~RidesEndpointsTests|FullyQualifiedName~RidesEndpointsSqliteIntegrationTests"` +- Result: 41 total, 0 failed, 41 succeeded, duration: 5.7s + +### US1 RED gate (expected failures before implementation) +- Backend command: + `dotnet test src/BikeTracking.Api.Tests/BikeTracking.Api.Tests.csproj --filter "FullyQualifiedName~RidePresetCrud_FullRoundTrip_IncludingExactTimeAndDelete|FullyQualifiedName~CreateRidePreset_DuplicateNameForSameRider_Returns400"` + Result: 2 failed as expected (endpoint missing), expected `Created` but got `MethodNotAllowed`. +- Frontend command: + `npm run test:unit -- SettingsPage.test.tsx -t "ride presets management section|preset fields including exact start time and duration controls"` + Result: 2 failed as expected (preset UI fields/section not rendered yet). + +### US1 GREEN gate +- Backend command: + `dotnet test src/BikeTracking.Api.Tests/BikeTracking.Api.Tests.csproj --filter "FullyQualifiedName~RidesEndpointsTests.CreateRidePreset|FullyQualifiedName~RidesEndpointsTests.RidePresetCrud|FullyQualifiedName~RidesEndpointsSqliteIntegrationTests.RidePreset_"` + Result: 5 total, 0 failed, 5 succeeded, duration: 5.5s +- Frontend command: + `npm run test:unit -- src/pages/settings/SettingsPage.test.tsx` + Result: 10 total, 0 failed, 10 succeeded, duration: 23.7s + +### Phase 6 targeted polish verification +- Backend command: + `dotnet test src/BikeTracking.Api.Tests/BikeTracking.Api.Tests.csproj --filter "FullyQualifiedName~RidesEndpointsSqliteIntegrationTests"` + Result: 11 total, 0 failed, 11 succeeded, duration: 10.9s. Added coverage for preset endpoint `401` responses and cross-rider update/delete `404` behavior. +- Targeted E2E command: + `npm run test:e2e -- tests/e2e/record-ride.spec.ts` + Result: 4 passed in 32.0s. Evidence: settings preset CRUD succeeded, ride-entry preset apply populated direction/time/duration, MRU reorder placed the used preset first on revisit, and no `Quick Ride Options` UI was visible. + +### Phase 6 full verification matrix +- Backend command: + `dotnet test BikeTracking.slnx` + Result: 360 total, 0 failed, 358 succeeded, 2 skipped, duration: 19.9s. +- Frontend commands: + `npm run lint && npm run build && npm run test:unit` + Result: lint passed, production build passed in 7.14s (existing Vite chunk-size warning only), and unit tests passed with 22 files / 159 tests green. +- Full E2E command: + `npm run test:e2e` + Result: 38 passed, 0 failed, duration: 2.3m. + +### Final T049 confirmation +- Full E2E command rerun after selector stabilization: + `npm run test:e2e` + Result: 38 passed, 0 failed, duration: 2.3m. +- Key success criteria evidence: + - Preset apply behavior validated end-to-end in `tests/e2e/record-ride.spec.ts` (direction/time/duration populated from preset). + - MRU behavior validated by revisiting ride-entry selector ordering after successful save with selected preset. + - Legacy quick-entry visibility validated by explicit assertions that `Quick Ride Options` is not rendered. + +--- + +## Implementation Order (TDD gates required) + +1. Write failing tests first, run, and get user confirmation of meaningful failures before implementation. +2. Implement smallest backend slice to pass. +3. Implement frontend slice to pass. +4. Run full verification matrix. + + +## Implementation Notes (checkpoint boundaries, commit points) + +- Phase 1: Baseline captured above. No changes yet. +- Phase 2: After T010 (foundational persistence tests GREEN), commit all new model/contracts/service/migration files. +- Phase 3: After T021 (US1 GREEN), commit all settings CRUD and test changes. +- Phase 4: After T036 (US2 GREEN), commit all ride-entry preset/MRU/legacy removal changes. +- Phase 5: After T044 (US3 GREEN), commit all direction default/override changes. +- Phase 6: After T049 (final E2E/verification), commit all polish/test artifacts. + +1. Add `RidePreset` entity, DbSet, and EF migration. +2. Add rider-scoped preset contracts and endpoint mappings. +3. Add request validation and unique-name enforcement. + +Suggested files: +- `src/BikeTracking.Api/Infrastructure/Persistence/Entities/` (new preset entity) +- `src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs` +- `src/BikeTracking.Api/Contracts/RidesContracts.cs` +- `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs` +- `src/BikeTracking.Api/Application/Rides/` (new preset services) + +## Step 2: MRU Update on Ride Save + +1. Extend record-ride request with optional `selectedPresetId`. +2. In record-ride flow, after successful save and rider ownership validation, update preset `LastUsedAtUtc`. +3. Keep ride save successful even if no preset selected. + +Suggested file: +- `src/BikeTracking.Api/Application/Rides/RecordRideService.cs` + +## Step 3: Remove Legacy Quick-Entry Backend Surface + +1. Remove quick-options route mapping from rides endpoints. +2. Remove/deprecate `GetQuickRideOptionsService` and associated contract types. +3. Update tests to assert endpoint absence and no quick-options usage path. + +Suggested files: +- `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs` +- `src/BikeTracking.Api/Application/Rides/GetQuickRideOptionsService.cs` +- `src/BikeTracking.Api/Contracts/RidesContracts.cs` +- `src/BikeTracking.Api.Tests/Endpoints/` + +## Step 4: Settings UI - Preset Management + +1. Add preset section to settings page with create/edit/delete. +2. Provide period-based default direction suggestions: + - morning -> SW + - afternoon -> NE +3. Keep user override always enabled. + +Suggested files: +- `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx` +- `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css` +- `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx` +- `src/BikeTracking.Frontend/src/services/ridesService.ts` + +## Step 5: Ride Entry - Preset Apply and Legacy UI Removal + +1. Remove "Quick Ride Options" section and calls from ride entry page. +2. Add preset selector/list ordered by MRU from backend response. +3. On preset apply, populate direction, exact start time, duration. +4. Include `selectedPresetId` when submitting if preset-origin values are used. + +Suggested files: +- `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx` +- `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx` +- `src/BikeTracking.Frontend/src/services/ridesService.ts` +- `src/BikeTracking.Frontend/src/services/ridesService.test.ts` + +## Step 6: Header/Menu Access Validation + +1. Ensure settings remains reachable from username area through existing click/hover interactions. +2. Add/update tests for navigation affordance if needed. + +Suggested file: +- `src/BikeTracking.Frontend/src/components/app-header/app-header.tsx` + +## Required Test Plan + +Backend tests: +- Create preset with exact start time persists correctly. +- Duplicate preset name for same rider returns validation error. +- Same preset name across different riders is allowed. +- Preset list returns MRU order by `LastUsedAtUtc`. +- Record ride with `selectedPresetId` updates MRU timestamp. +- Preset list excludes presets from other riders. +- Legacy quick-options endpoint no longer available. + +Frontend tests: +- Settings page supports create/edit/delete preset flows. +- Period change suggests SW/NE defaults and still permits user override. +- Record ride page does not render legacy quick-entry section. +- Record ride page applies preset values to direction/time/duration. +- Submission sends `selectedPresetId` when a preset is used. + +E2E tests: +- Rider creates presets in settings and sees them in ride entry. +- MRU order changes only after successful ride save using a preset. +- No legacy quick-entry UI visible for any rider profile. + +## Verification Commands + +```bash +cd /workspaces/neCodeBikeTracking + +# Backend +DOTNET_CLI_TELEMETRY_OPTOUT=1 dotnet test BikeTracking.slnx + +# Frontend +cd /workspaces/neCodeBikeTracking/src/BikeTracking.Frontend +npm run lint +npm run build +npm run test:unit + +# E2E (with Aspire app running) +npm run test:e2e +``` diff --git a/specs/020-improve-ride-preset-options/research.md b/specs/020-improve-ride-preset-options/research.md new file mode 100644 index 0000000..1e7d775 --- /dev/null +++ b/specs/020-improve-ride-preset-options/research.md @@ -0,0 +1,104 @@ +# Research: Improve Ride Preset Options + +**Feature**: 020-improve-ride-preset-options +**Date**: 2026-04-29 +**Status**: Complete - all planning clarifications resolved + +--- + +## Decision 1: Preset Time Representation + +**Decision**: Store preset time as an exact local clock time (`TimeOnly`) named `ExactStartTimeLocal`, not as a relative window or inferred time. + +**Rationale**: +- The specification requires exact start time to be stored and re-applied. +- Storing a pure time value avoids accidental date coupling and keeps presets reusable across days. +- Existing ride entry already captures a full local datetime; applying a preset can safely overwrite only the time component. + +**Alternatives considered**: +- Store full `DateTime`: rejected because presets should be reusable and date-independent. +- Store broad morning/afternoon buckets only: rejected because requirement explicitly calls for exact time. + +--- + +## Decision 2: Most-Recently-Used Ordering Semantics + +**Decision**: Preset list on ride entry is ordered by `LastUsedAtUtc DESC`, where "used" means a ride is successfully saved with that preset. + +**Rationale**: +- Produces deterministic and user-visible recency behavior. +- Avoids noisy ordering changes when a rider merely clicks/tries presets but does not save. +- Aligns with existing event-sourced flow where final write success is the source of truth. + +**Alternatives considered**: +- Update recency on preset selection click: rejected because canceled/abandoned forms would reorder list unexpectedly. +- Keep static/manual order: rejected because FR-009 requires MRU ordering. + +--- + +## Decision 3: Legacy Quick-Entry Removal Strategy + +**Decision**: Remove legacy quick-entry endpoint, contracts, service wiring, and ride-entry UI for all authenticated riders. No feature flag and no user-segment fallback. + +**Rationale**: +- FR-011 requires deleting legacy previous-ride quick-entry UI for all users. +- Eliminates conflicting mental models (history-based quick options vs explicit presets). +- Reduces code-path complexity and test surface. + +**Alternatives considered**: +- Keep legacy quick options behind fallback toggle: rejected because it violates replacement requirement. +- Soft-hide frontend UI but keep backend endpoint: rejected because dead paths drift and risk accidental reuse. + +--- + +## Decision 4: API Boundary for Preset CRUD + +**Decision**: Add dedicated rider-scoped preset endpoints in rides/settings API surface: +- `GET /api/rides/presets` +- `POST /api/rides/presets` +- `PUT /api/rides/presets/{presetId}` +- `DELETE /api/rides/presets/{presetId}` + +And extend `POST /api/rides` request with optional `selectedPresetId` to update MRU on successful save. + +**Rationale**: +- Keeps preset lifecycle explicit and testable. +- Avoids overloading generic user settings payload with collection mutation concerns. +- Fits existing Minimal API style and rider-scoped authorization pattern. + +**Alternatives considered**: +- Embed full preset CRUD inside `PUT /api/users/settings`: rejected because list mutation semantics become opaque and conflict-prone. +- Update MRU on client only: rejected because ordering must be authoritative and secure server-side. + +--- + +## Decision 5: Direction Defaults by Period + +**Decision**: In preset create/update flow, auto-suggest default primary direction based on period: +- `Morning -> SW` +- `Afternoon -> NE` + +Suggestion is non-binding and fully user-overridable before save. + +**Rationale**: +- Meets FR-006/FR-007/FR-008 exactly. +- Supports commute defaults without constraining custom routes. + +**Alternatives considered**: +- Hard-lock direction by period: rejected because override is required. +- No defaults: rejected because default behavior is explicitly required. + +--- + +## Decision 6: Migration Shape + +**Decision**: Add a new `RidePresets` table plus additive optional `SelectedPresetId` on ride write contract only (no destructive migration). Keep existing ride history and analytics schema intact. + +**Rationale**: +- Preserves historical data and analytics behavior per FR-014. +- Supports rider-scoped unique names and MRU sorting with indexed columns. +- Minimizes regression risk while replacing UI behavior. + +**Alternatives considered**: +- Reuse existing rides table to infer presets dynamically: rejected because explicit user-managed names and exact time cannot be represented reliably. +- Delete historical quick-entry artifacts from persisted data: rejected because historical data must remain intact. diff --git a/specs/020-improve-ride-preset-options/spec.md b/specs/020-improve-ride-preset-options/spec.md new file mode 100644 index 0000000..051dbad --- /dev/null +++ b/specs/020-improve-ride-preset-options/spec.md @@ -0,0 +1,107 @@ +# Feature Specification: Improve Ride Entry Preset Options + +**Feature Branch**: `020-improve-ride-preset-options` +**Created**: 2026-04-29 +**Status**: Draft +**Input**: User description: "improve ride entry - pre setup options (new UI under user settings, navigatable from clicking/hovering on user name, then a new settings option) with primary direction, morning is SW, afternoon is NE, time, duration, miles, user chooses name; remove auto fill from previous, replace with pre-setup options; this changes a previous spec" + +**Update 2026-05-20**: Each ride preset now includes a 'miles' field (required, positive number). When a preset is chosen, the ride entry's miles field is pre-filled from the preset. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Configure Ride Presets in Settings (Priority: P1) + +As a rider, I want to pre-configure named ride presets in my user settings so I can reuse my most common ride setup (including miles ridden) without re-entering the same values each time. + +**Why this priority**: This is the core behavior shift requested and replaces the current ride-entry autofill behavior. + +**Independent Test**: Open profile menu from username, navigate to settings, create a named preset with direction, exact start time, duration, and miles, save, then reopen settings and verify preset persists. + +**Acceptance Scenarios**: + +1. **Given** rider is authenticated, **When** rider opens username menu by click or hover and selects settings, **Then** rider can access a new ride-preset setup section. +2. **Given** rider is in ride-preset setup, **When** rider adds a preset name, direction, period (morning or afternoon), exact start time, duration, and miles then saves, **Then** preset is stored for that rider. +3. **Given** rider has existing presets, **When** rider revisits settings, **Then** rider sees previously saved presets and can identify each by custom name. + +--- + +### User Story 2 - Apply Presets During Ride Entry (Priority: P1) + +As a rider, I want ride entry to use my pre-setup options (including miles) instead of previous-ride autofill so entry is predictable and aligned with my routine. + +**Why this priority**: Requested behavior explicitly replaces legacy autofill from past entries. + +**Independent Test**: With at least one configured preset, open ride entry and verify preset options are available while previous-ride autofill options are absent. + +**Acceptance Scenarios**: + +1. **Given** rider has one or more saved presets, **When** rider opens ride entry, **Then** rider can choose from saved preset names. +2. **Given** rider selects a preset in ride entry, **When** preset is applied, **Then** direction, exact start time, duration, and miles fields are populated from that preset. +3. **Given** rider opens ride entry after feature release, **When** no presets are configured, **Then** system does not show legacy quick-entry UI and keeps manual entry available. + +--- + +### User Story 3 - Support Routine Direction Defaults (Priority: P2) + +As a rider, I want morning and afternoon directional defaults so I can quickly map regular commute flow (morning SW, afternoon NE) while still choosing my own preset names. + +**Why this priority**: Speeds setup for common commute pattern while preserving user control. + +**Independent Test**: In settings, create presets using default directional suggestions for morning and afternoon; verify user can override values and keep custom names. + +**Acceptance Scenarios**: + +1. **Given** rider creates a morning preset, **When** period tag is morning, **Then** system suggests SW as default primary direction. +2. **Given** rider creates an afternoon preset, **When** period tag is afternoon, **Then** system suggests NE as default primary direction. +3. **Given** directional defaults are suggested, **When** rider edits direction or preset name, **Then** system accepts rider-selected values. + +### Edge Cases + +- Rider creates a preset with invalid or missing miles: system blocks save and requests a valid positive number for miles. + +- Rider has no presets configured: ride entry remains fully manual with no prior-ride autofill. +- Rider creates two presets with same name: system blocks save and requests unique name per rider. +- Rider selects a preset then manually edits values: edited values are used for that entry only unless rider explicitly updates preset in settings. +- Rider has legacy quick-entry history data: data remains stored historically but is not used to auto-populate new ride entry. +- Rider toggles between click and hover interaction patterns on username menu: settings option remains reachable in both flows. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide access to user settings from the username menu, reachable by click and hover interactions. +- **FR-002**: System MUST include a ride-preset setup section within user settings. +- **FR-003**: System MUST allow riders to create, view, update, and remove ride presets. +- **FR-004**: Each ride preset MUST include rider-defined name, primary direction, period tag (morning or afternoon), exact start time, duration, and miles (required, positive number). +- **FR-005**: System MUST require preset names to be unique per rider. +- **FR-006**: System MUST suggest SW as default direction for morning time-window presets. +- **FR-007**: System MUST suggest NE as default direction for afternoon time-window presets. +- **FR-008**: System MUST allow riders to override suggested directions before saving presets. +- **FR-009**: Ride entry MUST display rider’s saved preset names as selectable setup options ordered by most recently used preset first. +- **FR-010**: When preset selected in ride entry, system MUST populate direction, exact start time, duration, and miles from preset values. +- **FR-011**: System MUST delete legacy previous-ride quick-entry UI introduced by earlier quick-entry specification and replace it with preset-based setup options for all riders. +- **FR-012**: System MUST keep manual ride entry functional when no presets exist. +- **FR-013**: Presets MUST be scoped to authenticated rider and never visible to other riders. +- **FR-014**: System MUST preserve historical rides and analytics behavior independent of preset configuration changes. +- **FR-015**: System MUST remove record-ride defaults behavior from both API and UI (no ride-defaults endpoint and no previous-ride autofill initialization); ride setup MUST rely on manual entry and rider presets. + +### Key Entities *(include if feature involves data)* + +- **Ride Preset**: Rider-owned reusable entry profile containing custom name, primary direction, period classification (morning or afternoon), exact start time, default duration, and miles. +- **Rider Preset Collection**: Ordered set of ride presets owned by one rider and surfaced in settings and ride-entry selection. +- **Ride Entry Session**: Single ride-creation interaction that may apply a preset and optionally override values before save. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 95% of riders can create first named preset from settings in under 60 seconds. +- **SC-002**: 95% of ride entries that use a preset populate direction, exact start time, duration, and miles fields within 1 second of selection. +- **SC-003**: 0 ride-entry sessions use legacy previous-ride autofill after release. +- **SC-004**: At least 80% of riders who record rides on both morning and afternoon windows configure at least two presets within first 14 days. + +## Assumptions + +- Existing ride-entry flow already supports editable direction, exact start time, and duration fields. +- "Morning" and "Afternoon" time windows are already defined in product language and can be reused. +- This feature supersedes prior quick-entry behavior from the earlier ride-entry spec where values were auto-filled from previous rides. diff --git a/specs/020-improve-ride-preset-options/tasks.md b/specs/020-improve-ride-preset-options/tasks.md new file mode 100644 index 0000000..502c858 --- /dev/null +++ b/specs/020-improve-ride-preset-options/tasks.md @@ -0,0 +1,225 @@ +# Tasks: Improve Ride Entry Preset Options + +**Feature**: `020-improve-ride-preset-options` +**Branch**: `020-improve-ride-preset-options` +**Spec**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md) +**Generated**: 2026-04-29 +**Design inputs**: spec.md, plan.md, research.md, data-model.md, contracts/api-contracts.md, quickstart.md + +## Format: `[ID] [P?] [Story?] Description with file path` + +- **[P]**: Parallelizable task (different files, no dependency on incomplete task) +- **[US1/US2/US3]**: User story label +- Every task includes concrete repository file targets +- Tests are mandatory for this feature and must run RED before implementation and GREEN after implementation + +--- + +## Phase 1: Setup (Shared Context) + +**Purpose**: Baseline checks and feature scaffolding alignment before schema/API/UI changes. + +- [X] T001 Validate baseline build/test commands and capture pre-change status in `specs/020-improve-ride-preset-options/quickstart.md` +- [X] T002 Add implementation notes section for task checkpoints and commit boundaries in `specs/020-improve-ride-preset-options/quickstart.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Persisted preset model + core API contracts and service wiring that all stories depend on. + +**⚠️ CRITICAL**: No user story implementation starts until this phase is complete. + +- [X] T003 Create `RidePresetEntity` with rider scope, exact start time, period tag, duration, MRU fields, and concurrency token in `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RidePresetEntity.cs` +- [X] T004 [P] Register `DbSet`, unique rider+name index, MRU index, and field constraints in `src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs` +- [X] T005 [P] Extend ride-create contract with optional `selectedPresetId` and add preset DTO/request/response records in `src/BikeTracking.Api/Contracts/RidesContracts.cs` +- [X] T006 Add preset service interfaces and implementations for list/create/update/delete + rider authorization checks in `src/BikeTracking.Api/Application/Rides/RidePresetService.cs` +- [X] T007 Register preset services in DI container in `src/BikeTracking.Api/Program.cs` +- [X] T008 Generate EF Core migration for `RidePresets` table and indexes in `src/BikeTracking.Api/Infrastructure/Persistence/Migrations/` +- [X] T009 [P] Add backend persistence tests for unique rider-scoped names, exact `HH:mm` time parsing/storage, and rider isolation in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs` +- [X] T010 Run foundational persistence tests GREEN and document output in `specs/020-improve-ride-preset-options/quickstart.md` + +**Checkpoint**: Preset persistence/contracts/services exist and pass foundational persistence validation. + +--- + +## Phase 3: User Story 1 - Configure Ride Presets in Settings (Priority: P1) 🎯 MVP + +**Goal**: Riders can reach settings from username menu and perform full preset CRUD with exact start time persisted. + +**Independent Test**: Navigate from username menu to settings, create/edit/delete presets, reload page, and confirm persisted values. + +### Tests for User Story 1 (RED Gate Required) + +- [X] T011 [P] [US1] Add failing API tests for `GET/POST/PUT/DELETE /api/rides/presets` including duplicate-name rejection and exact time roundtrip in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs` +- [X] T012 [P] [US1] Add failing settings-page tests for preset CRUD UI and persisted reload behavior in `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx` +- [X] T013 [US1] Run US1 RED test gate (`dotnet test` and `npm run test:unit` filtered suites) and record failing assertions in `specs/020-improve-ride-preset-options/quickstart.md` + +### Implementation for User Story 1 + +- [X] T014 [US1] Map preset CRUD endpoints (`GET/POST/PUT/DELETE /api/rides/presets`) with rider auth and contract validation in `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs` +- [X] T015 [US1] Add preset contract mapping and validation helpers (`periodTag`, exact `HH:mm`, duration bounds) in `src/BikeTracking.Api/Contracts/RidesContracts.cs` +- [X] T016 [US1] Add settings-page preset section with list/create/edit/delete forms in `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx` +- [X] T017 [P] [US1] Add settings-page styling for preset section states (empty/list/edit/delete confirm) in `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css` +- [X] T018 [US1] Extend ride service API client with preset CRUD methods and typed models in `src/BikeTracking.Frontend/src/services/ridesService.ts` +- [X] T019 [US1] Ensure username menu exposes settings navigation for click and hover interaction paths in `src/BikeTracking.Frontend/src/components/app-header/app-header.tsx` + +### Verification for User Story 1 (GREEN Gate Required) + +- [X] T020 [US1] Run US1 backend GREEN suite for preset endpoint tests and capture pass output in `specs/020-improve-ride-preset-options/quickstart.md` +- [X] T021 [US1] Run US1 frontend GREEN suite for settings preset tests and capture pass output in `specs/020-improve-ride-preset-options/quickstart.md` + +**Checkpoint**: US1 independently shippable (preset CRUD in settings with exact start time persistence). + +--- + +## Phase 4: User Story 2 - Apply Presets During Ride Entry (Priority: P1) + +**Goal**: Ride entry uses saved presets ordered by MRU, applies preset values, and removes legacy quick-entry behavior. + +**Independent Test**: Create multiple presets, record rides using selected presets, confirm MRU reorder after successful save, and confirm legacy quick-entry UI is absent. + +### Tests for User Story 2 (RED Gate Required) + +- [X] T022 [P] [US2] Add failing API tests for MRU ordering (`lastUsedAtUtc DESC`), post-save MRU update, and selected-preset ownership enforcement in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs` +- [X] T023 [P] [US2] Add failing record-ride page tests for preset apply (direction + exact time + duration), manual override behavior, and no-legacy-quick-entry rendering in `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx` +- [X] T024 [P] [US2] Add failing service tests for rides API preset fetch and ride submit payload with `selectedPresetId` in `src/BikeTracking.Frontend/src/services/ridesService.test.ts` +- [X] T025 [US2] Run US2 RED test gate and capture failing assertions in `specs/020-improve-ride-preset-options/quickstart.md` + +### Implementation for User Story 2 + +- [X] T026 [US2] Update preset listing service query to return MRU order (`LastUsedAtUtc DESC`, `UpdatedAtUtc DESC`) in `src/BikeTracking.Api/Application/Rides/RidePresetService.cs` +- [X] T027 [US2] Extend ride save flow to update preset `LastUsedAtUtc` only after successful ride persistence when `selectedPresetId` is provided in `src/BikeTracking.Api/Application/Rides/RecordRideService.cs` +- [X] T028 [US2] Remove legacy quick-options endpoint mapping from rides API surface in `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs` +- [X] T029 [US2] Delete legacy backend quick-options service implementation in `src/BikeTracking.Api/Application/Rides/GetQuickRideOptionsService.cs` +- [X] T030 [US2] Remove legacy quick-options contracts/types from rides contracts in `src/BikeTracking.Api/Contracts/RidesContracts.cs` +- [X] T031 [US2] Add ride-entry preset selector, apply action, and local form population for direction/exact start time/duration in `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx` +- [X] T032 [P] [US2] Extend rides client for `getRidePresets` and include optional `selectedPresetId` in ride submit request in `src/BikeTracking.Frontend/src/services/ridesService.ts` +- [X] T033 [US2] Remove legacy quick-entry UI section and related fetch/apply flow from ride-entry page in `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx` +- [X] T034 [US2] Remove quick-entry usage path from history/edit interactions if referenced by legacy code in `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx` + +### Verification for User Story 2 (GREEN Gate Required) + +- [X] T035 [US2] Run US2 backend GREEN suite for MRU ordering and post-save update behavior and record pass output in `specs/020-improve-ride-preset-options/quickstart.md` +- [X] T036 [US2] Run US2 frontend GREEN suites for record-ride preset apply and legacy quick-entry removal and record pass output in `specs/020-improve-ride-preset-options/quickstart.md` + +**Checkpoint**: US2 independently shippable (preset apply + MRU behavior + legacy quick-entry deletion). + +--- + +## Phase 5: User Story 3 - Support Routine Direction Defaults (Priority: P2) + +**Goal**: Settings preset form suggests Morning -> SW and Afternoon -> NE defaults while preserving user override. + +**Independent Test**: In settings preset form choose morning/afternoon tags, verify default direction suggestions appear, override direction and save successfully. + +### Tests for User Story 3 (RED Gate Required) + +- [X] T037 [P] [US3] Add failing settings-page tests for period-tag direction suggestions (morning SW, afternoon NE) and override persistence in `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx` +- [X] T038 [P] [US3] Add failing API tests proving override values are accepted and persisted independent of suggested defaults in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs` +- [X] T039 [US3] Run US3 RED test gate and capture failing assertions in `specs/020-improve-ride-preset-options/quickstart.md` + +### Implementation for User Story 3 + +- [X] T040 [US3] Add preset-form default suggestion logic keyed by `periodTag` while preserving manual overrides in `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx` +- [X] T041 [P] [US3] Add helper constants/types for period-tag defaults and direction options in `src/BikeTracking.Frontend/src/services/ridesService.ts` +- [X] T042 [US3] Ensure backend accepts rider-selected direction regardless of default suggestion source in `src/BikeTracking.Api/Application/Rides/RidePresetService.cs` + +### Verification for User Story 3 (GREEN Gate Required) + +- [X] T043 [US3] Run US3 backend GREEN tests for override acceptance and capture output in `specs/020-improve-ride-preset-options/quickstart.md` +- [X] T044 [US3] Run US3 frontend GREEN tests for default suggestion + override flows and capture output in `specs/020-improve-ride-preset-options/quickstart.md` + +**Checkpoint**: US3 independently shippable (direction defaults are helpful but never restrictive). + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: End-to-end hardening, regression checks, and verification matrix execution. + +- [X] T045 [P] Add/update E2E flow covering settings preset CRUD, ride-entry preset apply, MRU reorder after save, and no legacy quick-entry UI in `src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts` +- [X] T046 [P] Update backend endpoint integration coverage for unauthorized and cross-rider preset access attempts in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs` +- [X] T047 [P] Run backend full test suite and capture results in `specs/020-improve-ride-preset-options/quickstart.md` +- [X] T048 [P] Run frontend lint/build/unit suite and capture results in `specs/020-improve-ride-preset-options/quickstart.md` +- [X] T049 Run E2E suite and confirm key success criteria evidence (preset apply speed and zero legacy quick-entry visibility) in `specs/020-improve-ride-preset-options/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: No dependencies +- **Phase 2 (Foundational)**: Depends on Phase 1, blocks all user stories +- **Phase 3 (US1)**: Depends on Phase 2 +- **Phase 4 (US2)**: Depends on Phase 2 and US1 contracts/service availability +- **Phase 5 (US3)**: Depends on Phase 3 settings preset form foundation +- **Phase 6 (Polish)**: Depends on completion of all user stories + +### User Story Dependencies + +- **US1 (P1)**: Independent after foundation; delivers MVP +- **US2 (P1)**: Depends on preset CRUD artifacts from US1 but is independently testable once those exist +- **US3 (P2)**: Depends on settings preset UI from US1; independently testable suggestion/override behavior + +### Within Each User Story + +- RED tests must be written and executed before implementation tasks +- Backend contract/service changes before endpoint mapping +- Frontend service typing before UI integration +- GREEN verification tasks must pass before story checkpoint + +--- + +## Parallel Opportunities + +- Phase 2: T004 + T005 + T009 can run in parallel after T003 starts +- US1: T011 and T012 can run in parallel; T017 can run in parallel with T016/T018 +- US2: T022 + T023 + T024 can run in parallel; T032 can run in parallel with T031 after API contract is stable +- US3: T037 and T038 can run in parallel; T041 can run in parallel with T040 +- Phase 6: T045 + T046 + T047 + T048 can run in parallel, then T049 + +--- + +## Parallel Execution Examples by User Story + +### User Story 1 + +- Run in parallel: T011 and T012 (RED tests) +- Run in parallel: T016 and T018 and T017 (settings UI + service + styling) + +### User Story 2 + +- Run in parallel: T022 and T023 and T024 (RED tests) +- Run in parallel: T031 and T032 (UI apply flow + service payload typing) + +### User Story 3 + +- Run in parallel: T037 and T038 (RED tests) +- Run in parallel: T040 and T041 (UI suggestion logic + constants/types) + +--- + +## Implementation Strategy + +### MVP First (US1 only) + +1. Complete Phase 1 and Phase 2 +2. Complete US1 RED gate tasks (T011-T013) +3. Complete US1 implementation tasks (T014-T019) +4. Complete US1 GREEN gate tasks (T020-T021) +5. Validate and demo settings preset CRUD with exact start time persistence + +### Incremental Delivery + +1. Ship MVP (US1) +2. Add US2 for ride-entry apply + MRU + legacy quick-entry deletion +3. Add US3 for morning/afternoon direction defaults with override support +4. Run full polish verification matrix + +### Key Clarified Requirement Coverage + +- Exact preset start time persisted and reapplied: T003, T005, T011, T016, T031 +- MRU ordering and update semantics (only after successful ride save): T022, T026, T027, T035 +- Legacy quick-entry behavior and UI removal for all riders: T028, T029, T030, T033, T036, T049 diff --git a/src/BikeTracking.Api.Tests/Application/ExpenseImports/CsvExpenseParserTests.cs b/src/BikeTracking.Api.Tests/Application/ExpenseImports/CsvExpenseParserTests.cs index add6008..f9eda27 100644 --- a/src/BikeTracking.Api.Tests/Application/ExpenseImports/CsvExpenseParserTests.cs +++ b/src/BikeTracking.Api.Tests/Application/ExpenseImports/CsvExpenseParserTests.cs @@ -30,15 +30,28 @@ public void Parse_WithMissingAmountHeader_Throws() Assert.Contains("Amount", exception.Message); } - [Theory] - [InlineData("$1,250.00 USD", "1250.00")] - [InlineData("£12.50 GBP", "12.50")] - [InlineData(" 25.00 EUR ", "25.00")] - public void NormalizeAmount_StripsCurrencyFormatting(string raw, string expected) + [Fact] + public void NormalizeAmount_StripsCurrencyFormatting_WithUsdAmount() + { + var normalized = parser.NormalizeAmount("$1,250.00 USD"); + + Assert.Equal("1250.00", normalized); + } + + [Fact] + public void NormalizeAmount_StripsCurrencyFormatting_WithGbpAmount() + { + var normalized = parser.NormalizeAmount("£12.50 GBP"); + + Assert.Equal("12.50", normalized); + } + + [Fact] + public void NormalizeAmount_StripsCurrencyFormatting_WithTrimmedEurAmount() { - var normalized = parser.NormalizeAmount(raw); + var normalized = parser.NormalizeAmount(" 25.00 EUR "); - Assert.Equal(expected, normalized); + Assert.Equal("25.00", normalized); } [Fact] diff --git a/src/BikeTracking.Api.Tests/Application/Rides/EditRideWithDifficultyTests.cs b/src/BikeTracking.Api.Tests/Application/Rides/EditRideWithDifficultyTests.cs index 36ed93e..db9811b 100644 --- a/src/BikeTracking.Api.Tests/Application/Rides/EditRideWithDifficultyTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Rides/EditRideWithDifficultyTests.cs @@ -2,6 +2,7 @@ namespace BikeTracking.Api.Tests.Application.Rides; using System.Net; using System.Net.Http.Json; +using System.Security.Claims; using BikeTracking.Api.Application.Rides; using BikeTracking.Api.Contracts; using BikeTracking.Api.Endpoints; @@ -13,7 +14,6 @@ namespace BikeTracking.Api.Tests.Application.Rides; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using System.Security.Claims; using Xunit; /// @@ -232,11 +232,7 @@ public async Task EditRide_DifficultyStoredAsRiderChoice_NotOverridden() var recordReq = new HttpRequestMessage(HttpMethod.Post, "/api/rides") { Content = JsonContent.Create( - new RecordRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 5.5m, - RideMinutes: 22 - ) + new RecordRideRequest(RideDateTimeLocal: DateTime.Now, Miles: 5.5m, RideMinutes: 22) ), }; recordReq.Headers.Add("X-User-Id", userId.ToString()); @@ -288,19 +284,23 @@ public static async Task StartAsync() ); builder .Services.AddAuthentication("test") - .AddScheme< - EditDifficultyTestAuthSchemeOptions, - EditDifficultyTestAuthHandler - >("test", _ => { }); + .AddScheme( + "test", + _ => { } + ); builder.Services.AddAuthorization(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped< + IGasPriceLookupService, + EditDifficultyNullGasPriceLookupService + >(); + builder.Services.AddScoped< + IWeatherLookupService, + EditDifficultyNullWeatherLookupService + >(); var app = builder.Build(); app.UseAuthentication(); diff --git a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs index 5b05546..9a9ac72 100644 --- a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs @@ -290,254 +290,6 @@ await Assert.ThrowsAsync(() => ); } - [Fact] - public async Task GetRideDefaultsService_ReturnsDefaultsForNewRider() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Diana", - NormalizedName = "diana", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var service = new GetRideDefaultsService(context); - - var defaults = await service.ExecuteAsync(user.UserId); - - Assert.False(defaults.HasPreviousRide); - Assert.Null(defaults.DefaultMiles); - Assert.Null(defaults.DefaultRideMinutes); - Assert.Null(defaults.DefaultTemperature); - Assert.NotEqual(DateTime.MinValue, defaults.DefaultRideDateTimeLocal); - } - - [Fact] - public async Task GetRideDefaultsService_ReturnsLastRideDefaults() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Eve", - NormalizedName = "eve", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - // Create previous ride - var previousRide = new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = DateTime.Now.AddHours(-1), - Miles = 10.5m, - RideMinutes = 45, - Temperature = 72m, - CreatedAtUtc = DateTime.UtcNow, - }; - context.Rides.Add(previousRide); - await context.SaveChangesAsync(); - - var service = new GetRideDefaultsService(context); - - var defaults = await service.ExecuteAsync(user.UserId); - - Assert.True(defaults.HasPreviousRide); - Assert.Equal(10.5m, defaults.DefaultMiles); - Assert.Equal(45, defaults.DefaultRideMinutes); - Assert.Equal(72m, defaults.DefaultTemperature); - } - - [Fact] - public async Task GetRideDefaultsService_ReturnsLatestWeatherDefaults() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Weather Defaults", - NormalizedName = "weather defaults", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - context.Rides.Add( - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = DateTime.Now.AddMinutes(-10), - Miles = 4.2m, - RideMinutes = 15, - Temperature = 58m, - WindSpeedMph = 9.1m, - WindDirectionDeg = 245, - RelativeHumidityPercent = 67, - CloudCoverPercent = 30, - PrecipitationType = "snow", - WeatherUserOverridden = true, - CreatedAtUtc = DateTime.UtcNow, - } - ); - await context.SaveChangesAsync(); - - var service = new GetRideDefaultsService(context); - var defaults = await service.ExecuteAsync(user.UserId); - - Assert.Equal(9.1m, defaults.DefaultWindSpeedMph); - Assert.Equal(245, defaults.DefaultWindDirectionDeg); - Assert.Equal(67, defaults.DefaultRelativeHumidityPercent); - Assert.Equal(30, defaults.DefaultCloudCoverPercent); - Assert.Equal("snow", defaults.DefaultPrecipitationType); - } - - [Fact] - public async Task GetQuickRideOptionsService_ReturnsOnlyAuthenticatedRiderOptions() - { - using var context = CreateDbContext(); - var riderA = new UserEntity - { - DisplayName = "Quick Rider A", - NormalizedName = "quick rider a", - CreatedAtUtc = DateTime.UtcNow, - }; - var riderB = new UserEntity - { - DisplayName = "Quick Rider B", - NormalizedName = "quick rider b", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.AddRange(riderA, riderB); - await context.SaveChangesAsync(); - - context.Rides.AddRange( - new RideEntity - { - RiderId = riderA.UserId, - RideDateTimeLocal = DateTime.Now.AddDays(-1), - Miles = 8m, - RideMinutes = 30, - CreatedAtUtc = DateTime.UtcNow, - }, - new RideEntity - { - RiderId = riderA.UserId, - RideDateTimeLocal = DateTime.Now, - Miles = 9m, - RideMinutes = 35, - CreatedAtUtc = DateTime.UtcNow, - }, - new RideEntity - { - RiderId = riderB.UserId, - RideDateTimeLocal = DateTime.Now, - Miles = 50m, - RideMinutes = 120, - CreatedAtUtc = DateTime.UtcNow, - } - ); - await context.SaveChangesAsync(); - - var service = new GetQuickRideOptionsService(context); - var response = await service.ExecuteAsync(riderA.UserId); - - Assert.NotNull(response); - foreach (var option in response.Options) - { - Assert.True(option.Miles < 20m); - Assert.True(option.RideMinutes < 60); - } - } - - [Fact] - public async Task GetQuickRideOptionsService_DeduplicatesByMilesAndRideMinutes_AndOrdersByMostRecent() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Quick Rider Distinct", - NormalizedName = "quick rider distinct", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var now = DateTime.Now; - context.Rides.AddRange( - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = now.AddDays(-3), - Miles = 10m, - RideMinutes = 30, - CreatedAtUtc = DateTime.UtcNow, - }, - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = now.AddDays(-1), - Miles = 10m, - RideMinutes = 30, - CreatedAtUtc = DateTime.UtcNow, - }, - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = now, - Miles = 8m, - RideMinutes = 25, - CreatedAtUtc = DateTime.UtcNow, - } - ); - await context.SaveChangesAsync(); - - var service = new GetQuickRideOptionsService(context); - var response = await service.ExecuteAsync(user.UserId); - - Assert.Equal(2, response.Options.Count); - Assert.Equal(8m, response.Options[0].Miles); - Assert.Equal(25, response.Options[0].RideMinutes); - Assert.Equal(10m, response.Options[1].Miles); - Assert.Equal(30, response.Options[1].RideMinutes); - } - - [Fact] - public async Task GetQuickRideOptionsService_ReturnsAtMostFiveDistinctOptions() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Quick Rider Limit", - NormalizedName = "quick rider limit", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var now = DateTime.Now; - for (var index = 0; index < 6; index++) - { - context.Rides.Add( - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = now.AddDays(-index), - Miles = 5m + index, - RideMinutes = 20 + index, - CreatedAtUtc = DateTime.UtcNow, - } - ); - } - - await context.SaveChangesAsync(); - - var service = new GetQuickRideOptionsService(context); - var response = await service.ExecuteAsync(user.UserId); - - Assert.Equal(5, response.Options.Count); - } - // History service tests [Fact] diff --git a/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs index e30a0f3..0ce72ba 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs @@ -117,8 +117,6 @@ public static async Task StartAsync() // Add Rides services builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/BikeTracking.Api.Tests/Endpoints/Rides/RecordRideWithDifficultyTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Rides/RecordRideWithDifficultyTests.cs index d609892..b2c2f84 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/Rides/RecordRideWithDifficultyTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/Rides/RecordRideWithDifficultyTests.cs @@ -158,11 +158,7 @@ public async Task PostRecordRide_WithInvalidDifficulty_Returns400() var recordRequest = new HttpRequestMessage(HttpMethod.Post, "/api/rides") { Content = JsonContent.Create( - new RecordRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 5.0m, - Difficulty: 6 - ) + new RecordRideRequest(RideDateTimeLocal: DateTime.Now, Miles: 5.0m, Difficulty: 6) ), }; recordRequest.Headers.Add("X-User-Id", userId.ToString()); @@ -219,8 +215,6 @@ public static async Task StartAsync() builder.Services.AddAuthorization(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/BikeTracking.Api.Tests/Endpoints/Rides/SampleCsvDownloadTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Rides/SampleCsvDownloadTests.cs index 53c7a7f..80732b4 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/Rides/SampleCsvDownloadTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/Rides/SampleCsvDownloadTests.cs @@ -143,8 +143,6 @@ public static async Task StartAsync() builder.Services.AddAuthorization(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs index 274ec45..663a59d 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs @@ -39,6 +39,240 @@ public async Task GetRideHistory_WithSqliteMigrationsApplied_ReturnsGasPricePerG Assert.Equal(3.4567m, ride.GasPricePerGallon); } + [Fact] + public async Task RidePreset_WithDuplicateNameForSameRider_ViolatesUniqueConstraint() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var riderId = await host.SeedUserAsync("PresetUnique"); + + await host.CreateRidePresetAsync(riderId, "Morning", "07:45", 30); + + await Assert.ThrowsAsync(async () => + await host.CreateRidePresetAsync(riderId, "Morning", "08:15", 40) + ); + } + + [Fact] + public async Task RidePreset_WithSameNameAcrossRiders_IsAllowed() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var riderA = await host.SeedUserAsync("PresetRiderA"); + var riderB = await host.SeedUserAsync("PresetRiderB"); + + await host.CreateRidePresetAsync(riderA, "Morning", "07:45", 30); + await host.CreateRidePresetAsync(riderB, "Morning", "08:15", 40); + + var riderAPresets = await host.GetPresetCountForRiderAsync(riderA); + var riderBPresets = await host.GetPresetCountForRiderAsync(riderB); + + Assert.Equal(1, riderAPresets); + Assert.Equal(1, riderBPresets); + } + + [Fact] + public async Task RidePreset_StoresExactStartTime() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var riderId = await host.SeedUserAsync("PresetTime"); + var presetId = await host.CreateRidePresetAsync(riderId, "Afternoon", "17:35", 32); + + var preset = await host.GetRidePresetAsync(presetId); + + Assert.NotNull(preset); + Assert.Equal(new TimeOnly(17, 35), preset.ExactStartTimeLocal); + Assert.Equal("afternoon", preset.PeriodTag); + } + + [Fact] + public async Task GetRidePresets_WithoutAuth_ReturnsUnauthorized() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var response = await host.Client.GetAsync("/api/rides/presets"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task CreateRidePreset_WithoutAuth_ReturnsUnauthorized() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var response = await host.Client.PostAsJsonAsync( + "/api/rides/presets", + new UpsertRidePresetRequest( + Name: "Unauthorized", + PrimaryDirection: "SW", + PeriodTag: "morning", + ExactStartTimeLocal: "07:45", + DurationMinutes: 30, + Miles: 6.5m + ) + ); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task RidePresets_AreOrderedByMostRecentlyUsedAfterSuccessfulRideSave() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var riderId = await host.SeedUserAsync("PresetMruOrder"); + var morningPresetId = await host.CreateRidePresetAsync(riderId, "Morning", "07:45", 30); + var afternoonPresetId = await host.CreateRidePresetAsync(riderId, "Afternoon", "17:35", 32); + + var recordRideResponse = await host.Client.PostWithAuthAsync( + "/api/rides", + new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 8.2m, + RideMinutes: 30, + SelectedPresetId: morningPresetId + ), + riderId + ); + Assert.Equal(HttpStatusCode.Created, recordRideResponse.StatusCode); + + var presetsResponse = await host.Client.GetWithAuthAsync("/api/rides/presets", riderId); + Assert.Equal(HttpStatusCode.OK, presetsResponse.StatusCode); + + var payload = await presetsResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal(2, payload.Presets.Count); + Assert.Equal(morningPresetId, payload.Presets[0].PresetId); + Assert.Equal(afternoonPresetId, payload.Presets[1].PresetId); + } + + [Fact] + public async Task RecordRide_WithSelectedPreset_UpdatesPresetLastUsedAtUtc() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var riderId = await host.SeedUserAsync("PresetMruUpdate"); + var presetId = await host.CreateRidePresetAsync(riderId, "Morning", "07:45", 30); + + var before = await host.GetRidePresetAsync(presetId); + Assert.NotNull(before); + Assert.Null(before.LastUsedAtUtc); + + var response = await host.Client.PostWithAuthAsync( + "/api/rides", + new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 6.4m, + RideMinutes: 28, + SelectedPresetId: presetId + ), + riderId + ); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var after = await host.GetRidePresetAsync(presetId); + Assert.NotNull(after); + Assert.NotNull(after.LastUsedAtUtc); + } + + [Fact] + public async Task RecordRide_WithSelectedPresetOwnedByAnotherRider_ReturnsBadRequest() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var ownerRiderId = await host.SeedUserAsync("PresetOwner"); + var attackerRiderId = await host.SeedUserAsync("PresetAttacker"); + var ownerPresetId = await host.CreateRidePresetAsync( + ownerRiderId, + "OwnerPreset", + "07:45", + 30 + ); + + var beforeRideCount = await host.GetRideCountForRiderAsync(attackerRiderId); + + var response = await host.Client.PostWithAuthAsync( + "/api/rides", + new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 7.1m, + RideMinutes: 29, + SelectedPresetId: ownerPresetId + ), + attackerRiderId + ); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var afterRideCount = await host.GetRideCountForRiderAsync(attackerRiderId); + Assert.Equal(beforeRideCount, afterRideCount); + } + + [Fact] + public async Task UpdateRidePreset_WithPresetOwnedByAnotherRider_ReturnsNotFound() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var ownerRiderId = await host.SeedUserAsync("PresetUpdateOwner"); + var attackerRiderId = await host.SeedUserAsync("PresetUpdateAttacker"); + var ownerPresetId = await host.CreateRidePresetAsync( + ownerRiderId, + "OwnerMorning", + "07:45", + 30 + ); + + var response = await host.Client.PutWithAuthAsync( + $"/api/rides/presets/{ownerPresetId}", + new UpsertRidePresetRequest( + Name: "Hijacked", + PrimaryDirection: "NE", + PeriodTag: "afternoon", + ExactStartTimeLocal: "17:15", + DurationMinutes: 33, + Miles: 5.9m + ), + attackerRiderId + ); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var preset = await host.GetRidePresetAsync(ownerPresetId); + Assert.NotNull(preset); + Assert.Equal("OwnerMorning", preset.Name); + Assert.Equal("SW", preset.PrimaryDirection); + } + + [Fact] + public async Task DeleteRidePreset_WithPresetOwnedByAnotherRider_ReturnsNotFound() + { + await using var host = await SqliteRidesApiHost.StartAsync(); + + var ownerRiderId = await host.SeedUserAsync("PresetDeleteOwner"); + var attackerRiderId = await host.SeedUserAsync("PresetDeleteAttacker"); + var ownerPresetId = await host.CreateRidePresetAsync( + ownerRiderId, + "OwnerAfternoon", + "17:35", + 32 + ); + + var deleteRequest = new HttpRequestMessage( + HttpMethod.Delete, + $"/api/rides/presets/{ownerPresetId}" + ); + deleteRequest.Headers.Add("X-User-Id", attackerRiderId.ToString()); + + var response = await host.Client.SendAsync(deleteRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var preset = await host.GetRidePresetAsync(ownerPresetId); + Assert.NotNull(preset); + Assert.Equal(ownerRiderId, preset.RiderId); + } + private sealed class SqliteRidesApiHost(WebApplication app, string databasePath) : IAsyncDisposable { @@ -69,8 +303,7 @@ public static async Task StartAsync() builder.Services.AddAuthorization(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -152,6 +385,59 @@ public async Task RecordRideAsync( return ride.Id; } + public async Task CreateRidePresetAsync( + long riderId, + string name, + string exactStartTime, + int durationMinutes, + decimal miles = 7.5m + ) + { + await using var scope = App.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var preset = new RidePresetEntity + { + RiderId = riderId, + Name = name, + PrimaryDirection = "SW", + PeriodTag = name == "Afternoon" ? "afternoon" : "morning", + ExactStartTimeLocal = TimeOnly.ParseExact(exactStartTime, "HH:mm"), + DurationMinutes = durationMinutes, + Miles = miles, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + Version = 1, + }; + + dbContext.RidePresets.Add(preset); + await dbContext.SaveChangesAsync(); + return preset.RidePresetId; + } + + public async Task GetPresetCountForRiderAsync(long riderId) + { + await using var scope = App.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + return await dbContext.RidePresets.CountAsync(x => x.RiderId == riderId); + } + + public async Task GetRideCountForRiderAsync(long riderId) + { + await using var scope = App.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + return await dbContext.Rides.CountAsync(x => x.RiderId == riderId); + } + + public async Task GetRidePresetAsync(long presetId) + { + await using var scope = App.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + return await dbContext + .RidePresets.AsNoTracking() + .SingleOrDefaultAsync(x => x.RidePresetId == presetId); + } + public async ValueTask DisposeAsync() { Client.Dispose(); diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs index 1bdc3cd..2e37ef5 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs @@ -107,90 +107,6 @@ public async Task PostRecordRide_WithoutAuth_Returns401() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } - [Fact] - public async Task GetRideDefaults_WithoutPriorRides_ReturnsCurrentDateTime() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Eve"); - - var response = await host.Client.GetWithAuthAsync("/api/rides/defaults", userId); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.False(payload.HasPreviousRide); - Assert.Null(payload.DefaultMiles); - Assert.NotEqual(DateTime.MinValue, payload.DefaultRideDateTimeLocal); - } - - [Fact] - public async Task GetRideDefaults_WithPriorRides_ReturnsLastDefaults() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Frank"); - - // Record a ride - await host.RecordRideAsync( - userId, - miles: 10.5m, - rideMinutes: 45, - temperature: 72m, - gasPricePerGallon: 3.4999m - ); - - var response = await host.Client.GetWithAuthAsync("/api/rides/defaults", userId); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.True(payload.HasPreviousRide); - Assert.Equal(10.5m, payload.DefaultMiles); - Assert.Equal(45, payload.DefaultRideMinutes); - Assert.Equal(72m, payload.DefaultTemperature); - Assert.Equal(3.4999m, payload.DefaultGasPricePerGallon); - } - - [Fact] - public async Task GetRideDefaults_WithWeatherOnPreviousRide_ReturnsWeatherDefaults() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("WeatherDefaults"); - - using (var scope = host.App.Services.CreateScope()) - { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Rides.Add( - new RideEntity - { - RiderId = userId, - RideDateTimeLocal = DateTime.Now.AddHours(-3), - Miles = 6.6m, - RideMinutes = 24, - Temperature = 61m, - WindSpeedMph = 10.3m, - WindDirectionDeg = 255, - RelativeHumidityPercent = 71, - CloudCoverPercent = 48, - PrecipitationType = "snow", - WeatherUserOverridden = true, - CreatedAtUtc = DateTime.UtcNow, - } - ); - await dbContext.SaveChangesAsync(); - } - - var response = await host.Client.GetWithAuthAsync("/api/rides/defaults", userId); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.Equal(10.3m, payload.DefaultWindSpeedMph); - Assert.Equal(255, payload.DefaultWindDirectionDeg); - Assert.Equal(71, payload.DefaultRelativeHumidityPercent); - Assert.Equal(48, payload.DefaultCloudCoverPercent); - Assert.Equal("snow", payload.DefaultPrecipitationType); - } - [Fact] public async Task GetGasPrice_WithValidDate_ReturnsShape() { @@ -380,70 +296,145 @@ public async Task PostRecordRide_WithNullGasPrice_PersistsNull() } [Fact] - public async Task GetRideDefaults_WithoutAuth_Returns401() + public async Task RidePresetCrud_FullRoundTrip_IncludingExactTimeAndDelete() { await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("PresetCrud"); - var response = await host.Client.GetAsync("/api/rides/defaults"); + var createRequest = new UpsertRidePresetRequest( + Name: "Morning Commute", + PrimaryDirection: "SW", + PeriodTag: "morning", + ExactStartTimeLocal: "07:45", + DurationMinutes: 34, + Miles: 7.2m + ); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } + var createResponse = await host.Client.PostWithAuthAsync( + "/api/rides/presets", + createRequest, + userId + ); - [Fact] - public async Task GetQuickRideOptions_WithAuth_Returns200() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("QuickOptionsUser"); - await host.RecordRideAsync(userId, miles: 11.5m, rideMinutes: 39, temperature: 65m); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); - var response = await host.Client.GetWithAuthAsync("/api/rides/quick-options", userId); + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal("07:45", created.ExactStartTimeLocal); + Assert.Equal(7.2m, created.Miles); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.NotNull(payload.Options); - Assert.NotEmpty(payload.Options); + var listResponse = await host.Client.GetWithAuthAsync("/api/rides/presets", userId); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + + var listed = await listResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(listed); + var existing = Assert.Single(listed.Presets); + Assert.Equal(created.PresetId, existing.PresetId); + + var updateRequest = new UpsertRidePresetRequest( + Name: "Morning Commute", + PrimaryDirection: "NE", + PeriodTag: "morning", + ExactStartTimeLocal: "08:05", + DurationMinutes: 40, + Miles: 8.1m + ); + + var updateResponse = await host.Client.PutWithAuthAsync( + $"/api/rides/presets/{created.PresetId}", + updateRequest, + userId + ); + + Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode); + var updated = await updateResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(updated); + Assert.Equal("08:05", updated.ExactStartTimeLocal); + Assert.Equal("NE", updated.PrimaryDirection); + Assert.Equal(8.1m, updated.Miles); + + var deleteRequest = new HttpRequestMessage( + HttpMethod.Delete, + $"/api/rides/presets/{created.PresetId}" + ); + deleteRequest.Headers.Add("X-User-Id", userId.ToString()); + var deleteResponse = await host.Client.SendAsync(deleteRequest); + + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); } [Fact] - public async Task GetQuickRideOptions_WithoutAuth_Returns401() + public async Task CreateRidePreset_DuplicateNameForSameRider_Returns400() { await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("PresetDuplicate"); - var response = await host.Client.GetAsync("/api/rides/quick-options"); + var request = new UpsertRidePresetRequest( + Name: "Afternoon Return", + PrimaryDirection: "NE", + PeriodTag: "afternoon", + ExactStartTimeLocal: "17:35", + DurationMinutes: 32, + Miles: 6.8m + ); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var first = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var second = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); } [Fact] - public async Task GetQuickRideOptions_ExcludesRidesWithoutRideMinutes() + public async Task CreateRidePreset_MorningWithOverrideDirection_PersistsOverrideNotDefault() { await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("QuickOptionsIncomplete"); - await host.RecordRideAsync(userId, miles: 11.5m, rideMinutes: 39, temperature: 65m); - await host.RecordRideAsync(userId, miles: 7.25m, rideMinutes: null, temperature: 55m); + var userId = await host.SeedUserAsync("PresetDirectionOverride"); - var response = await host.Client.GetWithAuthAsync("/api/rides/quick-options", userId); + // Morning default would be SW, but rider overrides to North + var request = new UpsertRidePresetRequest( + Name: "Custom Morning", + PrimaryDirection: "North", + PeriodTag: "morning", + ExactStartTimeLocal: "07:30", + DurationMinutes: 35, + Miles: 9.3m + ); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.All(payload.Options, option => Assert.True(option.RideMinutes > 0)); - Assert.DoesNotContain(payload.Options, option => option.Miles == 7.25m); + var response = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var created = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal("North", created.PrimaryDirection); + Assert.Equal("morning", created.PeriodTag); + Assert.Equal(9.3m, created.Miles); } [Fact] - public async Task GetQuickRideOptions_WithoutEligibleRides_ReturnsEmptyOptionsArray() + public async Task CreateRidePreset_AfternoonWithOverrideDirection_PersistsOverrideNotDefault() { await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("QuickOptionsEmpty"); + var userId = await host.SeedUserAsync("PresetDirectionOverrideAft"); + + // Afternoon default would be NE, but rider overrides to South + var request = new UpsertRidePresetRequest( + Name: "Custom Afternoon", + PrimaryDirection: "South", + PeriodTag: "afternoon", + ExactStartTimeLocal: "17:15", + DurationMinutes: 30, + Miles: 8.7m + ); - var response = await host.Client.GetWithAuthAsync("/api/rides/quick-options", userId); + var response = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.Empty(payload.Options); + var created = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal("South", created.PrimaryDirection); + Assert.Equal("afternoon", created.PeriodTag); + Assert.Equal(8.7m, created.Miles); } // History endpoint tests @@ -784,8 +775,7 @@ public static async Task StartAsync() // Add Rides services builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs index b7794d1..2219a87 100644 --- a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs +++ b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs @@ -44,6 +44,10 @@ public sealed class MigrationTestCoveragePolicyTests "Added test: RidesPersistenceTests validates Difficulty, PrimaryTravelDirection, WindResistanceRating columns; RecordRideWithDifficultyTests and EditRideWithDifficultyTests cover end-to-end wind resistance calculation and persistence.", ["20260424203433_AddImportRowDifficultyDirection"] = "Added test: CsvImportDifficultyTests validates Difficulty and Direction column parsing, validation rules, and storage in ImportRowEntity after schema migration.", + ["20260429180854_AddRidePresets"] = + "Added test: RidesEndpointsSqliteIntegrationTests validates RidePresets table including unique rider-scoped names, exact start time parsing, rider isolation, MRU ordering, and preset ownership enforcement.", + ["20260520150127_AddRidePresetMiles"] = + "Updated test: RidePreset CRUD endpoint coverage validates Miles round-trip and persistence on create and update after schema migration.", }; [Fact] diff --git a/src/BikeTracking.Api/Application/Rides/GetQuickRideOptionsService.cs b/src/BikeTracking.Api/Application/Rides/GetQuickRideOptionsService.cs deleted file mode 100644 index 4e4d2ae..0000000 --- a/src/BikeTracking.Api/Application/Rides/GetQuickRideOptionsService.cs +++ /dev/null @@ -1,42 +0,0 @@ -using BikeTracking.Api.Contracts; -using BikeTracking.Api.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace BikeTracking.Api.Application.Rides; - -public sealed class GetQuickRideOptionsService(BikeTrackingDbContext dbContext) -{ - /// - /// Returns quick ride options for the authenticated rider. - /// Full deduplication/ordering rules are implemented in user-story phases. - /// - public async Task ExecuteAsync( - long riderId, - CancellationToken cancellationToken = default - ) - { - var rides = await dbContext - .Rides.Where(r => r.RiderId == riderId && r.RideMinutes.HasValue) - .AsNoTracking() - .Select(r => new - { - r.Miles, - RideMinutes = r.RideMinutes!.Value, - r.RideDateTimeLocal, - }) - .ToListAsync(cancellationToken); - - var options = rides - .GroupBy(ride => new { ride.Miles, ride.RideMinutes }) - .Select(group => new QuickRideOption( - group.Key.Miles, - group.Key.RideMinutes, - group.Max(ride => ride.RideDateTimeLocal) - )) - .OrderByDescending(option => option.LastUsedAtLocal) - .Take(5) - .ToList(); - - return new QuickRideOptionsResponse(options, DateTime.UtcNow); - } -} diff --git a/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs b/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs deleted file mode 100644 index ee7c304..0000000 --- a/src/BikeTracking.Api/Application/Rides/GetRideDefaultsService.cs +++ /dev/null @@ -1,49 +0,0 @@ -using BikeTracking.Api.Contracts; -using BikeTracking.Api.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace BikeTracking.Api.Application.Rides; - -public class GetRideDefaultsService(BikeTrackingDbContext dbContext) -{ - private readonly BikeTrackingDbContext _dbContext = dbContext; - - /// - /// Gets ride defaults for a rider by looking up the most recent ride. - /// - public async Task ExecuteAsync( - long riderId, - CancellationToken cancellationToken = default - ) - { - // Query latest ride for this rider - var lastRide = await _dbContext - .Rides.Where(r => r.RiderId == riderId) - .OrderByDescending(r => r.CreatedAtUtc) - .FirstOrDefaultAsync(cancellationToken); - - if (lastRide == null) - { - // No prior rides - return defaults with current time - return new RideDefaultsResponse( - HasPreviousRide: false, - DefaultRideDateTimeLocal: DateTime.Now - ); - } - - // Has prior rides - return last ride values - return new RideDefaultsResponse( - HasPreviousRide: true, - DefaultRideDateTimeLocal: DateTime.Now, - DefaultMiles: lastRide.Miles, - DefaultRideMinutes: lastRide.RideMinutes, - DefaultTemperature: lastRide.Temperature, - DefaultGasPricePerGallon: lastRide.GasPricePerGallon, - DefaultWindSpeedMph: lastRide.WindSpeedMph, - DefaultWindDirectionDeg: lastRide.WindDirectionDeg, - DefaultRelativeHumidityPercent: lastRide.RelativeHumidityPercent, - DefaultCloudCoverPercent: lastRide.CloudCoverPercent, - DefaultPrecipitationType: lastRide.PrecipitationType - ); - } -} diff --git a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs index f61e143..7c49a41 100644 --- a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs @@ -43,7 +43,10 @@ ILogger logger throw new ArgumentException("Note must be 500 characters or fewer", nameof(request)); } - if (request.Difficulty.HasValue && (request.Difficulty.Value < 1 || request.Difficulty.Value > 5)) + if ( + request.Difficulty.HasValue + && (request.Difficulty.Value < 1 || request.Difficulty.Value > 5) + ) { throw new ArgumentException("Difficulty must be between 1 and 5", nameof(request)); } @@ -125,6 +128,23 @@ ILogger logger } } + // Validate preset ownership before saving ride + if (request.SelectedPresetId.HasValue) + { + var presetExists = await dbContext.RidePresets.AnyAsync( + x => x.RidePresetId == request.SelectedPresetId.Value && x.RiderId == riderId, + cancellationToken + ); + + if (!presetExists) + { + throw new ArgumentException( + "Selected preset does not belong to this rider.", + nameof(request) + ); + } + } + var rideEntity = new RideEntity { RiderId = riderId, @@ -153,6 +173,21 @@ ILogger logger dbContext.Rides.Add(rideEntity); await dbContext.SaveChangesAsync(cancellationToken); + // Update preset MRU after successful ride save + if (request.SelectedPresetId.HasValue) + { + var preset = await dbContext.RidePresets.SingleOrDefaultAsync( + x => x.RidePresetId == request.SelectedPresetId.Value, + cancellationToken + ); + + if (preset is not null) + { + preset.LastUsedAtUtc = DateTime.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + } + } + var eventPayload = RideRecordedEventPayload.Create( riderId: riderId, rideDateTimeLocal: request.RideDateTimeLocal, diff --git a/src/BikeTracking.Api/Application/Rides/RidePresetService.cs b/src/BikeTracking.Api/Application/Rides/RidePresetService.cs new file mode 100644 index 0000000..f5aed18 --- /dev/null +++ b/src/BikeTracking.Api/Application/Rides/RidePresetService.cs @@ -0,0 +1,214 @@ +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Application.Rides; + +public interface IRidePresetService +{ + Task ListAsync( + long riderId, + CancellationToken cancellationToken = default + ); + + Task CreateAsync( + long riderId, + UpsertRidePresetRequest request, + CancellationToken cancellationToken = default + ); + + Task UpdateAsync( + long riderId, + long presetId, + UpsertRidePresetRequest request, + CancellationToken cancellationToken = default + ); + + Task DeleteAsync( + long riderId, + long presetId, + CancellationToken cancellationToken = default + ); +} + +public sealed class RidePresetService(BikeTrackingDbContext dbContext) : IRidePresetService +{ + private readonly BikeTrackingDbContext _dbContext = dbContext; + + public async Task ListAsync( + long riderId, + CancellationToken cancellationToken = default + ) + { + var presets = await _dbContext + .RidePresets.AsNoTracking() + .Where(x => x.RiderId == riderId) + .OrderByDescending(x => x.LastUsedAtUtc) + .ThenByDescending(x => x.UpdatedAtUtc) + .Select(x => ToDto(x)) + .ToListAsync(cancellationToken); + + return new RidePresetsResponse(presets, DateTime.UtcNow); + } + + public async Task CreateAsync( + long riderId, + UpsertRidePresetRequest request, + CancellationToken cancellationToken = default + ) + { + if (!TimeOnly.TryParseExact(request.ExactStartTimeLocal, "HH:mm", out var exactTime)) + { + return RidePresetResult.Failure( + "VALIDATION_FAILED", + "Exact start time must be in HH:mm format." + ); + } + + var exists = await _dbContext.RidePresets.AnyAsync( + x => x.RiderId == riderId && x.Name == request.Name, + cancellationToken + ); + + if (exists) + { + return RidePresetResult.Failure( + "VALIDATION_FAILED", + "A preset with this name already exists for this rider." + ); + } + + var now = DateTime.UtcNow; + var entity = new RidePresetEntity + { + RiderId = riderId, + Name = request.Name, + PrimaryDirection = request.PrimaryDirection, + PeriodTag = request.PeriodTag, + ExactStartTimeLocal = exactTime, + DurationMinutes = request.DurationMinutes, + Miles = request.Miles, + LastUsedAtUtc = null, + CreatedAtUtc = now, + UpdatedAtUtc = now, + Version = 1, + }; + + _dbContext.RidePresets.Add(entity); + await _dbContext.SaveChangesAsync(cancellationToken); + + return RidePresetResult.Success(ToDto(entity)); + } + + public async Task UpdateAsync( + long riderId, + long presetId, + UpsertRidePresetRequest request, + CancellationToken cancellationToken = default + ) + { + if (!TimeOnly.TryParseExact(request.ExactStartTimeLocal, "HH:mm", out var exactTime)) + { + return RidePresetResult.Failure( + "VALIDATION_FAILED", + "Exact start time must be in HH:mm format." + ); + } + + var existing = await _dbContext.RidePresets.SingleOrDefaultAsync( + x => x.RidePresetId == presetId && x.RiderId == riderId, + cancellationToken + ); + + if (existing is null) + { + return RidePresetResult.Failure("PRESET_NOT_FOUND", "Ride preset was not found."); + } + + var duplicateName = await _dbContext.RidePresets.AnyAsync( + x => x.RiderId == riderId && x.RidePresetId != presetId && x.Name == request.Name, + cancellationToken + ); + + if (duplicateName) + { + return RidePresetResult.Failure( + "VALIDATION_FAILED", + "A preset with this name already exists for this rider." + ); + } + + existing.Name = request.Name; + existing.PrimaryDirection = request.PrimaryDirection; + existing.PeriodTag = request.PeriodTag; + existing.ExactStartTimeLocal = exactTime; + existing.DurationMinutes = request.DurationMinutes; + existing.Miles = request.Miles; + existing.UpdatedAtUtc = DateTime.UtcNow; + + await _dbContext.SaveChangesAsync(cancellationToken); + + return RidePresetResult.Success(ToDto(existing)); + } + + public async Task DeleteAsync( + long riderId, + long presetId, + CancellationToken cancellationToken = default + ) + { + var existing = await _dbContext.RidePresets.SingleOrDefaultAsync( + x => x.RidePresetId == presetId && x.RiderId == riderId, + cancellationToken + ); + + if (existing is null) + { + return RidePresetDeleteResult.Failure("PRESET_NOT_FOUND", "Ride preset was not found."); + } + + _dbContext.RidePresets.Remove(existing); + await _dbContext.SaveChangesAsync(cancellationToken); + + return RidePresetDeleteResult.Success( + new DeleteRidePresetResponse(existing.RidePresetId, DateTime.UtcNow, "Preset deleted") + ); + } + + private static RidePresetDto ToDto(RidePresetEntity entity) + { + return new RidePresetDto( + PresetId: entity.RidePresetId, + Name: entity.Name, + PrimaryDirection: entity.PrimaryDirection, + PeriodTag: entity.PeriodTag, + ExactStartTimeLocal: entity.ExactStartTimeLocal.ToString("HH:mm"), + DurationMinutes: entity.DurationMinutes, + Miles: entity.Miles, + LastUsedAtUtc: entity.LastUsedAtUtc, + UpdatedAtUtc: entity.UpdatedAtUtc + ); + } +} + +public sealed record RidePresetResult(bool IsSuccess, RidePresetDto? Preset, ErrorResponse? Error) +{ + public static RidePresetResult Success(RidePresetDto preset) => new(true, preset, null); + + public static RidePresetResult Failure(string code, string message) => + new(false, null, new ErrorResponse(code, message)); +} + +public sealed record RidePresetDeleteResult( + bool IsSuccess, + DeleteRidePresetResponse? Response, + ErrorResponse? Error +) +{ + public static RidePresetDeleteResult Success(DeleteRidePresetResponse response) => + new(true, response, null); + + public static RidePresetDeleteResult Failure(string code, string message) => + new(false, null, new ErrorResponse(code, message)); +} diff --git a/src/BikeTracking.Api/BikeTracking.Api.http b/src/BikeTracking.Api/BikeTracking.Api.http index 302a92b..9ea32ed 100644 --- a/src/BikeTracking.Api/BikeTracking.Api.http +++ b/src/BikeTracking.Api/BikeTracking.Api.http @@ -36,21 +36,11 @@ X-User-Id: {{RiderId}} "weatherUserOverridden": true } -### Get ride defaults -GET {{ApiService_HostAddress}}/api/rides/defaults -Accept: application/json -X-User-Id: {{RiderId}} - ### Load weather for create/edit form GET {{ApiService_HostAddress}}/api/rides/weather?rideDateTimeLocal=2026-03-26T07:30:00 Accept: application/json X-User-Id: {{RiderId}} -### Get quick ride options -GET {{ApiService_HostAddress}}/api/rides/quick-options -Accept: application/json -X-User-Id: {{RiderId}} - ### Get unfiltered ride history GET {{ApiService_HostAddress}}/api/rides/history?page=1&pageSize=25 Accept: application/json diff --git a/src/BikeTracking.Api/Contracts/RidesContracts.cs b/src/BikeTracking.Api/Contracts/RidesContracts.cs index 930e0b0..71cb036 100644 --- a/src/BikeTracking.Api/Contracts/RidesContracts.cs +++ b/src/BikeTracking.Api/Contracts/RidesContracts.cs @@ -31,8 +31,13 @@ public sealed record RecordRideRequest( bool WeatherUserOverridden = false, [property: Range(1, 5, ErrorMessage = "Difficulty must be between 1 and 5")] int? Difficulty = null, - [property: MaxLength(5, ErrorMessage = "Primary travel direction must be 5 characters or fewer")] - string? PrimaryTravelDirection = null + [property: MaxLength( + 5, + ErrorMessage = "Primary travel direction must be 5 characters or fewer" + )] + string? PrimaryTravelDirection = null, + [property: Range(1, long.MaxValue, ErrorMessage = "Selected preset id must be greater than 0")] + long? SelectedPresetId = null ); public sealed record RecordRideSuccessResponse( @@ -42,20 +47,6 @@ public sealed record RecordRideSuccessResponse( string EventStatus ); -public sealed record RideDefaultsResponse( - bool HasPreviousRide, - DateTime DefaultRideDateTimeLocal, - decimal? DefaultMiles = null, - int? DefaultRideMinutes = null, - decimal? DefaultTemperature = null, - decimal? DefaultGasPricePerGallon = null, - decimal? DefaultWindSpeedMph = null, - int? DefaultWindDirectionDeg = null, - int? DefaultRelativeHumidityPercent = null, - int? DefaultCloudCoverPercent = null, - string? DefaultPrecipitationType = null -); - public sealed record GasPriceResponse( string Date, decimal? PricePerGallon, @@ -74,13 +65,59 @@ public sealed record RideWeatherResponse( bool IsAvailable ); -public sealed record QuickRideOption(decimal Miles, int RideMinutes, DateTime LastUsedAtLocal); +public sealed record RidePresetDto( + long PresetId, + string Name, + string PrimaryDirection, + string PeriodTag, + string ExactStartTimeLocal, + int DurationMinutes, + decimal Miles, + DateTime? LastUsedAtUtc, + DateTime UpdatedAtUtc +); -public sealed record QuickRideOptionsResponse( - IReadOnlyList Options, +public sealed record RidePresetsResponse( + IReadOnlyList Presets, DateTime GeneratedAtUtc ); +public sealed record UpsertRidePresetRequest( + [property: Required(ErrorMessage = "Preset name is required")] + [property: StringLength( + 80, + MinimumLength = 1, + ErrorMessage = "Preset name must be between 1 and 80 characters" + )] + string Name, + [property: Required(ErrorMessage = "Primary direction is required")] + [property: MaxLength(5, ErrorMessage = "Primary direction must be 5 characters or fewer")] + string PrimaryDirection, + [property: Required(ErrorMessage = "Period tag is required")] + [property: RegularExpression( + "^(morning|afternoon)$", + ErrorMessage = "Period tag must be 'morning' or 'afternoon'" + )] + string PeriodTag, + [property: Required(ErrorMessage = "Exact start time is required")] + [property: RegularExpression( + "^([01]\\d|2[0-3]):[0-5]\\d$", + ErrorMessage = "Exact start time must be in HH:mm format" + )] + string ExactStartTimeLocal, + [property: Range(1, 1440, ErrorMessage = "Duration minutes must be between 1 and 1440")] + int DurationMinutes, + [property: Required(ErrorMessage = "Miles is required")] + [property: Range( + 0.01, + 200, + ErrorMessage = "Miles must be greater than 0 and less than or equal to 200" + )] + decimal Miles +); + +public sealed record DeleteRidePresetResponse(long PresetId, DateTime DeletedAtUtc, string Message); + public sealed record EditRideRequest( [property: Required(ErrorMessage = "Ride date/time is required")] DateTime RideDateTimeLocal, [property: Required(ErrorMessage = "Miles is required")] @@ -116,7 +153,10 @@ public sealed record EditRideRequest( bool WeatherUserOverridden = false, [property: Range(1, 5, ErrorMessage = "Difficulty must be between 1 and 5")] int? Difficulty = null, - [property: MaxLength(5, ErrorMessage = "Primary travel direction must be 5 characters or fewer")] + [property: MaxLength( + 5, + ErrorMessage = "Primary travel direction must be 5 characters or fewer" + )] string? PrimaryTravelDirection = null ); diff --git a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs index 0f88935..f510e9a 100644 --- a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs +++ b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs @@ -24,14 +24,6 @@ public static IEndpointRouteBuilder MapRidesEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status401Unauthorized) .RequireAuthorization(); - group - .MapGet("/defaults", GetRideDefaults) - .WithName("GetRideDefaults") - .WithSummary("Get record-ride form defaults") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status401Unauthorized) - .RequireAuthorization(); - group .MapGet("/gas-price", GetGasPrice) .WithName("GetGasPrice") @@ -51,11 +43,39 @@ public static IEndpointRouteBuilder MapRidesEndpoints(this IEndpointRouteBuilder .RequireAuthorization(); group - .MapGet("/quick-options", GetQuickRideOptions) - .WithName("GetQuickRideOptions") - .WithSummary("Get quick ride options for the authenticated rider") - .Produces(StatusCodes.Status200OK) + .MapGet("/presets", GetRidePresets) + .WithName("GetRidePresets") + .WithSummary("Get ride presets for the authenticated rider") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .RequireAuthorization(); + + group + .MapPost("/presets", PostRidePreset) + .WithName("CreateRidePreset") + .WithSummary("Create a new ride preset for the authenticated rider") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .RequireAuthorization(); + + group + .MapPut("/presets/{presetId:long}", PutRidePreset) + .WithName("UpdateRidePreset") + .WithSummary("Update a ride preset for the authenticated rider") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); + + group + .MapDelete("/presets/{presetId:long}", DeleteRidePreset) + .WithName("DeleteRidePreset") + .WithSummary("Delete a ride preset for the authenticated rider") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(); group @@ -101,6 +121,165 @@ public static IEndpointRouteBuilder MapRidesEndpoints(this IEndpointRouteBuilder return endpoints; } + private static async Task GetRidePresets( + HttpContext context, + [FromServices] IRidePresetService ridePresetService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + return Results.Unauthorized(); + + var response = await ridePresetService.ListAsync(riderId, cancellationToken); + return Results.Ok(response); + } + + private static async Task PostRidePreset( + HttpContext context, + [FromBody] UpsertRidePresetRequest request, + [FromServices] IRidePresetService ridePresetService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + return Results.Unauthorized(); + + var normalizedRequest = NormalizePresetRequest(request); + var validation = ValidatePresetRequest(normalizedRequest); + if (validation is not null) + return Results.BadRequest(validation); + + var result = await ridePresetService.CreateAsync( + riderId, + normalizedRequest, + cancellationToken + ); + if (result.IsSuccess && result.Preset is not null) + { + return Results.Created($"/api/rides/presets/{result.Preset.PresetId}", result.Preset); + } + + return Results.BadRequest( + result.Error ?? new ErrorResponse("ERROR", "Failed to create preset.") + ); + } + + private static async Task PutRidePreset( + [FromRoute] long presetId, + HttpContext context, + [FromBody] UpsertRidePresetRequest request, + [FromServices] IRidePresetService ridePresetService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + return Results.Unauthorized(); + + var normalizedRequest = NormalizePresetRequest(request); + var validation = ValidatePresetRequest(normalizedRequest); + if (validation is not null) + return Results.BadRequest(validation); + + var result = await ridePresetService.UpdateAsync( + riderId, + presetId, + normalizedRequest, + cancellationToken + ); + + if (result.IsSuccess && result.Preset is not null) + { + return Results.Ok(result.Preset); + } + + var error = result.Error ?? new ErrorResponse("ERROR", "Failed to update preset."); + return error.Code == "PRESET_NOT_FOUND" + ? Results.NotFound(error) + : Results.BadRequest(error); + } + + private static async Task DeleteRidePreset( + [FromRoute] long presetId, + HttpContext context, + [FromServices] IRidePresetService ridePresetService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + return Results.Unauthorized(); + + var result = await ridePresetService.DeleteAsync(riderId, presetId, cancellationToken); + if (result.IsSuccess && result.Response is not null) + { + return Results.Ok(result.Response); + } + + var error = result.Error ?? new ErrorResponse("ERROR", "Failed to delete preset."); + return error.Code == "PRESET_NOT_FOUND" + ? Results.NotFound(error) + : Results.BadRequest(error); + } + + private static UpsertRidePresetRequest NormalizePresetRequest(UpsertRidePresetRequest request) + { + return request with + { + Name = request.Name.Trim(), + PrimaryDirection = request.PrimaryDirection.Trim(), + PeriodTag = request.PeriodTag.Trim().ToLowerInvariant(), + ExactStartTimeLocal = request.ExactStartTimeLocal.Trim(), + }; + } + + private static ErrorResponse? ValidatePresetRequest(UpsertRidePresetRequest request) + { + if (request.Name.Length is < 1 or > 80) + { + return new ErrorResponse( + "VALIDATION_FAILED", + "Preset name must be between 1 and 80 characters." + ); + } + + if (request.PeriodTag is not ("morning" or "afternoon")) + { + return new ErrorResponse( + "VALIDATION_FAILED", + "Period tag must be 'morning' or 'afternoon'." + ); + } + + if (!TimeOnly.TryParseExact(request.ExactStartTimeLocal, "HH:mm", out _)) + { + return new ErrorResponse( + "VALIDATION_FAILED", + "Exact start time must be in HH:mm format." + ); + } + + if (request.DurationMinutes is < 1 or > 1440) + { + return new ErrorResponse( + "VALIDATION_FAILED", + "Duration minutes must be between 1 and 1440." + ); + } + + if (request.Miles is < 0.01m or > 200m) + { + return new ErrorResponse( + "VALIDATION_FAILED", + "Miles must be greater than 0 and less than or equal to 200." + ); + } + + return null; + } + private static async Task PostRecordRide( [FromBody] RecordRideRequest request, HttpContext context, @@ -142,29 +321,6 @@ CancellationToken cancellationToken } } - private static async Task GetRideDefaults( - HttpContext context, - GetRideDefaultsService getDefaultsService, - CancellationToken cancellationToken - ) - { - try - { - var userIdString = context.User.FindFirst("sub")?.Value; - if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) - return Results.Unauthorized(); - - var defaults = await getDefaultsService.ExecuteAsync(riderId, cancellationToken); - return Results.Ok(defaults); - } - catch - { - return Results.BadRequest( - new ErrorResponse("ERROR", "An error occurred while retrieving defaults") - ); - } - } - private static async Task GetGasPrice( HttpContext context, [FromQuery] string? date, @@ -347,29 +503,6 @@ private static async Task GetRideHistory( } } - private static async Task GetQuickRideOptions( - HttpContext context, - GetQuickRideOptionsService quickRideOptionsService, - CancellationToken cancellationToken - ) - { - try - { - var userIdString = context.User.FindFirst("sub")?.Value; - if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) - return Results.Unauthorized(); - - var response = await quickRideOptionsService.ExecuteAsync(riderId, cancellationToken); - return Results.Ok(response); - } - catch - { - return Results.BadRequest( - new ErrorResponse("ERROR", "An error occurred while retrieving quick ride options") - ); - } - } - private static async Task PutEditRide( [FromRoute] long rideId, [FromBody] EditRideRequest request, diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs index 62a56e2..5c17b7a 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs @@ -19,6 +19,7 @@ public sealed class BikeTrackingDbContext(DbContextOptions GasPriceLookups => Set(); public DbSet WeatherLookups => Set(); public DbSet UserSettings => Set(); + public DbSet RidePresets => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -468,6 +469,66 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(static x => x.UserId) .OnDelete(DeleteBehavior.Cascade); }); + + modelBuilder.Entity(static entity => + { + entity.ToTable( + "RidePresets", + static tableBuilder => + { + tableBuilder.HasCheckConstraint( + "CK_RidePresets_DurationMinutes_Positive", + "\"DurationMinutes\" > 0" + ); + tableBuilder.HasCheckConstraint( + "CK_RidePresets_Miles_Positive", + "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200" + ); + tableBuilder.HasCheckConstraint( + "CK_RidePresets_PeriodTag_Values", + "\"PeriodTag\" IN ('morning', 'afternoon')" + ); + } + ); + + entity.HasKey(static x => x.RidePresetId); + entity.Property(static x => x.RiderId).IsRequired(); + entity.Property(static x => x.Name).IsRequired().HasMaxLength(80); + entity.Property(static x => x.PrimaryDirection).IsRequired().HasMaxLength(5); + entity.Property(static x => x.PeriodTag).IsRequired().HasMaxLength(20); + entity.Property(static x => x.ExactStartTimeLocal).IsRequired(); + entity.Property(static x => x.DurationMinutes).IsRequired(); + entity.Property(static x => x.Miles).IsRequired().HasPrecision(10, 2); + entity.Property(static x => x.LastUsedAtUtc); + entity.Property(static x => x.CreatedAtUtc).IsRequired(); + entity.Property(static x => x.UpdatedAtUtc).IsRequired(); + entity + .Property(static x => x.Version) + .IsRequired() + .HasDefaultValue(1) + .IsConcurrencyToken(); + + entity + .HasIndex(static x => new { x.RiderId, x.Name }) + .IsUnique() + .HasDatabaseName("IX_RidePresets_RiderId_Name"); + + entity + .HasIndex(static x => new + { + x.RiderId, + x.LastUsedAtUtc, + x.UpdatedAtUtc, + }) + .IsDescending(false, true, true) + .HasDatabaseName("IX_RidePresets_RiderId_LastUsedAtUtc_UpdatedAtUtc_Desc"); + + entity + .HasOne() + .WithMany() + .HasForeignKey(static x => x.RiderId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RidePresetEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RidePresetEntity.cs new file mode 100644 index 0000000..3aa3807 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RidePresetEntity.cs @@ -0,0 +1,28 @@ +namespace BikeTracking.Api.Infrastructure.Persistence.Entities; + +public sealed class RidePresetEntity +{ + public long RidePresetId { get; set; } + + public long RiderId { get; set; } + + public required string Name { get; set; } + + public required string PrimaryDirection { get; set; } + + public required string PeriodTag { get; set; } + + public TimeOnly ExactStartTimeLocal { get; set; } + + public int DurationMinutes { get; set; } + + public decimal Miles { get; set; } + + public DateTime? LastUsedAtUtc { get; set; } + + public DateTime CreatedAtUtc { get; set; } + + public DateTime UpdatedAtUtc { get; set; } + + public int Version { get; set; } = 1; +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260429180854_AddRidePresets.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260429180854_AddRidePresets.Designer.cs new file mode 100644 index 0000000..a3b51c1 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260429180854_AddRidePresets.Designer.cs @@ -0,0 +1,968 @@ +// +using System; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(BikeTrackingDbContext))] + [Migration("20260429180854_AddRidePresets")] + partial class AddRidePresets + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("ConsecutiveWrongCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DelayUntilUtc") + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulAuthUtc") + .HasColumnType("TEXT"); + + b.Property("LastWrongAttemptUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("AuthAttemptStates", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpenseDate") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ReceiptPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "ExpenseDate") + .IsDescending(false, true) + .HasDatabaseName("IX_Expenses_RiderId_ExpenseDate_Desc"); + + b.HasIndex("RiderId", "IsDeleted") + .HasDatabaseName("IX_Expenses_RiderId_IsDeleted"); + + b.ToTable("Expenses", null, t => + { + t.HasCheckConstraint("CK_Expenses_Amount_Positive", "CAST(\"Amount\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ImportedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("InvalidRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("OverrideAllDuplicates") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SkippedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TotalRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ValidRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("RiderId") + .HasDatabaseName("IX_ExpenseImportJobs_RiderId"); + + b.ToTable("ExpenseImportJobs", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("CreatedExpenseId") + .HasColumnType("INTEGER"); + + b.Property("DuplicateResolution") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("DuplicateStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("ExistingExpenseIdsJson") + .HasColumnType("TEXT"); + + b.Property("ExpenseDateLocal") + .HasColumnType("TEXT"); + + b.Property("ImportJobId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("RowNumber") + .HasColumnType("INTEGER"); + + b.Property("ValidationErrorsJson") + .HasColumnType("TEXT"); + + b.Property("ValidationStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ImportJobId") + .HasDatabaseName("IX_ExpenseImportRows_ImportJobId"); + + b.HasIndex("ImportJobId", "RowNumber") + .IsUnique(); + + b.ToTable("ExpenseImportRows", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.GasPriceLookupEntity", b => + { + b.Property("GasPriceLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EiaPeriodDate") + .HasColumnType("TEXT"); + + b.Property("PriceDate") + .HasColumnType("TEXT"); + + b.Property("PricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("WeekStartDate") + .HasColumnType("TEXT"); + + b.HasKey("GasPriceLookupId"); + + b.HasIndex("PriceDate") + .IsUnique(); + + b.HasIndex("WeekStartDate") + .IsUnique(); + + b.ToTable("GasPriceLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("EtaMinutesRounded") + .HasColumnType("INTEGER"); + + b.Property("FailedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ImportedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OverrideAllDuplicates") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ProcessedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SkippedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TotalRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc"); + + b.ToTable("ImportJobs", null, t => + { + t.HasCheckConstraint("CK_ImportJobs_FailedRows_NonNegative", "\"FailedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_ImportedRows_NonNegative", "\"ImportedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_ProcessedRows_Lte_TotalRows", "\"ProcessedRows\" <= \"TotalRows\""); + + t.HasCheckConstraint("CK_ImportJobs_ProcessedRows_NonNegative", "\"ProcessedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_SkippedRows_NonNegative", "\"SkippedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_TotalRows_NonNegative", "\"TotalRows\" >= 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedRideId") + .HasColumnType("INTEGER"); + + b.Property("Difficulty") + .HasColumnType("INTEGER"); + + b.Property("DuplicateResolution") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("DuplicateStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("ExistingRideIdsJson") + .HasColumnType("TEXT"); + + b.Property("ImportJobId") + .HasColumnType("INTEGER"); + + b.Property("Miles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("PrimaryTravelDirection") + .HasColumnType("TEXT"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("RideDateLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RowNumber") + .HasColumnType("INTEGER"); + + b.Property("TagsRaw") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("ValidationErrorsJson") + .HasColumnType("TEXT"); + + b.Property("ValidationStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ImportJobId", "RowNumber") + .IsUnique(); + + b.ToTable("ImportRows", null, t => + { + t.HasCheckConstraint("CK_ImportRows_Miles_Range", "\"Miles\" IS NULL OR (CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200)"); + + t.HasCheckConstraint("CK_ImportRows_RideMinutes_Positive", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_ImportRows_RowNumber_Positive", "\"RowNumber\" > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Difficulty") + .HasColumnType("INTEGER"); + + b.Property("GasPricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PrimaryTravelDirection") + .HasMaxLength(5) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RideDateTimeLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SnapshotAverageCarMpg") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotMileageRateCents") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotOilChangePrice") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotYearlyGoalMiles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("WeatherUserOverridden") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindResistanceRating") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_Rides_RiderId_CreatedAtUtc_Desc"); + + b.ToTable("Rides", null, t => + { + t.HasCheckConstraint("CK_Rides_Difficulty", "Difficulty IS NULL OR (Difficulty >= 1 AND Difficulty <= 5)"); + + t.HasCheckConstraint("CK_Rides_Miles_GreaterThanZero", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); + + t.HasCheckConstraint("CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotAverageCarMpg_Positive", "\"SnapshotAverageCarMpg\" IS NULL OR CAST(\"SnapshotAverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotMileageRateCents_Positive", "\"SnapshotMileageRateCents\" IS NULL OR CAST(\"SnapshotMileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotOilChangePrice_Positive", "\"SnapshotOilChangePrice\" IS NULL OR CAST(\"SnapshotOilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotYearlyGoalMiles_Positive", "\"SnapshotYearlyGoalMiles\" IS NULL OR CAST(\"SnapshotYearlyGoalMiles\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_WindResistanceRating", "WindResistanceRating IS NULL OR (WindResistanceRating >= -4 AND WindResistanceRating <= 4)"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RidePresetEntity", b => + { + b.Property("RidePresetId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("ExactStartTimeLocal") + .HasColumnType("TEXT"); + + b.Property("LastUsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("PeriodTag") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PrimaryDirection") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("TEXT"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.HasKey("RidePresetId"); + + b.HasIndex("RiderId", "Name") + .IsUnique() + .HasDatabaseName("IX_RidePresets_RiderId_Name"); + + b.HasIndex("RiderId", "LastUsedAtUtc", "UpdatedAtUtc") + .IsDescending(false, true, true) + .HasDatabaseName("IX_RidePresets_RiderId_LastUsedAtUtc_UpdatedAtUtc_Desc"); + + b.ToTable("RidePresets", null, t => + { + t.HasCheckConstraint("CK_RidePresets_DurationMinutes_Positive", "\"DurationMinutes\" > 0"); + + t.HasCheckConstraint("CK_RidePresets_PeriodTag_Values", "\"PeriodTag\" IN ('morning', 'afternoon')"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("AverageCarMpg") + .HasColumnType("TEXT"); + + b.Property("DashboardGallonsAvoidedEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("DashboardGoalProgressEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasColumnType("TEXT"); + + b.Property("LocationLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("TEXT"); + + b.Property("MileageRateCents") + .HasColumnType("TEXT"); + + b.Property("OilChangePrice") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("YearlyGoalMiles") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserSettings", null, t => + { + t.HasCheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"); + + t.HasCheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"); + + t.HasCheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.WeatherLookupEntity", b => + { + b.Property("WeatherLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LatitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LongitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LookupHourUtc") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("WeatherLookupId"); + + b.HasIndex("LookupHourUtc", "LatitudeRounded", "LongitudeRounded") + .IsUnique(); + + b.ToTable("WeatherLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => + { + b.Property("OutboxEventId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AggregateId") + .HasColumnType("INTEGER"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EventPayloadJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("NextAttemptUtc") + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PublishedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("OutboxEventId"); + + b.HasIndex("AggregateType", "AggregateId"); + + b.HasIndex("PublishedAtUtc", "NextAttemptUtc"); + + b.ToTable("OutboxEvents", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.Property("UserCredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CredentialVersion") + .HasColumnType("INTEGER"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IterationCount") + .HasColumnType("INTEGER"); + + b.Property("PinHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PinSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("UserCredentialId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserCredentials", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("AuthAttemptState") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportRowEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", "ImportJob") + .WithMany("Rows") + .HasForeignKey("ImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ImportJob"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportRowEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", "ImportJob") + .WithMany("Rows") + .HasForeignKey("ImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ImportJob"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RidePresetEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("Credential") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Navigation("AuthAttemptState"); + + b.Navigation("Credential"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260429180854_AddRidePresets.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260429180854_AddRidePresets.cs new file mode 100644 index 0000000..8fd25cb --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260429180854_AddRidePresets.cs @@ -0,0 +1,78 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRidePresets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RidePresets", + columns: table => new + { + RidePresetId = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RiderId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 80, nullable: false), + PrimaryDirection = table.Column( + type: "TEXT", + maxLength: 5, + nullable: false + ), + PeriodTag = table.Column(type: "TEXT", maxLength: 20, nullable: false), + ExactStartTimeLocal = table.Column(type: "TEXT", nullable: false), + DurationMinutes = table.Column(type: "INTEGER", nullable: false), + LastUsedAtUtc = table.Column(type: "TEXT", nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: false), + Version = table.Column(type: "INTEGER", nullable: false, defaultValue: 1), + }, + constraints: table => + { + table.PrimaryKey("PK_RidePresets", x => x.RidePresetId); + table.CheckConstraint( + "CK_RidePresets_DurationMinutes_Positive", + "\"DurationMinutes\" > 0" + ); + table.CheckConstraint( + "CK_RidePresets_PeriodTag_Values", + "\"PeriodTag\" IN ('morning', 'afternoon')" + ); + table.ForeignKey( + name: "FK_RidePresets_Users_RiderId", + column: x => x.RiderId, + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_RidePresets_RiderId_LastUsedAtUtc_UpdatedAtUtc_Desc", + table: "RidePresets", + columns: new[] { "RiderId", "LastUsedAtUtc", "UpdatedAtUtc" }, + descending: new[] { false, true, true } + ); + + migrationBuilder.CreateIndex( + name: "IX_RidePresets_RiderId_Name", + table: "RidePresets", + columns: new[] { "RiderId", "Name" }, + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "RidePresets"); + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260520150127_AddRidePresetMiles.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260520150127_AddRidePresetMiles.Designer.cs new file mode 100644 index 0000000..643a58b --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260520150127_AddRidePresetMiles.Designer.cs @@ -0,0 +1,974 @@ +// +using System; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(BikeTrackingDbContext))] + [Migration("20260520150127_AddRidePresetMiles")] + partial class AddRidePresetMiles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("ConsecutiveWrongCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DelayUntilUtc") + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulAuthUtc") + .HasColumnType("TEXT"); + + b.Property("LastWrongAttemptUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("AuthAttemptStates", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpenseDate") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ReceiptPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "ExpenseDate") + .IsDescending(false, true) + .HasDatabaseName("IX_Expenses_RiderId_ExpenseDate_Desc"); + + b.HasIndex("RiderId", "IsDeleted") + .HasDatabaseName("IX_Expenses_RiderId_IsDeleted"); + + b.ToTable("Expenses", null, t => + { + t.HasCheckConstraint("CK_Expenses_Amount_Positive", "CAST(\"Amount\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ImportedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("InvalidRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("OverrideAllDuplicates") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SkippedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TotalRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ValidRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("RiderId") + .HasDatabaseName("IX_ExpenseImportJobs_RiderId"); + + b.ToTable("ExpenseImportJobs", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("CreatedExpenseId") + .HasColumnType("INTEGER"); + + b.Property("DuplicateResolution") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("DuplicateStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("ExistingExpenseIdsJson") + .HasColumnType("TEXT"); + + b.Property("ExpenseDateLocal") + .HasColumnType("TEXT"); + + b.Property("ImportJobId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("RowNumber") + .HasColumnType("INTEGER"); + + b.Property("ValidationErrorsJson") + .HasColumnType("TEXT"); + + b.Property("ValidationStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ImportJobId") + .HasDatabaseName("IX_ExpenseImportRows_ImportJobId"); + + b.HasIndex("ImportJobId", "RowNumber") + .IsUnique(); + + b.ToTable("ExpenseImportRows", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.GasPriceLookupEntity", b => + { + b.Property("GasPriceLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EiaPeriodDate") + .HasColumnType("TEXT"); + + b.Property("PriceDate") + .HasColumnType("TEXT"); + + b.Property("PricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("WeekStartDate") + .HasColumnType("TEXT"); + + b.HasKey("GasPriceLookupId"); + + b.HasIndex("PriceDate") + .IsUnique(); + + b.HasIndex("WeekStartDate") + .IsUnique(); + + b.ToTable("GasPriceLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("EtaMinutesRounded") + .HasColumnType("INTEGER"); + + b.Property("FailedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ImportedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OverrideAllDuplicates") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ProcessedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SkippedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TotalRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc"); + + b.ToTable("ImportJobs", null, t => + { + t.HasCheckConstraint("CK_ImportJobs_FailedRows_NonNegative", "\"FailedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_ImportedRows_NonNegative", "\"ImportedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_ProcessedRows_Lte_TotalRows", "\"ProcessedRows\" <= \"TotalRows\""); + + t.HasCheckConstraint("CK_ImportJobs_ProcessedRows_NonNegative", "\"ProcessedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_SkippedRows_NonNegative", "\"SkippedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_TotalRows_NonNegative", "\"TotalRows\" >= 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedRideId") + .HasColumnType("INTEGER"); + + b.Property("Difficulty") + .HasColumnType("INTEGER"); + + b.Property("DuplicateResolution") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("DuplicateStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("ExistingRideIdsJson") + .HasColumnType("TEXT"); + + b.Property("ImportJobId") + .HasColumnType("INTEGER"); + + b.Property("Miles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("PrimaryTravelDirection") + .HasColumnType("TEXT"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("RideDateLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RowNumber") + .HasColumnType("INTEGER"); + + b.Property("TagsRaw") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("ValidationErrorsJson") + .HasColumnType("TEXT"); + + b.Property("ValidationStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ImportJobId", "RowNumber") + .IsUnique(); + + b.ToTable("ImportRows", null, t => + { + t.HasCheckConstraint("CK_ImportRows_Miles_Range", "\"Miles\" IS NULL OR (CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200)"); + + t.HasCheckConstraint("CK_ImportRows_RideMinutes_Positive", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_ImportRows_RowNumber_Positive", "\"RowNumber\" > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Difficulty") + .HasColumnType("INTEGER"); + + b.Property("GasPricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PrimaryTravelDirection") + .HasMaxLength(5) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RideDateTimeLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SnapshotAverageCarMpg") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotMileageRateCents") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotOilChangePrice") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotYearlyGoalMiles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("WeatherUserOverridden") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindResistanceRating") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_Rides_RiderId_CreatedAtUtc_Desc"); + + b.ToTable("Rides", null, t => + { + t.HasCheckConstraint("CK_Rides_Difficulty", "Difficulty IS NULL OR (Difficulty >= 1 AND Difficulty <= 5)"); + + t.HasCheckConstraint("CK_Rides_Miles_GreaterThanZero", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); + + t.HasCheckConstraint("CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotAverageCarMpg_Positive", "\"SnapshotAverageCarMpg\" IS NULL OR CAST(\"SnapshotAverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotMileageRateCents_Positive", "\"SnapshotMileageRateCents\" IS NULL OR CAST(\"SnapshotMileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotOilChangePrice_Positive", "\"SnapshotOilChangePrice\" IS NULL OR CAST(\"SnapshotOilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotYearlyGoalMiles_Positive", "\"SnapshotYearlyGoalMiles\" IS NULL OR CAST(\"SnapshotYearlyGoalMiles\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_WindResistanceRating", "WindResistanceRating IS NULL OR (WindResistanceRating >= -4 AND WindResistanceRating <= 4)"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RidePresetEntity", b => + { + b.Property("RidePresetId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("ExactStartTimeLocal") + .HasColumnType("TEXT"); + + b.Property("LastUsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("PeriodTag") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PrimaryDirection") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("TEXT"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.HasKey("RidePresetId"); + + b.HasIndex("RiderId", "Name") + .IsUnique() + .HasDatabaseName("IX_RidePresets_RiderId_Name"); + + b.HasIndex("RiderId", "LastUsedAtUtc", "UpdatedAtUtc") + .IsDescending(false, true, true) + .HasDatabaseName("IX_RidePresets_RiderId_LastUsedAtUtc_UpdatedAtUtc_Desc"); + + b.ToTable("RidePresets", null, t => + { + t.HasCheckConstraint("CK_RidePresets_DurationMinutes_Positive", "\"DurationMinutes\" > 0"); + + t.HasCheckConstraint("CK_RidePresets_Miles_Positive", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); + + t.HasCheckConstraint("CK_RidePresets_PeriodTag_Values", "\"PeriodTag\" IN ('morning', 'afternoon')"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("AverageCarMpg") + .HasColumnType("TEXT"); + + b.Property("DashboardGallonsAvoidedEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("DashboardGoalProgressEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasColumnType("TEXT"); + + b.Property("LocationLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("TEXT"); + + b.Property("MileageRateCents") + .HasColumnType("TEXT"); + + b.Property("OilChangePrice") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("YearlyGoalMiles") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserSettings", null, t => + { + t.HasCheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"); + + t.HasCheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"); + + t.HasCheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.WeatherLookupEntity", b => + { + b.Property("WeatherLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LatitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LongitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LookupHourUtc") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("WeatherLookupId"); + + b.HasIndex("LookupHourUtc", "LatitudeRounded", "LongitudeRounded") + .IsUnique(); + + b.ToTable("WeatherLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => + { + b.Property("OutboxEventId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AggregateId") + .HasColumnType("INTEGER"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EventPayloadJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("NextAttemptUtc") + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PublishedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("OutboxEventId"); + + b.HasIndex("AggregateType", "AggregateId"); + + b.HasIndex("PublishedAtUtc", "NextAttemptUtc"); + + b.ToTable("OutboxEvents", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.Property("UserCredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CredentialVersion") + .HasColumnType("INTEGER"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IterationCount") + .HasColumnType("INTEGER"); + + b.Property("PinHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PinSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("UserCredentialId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserCredentials", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("AuthAttemptState") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportRowEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", "ImportJob") + .WithMany("Rows") + .HasForeignKey("ImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ImportJob"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportRowEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", "ImportJob") + .WithMany("Rows") + .HasForeignKey("ImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ImportJob"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RidePresetEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("Credential") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Navigation("AuthAttemptState"); + + b.Navigation("Credential"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260520150127_AddRidePresetMiles.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260520150127_AddRidePresetMiles.cs new file mode 100644 index 0000000..e600742 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260520150127_AddRidePresetMiles.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRidePresetMiles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Miles", + table: "RidePresets", + type: "TEXT", + precision: 10, + scale: 2, + nullable: false, + defaultValue: 1m + ); + + migrationBuilder.AddCheckConstraint( + name: "CK_RidePresets_Miles_Positive", + table: "RidePresets", + sql: "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_RidePresets_Miles_Positive", + table: "RidePresets" + ); + + migrationBuilder.DropColumn(name: "Miles", table: "RidePresets"); + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs index e20a0b1..65f1fa8 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs @@ -361,9 +361,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Difficulty") .HasColumnType("INTEGER"); - b.Property("PrimaryTravelDirection") - .HasColumnType("TEXT"); - b.Property("DuplicateResolution") .HasMaxLength(30) .HasColumnType("TEXT"); @@ -387,6 +384,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(2000) .HasColumnType("TEXT"); + b.Property("PrimaryTravelDirection") + .HasColumnType("TEXT"); + b.Property("ProcessingStatus") .IsRequired() .HasMaxLength(30) @@ -544,6 +544,77 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RidePresetEntity", b => + { + b.Property("RidePresetId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("ExactStartTimeLocal") + .HasColumnType("TEXT"); + + b.Property("LastUsedAtUtc") + .HasColumnType("TEXT"); + +// Because this is SQLite, not SQL Server or PostgreSQL. EF Core’s SQLite provider commonly maps decimal columns to TEXT in migrations so it can preserve the exact decimal value instead of relying on SQLite’s loose numeric affinity. The model snapshot mirrors that same provider-generated mapping, so 20260429180854_AddRidePresets.cs and BikeTrackingDbContextModelSnapshot.cs both show TEXT. +// The important part is that the column is still modeled as decimal in C#; the storage type is just SQLite’s representation. We also added a check constraint with CAST("Miles" AS REAL) to enforce the range, so validation is not relying on the type name alone. + b.Property("Miles") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("PeriodTag") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PrimaryDirection") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("TEXT"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.HasKey("RidePresetId"); + + b.HasIndex("RiderId", "Name") + .IsUnique() + .HasDatabaseName("IX_RidePresets_RiderId_Name"); + + b.HasIndex("RiderId", "LastUsedAtUtc", "UpdatedAtUtc") + .IsDescending(false, true, true) + .HasDatabaseName("IX_RidePresets_RiderId_LastUsedAtUtc_UpdatedAtUtc_Desc"); + + b.ToTable("RidePresets", null, t => + { + t.HasCheckConstraint("CK_RidePresets_DurationMinutes_Positive", "\"DurationMinutes\" > 0"); + + t.HasCheckConstraint("CK_RidePresets_Miles_Positive", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); + + t.HasCheckConstraint("CK_RidePresets_PeriodTag_Values", "\"PeriodTag\" IN ('morning', 'afternoon')"); + }); + }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => { b.Property("UserId") @@ -851,6 +922,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RidePresetEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => { b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) diff --git a/src/BikeTracking.Api/Program.cs b/src/BikeTracking.Api/Program.cs index c81023d..6a2df13 100644 --- a/src/BikeTracking.Api/Program.cs +++ b/src/BikeTracking.Api/Program.cs @@ -52,8 +52,7 @@ builder.Services.AddAuthorization(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/BikeTracking.Frontend/src/components/app-header/app-header.css b/src/BikeTracking.Frontend/src/components/app-header/app-header.css index e5506e2..9cb0dfe 100644 --- a/src/BikeTracking.Frontend/src/components/app-header/app-header.css +++ b/src/BikeTracking.Frontend/src/components/app-header/app-header.css @@ -60,20 +60,65 @@ } .app-header-user { - display: flex; - align-items: center; - gap: 0.75rem; + position: relative; + display: grid; + justify-items: end; flex-shrink: 0; } -.app-header-username { +.app-header-user-trigger { + border: 1px solid transparent; + border-radius: 0.375rem; + background: transparent; + cursor: pointer; + padding: 0.35rem 0.55rem; font-size: 0.875rem; font-weight: 600; color: #374151; } +.app-header-user-trigger:hover { + background: #f3f4f6; +} + +.app-header-user-menu { + position: absolute; + top: 100%; + right: 0; + min-width: 9.5rem; + border: 1px solid #d4dbe5; + border-radius: 0.5rem; + background: #fff; + box-shadow: 0 6px 18px rgb(15 23 42 / 14%); + padding: 0.45rem; + display: none; + z-index: 30; +} + +.app-header-user-menu-open { + display: grid; + gap: 0.4rem; +} + +.app-header-user-menu-link { + display: block; + border: 1px solid #d4dbe5; + border-radius: 0.375rem; + color: #374151; + text-decoration: none; + padding: 0.4rem 0.7rem; + font-size: 0.85rem; + font-weight: 600; +} + +.app-header-user-menu-link:hover { + background: #f3f4f6; +} + .header-logout-btn { - padding: 0.4rem 0.85rem; + width: 100%; + text-align: left; + padding: 0.4rem 0.7rem; border: 1px solid #d4dbe5; border-radius: 0.375rem; background: transparent; @@ -89,10 +134,6 @@ } @media (width <= 480px) { - .app-header-username { - display: none; - } - .app-header-brand { font-size: 0.875rem; } diff --git a/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx b/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx index ff05b23..c3381cc 100644 --- a/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx +++ b/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx @@ -1,9 +1,54 @@ import { NavLink } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' import { useAuth } from '../../context/auth-context' import './app-header.css' export function AppHeader() { const { user, logout } = useAuth() + const [menuOpen, setMenuOpen] = useState(false) + const closeMenuTimeoutRef = useRef(null) + + function clearCloseMenuTimeout(): void { + if (closeMenuTimeoutRef.current !== null) { + window.clearTimeout(closeMenuTimeoutRef.current) + closeMenuTimeoutRef.current = null + } + } + + function openMenu(): void { + clearCloseMenuTimeout() + setMenuOpen(true) + } + + function scheduleMenuClose(): void { + clearCloseMenuTimeout() + closeMenuTimeoutRef.current = window.setTimeout(() => { + setMenuOpen(false) + closeMenuTimeoutRef.current = null + }, 180) + } + + useEffect(() => { + return () => { + clearCloseMenuTimeout() + } + }, []) + + useEffect(() => { + function handleClickOutside(event: MouseEvent): void { + const headerUser = document.querySelector('.app-header-user') + if (headerUser && !headerUser.contains(event.target as Node)) { + setMenuOpen(false) + } + } + + if (menuOpen) { + document.addEventListener('click', handleClickOutside) + return () => { + document.removeEventListener('click', handleClickOutside) + } + } + }, [menuOpen]) return (
@@ -63,11 +108,50 @@ export function AppHeader() { -
- {user?.userName} - + +
+ setMenuOpen(false)} + > + Settings + + +
diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx index 6f9b0d3..eaabc36 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx @@ -3,12 +3,10 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' import { RecordRidePage } from '../pages/RecordRidePage' -// Mock the ridesService vi.mock('../services/ridesService', () => ({ - getRideDefaults: vi.fn(), getGasPrice: vi.fn(), getRideWeather: vi.fn(), - getQuickRideOptions: vi.fn(), + getRidePresets: vi.fn(), recordRide: vi.fn(), COMPASS_DIRECTIONS: ['North', 'NE', 'East', 'SE', 'South', 'SW', 'West', 'NW'], })) @@ -19,10 +17,9 @@ vi.mock('../utils/windResistance', () => ({ import * as ridesService from '../services/ridesService' -const mockGetRideDefaults = vi.mocked(ridesService.getRideDefaults) const mockGetGasPrice = vi.mocked(ridesService.getGasPrice) const mockGetRideWeather = vi.mocked(ridesService.getRideWeather) -const mockGetQuickRideOptions = vi.mocked(ridesService.getQuickRideOptions) +const mockGetRidePresets = vi.mocked(ridesService.getRidePresets) const mockRecordRide = vi.mocked(ridesService.recordRide) describe('RecordRidePage', () => { @@ -34,8 +31,8 @@ describe('RecordRidePage', () => { isAvailable: false, dataSource: null, }) - mockGetQuickRideOptions.mockResolvedValue({ - options: [], + mockGetRidePresets.mockResolvedValue({ + presets: [], generatedAtUtc: new Date().toISOString(), }) mockGetRideWeather.mockResolvedValue({ @@ -50,12 +47,7 @@ describe('RecordRidePage', () => { }) }) - it('should render form fields', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - + it('renders form fields', async () => { render( @@ -70,16 +62,22 @@ describe('RecordRidePage', () => { }) }) - it('should include note in submit payload when provided', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockRecordRide.mockResolvedValue({ - rideId: 321, - riderId: 1, - savedAtUtc: new Date().toISOString(), - eventStatus: 'Queued', + it('loads ride presets and does not render legacy quick options section', async () => { + mockGetRidePresets.mockResolvedValue({ + presets: [ + { + presetId: 10, + name: 'Morning Commute', + primaryDirection: 'SW', + periodTag: 'morning', + exactStartTimeLocal: '07:45', + durationMinutes: 34, + miles: 7.2, + lastUsedAtUtc: null, + updatedAtUtc: new Date().toISOString(), + }, + ], + generatedAtUtc: new Date().toISOString(), }) render( @@ -89,33 +87,45 @@ describe('RecordRidePage', () => { ) await waitFor(() => { - expect(screen.getByLabelText(/miles/i)).toBeInTheDocument() + expect(mockGetRidePresets).toHaveBeenCalled() + expect(screen.getByLabelText(/ride preset/i)).toBeInTheDocument() }) - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - fireEvent.change(milesInput, { target: { value: '9.4' } }) + expect(screen.queryByText(/quick ride options/i)).not.toBeInTheDocument() + }) - const notesInput = screen.getByLabelText(/notes/i) as HTMLTextAreaElement - fireEvent.change(notesInput, { - target: { value: 'Strong headwind near the river trail.' }, + it('applies selected preset and loads weather while preserving gas price', async () => { + mockGetGasPrice.mockResolvedValue({ + date: new Date().toISOString().slice(0, 10), + pricePerGallon: 3.55, + isAvailable: true, + dataSource: 'Source: U.S. Energy Information Administration (EIA)', }) - - const submitButton = screen.getByRole('button', { name: /record ride/i }) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockRecordRide).toHaveBeenCalledWith( - expect.objectContaining({ - note: 'Strong headwind near the river trail.', - }) - ) + mockGetRidePresets.mockResolvedValue({ + presets: [ + { + presetId: 11, + name: 'Afternoon Return', + primaryDirection: 'NE', + periodTag: 'afternoon', + exactStartTimeLocal: '17:35', + durationMinutes: 32, + miles: 8.6, + lastUsedAtUtc: null, + updatedAtUtc: new Date().toISOString(), + }, + ], + generatedAtUtc: new Date().toISOString(), }) - }) - - it('should render an import rides link', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), + mockGetRideWeather.mockResolvedValue({ + rideDateTimeLocal: '2026-04-03T17:35:00', + temperature: 58.2, + windSpeedMph: 12.4, + windDirectionDeg: 240, + relativeHumidityPercent: 81, + cloudCoverPercent: 72, + precipitationType: 'rain', + isAvailable: true, }) render( @@ -124,18 +134,21 @@ describe('RecordRidePage', () => { ) + const presetSelector = await screen.findByLabelText(/ride preset/i) + fireEvent.change(presetSelector, { target: { value: '11' } }) + await waitFor(() => { - const importLink = screen.getByRole('link', { name: /import rides from csv/i }) - expect(importLink).toHaveAttribute('href', '/rides/import') + expect(mockGetRideWeather).toHaveBeenCalled() + expect((screen.getByLabelText(/primary direction of travel/i) as HTMLSelectElement).value).toBe('NE') + expect((screen.getByLabelText(/duration/i) as HTMLInputElement).value).toBe('32') + expect((screen.getByLabelText(/miles/i) as HTMLInputElement).value).toBe('8.6') + expect((screen.getByLabelText(/date & time/i) as HTMLInputElement).value).toContain('T17:35') + expect((screen.getByLabelText(/gas price/i) as HTMLInputElement).value).toBe('3.55') }) }) - it('should default date/time to now', async () => { + it('defaults date/time to now format', async () => { const now = new Date() - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: now.toISOString(), - }) render( @@ -145,51 +158,13 @@ describe('RecordRidePage', () => { await waitFor(() => { const input = screen.getByLabelText(/date & time/i) as HTMLInputElement - // Component uses toISOString() (UTC) — compare the UTC date+hour prefix - // which is timezone-safe regardless of the container's local timezone expect(input.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/) - const utcDateHour = now.toISOString().slice(0, 13) // 'YYYY-MM-DDTHH' + const utcDateHour = now.toISOString().slice(0, 13) expect(input.value).toContain(utcDateHour) }) }) - it('should fetch and display defaults', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: true, - defaultRideDateTimeLocal: new Date().toISOString(), - defaultMiles: 10.5, - defaultRideMinutes: 45, - defaultTemperature: 72, - defaultGasPricePerGallon: 3.1111, - }) - - render( - - - - ) - - await waitFor(() => { - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - expect(milesInput.value).toBe('10.5') - - const minutesInput = screen.getByLabelText(/duration/i) as HTMLInputElement - expect(minutesInput.value).toBe('45') - - const tempInput = screen.getByLabelText(/temperature/i) as HTMLInputElement - expect(tempInput.value).toBe('72') - - const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement - expect(gasPriceInput.value).toBe('3.1111') - }) - }) - - it('should call getGasPrice on initial load and use available value', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: true, - defaultRideDateTimeLocal: new Date().toISOString(), - defaultGasPricePerGallon: 3.1111, - }) + it('calls getGasPrice on initial load and uses available value', async () => { mockGetGasPrice.mockResolvedValue({ date: new Date().toISOString().slice(0, 10), pricePerGallon: 3.2222, @@ -207,17 +182,11 @@ describe('RecordRidePage', () => { expect(mockGetGasPrice).toHaveBeenCalled() const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement expect(gasPriceInput.value).toBe('3.2222') - expect( - screen.getByText('Source: U.S. Energy Information Administration (EIA)') - ).toBeInTheDocument() + expect(screen.getByText('Source: U.S. Energy Information Administration (EIA)')).toBeInTheDocument() }) }) - it('should allow empty gas price and omit it on submit', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) + it('allows empty gas price and omits it on submit', async () => { mockRecordRide.mockResolvedValue({ rideId: 50, riderId: 1, @@ -239,482 +208,16 @@ describe('RecordRidePage', () => { const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement fireEvent.change(gasPriceInput, { target: { value: '' } }) - const submitButton = screen.getByRole('button', { name: /record ride/i }) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockRecordRide).toHaveBeenCalledWith( - expect.not.objectContaining({ gasPricePerGallon: expect.anything() }) - ) - }) - }) - - it('should block submit when gas price is negative', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - - render( - - - - ) - - await waitFor(() => { - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - fireEvent.change(milesInput, { target: { value: '10' } }) - }) - - const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement - fireEvent.change(gasPriceInput, { target: { value: '-1' } }) - - const submitButton = screen.getByRole('button', { name: /record ride/i }) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockRecordRide).not.toHaveBeenCalled() - expect(screen.getByText(/gas price must be greater than 0/i)).toBeInTheDocument() - }) - }) - - it('should retain fallback gas price when date-change lookup is unavailable', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: true, - defaultRideDateTimeLocal: new Date().toISOString(), - defaultGasPricePerGallon: 3.1111, - }) - mockGetGasPrice.mockResolvedValue({ - date: new Date().toISOString().slice(0, 10), - pricePerGallon: null, - isAvailable: false, - dataSource: null, - }) - - render( - - - - ) - - await waitFor(() => { - const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement - expect(gasPriceInput.value).toBe('3.1111') - }) - - const dateInput = screen.getByLabelText(/date & time/i) as HTMLInputElement - fireEvent.change(dateInput, { target: { value: '2026-01-01T09:00' } }) - - await waitFor(() => { - const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement - expect(gasPriceInput.value).toBe('3.1111') - }) - }) - - it('should keep gas price empty when lookup unavailable and no fallback exists', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockGetGasPrice.mockResolvedValue({ - date: new Date().toISOString().slice(0, 10), - pricePerGallon: null, - isAvailable: false, - dataSource: null, - }) - - render( - - - - ) - - await waitFor(() => { - const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement - expect(gasPriceInput.value).toBe('') - }) - - const dateInput = screen.getByLabelText(/date & time/i) as HTMLInputElement - fireEvent.change(dateInput, { target: { value: '2026-01-01T09:00' } }) - - await waitFor(() => { - const gasPriceInput = screen.getByLabelText(/gas price/i) as HTMLInputElement - expect(gasPriceInput.value).toBe('') - }) - }) - - it('should show validation error for negative miles', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - - render( - - - - ) - - await waitFor(() => { - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - fireEvent.change(milesInput, { target: { value: '-1' } }) - - const submitButton = screen.getByRole('button', { name: /record ride/i }) - fireEvent.click(submitButton) - }) - - await waitFor(() => { - expect(screen.getByText(/miles must be greater than 0/i)).toBeInTheDocument() - }) - }) - - it('should show validation error for miles above maximum', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - - render( - - - - ) - - await waitFor(() => { - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - fireEvent.change(milesInput, { target: { value: '201' } }) - - const submitButton = screen.getByRole('button', { name: /record ride/i }) - fireEvent.click(submitButton) - }) - - await waitFor(() => { - expect( - screen.getByText(/miles must be less than or equal to 200/i) - ).toBeInTheDocument() - expect(mockRecordRide).not.toHaveBeenCalled() - }) - }) - - it('should show success message on successful submit', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockRecordRide.mockResolvedValue({ - rideId: 123, - riderId: 1, - savedAtUtc: new Date().toISOString(), - eventStatus: 'Queued', - }) - - render( - - - - ) - - await waitFor(() => { - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - fireEvent.change(milesInput, { target: { value: '10' } }) - - const submitButton = screen.getByRole('button', { name: /record ride/i }) - fireEvent.click(submitButton) - }) - - await waitFor(() => { - expect(screen.getByText(/ride recorded successfully/i)).toBeInTheDocument() - }) - }) - - it('should preserve form values on submit error', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockRecordRide.mockRejectedValue(new Error('Server error')) - - render( - - - - ) - - await waitFor(() => { - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - fireEvent.change(milesInput, { target: { value: '10' } }) - - const submitButton = screen.getByRole('button', { name: /record ride/i }) - fireEvent.click(submitButton) - }) - - await waitFor(() => { - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - expect(milesInput.value).toBe('10') - // The component surfaces error.message from the rejection - expect(screen.getByText(/server error/i)).toBeInTheDocument() - }) - }) - - it('should render quick ride options when available', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockGetQuickRideOptions.mockResolvedValue({ - options: [ - { - miles: 10.5, - rideMinutes: 40, - lastUsedAtLocal: new Date().toISOString(), - }, - ], - generatedAtUtc: new Date().toISOString(), - }) - - render( - - - - ) - - await waitFor(() => { - expect(screen.getByRole('button', { name: /10\.5 mi .* 40 min/i })).toBeInTheDocument() - }) - }) - - it('should prefill miles and duration when quick option selected', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockGetQuickRideOptions.mockResolvedValue({ - options: [ - { - miles: 9.25, - rideMinutes: 33, - lastUsedAtLocal: new Date().toISOString(), - }, - ], - generatedAtUtc: new Date().toISOString(), - }) - - render( - - - - ) - - const optionButton = await screen.findByRole('button', { - name: /9\.25 mi .* 33 min/i, - }) - fireEvent.click(optionButton) - - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - const minutesInput = screen.getByLabelText(/duration/i) as HTMLInputElement - - expect(milesInput.value).toBe('9.25') - expect(minutesInput.value).toBe('33') - expect(mockRecordRide).not.toHaveBeenCalled() - }) - - it('should allow editing copied values and submit edited payload', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockGetQuickRideOptions.mockResolvedValue({ - options: [ - { - miles: 7.5, - rideMinutes: 25, - lastUsedAtLocal: new Date().toISOString(), - }, - ], - generatedAtUtc: new Date().toISOString(), - }) - mockRecordRide.mockResolvedValue({ - rideId: 222, - riderId: 1, - savedAtUtc: new Date().toISOString(), - eventStatus: 'Queued', - }) - - render( - - - - ) - - const optionButton = await screen.findByRole('button', { - name: /7\.5 mi .* 25 min/i, - }) - fireEvent.click(optionButton) - - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - const minutesInput = screen.getByLabelText(/duration/i) as HTMLInputElement - - fireEvent.change(milesInput, { target: { value: '8.25' } }) - fireEvent.change(minutesInput, { target: { value: '29' } }) - - const submitButton = screen.getByRole('button', { name: /record ride/i }) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockRecordRide).toHaveBeenCalledWith( - expect.objectContaining({ - miles: 8.25, - rideMinutes: 29, - }) - ) - }) - }) - - it('should block submit when copied miles is cleared', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockGetQuickRideOptions.mockResolvedValue({ - options: [ - { - miles: 6.4, - rideMinutes: 22, - lastUsedAtLocal: new Date().toISOString(), - }, - ], - generatedAtUtc: new Date().toISOString(), - }) - - render( - - - - ) - - const optionButton = await screen.findByRole('button', { - name: /6\.4 mi .* 22 min/i, - }) - fireEvent.click(optionButton) - - const milesInput = screen.getByLabelText(/miles/i) as HTMLInputElement - fireEvent.change(milesInput, { target: { value: '' } }) - - const submitButton = screen.getByRole('button', { name: /record ride/i }) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockRecordRide).not.toHaveBeenCalled() - expect(screen.getByText(/miles must be greater than 0/i)).toBeInTheDocument() - }) - }) - - it('should not render quick ride options section when no options exist', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockGetQuickRideOptions.mockResolvedValue({ - options: [], - generatedAtUtc: new Date().toISOString(), - }) - - render( - - - - ) - - await waitFor(() => { - expect(screen.getByLabelText(/miles/i)).toBeInTheDocument() - expect(screen.queryByRole('heading', { name: /quick ride options/i })).not.toBeInTheDocument() - }) - }) - - it('should keep manual entry available when quick options fetch fails', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockGetQuickRideOptions.mockRejectedValue(new Error('Quick options unavailable')) - - render( - - - - ) - - await waitFor(() => { - expect(screen.getByLabelText(/miles/i)).toBeInTheDocument() - expect(screen.getByRole('button', { name: /record ride/i })).toBeInTheDocument() - expect(screen.queryByRole('heading', { name: /quick ride options/i })).not.toBeInTheDocument() - }) - }) - - it('should render editable weather fields for manual override on create', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - - render( - - - - ) - - await waitFor(() => { - expect(screen.getByLabelText(/wind speed/i)).toBeInTheDocument() - expect(screen.getByLabelText(/wind direction/i)).toBeInTheDocument() - expect(screen.getByLabelText(/relative humidity/i)).toBeInTheDocument() - expect(screen.getByLabelText(/cloud cover/i)).toBeInTheDocument() - expect(screen.getByLabelText(/precipitation type/i)).toBeInTheDocument() - }) - }) - - it('should send weatherUserOverridden when weather fields are manually edited', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) - mockRecordRide.mockResolvedValue({ - rideId: 321, - riderId: 1, - savedAtUtc: new Date().toISOString(), - eventStatus: 'Queued', - }) - - render( - - - - ) - - await waitFor(() => { - fireEvent.change(screen.getByLabelText(/miles/i), { target: { value: '12.5' } }) - }) - - fireEvent.change(screen.getByLabelText(/wind speed/i), { - target: { value: '11.2' }, - }) - fireEvent.click(screen.getByRole('button', { name: /record ride/i })) await waitFor(() => { expect(mockRecordRide).toHaveBeenCalledWith( - expect.objectContaining({ - windSpeedMph: 11.2, - weatherUserOverridden: true, - }) + expect.not.objectContaining({ gasPricePerGallon: expect.anything() }) ) }) }) - it('should load weather into fields when Load Weather is clicked', async () => { - mockGetRideDefaults.mockResolvedValue({ - hasPreviousRide: false, - defaultRideDateTimeLocal: new Date().toISOString(), - }) + it('loads weather into fields when Load Weather is clicked', async () => { mockGetRideWeather.mockResolvedValue({ rideDateTimeLocal: '2026-04-03T08:00:00', temperature: 58.2, diff --git a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx index 37400de..73164a1 100644 --- a/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx +++ b/src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx @@ -1,11 +1,10 @@ -import { useEffect, useState } from 'react' -import type { CompassDirection, QuickRideOption, RecordRideRequest } from '../services/ridesService' +import { useEffect, useRef, useState } from 'react' +import type { CompassDirection, RidePreset, RecordRideRequest } from '../services/ridesService' import { getGasPrice, getRideWeather, - getQuickRideOptions, + getRidePresets, recordRide, - getRideDefaults, COMPASS_DIRECTIONS, } from '../services/ridesService' import { suggestDifficulty } from '../utils/windResistance' @@ -26,7 +25,8 @@ export function RecordRidePage() { const [weatherEdited, setWeatherEdited] = useState(false) const [gasPrice, setGasPrice] = useState('') const [gasPriceSource, setGasPriceSource] = useState('') - const [quickRideOptions, setQuickRideOptions] = useState([]) + const [ridePresets, setRidePresets] = useState([]) + const [selectedPresetId, setSelectedPresetId] = useState(null) const [primaryTravelDirection, setPrimaryTravelDirection] = useState('') const [difficulty, setDifficulty] = useState('') @@ -37,6 +37,7 @@ export function RecordRidePage() { const [loadingWeather, setLoadingWeather] = useState(false) const [successMessage, setSuccessMessage] = useState('') const [errorMessage, setErrorMessage] = useState('') + const rideDateTimeLocalRef = useRef('') const applyLoadedWeather = (weather: { temperature?: number @@ -82,74 +83,51 @@ export function RecordRidePage() { } } - const loadQuickRideOptions = async () => { + const loadRidePresets = async () => { try { - const quickOptionsResponse = await getQuickRideOptions() - setQuickRideOptions(quickOptionsResponse.options) + const presetsResponse = await getRidePresets() + setRidePresets(presetsResponse.presets) } catch (error) { - setQuickRideOptions([]) - console.error('Failed to load quick ride options:', error) + setRidePresets([]) + console.error('Failed to load ride presets:', error) } } useEffect(() => { - const initializeDefaults = async () => { - try { - const defaults = await getRideDefaults() - - // Set date/time to current local time - const now = new Date() - const localIso = now.toISOString().slice(0, 16) - setRideDateTimeLocal(localIso) - - // Set optional defaults - if (defaults.hasPreviousRide) { - if (defaults.defaultMiles) setMiles(defaults.defaultMiles.toString()) - if (defaults.defaultRideMinutes) - setRideMinutes(defaults.defaultRideMinutes.toString()) - if (defaults.defaultTemperature) - setTemperature(defaults.defaultTemperature.toString()) - if (defaults.defaultWindSpeedMph) - setWindSpeedMph(defaults.defaultWindSpeedMph.toString()) - if (defaults.defaultWindDirectionDeg) - setWindDirectionDeg(defaults.defaultWindDirectionDeg.toString()) - if (defaults.defaultRelativeHumidityPercent) - setRelativeHumidityPercent(defaults.defaultRelativeHumidityPercent.toString()) - if (defaults.defaultCloudCoverPercent) - setCloudCoverPercent(defaults.defaultCloudCoverPercent.toString()) - if (defaults.defaultPrecipitationType) - setPrecipitationType(defaults.defaultPrecipitationType) - if (defaults.defaultGasPricePerGallon) - setGasPrice(defaults.defaultGasPricePerGallon.toString()) - } + const initializePageData = async () => { + // Initialize date/time to current local time only. + const now = new Date() + const localIso = now.toISOString().slice(0, 16) + setRideDateTimeLocal(localIso) - try { - const today = localIso.slice(0, 10) - const lookup = await getGasPrice(today) - if (lookup.isAvailable && lookup.pricePerGallon !== null) { - setGasPrice(lookup.pricePerGallon.toString()) - setGasPriceSource(lookup.dataSource ?? EIA_GAS_PRICE_SOURCE) - } else { - setGasPriceSource('') - } - } catch (error) { + try { + const today = localIso.slice(0, 10) + const lookup = await getGasPrice(today) + if (lookup.isAvailable && lookup.pricePerGallon !== null) { + setGasPrice(lookup.pricePerGallon.toString()) + setGasPriceSource(lookup.dataSource ?? EIA_GAS_PRICE_SOURCE) + } else { setGasPriceSource('') - console.error('Failed to load gas price:', error) } } catch (error) { - console.error('Failed to load defaults:', error) + setGasPriceSource('') + console.error('Failed to load gas price:', error) } try { - await loadQuickRideOptions() + await loadRidePresets() } finally { setLoading(false) } } - initializeDefaults() + initializePageData() }, []) + useEffect(() => { + rideDateTimeLocalRef.current = rideDateTimeLocal + }, [rideDateTimeLocal]) + useEffect(() => { if (!rideDateTimeLocal) { return @@ -192,9 +170,61 @@ export function RecordRidePage() { } }, [primaryTravelDirection, windSpeedMph, windDirectionDeg]) - const applyQuickRideOption = (option: QuickRideOption) => { - setMiles(option.miles.toString()) - setRideMinutes(option.rideMinutes.toString()) + useEffect(() => { + if (selectedPresetId === null) return + + const handlePresetChange = async () => { + const preset = ridePresets.find((p) => p.presetId === selectedPresetId) + if (!preset) return + + // Clear weather fields when preset changes (keep gas price) + setTemperature('') + setWindSpeedMph('') + setWindDirectionDeg('') + setRelativeHumidityPercent('') + setCloudCoverPercent('') + setPrecipitationType('') + setWeatherEdited(false) + + // Apply the preset + setPrimaryTravelDirection(preset.primaryDirection as CompassDirection) + setRideMinutes(preset.durationMinutes.toString()) + setMiles(preset.miles.toString()) + + // Construct new date/time and refetch weather + const currentRideDateTimeLocal = rideDateTimeLocalRef.current + const datePart = currentRideDateTimeLocal + ? currentRideDateTimeLocal.slice(0, 11) + : new Date().toISOString().slice(0, 11) + const newDateTime = `${datePart}${preset.exactStartTimeLocal}` + + setRideDateTimeLocal(newDateTime) + + // Refetch weather for the new preset's time + setLoadingWeather(true) + try { + const weather = await getRideWeather(newDateTime) + applyLoadedWeather(weather) + } catch (error) { + console.error('Failed to load weather for new preset:', error) + } finally { + setLoadingWeather(false) + } + } + + handlePresetChange() + }, [selectedPresetId, ridePresets]) + + const applyPreset = (preset: RidePreset) => { + setPrimaryTravelDirection(preset.primaryDirection as CompassDirection) + setRideMinutes(preset.durationMinutes.toString()) + setMiles(preset.miles.toString()) + // Keep current date, replace time with preset's exact start time + const currentRideDateTimeLocal = rideDateTimeLocalRef.current + const datePart = currentRideDateTimeLocal + ? currentRideDateTimeLocal.slice(0, 11) + : new Date().toISOString().slice(0, 11) + setRideDateTimeLocal(`${datePart}${preset.exactStartTimeLocal}`) } const handleSubmit = async (e: React.FormEvent) => { @@ -259,11 +289,13 @@ export function RecordRidePage() { gasPricePerGallon: gasPrice ? parseFloat(gasPrice) : undefined, difficulty: difficulty ? parseInt(difficulty) : undefined, primaryTravelDirection: primaryTravelDirection || undefined, + selectedPresetId: selectedPresetId ?? undefined, } const response = await recordRide(request) setSuccessMessage(`Ride recorded successfully (ID: ${response.rideId})`) - await loadQuickRideOptions() + await loadRidePresets() + setSelectedPresetId(null) // Keep form values but clear after delay setTimeout(() => { @@ -283,6 +315,7 @@ export function RecordRidePage() { setPrimaryTravelDirection('') setDifficulty('') setDifficultyAutoSuggested(false) + setSelectedPresetId(null) setSuccessMessage('') }, 3000) } catch (error) { @@ -295,11 +328,12 @@ export function RecordRidePage() { } } - if (loading) return
Loading defaults...
+ if (loading) return
Loading ride entry...
return (

Record a Ride

+

Do you repeat rides often? Setup a Ride Preset.

Need to add past rides in bulk? Import rides from CSV.

@@ -307,19 +341,32 @@ export function RecordRidePage() { {successMessage &&
{successMessage}
} {errorMessage &&
{errorMessage}
} - {quickRideOptions.length > 0 && ( -
-

Quick Ride Options

-
- {quickRideOptions.map((option, index) => ( - - ))} + {ridePresets.length > 0 && ( +
+
+ + +
)} diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css index 1c16c50..cd76c48 100644 --- a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css @@ -136,3 +136,44 @@ color: #0b6b2e; margin: 0; } + +.settings-presets { + border-top: 1px solid #d4dbe5; + padding-top: 1rem; + display: grid; + gap: 0.75rem; +} + +.settings-presets h2 { + margin: 0; + color: #152238; +} + +.settings-presets-form { + display: grid; + gap: 0.75rem; +} + +.settings-field select { + border: 1px solid #b9c5d6; + border-radius: 0.5rem; + padding: 0.5rem 0.625rem; +} + +.settings-presets-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.5rem; +} + +.settings-presets-item { + border: 1px solid #d4dbe5; + border-radius: 0.5rem; + padding: 0.65rem 0.75rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx index e45908e..6fdcfd1 100644 --- a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx @@ -3,14 +3,28 @@ import { BrowserRouter } from 'react-router-dom' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { SettingsPage } from './SettingsPage' import * as usersApi from '../../services/users-api' +import * as ridesService from '../../services/ridesService' vi.mock('../../services/users-api', () => ({ getUserSettings: vi.fn(), saveUserSettings: vi.fn(), })) +vi.mock('../../services/ridesService', () => ({ + COMPASS_DIRECTIONS: ['North', 'NE', 'East', 'SE', 'South', 'SW', 'West', 'NW'], + PERIOD_TAG_DEFAULT_DIRECTIONS: { morning: 'SW', afternoon: 'NE' }, + getRidePresets: vi.fn(), + createRidePreset: vi.fn(), + updateRidePreset: vi.fn(), + deleteRidePreset: vi.fn(), +})) + const mockGetUserSettings = vi.mocked(usersApi.getUserSettings) const mockSaveUserSettings = vi.mocked(usersApi.saveUserSettings) +const mockGetRidePresets = vi.mocked(ridesService.getRidePresets) +const mockCreateRidePreset = vi.mocked(ridesService.createRidePreset) +const mockUpdateRidePreset = vi.mocked(ridesService.updateRidePreset) +const mockDeleteRidePreset = vi.mocked(ridesService.deleteRidePreset) function installGeolocationMock( implementation: Geolocation['getCurrentPosition'] @@ -26,6 +40,34 @@ function installGeolocationMock( describe('SettingsPage', () => { beforeEach(() => { vi.clearAllMocks() + mockGetRidePresets.mockResolvedValue({ presets: [], generatedAtUtc: '2026-04-29T00:00:00Z' }) + mockCreateRidePreset.mockResolvedValue({ + presetId: 1, + name: 'Morning Commute', + primaryDirection: 'SW', + periodTag: 'morning', + exactStartTimeLocal: '07:45', + durationMinutes: 34, + miles: 7.2, + lastUsedAtUtc: null, + updatedAtUtc: '2026-04-29T00:00:00Z', + }) + mockUpdateRidePreset.mockResolvedValue({ + presetId: 1, + name: 'Morning Commute', + primaryDirection: 'SW', + periodTag: 'morning', + exactStartTimeLocal: '07:45', + durationMinutes: 34, + miles: 7.2, + lastUsedAtUtc: null, + updatedAtUtc: '2026-04-29T00:00:00Z', + }) + mockDeleteRidePreset.mockResolvedValue({ + presetId: 1, + deletedAtUtc: '2026-04-29T00:00:00Z', + message: 'Preset deleted', + }) installGeolocationMock((success) => { success({ coords: { @@ -483,4 +525,203 @@ describe('SettingsPage', () => { ) }) }) + + it('renders a ride presets management section', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: false, + settings: { + averageCarMpg: null, + yearlyGoalMiles: null, + oilChangePrice: null, + mileageRateCents: null, + locationLabel: null, + latitude: null, + longitude: null, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, + updatedAtUtc: null, + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /ride presets/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /add preset/i })).toBeInTheDocument() + }) + }) + + it('shows preset fields including exact start time and duration controls', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: false, + settings: { + averageCarMpg: null, + yearlyGoalMiles: null, + oilChangePrice: null, + mileageRateCents: null, + locationLabel: null, + latitude: null, + longitude: null, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, + updatedAtUtc: null, + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByLabelText(/preset name/i)).toBeInTheDocument() + expect(screen.getByLabelText(/exact start time/i)).toBeInTheDocument() + expect(screen.getByLabelText(/duration minutes/i)).toBeInTheDocument() + expect(screen.getByLabelText(/miles/i)).toBeInTheDocument() + }) + }) + + it('suggests SW as primary direction when period tag is morning', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: false, + settings: { + averageCarMpg: null, yearlyGoalMiles: null, oilChangePrice: null, + mileageRateCents: null, locationLabel: null, latitude: null, + longitude: null, dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: null, + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByLabelText(/period tag/i)).toBeInTheDocument() + }) + + const periodSelect = screen.getByLabelText(/period tag/i) as HTMLSelectElement + fireEvent.change(periodSelect, { target: { value: 'morning' } }) + + await waitFor(() => { + const directionSelect = screen.getByLabelText(/primary direction/i) as HTMLSelectElement + expect(directionSelect.value).toBe('SW') + }) + }) + + it('suggests NE as primary direction when period tag is afternoon', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: false, + settings: { + averageCarMpg: null, yearlyGoalMiles: null, oilChangePrice: null, + mileageRateCents: null, locationLabel: null, latitude: null, + longitude: null, dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: null, + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByLabelText(/period tag/i)).toBeInTheDocument() + }) + + const periodSelect = screen.getByLabelText(/period tag/i) as HTMLSelectElement + fireEvent.change(periodSelect, { target: { value: 'afternoon' } }) + + await waitFor(() => { + const directionSelect = screen.getByLabelText(/primary direction/i) as HTMLSelectElement + expect(directionSelect.value).toBe('NE') + }) + }) + + it('keeps rider-selected direction when manually overriding suggestion', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: false, + settings: { + averageCarMpg: null, yearlyGoalMiles: null, oilChangePrice: null, + mileageRateCents: null, locationLabel: null, latitude: null, + longitude: null, dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: null, + }, + }, + }) + mockCreateRidePreset.mockResolvedValue({ + presetId: 99, + name: 'Custom Morning', + primaryDirection: 'North', + periodTag: 'morning', + exactStartTimeLocal: '07:45', + durationMinutes: 30, + miles: 9.5, + lastUsedAtUtc: null, + updatedAtUtc: '2026-04-29T00:00:00Z', + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByLabelText(/preset name/i)).toBeInTheDocument() + }) + + // Select morning (suggests SW) + const periodSelect = screen.getByLabelText(/period tag/i) as HTMLSelectElement + fireEvent.change(periodSelect, { target: { value: 'morning' } }) + + await waitFor(() => { + const directionSelect = screen.getByLabelText(/primary direction/i) as HTMLSelectElement + expect(directionSelect.value).toBe('SW') + }) + + // Override direction to North + const directionSelect = screen.getByLabelText(/primary direction/i) as HTMLSelectElement + fireEvent.change(directionSelect, { target: { value: 'North' } }) + expect(directionSelect.value).toBe('North') + + // Fill required fields and submit + fireEvent.change(screen.getByLabelText(/preset name/i), { target: { value: 'Custom Morning' } }) + fireEvent.change(screen.getByLabelText(/duration minutes/i), { target: { value: '30' } }) + fireEvent.change(screen.getByLabelText(/miles/i), { target: { value: '9.5' } }) + fireEvent.click(screen.getByRole('button', { name: /add preset/i })) + + await waitFor(() => { + expect(mockCreateRidePreset).toHaveBeenCalledWith( + expect.objectContaining({ primaryDirection: 'North', periodTag: 'morning', miles: 9.5 }) + ) + }) + }) }) diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx index 8efafc5..b62b44d 100644 --- a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx @@ -6,6 +6,17 @@ import { type UserSettingsResponse, type UserSettingsUpsertRequest, } from '../../services/users-api' +import { + COMPASS_DIRECTIONS, + createRidePreset, + deleteRidePreset, + getRidePresets, + updateRidePreset, + type RidePreset, + type RidePresetPeriodTag, + type UpsertRidePresetRequest, +} from '../../services/ridesService' +import { PERIOD_TAG_DEFAULT_DIRECTIONS } from '../../services/ridesService' import './SettingsPage.css' interface SettingsFormSnapshot { @@ -66,6 +77,14 @@ export function SettingsPage() { const [saving, setSaving] = useState(false) const [error, setError] = useState('') const [success, setSuccess] = useState('') + const [ridePresets, setRidePresets] = useState([]) + const [editingPresetId, setEditingPresetId] = useState(null) + const [presetName, setPresetName] = useState('') + const [presetPrimaryDirection, setPresetPrimaryDirection] = useState('SW') + const [presetPeriodTag, setPresetPeriodTag] = useState('morning') + const [presetExactStartTimeLocal, setPresetExactStartTimeLocal] = useState('07:45') + const [presetDurationMinutes, setPresetDurationMinutes] = useState('') + const [presetMiles, setPresetMiles] = useState('') useEffect(() => { let isMounted = true @@ -73,11 +92,14 @@ export function SettingsPage() { async function load(): Promise { setError('') try { - const response = await getUserSettings() + const [settingsResponse, presetsResponse] = await Promise.all([ + getUserSettings(), + getRidePresets(), + ]) if (!isMounted) return - if (response.ok && response.data) { - const settings = response.data.settings + if (settingsResponse.ok && settingsResponse.data) { + const settings = settingsResponse.data.settings setAverageCarMpg(settings.averageCarMpg ?? '') setYearlyGoalMiles(settings.yearlyGoalMiles ?? '') setOilChangePrice(settings.oilChangePrice ?? '') @@ -87,9 +109,10 @@ export function SettingsPage() { setLongitude(settings.longitude ?? '') setDashboardGallonsAvoidedEnabled(settings.dashboardGallonsAvoidedEnabled) setDashboardGoalProgressEnabled(settings.dashboardGoalProgressEnabled) - setInitialSnapshot(toSnapshot(response.data)) + setInitialSnapshot(toSnapshot(settingsResponse.data)) + setRidePresets(presetsResponse.presets) } else { - setError(response.error?.message ?? 'Failed to load settings') + setError(settingsResponse.error?.message ?? 'Failed to load settings') } } catch { if (isMounted) { @@ -138,6 +161,95 @@ export function SettingsPage() { ) } + function resetPresetForm(): void { + setEditingPresetId(null) + setPresetName('') + setPresetPrimaryDirection('SW') + setPresetPeriodTag('morning') + setPresetExactStartTimeLocal('07:45') + setPresetDurationMinutes('') + setPresetMiles('') + } + + function onPeriodTagChange(tag: RidePresetPeriodTag): void { + setPresetPeriodTag(tag) + setPresetPrimaryDirection(PERIOD_TAG_DEFAULT_DIRECTIONS[tag]) + } + + async function onSubmitPreset(event: React.FormEvent): Promise { + event.preventDefault() + setError('') + setSuccess('') + + if (presetName.trim().length === 0) { + setError('Preset name is required.') + return + } + + if (presetDurationMinutes === '' || presetDurationMinutes <= 0) { + setError('Duration minutes must be greater than 0.') + return + } + + if (presetMiles === '' || presetMiles <= 0) { + setError('Miles must be greater than 0.') + return + } + + const request: UpsertRidePresetRequest = { + name: presetName.trim(), + primaryDirection: presetPrimaryDirection as UpsertRidePresetRequest['primaryDirection'], + periodTag: presetPeriodTag, + exactStartTimeLocal: presetExactStartTimeLocal, + durationMinutes: presetDurationMinutes, + miles: presetMiles, + } + + try { + if (editingPresetId === null) { + const created = await createRidePreset(request) + setRidePresets((current) => [created, ...current]) + setSuccess('Preset created.') + } else { + const updated = await updateRidePreset(editingPresetId, request) + setRidePresets((current) => + current.map((preset) => (preset.presetId === updated.presetId ? updated : preset)) + ) + setSuccess('Preset updated.') + } + + resetPresetForm() + } catch { + setError('Failed to save preset') + } + } + + function onEditPreset(preset: RidePreset): void { + setEditingPresetId(preset.presetId) + setPresetName(preset.name) + setPresetPrimaryDirection(preset.primaryDirection) + setPresetPeriodTag(preset.periodTag) + setPresetExactStartTimeLocal(preset.exactStartTimeLocal) + setPresetDurationMinutes(preset.durationMinutes) + setPresetMiles(preset.miles) + } + + async function onDeletePreset(presetId: number): Promise { + setError('') + setSuccess('') + + try { + await deleteRidePreset(presetId) + setRidePresets((current) => current.filter((preset) => preset.presetId !== presetId)) + if (editingPresetId === presetId) { + resetPresetForm() + } + setSuccess('Preset deleted.') + } catch { + setError('Failed to delete preset') + } + } + async function onSave(event: React.FormEvent): Promise { event.preventDefault() setError('') @@ -370,6 +482,134 @@ export function SettingsPage() {
+ +
+

Ride Presets

+ +
+
+
+ + setPresetName(event.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setPresetExactStartTimeLocal(event.target.value)} + /> +
+ +
+ + + setPresetDurationMinutes( + event.target.value === '' ? '' : Number(event.target.value) + ) + } + /> +
+ +
+ + + setPresetMiles(event.target.value === '' ? '' : Number(event.target.value)) + } + /> +
+
+ +
+ + {editingPresetId === null ? null : ( + + )} +
+
+ +
    + {ridePresets.map((preset) => ( +
  • + + {preset.name} ({preset.primaryDirection}, {preset.periodTag}, {preset.exactStartTimeLocal},{' '} + {preset.durationMinutes} min, {preset.miles} mi) + +
    + + +
    +
  • + ))} +
+
) diff --git a/src/BikeTracking.Frontend/src/services/ridesService.test.ts b/src/BikeTracking.Frontend/src/services/ridesService.test.ts index 031b2b5..95e8c3f 100644 --- a/src/BikeTracking.Frontend/src/services/ridesService.test.ts +++ b/src/BikeTracking.Frontend/src/services/ridesService.test.ts @@ -90,47 +90,66 @@ describe("ridesService", () => { await expect(ridesService.recordRide(request)).rejects.toThrow(); }); - it("should return defaults from GET /api/rides/defaults", async () => { + it("should return ride presets from GET /api/rides/presets", async () => { const response = { - hasPreviousRide: true, - defaultMiles: 10.5, - defaultRideMinutes: 45, - defaultTemperature: 72, - defaultRideDateTimeLocal: new Date().toISOString(), + presets: [ + { + presetId: 1, + name: "Morning Commute", + primaryDirection: "SW", + periodTag: "morning", + exactStartTimeLocal: "07:45", + durationMinutes: 34, + miles: 7.2, + lastUsedAtUtc: null, + updatedAtUtc: new Date().toISOString(), + }, + ], + generatedAtUtc: new Date().toISOString(), }; fetchMock.mockResolvedValueOnce(jsonResponse(response, true)); - const result = await ridesService.getRideDefaults(); + const result = await ridesService.getRidePresets(); expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining("/api/rides/defaults"), - expect.any(Object), + expect.stringContaining("/api/rides/presets"), + expect.objectContaining({ method: "GET" }), ); - expect(result).toEqual(response); - expect(result.defaultMiles).toBe(10.5); + expect(result.presets).toHaveLength(1); + expect(result.presets[0].exactStartTimeLocal).toBe("07:45"); }); - it("should return quick ride options from GET /api/rides/quick-options", async () => { - const response = { - options: [ + it("should include selectedPresetId in POST /api/rides payload when provided", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( { - miles: 10.5, - rideMinutes: 45, - lastUsedAtLocal: "2026-03-30T07:30:00", + rideId: 77, + riderId: 1, + savedAtUtc: new Date().toISOString(), + eventStatus: "Queued", }, - ], - generatedAtUtc: new Date().toISOString(), + true, + ), + ); + + const request: ridesService.RecordRideRequest = { + rideDateTimeLocal: new Date().toISOString(), + miles: 9.1, + selectedPresetId: 42, }; - fetchMock.mockResolvedValueOnce(jsonResponse(response, true)); - const result = await ridesService.getQuickRideOptions(); + await ridesService.recordRide(request); expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining("/api/rides/quick-options"), - expect.objectContaining({ method: "GET" }), + expect.stringContaining("/api/rides"), + expect.objectContaining({ method: "POST" }), ); - expect(result.options).toHaveLength(1); - expect(result.options[0].miles).toBe(10.5); + + const options = fetchMock.mock.calls[0][1] as RequestInit; + const payload = JSON.parse((options.body ?? "{}") as string) as { + selectedPresetId?: number; + }; + expect(payload.selectedPresetId).toBe(42); }); it("should fetch ride history and return typed response", async () => { diff --git a/src/BikeTracking.Frontend/src/services/ridesService.ts b/src/BikeTracking.Frontend/src/services/ridesService.ts index 271ce9e..6cbe64f 100644 --- a/src/BikeTracking.Frontend/src/services/ridesService.ts +++ b/src/BikeTracking.Frontend/src/services/ridesService.ts @@ -34,6 +34,7 @@ export interface RecordRideRequest { weatherUserOverridden?: boolean; difficulty?: number; primaryTravelDirection?: CompassDirection; + selectedPresetId?: number; } export interface RecordRideSuccessResponse { @@ -43,20 +44,6 @@ export interface RecordRideSuccessResponse { eventStatus: string; } -export interface RideDefaultsResponse { - hasPreviousRide: boolean; - defaultMiles?: number; - defaultRideMinutes?: number; - defaultTemperature?: number; - defaultGasPricePerGallon?: number; - defaultWindSpeedMph?: number; - defaultWindDirectionDeg?: number; - defaultRelativeHumidityPercent?: number; - defaultCloudCoverPercent?: number; - defaultPrecipitationType?: string; - defaultRideDateTimeLocal: string; -} - export interface GasPriceResponse { date: string; pricePerGallon: number | null; @@ -75,17 +62,48 @@ export interface RideWeatherResponse { isAvailable: boolean; } -export interface QuickRideOption { +export type RidePresetPeriodTag = "morning" | "afternoon"; + +export const PERIOD_TAG_DEFAULT_DIRECTIONS: Record< + RidePresetPeriodTag, + CompassDirection +> = { + morning: "SW", + afternoon: "NE", +}; + +export interface RidePreset { + presetId: number; + name: string; + primaryDirection: CompassDirection; + periodTag: RidePresetPeriodTag; + exactStartTimeLocal: string; + durationMinutes: number; miles: number; - rideMinutes: number; - lastUsedAtLocal: string; + lastUsedAtUtc: string | null; + updatedAtUtc: string; } -export interface QuickRideOptionsResponse { - options: QuickRideOption[]; +export interface RidePresetsResponse { + presets: RidePreset[]; generatedAtUtc: string; } +export interface UpsertRidePresetRequest { + name: string; + primaryDirection: CompassDirection; + periodTag: RidePresetPeriodTag; + exactStartTimeLocal: string; + durationMinutes: number; + miles: number; +} + +export interface DeleteRidePresetResponse { + presetId: number; + deletedAtUtc: string; + message: string; +} + export interface EditRideRequest { rideDateTimeLocal: string; miles: number; @@ -255,21 +273,6 @@ export async function recordRide( return response.json(); } -export async function getRideDefaults(): Promise { - const response = await fetch(`${API_BASE_URL}/api/rides/defaults`, { - method: "GET", - headers: getAuthHeaders(), - }); - - if (!response.ok) { - throw new Error( - await parseErrorMessage(response, "Failed to fetch ride defaults"), - ); - } - - return response.json(); -} - export async function getGasPrice(date: string): Promise { const response = await fetch( `${API_BASE_URL}/api/rides/gas-price?date=${encodeURIComponent(date)}`, @@ -308,15 +311,75 @@ export async function getRideWeather( return response.json(); } -export async function getQuickRideOptions(): Promise { - const response = await fetch(`${API_BASE_URL}/api/rides/quick-options`, { +export async function getRidePresets(): Promise { + const response = await fetch(`${API_BASE_URL}/api/rides/presets`, { method: "GET", headers: getAuthHeaders(), }); if (!response.ok) { throw new Error( - await parseErrorMessage(response, "Failed to fetch quick ride options"), + await parseErrorMessage(response, "Failed to fetch ride presets"), + ); + } + + return response.json(); +} + +export async function createRidePreset( + request: UpsertRidePresetRequest, +): Promise { + const response = await fetch(`${API_BASE_URL}/api/rides/presets`, { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error( + await parseErrorMessage(response, "Failed to create ride preset"), + ); + } + + return response.json(); +} + +export async function updateRidePreset( + presetId: number, + request: UpsertRidePresetRequest, +): Promise { + const response = await fetch( + `${API_BASE_URL}/api/rides/presets/${presetId}`, + { + method: "PUT", + headers: getAuthHeaders(), + body: JSON.stringify(request), + }, + ); + + if (!response.ok) { + throw new Error( + await parseErrorMessage(response, "Failed to update ride preset"), + ); + } + + return response.json(); +} + +export async function deleteRidePreset( + presetId: number, +): Promise { + const response = await fetch( + `${API_BASE_URL}/api/rides/presets/${presetId}`, + { + method: "DELETE", + headers: getAuthHeaders(), + }, + ); + + if (!response.ok) { + throw new Error( + await parseErrorMessage(response, "Failed to delete ride preset"), ); } diff --git a/src/BikeTracking.Frontend/tests/e2e/delete-ride-history.spec.ts b/src/BikeTracking.Frontend/tests/e2e/delete-ride-history.spec.ts index 113e6f2..cd518dd 100644 --- a/src/BikeTracking.Frontend/tests/e2e/delete-ride-history.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/delete-ride-history.spec.ts @@ -51,11 +51,12 @@ test.describe("007-delete-ride-history e2e", () => { page.getByLabel("Visible total miles").getByText("15.0 mi"), ).toBeVisible(); - await page.getByRole("button", { name: "Delete" }).first().click(); - await expect( - page.getByRole("dialog", { name: /delete ride confirmation/i }), - ).toBeVisible(); - await page.getByRole("button", { name: /confirm delete/i }).click(); + const firstRow = page.locator("tbody tr").first(); + await firstRow.getByRole("button", { name: "Delete" }).click(); + + const deleteDialog = page.locator('[data-testid="delete-dialog"]'); + await expect(deleteDialog).toBeVisible(); + await deleteDialog.getByRole("button", { name: /confirm delete/i }).click(); await expect(page.getByRole("row")).toHaveCount(2); // header + 1 remaining ride await expect( @@ -86,15 +87,14 @@ test.describe("007-delete-ride-history e2e", () => { page.getByRole("table", { name: /ride history table/i }), ).toBeVisible(); - await page.getByRole("button", { name: "Delete" }).first().click(); - await expect( - page.getByRole("dialog", { name: /delete ride confirmation/i }), - ).toBeVisible(); - await page.getByRole("button", { name: /cancel/i }).click(); + const firstRow = page.locator("tbody tr").first(); + await firstRow.getByRole("button", { name: "Delete" }).click(); - await expect( - page.getByRole("dialog", { name: /delete ride confirmation/i }), - ).not.toBeVisible(); + const deleteDialog = page.locator('[data-testid="delete-dialog"]'); + await expect(deleteDialog).toBeVisible(); + await deleteDialog.getByRole("button", { name: /^Cancel$/i }).click(); + + await expect(deleteDialog).not.toBeVisible(); await expect( page.getByLabel("Visible total miles").getByText("7.5 mi"), ).toBeVisible(); @@ -135,8 +135,11 @@ test.describe("007-delete-ride-history e2e", () => { const rideId = historyPayload.rides[0]?.rideId; expect(typeof rideId).toBe("number"); - await page.getByRole("button", { name: "Delete" }).first().click(); - await page.getByRole("button", { name: /confirm delete/i }).click(); + const firstRow = page.locator("tbody tr").first(); + await firstRow.getByRole("button", { name: "Delete" }).click(); + const deleteDialog = page.locator('[data-testid="delete-dialog"]'); + await expect(deleteDialog).toBeVisible(); + await deleteDialog.getByRole("button", { name: /confirm delete/i }).click(); await expect(page.getByText(/no rides found/i)).toBeVisible(); const secondDeleteResponse = await request.delete( diff --git a/src/BikeTracking.Frontend/tests/e2e/edit-ride-history.spec.ts b/src/BikeTracking.Frontend/tests/e2e/edit-ride-history.spec.ts index b5ff950..61a895d 100644 --- a/src/BikeTracking.Frontend/tests/e2e/edit-ride-history.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/edit-ride-history.spec.ts @@ -37,21 +37,14 @@ test.describe("006-edit-ride-history e2e", () => { test("enters edit mode, modifies miles, saves successfully, and refreshes totals", async ({ page, }) => { - // Find the Edit button for the ride row and click it - const editButton = page.getByRole("button", { name: "Edit" }).first(); - await editButton.click(); + const firstRow = page.locator("tbody tr").first(); + await firstRow.getByRole("button", { name: "Edit" }).click(); - // Save and Cancel buttons should appear - await expect(page.getByRole("button", { name: /save/i })).toBeVisible(); - await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible(); - - // Change miles value - const milesInput = page.getByRole("spinbutton", { name: /miles/i }).first(); - await milesInput.clear(); + const milesInput = firstRow.locator('input[id^="edit-ride-miles-"]'); + await expect(milesInput).toBeVisible(); await milesInput.fill("8.5"); - // Click Save - await page.getByRole("button", { name: /save/i }).click(); + await firstRow.getByRole("button", { name: "Save" }).click(); // Verify the row updates to show new miles value in the grid await expect( @@ -72,17 +65,14 @@ test.describe("006-edit-ride-history e2e", () => { test("blocks save and shows validation message for invalid miles", async ({ page, }) => { - // Enter edit mode - const editButton = page.getByRole("button", { name: "Edit" }).first(); - await editButton.click(); + const firstRow = page.locator("tbody tr").first(); + await firstRow.getByRole("button", { name: "Edit" }).click(); - // Try to set miles to 0 - const milesInput = page.getByRole("spinbutton", { name: /miles/i }).first(); - await milesInput.clear(); + const milesInput = firstRow.locator('input[id^="edit-ride-miles-"]'); + await expect(milesInput).toBeVisible(); await milesInput.fill("0"); - // Click Save - await page.getByRole("button", { name: /save/i }).click(); + await firstRow.getByRole("button", { name: "Save" }).click(); // Expect validation error message await expect(page.getByRole("alert")).toContainText( @@ -90,7 +80,9 @@ test.describe("006-edit-ride-history e2e", () => { ); // Edit mode should remain active - await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible(); + await expect( + firstRow.getByRole("button", { name: "Cancel" }), + ).toBeVisible(); }); test("cancels edit and discards in-progress changes", async ({ page }) => { @@ -103,17 +95,14 @@ test.describe("006-edit-ride-history e2e", () => { .textContent(); expect(originalText).toContain("5.0 mi"); - // Enter edit mode - const editButton = page.getByRole("button", { name: "Edit" }).first(); - await editButton.click(); + const firstRow = page.locator("tbody tr").first(); + await firstRow.getByRole("button", { name: "Edit" }).click(); - // Modify miles but do NOT save - const milesInput = page.getByRole("spinbutton", { name: /miles/i }).first(); - await milesInput.clear(); + const milesInput = firstRow.locator('input[id^="edit-ride-miles-"]'); + await expect(milesInput).toBeVisible(); await milesInput.fill("99.0"); - // Click Cancel - await page.getByRole("button", { name: /cancel/i }).click(); + await firstRow.getByRole("button", { name: "Cancel" }).click(); // Edit mode should exit and original value should be restored await expect( @@ -153,14 +142,14 @@ test.describe("006-edit-ride-history e2e", () => { ).toBeVisible(); // Edit the ride to 10 miles - const editButton = page.getByRole("button", { name: "Edit" }).first(); - await editButton.click(); + const firstRow = page.locator("tbody tr").first(); + await firstRow.getByRole("button", { name: "Edit" }).click(); - const milesInput = page.getByRole("spinbutton", { name: /miles/i }).first(); - await milesInput.clear(); + const milesInput = firstRow.locator('input[id^="edit-ride-miles-"]'); + await expect(milesInput).toBeVisible(); await milesInput.fill("10.0"); - await page.getByRole("button", { name: /save/i }).click(); + await firstRow.getByRole("button", { name: "Save" }).click(); // Totals should update to reflect new 10 mi value const summaryCards = page.getByText(/this month/i); @@ -207,13 +196,22 @@ test.describe("006-edit-ride-history e2e", () => { page.getByRole("table", { name: /ride history table/i }), ).toBeVisible(); - await page.getByRole("button", { name: "Edit" }).first().click(); - await page.getByLabel("Temperature").first().fill(""); - await page.getByLabel("Wind Speed").first().fill(""); + const firstRow = page.locator("tbody tr").first(); + await firstRow.getByRole("button", { name: "Edit" }).click(); + + const temperatureInput = firstRow.locator( + 'input[id^="edit-ride-temperature-"]', + ); + const windSpeedInput = firstRow.locator( + 'input[id^="edit-ride-wind-speed-"]', + ); + await expect(temperatureInput).toBeVisible(); + await temperatureInput.fill(""); + await windSpeedInput.fill(""); - await page.getByRole("button", { name: "Load Weather" }).click(); + await firstRow.getByRole("button", { name: "Load Weather" }).click(); - await expect(page.getByLabel("Temperature").first()).toHaveValue("51.5"); - await expect(page.getByLabel("Wind Speed").first()).toHaveValue("8.4"); + await expect(temperatureInput).toHaveValue("51.5"); + await expect(windSpeedInput).toHaveValue("8.4"); }); }); diff --git a/src/BikeTracking.Frontend/tests/e2e/import-rides.spec.ts b/src/BikeTracking.Frontend/tests/e2e/import-rides.spec.ts index 473e334..22cc2be 100644 --- a/src/BikeTracking.Frontend/tests/e2e/import-rides.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/import-rides.spec.ts @@ -398,7 +398,7 @@ test.describe("013-csv-import e2e", () => { }); // Cancel the import - await page.getByRole("button", { name: /cancel/i }).click(); + await page.getByRole("button", { name: /cancel import/i }).click(); // Verify cancellation dialog or confirmation // Then confirm the cancellation diff --git a/src/BikeTracking.Frontend/tests/e2e/login-smoke.spec.ts b/src/BikeTracking.Frontend/tests/e2e/login-smoke.spec.ts index 1fcc222..6d5ea11 100644 --- a/src/BikeTracking.Frontend/tests/e2e/login-smoke.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/login-smoke.spec.ts @@ -1,5 +1,10 @@ import { test, expect, type Page } from "@playwright/test"; -import { loginUser, signupUser, uniqueUser } from "./support/auth-helpers"; +import { + loginUser, + logoutUser, + signupUser, + uniqueUser, +} from "./support/auth-helpers"; /** * T014 - E2E Smoke Test: User Login @@ -67,8 +72,7 @@ test.describe("003-user-login smoke tests", () => { await loginUser(page, userName, TEST_PIN); // Logout - await page.getByRole("button", { name: "Log out" }).click(); - await expect(page).toHaveURL("/login"); + await logoutUser(page, userName); }); test("signup page has link to /login", async ({ page }) => { diff --git a/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts b/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts index 342cee4..3182601 100644 --- a/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts @@ -4,10 +4,19 @@ import { saveUserLocation, uniqueUser, } from "./support/auth-helpers"; -import { recordRide, selectQuickRideOption } from "./support/ride-helpers"; +import { recordRide } from "./support/ride-helpers"; const TEST_PIN = "87654321"; +async function openSettingsFromUserMenu( + page: import("@playwright/test").Page, + userName: string, +) { + await page.getByRole("button", { name: userName }).click(); + await page.getByRole("link", { name: "Settings" }).click(); + await expect(page).toHaveURL("/settings"); +} + test.describe("004-record-ride e2e", () => { test("records a ride from the record page", async ({ page }) => { const userName = uniqueUser("e2e-record-ride"); @@ -20,51 +29,108 @@ test.describe("004-record-ride e2e", () => { }); }); - test("prefills defaults from the previous ride", async ({ page }) => { - const userName = uniqueUser("e2e-ride-defaults"); + test("manages presets in settings and applies MRU preset during ride entry", async ({ + page, + }) => { + const userName = uniqueUser("e2e-ride-presets"); await createAndLoginUser(page, userName, TEST_PIN); - await recordRide(page, { - miles: "9.75", - rideMinutes: "35", - temperature: "61", + await openSettingsFromUserMenu(page, userName); + + await page.getByLabel("Preset Name").fill("Morning Commute"); + await page.getByLabel("Duration Minutes").fill("34"); + await page.getByLabel("Miles").fill("8.4"); + await page.getByRole("button", { name: "Add Preset" }).click(); + + await expect(page.getByText(/preset created\./i)).toBeVisible(); + await expect(page.locator(".settings-presets-list")).toContainText( + "Morning Commute", + ); + + await page.getByLabel("Preset Name").fill("Afternoon Return"); + await page.getByLabel("Period Tag").selectOption("afternoon"); + await expect(page.getByLabel("Primary Direction")).toHaveValue("NE"); + await page.getByLabel("Exact Start Time").fill("17:35"); + await page.getByLabel("Duration Minutes").fill("32"); + await page.getByLabel("Miles").fill("9.1"); + await page.getByRole("button", { name: "Add Preset" }).click(); + + await expect(page.getByText(/preset created\./i)).toBeVisible(); + await expect(page.locator(".settings-presets-list")).toContainText( + "Afternoon Return", + ); + + const morningItem = page.locator(".settings-presets-item").filter({ + hasText: "Morning Commute", }); + await morningItem.getByRole("button", { name: "Edit" }).click(); + await page.getByLabel("Preset Name").fill("Morning Express"); + await page.getByRole("button", { name: "Save Preset" }).click(); - await page.goto("/miles"); - await page.getByRole("link", { name: "Record Ride" }).click(); - await expect(page).toHaveURL("/rides/record"); - - await expect(page.locator("#miles")).toHaveValue("9.75"); - await expect(page.locator("#rideMinutes")).toHaveValue("35"); - await expect(page.locator("#temperature")).toHaveValue("61"); - }); + await expect(page.getByText(/preset updated\./i)).toBeVisible(); + await expect(page.locator(".settings-presets-list")).toContainText( + "Morning Express", + ); - test("allows editing quick-option copied values before save", async ({ - page, - }) => { - const userName = uniqueUser("e2e-quick-option-edit"); - await createAndLoginUser(page, userName, TEST_PIN); + await page.goto("/rides/record"); + await expect(page).toHaveURL("/rides/record"); + await expect(page.getByText(/quick ride options/i)).toHaveCount(0); - await recordRide(page, { - miles: "8.50", - rideMinutes: "30", - temperature: "60", + const morningPresetOption = page.locator("#ridePreset option", { + hasText: "Morning Express", }); + await expect(morningPresetOption).toHaveCount(1); + const morningPresetValue = await morningPresetOption + .first() + .getAttribute("value"); + expect(morningPresetValue).not.toBeNull(); + + await page.getByLabel("Ride Preset").selectOption(morningPresetValue!); + await page.getByRole("button", { name: "Apply Preset" }).click(); + + await expect(page.getByLabel(/primary direction of travel/i)).toHaveValue( + "SW", + ); + await expect(page.locator("#rideMinutes")).toHaveValue("34"); + await expect(page.locator("#rideDateTimeLocal")).toHaveValue(/T07:45$/); + + await page.locator("#miles").fill("8.40"); + await page.getByRole("button", { name: "Record Ride" }).click(); + await expect(page.getByText(/ride recorded successfully/i)).toBeVisible(); await page.goto("/rides/record"); + const firstPresetOption = page.locator("#ridePreset option").nth(1); + await expect(firstPresetOption).toContainText("Morning Express"); + await expect(page.getByText(/quick ride options/i)).toHaveCount(0); - await selectQuickRideOption(page, /8\.5 mi\s*-\s*30 min/i); - - await page.locator("#miles").fill("9.25"); - await page.locator("#rideMinutes").fill("33"); + await page.goto("/settings"); + const afternoonItem = page.locator(".settings-presets-item").filter({ + hasText: "Afternoon Return", + }); + await afternoonItem.getByRole("button", { name: "Delete" }).click(); - await page.getByRole("button", { name: "Record Ride" }).click(); - await expect(page.getByText(/ride recorded successfully/i)).toBeVisible(); + await expect(page.getByText(/preset deleted\./i)).toBeVisible(); + await expect(page.locator(".settings-presets-list")).not.toContainText( + "Afternoon Return", + ); }); test("shows gas price, prepopulates it, and displays it in history", async ({ page, }) => { + await page.route("**/api/rides/gas-price?date=*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + date: "2026-04-03", + pricePerGallon: 3.4567, + dataSource: "Source: U.S. Energy Information Administration (EIA)", + isAvailable: true, + }), + }); + }); + const userName = uniqueUser("e2e-gas-price"); await createAndLoginUser(page, userName, TEST_PIN); diff --git a/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts b/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts index fa507df..e4e573a 100644 --- a/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from "@playwright/test"; -import { createAndLoginUser, uniqueUser } from "./support/auth-helpers"; +import { + createAndLoginUser, + logoutUser, + uniqueUser, +} from "./support/auth-helpers"; const TEST_PIN = "87654321"; @@ -18,8 +22,7 @@ test.describe("009-settings e2e", () => { await page.getByRole("button", { name: "Save Settings" }).click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible(); - await page.getByRole("button", { name: "Log out" }).click(); - await expect(page).toHaveURL("/login"); + await logoutUser(page, riderOne); await createAndLoginUser(page, riderTwo, TEST_PIN); diff --git a/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts b/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts index 1637cf1..2785ef1 100644 --- a/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts +++ b/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts @@ -37,6 +37,17 @@ export async function createAndLoginUser( await loginUser(page, userName, pin); } +export async function logoutUser(page: Page, userName: string): Promise { + await page.getByRole("button", { name: userName }).click(); + await page.evaluate(() => { + const logoutButton = document.querySelector( + ".header-logout-btn", + ) as HTMLButtonElement | null; + logoutButton?.click(); + }); + await expect(page).toHaveURL("/login"); +} + export async function saveUserLocation( page: Page, latitude: string, diff --git a/src/BikeTracking.Frontend/tests/e2e/support/ride-helpers.ts b/src/BikeTracking.Frontend/tests/e2e/support/ride-helpers.ts index 4540e42..784df79 100644 --- a/src/BikeTracking.Frontend/tests/e2e/support/ride-helpers.ts +++ b/src/BikeTracking.Frontend/tests/e2e/support/ride-helpers.ts @@ -42,10 +42,3 @@ export async function recordRide( await page.getByRole("button", { name: "Record Ride" }).click(); await expect(page.getByText(/ride recorded successfully/i)).toBeVisible(); } - -export async function selectQuickRideOption( - page: Page, - labelPattern: RegExp, -): Promise { - await page.getByRole("button", { name: labelPattern }).click(); -}