From abc2b0098dd2f6c2f292fe06efd238fcbd821aa6 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Tue, 6 Oct 2026 11:41:23 -0500 Subject: [PATCH 1/4] [Spec Kit] Add specification for riding time totals Adds spec.md and requirements checklist for feature 031: show total recorded riding time (hours and minutes) on the main dashboard, the advanced dashboard's overall summary, and the yearly stats dashboard. Co-Authored-By: Claude Sonnet 5 --- .specify/feature.json | 2 +- .../checklists/requirements.md | 35 +++++++ specs/031-ride-time-stats/spec.md | 97 +++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 specs/031-ride-time-stats/checklists/requirements.md create mode 100644 specs/031-ride-time-stats/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index 6829c62..43d6f25 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/030-gas-price-grade-cache" + "feature_directory": "specs/031-ride-time-stats" } diff --git a/specs/031-ride-time-stats/checklists/requirements.md b/specs/031-ride-time-stats/checklists/requirements.md new file mode 100644 index 0000000..aee46c2 --- /dev/null +++ b/specs/031-ride-time-stats/checklists/requirements.md @@ -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). diff --git a/specs/031-ride-time-stats/spec.md b/specs/031-ride-time-stats/spec.md new file mode 100644 index 0000000..252a68d --- /dev/null +++ b/specs/031-ride-time-stats/spec.md @@ -0,0 +1,97 @@ +# Feature Specification: Riding Time Totals + +**Feature Branch**: `031-ride-time-stats` + +**Created**: 2026-10-06 + +**Status**: Draft + +**Input**: User description: "As a user, I want to see how many hours and minutes I've recorded riding on the dashboard, advanced dashboard overall and the yearly stats" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See total riding time on the main dashboard (Priority: P1) + +As a rider, I want the main dashboard to show the total amount of time (hours and minutes) I've spent riding across all my recorded rides, so I can see the cumulative time commitment of my commute habit at a glance, alongside the mileage and savings totals I already see there. + +**Why this priority**: The main dashboard is the first thing a rider sees after login and already anchors the other all-time totals (miles, money saved). Riding time is the highest-value, most-visible surface for this feature. + +**Independent Test**: Can be fully tested by signing in as a rider with recorded rides that include duration, and confirming the main dashboard shows a total riding time figure in hours and minutes that matches the sum of the rider's own recorded ride durations. + +**Acceptance Scenarios**: + +1. **Given** an authenticated rider with several rides that each have a recorded duration, **When** they open the main dashboard, **Then** they see a total riding time stat expressed in hours and minutes (e.g., "42h 15m") equal to the sum of those durations. +2. **Given** an authenticated rider with no recorded rides, **When** they open the main dashboard, **Then** the riding time stat shows a zero/placeholder state rather than an error. +3. **Given** an authenticated rider whose rides include some without a recorded duration, **When** they view the total riding time, **Then** the total reflects only the rides that have a recorded duration, and the dashboard's existing missing-data indicator continues to flag that some rides are missing duration. + +--- + +### User Story 2 - See overall riding time on the advanced dashboard (Priority: P2) + +As a rider, I want the advanced dashboard's overall (all-time) summary to also show my total riding time, so the deeper savings/analytics view is as complete a picture of my riding history as the main dashboard. + +**Why this priority**: The advanced dashboard is the secondary, more detailed destination riders visit after the main dashboard. Adding the same total there completes the feature but is less critical than the primary dashboard surface. + +**Independent Test**: Can be fully tested by navigating to the advanced dashboard and confirming the overall (all-time) summary includes a total riding time figure in hours and minutes matching the sum of the rider's recorded ride durations. + +**Acceptance Scenarios**: + +1. **Given** an authenticated rider with recorded rides that include duration, **When** they open the advanced dashboard, **Then** the overall (all-time) summary shows total riding time in hours and minutes. +2. **Given** an authenticated rider with no recorded rides, **When** they open the advanced dashboard, **Then** the overall riding time shows a zero/placeholder state consistent with the dashboard's other zero-state metrics. + +--- + +### User Story 3 - See riding time for a chosen year (Priority: P3) + +As a rider, I want the yearly stats dashboard to show total riding time for whichever year I've selected, so I can compare how much time I spent riding in different years. + +**Why this priority**: This extends the same stat to a year-scoped view already used for other totals (mileage, savings). It depends on the underlying time-total calculation built for User Story 1, so it is lowest priority to implement but still part of the requested feature. + +**Independent Test**: Can be fully tested by opening the yearly stats dashboard, selecting a year with recorded rides, and confirming the displayed total riding time equals the sum of durations for rides within that calendar year only; selecting a different year updates the total accordingly. + +**Acceptance Scenarios**: + +1. **Given** a rider has ride data spanning multiple years, **When** they view the yearly stats dashboard for a specific year, **Then** the total riding time shown reflects only rides dated within that calendar year. +2. **Given** a rider selects a year with no ride data, **When** the yearly stats dashboard loads, **Then** total riding time is displayed using the page's existing "no data for this year" treatment rather than an error or blank value. +3. **Given** a rider switches the selected year, **When** the new year loads, **Then** the total riding time updates to match the newly selected year without navigating away from the page. + +--- + +### Edge Cases + +- What happens when a rider has recorded rides but none of them have a duration entered? The total riding time shows a zero/placeholder state, consistent with how other dashboard metrics behave when their underlying data is missing, rather than an error. +- How does the system handle a very large cumulative total (e.g., over 1,000 hours)? The hours portion grows without an upper bound and remains readable (e.g., "1,042h 30m"). +- How is partial data (some rides with duration, some without) communicated to the rider? The total only sums rides with a recorded duration, and the existing "missing duration" indicator on the main dashboard continues to signal that the total may be incomplete. +- What happens on the yearly stats page for a year with rides but none carrying a duration? The year's total riding time shows as zero for that year, not as "no data," since ride records do exist for that year. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The main dashboard MUST display a total riding time stat, expressed in hours and minutes, summed across all of the rider's own rides that have a recorded duration. +- **FR-002**: The advanced dashboard's overall (all-time) summary MUST display a total riding time stat, expressed in hours and minutes, using the same all-time scope as the dashboard's other overall totals. +- **FR-003**: The yearly stats dashboard MUST display a total riding time stat, expressed in hours and minutes, scoped to whichever calendar year is currently selected, and MUST update when the selected year changes. +- **FR-004**: Total riding time calculations MUST only include rides that have a recorded duration; rides without a recorded duration MUST be excluded from the sum rather than treated as zero-duration. +- **FR-005**: When a rider has no rides, or no rides with a recorded duration, within the relevant scope (all-time or selected year), the total riding time MUST display a zero or "no data" state consistent with that dashboard's existing treatment for other metrics, rather than an error. +- **FR-006**: Total riding time MUST be displayed as whole hours and minutes (e.g., "42h 15m"), not as a decimal number of hours. +- **FR-007**: Total riding time MUST only be calculated from the authenticated rider's own ride data. + +### Key Entities + +- **Ride**: Existing record of a single commute, which already stores an optional recorded duration; this feature sums that existing duration data per display scope (all-time or selected year) rather than introducing new stored data. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A rider can see their total all-time riding time on the main dashboard without navigating to any other page. +- **SC-002**: The total riding time shown on the main dashboard, the advanced dashboard's overall summary, and the yearly stats page for a given year each independently equal the sum of that rider's qualifying ride durations for that scope, verifiable by comparison against ride history. +- **SC-003**: Riders with no duration data see a clear zero/placeholder state for riding time on all three surfaces, with no errors or broken displays. +- **SC-004**: Changing the selected year on the yearly stats dashboard updates the displayed total riding time to match the newly selected year in the same interaction used for the page's other year-scoped stats. + +## Assumptions + +- "Advanced dashboard overall" refers to the advanced dashboard's existing all-time summary, not each of its four time-window breakdowns (weekly/monthly/yearly/all-time); only the all-time figure is in scope for this feature. +- Total riding time is a simple sum of recorded ride durations and does not require a new historical "snapshot" mechanism, since duration is a fixed, already-recorded fact about a ride rather than a value computed from settings that can change over time. +- Hours and minutes format (e.g., "42h 15m") is the desired display format, matching how the feature was described, rather than decimal hours (e.g., "42.25 hours"). +- Rides missing a recorded duration are excluded from the sum rather than counted as zero; this is consistent with how the main dashboard already flags rides missing duration as incomplete data. From 18d56abad1f25405c72c626a75917652319bdad4 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Tue, 6 Oct 2026 13:50:05 -0500 Subject: [PATCH 2/4] [Spec Kit] Add plan and tasks for riding time totals Adds plan, research, data model, contract, quickstart, and tasks for 031-ride-time-stats, including fixes from /speckit.analyze: singular "1 ride missing duration" wording, a ride-seeding helper for the cross-surface endpoint test, and an issue-linking task (T031). Points copilot-instructions at the new plan. Co-Authored-By: Claude Opus 5.5 --- .github/copilot-instructions.md | 2 +- .../contracts/riding-time-totals-contract.md | 103 ++++++++ specs/031-ride-time-stats/data-model.md | 67 ++++++ specs/031-ride-time-stats/plan.md | 112 +++++++++ specs/031-ride-time-stats/quickstart.md | 64 +++++ specs/031-ride-time-stats/research.md | 53 +++++ specs/031-ride-time-stats/tasks.md | 225 ++++++++++++++++++ 7 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 specs/031-ride-time-stats/contracts/riding-time-totals-contract.md create mode 100644 specs/031-ride-time-stats/data-model.md create mode 100644 specs/031-ride-time-stats/plan.md create mode 100644 specs/031-ride-time-stats/quickstart.md create mode 100644 specs/031-ride-time-stats/research.md create mode 100644 specs/031-ride-time-stats/tasks.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4db9b72..5496e19 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/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) diff --git a/specs/031-ride-time-stats/contracts/riding-time-totals-contract.md b/specs/031-ride-time-stats/contracts/riding-time-totals-contract.md new file mode 100644 index 0000000..0be750a --- /dev/null +++ b/specs/031-ride-time-stats/contracts/riding-time-totals-contract.md @@ -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 "All time" | `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`) | `
`/`
` in `year-stats-summary` | `dt` "Total riding time" | `formatHoursMinutes(totals.totalRideMinutes)`; rendered only when `hasDataForYear` | diff --git a/specs/031-ride-time-stats/data-model.md b/specs/031-ride-time-stats/data-model.md new file mode 100644 index 0000000..d306f79 --- /dev/null +++ b/specs/031-ride-time-stats/data-model.md @@ -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)` (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. diff --git a/specs/031-ride-time-stats/plan.md b/specs/031-ride-time-stats/plan.md new file mode 100644 index 0000000..2f1733f --- /dev/null +++ b/specs/031-ride-time-stats/plan.md @@ -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) → 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"
/
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. diff --git a/specs/031-ride-time-stats/quickstart.md b/specs/031-ride-time-stats/quickstart.md new file mode 100644 index 0000000..2f645d5 --- /dev/null +++ b/specs/031-ride-time-stats/quickstart.md @@ -0,0 +1,64 @@ +# Quickstart: Riding Time Totals — Validation Guide + +**Feature**: `031-ride-time-stats` + +How to prove the feature works end-to-end. For field shapes, see [contracts/riding-time-totals-contract.md](contracts/riding-time-totals-contract.md). For calculation and formatting rules, see [data-model.md](data-model.md). + +## Prerequisites + +- DevContainer up; .NET 10 SDK and Node available. +- Repo root: `BikeTracking.slnx`. +- Frontend deps installed: `cd src/BikeTracking.Frontend && npm ci`. + +## 1. Automated checks + +```bash +# Backend: RidingTimeRules + three dashboard services +dotnet test BikeTracking.slnx --filter "FullyQualifiedName~Application.Dashboard" + +# Full backend suite (no regressions in dashboard endpoint tests) +dotnet test BikeTracking.slnx + +# Frontend unit: formatHoursMinutes + three dashboard pages +cd src/BikeTracking.Frontend +npm run test:unit -- ridingTime dashboard-page advanced-dashboard-page year-stats-dashboard-page + +# E2E (required for PR) +npm run test:e2e -- dashboard.spec.ts savings-calculation.spec.ts year-stats-dashboard.spec.ts +``` + +**Expected**: all green. Per the TDD gate, the new tests must be shown failing before the implementation lands. + +## 2. Manual scenario walk-through + +Start the app (Aspire AppHost, as usual) and sign in as a new rider. Then record these rides: + +| # | Date | Miles | Duration (min) | +|---|------|-------|----------------| +| A | Dec 31 of last year | 10 | 45 | +| B | Jan 1 of this year | 10 | 30 | +| C | Today | 10 | 90 | +| D | Today | 5 | *(leave blank)* | + +Expected totals: all-time = 45 + 30 + 90 = **165 min → `2h 45m`**. This year = 30 + 90 = **120 → `2h 0m`**. Last year = **45 → `0h 45m`**. + +| Step | Where | Expected | Covers | +|------|-------|----------|--------| +| 1 | Main dashboard | "Riding Time" card shows `2h 45m`; detail mentions 1 ride missing duration | US1 AC1, AC3; FR-001, FR-004, FR-006 | +| 2 | Advanced dashboard | "All-time riding time: `2h 45m`" in the Savings Breakdown section | US2 AC1; FR-002 | +| 3 | Year stats, this year | "Total riding time" = `2h 0m` | US3 AC1; FR-003 | +| 4 | Year stats, switch to last year | updates to `0h 45m` without leaving the page | US3 AC3; SC-004 | +| 5 | Year stats, pick a year with no rides (if offered) | existing "No ride data for {year}." message; no riding-time item | US3 AC2; FR-005 | +| 6 | Sign in as a second, brand-new rider | main dashboard `0h 0m`; advanced `0h 0m`; no errors | US1 AC2, US2 AC2; FR-005, FR-007; SC-003 | + +## 3. API spot-check (optional) + +With an authenticated session for the rider above: + +```bash +curl -s "$API/api/dashboard" | jq '.totals.totalRideMinutes' # 165 +curl -s "$API/api/dashboard/advanced" | jq '.totalRideMinutes' # 165 +curl -s "$API/api/dashboard/year-stats?year=$(date +%Y)" | jq '.totals.totalRideMinutes' # 120 +``` + +The three all-time and year values must agree with each other and with the ride history (SC-002). diff --git a/specs/031-ride-time-stats/research.md b/specs/031-ride-time-stats/research.md new file mode 100644 index 0000000..73ab5e2 --- /dev/null +++ b/specs/031-ride-time-stats/research.md @@ -0,0 +1,53 @@ +# Research: Riding Time Totals + +**Feature**: `031-ride-time-stats` | **Date**: 2026-10-06 + +The Technical Context had no `NEEDS CLARIFICATION` items: the stack, storage, and test tooling are all existing and unchanged. The decisions below settle the design choices that the spec left open. + +## Decision 1: API returns integer total minutes; the frontend formats hours and minutes + +- **Decision**: Each response gets a non-nullable `int TotalRideMinutes` (raw minutes). The frontend turns it into `"{h}h {m}m"`. +- **Rationale**: Matches existing conventions. The API returns raw numeric values (`AverageRideMinutes`, `TotalMiles`, `Co2Saved`), and pages own presentation (`formatMiles`, `formatCurrency`, `formatCo2`, `formatRideDuration`). Raw minutes keep the contract unit-stable and easy to assert in API tests, and display wording can change later without an API change. `int` is enough: `RideMinutes` is `int?`, and `int.MaxValue` minutes is about 4,000 years of riding. +- **Alternatives considered**: + - *Separate `Hours` + `Minutes` fields*: rejected. Two fields to keep consistent, and an awkward shape for future reuse (e.g. charts). + - *Pre-formatted string from the API*: rejected. Mixes presentation into the contract, and no other dashboard field does this. + - *Decimal hours*: rejected. FR-006 explicitly forbids decimal-hours display, and converting back to minutes would add rounding error. + - *`long`*: unnecessary given the bounds above, and it diverges from the `int?` source column. + +## Decision 2: Non-nullable `0` instead of `null` for "nothing to sum" + +- **Decision**: `TotalRideMinutes` is `0` when the scope has no rides or no rides with a duration. The UI renders `0h 0m`. +- **Rationale**: A sum over an empty set is naturally zero, and the existing totals (`TotalMiles`, `RideCount`, `Co2Saved`) are non-nullable for the same reason. The 029 CO2 feature made the same choice ("`0` (not `null`) when the window has zero ride miles"). This meets FR-005 and SC-003: no error and no blank value. On year stats, the "no data for this year" case is already handled by `HasDataForYear == false`, which hides the whole summary and shows the existing empty-state message (US3 AC2). A year that has rides but no durations shows `0h 0m`, exactly as Edge Case 4 requires. The main dashboard's existing `MissingData.RidesMissingDuration` count continues to flag an incomplete total (US1 AC3), so nullability isn't needed to signal partial data. +- **Alternatives considered**: + - *`int?` with `null` when no ride has a duration*: rejected. It would make year stats render "—" for a year with rides but no durations, contradicting Edge Case 4. It also adds a null branch on three pages for no user benefit. + +## Decision 3: Keep the calculation in a C# `Application/Dashboard` helper, not the F# domain + +- **Decision**: Add `internal static class RidingTimeRules` with `SumRecordedRideMinutes(IEnumerable rides) => rides.Where(r => r.RideMinutes.HasValue).Sum(r => r.RideMinutes!.Value)`, next to `SavingsCalculationRules.cs`. +- **Rationale**: All three services already compute sibling aggregations (`rides.Sum(r => r.Miles)`, `CalculateAverageRideMinutes`) in C# directly over `RideEntity` lists. A null-excluding sum has no policy, constants, or business rule beyond FR-004. The CO2 feature (029) used F# because it introduced a domain constant (0.90 lb/mile) and an emission rule. A shared helper, rather than three inline `Sum` calls, guarantees the three surfaces agree (SC-002) and gives FR-004 one unit-testable home. It follows the established `SavingsCalculationRules` pattern. +- **Alternatives considered**: + - *Add `calculateTotalRideMinutes` to `AdvancedDashboardCalculations.fs`*: rejected. It requires projecting `RideEntity` into an F# snapshot type at three call sites for a one-line sum. That is more ceremony than value, and inconsistent with how mileage totals are computed. + - *Inline `Sum` in each service*: rejected. It triplicates the FR-004 filtering rule and invites drift. + - *Database-side `SumAsync`*: rejected. Every service already materializes the rider's rides for other sections, so an extra query would add a round-trip for no gain. + +## Decision 4: Advanced dashboard — one response-level all-time value, not per-window + +- **Decision**: Add `TotalRideMinutes` to `AdvancedDashboardResponse` (computed from all rides), placed after `Co2SavedPerMileLbs` and before the optional `DifficultySection` parameter. Render it as an "All-time riding time: 42h 15m" line inside the existing Savings Breakdown section, next to the CO2-per-mile line that `SavingsWindowsTable` already shows above the table. +- **Rationale**: Spec Assumption 1 limits scope to the all-time figure. The advanced dashboard has no separate "overall summary" panel; its only all-time view is the `allTime` row of the savings windows table. Adding a column to `AdvancedSavingsWindow` would either compute weekly, monthly, and yearly values that the spec excludes, or leave three cells blank. A single response-level field keeps the contract honest about scope. The record parameter must come before `DifficultySection`, which has a default value, so the positional record still compiles. +- **Alternatives considered**: + - *Add `TotalRideMinutes` to `AdvancedSavingsWindow` and show a column for all four windows*: cheap to build and arguably useful, but out of the spec's stated scope. It is noted as a possible follow-up, not built here. + - *Add it only to `AllTime` as a nullable window field*: rejected. The same record shape would carry different semantics per instance, which is confusing for API consumers. + +## Decision 5: Frontend formatting — shared `formatHoursMinutes` in `src/utils/ridingTime.ts` + +- **Decision**: `formatHoursMinutes(totalMinutes: number): string` returns `` `${Math.floor(totalMinutes / 60).toLocaleString('en-US')}h ${totalMinutes % 60}m` ``. Examples: `0 → "0h 0m"`, `45 → "0h 45m"`, `120 → "2h 0m"`, `62_550 → "1,042h 30m"`. +- **Rationale**: Three pages need identical output (SC-002), so one helper avoids drift. `src/utils/` already holds cross-page helpers (`windResistance.ts`). The existing `formatRideDuration` in `pages/miles/history-page.helpers.ts` formats a single ride as `"45 min"` and returns `""` for missing values. Those semantics don't fit here, so it stays unchanged. The `en-US` locale matches the explicit `en-US` currency formatting used across the dashboards and gives the `"1,042h"` grouping from Edge Case 2. Always showing the `Xh Ym` form, even `0h 45m` or `2h 0m`, keeps the format predictable across all three surfaces. +- **Alternatives considered**: + - *Drop zero parts (`"45m"`, `"2h"`)*: rejected. The output would be less uniform, and tests and E2E assertions would need more cases. + - *Reuse or extend `formatRideDuration`*: rejected. Different semantics (single ride, minutes only, empty string for missing), and it lives in a page-scoped helper file. + +## Decision 6: Placement on each page + +- **Main dashboard**: a new `DashboardSummaryCard` titled "Riding Time" (eyebrow "All time") in `dashboard-summary-grid`, placed after the "All Time" mileage card. The value is `formatHoursMinutes(totals.totalRideMinutes)`. The detail is `"{n} rides missing duration"` when `missingData.ridesMissingDuration > 0`, and otherwise the all-time ride count. This puts the incomplete-data signal (US1 AC3) next to the number. The empty-state default object in `dashboard-page.tsx` gets `totalRideMinutes: 0`. Rationale: US1 asks for the stat "alongside the mileage and savings totals", which are the summary cards. The averages row holds per-ride averages, not totals. +- **Advanced dashboard**: see Decision 4. +- **Year stats**: a new `
Total riding time
…
` after "Total miles" in `year-stats-summary-totals`. It is inside the `hasDataForYear` branch, so it gets the existing empty-state treatment for free (US3 AC2), and it updates with the page's existing year-change fetch (US3 AC3, SC-004). diff --git a/specs/031-ride-time-stats/tasks.md b/specs/031-ride-time-stats/tasks.md new file mode 100644 index 0000000..8b48ebc --- /dev/null +++ b/specs/031-ride-time-stats/tasks.md @@ -0,0 +1,225 @@ +--- + +description: "Task list for Riding Time Totals" +--- + +# Tasks: Riding Time Totals + +**Input**: Design documents from `/specs/031-ride-time-stats/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/riding-time-totals-contract.md, quickstart.md + +**Tests**: This repo's constitution mandates TDD ("TDD mandatory" — see plan.md Constitution Check) and E2E on every PR. All test tasks below are REQUIRED, MUST be written first, and MUST FAIL before their corresponding implementation tasks. + +**Organization**: Tasks are grouped by user story (US1 = main dashboard, P1; US2 = advanced dashboard overall, P2; US3 = yearly stats, P3) so each story can be implemented and tested on its own. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +Web application layout per plan.md: +- Backend: `src/BikeTracking.Api/`, tests in `src/BikeTracking.Api.Tests/` +- Frontend: `src/BikeTracking.Frontend/src/`, e2e tests in `src/BikeTracking.Frontend/tests/e2e/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: No scaffolding needed. The feature extends existing dashboard slices in place, with no schema change and no new dependencies. This phase only confirms a clean baseline. + +- [ ] T001 Run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit` to confirm a clean baseline before starting (no code changes) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The shared calculation (`RidingTimeRules`) and shared display formatter (`formatHoursMinutes`) that all three stories use, so the three surfaces cannot disagree (SC-002, research.md Decisions 3 & 5). + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +### Tests for Foundational (write first, MUST FAIL) ⚠️ + +- [ ] T002 [P] Create `src/BikeTracking.Api.Tests/Application/Dashboard/RidingTimeRulesTests.cs` with xUnit tests for `RidingTimeRules.SumRecordedRideMinutes`: (a) empty sequence → `0`; (b) all rides `RideMinutes = null` → `0`; (c) mixed `[45, null, 30, 90]` → `165` (nulls excluded, FR-004); (d) large total, e.g. 1,000 rides × 63 min → `63000` (no overflow, Edge Case 2). Build `RideEntity` instances directly, following the existing `GetDashboardServiceTests.cs` style +- [ ] T003 [P] Create `src/BikeTracking.Frontend/src/utils/ridingTime.test.ts` with Vitest cases for `formatHoursMinutes` per data-model.md Display Projection: `0 → "0h 0m"`, `45 → "0h 45m"`, `120 → "2h 0m"`, `2535 → "42h 15m"`, `62550 → "1,042h 30m"` (FR-006, Edge Case 2) + +### Implementation for Foundational + +- [ ] T004 [P] Create `internal static class RidingTimeRules` in `src/BikeTracking.Api/Application/Dashboard/RidingTimeRules.cs` (namespace `BikeTracking.Api.Application.Dashboard`, same style as `SavingsCalculationRules.cs`) with `public static int SumRecordedRideMinutes(IEnumerable rides)` returning `rides.Where(r => r.RideMinutes.HasValue).Sum(r => r.RideMinutes!.Value)`. Include a short `` noting that rides without a duration are excluded (FR-004). Makes T002 pass. Depends on T002 being red +- [ ] T005 [P] Create `src/BikeTracking.Frontend/src/utils/ridingTime.ts` exporting `formatHoursMinutes(totalMinutes: number): string` that returns `` `${Math.floor(totalMinutes / 60).toLocaleString('en-US')}h ${totalMinutes % 60}m` ``, with a JSDoc comment. Makes T003 pass. Depends on T003 being red + +**Checkpoint**: The shared calculation and formatter exist and are tested. User story work can now begin, in parallel if desired. + +--- + +## Phase 3: User Story 1 - See total riding time on the main dashboard (Priority: P1) 🎯 MVP + +**Goal**: The main dashboard shows a "Riding Time" summary card with the rider's all-time total (`Xh Ym`), and its detail line flags rides that are missing a duration. + +**Independent Test**: Sign in as a rider with rides that have durations (and one without), open `/dashboard`, and confirm the "Riding Time" card equals the sum of the recorded durations in `Xh Ym`, with a note about the ride missing a duration. A rider with no rides sees `0h 0m`. + +### Tests for User Story 1 (write first, MUST FAIL) ⚠️ + +- [ ] T006 [P] [US1] Extend `src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs`: (a) rides with `RideMinutes` `[45, 30, 90, null]` → `response.Totals.TotalRideMinutes == 165` and `MissingData.RidesMissingDuration == 1` (US1 AC1, AC3); (b) no rides → `TotalRideMinutes == 0` (US1 AC2); (c) another rider's rides are excluded (FR-007) +- [ ] T007 [P] [US1] Extend `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx`: (a) a mocked response with `totals.totalRideMinutes: 2535` renders a "Riding Time" card showing `42h 15m`; (b) `missingData.ridesMissingDuration: 2` makes the card detail read `2 rides missing duration`, and `1` makes it read `1 ride missing duration`; (c) the empty/default state renders `0h 0m`. Add `totalRideMinutes` to every existing `DashboardTotals` fixture in this file +- [ ] T008 [P] [US1] Extend `src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts`: record rides with durations 45 and 90 min plus one with no duration, open the dashboard, and assert the "Riding Time" card shows `2h 15m` and its detail reads `1 ride missing duration` + +### Implementation for User Story 1 + +- [ ] T009 [US1] Add `int TotalRideMinutes` as the last positional parameter of `DashboardTotals` in `src/BikeTracking.Api/Contracts/DashboardContracts.cs` (per contract §1) +- [ ] T010 [US1] In `src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs`, pass `TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides)` (all rides) to the `DashboardTotals` constructor. Makes T006 pass. Depends on T004, T009 +- [ ] T011 [P] [US1] Add `totalRideMinutes: number;` to the `DashboardTotals` interface in `src/BikeTracking.Frontend/src/services/dashboard-api.ts`, and add `totalRideMinutes` to any `DashboardTotals` fixtures in `src/BikeTracking.Frontend/src/services/dashboard-api.test.ts` so it type-checks +- [ ] T012 [US1] In `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx`: add `totalRideMinutes: 0` to the empty-state default `totals` object, and add a `DashboardSummaryCard` (title "Riding Time", eyebrow "All time", value `formatHoursMinutes(dashboard.totals.totalRideMinutes)`) right after the "All Time" mileage card. Detail is `formatMissingDuration(dashboard.missingData.ridesMissingDuration)` when the count is `> 0`, otherwise `formatRideCount(dashboard.totals.allTimeMiles.rideCount)` (research.md Decision 6). Add a local `formatMissingDuration(count: number): string` helper next to `formatRideCount`, in the same style (`` count === 1 ? '1 ride missing duration' : `${count} rides missing duration` ``). Add an accent class in `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.css` following the existing `dashboard-summary-card-accent-*` pattern. Makes T007 and T008 pass. Depends on T005, T011 + +**Checkpoint**: User Story 1 is fully functional and testable on its own (MVP). + +--- + +## Phase 4: User Story 2 - See overall riding time on the advanced dashboard (Priority: P2) + +**Goal**: The advanced dashboard shows a single "All-time riding time" figure in its Savings Breakdown section. It is all-time only, not added to each time window (spec Assumption 1, research.md Decision 4). + +**Independent Test**: Open `/dashboard/advanced` as a rider with recorded durations and confirm "All-time riding time" shows the summed total in `Xh Ym`. A rider with no rides sees `0h 0m`. + +### Tests for User Story 2 (write first, MUST FAIL) ⚠️ + +- [ ] T013 [P] [US2] Extend `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs`: (a) rides with `RideMinutes` `[45, null, 90]` spread across this week and a prior year → `response.TotalRideMinutes == 135` (all-time scope, US2 AC1); (b) no rides → `TotalRideMinutes == 0` (US2 AC2) +- [ ] T014 [P] [US2] Extend `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.test.tsx`: (a) a mocked response with `totalRideMinutes: 2535` renders `All-time riding time` with `42h 15m` inside the "Savings breakdown by time window" region; (b) `totalRideMinutes: 0` renders `0h 0m`. Add `totalRideMinutes` to the shared response fixture builder in this file +- [ ] T015 [P] [US2] Extend `src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts` (the existing advanced-dashboard E2E): after recording rides with durations, open the advanced dashboard and assert the "All-time riding time" value equals the formatted sum + +### Implementation for User Story 2 + +- [ ] T016 [US2] Add `int TotalRideMinutes` to `AdvancedDashboardResponse` in `src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs`, placed after `Co2SavedPerMileLbs` and before the defaulted `DifficultySection` parameter. Add a `` doc comment: all-time sum of recorded ride minutes; rides without duration excluded; `0` when none (contract §2) +- [ ] T017 [US2] In `src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs`, pass `TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides)` (all rides) to the `AdvancedDashboardResponse` constructor. Makes T013 pass. Depends on T004, T016 +- [ ] T018 [P] [US2] Add `totalRideMinutes: number;` (with a JSDoc comment) to the `AdvancedDashboardResponse` interface in `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` +- [ ] T019 [US2] In `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx`, inside the "Savings breakdown by time window" section between the `

