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/030-gas-price-grade-cache/plan.md](../specs/030-gas-price-grade-cache/plan.md)
[specs/031-ride-time-stats/plan.md](../specs/031-ride-time-stats/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/030-gas-price-grade-cache"
"feature_directory": "specs/031-ride-time-stats"
}
35 changes: 35 additions & 0 deletions specs/031-ride-time-stats/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Riding Time Totals

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-10-06
**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`
- No [NEEDS CLARIFICATION] markers were needed; ambiguous points (scope of "advanced dashboard overall", time display format, handling of rides without duration) were resolved with reasonable defaults documented in the Assumptions section, consistent with existing dashboard patterns (012-dashboard-stats, 018-advanced-dashboard, 026-year-stats-dashboard).
103 changes: 103 additions & 0 deletions specs/031-ride-time-stats/contracts/riding-time-totals-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Contract: Riding Time Totals

This feature extends three existing authenticated dashboard endpoints with one additive, non-nullable integer field each. No new endpoint, query parameter, or request body is introduced. Existing fields are unchanged.

All values are **total minutes** (`int`, `>= 0`), summed over the rider's rides that have a recorded `rideMinutes`. Rides without a duration are excluded. The value is `0`, never `null`, when nothing qualifies. Clients format it for display (see [data-model.md](../data-model.md#display-projection-frontend-only)).

---

## 1. `GET /api/dashboard` — main dashboard

**Field added**: `totals.totalRideMinutes` (all-time scope)

```json
{
"totals": {
"currentMonthMiles": { "miles": 42.5, "rideCount": 5, "period": "thisMonth" },
"yearToDateMiles": { "miles": 812.0, "rideCount": 96, "period": "thisYear" },
"allTimeMiles": { "miles": 3120.4, "rideCount": 380, "period": "allTime" },
"moneySaved": { "...": "unchanged" },
"expenseSummary": { "...": "unchanged" },
"totalRideMinutes": 2535
},
"missingData": {
"ridesMissingSavingsSnapshot": 0,
"ridesMissingGasPrice": 0,
"ridesMissingTemperature": 3,
"ridesMissingDuration": 12
}
}
```

- `missingData.ridesMissingDuration` (existing) tells the client that `totalRideMinutes` excludes some rides.
- No rides → `totalRideMinutes: 0`.

---

## 2. `GET /api/dashboard/advanced` — advanced dashboard

**Field added**: top-level `totalRideMinutes` (all-time scope only; **not** added to any `savingsWindows.*` entry)

```json
{
"savingsWindows": {
"weekly": { "...": "unchanged" },
"monthly": { "...": "unchanged" },
"yearly": { "...": "unchanged" },
"allTime": { "...": "unchanged" }
},
"suggestions": [ "...unchanged" ],
"reminders": { "...": "unchanged" },
"generatedAtUtc": "2026-10-06T14:00:00Z",
"co2SavedPerMileLbs": 0.90,
"totalRideMinutes": 2535,
"difficultySection": { "...": "unchanged" }
}
```

- Must equal `/api/dashboard` → `totals.totalRideMinutes` for the same rider at the same moment (SC-002).
- C# positional record: the parameter goes after `Co2SavedPerMileLbs` and before the defaulted `DifficultySection`.

---

## 3. `GET /api/dashboard/year-stats?year={yyyy}` — year stats

**Field added**: `totals.totalRideMinutes` (scope: rides with `rideDateTimeLocal` in `[yyyy-01-01, yyyy+1-01-01)`)

```json
{
"year": 2025,
"hasDataForYear": true,
"totals": {
"totalMiles": 1450.2,
"totalCombinedSavings": 312.55,
"expenseSummary": { "...": "unchanged" },
"totalRideMinutes": 6015
},
"mileageByMonth": [ "...unchanged" ],
"savingsByMonth": [ "...unchanged" ],
"difficulty": { "...": "unchanged" },
"windResistance": { "...": "unchanged" }
}
```

| Case | `hasDataForYear` | `totals.totalRideMinutes` | UI |
|------|------------------|---------------------------|----|
| Year with rides, some with duration | `true` | sum of those durations | `"100h 15m"` |
| Year with rides, none with duration | `true` | `0` | `"0h 0m"` (Edge Case 4) |
| Year with no rides | `false` | `0` | existing "No ride data for {year}." (US3 AC2) |

---

## Compatibility

- All changes are additive JSON properties. Older clients that ignore unknown properties are unaffected.
- No auth, route, status-code, or error-shape changes. Unauthenticated behavior stays exactly as it is today for all three endpoints.

## UI Contract

| Surface | Element | Accessible name / text | Value |
|---------|---------|------------------------|-------|
| Main dashboard (`/dashboard`) | `DashboardSummaryCard` in "Dashboard summary cards" region | title "Riding Time", eyebrow "Duration" | `formatHoursMinutes(totals.totalRideMinutes)`; detail = "1 ride missing duration" / "{n} rides missing duration" when `missingData.ridesMissingDuration > 0`, else all-time ride count |
| Advanced dashboard (`/dashboard/advanced`) | line in "Savings breakdown by time window" section | "All-time riding time" | `formatHoursMinutes(totalRideMinutes)` |
| Year stats (`/dashboard/year-stats`) | `<dt>`/`<dd>` in `year-stats-summary` | `dt` "Total riding time" | `formatHoursMinutes(totals.totalRideMinutes)`; rendered only when `hasDataForYear` |
67 changes: 67 additions & 0 deletions specs/031-ride-time-stats/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Data Model: Riding Time Totals

**Feature**: `031-ride-time-stats` | **Date**: 2026-10-06

This feature adds **no persisted data**. It reads one existing column and adds a derived read-only value to three API response records.

## Source Entity (existing, unchanged)

### `RideEntity` (`src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs`)

| Field | Type | Notes |
|-------|------|-------|
| `RiderId` | `long` | Scopes all sums to the authenticated rider (FR-007) |
| `RideDateTimeLocal` | `DateTime` | Year scope: `>= Jan 1 {year}` and `< Jan 1 {year+1}` (existing year-stats filter) |
| `RideMinutes` | `int?` | Recorded duration. DB check `CK_Rides_RideMinutes_GreaterThanZero` guarantees `NULL` or `> 0` |

No migration. No new index. The existing per-rider ride queries already load these rows.

## Derived Value

### `TotalRideMinutes` (`int`, non-nullable)

```text
TotalRideMinutes(scope) = Σ ride.RideMinutes for ride in scope where ride.RideMinutes is not null
= 0 when no ride in scope has a duration
```

| Rule | Source |
|------|--------|
| Rides with `RideMinutes == null` are excluded, not counted as 0 | FR-004 |
| Only the authenticated rider's rides | FR-007 (existing `RiderId` filters) |
| Never stored or snapshotted; recomputed per request | Spec Assumption 2 |
| Always ≥ 0; never null | research.md Decision 2 |

Computed by `RidingTimeRules.SumRecordedRideMinutes(IEnumerable<RideEntity>)` (new, `Application/Dashboard`).

## Response Records (additive changes)

| Record | File | New field | Scope |
|--------|------|-----------|-------|
| `DashboardTotals` | `Contracts/DashboardContracts.cs` | `int TotalRideMinutes` | All rides for the rider |
| `AdvancedDashboardResponse` | `Contracts/AdvancedDashboardContracts.cs` | `int TotalRideMinutes` (before `DifficultySection`) | All rides for the rider |
| `YearStatsTotals` | `Contracts/DashboardContracts.cs` | `int TotalRideMinutes` | Rides in the requested calendar year |

Matching TypeScript interfaces gain `totalRideMinutes: number`:

| Interface | File |
|-----------|------|
| `DashboardTotals` | `src/BikeTracking.Frontend/src/services/dashboard-api.ts` |
| `YearStatsTotals` | `src/BikeTracking.Frontend/src/services/dashboard-api.ts` |
| `AdvancedDashboardResponse` | `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` |

## Display Projection (frontend only)

`formatHoursMinutes(totalMinutes)` → `"{floor(total/60) with en-US grouping}h {total mod 60}m"`

| Input | Output |
|-------|--------|
| `0` | `0h 0m` |
| `45` | `0h 45m` |
| `120` | `2h 0m` |
| `2535` | `42h 15m` |
| `62550` | `1,042h 30m` |

## State Transitions

None. The value is read-only and derived. It changes only as a side effect of existing ride record, edit, delete, and import flows, and it is picked up on the next dashboard request.
112 changes: 112 additions & 0 deletions specs/031-ride-time-stats/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Implementation Plan: Riding Time Totals

**Branch**: `031-ride-time-stats` | **Date**: 2026-10-06 | **Spec**: [spec.md](spec.md)

**Input**: Feature specification from `specs/031-ride-time-stats/spec.md`

**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.

## Summary

Surface a total riding time (sum of each ride's existing `RideEntity.RideMinutes`, rides with `null` duration excluded) on three existing read-only dashboard surfaces: the main dashboard (all-time), the advanced dashboard (all-time only), and the year stats dashboard (selected calendar year). The API returns an integer **total minutes** value on each response (`DashboardTotals.TotalRideMinutes`, `AdvancedDashboardResponse.TotalRideMinutes`, `YearStatsTotals.TotalRideMinutes`), always non-null (`0` when nothing qualifies). The sum lives in one small shared C# helper, `RidingTimeRules`, next to the existing `SavingsCalculationRules`, so all three services use the same calculation. The frontend formats minutes as whole hours and minutes (`"42h 15m"`, `"1,042h 30m"`) with one shared helper, `formatHoursMinutes`, in `src/utils/ridingTime.ts`. It adds a "Riding Time" summary card on the main dashboard, an "All-time riding time" line above the advanced dashboard's Savings Breakdown table, and a "Total riding time" item in the year stats text summary. No schema change, no migration, no new endpoint.

## Technical Context

**Language/Version**: .NET 10 (C# backend), F# 9 (domain — not touched; see research.md Decision 3), TypeScript 5.x + React 19 (frontend)

**Primary Dependencies**: ASP.NET Core Minimal API, EF Core (SQLite), xUnit, Vitest + React Testing Library, Playwright

**Storage**: SQLite via EF Core — reads the existing nullable `Rides.RideMinutes` column (`CK_Rides_RideMinutes_GreaterThanZero` already guarantees `NULL` or `> 0`); no new tables, columns, or migrations

**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**: No new SLA. Each service already loads the rider's rides into memory; adding one more O(n) in-memory sum over that list adds no measurable latency and no extra queries.

**Constraints**:
- Only rides with a non-null `RideMinutes` contribute to the sum (FR-004); this matches how `CalculateAverageRideMinutes` already filters.
- Scope matches each surface's existing ride set: all rides for the main and advanced dashboards, the selected calendar year's rides for year stats (FR-001–FR-003). Each service already filters by `RiderId` (FR-007).
- The value is computed on every request and never stored or snapshotted, because duration is a recorded fact on each ride (spec Assumption 2).
- Display is always whole `Xh Ym`, never decimal hours (FR-006). The hours part uses thousands separators and has no upper limit (Edge Case 2).
- Zero states: main and advanced dashboards show `0h 0m`. On year stats, a year with no rides keeps the existing "No ride data for {year}." empty state, and a year that has rides but no durations shows `0h 0m` (FR-005, Edge Cases 1 & 4).
- Advanced dashboard: only the all-time figure is in scope, not one per window (spec Assumption 1).

**Scale/Scope**: 3 contract records (+1 field each), 3 dashboard services, 1 new internal static helper (`RidingTimeRules`), 2 TS service type files, 3 page components, 1 new TS util, matching unit/service tests, and extensions to 3 existing E2E specs.

## Constitution Check

*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*

| Directive | Status | Notes |
|-----------|--------|-------|
| DevContainer environment | ✅ PASS | No tooling or environment change |
| Trunk-based delivery + PR flow | ✅ PASS | Feature branch `031-ride-time-stats`; PR links its GitHub issue (T031) |
| TDD mandatory | ✅ PASS | Failing tests come first for: `RidingTimeRules` (nulls excluded, empty list → 0), the three services' new totals (scope, rider isolation, year boundaries), `formatHoursMinutes` (0, <60, exact hours, ≥1,000h), and each page rendering the stat |
| E2E required on every PR | ✅ PASS | Extend `dashboard.spec.ts`, `savings-calculation.spec.ts` (advanced dashboard), and `year-stats-dashboard.spec.ts` to record rides with durations and assert the formatted totals |
| Ports/adapters boundaries | ✅ PASS | The calculation stays in the `Application/Dashboard` service layer, like the mileage sums and `SavingsCalculationRules`. Contracts change only by additive fields. No new adapters. |
| Result-style domain outcomes | ✅ PASS | The calculation is total (cannot fail) and returns a plain `int`. No new exceptions or error paths. |
| Transactional relational write model | ✅ PASS | Read-only feature; nothing is written. Audit logs are unaffected. |
| Local-first runtime posture | ✅ PASS | No new external calls or infrastructure |

**Post-design re-check (Phase 1)**: ✅ All gates remain green. The design adds one internal static helper, three additive non-nullable `int` contract fields, one frontend util, and UI elements on existing pages. There are no new projects, dependencies, endpoints, persistence changes, or layers. All contract changes are additive, so existing clients that ignore unknown JSON fields are unaffected.

## Project Structure

### Documentation (this feature)

```text
specs/031-ride-time-stats/
├── 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/
│ └── riding-time-totals-contract.md
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```

### Source Code (repository root)

```text
src/BikeTracking.Api/
├── Application/Dashboard/RidingTimeRules.cs # NEW: internal static SumRecordedRideMinutes(IEnumerable<RideEntity>) → int
├── Application/Dashboard/GetDashboardService.cs # populate DashboardTotals.TotalRideMinutes from all rides
├── Application/Dashboard/GetAdvancedDashboardService.cs # populate AdvancedDashboardResponse.TotalRideMinutes from all rides
├── Application/Dashboard/GetYearStatsDashboardService.cs # populate YearStatsTotals.TotalRideMinutes from the year's rides (BuildTotalsSection)
├── Contracts/DashboardContracts.cs # DashboardTotals + YearStatsTotals: add int TotalRideMinutes
└── Contracts/AdvancedDashboardContracts.cs # AdvancedDashboardResponse: add int TotalRideMinutes (before optional DifficultySection)

