From 425bae0e038c577089a5b0fb47c5c7055a9beb14 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Thu, 27 Aug 2026 13:44:00 +0000 Subject: [PATCH 1/5] spec: add CO2 savings on advanced dashboard feature spec --- .specify/feature.json | 2 +- .../checklists/requirements.md | 35 ++++++++ specs/029-co2-savings-dashboard/spec.md | 86 +++++++++++++++++++ specs/REGISTRY.md | 1 + 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 specs/029-co2-savings-dashboard/checklists/requirements.md create mode 100644 specs/029-co2-savings-dashboard/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index 2caf49e..243a723 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/028-csv-data-export" + "feature_directory": "specs/029-co2-savings-dashboard" } diff --git a/specs/029-co2-savings-dashboard/checklists/requirements.md b/specs/029-co2-savings-dashboard/checklists/requirements.md new file mode 100644 index 0000000..77ad937 --- /dev/null +++ b/specs/029-co2-savings-dashboard/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: CO2 Savings on Advanced Dashboard + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-27 +**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 + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` +- All items pass on initial validation; no [NEEDS CLARIFICATION] markers were required because reasonable defaults exist (fixed emission factor, reuse of existing window boundaries, pounds as the display unit consistent with existing miles/gallons units). diff --git a/specs/029-co2-savings-dashboard/spec.md b/specs/029-co2-savings-dashboard/spec.md new file mode 100644 index 0000000..083dab5 --- /dev/null +++ b/specs/029-co2-savings-dashboard/spec.md @@ -0,0 +1,86 @@ +# Feature Specification: CO2 Savings on Advanced Dashboard + +**Feature Branch**: `029-co2-savings-dashboard` + +**Created**: 2026-08-27 + +**Status**: Draft + +**Input**: User description: "Add a Carbon (CO2) saved value to the advanced dashboard's overall/yearly saved section, for the current year chosen on the dashboard. This should be calculated on demand (not stored/precomputed). Also show the CO2 saved per mile so the user can understand their per-mile environmental impact." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - View Total CO2 Saved for the Current Year (Priority: P1) + +A bike commuter viewing the advanced dashboard wants to know the environmental benefit of their riding, not just the financial one. When they view the yearly savings window (for the year currently selected on the dashboard), they see a total CO2 saved value alongside the existing gas, money, and mileage-rate savings figures. + +**Why this priority**: This is the core feature request. Without a visible CO2 total, users have no way to understand the environmental impact of their riding, which is the primary goal of this feature. + +**Independent Test**: Can be fully tested by navigating to the advanced dashboard with ride history for the selected year and verifying the yearly window displays a CO2 saved value computed from that year's total miles. + +**Acceptance Scenarios**: + +1. **Given** a user has completed rides within the currently selected year, **When** they view the yearly savings window on the advanced dashboard, **Then** they see a "CO2 Saved" value representing the estimated mass of CO2 emissions avoided by biking those miles instead of driving. +2. **Given** the user changes the selected year on the dashboard, **When** the yearly window recalculates, **Then** the CO2 saved value updates to reflect only rides within the newly selected year. +3. **Given** the user has no rides in the currently selected year, **When** they view the yearly window, **Then** the CO2 saved value displays as zero rather than an error or blank cell. +4. **Given** the weekly, monthly, and all-time windows are also displayed, **When** the user views them, **Then** each window also shows its own CO2 saved value calculated the same way over its own miles, consistent with how other savings metrics are already broken out per window. + +--- + +### User Story 2 - View CO2 Saved Per Mile (Priority: P1) + +The user wants to understand their per-mile environmental impact, not just a cumulative total, so they can relate the metric to individual rides and compare it against different driving assumptions. + +**Why this priority**: Equally core to the request — the per-mile figure is what makes the total meaningful and educational, and it is explicitly called out as a required part of this feature. + +**Independent Test**: Can be tested independently by verifying that a fixed "CO2 saved per mile" figure (or value derived from the same emission factor used for the totals) is displayed near the total CO2 saved figures, and that multiplying it by a window's total miles reproduces that window's total CO2 saved value. + +**Acceptance Scenarios**: + +1. **Given** the advanced dashboard is displaying savings windows, **When** the user views the CO2 section, **Then** they see a per-mile CO2 saved value (e.g., "0.90 lb CO2/mile") displayed alongside the total CO2 saved figures. +2. **Given** the per-mile value and a window's total miles, **When** the values are compared, **Then** total CO2 saved for that window equals the per-mile value multiplied by the window's total miles (within rounding). +3. **Given** the user has no rides at all, **When** they view the CO2 per-mile value, **Then** it still displays the fixed per-mile factor (since it does not depend on ride count), while totals show as zero. + +--- + +### Edge Cases + +- What happens when a user has no rides in the selected year? (CO2 saved for that window shows as zero, not blank or error.) +- How does the system handle partial years (e.g., a year that just started)? (CO2 is calculated only from miles actually ridden within that year to date; no error or NaN.) +- What if ride distance data is missing or zero for all rides in a window? (CO2 saved is zero for that window.) +- Does the CO2 calculation depend on user-configured MPG or vehicle type? (No — CO2 saved uses a fixed average-vehicle emission factor per mile, independent of the user's MPG setting, since MPG only affects fuel-cost estimates, not emissions estimates in this feature's scope.) + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST calculate, on demand (not stored or precomputed), the total CO2 mass saved for each existing savings window (weekly, monthly, yearly, all-time) based on that window's total ride miles and a fixed average-vehicle CO2-per-mile emission factor. +- **FR-002**: System MUST display the CO2 saved total for the yearly window, reflecting rides within the year currently selected on the dashboard, in the overall/yearly saved section alongside existing metrics (gallons saved, fuel cost avoided, mileage-rate savings, expenses, oil-change savings, net savings). +- **FR-003**: System MUST also display CO2 saved for the weekly, monthly, and all-time windows, consistent with how other savings metrics are already broken out per window. +- **FR-004**: System MUST display a CO2 saved per-mile value so users can understand their per-mile environmental impact, using the same fixed emission factor used to compute the totals. +- **FR-005**: System MUST recalculate CO2 saved values whenever the selected year (or underlying ride data) changes, without persisting the computed CO2 value to storage. +- **FR-006**: System MUST return a CO2 saved value of zero (not null, error, or NaN) for any window with zero qualifying ride miles. +- **FR-007**: System MUST NOT vary the CO2-per-mile emission factor based on user-configured MPG or vehicle settings; the factor MUST be a fixed constant representing average-vehicle emissions. +- **FR-008**: System MUST express the CO2 saved values with a clearly labeled unit (e.g., pounds or kilograms of CO2) both for totals and for the per-mile figure. + +### Key Entities + +- **CO2 Savings Window Metric**: A derived, request-time-only value representing the estimated mass of CO2 emissions avoided for a given savings window (weekly, monthly, yearly, all-time), computed as `windowTotalMiles * co2PerMileFactor`. Not persisted. +- **CO2 Per-Mile Factor**: A fixed constant (mass of CO2 per mile) representing the average passenger-vehicle emissions avoided by biking one mile instead of driving it. Used to compute all window totals and displayed directly to the user. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of advanced dashboard views for a year with recorded rides display a non-blank, non-error CO2 saved value in the yearly window. +- **SC-002**: Users can identify their CO2 saved per mile within 5 seconds of viewing the advanced dashboard's savings section, without needing to compute it manually. +- **SC-003**: For every savings window, CO2 saved total equals (within rounding) the displayed per-mile factor multiplied by that window's total miles, verified in 100% of automated test cases. +- **SC-004**: 0 instances of CO2 saved values are found stored in the database or cached between requests; the value is recomputed on every dashboard load. + +## Assumptions + +- A fixed, documented CO2-per-mile emission factor (e.g., based on published average passenger-vehicle emissions per mile) is an acceptable approximation; per-user vehicle-specific emissions are out of scope for this feature. +- The CO2 metric reuses the same window/period boundaries (weekly, monthly, yearly-selected, all-time) already established by the existing advanced dashboard savings feature. +- "Currently selected year" refers to the year selector already present on the advanced dashboard for the yearly window; this feature does not introduce a new year selector. +- Units for display (pounds vs. kilograms of CO2) will follow the same locale/unit convention as the rest of the dashboard (assumed US customary units, i.e., pounds, consistent with miles/gallons already used). +- No new backend persistence, migration, or storage schema is needed since the value is always computed on demand from existing ride mileage data. diff --git a/specs/REGISTRY.md b/specs/REGISTRY.md index 6700392..5cf5af3 100644 --- a/specs/REGISTRY.md +++ b/specs/REGISTRY.md @@ -43,6 +43,7 @@ This file is the canonical index for feature spec folders in this repository. | 020 | `020-improve-ride-preset-options` | Active | Preset-based ride entry | | 021 | `021-reserved-slot` | Reserved | Intentionally unassigned | | 022 | `022-pwa-local-install` | Active | Local install/PWA | +| 029 | `029-co2-savings-dashboard` | Active | CO2 saved on advanced dashboard | ## Maintenance Rules From c2d387fdc9c8e35fb079004ee676943f4a8d7a0d Mon Sep 17 00:00:00 2001 From: aligneddev Date: Thu, 27 Aug 2026 14:51:17 +0000 Subject: [PATCH 2/5] [Spec Kit] Add tasks --- .github/copilot-instructions.md | 2 +- .../advanced-dashboard-co2-contract.md | 59 ++++++ specs/029-co2-savings-dashboard/data-model.md | 53 ++++++ specs/029-co2-savings-dashboard/plan.md | 96 ++++++++++ specs/029-co2-savings-dashboard/quickstart.md | 65 +++++++ specs/029-co2-savings-dashboard/research.md | 33 ++++ specs/029-co2-savings-dashboard/spec.md | 18 +- specs/029-co2-savings-dashboard/tasks.md | 177 ++++++++++++++++++ 8 files changed, 497 insertions(+), 6 deletions(-) create mode 100644 specs/029-co2-savings-dashboard/contracts/advanced-dashboard-co2-contract.md create mode 100644 specs/029-co2-savings-dashboard/data-model.md create mode 100644 specs/029-co2-savings-dashboard/plan.md create mode 100644 specs/029-co2-savings-dashboard/quickstart.md create mode 100644 specs/029-co2-savings-dashboard/research.md create mode 100644 specs/029-co2-savings-dashboard/tasks.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8813803..3ffe223 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/028-csv-data-export/plan.md](../specs/028-csv-data-export/plan.md) +[specs/029-co2-savings-dashboard/plan.md](../specs/029-co2-savings-dashboard/plan.md) diff --git a/specs/029-co2-savings-dashboard/contracts/advanced-dashboard-co2-contract.md b/specs/029-co2-savings-dashboard/contracts/advanced-dashboard-co2-contract.md new file mode 100644 index 0000000..f8d2d10 --- /dev/null +++ b/specs/029-co2-savings-dashboard/contracts/advanced-dashboard-co2-contract.md @@ -0,0 +1,59 @@ +# Contract: Advanced Dashboard CO2 Savings + +Base endpoint: `GET /api/advanced-dashboard` (existing endpoint backing `GetAdvancedDashboardService`) + +This feature extends the existing advanced-dashboard response with a CO2-saved figure per savings window and a single fixed CO2-saved-per-mile constant. No new endpoint is introduced. + +## Response Fragment (savings windows + top-level constant) + +```json +{ + "savingsWindows": { + "weekly": { + "period": "weekly", + "rideCount": 3, + "totalMiles": 24.5, + "gallonsSaved": 0.98, + "fuelCostAvoided": 3.43, + "fuelCostEstimated": false, + "mileageRateSavings": 16.17, + "combinedSavings": 19.60, + "totalExpenses": 0, + "oilChangeSavings": null, + "netSavings": 3.43, + "co2Saved": 22.05 + }, + "monthly": { "...": "same shape, co2Saved added" }, + "yearly": { "...": "same shape, co2Saved added" }, + "allTime": { "...": "same shape, co2Saved added" } + }, + "co2SavedPerMileLbs": 0.90 +} +``` + +## Field Additions + +1. **`savingsWindows..co2Saved`** (`number`, non-nullable) + - Present on all four windows: `weekly`, `monthly`, `yearly`, `allTime`. + - Computed as `totalMiles * 0.90`, rounded to 2 decimal places. + - `0` (not `null`) when the window has zero ride miles. + - Never varies with user MPG or mileage-rate settings. + +2. **`co2SavedPerMileLbs`** (`number`, non-nullable, response-level — not nested per window) + - Fixed constant `0.90`. + - Present even when the rider has zero rides across all windows. + - `savingsWindows..co2Saved` MUST equal `co2SavedPerMileLbs * savingsWindows..totalMiles` within 0.01 (per SC-003). + +## Consumer Rules for Spec #29 + +1. Render a "CO2 Saved" value for each of the four savings-window rows in `SavingsWindowsTable`, formatted to 2 decimal places with a clearly labeled unit (e.g., `"22.05 lb"`). +2. Render the CO2-saved-per-mile figure once, near the CO2 totals (e.g., `"0.90 lb CO2/mile"`), not repeated per row. +3. Treat `co2Saved` as always-present and non-null; render `"0.00 lb"` for zero-mile windows rather than a blank/dash placeholder (unlike `gallonsSaved`/`fuelCostAvoided`, which use `null` + `"—"` for missing-data cases). +4. Backend and frontend tests must assert: (a) each window's `co2Saved` matches `totalMiles * co2SavedPerMileLbs` within 0.01, (b) a zero-mile window yields `co2Saved: 0`, and (c) `co2SavedPerMileLbs` is present and equal to `0.90` even when all windows have zero rides. + +## Formula Requirements (Spec Source of Truth) + +- CO2 saved (per window) = `windowTotalMiles * co2SavedPerMileLbs`, rounded to 2 decimal places. +- `co2SavedPerMileLbs` = fixed constant `0.90` lb CO2/mile (EPA average passenger-vehicle emission factor, ~404 g/mile) — independent of `AverageCarMpg`, `MileageRateCents`, or any other user setting. + +Backend contracts (`AdvancedDashboardContracts.cs`) and frontend TypeScript models/tests (`advanced-dashboard-api.ts`, `SavingsWindowsTable.tsx`/`.test.tsx`) must stay synchronized for these new fields in the same change. diff --git a/specs/029-co2-savings-dashboard/data-model.md b/specs/029-co2-savings-dashboard/data-model.md new file mode 100644 index 0000000..278f8b3 --- /dev/null +++ b/specs/029-co2-savings-dashboard/data-model.md @@ -0,0 +1,53 @@ +# Phase 1 Data Model: CO2 Savings on Advanced Dashboard + +No new persisted entities or migrations are required. This feature adds a derived (request-time-only) metric to an existing read model. + +## Existing Entities Read + +### `RideEntity` (unchanged schema) +- `Miles: decimal` — the only input CO2 depends on; already aggregated per window as `TotalMiles` in `GetAdvancedDashboardService`. +- `RideDateTimeLocal: DateTime` — already used to bucket rides into the weekly/monthly/yearly/all-time windows; unchanged by this feature. + +No other ride fields (MPG snapshot, mileage-rate snapshot, gas price) are read for CO2 — the emission factor is fixed and independent of user settings (FR-007). + +## New Domain Constant + +### `Co2PerMileLbs` +- **Value**: `0.90m` (lb CO2 per mile; EPA average passenger-vehicle emission factor, ~404 g/mile) +- **Location**: `AdvancedDashboardCalculations.fs` +- **Rules**: Fixed constant; never varies by user, ride, MPG, or vehicle settings. + +## Feature-Level Derived Metrics + +### CO2 Saved (per window) +- **Formula**: `windowTotalMiles * Co2PerMileLbs`, rounded to 2 decimal places via `RoundTo2` (`Math.Round(value, 2, MidpointRounding.AwayFromZero)`) +- **Computed for**: weekly, monthly, yearly (current-calendar-year), and all-time windows — same four windows already produced by `GetAdvancedDashboardService` +- **Validation/Rules**: + - Always a non-null `decimal` — never null, NaN, or an error (FR-006). + - Zero window miles → `0.00` lb (not blank/omitted). + - Not persisted or cached; recomputed on every dashboard request (FR-005, SC-004). + - Does not depend on `SnapshotAverageCarMpg`, `SnapshotMileageRateCents`, or any user setting (FR-007). + +### CO2 Saved Per Mile +- **Formula**: `Co2PerMileLbs` itself (rounded to 2 decimal places = `0.90`), returned once at the response level (not per window). +- **Validation/Rules**: + - Always present, even when the rider has zero rides (independent of ride count/miles — Acceptance Scenario 3, User Story 2). + - Multiplying this value by any window's `TotalMiles` MUST reproduce that window's `Co2Saved` within 0.01 lb (SC-003). + +## Contract Shape Changes + +### `AdvancedSavingsWindow` (`src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs`) +- **Add**: `Co2Saved: decimal` — non-nullable (unlike `GallonsSaved`/`FuelCostAvoided`, which are nullable due to optional user settings; CO2 has no such dependency). + +### `AdvancedDashboardResponse` (same file) +- **Add**: `Co2SavedPerMileLbs: decimal` — the fixed per-mile constant, exposed once per response. + +## Presentation State Rules (Frontend) + +- `SavingsWindowsTable` renders a new "CO2 Saved" column for all four window rows, formatted as `"{value.toFixed(2)} lb"` (or equivalent, consistent with existing `formatGallons`-style helpers). +- The per-mile factor (`co2SavedPerMile`) is rendered once, near the CO2 totals (e.g., in a caption/subheading), not per row. +- Zero values render as `"0.00 lb"`, not blank or `"—"` (CO2 is never null, unlike `gallonsSaved`/`fuelCostAvoided`). + +## Relationship/Flow + +`RideEntity.Miles` (aggregated as window `TotalMiles`, already computed) → `AdvancedDashboardCalculations.calculateCo2Saved` (F#, pure) → `GetAdvancedDashboardService.BuildWindow` → `AdvancedSavingsWindow.Co2Saved` / `AdvancedDashboardResponse.Co2SavedPerMileLbs` → `advanced-dashboard-api.ts` typed model → `SavingsWindowsTable` display. diff --git a/specs/029-co2-savings-dashboard/plan.md b/specs/029-co2-savings-dashboard/plan.md new file mode 100644 index 0000000..1b4cd3a --- /dev/null +++ b/specs/029-co2-savings-dashboard/plan.md @@ -0,0 +1,96 @@ +# Implementation Plan: CO2 Savings on Advanced Dashboard + +**Branch**: `029-co2-savings-dashboard` | **Date**: 2026-08-27 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/029-co2-savings-dashboard/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +Add an on-demand (never persisted) CO2-saved metric to each of the advanced dashboard's four existing savings windows (weekly, monthly, yearly, all-time), plus a fixed CO2-saved-per-mile value shown alongside the totals. CO2 saved for a window is computed as `windowTotalMiles * 0.90 lb/mile` (EPA average passenger-vehicle emission factor), rounded to 2 decimal places, consistent with the existing gallons/currency rounding convention. The calculation is a pure function added next to the existing gallons/fuel-cost/mileage-rate calculations, wired into `GetAdvancedDashboardService`'s existing per-window aggregation, exposed on the existing `AdvancedSavingsWindow` contract plus a new per-mile constant on the response, and rendered as two additional columns/values in `SavingsWindowsTable`. + +## 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 `RideEntity` (no new storage engine, no new tables, no migrations — CO2 is derived at request time from existing `Miles` data) + +**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; CO2 calculation is O(1) per window given already-computed `TotalMiles`, negligible cost added to existing advanced-dashboard request path + +**Constraints**: +- CO2 value MUST NOT be persisted or cached between requests (recomputed every load per FR-005/SC-004) +- CO2-per-mile factor is a fixed constant (0.90 lb/mile) and MUST NOT vary by user MPG/vehicle settings (FR-007) +- Rounding MUST be 2 decimal places, consistent with existing gallons/currency conventions (FR-009) +- Reuse existing weekly/monthly/yearly/all-time window boundaries already established by the advanced dashboard — the advanced dashboard's yearly window already scopes to the current calendar year with no persisted, user-selectable year control; this feature does not add one (see research.md Decision 1) +- No new backend persistence, migration, or storage schema + +**Scale/Scope**: Single advanced-dashboard savings computation path (`GetAdvancedDashboardService` + `AdvancedDashboardCalculations` F# module + `AdvancedSavingsWindow`/response contracts) and its rendering in `SavingsWindowsTable` + +## 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 `029-co2-savings-dashboard` | +| TDD mandatory | ✅ PASS | Plan requires failing F# calculation tests, backend service tests, and frontend component tests before implementation | +| E2E required on every PR | ✅ PASS | Extend `savings-calculation.spec.ts` (or add a CO2-focused spec) to assert CO2 values render on the advanced dashboard | +| Ports/adapters boundaries | ✅ PASS | New calculation lives in the existing F# domain module; service/contract/UI changes stay within existing advanced-dashboard layers | +| Result-style domain outcomes | ✅ PASS | Pure function returns a plain `decimal`; zero-miles/zero-rides windows produce `0m`, not exceptions or nulls | +| Transactional relational write model | ✅ PASS (N/A) | Read/compute/display only; no new writes, no migrations | +| Local-first runtime posture | ✅ PASS | SQLite + local stack unchanged | + +**Post-design re-check (Phase 1)**: ✅ All gates remain green. Design adds one pure calculation function, one contract field per window, one response-level constant, and new UI rendering — no new services, infrastructure, or persistence shape changes. + +## Project Structure + +### Documentation (this feature) + +```text +specs/029-co2-savings-dashboard/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ +│ └── advanced-dashboard-co2-contract.md +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +src/BikeTracking.Domain.FSharp/ +└── AdvancedDashboardCalculations.fs # add calculateCo2Saved (pure fn) + Co2PerMileLbs constant + +src/BikeTracking.Api/ +├── Application/Dashboard/GetAdvancedDashboardService.cs # call new calculation per window; add per-mile constant to response +├── Contracts/AdvancedDashboardContracts.cs # add Co2Saved to AdvancedSavingsWindow; add Co2SavedPerMile to response +└── (tests) src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs + +src/BikeTracking.Frontend/src/ +├── services/advanced-dashboard-api.ts # add co2Saved / co2SavedPerMile to TS types +├── pages/advanced-dashboard/SavingsWindowsTable.tsx # render CO2 Saved column + per-mile factor +└── pages/advanced-dashboard/SavingsWindowsTable.test.tsx # assert CO2 rendering/formatting + +src/BikeTracking.Frontend/tests/e2e/ +└── savings-calculation.spec.ts # extend to assert CO2 total + per-mile figure visible +``` + +**Structure Decision**: Use the existing web-app layout (F# domain → C# API service/contracts → React frontend) and modify only the existing advanced-dashboard calculation/service/contract/UI/test files directly tied to CO2 display. No new projects, services, or persistence layers are introduced. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +No violations — table intentionally left empty. diff --git a/specs/029-co2-savings-dashboard/quickstart.md b/specs/029-co2-savings-dashboard/quickstart.md new file mode 100644 index 0000000..0fa4182 --- /dev/null +++ b/specs/029-co2-savings-dashboard/quickstart.md @@ -0,0 +1,65 @@ +# Quickstart Validation: CO2 Savings on Advanced Dashboard + +Use this to validate spec #29 end-to-end after implementation. + +## Prerequisites + +1. Run app stack: + - `dotnet run --project src/BikeTracking.AppHost` +2. Have a rider account with ride data containing miles across at least the current week/month/year (and optionally a rider with zero rides, to verify the zero/edge-case path). + +## Backend Validation + +1. Run backend tests: + - `dotnet test BikeTracking.slnx` +2. Confirm F# calculation tests (`AdvancedDashboardCalculations`) assert: + - `calculateCo2Saved` returns `totalMiles * 0.90` rounded to 2 decimal places. + - Zero miles returns `0.00m`, not an exception, null, or NaN. +3. Confirm `GetAdvancedDashboardServiceTests` assert: + - `co2Saved` is present on all four windows (weekly, monthly, yearly, allTime) and matches `totalMiles * 0.90` within 0.01. + - `co2SavedPerMileLbs` is `0.90` on the response, present even for a rider with zero rides. + - CO2 values do not change when user MPG/mileage-rate settings are changed (only ride miles affect it). + +## 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: + - A "CO2 Saved" value renders for each of the four savings-window rows, formatted to 2 decimal places with a unit label (e.g., "22.05 lb"). + - The CO2-saved-per-mile figure ("0.90 lb CO2/mile") renders once near the totals. + - Changing the underlying ride data (or reloading) recalculates the CO2 total; no stale/cached value persists across reloads. + - A window with zero miles shows "0.00 lb" (not blank or an error). + +## Manual Check + +1. Log in and open the advanced dashboard (`/advanced-dashboard` or equivalent route). +2. Verify the savings breakdown table shows a CO2 Saved value for This Week, This Month, This Year, and All Time. +3. Verify the CO2 saved-per-mile figure is visible near the totals. +4. Verify: (per-mile figure) × (a window's Miles value) ≈ that window's CO2 Saved value, within 0.01 lb. +5. Verify a rider/period with no rides shows CO2 Saved as "0.00 lb", and the per-mile figure still displays. +6. Verify CO2 values are not stored: reload the page and confirm the value is recomputed (no persisted/stale value if ride data changed since last load). + +## Final Validation Matrix + +| Area | Command/Check | Expected Result | +|------|----------------|-----------------| +| F# calculation unit tests | `dotnet test BikeTracking.slnx` (domain test project) | Pass; `calculateCo2Saved` returns correct rounded value and `0.00m` for zero miles | +| Backend service tests | `dotnet test BikeTracking.slnx` (`GetAdvancedDashboardServiceTests`) | Pass; all four windows expose `co2Saved`; response exposes `co2SavedPerMileLbs = 0.90` | +| Frontend unit suite | `cd src/BikeTracking.Frontend && npm run test:unit` | Pass; `SavingsWindowsTable` renders CO2 values and per-mile figure with correct formatting | +| Frontend E2E suite | `cd src/BikeTracking.Frontend && npm run test:e2e` | Pass; CO2 totals and per-mile figure visible on advanced dashboard | +| Manual dashboard check | Open advanced dashboard after login | CO2 Saved visible for all four windows; per-mile figure visible; totals ≈ per-mile × miles; zero-ride case shows 0.00 lb | + +## 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) +- Research: [research.md](research.md) +- Data model: [data-model.md](data-model.md) +- Contract: [contracts/advanced-dashboard-co2-contract.md](contracts/advanced-dashboard-co2-contract.md) diff --git a/specs/029-co2-savings-dashboard/research.md b/specs/029-co2-savings-dashboard/research.md new file mode 100644 index 0000000..63ece91 --- /dev/null +++ b/specs/029-co2-savings-dashboard/research.md @@ -0,0 +1,33 @@ +# Phase 0 Research: CO2 Savings on Advanced Dashboard + +## Decision 1: "Currently selected year" maps to the advanced dashboard's existing yearly window (no new year selector) + +- **Decision**: The advanced dashboard's yearly window (`GetAdvancedDashboardService.BuildWindow("yearly", ...)`) already scopes to the current calendar year (`nowLocal.Year`, Jan 1–Dec 31) with no persisted, user-facing year-picker control on that page today (year selection exists only on the separate year-stats dashboard, `GetYearStatsDashboardService`). This feature adds CO2 to the existing yearly window computation as-is; it does not introduce a new year selector or change which year the yearly window covers. +- **Rationale**: The spec's acceptance scenarios describe CO2 recalculating "when the user changes the selected year" as a restatement of existing yearly-window behavior (rides table dates changing, dashboard reloading) rather than a request for a new UI control — the assumptions section explicitly says this feature "does not introduce a new year selector." Reusing the existing per-window computation keeps the change additive and avoids scope creep into an unrelated dashboard. +- **Alternatives considered**: Adding a year-select control to the advanced dashboard yearly window (rejected — explicitly out of scope per spec assumptions and would require its own dedicated feature/spec); wiring CO2 into the year-stats dashboard instead (rejected — spec explicitly targets "the advanced dashboard's overall/yearly saved section"). + +## Decision 2: Compute CO2 as a pure F# function alongside existing per-ride/window calculations + +- **Decision**: Add `calculateCo2Saved` (and a `Co2PerMileLbs = 0.90m` constant) to `AdvancedDashboardCalculations.fs`, following the same shape as `calculateGallonsSaved`/`calculateMileageRateSavings`: takes total window miles (or a `RideSnapshot list`), returns a rounded `decimal`, and never returns `None`/null/NaN — zero miles yields `0m` per FR-006. +- **Rationale**: Keeps the calculation pure, unit-testable in isolation, and consistent with the existing domain-layer pattern (ports-and-adapters: pure F# domain calculations, C# service orchestrates and reads/writes). Unlike `calculateGallonsSaved` (which needs `Option` because it depends on a user-configured MPG that may be missing), CO2 has no missing-data case — it only depends on `TotalMiles`, which is always a known decimal — so the function returns a plain `decimal`, not `decimal option`. +- **Alternatives considered**: Computing CO2 inline in `GetAdvancedDashboardService.BuildWindow` as a one-line expression (rejected — the constitution favors pure domain-layer calculations for the F# module, and this keeps CO2 unit-testable the same way as the other savings metrics); making it dependent on user MPG/vehicle settings (rejected — explicitly forbidden by FR-007). + +## Decision 3: Expose CO2 via existing per-window contract plus one new response-level constant + +- **Decision**: Add `Co2Saved: decimal` (non-nullable, since it's always computable) to `AdvancedSavingsWindow`, and add `Co2SavedPerMileLbs: decimal` as a single constant field on `AdvancedDashboardResponse` (not per-window, since the per-mile factor is fixed and window-independent per FR-004/Acceptance Scenario 2 in User Story 2). +- **Rationale**: Matches the existing pattern of extending `AdvancedSavingsWindow` per new metric (e.g., `OilChangeSavings`, `NetSavings`) while avoiding needless repetition of a constant value across four windows. Placing the per-mile factor once at the response level directly satisfies "per-mile value... displayed alongside the total CO2 saved figures" without suggesting it varies by window. +- **Alternatives considered**: Repeating the per-mile factor on every `AdvancedSavingsWindow` (rejected — redundant, and risks the four values silently drifting if the constant is ever refactored); returning `Co2Saved` as nullable to mirror `GallonsSaved`/`FuelCostAvoided` (rejected — those are nullable because they depend on optional user settings; CO2 has no such dependency and FR-006 requires zero, not null, for empty windows). + +## Decision 4: Rounding and unit conventions match existing savings metrics + +- **Decision**: Round `Co2Saved` and `Co2SavedPerMileLbs` to 2 decimal places using `Math.Round(value, 2, MidpointRounding.AwayFromZero)` — the same helper (`RoundTo2`) already used for `GallonsSaved`, `FuelCostAvoided`, `MileageRateSavings`, etc. Units are pounds (lb) of CO2, consistent with the spec's fixed choice of US customary units matching existing gallons/miles conventions. +- **Rationale**: FR-009 explicitly requires the same 2-decimal-place convention already used for gallons/currency; reusing `RoundTo2` guarantees consistent behavior and avoids introducing a second rounding helper. +- **Alternatives considered**: Kilograms of CO2 (rejected — spec assumption explicitly calls for pounds, consistent with existing miles/gallons US-customary convention); more/fewer decimal places (rejected — spec clarification session fixed 2 decimal places). + +## Open Items + +None. All NEEDS CLARIFICATION items are resolved either by the spec's own Clarifications session (emission factor, decimal precision) or by the decisions above (year-selector mapping, calculation placement, contract shape, rounding). + +## Post-Implementation Notes + +_To be filled in during/after implementation if any decisions above change._ diff --git a/specs/029-co2-savings-dashboard/spec.md b/specs/029-co2-savings-dashboard/spec.md index 083dab5..568327b 100644 --- a/specs/029-co2-savings-dashboard/spec.md +++ b/specs/029-co2-savings-dashboard/spec.md @@ -8,6 +8,13 @@ **Input**: User description: "Add a Carbon (CO2) saved value to the advanced dashboard's overall/yearly saved section, for the current year chosen on the dashboard. This should be calculated on demand (not stored/precomputed). Also show the CO2 saved per mile so the user can understand their per-mile environmental impact." +## Clarifications + +### Session 2026-08-27 + +- Q: What exact CO2 emission factor should be used per mile of biking (replacing an equivalent mile of driving)? → A: 0.90 lb CO2 per mile (EPA average passenger vehicle, ~404g/mile) +- Q: What decimal precision should be used when displaying CO2 saved totals and the per-mile figure? → A: 2 decimal places (e.g., "245.32 lb"), consistent with existing $ and gallons metrics + ## User Scenarios & Testing *(mandatory)* ### User Story 1 - View Total CO2 Saved for the Current Year (Priority: P1) @@ -37,8 +44,8 @@ The user wants to understand their per-mile environmental impact, not just a cum **Acceptance Scenarios**: -1. **Given** the advanced dashboard is displaying savings windows, **When** the user views the CO2 section, **Then** they see a per-mile CO2 saved value (e.g., "0.90 lb CO2/mile") displayed alongside the total CO2 saved figures. -2. **Given** the per-mile value and a window's total miles, **When** the values are compared, **Then** total CO2 saved for that window equals the per-mile value multiplied by the window's total miles (within rounding). +1. **Given** the advanced dashboard is displaying savings windows, **When** the user views the CO2 section, **Then** they see a per-mile CO2 saved value of "0.90 lb CO2/mile" (EPA average passenger vehicle emission factor, ~404g/mile) displayed alongside the total CO2 saved figures. +2. **Given** the per-mile value and a window's total miles, **When** the values are compared, **Then** total CO2 saved for that window equals the per-mile value multiplied by the window's total miles (within 0.01 lb, per the 2-decimal-place rounding convention). 3. **Given** the user has no rides at all, **When** they view the CO2 per-mile value, **Then** it still displays the fixed per-mile factor (since it does not depend on ride count), while totals show as zero. --- @@ -62,11 +69,12 @@ The user wants to understand their per-mile environmental impact, not just a cum - **FR-006**: System MUST return a CO2 saved value of zero (not null, error, or NaN) for any window with zero qualifying ride miles. - **FR-007**: System MUST NOT vary the CO2-per-mile emission factor based on user-configured MPG or vehicle settings; the factor MUST be a fixed constant representing average-vehicle emissions. - **FR-008**: System MUST express the CO2 saved values with a clearly labeled unit (e.g., pounds or kilograms of CO2) both for totals and for the per-mile figure. +- **FR-009**: System MUST round CO2 saved totals and the per-mile figure to 2 decimal places (e.g., "245.32 lb"), consistent with the existing rounding convention used for gallons saved, fuel cost avoided, and mileage-rate savings. ### Key Entities - **CO2 Savings Window Metric**: A derived, request-time-only value representing the estimated mass of CO2 emissions avoided for a given savings window (weekly, monthly, yearly, all-time), computed as `windowTotalMiles * co2PerMileFactor`. Not persisted. -- **CO2 Per-Mile Factor**: A fixed constant (mass of CO2 per mile) representing the average passenger-vehicle emissions avoided by biking one mile instead of driving it. Used to compute all window totals and displayed directly to the user. +- **CO2 Per-Mile Factor**: A fixed constant of 0.90 lb CO2 per mile (EPA average passenger-vehicle emission factor, ~404g/mile) representing the average passenger-vehicle emissions avoided by biking one mile instead of driving it. Used to compute all window totals and displayed directly to the user. ## Success Criteria *(mandatory)* @@ -74,12 +82,12 @@ The user wants to understand their per-mile environmental impact, not just a cum - **SC-001**: 100% of advanced dashboard views for a year with recorded rides display a non-blank, non-error CO2 saved value in the yearly window. - **SC-002**: Users can identify their CO2 saved per mile within 5 seconds of viewing the advanced dashboard's savings section, without needing to compute it manually. -- **SC-003**: For every savings window, CO2 saved total equals (within rounding) the displayed per-mile factor multiplied by that window's total miles, verified in 100% of automated test cases. +- **SC-003**: For every savings window, CO2 saved total equals (within 0.01 lb, per the 2-decimal-place rounding convention) the displayed per-mile factor multiplied by that window's total miles, verified in 100% of automated test cases. - **SC-004**: 0 instances of CO2 saved values are found stored in the database or cached between requests; the value is recomputed on every dashboard load. ## Assumptions -- A fixed, documented CO2-per-mile emission factor (e.g., based on published average passenger-vehicle emissions per mile) is an acceptable approximation; per-user vehicle-specific emissions are out of scope for this feature. +- A fixed, documented CO2-per-mile emission factor of 0.90 lb CO2/mile (EPA average passenger-vehicle emissions, ~404g/mile) is an acceptable approximation; per-user vehicle-specific emissions are out of scope for this feature. - The CO2 metric reuses the same window/period boundaries (weekly, monthly, yearly-selected, all-time) already established by the existing advanced dashboard savings feature. - "Currently selected year" refers to the year selector already present on the advanced dashboard for the yearly window; this feature does not introduce a new year selector. - Units for display (pounds vs. kilograms of CO2) will follow the same locale/unit convention as the rest of the dashboard (assumed US customary units, i.e., pounds, consistent with miles/gallons already used). diff --git a/specs/029-co2-savings-dashboard/tasks.md b/specs/029-co2-savings-dashboard/tasks.md new file mode 100644 index 0000000..e9340fa --- /dev/null +++ b/specs/029-co2-savings-dashboard/tasks.md @@ -0,0 +1,177 @@ +--- + +description: "Task list for CO2 Savings on Advanced Dashboard" +--- + +# Tasks: CO2 Savings on Advanced Dashboard + +**Input**: Design documents from `/specs/029-co2-savings-dashboard/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/advanced-dashboard-co2-contract.md, quickstart.md + +**Tests**: Included — the constitution mandates TDD, and plan.md/quickstart.md both require failing F# calculation tests, backend service tests, and frontend component tests before implementation. + +**Organization**: Tasks are grouped by user story (US1, US2) to enable independent implementation and testing. A shared Foundational phase holds the pure CO2 calculation function both stories depend on, since it is not itself independently user-facing. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2) +- Exact file paths are included in each description + +## Path Conventions (Web app: F# domain → C# API → React frontend) + +- Domain: `src/BikeTracking.Domain.FSharp/` +- Backend: `src/BikeTracking.Api/`, tests in `src/BikeTracking.Api.Tests/` +- Frontend: `src/BikeTracking.Frontend/src/`, e2e in `src/BikeTracking.Frontend/tests/e2e/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm the working tree builds cleanly before making changes (no new projects/dependencies needed — this feature only touches existing files). + +- [ ] T001 Confirm baseline build/test health: run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit`, confirming both pass before any CO2 changes are made (no code changes in this task) + +**Checkpoint**: Baseline green — safe to start foundational work. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the fixed CO2-per-mile constant and the pure CO2 calculation function that both User Story 1 (per-window totals) and User Story 2 (per-mile figure) depend on. Per research.md Decision 2, this must be a pure, unit-testable F# function following the existing `calculateGallonsSaved`/`calculateMileageRateSavings` pattern. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [ ] T002 [P] Write failing F# calculation tests for `calculateCo2Saved` and `Co2PerMileLbs` in `src/BikeTracking.Api.Tests/Application/Dashboard/AdvancedDashboardCalculationsTests.cs` (new file), asserting: `calculateCo2Saved` returns `totalMiles * 0.90m` rounded to 2 decimal places (`MidpointRounding.AwayFromZero`); zero miles returns `0.00m` (not an exception, null, or NaN); `Co2PerMileLbs` equals `0.90m` +- [ ] T003 Add `Co2PerMileLbs = 0.90m` constant and `calculateCo2Saved (totalMiles: decimal) : decimal` pure function to `src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs`, computing `Math.Round(totalMiles * Co2PerMileLbs, 2, MidpointRounding.AwayFromZero)`; run T002's tests and confirm they now pass + +**Checkpoint**: Pure CO2 calculation exists and is unit-tested — User Story 1 and User Story 2 implementation can now begin. + +--- + +## Phase 3: User Story 1 - View Total CO2 Saved for the Current Year (Priority: P1) 🎯 MVP + +**Goal**: Every one of the four savings windows (weekly, monthly, yearly, all-time) displays a CO2-saved total computed on demand from that window's total miles, rendered alongside the existing gas/money/mileage-rate figures on the advanced dashboard. + +**Independent Test**: Navigate to the advanced dashboard with ride history for the selected year; verify the yearly window (and weekly/monthly/all-time windows) each display a CO2 saved value computed from that window's total miles, with zero-mile windows showing `0.00 lb` rather than blank/error. + +### Tests for User Story 1 ⚠️ + +> Write these tests FIRST, ensure they FAIL before implementation + +- [ ] T004 [P] [US1] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting `co2Saved`/`Co2Saved` is present and non-null on all four windows (weekly, monthly, yearly, allTime) and equals `totalMiles * 0.90` (within 0.01) for a rider with rides across multiple windows +- [ ] T005 [P] [US1] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting a window with zero ride miles returns `Co2Saved == 0.00m` (not null, exception, or NaN) +- [ ] T006 [P] [US1] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting `Co2Saved` does not change when a rider's `AverageCarMpg`/`MileageRateCents` settings change (only ride miles affect it) +- [ ] T007 [P] [US1] Write failing test in `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.test.tsx` asserting a "CO2 Saved" value renders for each of the four window rows, formatted to 2 decimal places with a unit label (e.g., `"22.05 lb"`), including `"0.00 lb"` for a zero-mile window (not blank/`"—"`) + +### Implementation for User Story 1 + +- [ ] T008 [US1] Add `Co2Saved: decimal` (non-nullable) field to the `AdvancedSavingsWindow` record in `src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs`, documented as always-present (never null, unlike `GallonsSaved`/`FuelCostAvoided`) per FR-006/data-model.md +- [ ] T009 [US1] Wire `AdvancedDashboardCalculations.calculateCo2Saved` into `BuildWindow` in `src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs`, passing `totalMiles` and setting the new `Co2Saved` field on the returned `AdvancedSavingsWindow` for all four windows (weekly, monthly, yearly, allTime) — depends on T008 +- [ ] T010 [US1] Add `co2Saved: number` (non-nullable) field to the `AdvancedSavingsWindow` TypeScript interface in `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` +- [ ] T011 [US1] Add a `formatCo2` helper (formats to `"{value.toFixed(2)} lb"`, never `"—"`) and a new "CO2 Saved" column (header + per-row cell) to `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.tsx`, rendering `co2Saved` for all four window rows — depends on T010 +- [ ] T012 [US1] Run T004–T007 and confirm they now pass; run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit` to confirm no regressions + +**Checkpoint**: User Story 1 is fully functional and independently testable — CO2 totals visible on all four savings windows. + +--- + +## Phase 4: User Story 2 - View CO2 Saved Per Mile (Priority: P1) + +**Goal**: A fixed CO2-saved-per-mile figure ("0.90 lb CO2/mile") is displayed once near the CO2 totals, independent of ride count, so users can relate per-window totals to an individual-mile impact. + +**Independent Test**: Verify a fixed "CO2 saved per mile" figure is displayed near the total CO2 saved figures (including for a rider with zero rides), and that multiplying it by any window's total miles reproduces that window's total CO2 saved value within 0.01 lb. + +### Tests for User Story 2 ⚠️ + +> Write these tests FIRST, ensure they FAIL before implementation + +- [ ] T013 [P] [US2] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting `Co2SavedPerMileLbs` is present on the response and equals `0.90m`, including for a rider with zero rides across all windows +- [ ] T014 [P] [US2] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting, for each of the four windows, `window.Co2Saved == Round(response.Co2SavedPerMileLbs * window.TotalMiles, 2)` within 0.01 (SC-003 cross-check) +- [ ] T015 [P] [US2] Write failing test in `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.test.tsx` (or a new `advanced-dashboard-page.test.tsx` case, whichever component ends up owning the caption) asserting the per-mile figure ("0.90 lb CO2/mile") renders once, not per row, including when all windows have zero rides + +### Implementation for User Story 2 + +- [ ] T016 [US2] Add `Co2SavedPerMileLbs: decimal` field to `AdvancedDashboardResponse` in `src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs`, documented as a fixed response-level constant (not per-window) +- [ ] T017 [US2] Set `Co2SavedPerMileLbs: AdvancedDashboardCalculations.Co2PerMileLbs` on the `AdvancedDashboardResponse` returned from `GetAsync` in `src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs` — depends on T016 +- [ ] T018 [US2] Add `co2SavedPerMileLbs: number` field to the `AdvancedDashboardResponse` TypeScript interface in `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` +- [ ] T019 [US2] Render the per-mile figure once (e.g., `"0.90 lb CO2/mile"`) near the CO2 totals — add a `co2SavedPerMile` prop to `SavingsWindowsTable` (or a caption in `advanced-dashboard-page.tsx`, whichever fits the existing layout best) in `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.tsx` and wire it from `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx` — depends on T018 +- [ ] T020 [US2] Run T013–T015 and confirm they now pass; run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit` to confirm no regressions + +**Checkpoint**: User Stories 1 AND 2 both work independently — CO2 totals per window and the per-mile figure are both visible and cross-validated. + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +**Purpose**: End-to-end validation and final consistency checks across both stories. + +- [ ] T021 [P] Extend `src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts` (or add a CO2-focused e2e spec in the same directory) to assert: CO2 totals render on the advanced dashboard for all four windows, the per-mile figure ("0.90 lb CO2/mile") is visible, and a zero-ride rider sees `"0.00 lb"` per window while the per-mile figure still displays +- [ ] T022 Run the full quickstart.md validation matrix: `dotnet test BikeTracking.slnx`, `cd src/BikeTracking.Frontend && npm run test:unit`, `cd src/BikeTracking.Frontend && npm run test:e2e`, and the manual dashboard check described in `specs/029-co2-savings-dashboard/quickstart.md` +- [ ] T023 Re-verify the Constitution Check post-implementation gates in `specs/029-co2-savings-dashboard/plan.md` still hold (no new persistence, CO2 not cached/stored, pure F# calculation, TDD followed) and update `plan.md`'s Post-Design re-check notes if anything changed during implementation + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — run first +- **Foundational (Phase 2)**: Depends on Setup completion — BLOCKS both user stories (both need `calculateCo2Saved`/`Co2PerMileLbs`) +- **User Story 1 (Phase 3)**: Depends on Foundational completion only — independently testable once done +- **User Story 2 (Phase 4)**: Depends on Foundational completion; touches the same contract/service/frontend files as US1 (`AdvancedDashboardContracts.cs`, `GetAdvancedDashboardService.cs`, `advanced-dashboard-api.ts`, `SavingsWindowsTable.tsx`) so is best done sequentially after US1 to avoid merge conflicts, though it does not functionally depend on US1's `Co2Saved` field +- **Polish (Phase 5)**: Depends on both User Story 1 and User Story 2 being complete (SC-003 cross-check needs both `Co2Saved` and `Co2SavedPerMileLbs`) + +### Within Each User Story + +- Tests (T004–T007, T013–T015) MUST be written and FAIL before their corresponding implementation tasks +- Contract change before service wiring before frontend type before frontend rendering +- Story complete before moving to the next phase + +### Parallel Opportunities + +- T002 (foundational tests) can be written in parallel with T001 (baseline check), though T003 must wait for T002 +- Within US1: T004, T005, T006, T007 (all test-writing tasks) can run in parallel — different files/independent assertions +- Within US2: T013, T014, T015 can run in parallel +- T021 (e2e) can be drafted in parallel with T022 (running the validation matrix) once US1 and US2 implementation is merged + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for User Story 1 together: +Task: "Write failing test asserting co2Saved present and correct on all four windows in GetAdvancedDashboardServiceTests.cs" +Task: "Write failing test asserting zero-mile window returns Co2Saved == 0.00m in GetAdvancedDashboardServiceTests.cs" +Task: "Write failing test asserting Co2Saved unaffected by MPG/mileage-rate settings in GetAdvancedDashboardServiceTests.cs" +Task: "Write failing test asserting CO2 Saved column renders per row with correct formatting in SavingsWindowsTable.test.tsx" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL — blocks both stories) +3. Complete Phase 3: User Story 1 (CO2 totals per window) +4. **STOP and VALIDATE**: Confirm CO2 totals render correctly for all four windows, including the zero-mile case +5. This alone satisfies FR-001–FR-003 and FR-006 and is demoable as the MVP + +### Incremental Delivery + +1. Complete Setup + Foundational → pure CO2 calculation ready and unit-tested +2. Add User Story 1 → test independently → CO2 totals visible (MVP) +3. Add User Story 2 → test independently → per-mile figure visible, SC-003 cross-check passes +4. Add Polish → e2e coverage + full quickstart validation + +### Notes + +- Both user stories are P1 in spec.md — there is no P2/P3 phase for this feature +- [P] tasks = different files/assertions, no dependencies +- [Story] label maps each task to US1 or US2 for traceability +- Verify tests fail before implementing (TDD is constitution-mandated for this repo) +- No new persistence, migrations, or storage schema are introduced by any task +- Commit after each task or logical group; stop at either checkpoint to validate independently From 2e6fdbbfdfa8dd545c1ef7c1d939c4590c6eafbe Mon Sep 17 00:00:00 2001 From: aligneddev Date: Thu, 27 Aug 2026 15:33:31 +0000 Subject: [PATCH 3/5] feat(dashboard): add CO2 savings to advanced dashboard Add calculateCo2Saved pure function and Co2PerMileLbs constant to the F# domain calculations, wire the new Co2Saved and Co2SavedPerMileLbs fields through the API contracts and GetAdvancedDashboardService, and surface the CO2 savings in the frontend SavingsWindowsTable and advanced dashboard page (types, column, per-mile caption). Includes backend unit tests (AdvancedDashboardCalculationsTests, GetAdvancedDashboardServiceTests), frontend unit tests (SavingsWindowsTable, advanced-dashboard-page), and extended e2e coverage in savings-calculation.spec.ts, following TDD. Completes tasks T001-T023 in specs/029-co2-savings-dashboard/tasks.md. --- specs/029-co2-savings-dashboard/tasks.md | 46 ++--- .../AdvancedDashboardCalculationsTests.cs | 45 +++++ .../GetAdvancedDashboardServiceTests.cs | 175 ++++++++++++++++++ .../Dashboard/GetAdvancedDashboardService.cs | 8 +- .../Contracts/AdvancedDashboardContracts.cs | 17 +- .../AdvancedDashboardCalculations.fs | 10 + .../SavingsWindowsTable.test.tsx | 19 ++ .../SavingsWindowsTable.tsx | 15 ++ .../advanced-dashboard-page.test.tsx | 30 +++ .../advanced-dashboard-page.tsx | 1 + .../src/services/advanced-dashboard-api.ts | 11 ++ .../tests/e2e/savings-calculation.spec.ts | 37 ++++ 12 files changed, 387 insertions(+), 27 deletions(-) create mode 100644 src/BikeTracking.Api.Tests/Application/Dashboard/AdvancedDashboardCalculationsTests.cs diff --git a/specs/029-co2-savings-dashboard/tasks.md b/specs/029-co2-savings-dashboard/tasks.md index e9340fa..1afdeff 100644 --- a/specs/029-co2-savings-dashboard/tasks.md +++ b/specs/029-co2-savings-dashboard/tasks.md @@ -31,7 +31,7 @@ description: "Task list for CO2 Savings on Advanced Dashboard" **Purpose**: Confirm the working tree builds cleanly before making changes (no new projects/dependencies needed — this feature only touches existing files). -- [ ] T001 Confirm baseline build/test health: run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit`, confirming both pass before any CO2 changes are made (no code changes in this task) +- [X] T001 Confirm baseline build/test health: run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit`, confirming both pass before any CO2 changes are made (no code changes in this task) **Checkpoint**: Baseline green — safe to start foundational work. @@ -43,8 +43,8 @@ description: "Task list for CO2 Savings on Advanced Dashboard" **⚠️ CRITICAL**: No user story work can begin until this phase is complete. -- [ ] T002 [P] Write failing F# calculation tests for `calculateCo2Saved` and `Co2PerMileLbs` in `src/BikeTracking.Api.Tests/Application/Dashboard/AdvancedDashboardCalculationsTests.cs` (new file), asserting: `calculateCo2Saved` returns `totalMiles * 0.90m` rounded to 2 decimal places (`MidpointRounding.AwayFromZero`); zero miles returns `0.00m` (not an exception, null, or NaN); `Co2PerMileLbs` equals `0.90m` -- [ ] T003 Add `Co2PerMileLbs = 0.90m` constant and `calculateCo2Saved (totalMiles: decimal) : decimal` pure function to `src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs`, computing `Math.Round(totalMiles * Co2PerMileLbs, 2, MidpointRounding.AwayFromZero)`; run T002's tests and confirm they now pass +- [X] T002 [P] Write failing F# calculation tests for `calculateCo2Saved` and `Co2PerMileLbs` in `src/BikeTracking.Api.Tests/Application/Dashboard/AdvancedDashboardCalculationsTests.cs` (new file), asserting: `calculateCo2Saved` returns `totalMiles * 0.90m` rounded to 2 decimal places (`MidpointRounding.AwayFromZero`); zero miles returns `0.00m` (not an exception, null, or NaN); `Co2PerMileLbs` equals `0.90m` +- [X] T003 Add `Co2PerMileLbs = 0.90m` constant and `calculateCo2Saved (totalMiles: decimal) : decimal` pure function to `src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs`, computing `Math.Round(totalMiles * Co2PerMileLbs, 2, MidpointRounding.AwayFromZero)`; run T002's tests and confirm they now pass **Checkpoint**: Pure CO2 calculation exists and is unit-tested — User Story 1 and User Story 2 implementation can now begin. @@ -60,18 +60,18 @@ description: "Task list for CO2 Savings on Advanced Dashboard" > Write these tests FIRST, ensure they FAIL before implementation -- [ ] T004 [P] [US1] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting `co2Saved`/`Co2Saved` is present and non-null on all four windows (weekly, monthly, yearly, allTime) and equals `totalMiles * 0.90` (within 0.01) for a rider with rides across multiple windows -- [ ] T005 [P] [US1] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting a window with zero ride miles returns `Co2Saved == 0.00m` (not null, exception, or NaN) -- [ ] T006 [P] [US1] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting `Co2Saved` does not change when a rider's `AverageCarMpg`/`MileageRateCents` settings change (only ride miles affect it) -- [ ] T007 [P] [US1] Write failing test in `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.test.tsx` asserting a "CO2 Saved" value renders for each of the four window rows, formatted to 2 decimal places with a unit label (e.g., `"22.05 lb"`), including `"0.00 lb"` for a zero-mile window (not blank/`"—"`) +- [X] T004 [P] [US1] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting `co2Saved`/`Co2Saved` is present and non-null on all four windows (weekly, monthly, yearly, allTime) and equals `totalMiles * 0.90` (within 0.01) for a rider with rides across multiple windows +- [X] T005 [P] [US1] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting a window with zero ride miles returns `Co2Saved == 0.00m` (not null, exception, or NaN) +- [X] T006 [P] [US1] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting `Co2Saved` does not change when a rider's `AverageCarMpg`/`MileageRateCents` settings change (only ride miles affect it) +- [X] T007 [P] [US1] Write failing test in `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.test.tsx` asserting a "CO2 Saved" value renders for each of the four window rows, formatted to 2 decimal places with a unit label (e.g., `"22.05 lb"`), including `"0.00 lb"` for a zero-mile window (not blank/`"—"`) ### Implementation for User Story 1 -- [ ] T008 [US1] Add `Co2Saved: decimal` (non-nullable) field to the `AdvancedSavingsWindow` record in `src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs`, documented as always-present (never null, unlike `GallonsSaved`/`FuelCostAvoided`) per FR-006/data-model.md -- [ ] T009 [US1] Wire `AdvancedDashboardCalculations.calculateCo2Saved` into `BuildWindow` in `src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs`, passing `totalMiles` and setting the new `Co2Saved` field on the returned `AdvancedSavingsWindow` for all four windows (weekly, monthly, yearly, allTime) — depends on T008 -- [ ] T010 [US1] Add `co2Saved: number` (non-nullable) field to the `AdvancedSavingsWindow` TypeScript interface in `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` -- [ ] T011 [US1] Add a `formatCo2` helper (formats to `"{value.toFixed(2)} lb"`, never `"—"`) and a new "CO2 Saved" column (header + per-row cell) to `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.tsx`, rendering `co2Saved` for all four window rows — depends on T010 -- [ ] T012 [US1] Run T004–T007 and confirm they now pass; run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit` to confirm no regressions +- [X] T008 [US1] Add `Co2Saved: decimal` (non-nullable) field to the `AdvancedSavingsWindow` record in `src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs`, documented as always-present (never null, unlike `GallonsSaved`/`FuelCostAvoided`) per FR-006/data-model.md +- [X] T009 [US1] Wire `AdvancedDashboardCalculations.calculateCo2Saved` into `BuildWindow` in `src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs`, passing `totalMiles` and setting the new `Co2Saved` field on the returned `AdvancedSavingsWindow` for all four windows (weekly, monthly, yearly, allTime) — depends on T008 +- [X] T010 [US1] Add `co2Saved: number` (non-nullable) field to the `AdvancedSavingsWindow` TypeScript interface in `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` +- [X] T011 [US1] Add a `formatCo2` helper (formats to `"{value.toFixed(2)} lb"`, never `"—"`) and a new "CO2 Saved" column (header + per-row cell) to `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.tsx`, rendering `co2Saved` for all four window rows — depends on T010 +- [X] T012 [US1] Run T004–T007 and confirm they now pass; run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit` to confirm no regressions **Checkpoint**: User Story 1 is fully functional and independently testable — CO2 totals visible on all four savings windows. @@ -87,17 +87,17 @@ description: "Task list for CO2 Savings on Advanced Dashboard" > Write these tests FIRST, ensure they FAIL before implementation -- [ ] T013 [P] [US2] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting `Co2SavedPerMileLbs` is present on the response and equals `0.90m`, including for a rider with zero rides across all windows -- [ ] T014 [P] [US2] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting, for each of the four windows, `window.Co2Saved == Round(response.Co2SavedPerMileLbs * window.TotalMiles, 2)` within 0.01 (SC-003 cross-check) -- [ ] T015 [P] [US2] Write failing test in `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.test.tsx` (or a new `advanced-dashboard-page.test.tsx` case, whichever component ends up owning the caption) asserting the per-mile figure ("0.90 lb CO2/mile") renders once, not per row, including when all windows have zero rides +- [X] T013 [P] [US2] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting `Co2SavedPerMileLbs` is present on the response and equals `0.90m`, including for a rider with zero rides across all windows +- [X] T014 [P] [US2] Write failing test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs` asserting, for each of the four windows, `window.Co2Saved == Round(response.Co2SavedPerMileLbs * window.TotalMiles, 2)` within 0.01 (SC-003 cross-check) +- [X] T015 [P] [US2] Write failing test in `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.test.tsx` (or a new `advanced-dashboard-page.test.tsx` case, whichever component ends up owning the caption) asserting the per-mile figure ("0.90 lb CO2/mile") renders once, not per row, including when all windows have zero rides ### Implementation for User Story 2 -- [ ] T016 [US2] Add `Co2SavedPerMileLbs: decimal` field to `AdvancedDashboardResponse` in `src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs`, documented as a fixed response-level constant (not per-window) -- [ ] T017 [US2] Set `Co2SavedPerMileLbs: AdvancedDashboardCalculations.Co2PerMileLbs` on the `AdvancedDashboardResponse` returned from `GetAsync` in `src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs` — depends on T016 -- [ ] T018 [US2] Add `co2SavedPerMileLbs: number` field to the `AdvancedDashboardResponse` TypeScript interface in `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` -- [ ] T019 [US2] Render the per-mile figure once (e.g., `"0.90 lb CO2/mile"`) near the CO2 totals — add a `co2SavedPerMile` prop to `SavingsWindowsTable` (or a caption in `advanced-dashboard-page.tsx`, whichever fits the existing layout best) in `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.tsx` and wire it from `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx` — depends on T018 -- [ ] T020 [US2] Run T013–T015 and confirm they now pass; run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit` to confirm no regressions +- [X] T016 [US2] Add `Co2SavedPerMileLbs: decimal` field to `AdvancedDashboardResponse` in `src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs`, documented as a fixed response-level constant (not per-window) +- [X] T017 [US2] Set `Co2SavedPerMileLbs: AdvancedDashboardCalculations.Co2PerMileLbs` on the `AdvancedDashboardResponse` returned from `GetAsync` in `src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs` — depends on T016 +- [X] T018 [US2] Add `co2SavedPerMileLbs: number` field to the `AdvancedDashboardResponse` TypeScript interface in `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` +- [X] T019 [US2] Render the per-mile figure once (e.g., `"0.90 lb CO2/mile"`) near the CO2 totals — add a `co2SavedPerMile` prop to `SavingsWindowsTable` (or a caption in `advanced-dashboard-page.tsx`, whichever fits the existing layout best) in `src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.tsx` and wire it from `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx` — depends on T018 +- [X] T020 [US2] Run T013–T015 and confirm they now pass; run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit` to confirm no regressions **Checkpoint**: User Stories 1 AND 2 both work independently — CO2 totals per window and the per-mile figure are both visible and cross-validated. @@ -107,9 +107,9 @@ description: "Task list for CO2 Savings on Advanced Dashboard" **Purpose**: End-to-end validation and final consistency checks across both stories. -- [ ] T021 [P] Extend `src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts` (or add a CO2-focused e2e spec in the same directory) to assert: CO2 totals render on the advanced dashboard for all four windows, the per-mile figure ("0.90 lb CO2/mile") is visible, and a zero-ride rider sees `"0.00 lb"` per window while the per-mile figure still displays -- [ ] T022 Run the full quickstart.md validation matrix: `dotnet test BikeTracking.slnx`, `cd src/BikeTracking.Frontend && npm run test:unit`, `cd src/BikeTracking.Frontend && npm run test:e2e`, and the manual dashboard check described in `specs/029-co2-savings-dashboard/quickstart.md` -- [ ] T023 Re-verify the Constitution Check post-implementation gates in `specs/029-co2-savings-dashboard/plan.md` still hold (no new persistence, CO2 not cached/stored, pure F# calculation, TDD followed) and update `plan.md`'s Post-Design re-check notes if anything changed during implementation +- [X] T021 [P] Extend `src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts` (or add a CO2-focused e2e spec in the same directory) to assert: CO2 totals render on the advanced dashboard for all four windows, the per-mile figure ("0.90 lb CO2/mile") is visible, and a zero-ride rider sees `"0.00 lb"` per window while the per-mile figure still displays +- [X] T022 Run the full quickstart.md validation matrix: `dotnet test BikeTracking.slnx`, `cd src/BikeTracking.Frontend && npm run test:unit`, `cd src/BikeTracking.Frontend && npm run test:e2e`, and the manual dashboard check described in `specs/029-co2-savings-dashboard/quickstart.md` +- [X] T023 Re-verify the Constitution Check post-implementation gates in `specs/029-co2-savings-dashboard/plan.md` still hold (no new persistence, CO2 not cached/stored, pure F# calculation, TDD followed) and update `plan.md`'s Post-Design re-check notes if anything changed during implementation --- diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/AdvancedDashboardCalculationsTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/AdvancedDashboardCalculationsTests.cs new file mode 100644 index 0000000..f057042 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/AdvancedDashboardCalculationsTests.cs @@ -0,0 +1,45 @@ +using BikeTracking.Domain.FSharp; + +namespace BikeTracking.Api.Tests.Application.Dashboard; + +/// +/// Tests for the pure CO2-saved calculation function and per-mile constant +/// (Feature 029, Phase 2 — Foundational). Tests are RED until +/// AdvancedDashboardCalculations.fs is extended with calculateCo2Saved +/// and Co2PerMileLbs. +/// +public sealed class AdvancedDashboardCalculationsTests +{ + [Fact] + public void CalculateCo2Saved_WithPositiveMiles_ReturnsMilesTimesFactorRoundedTo2Places() + { + // 24.5 miles * 0.90 = 22.05 + var result = AdvancedDashboardCalculations.calculateCo2Saved(24.5m); + + Assert.Equal(22.05m, result); + } + + [Fact] + public void CalculateCo2Saved_WithZeroMiles_ReturnsZeroNotExceptionOrNaN() + { + var result = AdvancedDashboardCalculations.calculateCo2Saved(0m); + + Assert.Equal(0.00m, result); + } + + [Fact] + public void CalculateCo2Saved_RoundsAwayFromZeroAtMidpoint() + { + // 1 mile * 0.905 would round to 0.91 under AwayFromZero at the 3rd decimal, + // but with a fixed 0.90 factor: 12.345 * 0.90 = 11.1105 -> rounds to 11.11 + var result = AdvancedDashboardCalculations.calculateCo2Saved(12.345m); + + Assert.Equal(11.11m, result); + } + + [Fact] + public void Co2PerMileLbs_EqualsFixedPointNinety() + { + Assert.Equal(0.90m, AdvancedDashboardCalculations.Co2PerMileLbs); + } +} diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs index 0b036d2..7221a4b 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs @@ -1,4 +1,5 @@ using BikeTracking.Api.Application.Dashboard; +using BikeTracking.Api.Contracts; using BikeTracking.Api.Infrastructure.Persistence; using BikeTracking.Api.Infrastructure.Persistence.Entities; using BikeTracking.Api.Tests.TestSupport; @@ -586,6 +587,180 @@ public async Task GetAdvancedDashboardService_WithNoOilChangePrice_OilChangeSavi Assert.Null(result.SavingsWindows.AllTime.OilChangeSavings); } + // ── CO2 Savings (Feature 029) ─────────────────────────────────────────── + + [Fact] + public async Task GetAdvancedDashboardService_WithRidesAcrossWindows_ReturnsCo2SavedEqualToTotalMilesTimesFactorOnAllFourWindows() + { + using var dbContext = CreateDbContext(); + var rider = await CreateRiderAsync(dbContext, "Co2 Windows Rider"); + + var now = DateTime.Now; + var weekStart = now.Date.AddDays(-(((int)now.DayOfWeek - 1 + 7) % 7)); + + dbContext.Rides.AddRange( + // This week (also counts toward month/year/all-time) + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = weekStart, + Miles = 10m, + CreatedAtUtc = DateTime.UtcNow, + }, + // Earlier this year but before this week (counts toward year/all-time only) + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = new DateTime(now.Year, 1, 1), + Miles = 5m, + CreatedAtUtc = DateTime.UtcNow, + }, + // Last year (all-time only) + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = new DateTime(now.Year - 1, 6, 1), + Miles = 20m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); + var result = await service.GetAsync(rider.UserId); + + var windows = result.SavingsWindows; + AssertCo2SavedMatchesMiles(windows.Weekly); + AssertCo2SavedMatchesMiles(windows.Monthly); + AssertCo2SavedMatchesMiles(windows.Yearly); + AssertCo2SavedMatchesMiles(windows.AllTime); + + static void AssertCo2SavedMatchesMiles(AdvancedSavingsWindow window) + { + var expected = Math.Round(window.TotalMiles * 0.90m, 2, MidpointRounding.AwayFromZero); + Assert.Equal(expected, window.Co2Saved, 2); + } + } + + [Fact] + public async Task GetAdvancedDashboardService_ZeroMileWindow_ReturnsCo2SavedZeroNotNull() + { + using var dbContext = CreateDbContext(); + var rider = await CreateRiderAsync(dbContext, "Co2 ZeroMile Rider"); + + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); + var result = await service.GetAsync(rider.UserId); + + // No rides at all — every window has zero miles. + Assert.Equal(0.00m, result.SavingsWindows.Weekly.Co2Saved); + Assert.Equal(0.00m, result.SavingsWindows.Monthly.Co2Saved); + Assert.Equal(0.00m, result.SavingsWindows.Yearly.Co2Saved); + Assert.Equal(0.00m, result.SavingsWindows.AllTime.Co2Saved); + } + + [Fact] + public async Task GetAdvancedDashboardService_RiderMpgAndMileageRateSettingsChange_DoesNotAffectCo2Saved() + { + using var dbContext = CreateDbContext(); + var rider = await CreateRiderAsync(dbContext, "Co2 SettingsIndependent Rider"); + + dbContext.Rides.Add( + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = DateTime.Now, + Miles = 10m, + // No MPG/mileage-rate snapshot at all — CO2 must still be computed from miles. + SnapshotAverageCarMpg = null, + SnapshotMileageRateCents = null, + GasPricePerGallon = null, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var serviceWithoutSettings = new GetAdvancedDashboardService(dbContext, TimeProvider.System); + var resultWithoutSettings = await serviceWithoutSettings.GetAsync(rider.UserId); + + dbContext.UserSettings.Add( + new UserSettingsEntity + { + UserId = rider.UserId, + AverageCarMpg = 45m, + MileageRateCents = 70m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var serviceWithSettings = new GetAdvancedDashboardService(dbContext, TimeProvider.System); + var resultWithSettings = await serviceWithSettings.GetAsync(rider.UserId); + + Assert.Equal(9.00m, resultWithoutSettings.SavingsWindows.AllTime.Co2Saved); + Assert.Equal( + resultWithoutSettings.SavingsWindows.AllTime.Co2Saved, + resultWithSettings.SavingsWindows.AllTime.Co2Saved + ); + } + + [Fact] + public async Task GetAdvancedDashboardService_ZeroRides_ReturnsCo2SavedPerMileLbsOfNinetyHundredths() + { + using var dbContext = CreateDbContext(); + var rider = await CreateRiderAsync(dbContext, "Co2 PerMile ZeroRides Rider"); + + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); + var result = await service.GetAsync(rider.UserId); + + Assert.Equal(0.90m, result.Co2SavedPerMileLbs); + } + + [Fact] + public async Task GetAdvancedDashboardService_ForEachWindow_Co2SavedEqualsPerMileFactorTimesTotalMiles() + { + using var dbContext = CreateDbContext(); + var rider = await CreateRiderAsync(dbContext, "Co2 CrossCheck Rider"); + + dbContext.Rides.AddRange( + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = DateTime.Now, + Miles = 7.3m, + CreatedAtUtc = DateTime.UtcNow, + }, + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = new DateTime(DateTime.Now.Year - 1, 3, 1), + Miles = 13.6m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); + var result = await service.GetAsync(rider.UserId); + + foreach ( + var window in new[] + { + result.SavingsWindows.Weekly, + result.SavingsWindows.Monthly, + result.SavingsWindows.Yearly, + result.SavingsWindows.AllTime, + } + ) + { + var expected = Math.Round( + result.Co2SavedPerMileLbs * window.TotalMiles, + 2, + MidpointRounding.AwayFromZero + ); + Assert.Equal(expected, window.Co2Saved, 2); + } + } + // ── Helpers ─────────────────────────────────────────────────────────── private static async Task CreateRiderAsync( diff --git a/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs index 5a60d58..53bfacb 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs @@ -142,7 +142,8 @@ public async Task GetAsync( Suggestions: suggestions, Reminders: reminders, GeneratedAtUtc: DateTime.UtcNow, - DifficultySection: difficultySection + DifficultySection: difficultySection, + Co2SavedPerMileLbs: AdvancedDashboardCalculations.Co2PerMileLbs ); } @@ -263,6 +264,8 @@ private static AdvancedSavingsWindow BuildWindow( ); } + var co2Saved = AdvancedDashboardCalculations.calculateCo2Saved(totalMiles); + return new AdvancedSavingsWindow( Period: period, RideCount: rideCount, @@ -274,7 +277,8 @@ private static AdvancedSavingsWindow BuildWindow( CombinedSavings: combinedSavings, TotalExpenses: totalExpenses, OilChangeSavings: oilChangeSavings, - NetSavings: netSavings + NetSavings: netSavings, + Co2Saved: co2Saved ); } diff --git a/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs b/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs index 9e8c327..200f040 100644 --- a/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs +++ b/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs @@ -12,7 +12,13 @@ public sealed record AdvancedDashboardResponse( /// UTC timestamp when the response was generated (useful for caching/staleness checks). DateTime GeneratedAtUtc, /// Difficulty analytics section. Null when no qualifying ride data exists. - AdvancedDashboardDifficultySection? DifficultySection = null + AdvancedDashboardDifficultySection? DifficultySection = null, + /// + /// Fixed CO2-saved-per-mile figure in pounds (0.90 lb/mile — EPA average passenger-vehicle + /// emission factor). This is a response-level constant, not per-window, and does not vary + /// by user MPG/vehicle settings (FR-007). + /// + decimal Co2SavedPerMileLbs = 0.90m ); /// @@ -92,7 +98,14 @@ public sealed record AdvancedSavingsWindow( /// Null only when fuel/oil savings are unavailable and expenses are zero. /// Can be negative when expenses exceed savings. /// - decimal? NetSavings + decimal? NetSavings, + /// + /// Total CO2 saved (in pounds) for this window, computed as TotalMiles × 0.90 lb/mile + /// (fixed EPA average passenger-vehicle emission factor), rounded to 2 decimal places. + /// Always present (never null, unlike GallonsSaved/FuelCostAvoided) — a zero-mile + /// window yields 0.00m rather than null. Never persisted or cached; recomputed every request. + /// + decimal Co2Saved ); /// diff --git a/src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs b/src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs index dee8602..c0eb15f 100644 --- a/src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs +++ b/src/BikeTracking.Domain.FSharp/AdvancedDashboardCalculations.fs @@ -42,6 +42,16 @@ type WindResistanceBin = { Rating: int RideCount: int } +/// Fixed EPA average passenger-vehicle CO2 emission factor, in pounds of CO2 per mile. +/// This is a constant — it does not vary by user MPG or vehicle settings (FR-007). +let Co2PerMileLbs = 0.90m + +/// Calculates total CO2 saved (in pounds) for a given total mileage, using the fixed +/// Co2PerMileLbs factor. Pure and total: zero miles returns 0.00m rather than an +/// exception, null, or NaN. +let calculateCo2Saved (totalMiles: decimal) : decimal = + Math.Round(totalMiles * Co2PerMileLbs, 2, MidpointRounding.AwayFromZero) + /// Calculates total gallons saved across a set of rides. /// Returns None when no rides have a valid MPG snapshot. let calculateGallonsSaved (rides: RideSnapshot list) : decimal option = diff --git a/src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.test.tsx b/src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.test.tsx index c78db50..b9abe77 100644 --- a/src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.test.tsx @@ -19,6 +19,7 @@ function buildWindow( totalExpenses: 0, oilChangeSavings: null, netSavings: null, + co2Saved: 0, ...overrides, } } @@ -117,6 +118,24 @@ describe('SavingsWindowsTable', () => { expect(negativeCell).toBeInTheDocument() expect(negativeCell.closest('td')?.className).toContain('savings-windows-negative') }) + + it('SavingsWindowsTable_WithCo2SavedValues_RendersCo2SavedColumnForAllFourWindows', () => { + render( + + ) + + expect(screen.getByText(/co2 saved/i)).toBeInTheDocument() + expect(screen.getByText('9.00 lb')).toBeInTheDocument() + expect(screen.getByText('22.05 lb')).toBeInTheDocument() + expect(screen.getByText('90.00 lb')).toBeInTheDocument() + // Zero-mile window shows "0.00 lb", never blank or "—" + expect(screen.getByText('0.00 lb')).toBeInTheDocument() + }) }) describe('SavingsWindowsTable', () => { diff --git a/src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.tsx b/src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.tsx index e77ed78..d4da3ee 100644 --- a/src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.tsx +++ b/src/BikeTracking.Frontend/src/pages/advanced-dashboard/SavingsWindowsTable.tsx @@ -5,6 +5,8 @@ interface SavingsWindowsTableProps { monthly: AdvancedSavingsWindow yearly: AdvancedSavingsWindow allTime: AdvancedSavingsWindow + /** Fixed CO2-saved-per-mile figure in pounds, rendered once near the totals. */ + co2SavedPerMile?: number } /** Formats a dollar amount as USD currency, or "—" when null. */ @@ -28,6 +30,11 @@ function formatMiles(value: number): string { return `${value.toFixed(1)} mi` } +/** Formats a CO2 value to 2 decimal places with a "lb" unit label. Never "—". */ +function formatCo2(value: number): string { + return `${value.toFixed(2)} lb` +} + const WINDOW_LABELS: Record = { weekly: 'This Week', monthly: 'This Month', @@ -61,6 +68,7 @@ function WindowRow({ window: w }: WindowRowProps) { {formatCurrency(w.mileageRateSavings)} {formatCurrency(w.totalExpenses)} {formatCurrency(w.oilChangeSavings)} + {formatCo2(w.co2Saved)} @@ -81,9 +89,15 @@ export function SavingsWindowsTable({ monthly, yearly, allTime, + co2SavedPerMile, }: SavingsWindowsTableProps) { return (
+ {co2SavedPerMile !== undefined ? ( +

+ {co2SavedPerMile.toFixed(2)} lb CO2/mile +

+ ) : null} @@ -95,6 +109,7 @@ export function SavingsWindowsTable({ + diff --git a/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.test.tsx b/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.test.tsx index 8174e8d..1ed566b 100644 --- a/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.test.tsx @@ -31,6 +31,7 @@ function buildWindow( totalExpenses: 0, oilChangeSavings: null, netSavings: null, + co2Saved: 0, ...overrides, } } @@ -56,6 +57,7 @@ function buildResponse( }, generatedAtUtc: new Date().toISOString(), difficultySection: null, + co2SavedPerMileLbs: 0.9, ...overrides, } } @@ -203,4 +205,32 @@ describe('AdvancedDashboardPage', () => { expect(screen.getByRole('link', { name: /view advanced stats/i })).toBeInTheDocument() }) + + it('AdvancedDashboardPage_Co2SavedPerMile_RendersOnceEvenWithZeroRides', async () => { + mockGetAdvancedDashboard.mockResolvedValue( + buildResponse({ + savingsWindows: { + weekly: buildWindow('weekly'), + monthly: buildWindow('monthly'), + yearly: buildWindow('yearly'), + allTime: buildWindow('allTime'), + }, + co2SavedPerMileLbs: 0.9, + }) + ) + + const { AdvancedDashboardPage } = await import('./advanced-dashboard-page') + render( + + + + ) + + await waitFor(() => { + expect(screen.getByText(/0\.90 lb co2\/mile/i)).toBeInTheDocument() + }) + + // Must render exactly once — not once per window row. + expect(screen.getAllByText(/0\.90 lb co2\/mile/i)).toHaveLength(1) + }) }) diff --git a/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx b/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx index 75412ec..c972919 100644 --- a/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx +++ b/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx @@ -107,6 +107,7 @@ export function AdvancedDashboardPage() { monthly={data.savingsWindows.monthly} yearly={data.savingsWindows.yearly} allTime={data.savingsWindows.allTime} + co2SavedPerMile={data.co2SavedPerMileLbs} /> diff --git a/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts b/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts index a73c43b..5bd365b 100644 --- a/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts +++ b/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts @@ -27,6 +27,12 @@ export interface AdvancedSavingsWindow { * Null only when fuel/oil savings are unavailable and expenses are zero. Can be negative. */ netSavings: number | null; + /** + * Total CO2 saved (in pounds) for this window, computed as totalMiles × 0.90 lb/mile, + * rounded to 2 decimal places. Always present (never null) — a zero-mile window + * yields 0. + */ + co2Saved: number; } /** Four calendar time-window savings breakdown returned by the advanced dashboard endpoint. */ @@ -87,6 +93,11 @@ export interface AdvancedDashboardResponse { reminders: AdvancedDashboardReminders; generatedAtUtc: string; difficultySection: AdvancedDashboardDifficultySection | null; + /** + * Fixed CO2-saved-per-mile figure in pounds (0.90 lb/mile). A response-level + * constant, not per-window; does not vary by user MPG/vehicle settings. + */ + co2SavedPerMileLbs: number; } diff --git a/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts b/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts index 30eb76d..71c0bd9 100644 --- a/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts @@ -99,4 +99,41 @@ test.describe("015-savings-calculation e2e", () => { "-$10.00", ); }); + + test("advanced dashboard shows CO2 totals for all four windows and the fixed per-mile figure", async ({ + page, + }) => { + const userName = uniqueUser("e2e-dashboard-co2-savings"); + + await createAndLoginUser(page, userName, TEST_PIN); + await recordRide(page, { + rideDateTimeLocal: "2026-04-16T08:00", + miles: "10", + gasPrice: "3.0000", + }); + + await page.goto("/dashboard/advanced"); + + // Fixed per-mile figure is visible once, regardless of ride count. + await expect(page.getByText(/0\.90 lb co2\/mile/i)).toBeVisible(); + + // CO2 Saved column header and all four window rows render a CO2 value. + await expect(page.getByText(/co2 saved/i).first()).toBeVisible(); + await expect(page.getByText("9.00 lb").first()).toBeVisible(); + }); + + test("advanced dashboard shows 0.00 lb CO2 for a zero-ride rider while the per-mile figure still displays", async ({ + page, + }) => { + const userName = uniqueUser("e2e-dashboard-co2-zero-rides"); + + await createAndLoginUser(page, userName, TEST_PIN); + + await page.goto("/dashboard/advanced"); + + await expect(page.getByText(/0\.90 lb co2\/mile/i)).toBeVisible(); + const zeroCo2Cells = page.getByText("0.00 lb"); + await expect(zeroCo2Cells.first()).toBeVisible(); + await expect(zeroCo2Cells).toHaveCount(4); + }); }); From 5a90e6e2a5bfb26fba1226aeb98e89c4c5c0d4f7 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Thu, 27 Aug 2026 18:02:20 +0000 Subject: [PATCH 4/5] Remove duplicated hardcoded CO2 emission factor default from contract Co2SavedPerMileLbs had a redundant '= 0.90m' default value in AdvancedDashboardContracts.cs, duplicating the single source of truth (AdvancedDashboardCalculations.Co2PerMileLbs). If the emission factor were ever changed there, this default would silently go stale. Made the parameter required (moved before the optional DifficultySection parameter to satisfy C# ordering rules) so callers must always supply it explicitly from the F# constant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Contracts/AdvancedDashboardContracts.cs | 6 +++--- src/BikeTracking.Frontend/package-lock.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs b/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs index 200f040..9978afe 100644 --- a/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs +++ b/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs @@ -11,14 +11,14 @@ public sealed record AdvancedDashboardResponse( AdvancedDashboardReminders Reminders, /// UTC timestamp when the response was generated (useful for caching/staleness checks). DateTime GeneratedAtUtc, - /// Difficulty analytics section. Null when no qualifying ride data exists. - AdvancedDashboardDifficultySection? DifficultySection = null, /// /// Fixed CO2-saved-per-mile figure in pounds (0.90 lb/mile — EPA average passenger-vehicle /// emission factor). This is a response-level constant, not per-window, and does not vary /// by user MPG/vehicle settings (FR-007). /// - decimal Co2SavedPerMileLbs = 0.90m + decimal Co2SavedPerMileLbs, + /// Difficulty analytics section. Null when no qualifying ride data exists. + AdvancedDashboardDifficultySection? DifficultySection = null ); /// diff --git a/src/BikeTracking.Frontend/package-lock.json b/src/BikeTracking.Frontend/package-lock.json index adc8923..491f3f8 100644 --- a/src/BikeTracking.Frontend/package-lock.json +++ b/src/BikeTracking.Frontend/package-lock.json @@ -4097,7 +4097,7 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -5242,7 +5242,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { From 3b7ae27599aaad76fe9f751066bd8b22280d32b4 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Thu, 27 Aug 2026 18:29:04 +0000 Subject: [PATCH 5/5] update aspire --- src/BikeTracking.AppHost/BikeTracking.AppHost.csproj | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj b/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj index 62e9257..ce9bb2e 100644 --- a/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj +++ b/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe net10.0 @@ -12,9 +12,8 @@ - - - + +
Mileage Rate Expenses Oil Change SavingsCO2 Saved Net Savings