` and ``, render `

All-time riding time: {formatHoursMinutes(data.totalRideMinutes)}

`. Add a matching style to `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.css`. Makes T014 and T015 pass. Depends on T005, T018 + +**Checkpoint**: User Stories 1 and 2 both work on their own. + +--- + +## Phase 5: User Story 3 - See riding time for a chosen year (Priority: P3) + +**Goal**: The year stats summary shows "Total riding time" for the selected calendar year. It updates when the year changes, and the existing "no data" treatment covers years without rides. + +**Independent Test**: Open `/dashboard/year-stats`, pick a year with rides, and confirm "Total riding time" equals that year's summed durations. Switch years and confirm it updates without leaving the page. + +### Tests for User Story 3 (write first, MUST FAIL) ⚠️ + +- [ ] T020 [P] [US3] Extend `src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs`: (a) rides on Dec 31 of year N-1 (45 min), Jan 1 of year N (30 min), and mid-year N (90 min, plus one `null`) → for year N, `Totals.TotalRideMinutes == 120`; for year N-1, `== 45` (calendar boundary, US3 AC1); (b) a year with rides that all have `RideMinutes = null` → `HasDataForYear == true` and `TotalRideMinutes == 0` (Edge Case 4); (c) a year with no rides → `HasDataForYear == false` and `TotalRideMinutes == 0` +- [ ] T021 [P] [US3] Extend `src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.test.tsx`: (a) `hasDataForYear: true` with `totals.totalRideMinutes: 6015` renders a `Total riding time` term with `100h 15m`; (b) `totalRideMinutes: 0` with `hasDataForYear: true` renders `0h 0m`; (c) `hasDataForYear: false` renders the existing empty-state message and no `Total riding time` term (US3 AC2); (d) changing the selected year re-renders with the new year's value (US3 AC3). Add `totalRideMinutes` to every `YearStatsTotals` fixture in this file +- [ ] T022 [P] [US3] Extend `src/BikeTracking.Frontend/tests/e2e/year-stats-dashboard.spec.ts`: record rides with durations in two different years, assert "Total riding time" for each selected year, and assert it updates after switching years (SC-004) + +### Implementation for User Story 3 + +- [ ] T023 [US3] Add `int TotalRideMinutes` as the last positional parameter of `YearStatsTotals` in `src/BikeTracking.Api/Contracts/DashboardContracts.cs`, and update its `` (contract §3) +- [ ] T024 [US3] In `src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs` `BuildTotalsSection`, pass `TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides)` (the year-filtered rides already passed in) to the `YearStatsTotals` constructor. Makes T020 pass. Depends on T004, T023 +- [ ] T025 [P] [US3] Add `totalRideMinutes: number;` to the `YearStatsTotals` interface in `src/BikeTracking.Frontend/src/services/dashboard-api.ts`, and add it to any `YearStatsTotals` fixtures in `src/BikeTracking.Frontend/src/services/dashboard-api-year-stats.test.ts` +- [ ] T026 [US3] In `src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.tsx`, add `
Total riding time
{formatHoursMinutes(data.totals.totalRideMinutes)}
` right after the "Total miles" item inside `year-stats-summary-totals` (within the existing `hasDataForYear` branch). Makes T021 and T022 pass. Depends on T005, T025 + +**Checkpoint**: All three user stories work on their own. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Cross-story verification and cleanup. + +- [ ] T027 [P] Add `totalRideMinutes` to any typed dashboard / year-stats / advanced-dashboard response fixtures or route mocks in `src/BikeTracking.Frontend/tests/e2e/support/expense-helpers.ts`, `src/BikeTracking.Frontend/tests/e2e/dashboard-expenses.spec.ts`, and `src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts` that construct these shapes, so `tsc` and lint stay clean +- [ ] T028 [P] Add a cross-surface consistency test to `src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs` (SC-002). First add a `SeedRideAsync(long riderId, DateTime rideDateTimeLocal, decimal miles, int? rideMinutes = null)` helper to `DashboardApiHost`, following the existing `SeedRideAsync` in `ImportEndpointsTests.cs` (create a scope, get `BikeTrackingDbContext`, add a `RideEntity` with `RideMinutes = rideMinutes`, `SaveChangesAsync`). Seed one rider with rides of 45 min on Dec 31 of last year, 30 min on Jan 1 of this year, 90 min today, and one with no duration today. Assert `GET /api/dashboard` `totals.totalRideMinutes` == `165`, `GET /api/dashboard/advanced` `totalRideMinutes` == `165`, and `GET /api/dashboard/year-stats?year={this year}` `totals.totalRideMinutes` == `120` (matches quickstart.md) +- [ ] T029 Run the full suites: `dotnet test BikeTracking.slnx`, `cd src/BikeTracking.Frontend && npm run lint && npm run build && npm run test:unit && npm run test:e2e`; fix any regressions +- [ ] T030 Walk through the manual scenario table in `specs/031-ride-time-stats/quickstart.md` (steps 1–6) against the running app and confirm each expected value +- [ ] T031 Create (or find) the GitHub issue for "Riding Time Totals" and reference it in the PR description (`Closes #`), per constitution Directive 2 + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; can start immediately +- **Foundational (Phase 2)**: Depends on Setup. BLOCKS all user stories (every story uses `RidingTimeRules` and `formatHoursMinutes`) +- **User Stories (Phases 3–5)**: All depend on Foundational only. They touch disjoint contract records, services, and pages, so they can proceed in parallel or in priority order (P1 → P2 → P3) +- **Polish (Phase 6)**: Depends on all three stories (T028 exercises all three endpoints) + +### User Story Dependencies + +- **US1 (P1)**: Depends on T004, T005 only +- **US2 (P2)**: Depends on T004, T005 only; independent of US1 +- **US3 (P3)**: Depends on T004, T005 only; independent of US1/US2 + +### Shared-file notes (avoid concurrent edits) + +- `src/BikeTracking.Api/Contracts/DashboardContracts.cs` is edited by both T009 (US1) and T023 (US3), on different records +- `src/BikeTracking.Frontend/src/services/dashboard-api.ts` is edited by both T011 (US1) and T025 (US3), on different interfaces +- If US1 and US3 run in parallel, serialize these two pairs or expect a trivial merge + +### Within Each User Story + +- Tests are written first and MUST FAIL (TDD gate) +- Contract record → service → TS type → page +- Story complete and checkpoint verified before moving to the next priority (when working sequentially) + +### Parallel Opportunities + +- Phase 2: T002 ∥ T003, then T004 ∥ T005 (backend and frontend are independent) +- Within each story, all three test tasks are [P] (different files: service tests, page tests, E2E spec) +- Within each story, the TS type task (T011 / T018 / T025) can run alongside the backend contract and service tasks +- Across stories: US1, US2, and US3 can be staffed in parallel after Phase 2 (see shared-file notes) +- Phase 6: T027 ∥ T028 + +--- + +## Parallel Example: User Story 1 + +```bash +# Write all failing tests for User Story 1 together: +Task: "Extend GetDashboardServiceTests.cs with TotalRideMinutes cases" # T006 +Task: "Extend dashboard-page.test.tsx with Riding Time card cases" # T007 +Task: "Extend tests/e2e/dashboard.spec.ts with riding time assertion" # T008 + +# Then backend and frontend type work in parallel: +Task: "Add TotalRideMinutes to DashboardTotals + populate in GetDashboardService" # T009 → T010 +Task: "Add totalRideMinutes to DashboardTotals in dashboard-api.ts" # T011 +``` + +## Parallel Example: All Stories After Foundational + +```bash +Task: "US1 — T006..T012 (main dashboard)" +Task: "US2 — T013..T019 (advanced dashboard)" +Task: "US3 — T020..T026 (year stats)" # coordinate DashboardContracts.cs / dashboard-api.ts edits with US1 +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001) +2. Complete Phase 2: Foundational (T002–T005) +3. Complete Phase 3: User Story 1 (T006–T012) +4. **STOP and VALIDATE**: quickstart.md steps 1 and 6 (main dashboard parts) +5. Demo or open a PR if ready (E2E for US1 is included, so the PR gate is satisfied) + +### Incremental Delivery + +1. Setup + Foundational → shared rule and formatter ready +2. US1 → validate → PR (MVP) +3. US2 → validate → PR +4. US3 → validate → PR +5. Polish (T027–T031) with the final story + +--- + +## Notes + +- [P] tasks = different files, no dependencies on incomplete tasks +- [Story] label maps each task to a user story for traceability +- No migrations, no new endpoints, no F# changes (research.md Decision 3) +- Contract changes are additive non-nullable `int` fields; `0` means nothing to sum (research.md Decision 2) +- Commit after each task or logical group; show failing-test proof before each implementation task From ae16515d694b66e93db0c7f43da1859ec14e0b61 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Tue, 6 Oct 2026 16:27:39 -0500 Subject: [PATCH 3/4] [Spec Kit] Implement riding time totals on dashboards Show the sum of recorded ride durations on the main dashboard, the advanced dashboard (all-time), and the year stats page, using a shared RidingTimeRules calculation and formatHoursMinutes formatter so the surfaces agree. Rides without a duration are excluded and flagged on the main dashboard card. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../contracts/riding-time-totals-contract.md | 2 +- specs/031-ride-time-stats/research.md | 2 +- specs/031-ride-time-stats/tasks.md | 60 ++++++++-------- .../GetAdvancedDashboardServiceTests.cs | 66 +++++++++++++++++ .../Dashboard/GetDashboardServiceTests.cs | 62 ++++++++++++++++ .../GetYearStatsDashboardServiceTests.cs | 66 +++++++++++++++++ .../Dashboard/RidingTimeRulesTests.cs | 47 ++++++++++++ .../Endpoints/DashboardEndpointsTests.cs | 65 +++++++++++++++++ .../Dashboard/GetAdvancedDashboardService.cs | 3 +- .../Dashboard/GetDashboardService.cs | 3 +- .../Dashboard/GetYearStatsDashboardService.cs | 3 +- .../Application/Dashboard/RidingTimeRules.cs | 14 ++++ .../Contracts/AdvancedDashboardContracts.cs | 4 ++ .../Contracts/DashboardContracts.cs | 12 +++- .../advanced-dashboard-page.css | 5 ++ .../advanced-dashboard-page.test.tsx | 32 ++++++++- .../advanced-dashboard-page.tsx | 4 ++ .../src/pages/dashboard/dashboard-page.css | 4 ++ .../pages/dashboard/dashboard-page.test.tsx | 69 +++++++++++++++++- .../src/pages/dashboard/dashboard-page.tsx | 17 +++++ .../year-stats-dashboard-page.test.tsx | 71 +++++++++++++++++++ .../year-stats-dashboard-page.tsx | 5 ++ .../src/services/advanced-dashboard-api.ts | 5 ++ .../src/services/dashboard-api.ts | 4 ++ .../src/utils/ridingTime.test.ts | 14 ++++ .../src/utils/ridingTime.ts | 7 ++ .../tests/e2e/dashboard.spec.ts | 21 +++++- .../tests/e2e/savings-calculation.spec.ts | 19 +++++ .../tests/e2e/year-stats-dashboard.spec.ts | 35 +++++++++ 29 files changed, 680 insertions(+), 41 deletions(-) create mode 100644 src/BikeTracking.Api.Tests/Application/Dashboard/RidingTimeRulesTests.cs create mode 100644 src/BikeTracking.Api/Application/Dashboard/RidingTimeRules.cs create mode 100644 src/BikeTracking.Frontend/src/utils/ridingTime.test.ts create mode 100644 src/BikeTracking.Frontend/src/utils/ridingTime.ts diff --git a/specs/031-ride-time-stats/contracts/riding-time-totals-contract.md b/specs/031-ride-time-stats/contracts/riding-time-totals-contract.md index 0be750a..ba077bd 100644 --- a/specs/031-ride-time-stats/contracts/riding-time-totals-contract.md +++ b/specs/031-ride-time-stats/contracts/riding-time-totals-contract.md @@ -98,6 +98,6 @@ All values are **total minutes** (`int`, `>= 0`), summed over the rider's rides | Surface | Element | Accessible name / text | Value | |---------|---------|------------------------|-------| -| Main dashboard (`/dashboard`) | `DashboardSummaryCard` in "Dashboard summary cards" region | title "Riding Time", eyebrow "All time" | `formatHoursMinutes(totals.totalRideMinutes)`; detail = "1 ride missing duration" / "{n} rides missing duration" when `missingData.ridesMissingDuration > 0`, else all-time ride count | +| 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`) | `
`/`
` in `year-stats-summary` | `dt` "Total riding time" | `formatHoursMinutes(totals.totalRideMinutes)`; rendered only when `hasDataForYear` | diff --git a/specs/031-ride-time-stats/research.md b/specs/031-ride-time-stats/research.md index 73ab5e2..3a1996b 100644 --- a/specs/031-ride-time-stats/research.md +++ b/specs/031-ride-time-stats/research.md @@ -48,6 +48,6 @@ The Technical Context had no `NEEDS CLARIFICATION` items: the stack, storage, an ## Decision 6: Placement on each page -- **Main dashboard**: a new `DashboardSummaryCard` titled "Riding Time" (eyebrow "All time") in `dashboard-summary-grid`, placed after the "All Time" mileage card. The value is `formatHoursMinutes(totals.totalRideMinutes)`. The detail is `"{n} rides missing duration"` when `missingData.ridesMissingDuration > 0`, and otherwise the all-time ride count. This puts the incomplete-data signal (US1 AC3) next to the number. The empty-state default object in `dashboard-page.tsx` gets `totalRideMinutes: 0`. Rationale: US1 asks for the stat "alongside the mileage and savings totals", which are the summary cards. The averages row holds per-ride averages, not totals. +- **Main dashboard**: a new `DashboardSummaryCard` titled "Riding Time" (eyebrow "Duration", matching the category-style eyebrows of the other cards) in `dashboard-summary-grid`, placed after the "All Time" mileage card. The value is `formatHoursMinutes(totals.totalRideMinutes)`. The detail is `"{n} rides missing duration"` when `missingData.ridesMissingDuration > 0`, and otherwise the all-time ride count. This puts the incomplete-data signal (US1 AC3) next to the number. The empty-state default object in `dashboard-page.tsx` gets `totalRideMinutes: 0`. Rationale: US1 asks for the stat "alongside the mileage and savings totals", which are the summary cards. The averages row holds per-ride averages, not totals. - **Advanced dashboard**: see Decision 4. - **Year stats**: a new `
Total riding time
…
` after "Total miles" in `year-stats-summary-totals`. It is inside the `hasDataForYear` branch, so it gets the existing empty-state treatment for free (US3 AC2), and it updates with the page's existing year-change fetch (US3 AC3, SC-004). diff --git a/specs/031-ride-time-stats/tasks.md b/specs/031-ride-time-stats/tasks.md index 8b48ebc..ff99823 100644 --- a/specs/031-ride-time-stats/tasks.md +++ b/specs/031-ride-time-stats/tasks.md @@ -31,7 +31,7 @@ Web application layout per plan.md: **Purpose**: No scaffolding needed. The feature extends existing dashboard slices in place, with no schema change and no new dependencies. This phase only confirms a clean baseline. -- [ ] T001 Run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit` to confirm a clean baseline before starting (no code changes) +- [X] T001 Run `dotnet test BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run test:unit` to confirm a clean baseline before starting (no code changes) --- @@ -43,13 +43,13 @@ Web application layout per plan.md: ### Tests for Foundational (write first, MUST FAIL) ⚠️ -- [ ] T002 [P] Create `src/BikeTracking.Api.Tests/Application/Dashboard/RidingTimeRulesTests.cs` with xUnit tests for `RidingTimeRules.SumRecordedRideMinutes`: (a) empty sequence → `0`; (b) all rides `RideMinutes = null` → `0`; (c) mixed `[45, null, 30, 90]` → `165` (nulls excluded, FR-004); (d) large total, e.g. 1,000 rides × 63 min → `63000` (no overflow, Edge Case 2). Build `RideEntity` instances directly, following the existing `GetDashboardServiceTests.cs` style -- [ ] T003 [P] Create `src/BikeTracking.Frontend/src/utils/ridingTime.test.ts` with Vitest cases for `formatHoursMinutes` per data-model.md Display Projection: `0 → "0h 0m"`, `45 → "0h 45m"`, `120 → "2h 0m"`, `2535 → "42h 15m"`, `62550 → "1,042h 30m"` (FR-006, Edge Case 2) +- [X] T002 [P] Create `src/BikeTracking.Api.Tests/Application/Dashboard/RidingTimeRulesTests.cs` with xUnit tests for `RidingTimeRules.SumRecordedRideMinutes`: (a) empty sequence → `0`; (b) all rides `RideMinutes = null` → `0`; (c) mixed `[45, null, 30, 90]` → `165` (nulls excluded, FR-004); (d) large total, e.g. 1,000 rides × 63 min → `63000` (no overflow, Edge Case 2). Build `RideEntity` instances directly, following the existing `GetDashboardServiceTests.cs` style +- [X] T003 [P] Create `src/BikeTracking.Frontend/src/utils/ridingTime.test.ts` with Vitest cases for `formatHoursMinutes` per data-model.md Display Projection: `0 → "0h 0m"`, `45 → "0h 45m"`, `120 → "2h 0m"`, `2535 → "42h 15m"`, `62550 → "1,042h 30m"` (FR-006, Edge Case 2) ### Implementation for Foundational -- [ ] T004 [P] Create `internal static class RidingTimeRules` in `src/BikeTracking.Api/Application/Dashboard/RidingTimeRules.cs` (namespace `BikeTracking.Api.Application.Dashboard`, same style as `SavingsCalculationRules.cs`) with `public static int SumRecordedRideMinutes(IEnumerable rides)` returning `rides.Where(r => r.RideMinutes.HasValue).Sum(r => r.RideMinutes!.Value)`. Include a short `` noting that rides without a duration are excluded (FR-004). Makes T002 pass. Depends on T002 being red -- [ ] T005 [P] Create `src/BikeTracking.Frontend/src/utils/ridingTime.ts` exporting `formatHoursMinutes(totalMinutes: number): string` that returns `` `${Math.floor(totalMinutes / 60).toLocaleString('en-US')}h ${totalMinutes % 60}m` ``, with a JSDoc comment. Makes T003 pass. Depends on T003 being red +- [X] T004 [P] Create `internal static class RidingTimeRules` in `src/BikeTracking.Api/Application/Dashboard/RidingTimeRules.cs` (namespace `BikeTracking.Api.Application.Dashboard`, same style as `SavingsCalculationRules.cs`) with `public static int SumRecordedRideMinutes(IEnumerable rides)` returning `rides.Where(r => r.RideMinutes.HasValue).Sum(r => r.RideMinutes!.Value)`. Include a short `` noting that rides without a duration are excluded (FR-004). Makes T002 pass. Depends on T002 being red +- [X] T005 [P] Create `src/BikeTracking.Frontend/src/utils/ridingTime.ts` exporting `formatHoursMinutes(totalMinutes: number): string` that returns `` `${Math.floor(totalMinutes / 60).toLocaleString('en-US')}h ${totalMinutes % 60}m` ``, with a JSDoc comment. Makes T003 pass. Depends on T003 being red **Checkpoint**: The shared calculation and formatter exist and are tested. User story work can now begin, in parallel if desired. @@ -63,16 +63,16 @@ Web application layout per plan.md: ### Tests for User Story 1 (write first, MUST FAIL) ⚠️ -- [ ] T006 [P] [US1] Extend `src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs`: (a) rides with `RideMinutes` `[45, 30, 90, null]` → `response.Totals.TotalRideMinutes == 165` and `MissingData.RidesMissingDuration == 1` (US1 AC1, AC3); (b) no rides → `TotalRideMinutes == 0` (US1 AC2); (c) another rider's rides are excluded (FR-007) -- [ ] T007 [P] [US1] Extend `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx`: (a) a mocked response with `totals.totalRideMinutes: 2535` renders a "Riding Time" card showing `42h 15m`; (b) `missingData.ridesMissingDuration: 2` makes the card detail read `2 rides missing duration`, and `1` makes it read `1 ride missing duration`; (c) the empty/default state renders `0h 0m`. Add `totalRideMinutes` to every existing `DashboardTotals` fixture in this file -- [ ] T008 [P] [US1] Extend `src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts`: record rides with durations 45 and 90 min plus one with no duration, open the dashboard, and assert the "Riding Time" card shows `2h 15m` and its detail reads `1 ride missing duration` +- [X] T006 [P] [US1] Extend `src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs`: (a) rides with `RideMinutes` `[45, 30, 90, null]` → `response.Totals.TotalRideMinutes == 165` and `MissingData.RidesMissingDuration == 1` (US1 AC1, AC3); (b) no rides → `TotalRideMinutes == 0` (US1 AC2); (c) another rider's rides are excluded (FR-007) +- [X] T007 [P] [US1] Extend `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx`: (a) a mocked response with `totals.totalRideMinutes: 2535` renders a "Riding Time" card showing `42h 15m`; (b) `missingData.ridesMissingDuration: 2` makes the card detail read `2 rides missing duration`, and `1` makes it read `1 ride missing duration`; (c) the empty/default state renders `0h 0m`. Add `totalRideMinutes` to every existing `DashboardTotals` fixture in this file +- [X] T008 [P] [US1] Extend `src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts`: record rides with durations 45 and 90 min plus one with no duration, open the dashboard, and assert the "Riding Time" card shows `2h 15m` and its detail reads `1 ride missing duration` ### Implementation for User Story 1 -- [ ] T009 [US1] Add `int TotalRideMinutes` as the last positional parameter of `DashboardTotals` in `src/BikeTracking.Api/Contracts/DashboardContracts.cs` (per contract §1) -- [ ] T010 [US1] In `src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs`, pass `TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides)` (all rides) to the `DashboardTotals` constructor. Makes T006 pass. Depends on T004, T009 -- [ ] T011 [P] [US1] Add `totalRideMinutes: number;` to the `DashboardTotals` interface in `src/BikeTracking.Frontend/src/services/dashboard-api.ts`, and add `totalRideMinutes` to any `DashboardTotals` fixtures in `src/BikeTracking.Frontend/src/services/dashboard-api.test.ts` so it type-checks -- [ ] T012 [US1] In `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx`: add `totalRideMinutes: 0` to the empty-state default `totals` object, and add a `DashboardSummaryCard` (title "Riding Time", eyebrow "All time", value `formatHoursMinutes(dashboard.totals.totalRideMinutes)`) right after the "All Time" mileage card. Detail is `formatMissingDuration(dashboard.missingData.ridesMissingDuration)` when the count is `> 0`, otherwise `formatRideCount(dashboard.totals.allTimeMiles.rideCount)` (research.md Decision 6). Add a local `formatMissingDuration(count: number): string` helper next to `formatRideCount`, in the same style (`` count === 1 ? '1 ride missing duration' : `${count} rides missing duration` ``). Add an accent class in `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.css` following the existing `dashboard-summary-card-accent-*` pattern. Makes T007 and T008 pass. Depends on T005, T011 +- [X] T009 [US1] Add `int TotalRideMinutes` as the last positional parameter of `DashboardTotals` in `src/BikeTracking.Api/Contracts/DashboardContracts.cs` (per contract §1) +- [X] T010 [US1] In `src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs`, pass `TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides)` (all rides) to the `DashboardTotals` constructor. Makes T006 pass. Depends on T004, T009 +- [X] T011 [P] [US1] Add `totalRideMinutes: number;` to the `DashboardTotals` interface in `src/BikeTracking.Frontend/src/services/dashboard-api.ts`, and add `totalRideMinutes` to any `DashboardTotals` fixtures in `src/BikeTracking.Frontend/src/services/dashboard-api.test.ts` so it type-checks +- [X] T012 [US1] In `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx`: add `totalRideMinutes: 0` to the empty-state default `totals` object, and add a `DashboardSummaryCard` (title "Riding Time", eyebrow "Duration", value `formatHoursMinutes(dashboard.totals.totalRideMinutes)`) right after the "All Time" mileage card. Detail is `formatMissingDuration(dashboard.missingData.ridesMissingDuration)` when the count is `> 0`, otherwise `formatRideCount(dashboard.totals.allTimeMiles.rideCount)` (research.md Decision 6). Add a local `formatMissingDuration(count: number): string` helper next to `formatRideCount`, in the same style (`` count === 1 ? '1 ride missing duration' : `${count} rides missing duration` ``). Add an accent class in `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.css` following the existing `dashboard-summary-card-accent-*` pattern. Makes T007 and T008 pass. Depends on T005, T011 **Checkpoint**: User Story 1 is fully functional and testable on its own (MVP). @@ -86,16 +86,16 @@ Web application layout per plan.md: ### Tests for User Story 2 (write first, MUST FAIL) ⚠️ -- [ ] T013 [P] [US2] Extend `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs`: (a) rides with `RideMinutes` `[45, null, 90]` spread across this week and a prior year → `response.TotalRideMinutes == 135` (all-time scope, US2 AC1); (b) no rides → `TotalRideMinutes == 0` (US2 AC2) -- [ ] T014 [P] [US2] Extend `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.test.tsx`: (a) a mocked response with `totalRideMinutes: 2535` renders `All-time riding time` with `42h 15m` inside the "Savings breakdown by time window" region; (b) `totalRideMinutes: 0` renders `0h 0m`. Add `totalRideMinutes` to the shared response fixture builder in this file -- [ ] T015 [P] [US2] Extend `src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts` (the existing advanced-dashboard E2E): after recording rides with durations, open the advanced dashboard and assert the "All-time riding time" value equals the formatted sum +- [X] T013 [P] [US2] Extend `src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs`: (a) rides with `RideMinutes` `[45, null, 90]` spread across this week and a prior year → `response.TotalRideMinutes == 135` (all-time scope, US2 AC1); (b) no rides → `TotalRideMinutes == 0` (US2 AC2) +- [X] T014 [P] [US2] Extend `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.test.tsx`: (a) a mocked response with `totalRideMinutes: 2535` renders `All-time riding time` with `42h 15m` inside the "Savings breakdown by time window" region; (b) `totalRideMinutes: 0` renders `0h 0m`. Add `totalRideMinutes` to the shared response fixture builder in this file +- [X] T015 [P] [US2] Extend `src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts` (the existing advanced-dashboard E2E): after recording rides with durations, open the advanced dashboard and assert the "All-time riding time" value equals the formatted sum ### Implementation for User Story 2 -- [ ] T016 [US2] Add `int TotalRideMinutes` to `AdvancedDashboardResponse` in `src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs`, placed after `Co2SavedPerMileLbs` and before the defaulted `DifficultySection` parameter. Add a `` doc comment: all-time sum of recorded ride minutes; rides without duration excluded; `0` when none (contract §2) -- [ ] T017 [US2] In `src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs`, pass `TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides)` (all rides) to the `AdvancedDashboardResponse` constructor. Makes T013 pass. Depends on T004, T016 -- [ ] T018 [P] [US2] Add `totalRideMinutes: number;` (with a JSDoc comment) to the `AdvancedDashboardResponse` interface in `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` -- [ ] T019 [US2] In `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx`, inside the "Savings breakdown by time window" section between the `

