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/.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/contracts/riding-time-totals-contract.md b/specs/031-ride-time-stats/contracts/riding-time-totals-contract.md new file mode 100644 index 0000000..ba077bd --- /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 "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`) | `
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) ⚠️ + +- [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 + +- [X] T023 [US3] Add `int TotalRideMinutes` as the last positional parameter of `YearStatsTotals` in `src/BikeTracking.Api/Contracts/DashboardContracts.cs`, and update its `+ All-time riding time: {formatHoursMinutes(data.totalRideMinutes)} +