src/BikeTracking.Api.Tests/Application/Dashboard/
├── RidingTimeRulesTests.cs # NEW: null exclusion, empty input, plain sum
├── GetDashboardServiceTests.cs # extend: all-time total, other-rider isolation, no rides → 0
├── GetAdvancedDashboardServiceTests.cs # extend: all-time total, no rides → 0
└── GetYearStatsDashboardServiceTests.cs # extend: year-scoped total (Dec 31 / Jan 1 boundary), rides-without-duration year → 0

src/BikeTracking.Frontend/src/
├── utils/ridingTime.ts # NEW: formatHoursMinutes(totalMinutes) → "1,042h 30m"
├── utils/ridingTime.test.ts # NEW
├── services/dashboard-api.ts # DashboardTotals + YearStatsTotals: totalRideMinutes: number
├── services/advanced-dashboard-api.ts # AdvancedDashboardResponse: totalRideMinutes: number
├── pages/dashboard/dashboard-page.tsx # new "Riding Time" DashboardSummaryCard (+ empty default totalRideMinutes: 0)
├── pages/dashboard/dashboard-page.test.tsx # extend
├── pages/advanced-dashboard/advanced-dashboard-page.tsx # "All-time riding time" line in the Savings Breakdown section
├── pages/advanced-dashboard/advanced-dashboard-page.test.tsx # extend
├── pages/year-stats-dashboard/year-stats-dashboard-page.tsx # "Total riding time" <dt>/<dd> in year-stats-summary-totals
└── pages/year-stats-dashboard/year-stats-dashboard-page.test.tsx # extend

src/BikeTracking.Frontend/tests/e2e/
├── dashboard.spec.ts # extend: riding time card shows summed durations
├── savings-calculation.spec.ts # extend: advanced dashboard all-time riding time
└── year-stats-dashboard.spec.ts # extend: year-scoped total, updates on year change
```

**Structure Decision**: Use the existing web-app layout and extend the three dashboard vertical slices in place (C# service → contract → TS type → React page). The only new production types are a small `RidingTimeRules` helper in C#, following the `SavingsCalculationRules` pattern so the three services don't each re-implement the null-excluding sum, and a `formatHoursMinutes` util in TypeScript placed in `src/utils/` because three pages share it. The F# domain project is unchanged. The calculation is an aggregation over persisted ride rows, like the existing mileage totals the services already compute in C#. It is not a domain rule with constants or policy, which is the kind of logic that went to F# for CO2 (see research.md Decision 3).

## Complexity Tracking

> **Fill ONLY if Constitution Check has violations that must be justified**

No violations — table intentionally left empty.
Loading
Loading