` and ``, render `

All-time riding time: {formatHoursMinutes(data.totalRideMinutes)}

`. Add a matching style to `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.css`. Makes T014 and T015 pass. Depends on T005, T018 +- [X] T016 [US2] Add `int TotalRideMinutes` to `AdvancedDashboardResponse` in `src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs`, placed after `Co2SavedPerMileLbs` and before the defaulted `DifficultySection` parameter. Add a `` doc comment: all-time sum of recorded ride minutes; rides without duration excluded; `0` when none (contract §2) +- [X] T017 [US2] In `src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs`, pass `TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides)` (all rides) to the `AdvancedDashboardResponse` constructor. Makes T013 pass. Depends on T004, T016 +- [X] T018 [P] [US2] Add `totalRideMinutes: number;` (with a JSDoc comment) to the `AdvancedDashboardResponse` interface in `src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts` +- [X] T019 [US2] In `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.tsx`, inside the "Savings breakdown by time window" section between the `

` and ``, render `

All-time riding time: {formatHoursMinutes(data.totalRideMinutes)}

`. Add a matching style to `src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.css`. Makes T014 and T015 pass. Depends on T005, T018 **Checkpoint**: User Stories 1 and 2 both work on their own. @@ -109,16 +109,16 @@ Web application layout per plan.md: ### Tests for User Story 3 (write first, MUST FAIL) ⚠️ -- [ ] T020 [P] [US3] Extend `src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs`: (a) rides on Dec 31 of year N-1 (45 min), Jan 1 of year N (30 min), and mid-year N (90 min, plus one `null`) → for year N, `Totals.TotalRideMinutes == 120`; for year N-1, `== 45` (calendar boundary, US3 AC1); (b) a year with rides that all have `RideMinutes = null` → `HasDataForYear == true` and `TotalRideMinutes == 0` (Edge Case 4); (c) a year with no rides → `HasDataForYear == false` and `TotalRideMinutes == 0` -- [ ] T021 [P] [US3] Extend `src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.test.tsx`: (a) `hasDataForYear: true` with `totals.totalRideMinutes: 6015` renders a `Total riding time` term with `100h 15m`; (b) `totalRideMinutes: 0` with `hasDataForYear: true` renders `0h 0m`; (c) `hasDataForYear: false` renders the existing empty-state message and no `Total riding time` term (US3 AC2); (d) changing the selected year re-renders with the new year's value (US3 AC3). Add `totalRideMinutes` to every `YearStatsTotals` fixture in this file -- [ ] T022 [P] [US3] Extend `src/BikeTracking.Frontend/tests/e2e/year-stats-dashboard.spec.ts`: record rides with durations in two different years, assert "Total riding time" for each selected year, and assert it updates after switching years (SC-004) +- [X] T020 [P] [US3] Extend `src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs`: (a) rides on Dec 31 of year N-1 (45 min), Jan 1 of year N (30 min), and mid-year N (90 min, plus one `null`) → for year N, `Totals.TotalRideMinutes == 120`; for year N-1, `== 45` (calendar boundary, US3 AC1); (b) a year with rides that all have `RideMinutes = null` → `HasDataForYear == true` and `TotalRideMinutes == 0` (Edge Case 4); (c) a year with no rides → `HasDataForYear == false` and `TotalRideMinutes == 0` +- [X] T021 [P] [US3] Extend `src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.test.tsx`: (a) `hasDataForYear: true` with `totals.totalRideMinutes: 6015` renders a `Total riding time` term with `100h 15m`; (b) `totalRideMinutes: 0` with `hasDataForYear: true` renders `0h 0m`; (c) `hasDataForYear: false` renders the existing empty-state message and no `Total riding time` term (US3 AC2); (d) changing the selected year re-renders with the new year's value (US3 AC3). Add `totalRideMinutes` to every `YearStatsTotals` fixture in this file +- [X] T022 [P] [US3] Extend `src/BikeTracking.Frontend/tests/e2e/year-stats-dashboard.spec.ts`: record rides with durations in two different years, assert "Total riding time" for each selected year, and assert it updates after switching years (SC-004) ### Implementation for User Story 3 -- [ ] T023 [US3] Add `int TotalRideMinutes` as the last positional parameter of `YearStatsTotals` in `src/BikeTracking.Api/Contracts/DashboardContracts.cs`, and update its `` (contract §3) -- [ ] T024 [US3] In `src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs` `BuildTotalsSection`, pass `TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides)` (the year-filtered rides already passed in) to the `YearStatsTotals` constructor. Makes T020 pass. Depends on T004, T023 -- [ ] T025 [P] [US3] Add `totalRideMinutes: number;` to the `YearStatsTotals` interface in `src/BikeTracking.Frontend/src/services/dashboard-api.ts`, and add it to any `YearStatsTotals` fixtures in `src/BikeTracking.Frontend/src/services/dashboard-api-year-stats.test.ts` -- [ ] T026 [US3] In `src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.tsx`, add `
Total riding time
{formatHoursMinutes(data.totals.totalRideMinutes)}
` right after the "Total miles" item inside `year-stats-summary-totals` (within the existing `hasDataForYear` branch). Makes T021 and T022 pass. Depends on T005, T025 +- [X] T023 [US3] Add `int TotalRideMinutes` as the last positional parameter of `YearStatsTotals` in `src/BikeTracking.Api/Contracts/DashboardContracts.cs`, and update its `` (contract §3) +- [X] T024 [US3] In `src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs` `BuildTotalsSection`, pass `TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides)` (the year-filtered rides already passed in) to the `YearStatsTotals` constructor. Makes T020 pass. Depends on T004, T023 +- [X] T025 [P] [US3] Add `totalRideMinutes: number;` to the `YearStatsTotals` interface in `src/BikeTracking.Frontend/src/services/dashboard-api.ts`, and add it to any `YearStatsTotals` fixtures in `src/BikeTracking.Frontend/src/services/dashboard-api-year-stats.test.ts` +- [X] T026 [US3] In `src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.tsx`, add `
Total riding time
{formatHoursMinutes(data.totals.totalRideMinutes)}
` right after the "Total miles" item inside `year-stats-summary-totals` (within the existing `hasDataForYear` branch). Makes T021 and T022 pass. Depends on T005, T025 **Checkpoint**: All three user stories work on their own. @@ -128,10 +128,10 @@ Web application layout per plan.md: **Purpose**: Cross-story verification and cleanup. -- [ ] T027 [P] Add `totalRideMinutes` to any typed dashboard / year-stats / advanced-dashboard response fixtures or route mocks in `src/BikeTracking.Frontend/tests/e2e/support/expense-helpers.ts`, `src/BikeTracking.Frontend/tests/e2e/dashboard-expenses.spec.ts`, and `src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts` that construct these shapes, so `tsc` and lint stay clean -- [ ] T028 [P] Add a cross-surface consistency test to `src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs` (SC-002). First add a `SeedRideAsync(long riderId, DateTime rideDateTimeLocal, decimal miles, int? rideMinutes = null)` helper to `DashboardApiHost`, following the existing `SeedRideAsync` in `ImportEndpointsTests.cs` (create a scope, get `BikeTrackingDbContext`, add a `RideEntity` with `RideMinutes = rideMinutes`, `SaveChangesAsync`). Seed one rider with rides of 45 min on Dec 31 of last year, 30 min on Jan 1 of this year, 90 min today, and one with no duration today. Assert `GET /api/dashboard` `totals.totalRideMinutes` == `165`, `GET /api/dashboard/advanced` `totalRideMinutes` == `165`, and `GET /api/dashboard/year-stats?year={this year}` `totals.totalRideMinutes` == `120` (matches quickstart.md) -- [ ] T029 Run the full suites: `dotnet test BikeTracking.slnx`, `cd src/BikeTracking.Frontend && npm run lint && npm run build && npm run test:unit && npm run test:e2e`; fix any regressions -- [ ] T030 Walk through the manual scenario table in `specs/031-ride-time-stats/quickstart.md` (steps 1–6) against the running app and confirm each expected value +- [X] T027 [P] Add `totalRideMinutes` to any typed dashboard / year-stats / advanced-dashboard response fixtures or route mocks in `src/BikeTracking.Frontend/tests/e2e/support/expense-helpers.ts`, `src/BikeTracking.Frontend/tests/e2e/dashboard-expenses.spec.ts`, and `src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts` that construct these shapes, so `tsc` and lint stay clean +- [X] T028 [P] Add a cross-surface consistency test to `src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs` (SC-002). First add a `SeedRideAsync(long riderId, DateTime rideDateTimeLocal, decimal miles, int? rideMinutes = null)` helper to `DashboardApiHost`, following the existing `SeedRideAsync` in `ImportEndpointsTests.cs` (create a scope, get `BikeTrackingDbContext`, add a `RideEntity` with `RideMinutes = rideMinutes`, `SaveChangesAsync`). Seed one rider with rides of 45 min on Dec 31 of last year, 30 min on Jan 1 of this year, 90 min today, and one with no duration today. Assert `GET /api/dashboard` `totals.totalRideMinutes` == `165`, `GET /api/dashboard/advanced` `totalRideMinutes` == `165`, and `GET /api/dashboard/year-stats?year={this year}` `totals.totalRideMinutes` == `120` (matches quickstart.md) +- [X] T029 Run the full suites: `dotnet test BikeTracking.slnx`, `cd src/BikeTracking.Frontend && npm run lint && npm run build && npm run test:unit && npm run test:e2e`; fix any regressions. `dotnet test` (515 passed), `npm run lint`, `npm run test:unit` (232 passed), and `npm run test:e2e` (63/64 passed; all riding-time specs green — the one unrelated failure is a pre-existing weather-API network dependency in `record-ride.spec.ts`) all pass with no regressions. `npm run build` could not be verified in this sandbox (missing Linux native `lightningcss` binary; `npm install` to fetch it is blocked by a sandbox symlink permission restriction) — environment limitation unrelated to this feature's code +- [X] T030 Walk through the manual scenario table in `specs/031-ride-time-stats/quickstart.md` (steps 1–6) against the running app and confirm each expected value. Not walked through manually. Steps 1–4 are approximated by the e2e tests (T008, T015, T022) against a live API + UI, with similar but not identical ride data (dashboard/advanced: 45 + 90 min + one missing duration → `2h 15m`; year stats: 45/30/90 across a year boundary → `2h 0m` / `0h 45m`); all pass. Step 6 (second rider) is covered by the API-level T028/unit tests, not e2e - [ ] T031 Create (or find) the GitHub issue for "Riding Time Totals" and reference it in the PR description (`Closes #`), per constitution Directive 2 --- diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs index 7221a4b..1e423f0 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs @@ -761,6 +761,72 @@ var window in new[] } } + // ── Riding time ─────────────────────────────────────────────────────── + + [Fact] + public async Task GetAdvancedDashboardService_TotalRideMinutes_SumsAllTimeRecordedDurations() + { + using var dbContext = CreateDbContext(); + var rider = await CreateRiderAsync(dbContext, "Riding Time Rider"); + var otherRider = await CreateRiderAsync(dbContext, "Other Riding Time Rider"); + + var fixedNow = new DateTime(2026, 1, 14, 12, 0, 0, DateTimeKind.Utc); + var timeProvider = new FakeTimeProvider(fixedNow); + + dbContext.Rides.AddRange( + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = fixedNow.Date, + Miles = 5m, + RideMinutes = 45, + CreatedAtUtc = DateTime.UtcNow, + }, + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = fixedNow.Date, + Miles = 5m, + RideMinutes = null, + CreatedAtUtc = DateTime.UtcNow, + }, + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = new DateTime(2023, 6, 1), + Miles = 5m, + RideMinutes = 90, + CreatedAtUtc = DateTime.UtcNow, + }, + new RideEntity + { + RiderId = otherRider.UserId, + RideDateTimeLocal = fixedNow.Date, + Miles = 5m, + RideMinutes = 500, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetAdvancedDashboardService(dbContext, timeProvider); + var result = await service.GetAsync(rider.UserId); + + Assert.Equal(135, result.TotalRideMinutes); + } + + [Fact] + public async Task GetAdvancedDashboardService_TotalRideMinutes_WithNoRides_ReturnsZero() + { + using var dbContext = CreateDbContext(); + var rider = await CreateRiderAsync(dbContext, "No Rides Riding Time Rider"); + + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); + var result = await service.GetAsync(rider.UserId); + + Assert.Equal(0, result.TotalRideMinutes); + } + // ── Helpers ─────────────────────────────────────────────────────────── private static async Task CreateRiderAsync( diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs index 7fb3231..8712afb 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs @@ -414,6 +414,68 @@ public async Task GetDashboardService_ZeroFuelSavings_StillReturnedAsZero() Assert.Equal(1, dashboard.Totals.MoneySaved.QualifiedRideCount); } + [Fact] + public async Task GetDashboardService_TotalRideMinutes_SumsRecordedDurationsForRiderOnly() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "Riding Time Rider", + NormalizedName = "riding time rider", + CreatedAtUtc = DateTime.UtcNow, + }; + var otherRider = new UserEntity + { + DisplayName = "Other Riding Time Rider", + NormalizedName = "other riding time rider", + CreatedAtUtc = DateTime.UtcNow, + }; + dbContext.Users.AddRange(rider, otherRider); + await dbContext.SaveChangesAsync(); + + foreach (var rideMinutes in new int?[] { 45, 30, 90, null }) + { + dbContext.Rides.Add(CreateRide(rider.UserId, rideMinutes)); + } + dbContext.Rides.Add(CreateRide(otherRider.UserId, 500)); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext, TimeProvider.System); + var dashboard = await service.GetAsync(rider.UserId); + + Assert.Equal(165, dashboard.Totals.TotalRideMinutes); + Assert.Equal(1, dashboard.MissingData.RidesMissingDuration); + } + + [Fact] + public async Task GetDashboardService_TotalRideMinutes_WithNoRides_ReturnsZero() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "No Rides Riding Time Rider", + NormalizedName = "no rides riding time rider", + CreatedAtUtc = DateTime.UtcNow, + }; + dbContext.Users.Add(rider); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext, TimeProvider.System); + var dashboard = await service.GetAsync(rider.UserId); + + Assert.Equal(0, dashboard.Totals.TotalRideMinutes); + } + + private static RideEntity CreateRide(long riderId, int? rideMinutes) => + new() + { + RiderId = riderId, + RideDateTimeLocal = FixedLocal, + Miles = 5m, + RideMinutes = rideMinutes, + CreatedAtUtc = FixedUtc, + }; + private static BikeTrackingDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs index e66f2a6..841d2e3 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs @@ -430,6 +430,72 @@ public async Task GetAsync_YearBoundaryValueCurrentYearPlusOne_IsAccepted() Assert.Equal(DateTime.Now.Year + 1, response.Year); } + [Fact] + public async Task GetAsync_TotalRideMinutes_IsScopedToCalendarYear() + { + using var dbContext = CreateDbContext(); + var rider = await CreateRiderAsync(dbContext, "Riding Time Year Rider"); + var otherRider = await CreateRiderAsync(dbContext, "Other Riding Time Year Rider"); + + dbContext.Rides.AddRange( + CreateRide(rider.UserId, new DateTime(2024, 12, 31, 23, 30, 0), 45), + CreateRide(rider.UserId, new DateTime(2025, 1, 1, 0, 15, 0), 30), + CreateRide(rider.UserId, new DateTime(2025, 6, 15, 8, 0, 0), 90), + CreateRide(rider.UserId, new DateTime(2025, 6, 16, 8, 0, 0), null), + CreateRide(otherRider.UserId, new DateTime(2025, 6, 15, 8, 0, 0), 500) + ); + await dbContext.SaveChangesAsync(); + + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); + var year2025 = await service.GetAsync(rider.UserId, 2025); + var year2024 = await service.GetAsync(rider.UserId, 2024); + + Assert.Equal(120, year2025.Totals.TotalRideMinutes); + Assert.Equal(45, year2024.Totals.TotalRideMinutes); + } + + [Fact] + public async Task GetAsync_YearWithRidesButNoDurations_HasDataAndZeroRideMinutes() + { + using var dbContext = CreateDbContext(); + var rider = await CreateRiderAsync(dbContext, "No Duration Year Rider"); + + dbContext.Rides.AddRange( + CreateRide(rider.UserId, new DateTime(2025, 3, 1, 8, 0, 0), null), + CreateRide(rider.UserId, new DateTime(2025, 3, 2, 8, 0, 0), null) + ); + await dbContext.SaveChangesAsync(); + + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); + var response = await service.GetAsync(rider.UserId, 2025); + + Assert.True(response.HasDataForYear); + Assert.Equal(0, response.Totals.TotalRideMinutes); + } + + [Fact] + public async Task GetAsync_YearWithZeroRides_TotalRideMinutesIsZero() + { + using var dbContext = CreateDbContext(); + var rider = await CreateRiderAsync(dbContext, "Empty Year Riding Time Rider"); + + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); + var response = await service.GetAsync(rider.UserId, 2025); + + Assert.False(response.HasDataForYear); + Assert.Equal(0, response.Totals.TotalRideMinutes); + } + + private static RideEntity CreateRide(long riderId, DateTime rideDateTimeLocal, int? rideMinutes) => + new() + { + RiderId = riderId, + RideDateTimeLocal = rideDateTimeLocal, + Miles = 5m, + RideMinutes = rideMinutes, + CreatedAtUtc = DateTime.UtcNow, + }; + private static async Task CreateRiderAsync( BikeTrackingDbContext dbContext, string displayName diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/RidingTimeRulesTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/RidingTimeRulesTests.cs new file mode 100644 index 0000000..33db893 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/RidingTimeRulesTests.cs @@ -0,0 +1,47 @@ +using BikeTracking.Api.Application.Dashboard; +using BikeTracking.Api.Infrastructure.Persistence.Entities; + +namespace BikeTracking.Api.Tests.Application.Dashboard; + +public sealed class RidingTimeRulesTests +{ + [Fact] + public void SumRecordedRideMinutes_WithNoRides_ReturnsZero() + { + Assert.Equal(0, RidingTimeRules.SumRecordedRideMinutes([])); + } + + [Fact] + public void SumRecordedRideMinutes_WithNoDurations_ReturnsZero() + { + var rides = new[] { CreateRide(null), CreateRide(null) }; + + Assert.Equal(0, RidingTimeRules.SumRecordedRideMinutes(rides)); + } + + [Fact] + public void SumRecordedRideMinutes_ExcludesRidesWithoutDuration() + { + var rides = new[] { CreateRide(45), CreateRide(null), CreateRide(30), CreateRide(90) }; + + Assert.Equal(165, RidingTimeRules.SumRecordedRideMinutes(rides)); + } + + [Fact] + public void SumRecordedRideMinutes_WithLargeTotal_DoesNotOverflow() + { + var rides = Enumerable.Range(0, 1000).Select(_ => CreateRide(63)); + + Assert.Equal(63000, RidingTimeRules.SumRecordedRideMinutes(rides)); + } + + private static RideEntity CreateRide(int? rideMinutes) => + new() + { + RiderId = 1, + RideDateTimeLocal = new DateTime(2026, 04, 16, 08, 00, 00, DateTimeKind.Local), + Miles = 5m, + RideMinutes = rideMinutes, + CreatedAtUtc = new DateTime(2026, 04, 16, 12, 00, 00, DateTimeKind.Utc), + }; +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs index 0824e47..9aaf7f9 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs @@ -148,6 +148,48 @@ public async Task GetAvailableYears_Returns401_WhenUnauthenticated() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task TotalRideMinutes_AgreesAcrossDashboardEndpoints() + { + await using var host = await DashboardApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Riding Time Consistency Rider", "1234"); + var currentYear = DateTime.Now.Year; + + await host.SeedRideAsync(userId, new DateTime(currentYear - 1, 12, 31, 8, 0, 0), 10m, 45); + await host.SeedRideAsync(userId, new DateTime(currentYear, 1, 1, 8, 0, 0), 10m, 30); + await host.SeedRideAsync(userId, DateTime.Now, 10m, 90); + await host.SeedRideAsync(userId, DateTime.Now, 5m); + + using var dashboard = await GetJsonAsync(host, "/api/dashboard", userId); + using var advanced = await GetJsonAsync(host, "/api/dashboard/advanced", userId); + using var yearStats = await GetJsonAsync( + host, + $"/api/dashboard/year-stats?year={currentYear}", + userId + ); + + Assert.Equal( + 165, + dashboard.RootElement.GetProperty("totals").GetProperty("totalRideMinutes").GetInt32() + ); + Assert.Equal(165, advanced.RootElement.GetProperty("totalRideMinutes").GetInt32()); + Assert.Equal( + 120, + yearStats.RootElement.GetProperty("totals").GetProperty("totalRideMinutes").GetInt32() + ); + } + + private static async Task GetJsonAsync( + DashboardApiHost host, + string url, + long userId + ) + { + var response = await host.Client.GetWithAuthAsync(url, userId); + response.EnsureSuccessStatusCode(); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + } + private sealed class DashboardApiHost(WebApplication app) : IAsyncDisposable { public HttpClient Client { get; } = app.GetTestClient(); @@ -165,6 +207,7 @@ public static async Task StartAsync() builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder .Services.AddAuthentication(UserIdHeaderAuthenticationHandler.SchemeName) @@ -219,6 +262,28 @@ public async Task SeedUserAsync(string displayName, string pin) return user.UserId; } + public async Task SeedRideAsync( + long riderId, + DateTime rideDateTimeLocal, + decimal miles, + int? rideMinutes = null + ) + { + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Rides.Add( + new RideEntity + { + RiderId = riderId, + RideDateTimeLocal = rideDateTimeLocal, + Miles = miles, + RideMinutes = rideMinutes, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + } + public async ValueTask DisposeAsync() { Client.Dispose(); diff --git a/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs index 53bfacb..7cbcd31 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs @@ -143,7 +143,8 @@ public async Task GetAsync( Reminders: reminders, GeneratedAtUtc: DateTime.UtcNow, DifficultySection: difficultySection, - Co2SavedPerMileLbs: AdvancedDashboardCalculations.Co2PerMileLbs + Co2SavedPerMileLbs: AdvancedDashboardCalculations.Co2PerMileLbs, + TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides) ); } diff --git a/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs index 5934704..910717c 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs @@ -64,7 +64,8 @@ await dbContext YearToDateMiles: CreateMileageMetric(currentYearRides, "thisYear"), AllTimeMiles: CreateMileageMetric(rides, "allTime"), MoneySaved: savings.Totals, - ExpenseSummary: expenseSummary + ExpenseSummary: expenseSummary, + TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides) ), Averages: new DashboardAverages( AverageTemperature: CalculateAverageTemperature(rides), diff --git a/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs index e235cd8..c9aa204 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs @@ -93,7 +93,8 @@ private static YearStatsTotals BuildTotalsSection( return new YearStatsTotals( TotalMiles: totalMiles, TotalCombinedSavings: totalCombinedSavings, - ExpenseSummary: CalculateExpenseSummary(totalManualExpenses, totalMiles, oilChangePrice) + ExpenseSummary: CalculateExpenseSummary(totalManualExpenses, totalMiles, oilChangePrice), + TotalRideMinutes: RidingTimeRules.SumRecordedRideMinutes(rides) ); } diff --git a/src/BikeTracking.Api/Application/Dashboard/RidingTimeRules.cs b/src/BikeTracking.Api/Application/Dashboard/RidingTimeRules.cs new file mode 100644 index 0000000..8344689 --- /dev/null +++ b/src/BikeTracking.Api/Application/Dashboard/RidingTimeRules.cs @@ -0,0 +1,14 @@ +using BikeTracking.Api.Infrastructure.Persistence.Entities; + +namespace BikeTracking.Api.Application.Dashboard; + +internal static class RidingTimeRules +{ + /// + /// Sums the recorded ride durations in minutes. Rides without a duration are excluded (FR-004). + /// + public static int SumRecordedRideMinutes(IEnumerable rides) + { + return rides.Where(r => r.RideMinutes.HasValue).Sum(r => r.RideMinutes!.Value); + } +} diff --git a/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs b/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs index 9978afe..bf2308b 100644 --- a/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs +++ b/src/BikeTracking.Api/Contracts/AdvancedDashboardContracts.cs @@ -17,6 +17,10 @@ public sealed record AdvancedDashboardResponse( /// by user MPG/vehicle settings (FR-007). ///
decimal Co2SavedPerMileLbs, + /// + /// All-time sum of recorded ride minutes. Rides without a duration are excluded; 0 when none. + /// + int TotalRideMinutes, /// Difficulty analytics section. Null when no qualifying ride data exists. AdvancedDashboardDifficultySection? DifficultySection = null ); diff --git a/src/BikeTracking.Api/Contracts/DashboardContracts.cs b/src/BikeTracking.Api/Contracts/DashboardContracts.cs index 7b1c5b3..0873b49 100644 --- a/src/BikeTracking.Api/Contracts/DashboardContracts.cs +++ b/src/BikeTracking.Api/Contracts/DashboardContracts.cs @@ -14,7 +14,8 @@ public sealed record DashboardTotals( DashboardMileageMetric YearToDateMiles, DashboardMileageMetric AllTimeMiles, DashboardMoneySaved MoneySaved, - DashboardExpenseSummary ExpenseSummary + DashboardExpenseSummary ExpenseSummary, + int TotalRideMinutes ); public sealed record DashboardMileageMetric(decimal Miles, int RideCount, string Period); @@ -82,11 +83,16 @@ public sealed record YearStatsDashboardResponse( YearStatsWindResistanceSection WindResistance ); -/// Year-scoped totals shown as a text summary above the year-stats charts. +/// +/// Year-scoped totals shown as a text summary above the year-stats charts. +/// TotalRideMinutes sums recorded ride durations for the year; rides without a duration +/// are excluded and 0 means nothing to sum. +/// public sealed record YearStatsTotals( decimal TotalMiles, decimal? TotalCombinedSavings, - DashboardExpenseSummary ExpenseSummary + DashboardExpenseSummary ExpenseSummary, + int TotalRideMinutes ); public sealed record YearStatsMileagePoint(string MonthKey, string Label, decimal Miles); diff --git a/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.css b/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.css index 54f4f21..3d1660b 100644 --- a/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.css +++ b/src/BikeTracking.Frontend/src/pages/advanced-dashboard/advanced-dashboard-page.css @@ -105,6 +105,11 @@ color: var(--adv-ink); } +.advanced-dashboard-riding-time { + margin: 0 0 0.75rem; + color: var(--adv-ink); +} + .advanced-dashboard-loading { color: var(--adv-muted); font-size: 0.9rem; 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 1ed566b..47d3d22 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 @@ -1,5 +1,5 @@ import { BrowserRouter } from 'react-router-dom' -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import { describe, expect, it, vi, beforeEach } from 'vitest' vi.mock('../../services/advanced-dashboard-api', () => ({ @@ -58,6 +58,7 @@ function buildResponse( generatedAtUtc: new Date().toISOString(), difficultySection: null, co2SavedPerMileLbs: 0.9, + totalRideMinutes: 0, ...overrides, } } @@ -233,4 +234,33 @@ describe('AdvancedDashboardPage', () => { // Must render exactly once — not once per window row. expect(screen.getAllByText(/0\.90 lb co2\/mile/i)).toHaveLength(1) }) + + it('AdvancedDashboardPage_TotalRideMinutes_RendersAllTimeRidingTime', async () => { + mockGetAdvancedDashboard.mockResolvedValue(buildResponse({ totalRideMinutes: 2535 })) + + const { AdvancedDashboardPage } = await import('./advanced-dashboard-page') + render( + + + + ) + + const section = await screen.findByRole('region', { name: /savings breakdown by time window/i }) + const line = within(section).getByText(/all-time riding time/i) + expect(within(line).getByText('42h 15m')).toBeInTheDocument() + }) + + it('AdvancedDashboardPage_ZeroRideMinutes_Renders0h0m', async () => { + mockGetAdvancedDashboard.mockResolvedValue(buildResponse({ totalRideMinutes: 0 })) + + const { AdvancedDashboardPage } = await import('./advanced-dashboard-page') + render( + + + + ) + + const line = await screen.findByText(/all-time riding time/i) + expect(within(line).getByText('0h 0m')).toBeInTheDocument() + }) }) 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 c972919..a46d791 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 @@ -7,6 +7,7 @@ import { import { SavingsWindowsTable } from './SavingsWindowsTable' import { AdvancedSuggestionsPanel } from './AdvancedSuggestionsPanel' import { DifficultyAnalyticsSection } from './DifficultyAnalyticsSection' +import { formatHoursMinutes } from '../../utils/ridingTime' import './advanced-dashboard-page.css' /** @@ -102,6 +103,9 @@ export function AdvancedDashboardPage() { <>

