diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9b8e3ec..40854da 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -137,5 +137,5 @@ From `src/BikeTracking.Frontend`: For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan: -[specs/026-year-stats-dashboard/plan.md](../specs/026-year-stats-dashboard/plan.md) +[specs/027-mileage-rate-savings/plan.md](../specs/027-mileage-rate-savings/plan.md) diff --git a/.specify/feature.json b/.specify/feature.json index 01e23c7..1225662 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/026-year-stats-dashboard" + "feature_directory": "specs/027-mileage-rate-savings" } diff --git a/specs/012-dashboard-stats/research.md b/specs/012-dashboard-stats/research.md index 8109112..6c1bccd 100644 --- a/specs/012-dashboard-stats/research.md +++ b/specs/012-dashboard-stats/research.md @@ -121,7 +121,7 @@ introducing Tailwind. ## Decision 6: Expose two baseline savings calculations plus a combined total **Decision**: The dashboard money-saved model will include: -- `MileageRateSavings`: `miles * mileageRateCents / 100` +- `MileageRateSavings`: `miles * mileageRateCents` - `FuelCostAvoided`: `(miles / averageCarMpg) * gasPricePerGallon` - `CombinedSavings`: sum of the two only when both components are calculable for the included rides diff --git a/specs/018-advanced-dashboard/data-model.md b/specs/018-advanced-dashboard/data-model.md index 9584dfc..a0ecc14 100644 --- a/specs/018-advanced-dashboard/data-model.md +++ b/specs/018-advanced-dashboard/data-model.md @@ -78,7 +78,7 @@ fuel_cost_estimated = any ride in window has GasPricePerGallon IS NULL ### Mileage Rate Savings (per window) ``` -mileage_rate_savings_for_ride = ride.Miles × ride.SnapshotMileageRateCents / 100 +mileage_rate_savings_for_ride = ride.Miles × ride.SnapshotMileageRateCents total_mileage_rate_savings = Σ mileage_rate_savings_for_ride mileage_rate_savings = null if no rides have SnapshotMileageRateCents set diff --git a/specs/018-advanced-dashboard/research.md b/specs/018-advanced-dashboard/research.md index 4377c07..6f2469f 100644 --- a/specs/018-advanced-dashboard/research.md +++ b/specs/018-advanced-dashboard/research.md @@ -54,7 +54,7 @@ The response includes `FuelCostEstimated = true` for a given time window when an **Question**: What is the formula and data source for mileage-rate savings? -**Decision**: Mileage-rate savings use **per-ride `SnapshotMileageRateCents`** (already stored as of spec 012). Formula per ride: `miles × snapshotMileageRateCents / 100`. Summed across all rides in a window. +**Decision**: Mileage-rate savings use **per-ride `SnapshotMileageRateCents`** (already stored as of spec 012). Formula per ride: `miles × snapshotMileageRateCents`. Summed across all rides in a window. If `SnapshotMileageRateCents IS NULL` for a ride, that ride contributes $0 to mileage-rate savings. If the user's current `MileageRateCents` setting is NULL, the reminder card is shown. The reminder flag is derived from `UserSettings.MileageRateCents IS NULL` — not from ride snapshots (a user may have set it after early rides). diff --git a/specs/027-mileage-rate-savings/checklists/requirements.md b/specs/027-mileage-rate-savings/checklists/requirements.md new file mode 100644 index 0000000..b3dafec --- /dev/null +++ b/specs/027-mileage-rate-savings/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Split Savings Display Metrics + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-09 +**Feature**: [/workspaces/neCodeBikeTracking/specs/027-mileage-rate-savings/spec.md](/workspaces/neCodeBikeTracking/specs/027-mileage-rate-savings/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 complete: all checklist items passed. diff --git a/specs/027-mileage-rate-savings/contracts/dashboard-split-savings-contract.md b/specs/027-mileage-rate-savings/contracts/dashboard-split-savings-contract.md new file mode 100644 index 0000000..d3bfba4 --- /dev/null +++ b/specs/027-mileage-rate-savings/contracts/dashboard-split-savings-contract.md @@ -0,0 +1,34 @@ +# Contract: Dashboard Split Savings Metrics + +Base endpoint: `GET /api/dashboard` + +This feature keeps the existing dashboard endpoint and requires the consumer-facing savings section to expose two independently displayable metrics in the response consumed by the dashboard page. + +## Response Fragment (savings section) + +```json +{ + "totals": { + "moneySaved": { + "mileageRateSavings": 42.5, + "fuelCostAvoided": 18.2, + "qualifiedRideCount": 7 + } + } +} +``` + +## Consumer Rules for Spec #27 + +1. Render **Mileage rate savings** from `totals.moneySaved.mileageRateSavings`. +2. Render **Gallons-based savings** from `totals.moneySaved.fuelCostAvoided` (field name remains unchanged in this feature). +3. `totals.moneySaved.combinedSavings` is removed from this contract for the split-savings scope. +4. Preserve existing currency formatting and rounding conventions. +5. Backend and frontend assertions must explicitly verify split fields are present and `combinedSavings` is absent in `totals.moneySaved`. + +## Formula Requirements (Spec Source of Truth) + +- Mileage rate savings = `configuredMileageRate * periodMiles` (rate source: `UserSettings.MileageRateCents` for dashboard aggregations) +- Gallons-based savings = `gallonsSaved * miles` + +Backend contracts and frontend TypeScript models/tests must stay synchronized for these existing fields in the same change. diff --git a/specs/027-mileage-rate-savings/data-model.md b/specs/027-mileage-rate-savings/data-model.md new file mode 100644 index 0000000..5bb6887 --- /dev/null +++ b/specs/027-mileage-rate-savings/data-model.md @@ -0,0 +1,45 @@ +# Phase 1 Data Model: Split Savings Display Metrics + +No new persisted entities or migrations are required. This feature updates computed/read models used by dashboard/results presentation. + +## Existing Entities Read + +### `RideEntity` (unchanged schema) +- `Miles: decimal` +- Inputs required to derive gallons-based metric as defined in spec (`gallonsSaved` source from existing ride snapshot-derived data path) + +### `UserSettingsEntity` (unchanged schema) +- `MileageRateCents: decimal?` (rate source for dashboard month/year mileage-rate savings aggregation) + +### `DashboardMoneySaved` projection (`src/BikeTracking.Api/Contracts/DashboardContracts.cs`) +- `MileageRateSavings: decimal?` +- `FuelCostAvoided: decimal?` (mapped to displayed "Gallons-based savings" metric) +- `QualifiedRideCount: int` + +## Feature-Level Derived Metrics + +### Mileage Rate Savings +- **Label**: `Mileage rate savings` +- **Formula**: `configuredMileageRate * periodMiles` +- **Validation/Rules**: + - Uses current user settings `MileageRateCents` as rate source for dashboard period totals. + - Renders as formatted currency with current rounding rules. + - Zero values still render. + +### Gallons-Based Savings +- **Label**: `Gallons-based savings` +- **Formula**: `gallonsSaved * miles` +- **Validation/Rules**: + - Uses the existing dashboard savings data path for `gallonsSaved` derivation. + - Renders as formatted currency with current rounding rules. + - Zero values still render. + +## Presentation State Rules + +- Two separate lines must always be rendered together in the dashboard/results summary section. +- The target view must not show a merged single-total savings value. +- Existing formatting/rounding and missing-data fallback behavior remain unchanged. + +## Relationship/Flow + +`UserSettingsEntity.MileageRateCents` + `RideEntity.Miles` → `GetDashboardService` / `GetYearStatsDashboardService` aggregation → `DashboardMoneySaved` / `YearStatsSavingsPoint` contract → frontend typed model → split metric display diff --git a/specs/027-mileage-rate-savings/plan.md b/specs/027-mileage-rate-savings/plan.md new file mode 100644 index 0000000..d55bac8 --- /dev/null +++ b/specs/027-mileage-rate-savings/plan.md @@ -0,0 +1,87 @@ +# Implementation Plan: Split Savings Display Metrics + +**Branch**: `027-mileage-rate-savings` | **Date**: 2026-07-09 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/027-mileage-rate-savings/spec.md` + +## Summary + +Split the dashboard/results savings presentation into two explicit metrics and stop presenting a merged single-total savings value in that view. Keep scope limited to savings calculation/display, preserve existing formatting/rounding behavior, and keep backend/frontend tests aligned with the two formulas defined in the spec: +- Mileage rate savings = `configuredMileageRate * periodMiles` (using `UserSettings.MileageRateCents` for dashboard period aggregations) +- Gallons-based savings = `gallonsSaved * miles` +- Canonical mapping: UI label "Gallons-based savings" -> contract field `totals.moneySaved.fuelCostAvoided` + +## Technical Context + +**Language/Version**: .NET 10 (C# backend), F# 9 (domain), TypeScript 5.x + React 19 (frontend) + +**Primary Dependencies**: ASP.NET Core Minimal API, EF Core (SQLite), React Router, xUnit, Vitest + React Testing Library, Playwright + +**Storage**: SQLite via existing ride/settings entities (no new storage engine) + +**Testing**: `dotnet test BikeTracking.slnx`, `cd src/BikeTracking.Frontend && npm run test:unit`, `cd src/BikeTracking.Frontend && npm run test:e2e` + +**Target Platform**: Local-first web app (Aspire-hosted API + React frontend) + +**Project Type**: Web application (backend + frontend) + +**Performance Goals**: Out of scope for this feature; no explicit performance acceptance target added + +**Constraints**: +- Do not broaden to unrelated dashboard redesign work +- Keep currency/unit/rounding behavior unchanged +- Keep ride-entry and persisted data shape unchanged +- Keep mileage-rate source aligned to current settings for dashboard month/year aggregation logic + +**Scale/Scope**: Single dashboard/results savings card/section and its backing aggregation/tests + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Directive | Status | Notes | +|-----------|--------|-------| +| DevContainer environment | ✅ PASS | No environment/tooling change required | +| Trunk-based delivery + PR flow | ✅ PASS | Work remains on existing feature branch | +| TDD mandatory | ✅ PASS | Plan requires backend + frontend failing tests first | +| E2E required on every PR | ✅ PASS | Add/adjust Playwright coverage for split metrics | +| Ports/adapters boundaries | ✅ PASS | Changes remain in existing dashboard service + contracts + UI layers | +| Result-style domain outcomes | ✅ PASS | No exception-driven flow introduced | +| Transactional relational write model | ✅ PASS (N/A) | Read/compute/display update only; no new writes | +| Local-first runtime posture | ✅ PASS | SQLite + local stack unchanged | + +**Post-design re-check (Phase 1)**: ✅ All gates remain green. Design remains additive/tight and does not introduce new services, infra, or persistence shape changes. + +## Project Structure + +### Documentation (this feature) + +```text +specs/027-mileage-rate-savings/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── dashboard-split-savings-contract.md +└── tasks.md # generated later by /speckit.tasks +``` + +### Source Code (repository root) + +```text +src/BikeTracking.Api/ +├── Application/Dashboard/GetDashboardService.cs # update savings aggregation output as needed +├── Contracts/DashboardContracts.cs # keep split fields explicit; remove merged display dependency +└── Tests via src/BikeTracking.Api.Tests/Application/Dashboard/ + +src/BikeTracking.Frontend/src/ +├── pages/dashboard/dashboard-page.tsx # render two labeled metrics; remove merged line in summary display +├── services/dashboard-api.ts # keep frontend contract/types aligned +└── tests under pages/dashboard and tests/e2e + +src/BikeTracking.Frontend/tests/e2e/ +└── dashboard.spec.ts / savings-calculation.spec.ts # validate both metrics appear together +``` + +**Structure Decision**: Use existing web-app layout and modify only existing dashboard service/contracts/UI/test files directly tied to split-savings behavior. diff --git a/specs/027-mileage-rate-savings/quickstart.md b/specs/027-mileage-rate-savings/quickstart.md new file mode 100644 index 0000000..9e297c7 --- /dev/null +++ b/specs/027-mileage-rate-savings/quickstart.md @@ -0,0 +1,55 @@ +# Quickstart Validation: Split Savings Display Metrics + +Use this to validate spec #27 end-to-end after implementation. + +## Prerequisites + +1. Run app stack: + - `dotnet run --project src/BikeTracking.AppHost` +2. Have rider account with ride data containing miles, plus configured user mileage rate (`MileageRateCents`) in settings. + +## Backend Validation + +1. Run backend tests: + - `dotnet test BikeTracking.slnx` +2. Confirm dashboard aggregation tests assert both formulas independently (mileage-rate and gallons-based). + +## Frontend Validation + +1. Run frontend unit tests: + - `cd src/BikeTracking.Frontend && npm run test:unit` +2. Run E2E tests: + - `cd src/BikeTracking.Frontend && npm run test:e2e` +3. Confirm tests verify: + - Both labels render: **Mileage rate savings** and **Gallons-based savings** + - Both values render simultaneously + - No merged single-total savings display in the target view + +## Manual Check + +1. Log in and open dashboard/results view. +2. Verify two separate savings lines are present with expected labels. +3. Verify values match spec formulas. +4. Verify formatting/rounding behavior matches prior behavior. +5. Verify zero values still render (not hidden). + +## Final Validation Matrix + +| Area | Command/Check | Expected Result | +|------|----------------|-----------------| +| Backend suite | `dotnet test BikeTracking.slnx` | Pass; dashboard tests assert split formulas and no merged field in `/api/dashboard` payload | +| Frontend unit suite | `cd src/BikeTracking.Frontend && npm run test:unit` | Pass; dashboard page tests assert both labels, rounded currency values, and zero-value visibility | +| Frontend E2E suite | `cd src/BikeTracking.Frontend && npm run test:e2e` | Pass; split savings rows render together and merged savings label is absent | +| Manual dashboard check | Open dashboard/results after login | Both split labels visible with correctly formatted values and no merged line | + +## Command Checklist + +- [ ] `dotnet test BikeTracking.slnx` +- [ ] `cd src/BikeTracking.Frontend && npm run test:unit` +- [ ] `cd src/BikeTracking.Frontend && npm run test:e2e` + +## References + +- Spec: [spec.md](spec.md) +- Data model: [data-model.md](data-model.md) +- Contract: [contracts/dashboard-split-savings-contract.md](contracts/dashboard-split-savings-contract.md) diff --git a/specs/027-mileage-rate-savings/research.md b/specs/027-mileage-rate-savings/research.md new file mode 100644 index 0000000..4ab0472 --- /dev/null +++ b/specs/027-mileage-rate-savings/research.md @@ -0,0 +1,45 @@ +# Phase 0 Research: Split Savings Display Metrics + +## Decision 1: Keep scope to existing dashboard/results path + +- **Decision**: Limit changes to the existing dashboard summary savings presentation and its direct backend aggregation/contract path (`GetDashboardService`, `DashboardContracts`, dashboard page/types/tests). +- **Rationale**: The spec is narrowly focused on splitting displayed savings metrics and preserving existing behavior elsewhere. +- **Alternatives considered**: Broad dashboard-wide savings redesign (rejected as out-of-scope). + +## Decision 2: Treat spec formulas as authoritative for this feature + +- **Decision**: Use the formulas defined in the feature spec as the source of truth: + - Mileage rate savings = `configuredMileageRate * periodMiles` (using current user settings mileage rate for dashboard month/year aggregation). + - Gallons-based savings = `gallonsSaved * miles`. +- **Rationale**: The user requested that planning stay tight and use formulas already defined in spec #27. +- **Alternatives considered**: Preserving legacy merged-savings semantics only (rejected because it conflicts with FR-001..FR-004). + +## Decision 3: Preserve formatting/rounding rules by reusing current formatting utilities + +- **Decision**: Keep existing currency/unit/rounding behavior in the frontend formatter and backend rounding utilities; only split labeling/value wiring. +- **Rationale**: FR-005 explicitly requires unchanged presentation conventions. +- **Alternatives considered**: Introducing new formatting logic for split metrics (rejected to avoid regressions). + +## Decision 4: Keep contract alignment explicit + +- **Decision**: Document and validate the `/api/dashboard` money-saved fields used for split display, and synchronize backend/frontend tests if any field semantics are adjusted. +- **Rationale**: FR-007/FR-008 require contract and test synchronization across layers. +- **Alternatives considered**: Frontend-only change without contract verification (rejected because it can hide backend/frontend drift). + +## Open Items + +None. Technical context clarifications are resolved for planning scope. + +## Post-Implementation Notes + +### Confirmed Decisions + +- Dashboard `moneySaved` contract remains split-only (`mileageRateSavings`, `fuelCostAvoided`, `qualifiedRideCount`) and omits `combinedSavings` for this endpoint. +- Split savings rows continue using existing currency formatting and away-from-zero rounding conventions. +- Ride-entry create/edit behavior remains unchanged; no new ride contracts, entities, or migrations were introduced. + +### Non-Goals Reaffirmed + +- No redesign of advanced dashboard UX beyond keeping savings formula source aligned with dashboard month/year settings-based aggregation. +- No schema or migration work for ride-entry persistence. +- No changes to gas-price/weather collection behavior outside existing ride-entry flows. diff --git a/specs/027-mileage-rate-savings/spec.md b/specs/027-mileage-rate-savings/spec.md new file mode 100644 index 0000000..2633416 --- /dev/null +++ b/specs/027-mileage-rate-savings/spec.md @@ -0,0 +1,100 @@ +# Feature Specification: Split Savings Display Metrics + +**Feature Branch**: `027-mileage-rate-savings` + +**Created**: 2026-07-09 + +**Status**: Draft + +**Input**: User description: "Split savings display into 2 separate metrics in dashboard/results view." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - View two separate savings metrics (Priority: P1) + +As a rider reviewing ride results, I want to see Mileage rate savings and Gallons-based savings as two distinct lines, so I can understand each savings calculation independently instead of a merged value. + +**Why this priority**: This is the core business change and the primary user-facing outcome. + +**Independent Test**: Open a ride summary with valid ride data and verify two separate savings lines are displayed with the correct labels and formulas. + +**Acceptance Scenarios**: + +1. **Given** dashboard aggregation for a month/year window with valid configured `mileageRate` and ride `miles`, **When** the dashboard/results view renders, **Then** it shows a line labeled "Mileage rate savings" with value `configuredMileageRate * periodMiles`. +2. **Given** a ride summary with valid `gallonsSaved` and `miles`, **When** the dashboard/results view renders, **Then** it shows a line labeled "Gallons-based savings" with value `gallonsSaved * miles`. +3. **Given** both savings lines are displayed, **When** a rider reviews the summary, **Then** the values are not merged into a single total. + +--- + +### User Story 2 - Preserve existing presentation behavior (Priority: P2) + +As a rider, I want the same units, currency formatting, and rounding behavior to remain unchanged while the savings values are split, so the display is consistent with existing expectations. + +**Why this priority**: Prevents confusion and regression in trusted formatting while introducing the new split display. + +**Independent Test**: Compare old and new display formatting on identical ride inputs and verify only metric separation changed. + +**Acceptance Scenarios**: + +1. **Given** a ride summary rendered before and after this feature, **When** values are displayed, **Then** units, currency format, and rounding rules remain unchanged. +2. **Given** ride data is entered through create/edit ride-entry flows, **When** this feature is enabled, **Then** create/edit behavior and persisted ride data shape remain unchanged. + +--- + +### User Story 3 - Keep backend/frontend validation aligned (Priority: P3) + +As a team maintaining both backend and frontend, we want automated tests and consumer contracts to reflect the two savings metrics, so regressions are caught quickly across layers. + +**Why this priority**: Ensures confidence that formula and labeling changes are consistently enforced. + +**Independent Test**: Run backend, frontend, and end-to-end automated tests and verify explicit coverage for both formulas and labels. + +**Acceptance Scenarios**: + +1. **Given** backend savings computation tests, **When** tests run, **Then** they assert both formulas independently. +2. **Given** frontend rendering tests, **When** tests run, **Then** they assert both labels and both separate values are shown. +3. **Given** an end-to-end ride summary flow, **When** tests run, **Then** both savings lines appear together on the same summary screen. +4. **Given** dashboard API responses are consumed by frontend types, **When** this feature is implemented, **Then** `combinedSavings` is removed from the `moneySaved` contract and automated tests verify backend/frontend alignment for remaining split fields. + +### Edge Cases + +- When either computed value is zero, both lines still render with zero-valued formatted output rather than hiding one metric. +- Negative or unexpected source values must follow existing validation/error-display behavior; this feature does not introduce new data-entry rules. +- Historical rides must render the two split metrics consistently, without mutating stored ride records. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST calculate and display "Mileage rate savings" as `configuredMileageRate * periodMiles` for dashboard month/year aggregations, using current user settings mileage rate (`MileageRateCents`) as rate source. +- **FR-002**: System MUST calculate and display "Gallons-based savings" as `gallonsSaved * miles`. +- **FR-003**: System MUST render the two savings metrics as separate lines in the dashboard/results ride summary view. +- **FR-004**: System MUST NOT combine the two savings metrics into a single displayed total in that view. +- **FR-005**: System MUST preserve existing units, currency formatting, and rounding rules for both displayed metrics. +- **FR-006**: System MUST keep create/edit ride-entry flows unchanged and MUST keep persisted ride data shape unchanged. +- **FR-007**: System MUST remove `combinedSavings` from the dashboard `moneySaved` contract and MUST keep frontend-consumed contract/types and backend/frontend automated tests synchronized with remaining split fields (`mileageRateSavings`, `fuelCostAvoided`). +- **FR-008**: Automated test coverage MUST include backend formula validation, frontend label/value rendering validation, and end-to-end validation that both metrics appear on the same ride summary screen. + +**Terminology Note**: User-facing label **"Gallons-based savings"** maps to dashboard contract field `totals.moneySaved.fuelCostAvoided`. + +### Key Entities + +- **Ride Summary Metrics**: The displayed savings values derived from ride inputs and settings for a single ride summary view. +- **Mileage Rate Savings**: Derived metric computed as `configuredMileageRate * periodMiles` in dashboard month/year aggregations, labeled "Mileage rate savings." +- **Gallons-Based Savings**: Derived metric computed as `gallonsSaved * miles`, labeled "Gallons-based savings." + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of ride summary screens with valid ride data display two distinct savings lines with the specified labels. +- **SC-002**: In verification tests, 100% of deterministic fixture matrix (minimum: zero-value, standard-value, settings-rate, missing-rate, and historical-ride cases) match expected values for both formulas independently. +- **SC-003**: 0 instances of merged single-total savings display remain in the dashboard/results ride summary view. +- **SC-004**: Backend, frontend, and end-to-end test suites each include at least one passing test that verifies both split savings metrics. + +## Assumptions + +- Existing ride data plus user mileage-rate settings include inputs needed to compute both savings metrics. +- Existing formatting and rounding logic is authoritative and reused unchanged. +- No new user roles or permissions are needed for showing split savings metrics. +- This feature is limited to splitting savings presentation and related contract/test alignment, not broader dashboard redesign. diff --git a/specs/027-mileage-rate-savings/tasks.md b/specs/027-mileage-rate-savings/tasks.md new file mode 100644 index 0000000..e1de7cd --- /dev/null +++ b/specs/027-mileage-rate-savings/tasks.md @@ -0,0 +1,172 @@ +# Tasks: Split Savings Display Metrics + +**Feature Branch**: `027-mileage-rate-savings` +**Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) | **Data Model**: [data-model.md](data-model.md) | **Contracts**: [contracts/dashboard-split-savings-contract.md](contracts/dashboard-split-savings-contract.md) | **Research**: [research.md](research.md) | **Quickstart**: [quickstart.md](quickstart.md) + +--- + +## Phase 1: Setup + +**Purpose**: Prepare deterministic test fixtures for split-savings work. + +- [X] T001 [P] Add deterministic backend ride fixture setup for split-savings scenarios in src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs +- [X] T002 [P] Add deterministic dashboard payload fixture helper for savings-card tests in src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared test/contract scaffolding that blocks all user-story implementation. + +- [X] T003 [P] Add split `moneySaved` contract fixture coverage for `mileageRateSavings` and `fuelCostAvoided` in src/BikeTracking.Frontend/src/services/dashboard-api.test.ts +- [X] T004 [P] Add reusable savings-row locator helper for dashboard E2E assertions in src/BikeTracking.Frontend/tests/e2e/support/expense-helpers.ts + +**Checkpoint**: Foundational scaffolding complete; user story work can proceed. + +--- + +## Phase 3: User Story 1 - View two separate savings metrics (Priority: P1) 🎯 MVP + +**Goal**: Show two explicit savings lines using the defined formulas and labels, with no merged display line. + +**Independent Test**: Open dashboard/results with valid ride data and verify both labels render with values from their respective formulas. + +### Tests for User Story 1 + +- [X] T005 [P] [US1] Write failing backend tests for `configuredMileageRate * periodMiles` (settings-based) and `gallonsSaved * miles` formulas in src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs +- [X] T006 [P] [US1] Write failing dashboard rendering tests for labels "Mileage rate savings" and "Gallons-based savings" with no merged display line in src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx + +### Implementation for User Story 1 + +- [X] T007 [US1] Implement split-savings totals aggregation with spec formulas in src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs +- [X] T025 [US1] Align year-stats monthly/year totals mileage-rate math to settings-based period formula in src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs and src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs +- [X] T008 [US1] Render split savings rows and remove merged savings line from the target summary card in src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx +- [X] T009 [US1] Align dashboard response consumption for split savings fields in src/BikeTracking.Frontend/src/services/dashboard-api.ts + +**Checkpoint**: US1 is independently functional and testable. + +--- + +## Phase 4: User Story 2 - Preserve existing presentation behavior (Priority: P2) + +**Goal**: Keep currency/unit formatting and rounding behavior unchanged while split metrics are displayed. + +**Independent Test**: Compare dashboard output for the same ride data and confirm only metric separation changed, not formatting/rounding. + +### Tests for User Story 2 + +- [X] T010 [P] [US2] Add failing frontend regression tests for currency formatting, rounding, and zero-value visibility on both split lines in src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx +- [X] T011 [P] [US2] Add failing backend regression tests for existing rounding/null behavior on split savings totals in src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs +- [X] T021 [P] [US2] Add failing ride-entry regression coverage proving create/edit ride flow behavior and persisted ride shape are unchanged by split-savings work in src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs and src/BikeTracking.Frontend/tests/e2e/record-ride.spec.ts +- [X] T023 [P] [US2] Add failing frontend edit-flow invariant coverage proving edit ride history behavior/validation is unchanged by split-savings work in src/BikeTracking.Frontend/tests/e2e/edit-ride-history.spec.ts + +### Implementation for User Story 2 + +- [X] T012 [US2] Ensure split rows reuse existing currency formatting and render zero values in src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx +- [X] T013 [US2] Preserve existing money-rounding/null-handling behavior for split fields in src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs +- [X] T022 [US2] Keep create/edit ride-entry contracts/entities/migrations unchanged and satisfy T021/T023 without adding ride-entry schema changes in src/BikeTracking.Api/Contracts and src/BikeTracking.Api/Infrastructure/Persistence/Migrations + +**Checkpoint**: US2 formatting behavior is independently verified. + +--- + +## Phase 5: User Story 3 - Keep backend/frontend validation aligned (Priority: P3) + +**Goal**: Keep backend contract, frontend types, and automated tests synchronized for split-savings behavior. + +**Independent Test**: Run backend, frontend, and E2E suites and confirm explicit assertions for both formulas and both labels. + +### Tests for User Story 3 + +- [X] T014 [P] [US3] Add failing endpoint-level contract assertions for split `moneySaved` fields and explicit absence of `combinedSavings` in src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs +- [X] T015 [P] [US3] Add failing API client sync tests that remove `combinedSavings` typing/fixtures and validate split fields in src/BikeTracking.Frontend/src/services/dashboard-api.test.ts +- [X] T016 [P] [US3] Add failing E2E assertions that both split lines render together and merged savings is absent in src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts + +### Implementation for User Story 3 + +- [X] T017 [US3] Update split-savings contract notes to document removal of `combinedSavings` and retain `mileageRateSavings`/`fuelCostAvoided` mapping in specs/027-mileage-rate-savings/contracts/dashboard-split-savings-contract.md +- [X] T018 [US3] Remove `combinedSavings` from frontend `DashboardMoneySaved` typing/fixtures and sync with backend contract in src/BikeTracking.Frontend/src/services/dashboard-api.ts and src/BikeTracking.Frontend/src/services/dashboard-api.test.ts +- [X] T024 [US3] Remove `combinedSavings` from backend dashboard contracts/projections and synchronize endpoint payload shape in src/BikeTracking.Api/Contracts/DashboardContracts.cs and src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs + +**Checkpoint**: US3 cross-layer alignment is independently verified. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation and implementation-ready handoff notes for this narrow scope. + +- [X] T019 [P] Add final split-savings validation matrix and command checklist in specs/027-mileage-rate-savings/quickstart.md +- [X] T020 [P] Record post-implementation split-savings decisions and non-goals in specs/027-mileage-rate-savings/research.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; MVP delivery phase. +- **Phase 4 (US2)**: Depends on US1 split-line implementation (Phase 3). +- **Phase 5 (US3)**: Depends on US1 contract behavior; can run in parallel with late US2 hardening once split fields are stable. +- **Phase 6 (Polish)**: Depends on completed stories. + +### User Story Dependencies + +- **US1 (P1)**: Starts immediately after foundational phase. +- **US2 (P2)**: Depends on US1 rendering path being in place. +- **US3 (P3)**: Depends on US1 contract/rendering semantics; largely independent of US2 styling details. + +### Within Each User Story + +- Write tests first and confirm they fail (TDD red). +- Implement backend/frontend updates to satisfy tests. +- Re-run story-specific tests before moving to next priority. + +--- + +## Parallel Opportunities + +- **Phase 1**: T001 and T002 can run in parallel. +- **Phase 2**: T003 and T004 can run in parallel. +- **US1**: T005 and T006 can run in parallel; T007 and T008 can run in parallel after failing tests are in place. +- **US2**: T010 and T011 can run in parallel. +- **US3**: T014, T015, and T016 can run in parallel. +- **Polish**: T019 and T020 can run in parallel. + +## Parallel Example: User Story 1 + +```bash +Task: "T005 [US1] backend formula tests in src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs" +Task: "T006 [US1] frontend label/render tests in src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx" +``` + +## Parallel Example: User Story 3 + +```bash +Task: "T014 [US3] endpoint contract assertions in src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs" +Task: "T015 [US3] API client sync tests in src/BikeTracking.Frontend/src/services/dashboard-api.test.ts" +Task: "T016 [US3] E2E assertions in src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (US1 only) + +1. Complete Phase 1 and Phase 2. +2. Deliver Phase 3 (US1) end-to-end. +3. Validate labels/formulas and absence of merged display in dashboard/results. + +### Incremental Delivery + +1. Ship US1 (split metrics visible and correct). +2. Add US2 (format/rounding parity hardening). +3. Add US3 (cross-layer contract/test synchronization). + +### Scope Guardrails + +- Keep changes limited to split savings display and its direct backend/frontend contracts/tests. +- Do not broaden into unrelated dashboard redesign or ride-entry/storage changes. diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs index 4b55858..f3faf21 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs @@ -247,7 +247,7 @@ public async Task GetAdvancedDashboardService_CombinedSavingsExceed50_MilestoneS using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "Milestone Rider"); - // Mileage rate savings: 100 miles × $0.67 = $67 > $50 + // Mileage rate savings: 100 miles × 67 = 6700 > 50 dbContext.Rides.Add( new RideEntity { @@ -365,8 +365,8 @@ public async Task GetAdvancedDashboardService_MileageRateSavings_ComputedCorrect var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); - // 10 miles × $0.67 = $6.70 - Assert.Equal(6.70m, result.SavingsWindows.AllTime.MileageRateSavings); + // 10 miles × 67 = 670 + Assert.Equal(670m, result.SavingsWindows.AllTime.MileageRateSavings); } // ── US5: Expenses in Savings Breakdown ──────────────────────────────── @@ -458,10 +458,10 @@ public async Task GetAdvancedDashboardService_WithExpenses_NetSavingsIsCombinedM var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); - // 100 miles × $0.67 = $67 combined savings - $30 expenses = $37 net - Assert.Equal(67m, result.SavingsWindows.AllTime.CombinedSavings); + // 100 miles × 67 = 6700 combined savings - 30 expenses = 6670 net + Assert.Equal(6700m, result.SavingsWindows.AllTime.CombinedSavings); Assert.Equal(30m, result.SavingsWindows.AllTime.TotalExpenses); - Assert.Equal(37m, result.SavingsWindows.AllTime.NetSavings); + Assert.Equal(6670m, result.SavingsWindows.AllTime.NetSavings); } [Fact] @@ -480,13 +480,13 @@ public async Task GetAdvancedDashboardService_ExpensesExceedSavings_NetSavingsIs CreatedAtUtc = DateTime.UtcNow, } ); - // $20 expense — more than the $6.70 in savings + // 700 expense exceeds 10 × 67 = 670 savings dbContext.Expenses.Add( new ExpenseEntity { RiderId = rider.UserId, ExpenseDate = DateTime.Now.AddMonths(-1), - Amount = 20m, + Amount = 700m, IsDeleted = false, CreatedAtUtc = DateTime.UtcNow, UpdatedAtUtc = DateTime.UtcNow, @@ -497,7 +497,7 @@ public async Task GetAdvancedDashboardService_ExpensesExceedSavings_NetSavingsIs var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); - // Net savings should be negative: $6.70 - $20 = -$13.30 + // Net savings should be negative: 670 - 700 = -30 Assert.NotNull(result.SavingsWindows.AllTime.NetSavings); Assert.True(result.SavingsWindows.AllTime.NetSavings < 0m); } diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs index 41da19e..7fb3231 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs @@ -7,6 +7,9 @@ namespace BikeTracking.Api.Tests.Application.Dashboard; public sealed class GetDashboardServiceTests { + private static readonly DateTime FixedUtc = new(2026, 04, 16, 12, 00, 00, DateTimeKind.Utc); + private static readonly DateTime FixedLocal = new(2026, 04, 16, 08, 00, 00, DateTimeKind.Local); + [Fact] public void GetDashboardService_TypeExists() { @@ -32,7 +35,7 @@ public void GetDashboardService_ExposesAsyncReadMethod() } [Fact] - public async Task GetDashboardService_UsesRideSnapshotsForSavings_WhenCurrentSettingsChanged() + public async Task GetDashboardService_UsesCurrentSettingsMileageRate_ForMileageSavings() { using var dbContext = CreateDbContext(); var rider = new UserEntity @@ -58,12 +61,12 @@ public async Task GetDashboardService_UsesRideSnapshotsForSavings_WhenCurrentSet new RideEntity { RiderId = rider.UserId, - RideDateTimeLocal = DateTime.Now, + RideDateTimeLocal = FixedLocal, Miles = 10m, GasPricePerGallon = 3m, SnapshotAverageCarMpg = 20m, SnapshotMileageRateCents = 50m, - CreatedAtUtc = DateTime.UtcNow, + CreatedAtUtc = FixedUtc, } ); await dbContext.SaveChangesAsync(); @@ -71,9 +74,8 @@ public async Task GetDashboardService_UsesRideSnapshotsForSavings_WhenCurrentSet var service = new GetDashboardService(dbContext, TimeProvider.System); var dashboard = await service.GetAsync(rider.UserId); - Assert.Equal(5m, dashboard.Totals.MoneySaved.MileageRateSavings); + Assert.Equal(800m, dashboard.Totals.MoneySaved.MileageRateSavings); Assert.Equal(1.5m, dashboard.Totals.MoneySaved.FuelCostAvoided); - Assert.Equal(6.5m, dashboard.Totals.MoneySaved.CombinedSavings); } [Fact] @@ -93,10 +95,10 @@ public async Task GetDashboardService_ExcludesLegacyRideWithoutSnapshot_FromSavi new RideEntity { RiderId = rider.UserId, - RideDateTimeLocal = DateTime.Now, + RideDateTimeLocal = FixedLocal, Miles = 8m, GasPricePerGallon = 3.2m, - CreatedAtUtc = DateTime.UtcNow, + CreatedAtUtc = FixedUtc, } ); await dbContext.SaveChangesAsync(); @@ -106,7 +108,6 @@ public async Task GetDashboardService_ExcludesLegacyRideWithoutSnapshot_FromSavi Assert.Null(dashboard.Totals.MoneySaved.MileageRateSavings); Assert.Null(dashboard.Totals.MoneySaved.FuelCostAvoided); - Assert.Null(dashboard.Totals.MoneySaved.CombinedSavings); Assert.Equal(0, dashboard.Totals.MoneySaved.QualifiedRideCount); Assert.Equal(1, dashboard.MissingData.RidesMissingSavingsSnapshot); Assert.Equal(1, dashboard.Totals.AllTimeMiles.RideCount); @@ -335,6 +336,84 @@ public async Task GetDashboardService_ExpenseSummary_WithNoOilChangePrice_OilSav Assert.Null(dashboard.Totals.ExpenseSummary.NetExpenses); } + [Fact] + public async Task GetDashboardService_RoundsSplitSavings_AwayFromZero() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "Savings Round Rider", + NormalizedName = "savings round rider", + CreatedAtUtc = FixedUtc, + }; + dbContext.Users.Add(rider); + await dbContext.SaveChangesAsync(); + + dbContext.UserSettings.Add( + new UserSettingsEntity + { + UserId = rider.UserId, + MileageRateCents = 100.5m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + + dbContext.Rides.Add( + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = FixedLocal, + Miles = 1m, + GasPricePerGallon = 201m, + SnapshotAverageCarMpg = 200m, + SnapshotMileageRateCents = 40m, + CreatedAtUtc = FixedUtc, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext, TimeProvider.System); + var dashboard = await service.GetAsync(rider.UserId); + + Assert.Equal(100.5m, dashboard.Totals.MoneySaved.MileageRateSavings); + Assert.Equal(1.01m, dashboard.Totals.MoneySaved.FuelCostAvoided); + } + + [Fact] + public async Task GetDashboardService_ZeroFuelSavings_StillReturnedAsZero() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "Savings Zero Rider", + NormalizedName = "savings zero rider", + CreatedAtUtc = FixedUtc, + }; + dbContext.Users.Add(rider); + await dbContext.SaveChangesAsync(); + + dbContext.Rides.Add( + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = FixedLocal, + Miles = 10m, + GasPricePerGallon = 0m, + SnapshotAverageCarMpg = 20m, + SnapshotMileageRateCents = 0m, + CreatedAtUtc = FixedUtc, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext, TimeProvider.System); + var dashboard = await service.GetAsync(rider.UserId); + + Assert.Null(dashboard.Totals.MoneySaved.MileageRateSavings); + Assert.Equal(0m, dashboard.Totals.MoneySaved.FuelCostAvoided); + Assert.Equal(1, dashboard.Totals.MoneySaved.QualifiedRideCount); + } + private static BikeTrackingDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs index 623ba2c..e66f2a6 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs @@ -38,6 +38,14 @@ public async Task GetAsync_YearWithFullTwelveMonthsOfRides_ProducesCorrectPerMon { using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "Full Year Rider"); + dbContext.UserSettings.Add( + new UserSettingsEntity + { + UserId = rider.UserId, + MileageRateCents = 50m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); for (var month = 1; month <= 12; month++) { @@ -71,7 +79,7 @@ public async Task GetAsync_YearWithFullTwelveMonthsOfRides_ProducesCorrectPerMon Assert.Equal(10m * month, point.Miles); var savingsPoint = response.SavingsByMonth[month - 1]; - Assert.Equal(5m * month, savingsPoint.MileageRateSavings); + Assert.Equal(500m * month, savingsPoint.MileageRateSavings); Assert.NotNull(savingsPoint.FuelCostAvoided); } } @@ -136,7 +144,7 @@ public async Task GetAsync_YearWithZeroRides_ReturnsHasDataForYearFalse_NoExcept } [Fact] - public async Task GetAsync_UsesRideSnapshotsForSavings_WhenCurrentSettingsChanged() + public async Task GetAsync_UsesCurrentSettingsMileageRate_ForMileageSavings() { using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "Snapshot Rider"); @@ -169,9 +177,9 @@ public async Task GetAsync_UsesRideSnapshotsForSavings_WhenCurrentSettingsChange var response = await service.GetAsync(rider.UserId, 2022); var june = response.SavingsByMonth[5]; - Assert.Equal(5m, june.MileageRateSavings); + Assert.Equal(800m, june.MileageRateSavings); Assert.Equal(1.5m, june.FuelCostAvoided); - Assert.Equal(6.5m, june.CombinedSavings); + Assert.Equal(801.5m, june.CombinedSavings); } [Fact] @@ -242,6 +250,14 @@ public async Task GetAsync_YearWithRidesAndExpenses_ComputesTotalsSection() { using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "Totals Rider"); + dbContext.UserSettings.Add( + new UserSettingsEntity + { + UserId = rider.UserId, + MileageRateCents = 50m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); dbContext.Rides.AddRange( new RideEntity @@ -292,7 +308,7 @@ public async Task GetAsync_YearWithRidesAndExpenses_ComputesTotalsSection() var response = await service.GetAsync(rider.UserId, 2025); Assert.Equal(150m, response.Totals.TotalMiles); - Assert.Equal(75m + 22.5m, response.Totals.TotalCombinedSavings); + Assert.Equal(7500m + 22.5m, response.Totals.TotalCombinedSavings); Assert.Equal(40m, response.Totals.ExpenseSummary.TotalManualExpenses); } diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/SavingsCalculationRulesTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/SavingsCalculationRulesTests.cs new file mode 100644 index 0000000..7d5fb89 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/SavingsCalculationRulesTests.cs @@ -0,0 +1,128 @@ +using BikeTracking.Api.Application.Dashboard; + +namespace BikeTracking.Api.Tests.Application.Dashboard; + +public sealed class SavingsCalculationRulesTests +{ + [Theory] + [InlineData(10, 80, 800)] + [InlineData(1.25, 67.5, 84.375)] + [InlineData(-4, 50, -200)] + public void CalculateMileageRateSavings_WithRequiredRate_ReturnsMilesTimesRate( + decimal miles, + decimal rateCents, + decimal expected + ) + { + var result = SavingsCalculationRules.CalculateMileageRateSavings(miles, rateCents); + + Assert.Equal(expected, result); + } + + [Fact] + public void CalculateMileageRateSavings_WithNullableRateNull_ReturnsNull() + { + var result = SavingsCalculationRules.CalculateMileageRateSavings( + 10m, + mileageRateCents: null + ); + + Assert.Null(result); + } + + [Theory] + [InlineData(10, 80)] + [InlineData(12.5, 67.5)] + [InlineData(-2, 30)] + public void Contract_MileageNullableOverload_MatchesRequiredOverload_WhenRateProvided( + decimal miles, + decimal rateCents + ) + { + var requiredResult = SavingsCalculationRules.CalculateMileageRateSavings(miles, rateCents); + var nullableResult = SavingsCalculationRules.CalculateMileageRateSavings( + miles, + (decimal?)rateCents + ); + + Assert.NotNull(nullableResult); + Assert.Equal(requiredResult, nullableResult!.Value); + } + + [Theory] + [InlineData(10, 20, 3, 1.5)] + [InlineData(22.5, 30, 4.25, 3.1875)] + [InlineData(-10, 20, 3, -1.5)] + public void CalculateFuelCostAvoided_WithRequiredInputs_ReturnsMilesDivMpgTimesGas( + decimal miles, + decimal mpg, + decimal gasPrice, + decimal expected + ) + { + var result = SavingsCalculationRules.CalculateFuelCostAvoided(miles, mpg, gasPrice); + + Assert.Equal(expected, result); + } + + [Fact] + public void CalculateFuelCostAvoided_WithNullMpg_ReturnsNull() + { + var result = SavingsCalculationRules.CalculateFuelCostAvoided(10m, null, 3m); + + Assert.Null(result); + } + + [Fact] + public void CalculateFuelCostAvoided_WithZeroMpg_ReturnsNull() + { + var result = SavingsCalculationRules.CalculateFuelCostAvoided( + 10m, + averageCarMpg: (decimal?)0m, + gasPricePerGallon: (decimal?)3m + ); + + Assert.Null(result); + } + + [Fact] + public void CalculateFuelCostAvoided_WithNegativeMpg_ReturnsNull() + { + var result = SavingsCalculationRules.CalculateFuelCostAvoided( + 10m, + averageCarMpg: (decimal?)-1m, + gasPricePerGallon: (decimal?)3m + ); + + Assert.Null(result); + } + + [Fact] + public void CalculateFuelCostAvoided_WithNullGasPrice_ReturnsNull() + { + var result = SavingsCalculationRules.CalculateFuelCostAvoided(10m, 20m, null); + + Assert.Null(result); + } + + [Theory] + [InlineData(10, 20, 3)] + [InlineData(22.5, 30, 4.25)] + [InlineData(-10, 25, 3.1)] + public void Contract_FuelNullableOverload_MatchesRequiredOverload_WhenInputsValid( + decimal miles, + decimal mpg, + decimal gasPrice + ) + { + var requiredResult = SavingsCalculationRules.CalculateFuelCostAvoided(miles, mpg, gasPrice); + var nullableResult = SavingsCalculationRules.CalculateFuelCostAvoided( + miles, + (decimal?)mpg, + (decimal?)gasPrice + ); + + Assert.NotNull(nullableResult); + Assert.Equal(requiredResult, nullableResult!.Value); + } +} diff --git a/src/BikeTracking.Api.Tests/Application/Imports/CsvImportDifficultyTests.cs b/src/BikeTracking.Api.Tests/Application/Imports/CsvImportDifficultyTests.cs index 95ecc8b..2ea30ea 100644 --- a/src/BikeTracking.Api.Tests/Application/Imports/CsvImportDifficultyTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Imports/CsvImportDifficultyTests.cs @@ -63,7 +63,7 @@ public void ValidateRow_WithInvalidDifficulty_ReturnsError(string value) { var row = MakeRow(difficulty: value); var errors = CsvValidationRules.ValidateRow(row); - var error = Assert.Single(errors.Where(e => e.Field == "Difficulty")); + var error = Assert.Single(errors, e => e.Field == "Difficulty"); Assert.Equal("INVALID_DIFFICULTY", error.Code); Assert.Contains("1", error.Message); Assert.Contains("5", error.Message); @@ -115,7 +115,7 @@ public void ValidateRow_WithInvalidDirection_ReturnsError(string value) { var row = MakeRow(primaryTravelDirection: value); var errors = CsvValidationRules.ValidateRow(row); - var error = Assert.Single(errors.Where(e => e.Field == "PrimaryTravelDirection")); + var error = Assert.Single(errors, e => e.Field == "PrimaryTravelDirection"); Assert.Equal("INVALID_DIRECTION", error.Code); // Should list accepted values Assert.Contains("North", error.Message); diff --git a/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs index d722c4f..0824e47 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using System.Security.Claims; +using System.Text.Json; using BikeTracking.Api.Application.Dashboard; using BikeTracking.Api.Application.Users; using BikeTracking.Api.Contracts; @@ -31,6 +32,24 @@ public async Task GetDashboard_Returns200AndDashboardPayload_ForAuthenticatedRid Assert.Equal(0m, payload.Totals.CurrentMonthMiles.Miles); } + [Fact] + public async Task GetDashboard_MoneySavedContract_ExposesSplitFields_AndOmitsCombinedSavings() + { + await using var host = await DashboardApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Dashboard Contract Rider", "1234"); + + var response = await host.Client.GetWithAuthAsync("/api/dashboard", userId); + response.EnsureSuccessStatusCode(); + + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var moneySaved = json.RootElement.GetProperty("totals").GetProperty("moneySaved"); + + Assert.True(moneySaved.TryGetProperty("mileageRateSavings", out _)); + Assert.True(moneySaved.TryGetProperty("fuelCostAvoided", out _)); + Assert.True(moneySaved.TryGetProperty("qualifiedRideCount", out _)); + Assert.False(moneySaved.TryGetProperty("combinedSavings", out _)); + } + [Fact] public async Task GetYearStats_Returns200AndYearStatsPayload_ForAuthenticatedRider() { diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs index 4290ca4..58c2259 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using System.Security.Claims; +using System.Text.Json; using BikeTracking.Api.Application.Rides; using BikeTracking.Api.Contracts; using BikeTracking.Api.Endpoints; @@ -36,6 +37,25 @@ public async Task PostRecordRide_WithValidRequest_Returns201AndRideId() Assert.NotEqual(DateTime.MinValue, payload.SavedAtUtc); } + [Fact] + public async Task PostRecordRide_ResponseShape_RemainsRideEntryFocused() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Ride Shape"); + + var request = new RecordRideRequest(RideDateTimeLocal: DateTime.Now, Miles: 7.5m); + var response = await host.Client.PostWithAuthAsync("/api/rides", request, userId); + response.EnsureSuccessStatusCode(); + + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = json.RootElement; + Assert.True(root.TryGetProperty("rideId", out _)); + Assert.True(root.TryGetProperty("riderId", out _)); + Assert.True(root.TryGetProperty("savedAtUtc", out _)); + Assert.False(root.TryGetProperty("moneySaved", out _)); + Assert.False(root.TryGetProperty("combinedSavings", out _)); + } + [Fact] public async Task PostRecordRide_WithRequiredFieldsOnly_Returns201() { diff --git a/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs index d02011c..69998d4 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs @@ -202,14 +202,22 @@ private static AdvancedSavingsWindow BuildWindow( if (gasPrice.HasValue) { - fuelCostSum += gallons * gasPrice.Value; + fuelCostSum += SavingsCalculationRules.CalculateFuelCostAvoided( + ride.Miles, + mpg, + gasPrice.Value + ); hasFuelCost = true; } } - if (ride.SnapshotMileageRateCents is decimal rateCents) + var rideMileageRateSavings = SavingsCalculationRules.CalculateMileageRateSavings( + ride.Miles, + ride.SnapshotMileageRateCents + ); + if (rideMileageRateSavings.HasValue) { - mileageRateSum += ride.Miles * rateCents / 100m; + mileageRateSum += rideMileageRateSavings.Value; hasMileageRate = true; } } diff --git a/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs index eecaab5..5934704 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs @@ -1,4 +1,5 @@ using System.Globalization; +using BikeTracking.Api.Application.Dashboard; using BikeTracking.Api.Contracts; using BikeTracking.Api.Infrastructure.Persistence; using BikeTracking.Api.Infrastructure.Persistence.Entities; @@ -43,7 +44,7 @@ public async Task GetAsync( var yearToDateMiles = currentYearRides.Sum(ride => ride.Miles); var gallonsAvoided = CalculateGallonsAvoided(rides); - var savings = CalculateSavings(rides); + var savings = CalculateSavings(rides, settings?.MileageRateCents); var totalManualExpenses = await dbContext @@ -72,12 +73,12 @@ await dbContext ), Charts: new DashboardCharts( MileageByMonth: BuildMileageSeries(rides, nowLocal), - SavingsByMonth: BuildSavingsSeries(rides, nowLocal) + SavingsByMonth: BuildSavingsSeries(rides, nowLocal, settings?.MileageRateCents) ), Suggestions: BuildSuggestions(settings, gallonsAvoided, yearToDateMiles), MissingData: new DashboardMissingData( RidesMissingSavingsSnapshot: rides.Count(ride => - ride.SnapshotMileageRateCents is null || ride.SnapshotAverageCarMpg is null + ride.SnapshotAverageCarMpg is null ), RidesMissingGasPrice: rides.Count(ride => ride.GasPricePerGallon is null), RidesMissingTemperature: rides.Count(ride => ride.Temperature is null), @@ -156,12 +157,12 @@ string period : decimal.Round((decimal)rideMinutes.Average(), 1, MidpointRounding.AwayFromZero); } - private static SavingsComputation CalculateSavings(IReadOnlyCollection rides) + private static SavingsComputation CalculateSavings( + IReadOnlyCollection rides, + decimal? mileageRateCents + ) { - var totals = AggregateSavings(rides); - decimal? combinedSavings = totals.HasAnySavings - ? totals.MileageRateSavings + totals.FuelCostAvoided - : null; + var totals = AggregateSavings(rides, mileageRateCents); return new SavingsComputation( new DashboardMoneySaved( @@ -171,7 +172,6 @@ private static SavingsComputation CalculateSavings(IReadOnlyCollection BuildSavingsSeries( IReadOnlyCollection rides, - DateTime nowLocal + DateTime nowLocal, + decimal? mileageRateCents ) { return EnumerateRollingMonths(nowLocal) @@ -210,10 +211,7 @@ DateTime nowLocal .Where(ride => IsWithinMonth(ride.RideDateTimeLocal, month.Year, month.Month)) .ToList(); - var monthSavings = AggregateSavings(monthRides); - decimal? combinedSavings = monthSavings.HasAnySavings - ? monthSavings.MileageRateSavings + monthSavings.FuelCostAvoided - : null; + var monthSavings = AggregateSavings(monthRides, mileageRateCents); return new DashboardSavingsPoint( MonthKey: GetMonthKey(month.Year, month.Month), @@ -223,14 +221,16 @@ DateTime nowLocal : null, FuelCostAvoided: monthSavings.HasFuelCostAvoided ? RoundMoney(monthSavings.FuelCostAvoided) - : null, - CombinedSavings: RoundMoney(combinedSavings) + : null ); }) .ToList(); } - private static SavingsAggregate AggregateSavings(IEnumerable rides) + private static SavingsAggregate AggregateSavings( + IEnumerable rides, + decimal? mileageRateCents + ) { var mileageRateSavings = 0m; var fuelCostAvoided = 0m; @@ -240,7 +240,10 @@ private static SavingsAggregate AggregateSavings(IEnumerable rides) foreach (var ride in rides) { - var rideMileageRateSavings = CalculateMileageRateSavings(ride); + var rideMileageRateSavings = SavingsCalculationRules.CalculateMileageRateSavings( + ride.Miles, + mileageRateCents + ); var rideFuelCostAvoided = CalculateFuelCostAvoided(ride); if (rideMileageRateSavings.HasValue || rideFuelCostAvoided.HasValue) @@ -338,26 +341,13 @@ private static string GetMonthKey(int year, int month) return $"{year:D4}-{month:D2}"; } - private static decimal? CalculateMileageRateSavings(RideEntity ride) - { - return ride.SnapshotMileageRateCents is decimal mileageRateCents - ? ride.Miles * mileageRateCents / 100m - : null; - } - private static decimal? CalculateFuelCostAvoided(RideEntity ride) { - if (ride.SnapshotAverageCarMpg is not decimal averageCarMpg || averageCarMpg <= 0m) - { - return null; - } - - if (ride.GasPricePerGallon is not decimal gasPricePerGallon) - { - return null; - } - - return ride.Miles / averageCarMpg * gasPricePerGallon; + return SavingsCalculationRules.CalculateFuelCostAvoided( + ride.Miles, + ride.SnapshotAverageCarMpg, + ride.GasPricePerGallon + ); } private static decimal? RoundMoney(decimal? value) @@ -371,10 +361,7 @@ private sealed record SavingsAggregate( int QualifiedRideCount, bool HasMileageRateSavings, bool HasFuelCostAvoided - ) - { - public bool HasAnySavings => HasMileageRateSavings || HasFuelCostAvoided; - } + ) { } private sealed record SavingsComputation(DashboardMoneySaved Totals); } diff --git a/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs index f2436c4..e235cd8 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs @@ -13,9 +13,8 @@ namespace BikeTracking.Api.Application.Dashboard; /// Returns mileage, savings, difficulty, and wind-resistance analytics scoped to a single /// calendar year (Jan-Dec), rather than a rolling window () /// or an all-time/calendar-window breakdown (). -/// Reuses the same per-ride snapshot fields and F# domain calculations as those services, -/// unchanged, so historical accuracy (FR-006) and existing rolling/all-time behavior -/// (FR-005) are preserved. +/// Reuses difficulty/wind F# calculations while keeping savings aggregation aligned with +/// current dashboard settings and monthly/yearly mileage totals. /// public sealed class GetYearStatsDashboardService( BikeTrackingDbContext dbContext, @@ -65,9 +64,14 @@ await dbContext return new YearStatsDashboardResponse( Year: year, HasDataForYear: hasDataForYear, - Totals: BuildTotalsSection(rides, totalManualExpenses, settings?.OilChangePrice), + Totals: BuildTotalsSection( + rides, + totalManualExpenses, + settings?.OilChangePrice, + settings?.MileageRateCents + ), MileageByMonth: BuildMileageSeries(rides, year), - SavingsByMonth: BuildSavingsSeries(rides, year), + SavingsByMonth: BuildSavingsSeries(rides, year, settings?.MileageRateCents), Difficulty: BuildDifficultySection(rides, year), WindResistance: BuildWindResistanceSection(rides, year) ); @@ -76,11 +80,12 @@ await dbContext private static YearStatsTotals BuildTotalsSection( IReadOnlyList rides, decimal totalManualExpenses, - decimal? oilChangePrice + decimal? oilChangePrice, + decimal? mileageRateCents ) { var totalMiles = rides.Sum(ride => ride.Miles); - var savings = AggregateSavings(rides); + var savings = AggregateSavings(rides, mileageRateCents); decimal? totalCombinedSavings = savings.HasAnySavings ? RoundMoney(savings.MileageRateSavings + savings.FuelCostAvoided) : null; @@ -169,7 +174,8 @@ int year private static IReadOnlyList BuildSavingsSeries( IReadOnlyList rides, - int year + int year, + decimal? mileageRateCents ) { return EnumerateMonthsOfYear(year) @@ -179,7 +185,7 @@ int year .Where(ride => IsWithinMonth(ride.RideDateTimeLocal, year, month)) .ToList(); - var savings = AggregateSavings(monthRides); + var savings = AggregateSavings(monthRides, mileageRateCents); decimal? combinedSavings = savings.HasAnySavings ? savings.MileageRateSavings + savings.FuelCostAvoided : null; @@ -326,7 +332,10 @@ private static string GetWindResistanceLabel(int rating) => _ => $"{(rating > 0 ? "+" : "")}{rating}", }; - private static SavingsAggregate AggregateSavings(IEnumerable rides) + private static SavingsAggregate AggregateSavings( + IEnumerable rides, + decimal? mileageRateCents + ) { var mileageRateSavings = 0m; var fuelCostAvoided = 0m; @@ -335,20 +344,25 @@ private static SavingsAggregate AggregateSavings(IEnumerable rides) foreach (var ride in rides) { - if (ride.SnapshotMileageRateCents is decimal rateCents) + var rideMileageRateSavings = SavingsCalculationRules.CalculateMileageRateSavings( + ride.Miles, + mileageRateCents + ); + if (rideMileageRateSavings.HasValue) { hasMileageRateSavings = true; - mileageRateSavings += ride.Miles * rateCents / 100m; + mileageRateSavings += rideMileageRateSavings.Value; } - if ( - ride.SnapshotAverageCarMpg is decimal averageCarMpg - && averageCarMpg > 0m - && ride.GasPricePerGallon is decimal gasPricePerGallon - ) + var rideFuelCostAvoided = SavingsCalculationRules.CalculateFuelCostAvoided( + ride.Miles, + ride.SnapshotAverageCarMpg, + ride.GasPricePerGallon + ); + if (rideFuelCostAvoided.HasValue) { hasFuelCostAvoided = true; - fuelCostAvoided += ride.Miles / averageCarMpg * gasPricePerGallon; + fuelCostAvoided += rideFuelCostAvoided.Value; } } diff --git a/src/BikeTracking.Api/Application/Dashboard/SavingsCalculationRules.cs b/src/BikeTracking.Api/Application/Dashboard/SavingsCalculationRules.cs new file mode 100644 index 0000000..15163ed --- /dev/null +++ b/src/BikeTracking.Api/Application/Dashboard/SavingsCalculationRules.cs @@ -0,0 +1,44 @@ +namespace BikeTracking.Api.Application.Dashboard; + +internal static class SavingsCalculationRules +{ + public static decimal? CalculateMileageRateSavings(decimal miles, decimal? mileageRateCents) + { + return mileageRateCents is decimal rateCents + ? CalculateMileageRateSavings(miles, rateCents) + : null; + } + + public static decimal CalculateMileageRateSavings(decimal miles, decimal mileageRateCents) + { + return miles * mileageRateCents; + } + + public static decimal? CalculateFuelCostAvoided( + decimal miles, + decimal? averageCarMpg, + decimal? gasPricePerGallon + ) + { + if (averageCarMpg is not decimal mpg || mpg <= 0m) + { + return null; + } + + if (gasPricePerGallon is not decimal gasPrice) + { + return null; + } + + return CalculateFuelCostAvoided(miles, mpg, gasPrice); + } + + public static decimal CalculateFuelCostAvoided( + decimal miles, + decimal averageCarMpg, + decimal gasPricePerGallon + ) + { + return miles / averageCarMpg * gasPricePerGallon; + } +} diff --git a/src/BikeTracking.Api/Contracts/DashboardContracts.cs b/src/BikeTracking.Api/Contracts/DashboardContracts.cs index be5c6d9..7b1c5b3 100644 --- a/src/BikeTracking.Api/Contracts/DashboardContracts.cs +++ b/src/BikeTracking.Api/Contracts/DashboardContracts.cs @@ -22,7 +22,6 @@ public sealed record DashboardMileageMetric(decimal Miles, int RideCount, string public sealed record DashboardMoneySaved( decimal? MileageRateSavings, decimal? FuelCostAvoided, - decimal? CombinedSavings, int QualifiedRideCount ); @@ -43,8 +42,7 @@ public sealed record DashboardSavingsPoint( string MonthKey, string Label, decimal? MileageRateSavings, - decimal? FuelCostAvoided, - decimal? CombinedSavings + decimal? FuelCostAvoided ); public sealed record DashboardMetricSuggestion( diff --git a/src/BikeTracking.Api/Properties/AssemblyInfo.cs b/src/BikeTracking.Api/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..2311f9c --- /dev/null +++ b/src/BikeTracking.Api/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("BikeTracking.Api.Tests")] diff --git a/src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs b/src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs index 39e72da..dee8602 100644 --- a/src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs +++ b/src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs @@ -85,7 +85,7 @@ let calculateMileageRateSavings (rides: RideSnapshot list) : decimal option = rides |> List.choose (fun r -> match r.SnapshotMileageRateCents with - | Some rateCents -> Some(r.Miles * rateCents / 100m) + | Some rateCents -> Some(r.Miles * rateCents) | None -> None) match qualifiedRides with diff --git a/src/BikeTracking.Frontend/src/components/dashboard/dashboard-chart-section.test.tsx b/src/BikeTracking.Frontend/src/components/dashboard/dashboard-chart-section.test.tsx index 57b9cb8..a7c7979 100644 --- a/src/BikeTracking.Frontend/src/components/dashboard/dashboard-chart-section.test.tsx +++ b/src/BikeTracking.Frontend/src/components/dashboard/dashboard-chart-section.test.tsx @@ -8,8 +8,8 @@ const mileageByMonth = [ ] const savingsByMonth = [ - { monthKey: '2025-01', label: 'Jan', mileageRateSavings: 1, fuelCostAvoided: 2, combinedSavings: 3 }, - { monthKey: '2025-02', label: 'Feb', mileageRateSavings: 4, fuelCostAvoided: 5, combinedSavings: 9 }, + { monthKey: '2025-01', label: 'Jan', mileageRateSavings: 1, fuelCostAvoided: 2 }, + { monthKey: '2025-02', label: 'Feb', mileageRateSavings: 4, fuelCostAvoided: 5 }, ] describe('DashboardChartSection', () => { diff --git a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx index f3449ba..220c1ca 100644 --- a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx @@ -1,6 +1,6 @@ import { BrowserRouter } from 'react-router-dom' import { render, screen } from '@testing-library/react' -import { describe, expect, it, vi, afterEach } from 'vitest' +import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' vi.mock('../../components/dashboard/dashboard-chart-section', () => ({ DashboardChartSection: (props: { year?: number; seriesLabel?: string }) => ( @@ -8,7 +8,82 @@ vi.mock('../../components/dashboard/dashboard-chart-section', () => ({ ), })) +vi.mock('../../services/dashboard-api', async () => { + const actual = await vi.importActual( + '../../services/dashboard-api' + ) + return { + ...actual, + getDashboard: vi.fn(), + } +}) + +import * as dashboardApi from '../../services/dashboard-api' + +const mockGetDashboard = vi.mocked(dashboardApi.getDashboard) + +function buildDashboardResponse( + overrides?: Partial +): dashboardApi.DashboardResponse { + const base: dashboardApi.DashboardResponse = { + totals: { + currentMonthMiles: { miles: 10, rideCount: 1, period: 'thisMonth' }, + yearToDateMiles: { miles: 45, rideCount: 4, period: 'thisYear' }, + allTimeMiles: { miles: 120, rideCount: 12, period: 'allTime' }, + moneySaved: { + mileageRateSavings: 15, + fuelCostAvoided: 7, + qualifiedRideCount: 3, + }, + expenseSummary: { + totalManualExpenses: 0, + oilChangeSavings: null, + netExpenses: null, + oilChangeIntervalCount: 0, + }, + }, + averages: { + averageTemperature: null, + averageMilesPerRide: null, + averageRideMinutes: null, + }, + charts: { + mileageByMonth: [], + savingsByMonth: [], + }, + suggestions: [], + missingData: { + ridesMissingSavingsSnapshot: 0, + ridesMissingGasPrice: 0, + ridesMissingTemperature: 0, + ridesMissingDuration: 0, + }, + generatedAtUtc: '2026-04-16T12:00:00.000Z', + } + + if (!overrides) { + return base + } + + return { + ...base, + ...overrides, + totals: { + ...base.totals, + ...overrides.totals, + moneySaved: { + ...base.totals.moneySaved, + ...overrides.totals?.moneySaved, + }, + }, + } +} + describe('DashboardPage', () => { + beforeEach(() => { + mockGetDashboard.mockResolvedValue(buildDashboardResponse()) + }) + it('renders the baseline dashboard cards and charts', async () => { const module = await import('./dashboard-page') const DashboardPage = module.DashboardPage @@ -19,10 +94,10 @@ describe('DashboardPage', () => { ) - expect(screen.getByText(/current month/i)).toBeInTheDocument() + expect(await screen.findByText(/current month/i)).toBeInTheDocument() expect(screen.getByText(/year to date/i)).toBeInTheDocument() expect(screen.getByText(/all time/i)).toBeInTheDocument() - }) + }, 10000) it('renders expense summary card with total manual expenses label', async () => { const module = await import('./dashboard-page') @@ -74,7 +149,93 @@ describe('DashboardPage', () => { } }) + it('renders split savings labels and does not render a merged combined label', async () => { + sessionStorage.setItem('bike_tracking_auth_session', JSON.stringify({ userId: 1 })) + + try { + const module = await import('./dashboard-page') + const DashboardPage = module.DashboardPage + + render( + + + + ) + + expect(await screen.findByText(/mileage rate savings/i)).toBeInTheDocument() + expect(screen.getByText(/gallons-based savings/i)).toBeInTheDocument() + expect(screen.queryByText(/combined savings/i)).not.toBeInTheDocument() + } finally { + sessionStorage.removeItem('bike_tracking_auth_session') + } + }) + + it('renders split savings with currency rounding', async () => { + mockGetDashboard.mockResolvedValue( + buildDashboardResponse({ + totals: { + ...buildDashboardResponse().totals, + moneySaved: { + mileageRateSavings: 12.345, + fuelCostAvoided: 6.789, + qualifiedRideCount: 2, + }, + }, + }) + ) + sessionStorage.setItem('bike_tracking_auth_session', JSON.stringify({ userId: 1 })) + + try { + const module = await import('./dashboard-page') + const DashboardPage = module.DashboardPage + + render( + + + + ) + + expect(await screen.findByText(/mileage rate savings \$12\.35/i)).toBeInTheDocument() + expect(screen.getByText(/gallons-based savings \$6\.79/i)).toBeInTheDocument() + } finally { + sessionStorage.removeItem('bike_tracking_auth_session') + } + }) + + it('renders split savings zero values instead of hiding rows', async () => { + mockGetDashboard.mockResolvedValue( + buildDashboardResponse({ + totals: { + ...buildDashboardResponse().totals, + moneySaved: { + mileageRateSavings: 0, + fuelCostAvoided: 0, + qualifiedRideCount: 1, + }, + }, + }) + ) + sessionStorage.setItem('bike_tracking_auth_session', JSON.stringify({ userId: 1 })) + + try { + const module = await import('./dashboard-page') + const DashboardPage = module.DashboardPage + + render( + + + + ) + + expect(await screen.findByText(/mileage rate savings \$0\.00/i)).toBeInTheDocument() + expect(screen.getByText(/gallons-based savings \$0\.00/i)).toBeInTheDocument() + } finally { + sessionStorage.removeItem('bike_tracking_auth_session') + } + }) + afterEach(() => { + vi.clearAllMocks() sessionStorage.removeItem('bike_tracking_auth_session') }) }) \ No newline at end of file diff --git a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx index ccaebc5..f3adef7 100644 --- a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx +++ b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx @@ -35,7 +35,6 @@ function buildEmptyDashboard(): DashboardResponse { moneySaved: { mileageRateSavings: null, fuelCostAvoided: null, - combinedSavings: null, qualifiedRideCount: 0, }, expenseSummary: { @@ -57,7 +56,6 @@ function buildEmptyDashboard(): DashboardResponse { label: point.label, mileageRateSavings: null, fuelCostAvoided: null, - combinedSavings: null, })), }, suggestions: [], @@ -204,14 +202,14 @@ export function DashboardPage() { />
- Rate (cents per mile) {formatCurrency(dashboard.totals.moneySaved.mileageRateSavings)} - Fuel {formatCurrency(dashboard.totals.moneySaved.fuelCostAvoided)} + Mileage rate savings {formatCurrency(dashboard.totals.moneySaved.mileageRateSavings)} + Gallons-based savings {formatCurrency(dashboard.totals.moneySaved.fuelCostAvoided)}
diff --git a/src/BikeTracking.Frontend/src/services/dashboard-api.test.ts b/src/BikeTracking.Frontend/src/services/dashboard-api.test.ts index 30bb6a6..43681dc 100644 --- a/src/BikeTracking.Frontend/src/services/dashboard-api.test.ts +++ b/src/BikeTracking.Frontend/src/services/dashboard-api.test.ts @@ -34,7 +34,6 @@ describe("dashboard-api", () => { moneySaved: { mileageRateSavings: 15, fuelCostAvoided: 7, - combinedSavings: 22, qualifiedRideCount: 3, }, }, @@ -65,5 +64,6 @@ describe("dashboard-api", () => { expect.objectContaining({ method: "GET" }), ); expect(result.totals.allTimeMiles.miles).toBe(120); + expect("combinedSavings" in result.totals.moneySaved).toBe(false); }); }); diff --git a/src/BikeTracking.Frontend/src/services/dashboard-api.ts b/src/BikeTracking.Frontend/src/services/dashboard-api.ts index 412dc6c..b7ec1c6 100644 --- a/src/BikeTracking.Frontend/src/services/dashboard-api.ts +++ b/src/BikeTracking.Frontend/src/services/dashboard-api.ts @@ -8,7 +8,6 @@ export interface DashboardMileageMetric { export interface DashboardMoneySaved { mileageRateSavings: number | null; fuelCostAvoided: number | null; - combinedSavings: number | null; qualifiedRideCount: number; } @@ -40,7 +39,6 @@ export interface DashboardCharts { label: string; mileageRateSavings: number | null; fuelCostAvoided: number | null; - combinedSavings: number | null; }>; } diff --git a/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts b/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts index 9ce2794..4885656 100644 --- a/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; import { uniqueUser } from "./support/auth-helpers"; +import { moneySavedCard, moneySavedRow } from "./support/expense-helpers"; async function saveDashboardSettings( page: import("@playwright/test").Page, @@ -62,13 +63,24 @@ test.describe("012-dashboard-stats e2e", () => { await recordRideWithGasPrice(page, "10", "3.00"); await page.goto("/dashboard"); - await expect(page.getByText("$6.50", { exact: true })).toBeVisible(); + const card = moneySavedCard(page); + await expect(moneySavedRow(card, "Mileage rate savings")).toContainText( + "$500.00", + ); + await expect(moneySavedRow(card, "Gallons-based savings")).toContainText( + "$1.50", + ); await saveDashboardSettings(page, "40", "70"); await recordRideWithGasPrice(page, "10", "3.00"); await page.goto("/dashboard"); - await expect(page.getByText("$14.25", { exact: true })).toBeVisible(); + await expect(moneySavedRow(card, "Mileage rate savings")).toContainText( + "$1,400.00", + ); + await expect(moneySavedRow(card, "Gallons-based savings")).toContainText( + "$2.25", + ); }); test("optional metrics appear only after approval", async ({ page }) => { diff --git a/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts b/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts index be37720..30eb76d 100644 --- a/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts @@ -3,6 +3,8 @@ import { createAndLoginUser, uniqueUser } from "./support/auth-helpers"; import { expenseSummaryCard, expenseSummaryRow, + moneySavedCard, + moneySavedRow, recordExpense, } from "./support/expense-helpers"; import { recordRide } from "./support/ride-helpers"; @@ -37,6 +39,26 @@ async function recordMilesForOilInterval( } test.describe("015-savings-calculation e2e", () => { + test("dashboard shows split savings rows and no combined savings label", async ({ + page, + }) => { + const userName = uniqueUser("e2e-dashboard-split-savings"); + + await createAndLoginUser(page, userName, TEST_PIN); + await recordRide(page, { + rideDateTimeLocal: "2026-04-16T08:00", + miles: "10", + gasPrice: "3.0000", + }); + + await page.goto("/dashboard"); + + const card = moneySavedCard(page); + await expect(moneySavedRow(card, "Mileage rate savings")).toBeVisible(); + await expect(moneySavedRow(card, "Gallons-based savings")).toBeVisible(); + await expect(card).not.toContainText(/combined savings/i); + }); + test("dashboard recalculates net expense total after oil price changes", async ({ page, }) => { diff --git a/src/BikeTracking.Frontend/tests/e2e/support/expense-helpers.ts b/src/BikeTracking.Frontend/tests/e2e/support/expense-helpers.ts index e331e37..e9b8ad4 100644 --- a/src/BikeTracking.Frontend/tests/e2e/support/expense-helpers.ts +++ b/src/BikeTracking.Frontend/tests/e2e/support/expense-helpers.ts @@ -33,3 +33,15 @@ export function expenseSummaryRow(card: Locator, label: string): Locator { hasText: label, }); } + +export function moneySavedCard(page: Page): Locator { + return page.locator("article", { + has: page.getByRole("heading", { name: "Money Saved" }), + }); +} + +export function moneySavedRow(card: Locator, label: string): Locator { + return card.locator(".dashboard-summary-split span", { + hasText: label, + }); +}