From 0d9031f3a075bfd23e8bda90a5abb6f339eddec5 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Thu, 2 Jul 2026 19:12:40 +0000 Subject: [PATCH 1/7] Add spec for year stats dashboard feature --- .specify/feature.json | 2 +- .../checklists/requirements.md | 36 +++++++ specs/026-year-stats-dashboard/spec.md | 101 ++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 specs/026-year-stats-dashboard/checklists/requirements.md create mode 100644 specs/026-year-stats-dashboard/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index f148688..01e23c7 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/025-monthly-summary-import" + "feature_directory": "specs/026-year-stats-dashboard" } diff --git a/specs/026-year-stats-dashboard/checklists/requirements.md b/specs/026-year-stats-dashboard/checklists/requirements.md new file mode 100644 index 0000000..c346bbf --- /dev/null +++ b/specs/026-year-stats-dashboard/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Year Stats Dashboard + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-02 +**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 clarifications were needed: reasonable defaults were used for year-range selection (only years with ride data), default year (current year, falling back to most recent with data), and chart scope (mileage trend, savings breakdown, difficulty/wind analytics — mirroring existing dashboards). These are documented in the Assumptions section. +- All checklist items pass on first validation pass. diff --git a/specs/026-year-stats-dashboard/spec.md b/specs/026-year-stats-dashboard/spec.md new file mode 100644 index 0000000..b89fe73 --- /dev/null +++ b/specs/026-year-stats-dashboard/spec.md @@ -0,0 +1,101 @@ +# Feature Specification: Year Stats Dashboard + +**Feature Branch**: `026-year-stats-dashboard` + +**Created**: 2026-07-02 + +**Status**: Draft + +**Input**: User description: "I want to view a stats dashboard with many of the same charts for the year that I choose. Make this a new page, reuse existing graphs by moving them into components (pass the year into the component)" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - View stats for a chosen year (Priority: P1) + +As a rider, I want to open a dedicated stats page, pick a specific year, and see the same kinds of charts I already trust from the main dashboard (mileage trend, savings breakdown, difficulty/wind analytics), but scoped to that single year, so I can review or compare my riding history year over year. + +**Why this priority**: This is the core value of the feature — without year selection and year-scoped charts, there is no new capability to deliver. + +**Independent Test**: Can be fully tested by navigating to the new stats dashboard page, selecting a year from a list of years that have ride data, and confirming all charts render data limited to that year only. + +**Acceptance Scenarios**: + +1. **Given** I have ride data spanning multiple years, **When** I open the year stats dashboard page, **Then** it defaults to showing the current year (or the most recent year with data if the current year has none) and renders the standard set of charts scoped to that year. +2. **Given** I am viewing the year stats dashboard, **When** I select a different year from the year selector, **Then** all charts on the page update to reflect data for the newly selected year only, without navigating away from the page. +3. **Given** a year has no ride data at all, **When** I select that year, **Then** each chart displays a clear "no data for this year" state instead of an error or blank chart. + +--- + +### User Story 2 - Reuse existing chart components across pages (Priority: P2) + +As a developer/maintainer, I want the charts already shown on the main dashboard (and advanced dashboard) to be implemented as standalone, year-parameterized components, so the same chart logic can be reused on the new year stats dashboard without duplicating code. + +**Why this priority**: This is what makes the feature maintainable and keeps the two dashboards visually/behaviorally consistent, but the end user primarily cares about User Story 1; this story is the enabling refactor. + +**Independent Test**: Can be tested by verifying the main dashboard still renders identically (same charts, same rolling-window behavior) after the refactor, and that the new year stats dashboard renders using the same underlying chart components with a `year` input. + +**Acceptance Scenarios**: + +1. **Given** the main dashboard currently shows a rolling 12-month mileage trend and a rolling 12-month savings chart, **When** the refactor is complete, **Then** the main dashboard's behavior and appearance are unchanged (still rolling-window, not year-scoped). +2. **Given** the year stats dashboard passes a specific year into the mileage trend and savings chart components, **When** those components render, **Then** they display exactly the 12 months (Jan–Dec) of the selected year rather than a rolling window. +3. **Given** the difficulty and wind-resistance analytics charts exist on the advanced dashboard, **When** they are moved into reusable components, **Then** the year stats dashboard can also display year-scoped versions of these charts using the same components. + +--- + +### User Story 3 - Navigate to the year stats dashboard (Priority: P3) + +As a rider, I want to find and open the year stats dashboard from the app's navigation, so I don't need to know a direct URL to access it. + +**Why this priority**: Discoverability matters but the page is usable via direct link/testing even before nav wiring is done; it's a smaller polish item. + +**Independent Test**: Can be tested by checking that a link/menu entry to the year stats dashboard exists in the app's navigation and that clicking it opens the new page. + +**Acceptance Scenarios**: + +1. **Given** I am on any authenticated page, **When** I open the app's navigation menu, **Then** I see an entry for the year stats dashboard alongside the existing dashboard and advanced dashboard links. +2. **Given** I click the year stats dashboard nav entry, **When** the page loads, **Then** I land on the year stats dashboard with a year already selected and charts rendered. + +### Edge Cases + +- What happens when the selected year is the current, in-progress year (fewer than 12 months of data)? Charts should show only the months that have elapsed/have data, not project or fake future months. +- How does the system handle a year selection for a year before the user's account existed or before any rides were logged? Show the "no data for this year" state described in User Story 1. +- What happens if ride settings (e.g., mileage rate, MPG) changed partway through the selected year? Charts must use the same historical ride-setting snapshot rules already used elsewhere in the app, so savings figures for past years remain accurate regardless of later setting changes. +- What year range should the selector offer? Only years for which the user has at least one ride recorded (or the current year, if the user has no rides yet), so the user never picks an empty year unintentionally. +- What happens if the user has rides in only one year? The year selector should still function but effectively offer a single choice. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide a new, dedicated "Year Stats Dashboard" page, reachable from the app's primary navigation. +- **FR-002**: The Year Stats Dashboard MUST include a year selector control that lists selectable years, limited to years in which the user has at least one recorded ride (plus the current year as a fallback default when no rides exist). +- **FR-003**: When a year is selected, the Year Stats Dashboard MUST display the same categories of charts currently available on the existing dashboards (mileage trend, savings breakdown, and difficulty/wind analytics), scoped to only that year's data. +- **FR-004**: The existing mileage trend, savings breakdown, and difficulty/wind analytics charts MUST be refactored into standalone, reusable components that accept a year (or equivalent date-range) as an input parameter. +- **FR-005**: The refactored chart components MUST continue to support the main dashboard's existing rolling-window (non-year-scoped) behavior with no visible change to current dashboard users. +- **FR-006**: The Year Stats Dashboard MUST use the same historical ride-setting snapshot rules as the existing dashboards, so savings and cost figures for past years reflect the settings in effect at that time, not current settings. +- **FR-007**: When the selected year has no ride data, each chart on the Year Stats Dashboard MUST show an explicit empty/no-data state rather than an error or a misleading blank chart. +- **FR-008**: The Year Stats Dashboard MUST default to the current year on first load, falling back to the most recent year containing ride data if the current year has none. +- **FR-009**: Changing the selected year MUST update all charts on the page in place, without a full page reload or navigation. + +### Key Entities + +- **Ride**: An existing entity representing a single logged bike ride, including its date, distance, duration, and related metrics; used to compute all charts, filtered by year. +- **Ride Setting Snapshot**: An existing historical record of settings (e.g., mileage rate, MPG) in effect at the time of a ride; used to keep savings calculations for past years accurate. +- **Selected Year**: The year currently chosen by the user on the Year Stats Dashboard; drives which subset of ride data is passed into each chart component. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can switch the displayed year and see all charts update to that year's data in under 2 seconds. +- **SC-002**: 100% of chart types available on the existing dashboards are also available in year-scoped form on the Year Stats Dashboard. +- **SC-003**: The main dashboard's existing charts show no behavioral or visual regression after the component refactor, verified by side-by-side comparison before and after the change. +- **SC-004**: Users can locate and open the Year Stats Dashboard from the app's navigation without any external instructions, on their first attempt. + +## Assumptions + +- The Year Stats Dashboard is available to any authenticated user who can already access the main dashboard; no new permission tier is introduced. +- "Charts" in scope for this feature are the mileage trend chart, the savings breakdown chart, and the difficulty/wind-resistance analytics charts currently found on the main and advanced dashboards; summary cards and status panels (non-chart widgets) are out of scope for year-scoping unless trivially reused. +- The year selector offers whole calendar years (Jan–Dec) as the unit of comparison, consistent with how "yearly" windows are already used elsewhere in the app (e.g., advanced dashboard's yearly breakdown). +- Data volume per user is small enough that filtering a full year of rides client-side or via existing APIs will not introduce meaningful performance concerns beyond the 2-second target in SC-001. +- No new backend data is required; the feature reuses existing ride and ride-setting-snapshot data already available to the current dashboards. From f2ac93b86f519abb0c46116835bb4d9b5ca5e4ab Mon Sep 17 00:00:00 2001 From: aligneddev Date: Thu, 2 Jul 2026 21:47:30 +0000 Subject: [PATCH 2/7] plan and implementation, need to test --- .github/copilot-instructions.md | 2 +- .../contracts/year-stats-dashboard-api.md | 105 +++++ specs/026-year-stats-dashboard/data-model.md | 83 ++++ specs/026-year-stats-dashboard/plan.md | 190 +++++++++ specs/026-year-stats-dashboard/quickstart.md | 48 +++ specs/026-year-stats-dashboard/research.md | 43 ++ specs/026-year-stats-dashboard/tasks.md | 266 +++++++++++++ .../GetYearStatsDashboardServiceTests.cs | 368 ++++++++++++++++++ .../Endpoints/DashboardEndpointsTests.cs | 99 +++++ .../Dashboard/GetYearStatsDashboardService.cs | 330 ++++++++++++++++ .../Contracts/DashboardContracts.cs | 48 +++ .../Endpoints/DashboardEndpoints.cs | 70 ++++ src/BikeTracking.Api/Program.cs | 1 + src/BikeTracking.Frontend/src/App.tsx | 2 + .../src/components/app-header/app-header.tsx | 8 + .../dashboard-chart-section.test.tsx | 55 +++ .../dashboard/dashboard-chart-section.tsx | 7 +- .../dashboard/year-selector.test.tsx | 43 ++ .../components/dashboard/year-selector.tsx | 32 ++ .../pages/dashboard/dashboard-page.test.tsx | 36 +- .../year-stats-dashboard-page.css | 102 +++++ .../year-stats-dashboard-page.test.tsx | 154 ++++++++ .../year-stats-dashboard-page.tsx | 165 ++++++++ .../services/dashboard-api-year-stats.test.ts | 81 ++++ .../src/services/dashboard-api.ts | 86 ++++ .../tests/e2e/year-stats-dashboard.spec.ts | 90 +++++ 26 files changed, 2510 insertions(+), 4 deletions(-) create mode 100644 specs/026-year-stats-dashboard/contracts/year-stats-dashboard-api.md create mode 100644 specs/026-year-stats-dashboard/data-model.md create mode 100644 specs/026-year-stats-dashboard/plan.md create mode 100644 specs/026-year-stats-dashboard/quickstart.md create mode 100644 specs/026-year-stats-dashboard/research.md create mode 100644 specs/026-year-stats-dashboard/tasks.md create mode 100644 src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs create mode 100644 src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs create mode 100644 src/BikeTracking.Frontend/src/components/dashboard/dashboard-chart-section.test.tsx create mode 100644 src/BikeTracking.Frontend/src/components/dashboard/year-selector.test.tsx create mode 100644 src/BikeTracking.Frontend/src/components/dashboard/year-selector.tsx create mode 100644 src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.css create mode 100644 src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.test.tsx create mode 100644 src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.tsx create mode 100644 src/BikeTracking.Frontend/src/services/dashboard-api-year-stats.test.ts create mode 100644 src/BikeTracking.Frontend/tests/e2e/year-stats-dashboard.spec.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 38165c0..9b8e3ec 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/025-monthly-summary-import/plan.md](../specs/025-monthly-summary-import/plan.md) +[specs/026-year-stats-dashboard/plan.md](../specs/026-year-stats-dashboard/plan.md) diff --git a/specs/026-year-stats-dashboard/contracts/year-stats-dashboard-api.md b/specs/026-year-stats-dashboard/contracts/year-stats-dashboard-api.md new file mode 100644 index 0000000..40e2b1d --- /dev/null +++ b/specs/026-year-stats-dashboard/contracts/year-stats-dashboard-api.md @@ -0,0 +1,105 @@ +# API Contract: Year Stats Dashboard + +Base path: `/api/dashboard` (extends existing `DashboardEndpoints.cs` route group). All routes require authentication (existing `"sub"` claim → `riderId` resolution pattern) and return `401 Unauthorized` if the claim is missing/invalid, matching `/api/dashboard` and `/api/dashboard/advanced`. + +## `GET /api/dashboard/year-stats/years` + +Returns the list of years selectable in the year selector. + +**Auth**: Required (cookie/session, same as other dashboard endpoints). + +**Request**: No parameters. + +**Response `200 OK`**: +```json +{ + "years": [2026, 2025, 2024] +} +``` +- `years` is descending, contains only years with ≥1 recorded ride for the authenticated rider. +- If the rider has zero rides, `years` is `[]` (fallback default per FR-002/FR-008). + +**Response `401 Unauthorized`**: same shape as existing dashboard endpoints' unauthorized response. + +--- + +## `GET /api/dashboard/year-stats?year={yyyy}` + +Returns the year-scoped dashboard: mileage trend, savings breakdown, difficulty-by-month, most-difficult-months, and wind resistance distribution — all limited to the given calendar year. + +**Auth**: Required. + +**Query parameters**: +| Name | Type | Required | Notes | +|------|------|----------|-------| +| `year` | integer | Yes | 4-digit calendar year. Must satisfy `1900 <= year <= currentYear + 1`. | + +**Response `200 OK`** (`YearStatsDashboardResponse`, see `data-model.md` for full field descriptions): +```json +{ + "year": 2025, + "hasDataForYear": true, + "mileageByMonth": [ + { "monthKey": "2025-01", "label": "Jan", "miles": 142.5 }, + { "monthKey": "2025-02", "label": "Feb", "miles": 98.0 } + // ... 12 entries total, Jan..Dec of `year` + ], + "savingsByMonth": [ + { + "monthKey": "2025-01", + "label": "Jan", + "mileageRateSavings": 12.34, + "fuelCostAvoided": 5.10, + "combinedSavings": 17.44 + } + // ... 12 entries total + ], + "difficulty": { + "hasData": true, + "overallAverageDifficulty": 3.2, + "byMonth": [ + { "monthKey": "2025-01", "label": "Jan", "averageDifficulty": 2.8 } + ], + "mostDifficultMonths": [ + { "monthKey": "2025-07", "label": "Jul", "averageDifficulty": 4.1 } + ] + }, + "windResistance": { + "hasData": true, + "bins": [ + { "label": "Headwind", "count": 12 }, + { "label": "Tailwind", "count": 9 }, + { "label": "Crosswind", "count": 20 } + ] + } +} +``` + +**Empty-year example** (`hasDataForYear: false`): +```json +{ + "year": 2019, + "hasDataForYear": false, + "mileageByMonth": [ /* 12 entries, all miles: 0 */ ], + "savingsByMonth": [ /* 12 entries, all savings fields null */ ], + "difficulty": { "hasData": false, "overallAverageDifficulty": null, "byMonth": [], "mostDifficultMonths": [] }, + "windResistance": { "hasData": false, "bins": [] } +} +``` + +**Response `400 Bad Request`**: `year` missing, non-numeric, or out of bounds. + +**Response `401 Unauthorized`**: same as other dashboard endpoints. + +--- + +## Consumers + +- `year-stats-dashboard-page.tsx` calls `getAvailableYears()` on mount, then `getYearStatsDashboard(selectedYear)` whenever `selectedYear` changes. +- `dashboard-chart-section.tsx` consumes `mileageByMonth` / `savingsByMonth` via its existing props shape (field names kept 1:1 with `DashboardMileagePoint`/`DashboardSavingsPoint` naming conventions to minimize prop-mapping code). +- `DifficultyAnalyticsSection.tsx` consumes `difficulty` + `windResistance` via the same `section`-shaped prop it already accepts from the advanced dashboard, adapted 1:1 by the new page. + +## Non-goals + +- No write/mutation endpoints are introduced. +- No changes to `GET /api/dashboard` or `GET /api/dashboard/advanced` response shapes or behavior. diff --git a/specs/026-year-stats-dashboard/data-model.md b/specs/026-year-stats-dashboard/data-model.md new file mode 100644 index 0000000..c4f1ddb --- /dev/null +++ b/specs/026-year-stats-dashboard/data-model.md @@ -0,0 +1,83 @@ +# Phase 1 Data Model: Year Stats Dashboard + +This feature introduces **no new persisted entities and no schema migrations**. It is a read-only projection over existing data. This document describes the entities read, the new response shapes returned by the API, and validation/state rules. + +## Existing Entities Read (unchanged) + +### `RideEntity` (`src/BikeTracking.Api/Infrastructure/Persistence/Entities`) +Fields relevant to this feature (all pre-existing, no changes): +- `RiderId: long` — scoping filter. +- `RideDateTimeLocal: DateTime` — used to bucket rides into the selected year's Jan–Dec months, and to compute the distinct-years list for the year selector. +- `Miles: decimal` — summed per month for the mileage trend chart. +- `SnapshotMileageRateCents: decimal?` — historical mileage-rate-in-effect at ride time; used unchanged for mileage-rate savings (FR-006). +- `SnapshotAverageCarMpg: decimal?` — historical average car MPG in effect at ride time; used unchanged for fuel-cost-avoided/gallons-avoided savings (FR-006). +- `GasPricePerGallon: decimal?` — used with `SnapshotAverageCarMpg` for fuel-cost-avoided. +- Difficulty/wind fields (`Difficulty`, `WindResistanceRating`, or equivalent wind-direction/speed inputs) already consumed by `GetAdvancedDashboardService` and `AdvancedDashboardCalculations.fs` — read as-is, no changes. + +### `UserSettingsEntity` +- Not used for historical savings math (that comes from per-ride snapshots), but may still be read if the year-stats page reuses any current-settings-driven suggestion/goal widgets in the future. Out of scope for this feature's charts. + +## New Response DTOs (`src/BikeTracking.Api/Contracts`) + +These mirror the naming/shape conventions of existing `DashboardMileagePoint` / `DashboardSavingsPoint` / advanced-dashboard difficulty DTOs, adding an explicit year and empty-state signal. + +```text +YearStatsDashboardResponse +├── Year: int # the year this response describes +├── HasDataForYear: bool # true if the rider has ≥1 ride in this year +├── MileageByMonth: YearStatsMileagePoint[] # always 12 entries, Jan..Dec of Year +├── SavingsByMonth: YearStatsSavingsPoint[] # always 12 entries, Jan..Dec of Year +├── Difficulty: YearStatsDifficultySection # difficulty-by-month + most-difficult-months +└── WindResistance: YearStatsWindResistanceSection # wind resistance distribution bins + +YearStatsMileagePoint +├── MonthKey: string # "yyyy-MM", e.g. "2026-03" +├── Label: string # "Mar" +└── Miles: decimal # 0 for months with no rides (not omitted) + +YearStatsSavingsPoint +├── MonthKey: string +├── Label: string +├── MileageRateSavings: decimal? # null if no qualifying ride that month +├── FuelCostAvoided: decimal? # null if no qualifying ride that month +└── CombinedSavings: decimal? # null if both components null + +YearStatsDifficultySection +├── HasData: bool +├── OverallAverageDifficulty: decimal? +├── ByMonth: YearStatsDifficultyByMonthPoint[] # only months with ≥1 difficulty-rated ride +└── MostDifficultMonths: YearStatsDifficultyByMonthPoint[] # top N, existing ranking rule reused + +YearStatsDifficultyByMonthPoint +├── MonthKey: string +├── Label: string +└── AverageDifficulty: decimal + +YearStatsWindResistanceSection +├── HasData: bool +└── Bins: YearStatsWindResistanceBin[] # reuses WindResistance.fs bin shape + +YearStatsWindResistanceBin +├── Label: string # existing bin label convention (e.g. "Headwind", "Tailwind", "Crosswind") +└── Count: int + +AvailableYearsResponse +└── Years: int[] # distinct years descending; [currentYear] if rider has no rides +``` + +## Validation Rules + +- `year` query parameter (on `GET /api/dashboard/year-stats?year={yyyy}`): + - MUST parse as a 4-digit integer. + - MUST be within a sane bound (e.g., `1900 <= year <= currentYear + 1`) — reject with `400 Bad Request` otherwise (mirrors existing `from`/`to` parsing style in `RidesEndpoints.cs`). +- No rider input is written; this is a pure read/aggregation feature, so no write-side validation, no audit log entries, and no state transitions apply. + +## State / Empty-State Rules + +- A month within the selected year with zero rides still appears in `MileageByMonth`/`SavingsByMonth` with `Miles: 0` / null savings fields — it is **not omitted** from the 12-entry array, so the frontend can render a flat/zero bar rather than a gap (matches the in-progress-year edge case: elapsed months with real zero mileage vs. future months are visually indistinguishable by design, since the spec only requires not fabricating projected data, not distinguishing "zero" from "future"). +- `HasDataForYear = false` when the rider has zero rides in the selected year at all — the frontend renders the FR-007 "no data for this year" state for every chart section in that case instead of a set of empty bars. +- `Difficulty.HasData` / `WindResistance.HasData` are independently false when the rider has rides that year but none have difficulty/wind data recorded — allowing partial empty-states (e.g., mileage/savings render normally while difficulty section shows "no data"). + +## Relationships + +No new relationships. All data flows one-directional: `RideEntity` (existing) → year filter (new service) → existing F# pure functions (unchanged) → new response DTOs (new) → existing/threaded React chart components (extended with `year` prop). diff --git a/specs/026-year-stats-dashboard/plan.md b/specs/026-year-stats-dashboard/plan.md new file mode 100644 index 0000000..c7ec585 --- /dev/null +++ b/specs/026-year-stats-dashboard/plan.md @@ -0,0 +1,190 @@ +# Implementation Plan: Year Stats Dashboard + +**Branch**: `026-year-stats-dashboard` | **Date**: 2026-07-02 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/026-year-stats-dashboard/spec.md` + +## Summary + +Add a new, navigable "Year Stats Dashboard" page that lets a rider pick a calendar year and see the same chart types already shown on the main dashboard (mileage trend, savings breakdown) and the advanced dashboard (difficulty by month, most-difficult months, wind resistance distribution), scoped to exactly that year (Jan–Dec) instead of a rolling 12-month or all-time window. The enabling refactor extracts the existing chart-rendering pieces out of `dashboard-chart-section.tsx` and `DifficultyAnalyticsSection.tsx` into standalone, presentational components that already only consume props — so no rendering-layer extraction is required — and instead adds a new backend endpoint + service (`GetYearStatsDashboardService`) that reshapes the *same* F# domain calculations and EF Core query patterns already used by `GetDashboardService`/`GetAdvancedDashboardService`, but filtered to a specific year and iterating Jan–Dec instead of a rolling window or lifetime buckets. No new domain calculation logic is needed; existing pure F# functions (`AdvancedDashboardCalculations.fs`, `WindResistance.fs`) and per-ride snapshot fields (`SnapshotMileageRateCents`, `SnapshotAverageCarMpg`) are reused unchanged for historical accuracy. + +## Technical Context + +**Language/Version**: .NET 10 (C# 13 backend), F# 9 (domain layer), TypeScript 5.x (React 19 frontend) + +**Primary Dependencies**: ASP.NET Core Minimal API, Entity Framework Core 9 (SQLite), Recharts (frontend charting, already in use by `dashboard-chart-section.tsx` / `DifficultyAnalyticsSection.tsx`), xUnit (backend/domain tests), Vitest + React Testing Library (frontend unit tests), Playwright (E2E) + +**Storage**: SQLite via existing EF Core entities (`RideEntity`, `UserSettingsEntity`, `RideDifficultySnapshot`-equivalent fields). No schema changes — the feature is read-only and reuses existing columns (`RideDateTimeLocal`, `SnapshotMileageRateCents`, `SnapshotAverageCarMpg`, difficulty/wind fields already read by `GetAdvancedDashboardService`). + +**Testing**: `dotnet test BikeTracking.slnx` (new `GetYearStatsDashboardServiceTests`), `cd src/BikeTracking.Frontend && npm run test:unit` (new component/page tests), `npm run test:e2e` (new year-stats-dashboard E2E spec) — per `docs/agent/testing-and-quality-gates.md` verification matrix (cross-layer change → all three required). + +**Target Platform**: Local-first desktop web app (SQLite, .NET Aspire host, Tauri sidecar packaging). No new external dependency. + +**Project Type**: Web-service with React frontend (existing app extension) — Option 2 layout, matching `018-advanced-dashboard` and `025-monthly-summary-import`. + +**Performance Goals**: Year switch must update all charts in under 2 seconds (SC-001). A single year's rides for one rider is a small, indexed, `RiderId`-filtered query (same shape as existing dashboard queries) — no pagination or async job needed. + +**Constraints**: Must not change the main dashboard's or advanced dashboard's existing rolling-window/all-time behavior (FR-005, SC-003) — achieved by adding new year-scoped code paths rather than modifying existing rolling-window enumerations. Must reuse ride-setting snapshot fields for historical accuracy (FR-006) — no recomputation against current settings. + +**Scale/Scope**: Single rider per request. One year of rides is at most a few hundred rows. Three chart categories (mileage trend, savings breakdown, difficulty/wind analytics) plus a year selector. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Directive | Status | Notes | +|-----------|--------|-------| +| DevContainer environment | ✅ PASS | No tooling changes; work occurs inside existing DevContainer | +| Trunk-based delivery + PR flow | ✅ PASS | Feature branch `026-year-stats-dashboard`; PR-only merge | +| TDD mandatory — failing-test proof first | ✅ PASS | Plan requires failing `GetYearStatsDashboardServiceTests` and frontend component tests before implementation | +| E2E required on every PR | ✅ PASS | New `year-stats-dashboard.spec.ts` Playwright test is a delivery gate | +| Ports-and-adapters + ACL | ✅ PASS | New `GetYearStatsDashboardService` follows the same Application-layer service pattern as `GetDashboardService`/`GetAdvancedDashboardService`; no new external integration | +| Result-style domain outcomes | ✅ PASS | No new F# domain functions needed; existing pure functions already return `option`/typed results. "No data for year" is represented as an explicit empty-state DTO field, not an exception | +| Transactional relational + explicit audit logs | ✅ PASS (N/A) | Read-only feature; no writes, no audit log needed | +| Local-first runtime posture | ✅ PASS | SQLite only; no cloud dependency added | + +**Post-design re-check (Phase 1)**: All gates remain green. The design adds one read-only endpoint, one application service, and frontend components/pages only. No existing endpoint, service, or F# module signature changes — `GetDashboardService` and `GetAdvancedDashboardService` are untouched, satisfying FR-005/SC-003. + +## Project Structure + +### Documentation (this feature) + +```text +specs/026-year-stats-dashboard/ +├── plan.md # This file +├── research.md # Phase 0 findings (resolved unknowns) +├── data-model.md # Response shape, entities touched, no schema changes +├── contracts/ +│ └── year-stats-dashboard-api.md # API endpoint contract +├── quickstart.md # Validation guide (run guide, not implementation) +└── tasks.md # Phase 2 output — generated by /speckit.tasks +``` + +### Source Code (repository root) + +```text +src/BikeTracking.Api/ +├── Application/ +│ └── Dashboard/ +│ ├── GetDashboardService.cs # UNCHANGED — rolling-window main dashboard +│ ├── GetAdvancedDashboardService.cs # UNCHANGED — all-time/calendar-window advanced dashboard +│ └── GetYearStatsDashboardService.cs # NEW — year-scoped mileage/savings/difficulty/wind +├── Contracts/ +│ └── DashboardContracts.cs # EXTEND — add YearStatsDashboardResponse + related DTOs +│ # (or new YearStatsDashboardContracts.cs if the +│ # existing file conventions favor a new file) +└── Endpoints/ + └── DashboardEndpoints.cs # EXTEND — add GET /api/dashboard/year-stats?year={yyyy} + # and GET /api/dashboard/year-stats/years (available years) + +src/BikeTracking.Api.Tests/ +└── Application/ + └── Dashboard/ + └── GetYearStatsDashboardServiceTests.cs # NEW — year filtering, empty-year state, snapshot accuracy + +src/BikeTracking.Domain.FSharp/ +# NO NEW FILES — AdvancedDashboardCalculations.fs and WindResistance.fs are reused unchanged; +# year-filtering of the ride list happens in the C# application service before calling into them. + +src/BikeTracking.Frontend/src/ +├── pages/ +│ └── year-stats-dashboard/ +│ ├── year-stats-dashboard-page.tsx # NEW — page: year selector + composed charts +│ └── year-stats-dashboard-page.test.tsx # NEW +├── components/ +│ └── dashboard/ +│ ├── dashboard-chart-section.tsx # EXTEND — accept optional `year`/`seriesLabel` props +│ │ # so it can render either rolling-window (existing, +│ │ # default) or year-scoped (new) series without +│ │ # behavior change when `year` is omitted +│ └── year-selector.tsx # NEW — reusable year ` or accessible listbox pattern), no `any` types. +- `dashboard-api.ts` (or new `year-stats-dashboard-api.ts` if the existing file's conventions favor per-feature files, matching `monthly-import-api.ts`): `getYearStatsDashboard(year: number): Promise>`, `getAvailableYears(): Promise>` — typed request/response interfaces mirrored from the C# contracts, no `any`. + +### Phase 4 — Frontend: Chart Component Reuse + New Page + +**Scope**: Thread a `year` capability through existing chart-rendering pieces and compose the new page. + +- `dashboard-chart-section.tsx`: add optional props (e.g., `title` overrides and a `year`-scoped data source) so the same mileage/savings chart rendering serves both the rolling 12-month main dashboard (default, unchanged) and a fixed Jan–Dec year view — verified by an existing/updated `dashboard-page.test.tsx` snapshot-equivalent assertion proving no visual/behavioral change (SC-003). +- `DifficultyAnalyticsSection.tsx`: reused unchanged — the new page simply passes it a year-scoped `section` prop fetched from the new endpoint. +- `year-stats-dashboard-page.tsx`: composes `year-selector.tsx` + (year-scoped) `dashboard-chart-section.tsx` + `DifficultyAnalyticsSection.tsx`; on mount, fetches available years, defaults to current year or most recent year with data (FR-008), fetches year-stats data on year change (`useEffect` keyed on `selectedYear`), renders in-place updates without navigation (FR-009), and renders an explicit empty state per chart when `HasDataForYear`/section-level empty flags are false (FR-007). +- `App.tsx`: register `} />` inside the existing `` block. +- `app-header.tsx`: add a `NavLink` to `/dashboard/year-stats` alongside the existing Dashboard/Advanced Stats links (FR-001, User Story 3). + +### Phase 5 — Tests (E2E Gate) + +`year-stats-dashboard.spec.ts` Playwright scenarios: +1. Navigate via header nav link → lands on year stats dashboard with a year already selected and charts rendered (US3). +2. Default year is current year, or most recent year with data if current year is empty (FR-008). +3. Switching the year selector updates mileage, savings, and difficulty/wind charts in place, without a page navigation (FR-009). +4. Selecting a year with no ride data shows an explicit "no data for this year" state per chart, not an error or blank chart (FR-007, edge case). +5. Main dashboard (`/dashboard`) still renders its existing rolling 12-month charts unchanged after the refactor (regression check for FR-005/SC-003). + +## Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| New service vs. modifying existing dashboard services | New `GetYearStatsDashboardService`, existing services untouched | Directly protects FR-005/SC-003 (no regression to rolling-window/all-time behavior); keeps each service single-purpose per `architecture-principles.md` ("prefer small composable services") | +| Year filtering location | C# application service (LINQ filter before calling F# functions) | F# domain functions (`calculateDifficultyByMonth`, etc.) already accept a caller-supplied list — filtering by year is a query/orchestration concern, not a domain rule, so no F# signature changes are needed | +| Chart component extraction | Thread `year` as an optional prop through existing components rather than physically relocating files | `dashboard-chart-section.tsx` and `DifficultyAnalyticsSection.tsx` are already presentational/props-only (per exploration), so the "extraction" the spec asks for (FR-004) is satisfied by parameterization, avoiding needless file churn while still enabling reuse | +| Empty-year representation | Explicit `HasDataForYear`/per-section empty boolean in the response DTO | Matches "Represent expected... outcomes with explicit... values" (domain-and-error-handling.md); avoids the frontend guessing emptiness from zeroed numbers, satisfying FR-007 | +| Historical accuracy | Reuse per-ride `SnapshotMileageRateCents`/`SnapshotAverageCarMpg` fields exactly as `GetDashboardService` does | Directly satisfies FR-006 and the constitution's audit/accuracy posture; no new snapshot mechanism needed since it already exists | +| Available years source | Query distinct `RideDateTimeLocal.Year` from the rider's own rides, fallback to current year | Matches FR-002/FR-008 exactly; avoids exposing years with no data as selectable, preventing an "empty by surprise" selection | +| New endpoints under `/api/dashboard/year-stats` | Sibling routes to `/api/dashboard` and `/api/dashboard/advanced` | Consistent with existing route grouping in `DashboardEndpoints.cs`; keeps all dashboard-flavored endpoints in one file/route group | diff --git a/specs/026-year-stats-dashboard/quickstart.md b/specs/026-year-stats-dashboard/quickstart.md new file mode 100644 index 0000000..fa1e3cc --- /dev/null +++ b/specs/026-year-stats-dashboard/quickstart.md @@ -0,0 +1,48 @@ +# Quickstart: Year Stats Dashboard + +Validation guide for confirming the Year Stats Dashboard feature works end-to-end once implemented. See `contracts/year-stats-dashboard-api.md` for exact request/response shapes and `data-model.md` for field definitions. + +## Prerequisites + +- Local stack running: `dotnet run --project src/BikeTracking.AppHost` (starts API + serves frontend per Aspire dev workflow). +- A test rider account with ride data spanning at least two different calendar years, including: + - At least one year with a full 12 months of rides (to verify normal rendering). + - The current year with only some months populated (to verify the in-progress-year edge case). + - Knowledge of at least one year with **zero** rides for that rider (to verify the empty state) — e.g., a year before the account existed. + +## Backend Validation + +1. Run backend tests: `dotnet test BikeTracking.slnx` — confirm new `GetYearStatsDashboardServiceTests` pass, and existing `GetDashboardServiceTests`/`GetAdvancedDashboardServiceTests` still pass unmodified (proves FR-005/SC-003 no-regression). +2. Manually call the new endpoints (once running) to sanity-check shapes: + ```bash + curl -s -b cookies.txt "http://localhost:/api/dashboard/year-stats/years" | jq + curl -s -b cookies.txt "http://localhost:/api/dashboard/year-stats?year=2025" | jq + curl -s -b cookies.txt "http://localhost:/api/dashboard/year-stats?year=1901" # expect 400 + ``` +3. Confirm a year with zero rides returns `hasDataForYear: false` with zero-filled month arrays, not a `500` or `404`. + +## Frontend Validation + +1. `cd src/BikeTracking.Frontend && npm run lint && npm run build && npm run test:unit` — confirm new `year-stats-dashboard-page.test.tsx` and any updated `dashboard-page.test.tsx` pass. +2. `npm run test:e2e` — confirm `year-stats-dashboard.spec.ts` passes, covering: + - Nav link → page load with a year pre-selected and charts rendered. + - Changing the year selector updates charts in place (no navigation/reload). + - Selecting a known-empty year shows the "no data for this year" state per chart. + - `/dashboard` (main dashboard) still shows its unchanged rolling 12-month charts. + +## Manual Exploratory Pass + +1. Log in as the test rider. +2. Open the app navigation menu → click "Year Stats" (or equivalent label) → confirm the page loads without needing a direct URL (SC-004). +3. Confirm the year selector defaults to the current year, or the most recent year with data if the current year is empty (FR-008). +4. Select the full-year dataset → confirm mileage trend, savings breakdown, difficulty-by-month, most-difficult-months, and wind-resistance charts all render Jan–Dec of that year (not a rolling window). +5. Select the in-progress current year → confirm only elapsed/data months show real values; future months are not fabricated (visually flat/zero, not misleading projections). +6. Select the known-empty year → confirm every chart shows an explicit empty state, not an error or blank area. +7. Time the year switch (selector change → charts updated) — should feel well under 2 seconds (SC-001) on local dev data volumes. +8. Navigate to `/dashboard` directly → confirm the main dashboard's mileage/savings charts still show the rolling 12-month window exactly as before this feature (SC-003 regression check). + +## Done Criteria + +- All backend and frontend automated checks above pass. +- All manual exploratory steps behave as described. +- No changes observed in `/dashboard` or `/dashboard/advanced` behavior compared to pre-feature baseline. diff --git a/specs/026-year-stats-dashboard/research.md b/specs/026-year-stats-dashboard/research.md new file mode 100644 index 0000000..d11a248 --- /dev/null +++ b/specs/026-year-stats-dashboard/research.md @@ -0,0 +1,43 @@ +# Phase 0 Research: Year Stats Dashboard + +All items below were "NEEDS CLARIFICATION" candidates from the Technical Context template. Each is resolved by direct inspection of the existing codebase rather than external research, since this feature reuses existing infrastructure end-to-end. + +## Decision 1: How should "year-scoped" differ from the existing rolling/calendar windows? + +- **Decision**: Year-scoped series are a fixed Jan 1–Dec 31 window for the *selected* year, built the same way `GetDashboardService.BuildMileageSeries`/`BuildSavingsSeries` build a rolling 12-month window — by enumerating 12 `DateTime` month buckets and summing ride data per bucket — except the enumeration starts at `new DateTime(year, 1, 1)` instead of `nowLocal.AddMonths(-11)`. +- **Rationale**: Keeps the same per-month aggregation shape and DTO structure the frontend chart components already consume (`MonthKey`, `Label`, `Miles`, `MileageRateSavings`, etc.), minimizing frontend changes to prop-threading only. +- **Alternatives considered**: Reusing `GetDashboardService.EnumerateRollingMonths` with a parameter — rejected because that method is anchored to "now" and used by production rolling-window behavior; changing its signature risks regressing FR-005/SC-003. A parallel, year-anchored enumeration in the new service avoids any shared-code risk to the existing dashboard. + +## Decision 2: Do the existing chart components need to be physically moved into a new "components" directory? + +- **Decision**: No physical move is required. `dashboard-chart-section.tsx` (mileage/savings charts) and `DifficultyAnalyticsSection.tsx` (difficulty/wind charts) already only consume props (`mileageByMonth`, `savingsByMonth`, `section`) with no internal data fetching or hooks — confirmed by direct inspection. "Extraction into reusable, year-parameterized components" (FR-004) is satisfied by adding an optional `year`/label prop to `dashboard-chart-section.tsx` for cosmetic differences (e.g., replacing "Rolling 12 Months" copy with the selected year) while leaving the rendering logic and `DifficultyAnalyticsSection.tsx` untouched. +- **Rationale**: Avoids unnecessary file churn and merge risk; satisfies the spec's intent (single source of truth for chart rendering, reused by two pages) without a large refactor. +- **Alternatives considered**: Moving chart internals into a new `src/components/charts/` directory and having both `dashboard-chart-section.tsx` and the new page import from there — rejected as unnecessary scope; the components are already reusable as-is, only their *data source* differs (rolling vs. year-scoped), which is a backend/service concern, not a component-location concern. + +## Decision 3: Where does year-filtering belong — F# domain or C# application service? + +- **Decision**: Year filtering of the rider's ride list happens in the new C# `GetYearStatsDashboardService`, before calling into existing F# pure functions (`AdvancedDashboardCalculations.calculateDifficultyByMonth`, `calculateWindResistanceDistribution`, `calculateOverallAverageDifficulty`). No F# module changes. +- **Rationale**: The F# functions already accept a caller-supplied `RideDifficultySnapshot list` / `RideSnapshot list` with no embedded time-window assumption — filtering by year is a data-selection/orchestration concern, not a domain calculation rule. This matches `docs/agent/architecture-principles.md` ("keep domain and application logic isolated... separate orchestration, business rules, and I/O concerns") and avoids growing the F# API surface for a concern that's inherently about "which rides to look at," not "how to calculate on rides." +- **Alternatives considered**: Adding a `year: int option` parameter to each F# function — rejected; it would require touching multiple existing, already-tested pure functions and their C# call sites (`GetAdvancedDashboardService`) purely to add a filter that can be applied identically and more simply by the C# caller with a single `.Where(...)`. + +## Decision 4: How should "no data for this year" be represented in the API contract? + +- **Decision**: Add explicit boolean flags to the response (e.g., `HasDataForYear` at the top level, or per-section flags if sections can independently be empty) rather than relying on the frontend to infer emptiness from zero-filled series. +- **Rationale**: `docs/agent/domain-and-error-handling.md` requires expected outcomes (here: "no rides this year" is an entirely expected, common case) to be represented as explicit data, not inferred or thrown. This also directly satisfies FR-007 ("MUST show an explicit empty/no-data state rather than an error or a misleading blank chart"). +- **Alternatives considered**: Returning `null` arrays and letting the frontend treat `null`/`[]` as "no data" — rejected; ambiguous with legitimate zero-mileage months within an otherwise active year, and less explicit than a dedicated flag. + +## Decision 5: How should the year selector's option list be sourced? + +- **Decision**: New `GetAvailableYearsAsync` on the same service queries `SELECT DISTINCT YEAR(RideDateTimeLocal)` (via LINQ `.Select(r => r.RideDateTimeLocal.Year).Distinct()`) for the rider, sorted descending, falling back to `[DateTime.Now.Year]` when the rider has no rides at all. +- **Rationale**: Directly matches FR-002/FR-008 and the spec's edge cases (never offer a year with zero data unless the rider has no data at all, in which case offer the current year as a usable fallback default). +- **Alternatives considered**: Computing available years client-side from an already-fetched full ride list — rejected; no such full-ride-list fetch exists on any dashboard page today (dashboards are pre-aggregated server-side), and doing so would pull unnecessary data across the wire. + +## Decision 6: Should the new endpoints live in `DashboardEndpoints.cs` or a new file? + +- **Decision**: Extend the existing `DashboardEndpoints.cs` with two new routes (`GET /api/dashboard/year-stats`, `GET /api/dashboard/year-stats/years`), following the same `MapGet(...).RequireAuthorization().WithName(...).WithSummary(...).Produces<...>()` chain already used for `/api/dashboard` and `/api/dashboard/advanced`. +- **Rationale**: All three dashboard flavors (main, advanced, year-scoped) are conceptually one route group; keeping them together matches the existing file's single-responsibility ("dashboard endpoints") and avoids fragmenting route registration across files for what is a small addition (2 routes). +- **Alternatives considered**: A dedicated `YearStatsDashboardEndpoints.cs` — rejected as unnecessary for two small GET routes; would be reconsidered only if the year-stats surface grows substantially (e.g., mutation endpoints). + +## Open Items + +None — all Technical Context unknowns resolved by direct codebase inspection; no external library research or spike required since the feature is additive and reuses existing patterns exclusively. diff --git a/specs/026-year-stats-dashboard/tasks.md b/specs/026-year-stats-dashboard/tasks.md new file mode 100644 index 0000000..7791fdb --- /dev/null +++ b/specs/026-year-stats-dashboard/tasks.md @@ -0,0 +1,266 @@ +# Tasks: Year Stats Dashboard + +**Feature Branch**: `026-year-stats-dashboard` +**Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) | **Data Model**: [data-model.md](data-model.md) | **Contracts**: [contracts/year-stats-dashboard-api.md](contracts/year-stats-dashboard-api.md) | **Research**: [research.md](research.md) | **Quickstart**: [quickstart.md](quickstart.md) +**Generated**: 2026-07-02 + +--- + +## Format + +``` +- [ ] [ID] [P?] [USn?] Description with exact file path +``` + +- **[P]** — parallelizable (independent file, no blocking dependency on incomplete tasks) +- **[USn]** — maps to a User Story from spec.md (US1–US3) +- TDD enforced per constitution directive 3: all tests in each phase are written and confirmed **failing** before the corresponding implementation task begins + +--- + +## User Stories + +| Story | Title | Priority | +|-------|-------|----------| +| US1 | View stats for a chosen year (year-scoped charts) | P1 🎯 MVP | +| US2 | Reuse existing chart components across pages (year-parameterized components) | P2 | +| US3 | Navigate to the year stats dashboard | P3 | + +--- + +## Phase 1: Setup + +**Purpose**: Declare new contracts and register route/page stubs so later phases compile against real types. No behavior change to `/api/dashboard` or `/api/dashboard/advanced`. + +- [X] T001 [P] Add `YearStatsDashboardResponse`, `YearStatsMileagePoint`, `YearStatsSavingsPoint`, `YearStatsDifficultySection`, `YearStatsDifficultyByMonthPoint`, `YearStatsWindResistanceSection`, `YearStatsWindResistanceBin`, and `AvailableYearsResponse` records to `src/BikeTracking.Api/Contracts/DashboardContracts.cs` — field names/shapes exactly per `data-model.md` (mirrors `DashboardMileagePoint`/`DashboardSavingsPoint` naming) +- [X] T002 [P] Create empty `year-stats-dashboard` page directory scaffolding: `src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.tsx` (stub component rendering `
Year Stats Dashboard
`) and `year-stats-dashboard-page.css` (empty) +- [X] T003 Register stub route `} />` inside the existing `` block in `src/BikeTracking.Frontend/src/App.tsx`, importing the stub from T002 + +**Checkpoint ✅**: `dotnet build BikeTracking.slnx` and `cd src/BikeTracking.Frontend && npm run build` both succeed. No existing endpoint/service/component behavior changed. + +--- + +## Phase 2: Tests (TDD Red Phase) + +**Purpose**: Write all tests first per constitution directive 3 ("TDD is mandatory: failing-test proof must be shown before implementation starts"). Confirm every test below **fails**, then proceed to Phase 3. + +> ⚠️ **STOP AFTER THIS PHASE. Run `dotnet test BikeTracking.slnx` and `npm run test:unit`. All tests below MUST fail (red) because `GetYearStatsDashboardService`, the endpoints, and the frontend components/page do not exist yet. Do not proceed to Phase 3 until the red checkpoint is confirmed.** + +### Backend Service Tests — US1 + +- [X] T004 [P] [US1] Write `GetYearStatsDashboardService_TypeExists`/`ExposesAsyncReadMethod` smoke tests plus `GetAsync` behavior tests in `src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs` — covers: year with a full 12 months of rides produces correct per-month `MileageByMonth`/`SavingsByMonth` (Jan–Dec of the *requested* year, not a rolling window); in-progress current year with partial data returns zero-filled `Miles`/null savings for elapsed-but-empty and future months (not fabricated); year with zero rides returns `HasDataForYear == false` with all 12 months zero/null-filled and no exception +- [X] T005 [P] [US1] Write savings-snapshot-accuracy regression test in `src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs` — seed a rider with a ride in a past year using an old `SnapshotMileageRateCents`/`SnapshotAverageCarMpg`, then change current `UserSettings`; assert `GetAsync` for that past year still returns savings computed from the ride's own snapshot fields, not current settings (regression-proves FR-006, mirrors `GetDashboardService_UsesRideSnapshotsForSavings_WhenCurrentSettingsChanged`) +- [X] T006 [P] [US1] Write difficulty/wind-resistance year-filtering tests in `src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs` — seed rides with difficulty/wind data across two different years; assert `Difficulty.ByMonth`/`Difficulty.OverallAverageDifficulty`/`WindResistance.Bins` for a requested year reflect only that year's rides; assert a rider with rides that year but none carrying difficulty/wind data yields `Difficulty.HasData == false` and `WindResistance.HasData == false` while `HasDataForYear == true` (partial empty-state per data-model.md) +- [X] T007 [P] [US1] Write `GetAvailableYearsAsync` tests in `src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs` — covers: rider with rides in 2023/2024/2025 returns `[2025, 2024, 2023]` descending, no duplicates; rider with zero rides returns `[currentYear]` (fallback default, FR-002/FR-008) + +### Backend Endpoint Tests — US1 + +- [X] T008 [P] [US1] Write `DashboardEndpointsTests` additions in `src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs` — `GET /api/dashboard/year-stats?year={yyyy}` returns `200` with `YearStatsDashboardResponse` for an authenticated rider; returns `400` for `year=1901` and for `year={currentYear + 2}` (out of `1900 <= year <= currentYear + 1` bound); returns `400` for non-numeric `year`; returns `401` when unauthenticated (missing/invalid `sub` claim) +- [X] T009 [P] [US1] Write `GET /api/dashboard/year-stats/years` endpoint tests in `src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs` — returns `200` with descending distinct years for a seeded rider; returns `[currentYear]` for a rider with no rides; returns `401` when unauthenticated + +### Frontend API Client Tests — US1 + +- [X] T010 [P] [US1] Write `dashboard-api.test.ts` additions (or new co-located test) in `src/BikeTracking.Frontend/src/services/dashboard-api.test.ts` — `getYearStatsDashboard(year)` calls `GET /api/dashboard/year-stats?year={year}` and returns typed `YearStatsDashboardResponse`; throws on non-OK response; `getAvailableYears()` calls `GET /api/dashboard/year-stats/years` and returns typed `AvailableYearsResponse` + +### Frontend Component Tests — US1 + US2 + +- [X] T011 [P] [US2] Write `year-selector.test.tsx` co-located at `src/BikeTracking.Frontend/src/components/dashboard/year-selector.test.tsx` — covers: renders one option per entry in `years` prop; renders `selectedYear` as the selected option; calls `onChange` with the numeric year when a new option is chosen; renders correctly with a single-year list (disabled-looking but still functional, per spec edge case "rides in only one year") +- [X] T012 [P] [US2] Extend `dashboard-chart-section` tests co-located at `src/BikeTracking.Frontend/src/components/dashboard/dashboard-chart-section.test.tsx` (create if it does not exist) — covers: default render (no `year`/`seriesLabel` props) shows "Rolling 12 months" copy exactly as before (regression proof for FR-005/SC-003, asserted against the existing rendered output of `dashboard-page.test.tsx`); passing a `year`/`seriesLabel` prop renders that label (e.g. "Calendar year 2025") instead of "Rolling 12 months"; chart data (`mileageByMonth`/`savingsByMonth`) renders identically regardless of which label prop is used +- [X] T013 [P] [US1] Write `year-stats-dashboard-page.test.tsx` in `src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.test.tsx` — covers: on mount, fetches available years then fetches year-stats for the default year (current year, or most recent year with data when current year has none, per FR-008); renders `YearSelector`, year-scoped `DashboardChartSection`, and `DifficultyAnalyticsSection`; changing the year selector re-fetches and re-renders charts in place without a route change (FR-009); selecting/loading a year with `hasDataForYear: false` renders an explicit "no data for this year" empty state instead of blank/errored charts (FR-007) +- [X] T014 [P] [US1] Confirm existing `dashboard-page.test.tsx` in `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx` still asserts the main dashboard's rolling-12-month charts render unchanged; add an explicit assertion (if missing) that `DashboardChartSection` is invoked without `year`/`seriesLabel` props on the main dashboard page (regression proof for SC-003) + +### E2E Scaffold — Gate + +- [X] T015 [US1] [US2] [US3] Write E2E test scaffold (5 failing scenarios) in `src/BikeTracking.Frontend/tests/e2e/year-stats-dashboard.spec.ts`: + 1. Navigate via header nav link "Year Stats" → lands on `/dashboard/year-stats` with a year already selected and charts rendered (US3, FR-001) + 2. Default year is the current year, or the most recent year with data if the current year is empty (FR-008) + 3. Switching the year selector updates mileage, savings, and difficulty/wind charts in place, without a page navigation/reload (FR-009, US1) + 4. Selecting a year with no ride data shows an explicit "no data for this year" state per chart, not an error or blank chart (FR-007, edge case) + 5. Main dashboard (`/dashboard`) still renders its existing rolling 12-month charts unchanged after the refactor — regression check for FR-005/SC-003 (US2) + +**Checkpoint 🔴**: `dotnet test BikeTracking.slnx` and `npm run test:unit` — all T004–T014 confirmed **FAILING**. `npm run test:e2e` confirms T015's 5 scenarios fail against the current app. Red checkpoint committed before Phase 3 begins. + +--- + +## Phase 3: Core (Backend — Make Phase 2 Backend Tests Green) + +**Purpose**: Implement the year-scoped application service and endpoints. No F# domain changes (per research.md Decision 3 — `AdvancedDashboardCalculations.fs`/`WindResistance.fs` reused unchanged). + +- [X] T016 [US1] Implement `GetYearStatsDashboardService.cs` in `src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs` — `GetAsync(riderId, year, cancellationToken)`: loads rides for `riderId` (`dbContext.Rides.Where(...).AsNoTracking()`, same pattern as `GetDashboardService`/`GetAdvancedDashboardService`), filters to `RideDateTimeLocal.Year == year`, enumerates a fixed `new DateTime(year, 1, 1)`-anchored 12-month Jan–Dec series (not `EnumerateRollingMonths`) reusing the same per-ride snapshot math (`SnapshotMileageRateCents`, `SnapshotAverageCarMpg`, `GasPricePerGallon`) as `GetDashboardService.BuildMileageSeries`/`BuildSavingsSeries`; sets `HasDataForYear = rides.Count > 0` (post year-filter) +- [X] T017 [US1] Extend `GetYearStatsDashboardService.cs` in `src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs` — build the difficulty/wind sections by projecting the year-filtered rides to `AdvancedDashboardCalculations.RideDifficultySnapshot` (same projection as `GetAdvancedDashboardService.BuildDifficultySection`) and calling the existing `calculateDifficultyByMonth`, `calculateWindResistanceDistribution`, `calculateOverallAverageDifficulty` F# functions unchanged; map F# `MonthNumber`/`MonthName` results to `MonthKey` (`"{year:D4}-{month:D2}"`) and 3-letter `Label` (not the full month name) to match `YearStatsDifficultyByMonthPoint`; set `Difficulty.HasData`/`WindResistance.HasData` independently per data-model.md's partial-empty-state rule +- [X] T018 [US1] Add `GetAvailableYearsAsync(riderId, cancellationToken)` to `src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs` — query `dbContext.Rides.Where(r => r.RiderId == riderId).Select(r => r.RideDateTimeLocal.Year).Distinct()` (or equivalent `AsNoTracking()` LINQ), sort descending, fall back to `[DateTime.Now.Year]` when the rider has no rides; return `AvailableYearsResponse` +- [X] T019 [US1] Register `GetYearStatsDashboardService` in the DI container in `src/BikeTracking.Api/Program.cs` alongside the existing `GetDashboardService`/`GetAdvancedDashboardService` registrations +- [X] T020 [US1] Extend `src/BikeTracking.Api/Endpoints/DashboardEndpoints.cs` — add `GET /api/dashboard/year-stats?year={yyyy}` handler: resolves `riderId` from the `"sub"` claim (same pattern as `GetDashboardAsync`), validates `year` is a 4-digit int within `1900 <= year <= currentYear + 1` (returns `400` via `Results.BadRequest` otherwise), calls `GetYearStatsDashboardService.GetAsync`; add `GET /api/dashboard/year-stats/years` handler calling `GetAvailableYearsAsync`; both `.RequireAuthorization()`, `.WithName(...)`, `.WithSummary(...)`, `.Produces(200)`/`.Produces(200)`, `.Produces(401)`, matching existing endpoint conventions in the same file — no changes to the existing `/api/dashboard` or `/api/dashboard/advanced` handlers + +**Checkpoint 🟢**: `dotnet test BikeTracking.slnx` — T004–T009 green. Existing `GetDashboardServiceTests`/`GetAdvancedDashboardServiceTests` still pass unmodified (proves FR-005/SC-003 no backend regression). + +--- + +## Phase 4: User Story 1 - View Stats for a Chosen Year (Priority: P1) 🎯 MVP + +**Goal**: A rider can open the year stats dashboard, pick a year, and see mileage/savings/difficulty/wind charts scoped to exactly that calendar year. + +**Independent Test**: Navigate to the year stats dashboard page (direct URL is sufficient for this story), select a year from the year selector, and confirm all charts render data limited to that year only, including the explicit empty state for a year with zero rides. + +### Frontend API Client — US1 + +- [X] T021 [US1] Add `getYearStatsDashboard(year: number): Promise` and `getAvailableYears(): Promise` to `src/BikeTracking.Frontend/src/services/dashboard-api.ts` — add matching `YearStatsDashboardResponse`, `YearStatsMileagePoint`, `YearStatsSavingsPoint`, `YearStatsDifficultySection`, `YearStatsDifficultyByMonthPoint`, `YearStatsWindResistanceSection`, `YearStatsWindResistanceBin`, `AvailableYearsResponse` TypeScript interfaces mirrored 1:1 from the C# contracts (T001); follow the existing `fetch` + `getAuthHeaders()` + throw-on-non-OK pattern already used by `getDashboard()`, no `ApiResponse` wrapper (matches this file's convention, not the `monthly-import-api.ts` wrapper convention) + +### Frontend Components — US1 (depends on Phase 5 for chart reuse) + +- [X] T022 [US1] Implement `year-stats-dashboard-page.tsx` in `src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.tsx` (replacing the T002 stub) — on mount, calls `getAvailableYears()`, sets `selectedYear` to the current year if present in the list, else the most recent year in the list (FR-008); `useEffect` keyed on `selectedYear` calls `getYearStatsDashboard(selectedYear)` and updates state without navigation (FR-009); renders `YearSelector` (years, selectedYear, onChange), a year-scoped `DashboardChartSection` (passing `year`/`seriesLabel="Calendar year {selectedYear}"`), and `DifficultyAnalyticsSection` fed by an adapter that maps the response's `Difficulty`/`WindResistance` sections into the `AdvancedDashboardDifficultySection` shape `DifficultyAnalyticsSection` already expects (`isEmpty`, `difficultyByMonth[].monthNumber/monthName`, `mostDifficultMonths`, `windResistanceDistribution[].rating/rideCount/label/isAssisted`); when `hasDataForYear` is `false`, renders an explicit "No ride data for {selectedYear}" empty state instead of the chart sections (FR-007) +- [X] T023 [P] [US1] Style `year-stats-dashboard-page.css` in `src/BikeTracking.Frontend/src/pages/year-stats-dashboard/year-stats-dashboard-page.css` — hero/header layout consistent with `advanced-dashboard-page.css`/`dashboard-page.css`; empty-state styling for the no-data-for-year state; mobile-first responsive layout per repo convention + +**Checkpoint**: User Story 1 is fully functional via direct URL navigation to `/dashboard/year-stats` — year selection, year-scoped charts, and empty-state all work independently of US2/US3 wiring tasks below (though it depends on the Phase 5 component-reuse tasks being complete). + +--- + +## Phase 5: User Story 2 - Reuse Existing Chart Components (Priority: P2) + +**Goal**: The mileage/savings and difficulty/wind chart components are parameterized by year/label so the same rendering code serves both the rolling-window main dashboard (unchanged) and the year-scoped year stats dashboard, with no duplication. + +**Independent Test**: Verify the main dashboard still renders identically (same charts, same rolling-window "Rolling 12 months" copy and behavior) after the refactor, and that the year stats dashboard renders using the same underlying `DashboardChartSection`/`DifficultyAnalyticsSection` components with a `year`-scoped data source. + +- [X] T024 [US2] Extend `DashboardChartSection` props in `src/BikeTracking.Frontend/src/components/dashboard/dashboard-chart-section.tsx` — add optional `seriesLabel?: string` prop (default `'Rolling 12 months'`) rendered in place of the hardcoded `

Rolling 12 months

` text in both the mileage and savings chart card headers; no other rendering-logic changes, so the main dashboard's default (unchanged) call sites continue to render identically (FR-005) +- [X] T025 [US2] Create `year-selector.tsx` in `src/BikeTracking.Frontend/src/components/dashboard/year-selector.tsx` — presentational component, props `{ years: number[], selectedYear: number, onChange: (year: number) => void }`; native accessible `` has an associated `