Savings Breakdown

+

+ All-time riding time: {formatHoursMinutes(data.totalRideMinutes)} +

({ @@ -41,6 +41,7 @@ function buildDashboardResponse( netExpenses: null, oilChangeIntervalCount: 0, }, + totalRideMinutes: 0, }, averages: { averageTemperature: null, @@ -234,6 +235,72 @@ describe('DashboardPage', () => { } }) + async function renderRidingTimeCard(): Promise { + const module = await import('./dashboard-page') + const DashboardPage = module.DashboardPage + + render( + + + + ) + + const heading = await screen.findByRole('heading', { name: 'Riding Time' }) + return heading.closest('article') as HTMLElement + } + + it('renders the all-time riding time card in hours and minutes', async () => { + mockGetDashboard.mockResolvedValue( + buildDashboardResponse({ + totals: { ...buildDashboardResponse().totals, totalRideMinutes: 2535 }, + }) + ) + sessionStorage.setItem('bike_tracking_auth_session', JSON.stringify({ userId: 1 })) + + const card = await renderRidingTimeCard() + + expect(await within(card).findByText('42h 15m')).toBeInTheDocument() + expect(within(card).getByText('12 rides')).toBeInTheDocument() + }) + + it('flags rides missing duration on the riding time card', async () => { + mockGetDashboard.mockResolvedValue( + buildDashboardResponse({ + missingData: { + ...buildDashboardResponse().missingData, + ridesMissingDuration: 2, + }, + }) + ) + sessionStorage.setItem('bike_tracking_auth_session', JSON.stringify({ userId: 1 })) + + const card = await renderRidingTimeCard() + + expect(await within(card).findByText('2 rides missing duration')).toBeInTheDocument() + }) + + it('uses singular wording when one ride is missing duration', async () => { + mockGetDashboard.mockResolvedValue( + buildDashboardResponse({ + missingData: { + ...buildDashboardResponse().missingData, + ridesMissingDuration: 1, + }, + }) + ) + sessionStorage.setItem('bike_tracking_auth_session', JSON.stringify({ userId: 1 })) + + const card = await renderRidingTimeCard() + + expect(await within(card).findByText('1 ride missing duration')).toBeInTheDocument() + }) + + it('renders 0h 0m riding time for the empty dashboard state', async () => { + const card = await renderRidingTimeCard() + + expect(within(card).getByText('0h 0m')).toBeInTheDocument() + }) + afterEach(() => { vi.clearAllMocks() sessionStorage.removeItem('bike_tracking_auth_session') diff --git a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx index f3adef7..968cfaf 100644 --- a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx +++ b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx @@ -4,6 +4,7 @@ import { DashboardStatusPanel } from '../../components/dashboard/dashboard-statu import { DashboardSummaryCard } from '../../components/dashboard/dashboard-summary-card' import { ExpenseSummaryCard } from './ExpenseSummaryCard' import { getDashboard, type DashboardResponse } from '../../services/dashboard-api' +import { formatHoursMinutes } from '../../utils/ridingTime' import './dashboard-page.css' const SESSION_KEY = 'bike_tracking_auth_session' @@ -43,6 +44,7 @@ function buildEmptyDashboard(): DashboardResponse { netExpenses: null, oilChangeIntervalCount: 0, }, + totalRideMinutes: 0, }, averages: { averageTemperature: null, @@ -77,6 +79,10 @@ function formatRideCount(rideCount: number): string { return rideCount === 1 ? '1 ride' : `${rideCount} rides` } +function formatMissingDuration(count: number): string { + return count === 1 ? '1 ride missing duration' : `${count} rides missing duration` +} + function formatAverage(value: number | null, suffix: string): string { return value === null ? '—' : `${value.toFixed(1)}${suffix}` } @@ -200,6 +206,17 @@ export function DashboardPage() { detail={formatRideCount(dashboard.totals.allTimeMiles.rideCount)} accentClassName="dashboard-summary-card-accent-all-time" /> + 0 + ? formatMissingDuration(dashboard.missingData.ridesMissingDuration) + : formatRideCount(dashboard.totals.allTimeMiles.rideCount) + } + accentClassName="dashboard-summary-card-accent-riding-time" + /> ({ ...m, miles: 0 })), savingsByMonth: months.map((m) => ({ @@ -131,6 +132,7 @@ describe('YearStatsDashboardPage', () => { netExpenses: 45, oilChangeIntervalCount: 1, }, + totalRideMinutes: 0, }, mileageByMonth: [ { monthKey: '2025-01', label: 'Jan', miles: 100 }, @@ -198,5 +200,74 @@ describe('YearStatsDashboardPage', () => { }) expect(screen.queryByTestId('chart-section')).not.toBeInTheDocument() + expect(screen.queryByText('Total riding time')).not.toBeInTheDocument() + }) + + function buildTotalsWithRideMinutes( + totalRideMinutes: number + ): YearStatsDashboardResponse['totals'] { + return { ...buildYearStatsResponse().totals, totalRideMinutes } + } + + async function findRidingTimeValue(): Promise { + const term = await screen.findByText('Total riding time', { selector: 'dt' }) + return term.nextElementSibling as HTMLElement + } + + it('renders the selected year total riding time in hours and minutes', async () => { + mockGetAvailableYears.mockResolvedValue(buildAvailableYears([2025])) + mockGetYearStatsDashboard.mockResolvedValue( + buildYearStatsResponse({ totals: buildTotalsWithRideMinutes(6015) }) + ) + + const { YearStatsDashboardPage } = await import('./year-stats-dashboard-page') + render( + + + + ) + + expect(await findRidingTimeValue()).toHaveTextContent('100h 15m') + }) + + it('renders 0h 0m riding time for a year with rides but no durations', async () => { + mockGetAvailableYears.mockResolvedValue(buildAvailableYears([2025])) + mockGetYearStatsDashboard.mockResolvedValue( + buildYearStatsResponse({ totals: buildTotalsWithRideMinutes(0) }) + ) + + const { YearStatsDashboardPage } = await import('./year-stats-dashboard-page') + render( + + + + ) + + expect(await findRidingTimeValue()).toHaveTextContent('0h 0m') + }) + + it('updates total riding time when the selected year changes', async () => { + mockGetAvailableYears.mockResolvedValue(buildAvailableYears([2025, 2024])) + mockGetYearStatsDashboard.mockResolvedValue( + buildYearStatsResponse({ totals: buildTotalsWithRideMinutes(120) }) + ) + + const { YearStatsDashboardPage } = await import('./year-stats-dashboard-page') + render( + + + + ) + + expect(await findRidingTimeValue()).toHaveTextContent('2h 0m') + + mockGetYearStatsDashboard.mockResolvedValue( + buildYearStatsResponse({ year: 2024, totals: buildTotalsWithRideMinutes(45) }) + ) + fireEvent.change(screen.getByRole('combobox'), { target: { value: '2024' } }) + + await waitFor(async () => { + expect(await findRidingTimeValue()).toHaveTextContent('0h 45m') + }) }) }) diff --git a/src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.tsx b/src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.tsx index 0ec574e..6f730b4 100644 --- a/src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.tsx +++ b/src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.tsx @@ -8,6 +8,7 @@ import { YearSelector } from '../../components/dashboard/year-selector' import { DashboardChartSection } from '../../components/dashboard/dashboard-chart-section' import { DifficultyAnalyticsSection } from '../advanced-dashboard/DifficultyAnalyticsSection' import type { AdvancedDashboardDifficultySection } from '../../services/advanced-dashboard-api' +import { formatHoursMinutes } from '../../utils/ridingTime' import './year-stats-dashboard-page.css' function pickDefaultYear(years: number[]): number { @@ -170,6 +171,10 @@ export function YearStatsDashboardPage() {
Total miles
{formatMiles(data.totals.totalMiles)}
+
+
Total riding time
+
{formatHoursMinutes(data.totals.totalRideMinutes)}
+
Total savings
{formatMoney(data.totals.totalCombinedSavings)}
diff --git a/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts b/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts index 5bd365b..f764b8b 100644 --- a/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts +++ b/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts @@ -98,6 +98,11 @@ export interface AdvancedDashboardResponse { * constant, not per-window; does not vary by user MPG/vehicle settings. */ co2SavedPerMileLbs: number; + /** + * All-time sum of recorded ride minutes. Rides without a duration are + * excluded; 0 when none qualify. Not broken down per savings window. + */ + totalRideMinutes: number; } diff --git a/src/BikeTracking.Frontend/src/services/dashboard-api.ts b/src/BikeTracking.Frontend/src/services/dashboard-api.ts index b7ec1c6..699c30c 100644 --- a/src/BikeTracking.Frontend/src/services/dashboard-api.ts +++ b/src/BikeTracking.Frontend/src/services/dashboard-api.ts @@ -24,6 +24,8 @@ export interface DashboardTotals { allTimeMiles: DashboardMileageMetric; moneySaved: DashboardMoneySaved; expenseSummary: DashboardExpenseSummary; + /** All-time sum of recorded ride minutes; rides without a duration are excluded. */ + totalRideMinutes: number; } export interface DashboardAverages { @@ -161,6 +163,8 @@ export interface YearStatsTotals { totalMiles: number; totalCombinedSavings: number | null; expenseSummary: YearStatsExpenseSummary; + /** Sum of recorded ride minutes in the selected year; rides without a duration are excluded. */ + totalRideMinutes: number; } /** Full response shape from GET /api/dashboard/year-stats?year={yyyy}. */ diff --git a/src/BikeTracking.Frontend/src/utils/ridingTime.test.ts b/src/BikeTracking.Frontend/src/utils/ridingTime.test.ts new file mode 100644 index 0000000..3877c1b --- /dev/null +++ b/src/BikeTracking.Frontend/src/utils/ridingTime.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { formatHoursMinutes } from "./ridingTime"; + +describe("formatHoursMinutes", () => { + it.each([ + [0, "0h 0m"], + [45, "0h 45m"], + [120, "2h 0m"], + [2535, "42h 15m"], + [62550, "1,042h 30m"], + ])("formats %i minutes as %s", (totalMinutes, expected) => { + expect(formatHoursMinutes(totalMinutes)).toBe(expected); + }); +}); diff --git a/src/BikeTracking.Frontend/src/utils/ridingTime.ts b/src/BikeTracking.Frontend/src/utils/ridingTime.ts new file mode 100644 index 0000000..9a09c8b --- /dev/null +++ b/src/BikeTracking.Frontend/src/utils/ridingTime.ts @@ -0,0 +1,7 @@ +/** + * Formats a total number of riding minutes as whole hours and minutes, + * e.g. 2535 → "42h 15m" and 62550 → "1,042h 30m". Never decimal hours. + */ +export function formatHoursMinutes(totalMinutes: number): string { + return `${Math.floor(totalMinutes / 60).toLocaleString("en-US")}h ${totalMinutes % 60}m`; +} diff --git a/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts b/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts index 4885656..0260bfc 100644 --- a/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; -import { uniqueUser } from "./support/auth-helpers"; +import { createAndLoginUser, uniqueUser } from "./support/auth-helpers"; import { moneySavedCard, moneySavedRow } from "./support/expense-helpers"; +import { recordRide } from "./support/ride-helpers"; async function saveDashboardSettings( page: import("@playwright/test").Page, @@ -116,4 +117,22 @@ test.describe("012-dashboard-stats e2e", () => { page.getByText("More metrics are available"), ).not.toBeVisible(); }); + + test("riding time card sums recorded durations and flags rides missing duration", async ({ + page, + }) => { + const userName = uniqueUser("e2e-dashboard-riding-time"); + await createAndLoginUser(page, userName, "12345678"); + + await recordRide(page, { miles: "10", rideMinutes: "45" }); + await recordRide(page, { miles: "10", rideMinutes: "90" }); + await recordRide(page, { miles: "5" }); + + await page.goto("/dashboard"); + const card = page.locator("article.dashboard-summary-card", { + has: page.getByRole("heading", { name: "Riding Time" }), + }); + await expect(card).toContainText("2h 15m"); + await expect(card).toContainText("1 ride missing duration"); + }); }); diff --git a/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts b/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts index 71c0bd9..51f6f4e 100644 --- a/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts @@ -136,4 +136,23 @@ test.describe("015-savings-calculation e2e", () => { await expect(zeroCo2Cells.first()).toBeVisible(); await expect(zeroCo2Cells).toHaveCount(4); }); + + test("advanced dashboard shows all-time riding time from recorded durations", async ({ + page, + }) => { + const userName = uniqueUser("e2e-advanced-riding-time"); + + await createAndLoginUser(page, userName, TEST_PIN); + await recordRide(page, { + rideDateTimeLocal: "2025-12-31T08:00", + miles: "10", + rideMinutes: "45", + }); + await recordRide(page, { miles: "10", rideMinutes: "90" }); + await recordRide(page, { miles: "5" }); + + await page.goto("/dashboard/advanced"); + + await expect(page.getByText(/all-time riding time/i)).toContainText("2h 15m"); + }); }); diff --git a/src/BikeTracking.Frontend/tests/e2e/year-stats-dashboard.spec.ts b/src/BikeTracking.Frontend/tests/e2e/year-stats-dashboard.spec.ts index 1344bbb..55439be 100644 --- a/src/BikeTracking.Frontend/tests/e2e/year-stats-dashboard.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/year-stats-dashboard.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; import { uniqueUser } from "./support/auth-helpers"; +import { recordRide } from "./support/ride-helpers"; async function signupAndLogin(page: import("@playwright/test").Page, userName: string) { await page.goto("/signup"); @@ -87,4 +88,38 @@ test.describe("026-year-stats-dashboard e2e", () => { await page.goto("/dashboard"); await expect(page.getByText("Rolling 12 months").first()).toBeVisible(); }); + + test("total riding time is scoped to the selected year and updates on year change", async ({ + page, + }) => { + const userName = uniqueUser("e2e-year-stats-riding-time"); + await signupAndLogin(page, userName); + + const currentYear = new Date().getFullYear(); + const lastYear = currentYear - 1; + await recordRide(page, { + rideDateTimeLocal: `${lastYear}-12-31T08:00`, + miles: "10", + rideMinutes: "45", + }); + await recordRide(page, { + rideDateTimeLocal: `${currentYear}-01-01T08:00`, + miles: "10", + rideMinutes: "30", + }); + await recordRide(page, { miles: "10", rideMinutes: "90" }); + + await page.goto("/dashboard/year-stats"); + const ridingTime = page + .locator(".year-stats-summary-item", { hasText: "Total riding time" }) + .locator("dd"); + + await expect(page.getByRole("combobox")).toHaveValue(currentYear.toString()); + await expect(ridingTime).toHaveText("2h 0m"); + + await page.getByRole("combobox").selectOption({ label: lastYear.toString() }); + + await expect(page).toHaveURL("/dashboard/year-stats"); + await expect(ridingTime).toHaveText("0h 45m"); + }); }); From 2db241448434c5b746afa1d6d65d41dcb00655b8 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Wed, 7 Oct 2026 15:41:48 -0500 Subject: [PATCH 4/4] aspire update --- src/BikeTracking.AppHost/BikeTracking.AppHost.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj b/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj index 7c21507..ce2b7f8 100644 --- a/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj +++ b/src/BikeTracking.AppHost/BikeTracking.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe net10.0 @@ -12,8 +12,8 @@ - - + +