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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,5 +137,5 @@ From `src/BikeTracking.Frontend`:
<!-- SPECKIT START -->
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)
<!-- SPECKIT END -->
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/028-csv-data-export"
"feature_directory": "specs/029-co2-savings-dashboard"
}
35 changes: 35 additions & 0 deletions specs/029-co2-savings-dashboard/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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.<period>.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.<period>.co2Saved` MUST equal `co2SavedPerMileLbs * savingsWindows.<period>.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.
53 changes: 53 additions & 0 deletions specs/029-co2-savings-dashboard/data-model.md
Original file line number Diff line number Diff line change
@@ -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.
96 changes: 96 additions & 0 deletions specs/029-co2-savings-dashboard/plan.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions specs/029-co2-savings-dashboard/quickstart.md
Original file line number Diff line number Diff line change
@@ -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)
Loading