diff --git a/specs/012-dashboard-stats/checklists/requirements.md b/specs/012-dashboard-stats/checklists/requirements.md new file mode 100644 index 0000000..ed05129 --- /dev/null +++ b/specs/012-dashboard-stats/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Rider Dashboard Statistics + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-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 + +- Clarifications resolved: baseline averages include average miles per ride and average ride duration. +- Clarifications resolved: first optional suggestions are estimated gallons avoided and goal progress. +- Specification is ready for `/speckit.plan`. \ No newline at end of file diff --git a/specs/012-dashboard-stats/contracts/api-contracts.md b/specs/012-dashboard-stats/contracts/api-contracts.md new file mode 100644 index 0000000..df94552 --- /dev/null +++ b/specs/012-dashboard-stats/contracts/api-contracts.md @@ -0,0 +1,253 @@ +# API Contracts: Rider Dashboard Statistics + +**Feature**: 012-dashboard-stats +**Date**: 2026-04-06 +**Primary base paths**: `/api/dashboard`, `/api/users/me/settings` + +--- + +## New Endpoint + +### `GET /api/dashboard` + +Authenticated rider-only endpoint returning the full dashboard view model. + +```csharp +public sealed record DashboardResponse( + DashboardTotals Totals, + DashboardAverages Averages, + DashboardCharts Charts, + IReadOnlyList Suggestions, + DashboardMissingData MissingData, + DateTime GeneratedAtUtc +); + +public sealed record DashboardTotals( + DashboardMileageMetric CurrentMonthMiles, + DashboardMileageMetric YearToDateMiles, + DashboardMileageMetric AllTimeMiles, + DashboardMoneySaved MoneySaved +); + +public sealed record DashboardMileageMetric( + decimal Miles, + int RideCount, + string Period +); + +public sealed record DashboardMoneySaved( + decimal? MileageRateSavings, + decimal? FuelCostAvoided, + decimal? CombinedSavings, + int QualifiedRideCount +); + +public sealed record DashboardAverages( + decimal? AverageTemperature, + decimal? AverageMilesPerRide, + decimal? AverageRideMinutes +); + +public sealed record DashboardCharts( + IReadOnlyList MileageByMonth, + IReadOnlyList SavingsByMonth +); + +public sealed record DashboardMileagePoint( + string MonthKey, + string Label, + decimal Miles +); + +public sealed record DashboardSavingsPoint( + string MonthKey, + string Label, + decimal? MileageRateSavings, + decimal? FuelCostAvoided, + decimal? CombinedSavings +); + +public sealed record DashboardMetricSuggestion( + string MetricKey, + string Title, + string Description, + bool IsEnabled +); + +public sealed record DashboardMissingData( + int RidesMissingSavingsSnapshot, + int RidesMissingGasPrice, + int RidesMissingTemperature, + int RidesMissingDuration +); +``` + +**Behavior**: +- `200 OK` for authenticated riders, even when no rides exist. +- `401 Unauthorized` when the caller is unauthenticated. +- Empty-state responses still contain empty chart arrays or zeroed mileage cards instead of errors. + +--- + +## Modified Existing Contract: `UserSettingsUpsertRequest` + +File: `src/BikeTracking.Api/Contracts/UsersContracts.cs` + +```csharp +public sealed record UserSettingsUpsertRequest( + decimal? AverageCarMpg, + decimal? YearlyGoalMiles, + decimal? OilChangePrice, + decimal? MileageRateCents, + string? LocationLabel, + decimal? Latitude, + decimal? Longitude, + bool? DashboardGallonsAvoidedEnabled, + bool? DashboardGoalProgressEnabled +); +``` + +**Semantics**: +- Both new fields participate in the existing partial-update semantics. +- Omitting a field leaves the prior persisted value unchanged. + +--- + +## Modified Existing Contract: `UserSettingsView` + +```csharp +public sealed record UserSettingsView( + decimal? AverageCarMpg, + decimal? YearlyGoalMiles, + decimal? OilChangePrice, + decimal? MileageRateCents, + string? LocationLabel, + decimal? Latitude, + decimal? Longitude, + bool DashboardGallonsAvoidedEnabled, + bool DashboardGoalProgressEnabled, + DateTime? UpdatedAtUtc +); +``` + +These fields allow the frontend to render suggestion state and settings defaults consistently. + +--- + +## Modified Existing Event Payload Factories + +Files: +- `RideRecordedEventPayload.cs` +- `RideEditedEventPayload.cs` + +New additive optional fields: + +```csharp +decimal? SnapshotAverageCarMpg = null, +decimal? SnapshotMileageRateCents = null, +decimal? SnapshotYearlyGoalMiles = null, +decimal? SnapshotOilChangePrice = null +``` + +These are additive and backwards-compatible for existing call sites. + +--- + +## Frontend TypeScript Contracts + +### `dashboard-api.ts` + +```typescript +export interface DashboardResponse { + totals: DashboardTotals; + averages: DashboardAverages; + charts: DashboardCharts; + suggestions: DashboardMetricSuggestion[]; + missingData: DashboardMissingData; + generatedAtUtc: string; +} + +export interface DashboardTotals { + currentMonthMiles: DashboardMileageMetric; + yearToDateMiles: DashboardMileageMetric; + allTimeMiles: DashboardMileageMetric; + moneySaved: DashboardMoneySaved; +} + +export interface DashboardMileageMetric { + miles: number; + rideCount: number; + period: string; +} + +export interface DashboardMoneySaved { + mileageRateSavings: number | null; + fuelCostAvoided: number | null; + combinedSavings: number | null; + qualifiedRideCount: number; +} + +export interface DashboardAverages { + averageTemperature: number | null; + averageMilesPerRide: number | null; + averageRideMinutes: number | null; +} + +export interface DashboardCharts { + mileageByMonth: DashboardMileagePoint[]; + savingsByMonth: DashboardSavingsPoint[]; +} + +export interface DashboardMileagePoint { + monthKey: string; + label: string; + miles: number; +} + +export interface DashboardSavingsPoint { + monthKey: string; + label: string; + mileageRateSavings: number | null; + fuelCostAvoided: number | null; + combinedSavings: number | null; +} + +export interface DashboardMetricSuggestion { + metricKey: "gallonsAvoided" | "goalProgress"; + title: string; + description: string; + isEnabled: boolean; +} + +export interface DashboardMissingData { + ridesMissingSavingsSnapshot: number; + ridesMissingGasPrice: number; + ridesMissingTemperature: number; + ridesMissingDuration: number; +} +``` + +### `users-api.ts` + +Add to both request and response interfaces: + +```typescript +dashboardGallonsAvoidedEnabled?: boolean | null; +dashboardGoalProgressEnabled?: boolean | null; +``` + +For the view shape: + +```typescript +dashboardGallonsAvoidedEnabled: boolean; +dashboardGoalProgressEnabled: boolean; +``` + +--- + +## Compatibility Notes + +- Existing user settings callers remain compatible because the new fields are additive. +- Existing ride-history API consumers remain unchanged. +- The dashboard no longer depends on ride-history pagination hacks, but `/miles` can be preserved as + a client-side redirect to the new dashboard route for continuity. diff --git a/specs/012-dashboard-stats/data-model.md b/specs/012-dashboard-stats/data-model.md new file mode 100644 index 0000000..ec9ab59 --- /dev/null +++ b/specs/012-dashboard-stats/data-model.md @@ -0,0 +1,217 @@ +# Data Model: Rider Dashboard Statistics + +**Feature**: 012-dashboard-stats +**Date**: 2026-04-06 +**Status**: Complete + +--- + +## Overview + +This feature introduces three data-model changes: + +1. Extend `RideEntity` with a calculation snapshot captured from user settings at ride create/edit time. +2. Extend `UserSettingsEntity` with two dashboard optional-metric approval preferences. +3. Introduce a dashboard query response model that aggregates cards, averages, chart series, and + optional metric suggestions for the frontend. + +No new table is required if dashboard approvals are stored on the existing `UserSettings` row. + +--- + +## Existing Entity: `RideEntity` (extensions only) + +File: `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` + +**Existing fields used by the dashboard**: + +| Field | Type | Notes | +|-------|------|-------| +| `RiderId` | `long` | Rider scoping | +| `RideDateTimeLocal` | `DateTime` | Month/year/all-time bucketing | +| `Miles` | `decimal` | Mileage totals and averages | +| `RideMinutes` | `int?` | Average ride duration | +| `Temperature` | `decimal?` | Average temperature | +| `GasPricePerGallon` | `decimal?` | Fuel-cost avoided calculation | + +**New snapshot fields**: + +| Field | Type | Notes | +|-------|------|-------| +| `SnapshotAverageCarMpg` | `decimal?` | Historical fuel-economy assumption for this ride | +| `SnapshotMileageRateCents` | `decimal?` | Historical mileage-rate assumption for this ride | +| `SnapshotYearlyGoalMiles` | `decimal?` | Historical yearly goal available for progress calculations | +| `SnapshotOilChangePrice` | `decimal?` | Forward-compatible snapshot for later maintenance savings | + +**Validation / semantics**: +- Snapshot fields are nullable because older rides will not have them. +- Positive-value constraints should mirror the corresponding user settings rules. +- Snapshot fields are written from current user settings on ride create and overwritten with current + user settings again when a ride is edited. + +--- + +## Existing Entity: `UserSettingsEntity` (extensions only) + +File: `src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs` + +**Existing fields reused by this feature**: + +| Field | Type | Notes | +|-------|------|-------| +| `AverageCarMpg` | `decimal?` | Snapshot source | +| `YearlyGoalMiles` | `decimal?` | Snapshot source and optional metric source | +| `OilChangePrice` | `decimal?` | Snapshot source for future options | +| `MileageRateCents` | `decimal?` | Snapshot source | + +**New preference fields**: + +| Field | Type | Default | Notes | +|-------|------|---------|-------| +| `DashboardGallonsAvoidedEnabled` | `bool` | `false` | Approved optional metric visibility | +| `DashboardGoalProgressEnabled` | `bool` | `false` | Approved optional metric visibility | + +**Validation / semantics**: +- These are current user preferences, not historical data. +- They participate in the existing partial-update settings flow. + +--- + +## Extended Event Payloads + +Files: +- `src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs` +- `src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs` + +**New event fields**: + +| Field | Type | Source | +|-------|------|--------| +| `SnapshotAverageCarMpg` | `decimal?` | Current user settings at save time | +| `SnapshotMileageRateCents` | `decimal?` | Current user settings at save time | +| `SnapshotYearlyGoalMiles` | `decimal?` | Current user settings at save time | +| `SnapshotOilChangePrice` | `decimal?` | Current user settings at save time | + +These fields preserve the audit trail needed for historically accurate analytics. + +--- + +## New Query Model: `DashboardResponse` + +File: `src/BikeTracking.Api/Contracts/DashboardContracts.cs` + +### `DashboardResponse` + +| Field | Type | Notes | +|-------|------|-------| +| `Totals` | `DashboardTotals` | Headline mileage and savings cards | +| `Averages` | `DashboardAverages` | Average temperature, miles/ride, ride duration | +| `Charts` | `DashboardCharts` | Monthly mileage and monthly savings series | +| `Suggestions` | `IReadOnlyList` | Optional metrics available for approval | +| `MissingData` | `DashboardMissingData` | Counts for rides excluded from partial calculations | +| `GeneratedAtUtc` | `DateTime` | Response timestamp | + +### `DashboardTotals` + +| Field | Type | Notes | +|-------|------|-------| +| `CurrentMonthMiles` | `DashboardMileageMetric` | Current local month total | +| `YearToDateMiles` | `DashboardMileageMetric` | Current local year total | +| `AllTimeMiles` | `DashboardMileageMetric` | Lifetime total | +| `MoneySaved` | `DashboardMoneySaved` | Mileage-rate and MPG-based savings | + +### `DashboardMoneySaved` + +| Field | Type | Notes | +|-------|------|-------| +| `MileageRateSavings` | `decimal?` | Sum of mileage-rate formula for qualifying rides | +| `FuelCostAvoided` | `decimal?` | Sum of fuel-cost formula for qualifying rides | +| `CombinedSavings` | `decimal?` | Sum of both components when derivable | +| `QualifiedRideCount` | `int` | Number of rides contributing to at least one savings calculation | + +### `DashboardAverages` + +| Field | Type | Notes | +|-------|------|-------| +| `AverageTemperature` | `decimal?` | Only rides with saved temperature | +| `AverageMilesPerRide` | `decimal?` | All rides with miles | +| `AverageRideMinutes` | `decimal?` | Only rides with duration | + +### `DashboardCharts` + +| Field | Type | Notes | +|-------|------|-------| +| `MileageByMonth` | `IReadOnlyList` | Last 12 months | +| `SavingsByMonth` | `IReadOnlyList` | Last 12 months | + +### `DashboardMetricSuggestion` + +| Field | Type | Notes | +|-------|------|-------| +| `MetricKey` | `string` | Stable key (`gallonsAvoided`, `goalProgress`) | +| `Title` | `string` | UI label | +| `Description` | `string` | Why the metric might be useful | +| `IsEnabled` | `bool` | Current user preference state | + +### `DashboardMissingData` + +| Field | Type | Notes | +|-------|------|-------| +| `RidesMissingSavingsSnapshot` | `int` | Legacy rides missing calculation snapshot data | +| `RidesMissingGasPrice` | `int` | Rides that cannot participate in fuel-cost avoided | +| `RidesMissingTemperature` | `int` | Rides excluded from temperature average | +| `RidesMissingDuration` | `int` | Rides excluded from average ride duration | + +--- + +## Relationships + +- One rider has one `UserSettingsEntity` row. +- One rider has many `RideEntity` rows. +- Each ride captures a snapshot of selected values from the rider’s settings row at save time. +- The dashboard query is rider-scoped and aggregates only that rider’s rides plus that rider’s + current optional-metric approval settings. + +--- + +## State Transitions + +### Ride Create + +```text +RecordRideRequest received + → load current UserSettingsEntity for rider + → copy current calculation settings into RideEntity snapshot columns + → save RideEntity + → emit RideRecordedEventPayload containing the same snapshot values +``` + +### Ride Edit + +```text +EditRideRequest received + → load current UserSettingsEntity for rider + → overwrite ride snapshot columns with current calculation settings + → save updated RideEntity version + → emit RideEditedEventPayload containing the updated snapshot values +``` + +### Optional Metric Approval + +```text +User approves gallons avoided and/or goal progress + → PUT /api/users/me/settings with dashboard preference booleans + → UserSettingsEntity updated + → subsequent dashboard queries mark approved suggestions as enabled and render those metrics +``` + +### Dashboard Query + +```text +GET /api/dashboard + → load all rider rides and current user settings + → bucket rides into current month, current year, all time, and last 12 monthly chart points + → compute averages and savings using ride snapshots when present + → count rides excluded by missing data rules + → return dashboard response DTO +``` diff --git a/specs/012-dashboard-stats/plan.md b/specs/012-dashboard-stats/plan.md new file mode 100644 index 0000000..d5bdb44 --- /dev/null +++ b/specs/012-dashboard-stats/plan.md @@ -0,0 +1,201 @@ +# Implementation Plan: Rider Dashboard Statistics + +**Branch**: `012-dashboard-stats` | **Date**: 2026-04-06 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/012-dashboard-stats/spec.md` + +## Summary + +Replace the current lightweight miles landing page with a real rider dashboard backed by a +dedicated dashboard API. The backend will aggregate month, year, and lifetime mileage totals; +average temperature, miles per ride, and ride duration; and two savings calculations +(mileage-rate reimbursement equivalent and fuel-cost avoided). To preserve historical accuracy, +ride create/edit flows will snapshot calculation-relevant user settings into both ride storage +and ride event payloads. The frontend will move to a dedicated dashboard route and render a more +complete visual layout using Recharts with locally vendored ShadCN-style chart primitives that +fit the existing CSS architecture. + +## Technical Context + +**Language/Version**: C# .NET 10 (API layer); F# .NET 10 (domain layer unchanged for this feature); TypeScript 6 + React 19 (frontend) +**Primary Dependencies**: .NET 10 Minimal API, Entity Framework Core with SQLite, Microsoft Aspire, React 19 + Vite, Recharts, locally vendored ShadCN-style chart primitives adapted to existing CSS +**Storage**: SQLite local file via EF Core; additive columns on `Rides` and `UserSettings`; existing outbox event store payloads extended +**Testing**: xUnit (backend unit + integration), Vitest (frontend unit), Playwright (E2E) +**Target Platform**: Local-first web app on Windows/macOS/Linux, developed in DevContainer +**Project Type**: Aspire-orchestrated local web application (React frontend + Minimal API + SQLite) +**Performance Goals**: Dashboard endpoint ≤ 750 ms p95 for cached local queries at expected single-user scale; initial dashboard page render visually complete within 2 seconds on seeded local data +**Constraints**: No full Tailwind migration; preserve existing CSS architecture; historical savings must not change when user settings change later; missing snapshot/weather/fuel data must degrade individual metrics only; SQLite-compatible additive schema changes only +**Scale/Scope**: Single-user local deployment; expected history in the hundreds to low thousands of rides; one new dashboard endpoint, one new frontend page, one migration, and focused ride/settings contract extensions + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Gate | Status | Notes | +|------|--------|-------| +| Clean Architecture / Domain-Driven Design | ✅ PASS | Dedicated dashboard query service and endpoint keep aggregation logic out of controllers and UI | +| Functional Programming (pure/impure sandwich) | ✅ PASS | Snapshot selection and savings formulas can be isolated as pure helpers inside application services | +| Event Sourcing & CQRS | ✅ PASS | Ride recorded/edited event payloads are extended with calculation snapshots; dashboard is a read-side query | +| Quality-First / TDD | ✅ PASS | Quickstart defines failing backend, frontend, and E2E tests before implementation | +| UX Consistency & Accessibility | ✅ PASS | Dashboard remains React/CSS-based, uses accessible cards/charts, and preserves clear empty and partial-data states | +| Performance / Observability | ✅ PASS | Dedicated endpoint avoids page-size hacks; aggregations remain local-query bounded and observable via existing Aspire telemetry | +| Data Validation & Integrity | ✅ PASS | Snapshot and preference fields are additive, validated server-side, and backed by DB defaults/constraints where applicable | +| Experimentation / Security | ✅ PASS | Small-batch rollout via one dashboard route and one endpoint; no new secrets; no browser-side external API access | +| Modularity / Contract-First | ✅ PASS | New dashboard contracts are defined before implementation; settings and ride payload extensions are documented | +| TDD Mandate (mandatory gate) | ✅ PASS | Plan includes explicit red tests and requires user confirmation before implementation starts | +| Migration test coverage policy | ✅ PASS | One new migration implies one new/updated migration coverage policy entry | +| Spec completion gate | ✅ PASS | Plan assumes final validation includes migration application plus unit/lint/build/E2E checks | + +**Constitution Check post-design**: No violations. The design stays additive, contract-first, and +aligned with the repository’s current frontend/backend split. + +## Project Structure + +### Documentation (this feature) + +```text +specs/012-dashboard-stats/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── api-contracts.md +└── tasks.md +``` + +### Source Code Layout + +```text +src/BikeTracking.Api/ +├── Application/ +│ ├── Dashboard/ +│ │ └── GetDashboardService.cs ← NEW +│ ├── Events/ +│ │ ├── RideRecordedEventPayload.cs ← extend with calculation snapshots +│ │ └── RideEditedEventPayload.cs ← extend with calculation snapshots +│ ├── Rides/ +│ │ ├── RecordRideService.cs ← snapshot user settings into ride + event +│ │ └── EditRideService.cs ← same for edits +│ └── Users/ +│ └── UserSettingsService.cs ← extend with dashboard metric approvals +├── Contracts/ +│ ├── DashboardContracts.cs ← NEW +│ ├── RidesContracts.cs ← optional dashboard-facing read-model reuse only if needed +│ └── UsersContracts.cs ← extend with optional metric approval fields +├── Endpoints/ +│ ├── DashboardEndpoints.cs ← NEW +│ └── UsersEndpoints.cs ← existing settings endpoint continues with extended contract +└── Infrastructure/ + └── Persistence/ + ├── BikeTrackingDbContext.cs ← extend entity config + ├── Entities/ + │ ├── RideEntity.cs ← add snapshot columns + │ └── UserSettingsEntity.cs ← add dashboard preference booleans + └── Migrations/ + └── {timestamp}_AddDashboardSnapshotsAndPreferences.cs + +src/BikeTracking.Api.Tests/ +├── Application/ +│ ├── Dashboard/ +│ │ └── GetDashboardServiceTests.cs ← NEW +│ ├── RidesApplicationServiceTests.cs ← extend snapshot persistence behavior +│ └── Users/ +│ └── UserSettingsServiceTests.cs ← extend preference persistence +├── Endpoints/ +│ ├── DashboardEndpointsTests.cs ← NEW +│ └── UsersEndpointsTests.cs ← extend settings contract assertions +└── Infrastructure/ + ├── MigrationTestCoveragePolicyTests.cs ← add migration entry + └── RidesPersistenceTests.cs ← verify snapshot column round-trip + +src/BikeTracking.Frontend/src/ +├── components/ +│ ├── dashboard/ ← NEW dashboard cards/sections/charts +│ └── ui/ +│ └── chart.tsx ← NEW ShadCN-style chart wrapper adapted to repo CSS +├── pages/ +│ ├── dashboard/ +│ │ ├── dashboard-page.tsx ← NEW +│ │ ├── dashboard-page.css ← NEW +│ │ └── dashboard-page.test.tsx ← NEW +│ ├── miles/ +│ │ └── miles-shell-page.tsx ← retire or redirect +│ └── settings/ +│ ├── SettingsPage.tsx ← extend optional metric approvals +│ └── SettingsPage.test.tsx ← extend +├── services/ +│ ├── dashboard-api.ts ← NEW +│ ├── dashboard-api.test.ts ← NEW +│ └── users-api.ts ← extend settings DTOs +└── App.tsx ← route/main-page updates +``` + +**Structure Decision**: Existing web application structure. Backend work stays in +`src/BikeTracking.Api/` and tests in `src/BikeTracking.Api.Tests/`; frontend work stays in +`src/BikeTracking.Frontend/src/`. No new projects are added. + +## Implementation Phases + +### Phase 0 — Research + +Resolved decisions documented in `research.md`: +- dedicated dashboard API endpoint instead of overloading ride history summaries +- snapshot storage on ride rows plus ride event payloads for historical accuracy +- optional metric approvals persisted in `UserSettingsEntity` +- ShadCN-style charts implemented via Recharts + local wrapper, not full Tailwind adoption +- safe fallback rules for legacy rides missing snapshot data + +### Phase 1 — Backend Design and Contracts + +**Slice 1.1 — Contracts first** +- Add `DashboardContracts.cs` and extend `UsersContracts.cs` +- Document `GET /api/dashboard` and settings DTO changes + +**Slice 1.2 — Persistence** +- Extend `RideEntity` with snapshot fields +- Extend `UserSettingsEntity` with optional metric approval booleans +- Generate one migration and update migration coverage policy tests + +**Slice 1.3 — Query service** +- Add `GetDashboardService` that computes cards, averages, chart series, missing-data counts, and suggestions +- Isolate savings formulas and legacy-ride fallback rules in testable helpers + +**Slice 1.4 — Write-path integration** +- Update `RecordRideService` and `EditRideService` to capture current calculation settings into ride rows and event payloads + +**Slice 1.5 — Endpoint wiring** +- Add authenticated `DashboardEndpoints` +- Extend user settings endpoint/service for optional metric approvals + +### Phase 2 — Frontend Design + +**Slice 2.1 — API client and route changes** +- Add `dashboard-api.ts` +- Make dashboard the authenticated landing page +- Redirect legacy `/miles` traffic to the new dashboard route to preserve compatibility + +**Slice 2.2 — Dashboard page and charts** +- Add summary cards, averages, partial-data messaging, and two baseline charts +- Use Recharts with a local ShadCN-style wrapper component and CSS variables, not Tailwind + +**Slice 2.3 — Optional metric suggestion flow** +- Show gallons avoided and goal progress as suggested metrics first +- Persist approvals through user settings and render only when enabled + +## Test Plan Summary + +| Category | Count | Location | +|----------|-------|----------| +| Backend unit — dashboard aggregation | 8 | `Application/Dashboard/GetDashboardServiceTests.cs` | +| Backend unit — ride snapshot capture | 4 | `Application/RidesApplicationServiceTests.cs` | +| Backend unit — settings preference persistence | 3 | `Application/Users/UserSettingsServiceTests.cs` | +| Endpoint/integration — dashboard + settings contracts | 4 | `Endpoints/DashboardEndpointsTests.cs`, `Endpoints/UsersEndpointsTests.cs` | +| Persistence / migration | 3 | `Infrastructure/RidesPersistenceTests.cs`, `MigrationTestCoveragePolicyTests.cs` | +| Frontend unit | 5 | dashboard page/tests + settings/tests + API client tests | +| E2E (Playwright) | 4 | dashboard landing, totals refresh, settings-change stability, optional metric approval | +| **Total** | **31** | | + +## Complexity Tracking + +No constitution violations. The design avoids a separate analytics database, avoids Tailwind +adoption, and reuses the current settings/write paths with additive schema changes only. diff --git a/specs/012-dashboard-stats/quickstart.md b/specs/012-dashboard-stats/quickstart.md new file mode 100644 index 0000000..993e995 --- /dev/null +++ b/specs/012-dashboard-stats/quickstart.md @@ -0,0 +1,246 @@ +# Developer Quickstart: Rider Dashboard Statistics + +**Feature**: 012-dashboard-stats +**Branch**: `012-dashboard-stats` +**Date**: 2026-04-06 + +--- + +## Overview + +This feature replaces the current minimal miles landing page with a dashboard backed by a dedicated +API endpoint. The dashboard shows current-month, year-to-date, and all-time mileage; money saved +from mileage-rate and MPG-based formulas; average temperature; average miles per ride; average ride +duration; and two baseline charts. Historical accuracy comes from snapshotting calculation-relevant +user settings into each ride and ride event at save time. + +--- + +## Prerequisites + +- DevContainer running +- App launch path available: `dotnet run --project src/BikeTracking.AppHost` +- Existing ride, settings, and login flows already working +- Follow strict TDD: write failing tests first, run them, and get user confirmation before implementation + +--- + +## Implementation Order + +### Step 1 — Contracts first + +Create/modify: + +```text +src/BikeTracking.Api/Contracts/ + DashboardContracts.cs ← new + UsersContracts.cs ← extend settings DTOs +``` + +Then wire endpoint shell: + +```text +src/BikeTracking.Api/Endpoints/ + DashboardEndpoints.cs ← new +``` + +Goal: lock the backend/frontend contract before changing services or UI. + +--- + +### Step 2 — Persist dashboard-related data + +Modify: + +```text +src/BikeTracking.Api/Infrastructure/Persistence/Entities/ + RideEntity.cs ← add snapshot columns + UserSettingsEntity.cs ← add optional metric preference booleans + +src/BikeTracking.Api/Infrastructure/Persistence/ + BikeTrackingDbContext.cs ← configure new columns/defaults/constraints +``` + +Create migration: + +```bash +cd src/BikeTracking.Api +dotnet ef migrations add AddDashboardSnapshotsAndPreferences +``` + +Update: + +```text +src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs +``` + +--- + +### Step 3 — Snapshot settings during ride writes + +Modify: + +```text +src/BikeTracking.Api/Application/Rides/ + RecordRideService.cs + EditRideService.cs + +src/BikeTracking.Api/Application/Events/ + RideRecordedEventPayload.cs + RideEditedEventPayload.cs +``` + +Logic: +- Load current user settings when saving a ride. +- Copy relevant setting values into ride snapshot columns. +- Copy the same values into the event payload factory call. + +--- + +### Step 4 — Build the dashboard query service + +Create: + +```text +src/BikeTracking.Api/Application/Dashboard/ + GetDashboardService.cs +``` + +Responsibilities: +- aggregate month/year/all-time totals +- compute average temperature, miles per ride, ride duration +- compute mileage-rate savings and fuel-cost avoided from ride snapshots +- build last-12-month chart series +- count missing-data exclusions +- expose gallons-avoided and goal-progress suggestions with current enablement state + +--- + +### Step 5 — Extend settings flow for optional metric approvals + +Modify: + +```text +src/BikeTracking.Api/Application/Users/UserSettingsService.cs +src/BikeTracking.Api/Endpoints/UsersEndpoints.cs +src/BikeTracking.Frontend/src/services/users-api.ts +src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx +``` + +Goal: let riders approve `gallonsAvoided` and `goalProgress` before those metrics appear. + +--- + +### Step 6 — Add dashboard frontend + +Create/modify: + +```text +src/BikeTracking.Frontend/src/services/ + dashboard-api.ts + dashboard-api.test.ts + +src/BikeTracking.Frontend/src/components/ui/ + chart.tsx ← local ShadCN-style wrapper for Recharts + +src/BikeTracking.Frontend/src/components/dashboard/ + ... ← cards, chart sections, missing-data callouts + +src/BikeTracking.Frontend/src/pages/dashboard/ + dashboard-page.tsx + dashboard-page.css + dashboard-page.test.tsx + +src/BikeTracking.Frontend/src/App.tsx +src/BikeTracking.Frontend/src/components/app-header/app-header.tsx +``` + +Route behavior: +- authenticated landing page becomes dashboard +- legacy `/miles` route redirects to dashboard + +--- + +## Verification Commands + +Run after each meaningful slice, not just at the end: + +```bash +dotnet test BikeTracking.slnx + +cd src/BikeTracking.Frontend && npm run lint && npm run build && npm run test:unit + +cd src/BikeTracking.Frontend && npm run test:e2e +``` + +Formatting before merge: + +```bash +csharpier format . +``` + +--- + +## TDD Test Plan + +Write these tests first, run them red, and get user confirmation before implementing. + +### Backend unit tests + +`GetDashboardServiceTests.cs` + +| Test | What it proves | +|------|----------------| +| Returns zeroed mileage cards and empty series for riders with no rides | Empty-state dashboard is supported | +| Aggregates current month, current year, and all time miles correctly | Core headline totals are correct | +| Computes average miles per ride from all rides | Requested average metric is correct | +| Computes average ride duration only from rides with duration | Missing-duration handling is correct | +| Computes average temperature only from rides with temperature | Missing-temperature handling is correct | +| Computes mileage-rate savings from snapshot mileage rate | Historical mileage-rate math is correct | +| Computes fuel-cost avoided from snapshot MPG and saved gas price | Historical MPG-based savings math is correct | +| Excludes legacy rides without snapshots from savings totals and counts them in missing data | Safe fallback behavior is correct | + +`RidesApplicationServiceTests.cs` + +| Test | What it proves | +|------|----------------| +| RecordRideService copies current user settings into ride snapshot fields | New rides are historically accurate | +| RecordRideService includes snapshot fields in `RideRecordedEventPayload` | Event audit trail is complete | +| EditRideService refreshes snapshot fields from current settings on edit save | Edited rides use the new versioned assumptions | +| EditRideService includes snapshot fields in `RideEditedEventPayload` | Edited event audit trail is complete | + +`UserSettingsServiceTests.cs` + +| Test | What it proves | +|------|----------------| +| Saves dashboard optional metric preferences when explicitly provided | Approval state persists | +| Leaves existing approval values unchanged when omitted from partial update | Partial update semantics are preserved | +| Returns approval values in `UserSettingsResponse` | Frontend can render the saved state | + +### Endpoint / integration tests + +| Test | What it proves | +|------|----------------| +| `GET /api/dashboard` returns authenticated rider-scoped data only | Rider isolation is preserved | +| `GET /api/dashboard` returns empty-state payload for rider with no rides | Empty-state API contract is stable | +| `PUT /api/users/me/settings` round-trips dashboard approval booleans | Settings contract extension works | +| Migration coverage test includes new migration entry | Constitution migration rule is satisfied | + +### Frontend unit tests + +| Test | What it proves | +|------|----------------| +| Dashboard page loads and renders headline cards from API data | Main page UI contract works | +| Dashboard page renders missing-data notice for partial savings | Partial-data UX is clear | +| Dashboard page renders chart sections with provided series | Chart container wiring is correct | +| Settings page shows and saves optional metric approvals | Approval UI works | +| App routing sends authenticated users to dashboard and legacy `/miles` redirects | Main-page behavior is correct | + +### E2E tests + +| Test | What it proves | +|------|----------------| +| Authenticated login lands on dashboard instead of the old miles shell | Main-page requirement is satisfied | +| Recording a ride updates dashboard mileage and averages | Dashboard reflects new ride data | +| Changing user settings does not retroactively change old ride savings totals | Historical accuracy works end to end | +| Approving gallons avoided and goal progress makes those optional metrics appear | Suggest-first behavior works | diff --git a/specs/012-dashboard-stats/research.md b/specs/012-dashboard-stats/research.md new file mode 100644 index 0000000..8109112 --- /dev/null +++ b/specs/012-dashboard-stats/research.md @@ -0,0 +1,160 @@ +# Research: Rider Dashboard Statistics + +**Feature**: 012-dashboard-stats +**Date**: 2026-04-06 +**Status**: Complete + +--- + +## Decision 1: Use a dedicated dashboard API endpoint + +**Decision**: Add an authenticated `GET /api/dashboard` endpoint instead of continuing to build the +dashboard from `GET /api/rides/history?page=1&pageSize=1`. + +**Rationale**: +- The current miles page only gets summary cards by abusing the ride-history endpoint with a + one-row page request; that is not a stable contract for charts, averages, missing-data notices, + or optional metric suggestions. +- A dedicated query endpoint keeps dashboard-specific aggregation logic in one place and avoids + leaking UI concerns into ride-history pagination. +- The contract can return headline totals, averages, chart series, and suggestion state together, + which reduces frontend orchestration complexity. + +**Alternatives considered**: +- **Keep extending `GET /api/rides/history`**: rejected because ride history is a list-focused read + model, not a dashboard view model, and it would accumulate unrelated responsibilities. +- **Compute all aggregations in the frontend**: rejected because historical-snapshot fallbacks and + savings formulas belong on the server for consistency and testability. + +--- + +## Decision 2: Snapshot settings on each ride and in each ride event + +**Decision**: Persist the calculation-relevant user settings on every ride at create/edit time and +also include the same snapshot in `RideRecordedEventPayload` and `RideEditedEventPayload`. + +**Snapshot fields**: +- `AverageCarMpg` +- `MileageRateCents` +- `YearlyGoalMiles` +- `OilChangePrice` for forward compatibility, even though the current dashboard does not yet use it + +**Rationale**: +- The user explicitly requested historical accuracy when settings change later. +- The current system stores mutable user settings separately from rides; relying on current + settings would retroactively alter old savings calculations. +- Persisting snapshots both on the ride row and in the event payload matches the repo’s practical + architecture: events preserve the audit trail and ride rows remain queryable without replay. + +**Alternatives considered**: +- **Store only settings-changed events and reconstruct during dashboard queries**: rejected because + the current app does not materialize dashboard read models from event replay, and the extra + complexity is not justified for a local single-user application. +- **Store snapshots only in events**: rejected because the dashboard currently queries ride rows, + not event streams, and replaying events on every dashboard load would be a structural rewrite. + +--- + +## Decision 3: Persist optional metric approvals inside user settings + +**Decision**: Extend `UserSettingsEntity`, `UserSettingsUpsertRequest`, and `UserSettingsView` with + booleans for dashboard optional metrics, starting with: +- `DashboardGallonsAvoidedEnabled` +- `DashboardGoalProgressEnabled` + +**Rationale**: +- Optional metric approval is a current rider preference, not historical ride data. +- The app already has a per-user settings record and a partial-update settings endpoint; reusing it + is the smallest coherent change. +- Two explicit booleans are simpler and clearer than introducing a generic JSON preference bag for + only two approved optional metrics. + +**Alternatives considered**: +- **Create a separate dashboard preferences table**: rejected because it adds another entity and + migration without functional gain at this scale. +- **Keep approvals only in browser storage**: rejected because the user expects the app to ask once + and then respect the decision across sessions and devices/worktrees. + +--- + +## Decision 4: Use Recharts with locally adapted ShadCN-style chart primitives + +**Decision**: Implement charts with `recharts` and add a local `components/ui/chart.tsx` wrapper +patterned after ShadCN’s chart primitives, but adapted to the existing CSS approach instead of +introducing Tailwind. + +**Rationale**: +- The user explicitly asked for graphs from ShadCN. +- This frontend does not use Tailwind, `class-variance-authority`, or the normal shadcn/ui setup; + a full shadcn installation would force a large unrelated architectural change. +- ShadCN charts are fundamentally Recharts plus styling helpers; the visual and interaction pattern + can be preserved with local wrappers and CSS variables. + +**Alternatives considered**: +- **Install the full shadcn/ui + Tailwind stack**: rejected because it is high churn for one + feature and violates the project’s existing frontend patterns. +- **Use plain Recharts with no ShadCN-style wrapper**: rejected because it would not meet the user’s + stated design direction. + +--- + +## Decision 5: Legacy rides without snapshots degrade safely, not retroactively + +**Decision**: For rides created before snapshot support exists, the dashboard will: +- continue to include them in mileage totals and ride-count-based averages +- exclude them from exact savings totals when required snapshot data is missing +- expose missing-data counts so the UI can explain why savings totals may be partial + +**Rationale**: +- Using current settings for old rides would misstate historical values. +- Backfilling old rides with guessed snapshots would fabricate historical assumptions. +- Treating missing values as zero would understate savings and hide data quality issues. + +**Alternatives considered**: +- **Use current settings as fallback for old rides**: rejected because it violates the core feature + requirement for historical accuracy. +- **Backfill all old rides during migration**: rejected because no reliable historical settings + record exists. + +--- + +## Decision 6: Expose two baseline savings calculations plus a combined total + +**Decision**: The dashboard money-saved model will include: +- `MileageRateSavings`: `miles * mileageRateCents / 100` +- `FuelCostAvoided`: `(miles / averageCarMpg) * gasPricePerGallon` +- `CombinedSavings`: sum of the two only when both components are calculable for the included rides + +**Rationale**: +- The user explicitly asked for money saved from mileage and MPG. +- Keeping both components visible prevents a “black box” combined number and makes partial-data + cases easier to explain. +- A combined total is still useful as a headline metric when both component formulas are available. + +**Alternatives considered**: +- **Show only one combined number**: rejected because riders would not be able to distinguish the + reimbursement-style number from fuel-cost avoidance. +- **Show only one savings formula**: rejected because it would not satisfy the request for both + mileage-based and MPG-based savings. + +--- + +## Decision 7: Baseline chart set is monthly mileage trend + monthly savings trend + +**Decision**: The first dashboard implementation will render: +- a monthly mileage trend chart over the last 12 months +- a monthly savings trend chart over the last 12 months using the same monthly buckets + +Average temperature remains a headline metric rather than a baseline chart series. + +**Rationale**: +- This satisfies the requirement for one mileage trend chart and one savings-or-conditions trend + chart with the clearest user value. +- Monthly buckets are stable, easy to read, and match the requested current-month/year/total mental + model better than daily chart noise. +- This avoids making the first version visually dense while still supporting later optional metrics. + +**Alternatives considered**: +- **Daily charts**: rejected because local commute logging can be sparse and visually noisy. +- **Temperature trend as the second baseline chart**: rejected because money saved is more central + to the requested dashboard value. diff --git a/specs/012-dashboard-stats/spec.md b/specs/012-dashboard-stats/spec.md new file mode 100644 index 0000000..862c9e6 --- /dev/null +++ b/specs/012-dashboard-stats/spec.md @@ -0,0 +1,115 @@ +# Feature Specification: Rider Dashboard Statistics + +**Feature Branch**: `012-dashboard-stats` +**Created**: 2026-04-06 +**Status**: Draft +**Input**: User description: "Use the user settings to calculate statistics to show the user in a dashboard with nice charts and graphs. Include miles for the current month, total miles for the year, total miles, money saved (mileage and mpg), average temp, average, and suggest other options, but ask me first. Make the Dashboard is the main page, currently called miles. User settings that can change should be added to the events so we keep the accurate. Use graphs from ShadCn." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - View core dashboard statistics (Priority: P1) + +As a rider, I want the main page after login to show my key riding totals, averages, and trends so I can immediately understand my current progress without opening ride history. + +**Why this priority**: This is the primary user value. The dashboard only succeeds if riders can quickly see their most important commute statistics at a glance. + +**Independent Test**: Can be fully tested by signing in as a rider with ride history and confirming the main page shows current-month miles, year-to-date miles, all-time miles, money-saved values, average temperature, average miles per ride, average ride duration, and supporting charts based on that rider's saved rides. + +**Acceptance Scenarios**: + +1. **Given** an authenticated rider with recorded rides, **When** they open the main page, **Then** they see a dashboard with current-month miles, year-to-date miles, and all-time miles for their own rides. +2. **Given** an authenticated rider with ride records that include weather, distance, duration, and fuel-related data, **When** they view the dashboard, **Then** they see money-saved, average-temperature, average-miles-per-ride, and average-ride-duration statistics calculated from their saved ride data and settings snapshots. +3. **Given** an authenticated rider with at least several rides across time, **When** they view the dashboard, **Then** they see charts that visualize trends rather than only raw totals. + +--- + +### User Story 2 - Keep historical calculations accurate when settings change (Priority: P2) + +As a rider, I want dashboard savings and progress calculations to remain historically accurate after I change my settings so previously recorded rides do not silently change meaning later. + +**Why this priority**: The dashboard becomes misleading if savings are recalculated against today's settings instead of the assumptions in effect when each ride was recorded or edited. + +**Independent Test**: Can be fully tested by recording rides, changing rider settings that affect calculations, recording another ride, and confirming the dashboard preserves prior ride calculations using the earlier snapshot while newer rides use the updated values. + +**Acceptance Scenarios**: + +1. **Given** a rider records a ride while one set of calculation settings is active, **When** those settings are changed later, **Then** the dashboard still uses the original settings snapshot for the earlier ride. +2. **Given** a rider edits an existing ride after changing settings, **When** the ride is resaved, **Then** the dashboard uses the settings snapshot saved with that edited ride version. +3. **Given** a rider has older rides created before settings snapshots were available, **When** the dashboard is loaded, **Then** those rides remain visible and any unavailable calculation-based values are handled without breaking the dashboard. + +--- + +### User Story 3 - Review optional metrics before adding them (Priority: P3) + +As a rider, I want the app to suggest additional dashboard metrics before they are shown so I can decide which extra insights are worth including beyond the core statistics. + +**Why this priority**: The request explicitly calls for suggestions first, which means the initial dashboard scope must separate required metrics from optional follow-on metrics. + +**Independent Test**: Can be fully tested by opening the dashboard configuration or suggestion flow, reviewing the proposed additional metrics, and confirming only approved optional metrics appear afterward. + +**Acceptance Scenarios**: + +1. **Given** the rider has access to the dashboard, **When** optional metric suggestions are presented, **Then** the rider can review proposed additions before they are enabled. +2. **Given** the rider has not approved optional metrics yet, **When** they view the dashboard, **Then** only the required baseline metrics are shown. + +### Edge Cases + +- A rider has no recorded rides yet; the dashboard still loads and clearly shows empty-state totals and charts. +- A rider has rides but no saved MPG or mileage-rate values; miles-based metrics still render while savings metrics explain that required assumptions are missing. +- A rider has rides with some missing temperature or gas-price data; averages and savings are calculated only from rides with the required data and do not block the page. +- A rider changes settings mid-month or mid-year; historical statistics remain stable for older rides while newer rides use the new snapshots. +- A rider's earlier rides predate settings snapshots; the dashboard distinguishes unavailable historical savings from zero savings. +- A rider has only one ride or all rides in one month; charts still render meaningful output without visual errors. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST make the rider dashboard the primary authenticated landing page in place of the current miles landing page. +- **FR-002**: The dashboard MUST display the rider's current-month miles, year-to-date miles, and all-time miles using only that rider's saved ride records. +- **FR-003**: The dashboard MUST display the rider's estimated money saved from riding using the per-ride distance together with the rider settings snapshots needed for mileage-rate and fuel-economy calculations. +- **FR-004**: The dashboard MUST display the rider's average temperature based on rides that contain saved temperature data. +- **FR-004a**: The dashboard MUST display the rider's average miles per ride based on saved ride distance data. +- **FR-004b**: The dashboard MUST display the rider's average ride duration based on rides that contain saved duration data. +- **FR-005**: The dashboard MUST display at least one trend-oriented chart for ride mileage over time and at least one chart for savings or ride conditions over time. +- **FR-006**: The dashboard MUST continue to show miles-based metrics even when some rides are missing the settings or lookup data required for savings and weather-based calculations. +- **FR-007**: The dashboard MUST clearly distinguish between a value of zero and a value that cannot be calculated because required data is missing. +- **FR-008**: When a ride is created or edited, the system MUST store a snapshot of every rider setting required for dashboard calculations with that ride's event data so later settings changes do not recalculate historical rides incorrectly. +- **FR-009**: Settings snapshots used for dashboard calculations MUST include, at minimum, the rider values needed for fuel-economy-based savings, mileage-rate-based savings, and goal/progress calculations. +- **FR-010**: When dashboard calculations use ride-level snapshots, the system MUST prefer the snapshot saved with the ride over the rider's current settings. +- **FR-011**: For rides that do not yet contain the required snapshot data, the dashboard MUST still load and MUST apply a defined fallback behavior that does not misstate historical values. +- **FR-012**: The dashboard MUST be visually organized around summary cards and charts so riders can understand totals and trends without navigating to a different page. +- **FR-013**: The system MUST present additional dashboard metric suggestions to the rider before any non-core optional metrics are enabled. +- **FR-014**: The baseline dashboard MUST include only the explicitly requested core metrics until the rider approves additional suggested metrics. +- **FR-015**: The baseline dashboard MUST include both average miles per ride and average ride duration alongside average temperature. +- **FR-016**: The first set of optional metric suggestions MUST be estimated gallons avoided and goal progress. + +### Key Entities *(include if feature involves data)* + +- **Dashboard Summary**: A rider-specific view of headline statistics for current month, current year, and all time, including totals, calculated savings, averages, and trend outputs. +- **Ride Calculation Snapshot**: The set of rider assumptions saved with each created or edited ride that preserves the values needed for future dashboard calculations even after settings change. +- **Optional Metric Suggestion**: A proposed dashboard insight not included in the core default set until the rider reviews and approves it. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of authenticated riders who have at least one saved ride can open the main page and see current-month, year-to-date, and all-time mileage totals without navigating elsewhere. +- **SC-002**: 100% of rides created or edited after this feature is introduced carry the calculation snapshot data required for future dashboard accuracy. +- **SC-003**: When rider settings change, previously displayed savings values for older rides remain stable in repeated dashboard checks unless the ride itself is edited. +- **SC-004**: At least 90% of riders in acceptance testing can identify their current month mileage, yearly mileage, all-time mileage, and savings totals within 10 seconds of landing on the dashboard. +- **SC-005**: The dashboard remains usable for 100% of riders whose history contains partial weather, fuel-price, or settings data; missing data degrades only the affected metrics, not the whole page. + +## Assumptions + +- Savings are calculated from data already stored with rides plus the rider settings snapshots captured at ride create or ride edit time. +- Core requested metrics are current-month miles, year-to-date miles, all-time miles, mileage-rate savings, fuel-economy savings, average temperature, average miles per ride, and average ride duration. +- The existing dashboard route and navigation may be renamed or redirected as needed, but riders should experience the dashboard as the default signed-in home page. +- Historical rides that predate snapshot support do not need to be silently rewritten; instead, the dashboard should use a safe fallback and avoid presenting ungrounded values as exact. +- Optional metrics are intentionally separated from the core dashboard because the rider requested to be asked before those extras are included; the first suggestions will be estimated gallons avoided and goal progress. + +## Dependencies + +- Existing ride history data remains the source for mileage totals. +- Saved ride weather and fuel-price fields remain available for temperature and savings-related calculations. +- Rider settings continue to expose the values needed for savings and progress calculations so they can be captured into ride snapshots at save time. diff --git a/specs/012-dashboard-stats/tasks.md b/specs/012-dashboard-stats/tasks.md new file mode 100644 index 0000000..1a32001 --- /dev/null +++ b/specs/012-dashboard-stats/tasks.md @@ -0,0 +1,223 @@ +# Tasks: Rider Dashboard Statistics + +**Input**: Design documents from `/specs/012-dashboard-stats/` +**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md` + +**Tests**: Tests are required for this feature because the plan and quickstart define a strict TDD workflow. + +**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Add the dashboard-specific frontend dependency and prepare the shared visualization foundation. + +- [x] T001 Add the `recharts` dependency in `src/BikeTracking.Frontend/package.json` and `src/BikeTracking.Frontend/package-lock.json` +- [x] T002 Create the shared ShadCN-style chart wrapper in `src/BikeTracking.Frontend/src/components/ui/chart.tsx` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Define the shared contracts and persistence model that all user stories depend on. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T003 Create the dashboard API contracts in `src/BikeTracking.Api/Contracts/DashboardContracts.cs` +- [x] T004 Extend dashboard preference fields in `src/BikeTracking.Api/Contracts/UsersContracts.cs` +- [x] T005 [P] Add ride snapshot fields in `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` +- [x] T006 [P] Add dashboard preference fields in `src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs` +- [x] T007 Configure snapshot and preference persistence in `src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs` +- [x] T008 Create the EF Core migration in `src/BikeTracking.Api/Infrastructure/Persistence/Migrations/` +- [x] T009 Add migration coverage for the new migration in `src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs` + +**Checkpoint**: Foundation ready. Core dashboard, historical accuracy, and optional metric work can now proceed. + +--- + +## Phase 3: User Story 1 - View core dashboard statistics (Priority: P1) 🎯 MVP + +**Goal**: Replace the current miles landing page with a real dashboard that shows baseline mileage, savings, averages, and trend charts. + +**Independent Test**: Sign in as a rider with seeded ride history and verify the main page shows current-month miles, year-to-date miles, all-time miles, average temperature, average miles per ride, average ride duration, and baseline trend charts. + +### Tests for User Story 1 ⚠️ + +> **NOTE**: Write these tests first, ensure they fail, and get user confirmation before implementation. + +- [x] T010 [US1] Add dashboard aggregation unit tests in `src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs` +- [x] T011 [US1] Add dashboard endpoint tests in `src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs` +- [x] T012 [US1] Add dashboard API client tests in `src/BikeTracking.Frontend/src/services/dashboard-api.test.ts` +- [x] T013 [US1] Add dashboard page rendering tests in `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx` +- [x] T014 [US1] Add dashboard landing and totals E2E coverage in `src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts` + +### Implementation for User Story 1 + +- [x] T015 [US1] Implement dashboard aggregation logic in `src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs` +- [x] T016 [US1] Implement the dashboard endpoint in `src/BikeTracking.Api/Endpoints/DashboardEndpoints.cs` +- [x] T017 [US1] Register dashboard services and endpoint mapping in `src/BikeTracking.Api/Program.cs` +- [x] T018 [US1] Implement the dashboard API client in `src/BikeTracking.Frontend/src/services/dashboard-api.ts` +- [x] T019 [US1] Create dashboard summary card and metric components in `src/BikeTracking.Frontend/src/components/dashboard/dashboard-summary-card.tsx` +- [x] T020 [US1] Create dashboard chart section components in `src/BikeTracking.Frontend/src/components/dashboard/dashboard-chart-section.tsx` +- [x] T021 [US1] Create dashboard empty and partial-data callouts in `src/BikeTracking.Frontend/src/components/dashboard/dashboard-status-panel.tsx` +- [x] T022 [US1] Build the dashboard page layout in `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx` +- [x] T023 [US1] Implement dashboard styling in `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.css` +- [x] T024 [US1] Make the dashboard the authenticated main page in `src/BikeTracking.Frontend/src/App.tsx` +- [x] T025 [US1] Update the main navigation links for the new dashboard route in `src/BikeTracking.Frontend/src/components/app-header/app-header.tsx` +- [x] T026 [US1] Replace the legacy miles page with a redirect shell in `src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.tsx` + +**Checkpoint**: User Story 1 is independently functional when an authenticated rider lands on the dashboard and sees baseline cards, averages, and charts without using ride history. + +--- + +## Phase 4: User Story 2 - Keep historical calculations accurate when settings change (Priority: P2) + +**Goal**: Preserve historically accurate savings and progress calculations by snapshotting calculation-relevant settings on every ride and using those snapshots in dashboard aggregation. + +**Independent Test**: Record a ride, change savings-related user settings, record or edit another ride, and verify the dashboard keeps older ride savings anchored to the original snapshot while newer rides use the updated values. + +### Tests for User Story 2 ⚠️ + +- [x] T027 [US2] Extend ride write-service tests for snapshot capture in `src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs` +- [x] T028 [US2] Add snapshot persistence coverage in `src/BikeTracking.Api.Tests/Infrastructure/RidesPersistenceTests.cs` +- [x] T029 [US2] Add historical-savings stability E2E coverage in `src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts` + +### Implementation for User Story 2 + +- [x] T030 [US2] Add snapshot fields to recorded ride events in `src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs` +- [x] T031 [US2] Add snapshot fields to edited ride events in `src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs` +- [x] T032 [US2] Capture calculation snapshots during ride creation in `src/BikeTracking.Api/Application/Rides/RecordRideService.cs` +- [x] T033 [US2] Refresh calculation snapshots during ride edits in `src/BikeTracking.Api/Application/Rides/EditRideService.cs` +- [x] T034 [US2] Apply snapshot-first and legacy fallback rules in `src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs` + +**Checkpoint**: User Story 2 is independently functional when changing user settings no longer rewrites prior ride savings in the dashboard. + +--- + +## Phase 5: User Story 3 - Review optional metrics before adding them (Priority: P3) + +**Goal**: Let riders approve gallons avoided and goal progress before those optional metrics appear on the dashboard. + +**Independent Test**: Open settings or the dashboard suggestion flow, approve gallons avoided and goal progress, then reload the dashboard and verify those metrics appear only after approval. + +### Tests for User Story 3 ⚠️ + +- [x] T035 [US3] Extend user settings service tests for dashboard approvals in `src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs` +- [x] T036 [US3] Extend user settings endpoint tests for dashboard approvals in `src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs` +- [x] T037 [US3] Extend settings page approval tests in `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx` +- [x] T038 [US3] Add optional-metric approval E2E coverage in `src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts` + +### Implementation for User Story 3 + +- [x] T039 [US3] Persist dashboard approval fields in `src/BikeTracking.Api/Application/Users/UserSettingsService.cs` +- [x] T040 [US3] Accept and return dashboard approval fields in `src/BikeTracking.Api/Endpoints/UsersEndpoints.cs` +- [x] T041 [US3] Extend frontend user settings DTOs in `src/BikeTracking.Frontend/src/services/users-api.ts` +- [x] T042 [US3] Add gallons avoided and goal progress approval controls in `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx` +- [x] T043 [US3] Render optional metric suggestions and approved metrics in `src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx` + +**Checkpoint**: User Story 3 is independently functional when optional metrics stay hidden until approved and appear only after rider opt-in. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation and cleanup across stories. + +- [x] T044 [P] Update manual API examples for dashboard and settings approvals in `src/BikeTracking.Api/BikeTracking.Api.http` +- [x] T045 Code cleanup and shared helper refactoring in `src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs` +- [x] T046 [P] Run full validation from `specs/012-dashboard-stats/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies. Can start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion. Blocks all user stories. +- **User Story 1 (Phase 3)**: Depends on Foundational completion. Delivers the MVP dashboard. +- **User Story 2 (Phase 4)**: Depends on Foundational completion and integrates with the dashboard introduced in User Story 1. +- **User Story 3 (Phase 5)**: Depends on Foundational completion and integrates with the dashboard/settings surfaces introduced in User Story 1. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **User Story 1 (P1)**: No user-story dependency after Foundation. This is the MVP. +- **User Story 2 (P2)**: Uses the dashboard delivered in US1 to prove historical-accuracy behavior, so it should be completed after US1. +- **User Story 3 (P3)**: Uses the dashboard and settings surfaces from US1, so it should be completed after US1. + +### Within Each User Story + +- Test tasks must be written and run red before implementation. +- Backend contracts and persistence come before service implementation. +- Backend API client work comes before page integration. +- Route/navigation changes happen after the dashboard page can render usable data. + +### Parallel Opportunities + +- In Phase 2, `T005` and `T006` can run in parallel because they touch different entity files. +- After foundational work, backend and frontend tests for US1 can proceed independently. +- US2 and US3 can be developed in parallel after US1 if team capacity allows, since one focuses on ride/event snapshots and the other on optional metric approvals. +- Polish tasks `T044` and `T046` can run in parallel with final cleanup if the feature code is already stable. + +--- + +## Parallel Example: User Story 1 + +```bash +# Backend-first red tests: +Task: "Add dashboard aggregation unit tests in src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs" +Task: "Add dashboard endpoint tests in src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs" + +# Frontend red tests: +Task: "Add dashboard API client tests in src/BikeTracking.Frontend/src/services/dashboard-api.test.ts" +Task: "Add dashboard page rendering tests in src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx" +``` + +## Parallel Example: User Story 2 + +```bash +# Historical-accuracy verification work: +Task: "Extend ride write-service tests for snapshot capture in src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs" +Task: "Add snapshot persistence coverage in src/BikeTracking.Api.Tests/Infrastructure/RidesPersistenceTests.cs" +``` + +## Parallel Example: User Story 3 + +```bash +# Optional-metric approval tests: +Task: "Extend user settings service tests for dashboard approvals in src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs" +Task: "Extend settings page approval tests in src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Stop and validate the dashboard as the new authenticated landing page. + +### Incremental Delivery + +1. Deliver the baseline dashboard in US1. +2. Add historically accurate savings snapshots in US2. +3. Add optional metric approvals and gated rendering in US3. +4. Finish with polish and full quickstart validation. + +### Parallel Team Strategy + +1. One developer handles persistence/contracts while another prepares frontend chart infrastructure during Setup/Foundation where possible. +2. After US1 is stable, one developer can implement snapshot accuracy (US2) while another implements optional metric approvals (US3). +3. Merge only after the full validation pass in Phase 6. + +--- + +## Notes + +- `[P]` tasks are limited to work that does not touch the same file and does not depend on incomplete prior tasks. +- Each user story remains independently testable at its checkpoint. +- The task order assumes the repository’s strict TDD workflow: red tests, user confirmation, then implementation. +- `T008` intentionally targets the migrations directory path because the timestamped filename is not known until the migration is generated. \ No newline at end of file diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs new file mode 100644 index 0000000..9b51005 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs @@ -0,0 +1,176 @@ +using BikeTracking.Api.Application.Dashboard; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Tests.Application.Dashboard; + +public sealed class GetDashboardServiceTests +{ + [Fact] + public void GetDashboardService_TypeExists() + { + var serviceType = typeof(BikeTrackingDbContext).Assembly.GetType( + "BikeTracking.Api.Application.Dashboard.GetDashboardService" + ); + + Assert.NotNull(serviceType); + } + + [Fact] + public void GetDashboardService_ExposesAsyncReadMethod() + { + var serviceType = typeof(BikeTrackingDbContext).Assembly.GetType( + "BikeTracking.Api.Application.Dashboard.GetDashboardService" + ); + + Assert.NotNull(serviceType); + + var method = serviceType!.GetMethod("GetAsync") ?? serviceType.GetMethod("ExecuteAsync"); + + Assert.NotNull(method); + } + + [Fact] + public async Task GetDashboardService_UsesRideSnapshotsForSavings_WhenCurrentSettingsChanged() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "Dashboard Snapshot Rider", + NormalizedName = "dashboard snapshot rider", + CreatedAtUtc = DateTime.UtcNow, + }; + dbContext.Users.Add(rider); + await dbContext.SaveChangesAsync(); + + dbContext.UserSettings.Add( + new UserSettingsEntity + { + UserId = rider.UserId, + AverageCarMpg = 40m, + MileageRateCents = 80m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + + dbContext.Rides.Add( + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = DateTime.Now, + Miles = 10m, + GasPricePerGallon = 3m, + SnapshotAverageCarMpg = 20m, + SnapshotMileageRateCents = 50m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext); + var dashboard = await service.GetAsync(rider.UserId); + + Assert.Equal(5m, dashboard.Totals.MoneySaved.MileageRateSavings); + Assert.Equal(1.5m, dashboard.Totals.MoneySaved.FuelCostAvoided); + Assert.Equal(6.5m, dashboard.Totals.MoneySaved.CombinedSavings); + } + + [Fact] + public async Task GetDashboardService_ExcludesLegacyRideWithoutSnapshot_FromSavings() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "Legacy Snapshot Rider", + NormalizedName = "legacy snapshot rider", + CreatedAtUtc = DateTime.UtcNow, + }; + dbContext.Users.Add(rider); + await dbContext.SaveChangesAsync(); + + dbContext.Rides.Add( + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = DateTime.Now, + Miles = 8m, + GasPricePerGallon = 3.2m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext); + var dashboard = await service.GetAsync(rider.UserId); + + Assert.Null(dashboard.Totals.MoneySaved.MileageRateSavings); + Assert.Null(dashboard.Totals.MoneySaved.FuelCostAvoided); + Assert.Null(dashboard.Totals.MoneySaved.CombinedSavings); + Assert.Equal(0, dashboard.Totals.MoneySaved.QualifiedRideCount); + Assert.Equal(1, dashboard.MissingData.RidesMissingSavingsSnapshot); + Assert.Equal(1, dashboard.Totals.AllTimeMiles.RideCount); + Assert.Equal(8m, dashboard.Totals.AllTimeMiles.Miles); + } + + [Fact] + public async Task GetDashboardService_IncludesOptionalMetricValues_WhenDataIsAvailable() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "Optional Metric Rider", + NormalizedName = "optional metric rider", + CreatedAtUtc = DateTime.UtcNow, + }; + dbContext.Users.Add(rider); + await dbContext.SaveChangesAsync(); + + dbContext.UserSettings.Add( + new UserSettingsEntity + { + UserId = rider.UserId, + YearlyGoalMiles = 100m, + DashboardGallonsAvoidedEnabled = true, + DashboardGoalProgressEnabled = true, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + + dbContext.Rides.Add( + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = DateTime.Now, + Miles = 20m, + SnapshotAverageCarMpg = 10m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext); + var dashboard = await service.GetAsync(rider.UserId); + + var gallonsSuggestion = dashboard.Suggestions.Single(metric => + metric.MetricKey == "gallonsAvoided" + ); + var goalSuggestion = dashboard.Suggestions.Single(metric => + metric.MetricKey == "goalProgress" + ); + + Assert.Equal(2m, gallonsSuggestion.Value); + Assert.Equal("gal", gallonsSuggestion.UnitLabel); + Assert.Equal(20m, goalSuggestion.Value); + Assert.Equal("%", goalSuggestion.UnitLabel); + } + + private static BikeTrackingDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new BikeTrackingDbContext(options); + } +} diff --git a/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs index 354d47b..8f1c493 100644 --- a/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs @@ -129,6 +129,102 @@ public async Task GetOrFetchAsync_SecondCall_UsesCacheAndCallsHttpOnce() Assert.Equal(1, cacheCount); } + [Fact] + public async Task GetOrFetchAsync_ForecastRequest_DoesNotCombinePastDaysWithExplicitDateRange() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + await using var context = CreateSqliteContext(connection); + await context.Database.EnsureCreatedAsync(); + + Uri? capturedRequestUri = null; + var handler = new StubHandler(request => + { + capturedRequestUri = request.RequestUri; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + CreateHourlyResponseJson(), + Encoding.UTF8, + "application/json" + ), + }; + }); + + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://api.open-meteo.com") } + ); + var config = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + var service = new OpenMeteoWeatherLookupService( + context, + factory, + config, + NullLogger.Instance + ); + + var lookupTime = new DateTime(2026, 4, 2, 9, 34, 0, DateTimeKind.Utc); + + var result = await service.GetOrFetchAsync(40.7128m, -74.0060m, lookupTime); + + Assert.NotNull(result); + Assert.NotNull(capturedRequestUri); + Assert.Equal("/v1/forecast", capturedRequestUri!.AbsolutePath); + Assert.Contains("start_date=2026-04-02", capturedRequestUri.Query); + Assert.Contains("end_date=2026-04-02", capturedRequestUri.Query); + Assert.DoesNotContain("past_days=", capturedRequestUri.Query); + } + + [Fact] + public async Task GetOrFetchAsync_ArchiveRequest_UsesArchivePathWithExplicitDateRange() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + await using var context = CreateSqliteContext(connection); + await context.Database.EnsureCreatedAsync(); + + Uri? capturedRequestUri = null; + var handler = new StubHandler(request => + { + capturedRequestUri = request.RequestUri; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + CreateArchiveHourlyResponseJson(), + Encoding.UTF8, + "application/json" + ), + }; + }); + + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://archive-api.open-meteo.com") } + ); + var config = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + var service = new OpenMeteoWeatherLookupService( + context, + factory, + config, + NullLogger.Instance + ); + + var lookupTime = new DateTime(2025, 12, 1, 9, 34, 0, DateTimeKind.Utc); + + var result = await service.GetOrFetchAsync(40.7128m, -74.0060m, lookupTime); + + Assert.NotNull(result); + Assert.NotNull(capturedRequestUri); + Assert.Equal("/v1/archive", capturedRequestUri!.AbsolutePath); + Assert.Contains("start_date=2025-12-01", capturedRequestUri.Query); + Assert.Contains("end_date=2025-12-01", capturedRequestUri.Query); + Assert.DoesNotContain("past_days=", capturedRequestUri.Query); + } + [Fact] public async Task GetOrFetchAsync_AfterServiceRestart_UsesPersistedCacheWithoutHttp() { @@ -216,6 +312,25 @@ private static string CreateHourlyResponseJson() """; } + private static string CreateArchiveHourlyResponseJson() + { + return """ + { + "hourly": { + "time": ["2025-12-01T09:00"], + "temperature_2m": [38.5], + "wind_speed_10m": [12.1], + "wind_direction_10m": [215], + "relative_humidity_2m": [74], + "cloud_cover": [60], + "precipitation": [0.0], + "snowfall": [0.0], + "weather_code": [3] + } + } + """; + } + private sealed class StubHttpClientFactory(HttpClient client) : IHttpClientFactory { public HttpClient CreateClient(string name) => client; diff --git a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs index d2cd74e..5c81dbe 100644 --- a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs @@ -98,6 +98,55 @@ public async Task RecordRideService_WithWeatherFields_PersistsWeatherAndEventPay Assert.True(eventPayload.WeatherUserOverridden); } + [Fact] + public async Task RecordRideService_CapturesUserSettingsSnapshots_OnRideAndEventPayload() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Snapshot Rider", + NormalizedName = "snapshot rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + context.UserSettings.Add( + new UserSettingsEntity + { + UserId = user.UserId, + AverageCarMpg = 31.5m, + MileageRateCents = 67m, + YearlyGoalMiles = 2400m, + OilChangePrice = 79m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + + var (rideId, eventPayload) = await service.ExecuteAsync( + user.UserId, + new RecordRideRequest(DateTime.Now, 11m, 30, 63m, 3.29m) + ); + + var persistedRide = await context.Rides.SingleAsync(ride => ride.Id == rideId); + Assert.Equal(31.5m, persistedRide.SnapshotAverageCarMpg); + Assert.Equal(67m, persistedRide.SnapshotMileageRateCents); + Assert.Equal(2400m, persistedRide.SnapshotYearlyGoalMiles); + Assert.Equal(79m, persistedRide.SnapshotOilChangePrice); + + Assert.Equal(31.5m, eventPayload.SnapshotAverageCarMpg); + Assert.Equal(67m, eventPayload.SnapshotMileageRateCents); + Assert.Equal(2400m, eventPayload.SnapshotYearlyGoalMiles); + Assert.Equal(79m, eventPayload.SnapshotOilChangePrice); + } + [Fact] public async Task RecordRideService_ValidatesMillesGreaterThanZero() { @@ -850,6 +899,83 @@ public async Task EditRideService_WhenTimestampUnchanged_DoesNotRefetchWeather() Assert.Equal("rain", persistedRide.PrecipitationType); } + [Fact] + public async Task EditRideService_RefreshesSnapshotFields_FromCurrentSettings() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Snapshot Edit Rider", + NormalizedName = "snapshot edit rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + context.UserSettings.Add( + new UserSettingsEntity + { + UserId = user.UserId, + AverageCarMpg = 32m, + MileageRateCents = 65m, + YearlyGoalMiles = 1800m, + OilChangePrice = 70m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var ride = new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = DateTime.Now.AddDays(-1), + Miles = 8m, + RideMinutes = 28, + GasPricePerGallon = 3.49m, + SnapshotAverageCarMpg = 25m, + SnapshotMileageRateCents = 50m, + SnapshotYearlyGoalMiles = 1200m, + SnapshotOilChangePrice = 55m, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + }; + context.Rides.Add(ride); + await context.SaveChangesAsync(); + + var service = new EditRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + + var result = await service.ExecuteAsync( + user.UserId, + ride.Id, + new EditRideRequest( + RideDateTimeLocal: ride.RideDateTimeLocal, + Miles: 9m, + RideMinutes: 31, + Temperature: 60m, + GasPricePerGallon: 3.59m, + ExpectedVersion: 1 + ) + ); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.EventPayload); + + var updatedRide = await context.Rides.SingleAsync(entity => entity.Id == ride.Id); + Assert.Equal(32m, updatedRide.SnapshotAverageCarMpg); + Assert.Equal(65m, updatedRide.SnapshotMileageRateCents); + Assert.Equal(1800m, updatedRide.SnapshotYearlyGoalMiles); + Assert.Equal(70m, updatedRide.SnapshotOilChangePrice); + + Assert.Equal(32m, result.EventPayload!.SnapshotAverageCarMpg); + Assert.Equal(65m, result.EventPayload.SnapshotMileageRateCents); + Assert.Equal(1800m, result.EventPayload.SnapshotYearlyGoalMiles); + Assert.Equal(70m, result.EventPayload.SnapshotOilChangePrice); + } + private static BikeTrackingDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() diff --git a/src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs index 94eb1c2..865d06f 100644 --- a/src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs @@ -40,6 +40,8 @@ public async Task SaveAsync_CreatesSettingsProfile_ForFirstSave() Assert.Equal(1800m, loaded.Response.Settings.YearlyGoalMiles); Assert.Equal(89.99m, loaded.Response.Settings.OilChangePrice); Assert.Equal(67.5m, loaded.Response.Settings.MileageRateCents); + Assert.False(loaded.Response.Settings.DashboardGallonsAvoidedEnabled); + Assert.False(loaded.Response.Settings.DashboardGoalProgressEnabled); } [Fact] @@ -238,6 +240,79 @@ await service.SaveAsync( Assert.Equal(70m, secondLoaded.Response.Settings.MileageRateCents); } + [Fact] + public async Task SaveAsync_PersistsDashboardApprovalFields_WhenProvided() + { + using var dbContext = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(dbContext, "Settings User H"); + var service = new UserSettingsService(dbContext); + + var result = await service.SaveAsync( + user.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 30m, + YearlyGoalMiles: 1500m, + OilChangePrice: 70m, + MileageRateCents: 60m, + LocationLabel: null, + Latitude: null, + Longitude: null, + DashboardGallonsAvoidedEnabled: true, + DashboardGoalProgressEnabled: true + ), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Response); + Assert.True(result.Response.Settings.DashboardGallonsAvoidedEnabled); + Assert.True(result.Response.Settings.DashboardGoalProgressEnabled); + } + + [Fact] + public async Task SaveAsync_KeepsDashboardApprovals_WhenOmittedFromPartialUpdate() + { + using var dbContext = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(dbContext, "Settings User I"); + var service = new UserSettingsService(dbContext); + + await service.SaveAsync( + user.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 29m, + YearlyGoalMiles: 1200m, + OilChangePrice: 65m, + MileageRateCents: 55m, + LocationLabel: null, + Latitude: null, + Longitude: null, + DashboardGallonsAvoidedEnabled: true, + DashboardGoalProgressEnabled: false + ), + CancellationToken.None + ); + + await service.SaveAsync( + user.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 31m, + YearlyGoalMiles: null, + OilChangePrice: null, + MileageRateCents: null, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + CancellationToken.None, + new HashSet(StringComparer.OrdinalIgnoreCase) { "averageCarMpg" } + ); + + var loaded = await service.GetAsync(user.UserId, CancellationToken.None); + Assert.NotNull(loaded.Response); + Assert.True(loaded.Response.Settings.DashboardGallonsAvoidedEnabled); + Assert.False(loaded.Response.Settings.DashboardGoalProgressEnabled); + } + private static async Task SeedUserAsync( BikeTrackingDbContext dbContext, string name diff --git a/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs new file mode 100644 index 0000000..329238a --- /dev/null +++ b/src/BikeTracking.Api.Tests/Endpoints/DashboardEndpointsTests.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using BikeTracking.Api.Application.Dashboard; +using BikeTracking.Api.Application.Users; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Endpoints; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using BikeTracking.Api.Infrastructure.Security; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Tests.Endpoints; + +public sealed class DashboardEndpointsTests +{ + [Fact] + public async Task GetDashboard_Returns200AndDashboardPayload_ForAuthenticatedRider() + { + await using var host = await DashboardApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Dashboard Rider", "1234"); + + var response = await host.Client.GetWithAuthAsync("/api/dashboard", userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal(0m, payload.Totals.CurrentMonthMiles.Miles); + } + + private sealed class DashboardApiHost(WebApplication app) : IAsyncDisposable + { + public HttpClient Client { get; } = app.GetTestClient(); + + public static async Task StartAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + var databaseName = Guid.NewGuid().ToString(); + + builder.Services.Configure(_ => { }); + builder.Services.AddDbContext(options => + options.UseInMemoryDatabase(databaseName) + ); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder + .Services.AddAuthentication(UserIdHeaderAuthenticationHandler.SchemeName) + .AddScheme< + UserIdHeaderAuthenticationSchemeOptions, + UserIdHeaderAuthenticationHandler + >(UserIdHeaderAuthenticationHandler.SchemeName, _ => { }); + builder.Services.AddAuthorization(); + + var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapUsersEndpoints(); + TryMapDashboardEndpoints(app); + await app.StartAsync(); + + return new DashboardApiHost(app); + } + + public async Task SeedUserAsync(string displayName, string pin) + { + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var hasher = scope.ServiceProvider.GetRequiredService(); + + var hashResult = hasher.Hash(pin); + var user = new UserEntity + { + DisplayName = displayName, + NormalizedName = UserNameNormalizer.Normalize(displayName), + CreatedAtUtc = DateTime.UtcNow, + IsActive = true, + }; + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + + dbContext.UserCredentials.Add( + new UserCredentialEntity + { + UserId = user.UserId, + PinHash = hashResult.Hash, + PinSalt = hashResult.Salt, + HashAlgorithm = hashResult.Algorithm, + IterationCount = hashResult.Iterations, + CredentialVersion = hashResult.CredentialVersion, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + + await dbContext.SaveChangesAsync(); + return user.UserId; + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await app.StopAsync(); + await app.DisposeAsync(); + } + + private static void TryMapDashboardEndpoints(IEndpointRouteBuilder endpoints) + { + var dashboardEndpointsType = typeof(UsersEndpoints).Assembly.GetType( + "BikeTracking.Api.Endpoints.DashboardEndpoints" + ); + var mapMethod = dashboardEndpointsType?.GetMethod( + "MapDashboardEndpoints", + [typeof(IEndpointRouteBuilder)] + ); + + mapMethod?.Invoke(null, [endpoints]); + } + } +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs index 6f3c48f..6798392 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs @@ -264,6 +264,43 @@ await host.Client.PutWithAuthAsync( Assert.Equal(33m, secondPayload.Settings.AverageCarMpg); } + [Fact] + public async Task PutThenGetUserSettings_RoundTripsDashboardApprovals() + { + await using var host = await IdentifyApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ApprovalsCase", "1234"); + + var putResponse = await host.Client.PutWithAuthAsync( + "/api/users/me/settings", + new UserSettingsUpsertRequest( + AverageCarMpg: 31m, + YearlyGoalMiles: 2000m, + OilChangePrice: 75m, + MileageRateCents: 67m, + LocationLabel: null, + Latitude: null, + Longitude: null, + DashboardGallonsAvoidedEnabled: true, + DashboardGoalProgressEnabled: true + ), + userId + ); + + Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); + + var payload = await putResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.True(payload.Settings.DashboardGallonsAvoidedEnabled); + Assert.True(payload.Settings.DashboardGoalProgressEnabled); + + var getResponse = await host.Client.GetWithAuthAsync("/api/users/me/settings", userId); + var getPayload = await getResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(getPayload); + Assert.True(getPayload.Settings.DashboardGallonsAvoidedEnabled); + Assert.True(getPayload.Settings.DashboardGoalProgressEnabled); + } + private sealed class IdentifyApiHost(WebApplication app) : IAsyncDisposable { public HttpClient Client { get; } = app.GetTestClient(); diff --git a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs index 0027a17..d63660e 100644 --- a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs +++ b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs @@ -28,6 +28,8 @@ public sealed class MigrationTestCoveragePolicyTests "Added test: rides persistence tests validate weather columns round-trip after schema migration.", ["20260403192854_AddWeatherLookupCache"] = "Added test: weather lookup service tests validate cache read/write through weather lookup table.", + ["20260406183601_AddDashboardSnapshotsAndPreferences"] = + "Added test: dashboard and user settings coverage validates snapshot and preference columns after schema migration.", }; [Fact] diff --git a/src/BikeTracking.Api.Tests/Infrastructure/RidesPersistenceTests.cs b/src/BikeTracking.Api.Tests/Infrastructure/RidesPersistenceTests.cs index 8d9578b..f0d17bf 100644 --- a/src/BikeTracking.Api.Tests/Infrastructure/RidesPersistenceTests.cs +++ b/src/BikeTracking.Api.Tests/Infrastructure/RidesPersistenceTests.cs @@ -40,6 +40,43 @@ public async Task DbContext_CanSaveRideEntity_WithAllFields() Assert.Equal(72m, retrieved.Temperature); } + [Fact] + public async Task DbContext_CanRoundTripRideSnapshotFields() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Snapshot Persist Rider", + NormalizedName = "snapshot persist rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + context.Rides.Add( + new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = DateTime.Now, + Miles = 14m, + RideMinutes = 42, + GasPricePerGallon = 3.55m, + SnapshotAverageCarMpg = 34.2m, + SnapshotMileageRateCents = 67m, + SnapshotYearlyGoalMiles = 2500m, + SnapshotOilChangePrice = 95m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var retrieved = await context.Rides.SingleAsync(); + Assert.Equal(34.2m, retrieved.SnapshotAverageCarMpg); + Assert.Equal(67m, retrieved.SnapshotMileageRateCents); + Assert.Equal(2500m, retrieved.SnapshotYearlyGoalMiles); + Assert.Equal(95m, retrieved.SnapshotOilChangePrice); + } + [Fact] public async Task DbContext_AllowsNullOptionalFields() { diff --git a/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs new file mode 100644 index 0000000..7a3f31c --- /dev/null +++ b/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs @@ -0,0 +1,339 @@ +using System.Globalization; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Application.Dashboard; + +public sealed class GetDashboardService(BikeTrackingDbContext dbContext) +{ + public async Task GetAsync( + long riderId, + CancellationToken cancellationToken = default + ) + { + var rides = await dbContext + .Rides.Where(ride => ride.RiderId == riderId) + .OrderBy(ride => ride.RideDateTimeLocal) + .AsNoTracking() + .ToListAsync(cancellationToken); + + var settings = await dbContext + .UserSettings.AsNoTracking() + .SingleOrDefaultAsync(setting => setting.UserId == riderId, cancellationToken); + + var nowLocal = DateTime.Now; + var currentMonthStart = new DateTime(nowLocal.Year, nowLocal.Month, 1); + var nextMonthStart = currentMonthStart.AddMonths(1); + var currentYearStart = new DateTime(nowLocal.Year, 1, 1); + var nextYearStart = currentYearStart.AddYears(1); + + var currentMonthRides = rides + .Where(ride => + ride.RideDateTimeLocal >= currentMonthStart + && ride.RideDateTimeLocal < nextMonthStart + ) + .ToList(); + var currentYearRides = rides + .Where(ride => + ride.RideDateTimeLocal >= currentYearStart && ride.RideDateTimeLocal < nextYearStart + ) + .ToList(); + var yearToDateMiles = currentYearRides.Sum(ride => ride.Miles); + var gallonsAvoided = CalculateGallonsAvoided(rides); + + var savings = CalculateSavings(rides); + + return new DashboardResponse( + Totals: new DashboardTotals( + CurrentMonthMiles: CreateMileageMetric(currentMonthRides, "thisMonth"), + YearToDateMiles: CreateMileageMetric(currentYearRides, "thisYear"), + AllTimeMiles: CreateMileageMetric(rides, "allTime"), + MoneySaved: savings.Totals + ), + Averages: new DashboardAverages( + AverageTemperature: CalculateAverageTemperature(rides), + AverageMilesPerRide: CalculateAverageMilesPerRide(rides), + AverageRideMinutes: CalculateAverageRideMinutes(rides) + ), + Charts: new DashboardCharts( + MileageByMonth: BuildMileageSeries(rides, nowLocal), + SavingsByMonth: BuildSavingsSeries(rides, nowLocal) + ), + Suggestions: BuildSuggestions(settings, gallonsAvoided, yearToDateMiles), + MissingData: new DashboardMissingData( + RidesMissingSavingsSnapshot: rides.Count(ride => + ride.SnapshotMileageRateCents is null || ride.SnapshotAverageCarMpg is null + ), + RidesMissingGasPrice: rides.Count(ride => ride.GasPricePerGallon is null), + RidesMissingTemperature: rides.Count(ride => ride.Temperature is null), + RidesMissingDuration: rides.Count(ride => ride.RideMinutes is null) + ), + GeneratedAtUtc: DateTime.UtcNow + ); + } + + private static DashboardMileageMetric CreateMileageMetric( + IReadOnlyCollection rides, + string period + ) + { + return new DashboardMileageMetric( + Miles: rides.Sum(ride => ride.Miles), + RideCount: rides.Count, + Period: period + ); + } + + private static decimal? CalculateAverageTemperature(IReadOnlyCollection rides) + { + var temperatures = rides + .Where(ride => ride.Temperature.HasValue) + .Select(ride => ride.Temperature!.Value) + .ToList(); + return temperatures.Count == 0 + ? null + : decimal.Round(temperatures.Average(), 1, MidpointRounding.AwayFromZero); + } + + private static decimal? CalculateAverageMilesPerRide(IReadOnlyCollection rides) + { + return rides.Count == 0 + ? null + : decimal.Round(rides.Average(ride => ride.Miles), 1, MidpointRounding.AwayFromZero); + } + + private static decimal? CalculateAverageRideMinutes(IReadOnlyCollection rides) + { + var rideMinutes = rides + .Where(ride => ride.RideMinutes.HasValue) + .Select(ride => ride.RideMinutes!.Value) + .ToList(); + return rideMinutes.Count == 0 + ? null + : decimal.Round((decimal)rideMinutes.Average(), 1, MidpointRounding.AwayFromZero); + } + + private static SavingsComputation CalculateSavings(IReadOnlyCollection rides) + { + var totals = AggregateSavings(rides); + decimal? combinedSavings = totals.HasAnySavings + ? totals.MileageRateSavings + totals.FuelCostAvoided + : null; + + return new SavingsComputation( + new DashboardMoneySaved( + MileageRateSavings: totals.HasMileageRateSavings + ? RoundMoney(totals.MileageRateSavings) + : null, + FuelCostAvoided: totals.HasFuelCostAvoided + ? RoundMoney(totals.FuelCostAvoided) + : null, + CombinedSavings: RoundMoney(combinedSavings), + QualifiedRideCount: totals.QualifiedRideCount + ) + ); + } + + private static IReadOnlyList BuildMileageSeries( + IReadOnlyCollection rides, + DateTime nowLocal + ) + { + return EnumerateRollingMonths(nowLocal) + .Select(month => + { + var monthMiles = rides + .Where(ride => IsWithinMonth(ride.RideDateTimeLocal, month.Year, month.Month)) + .Sum(ride => ride.Miles); + + return new DashboardMileagePoint( + MonthKey: GetMonthKey(month.Year, month.Month), + Label: month.ToString("MMM", CultureInfo.InvariantCulture), + Miles: monthMiles + ); + }) + .ToList(); + } + + private static IReadOnlyList BuildSavingsSeries( + IReadOnlyCollection rides, + DateTime nowLocal + ) + { + return EnumerateRollingMonths(nowLocal) + .Select(month => + { + var monthRides = rides + .Where(ride => IsWithinMonth(ride.RideDateTimeLocal, month.Year, month.Month)) + .ToList(); + + var monthSavings = AggregateSavings(monthRides); + decimal? combinedSavings = monthSavings.HasAnySavings + ? monthSavings.MileageRateSavings + monthSavings.FuelCostAvoided + : null; + + return new DashboardSavingsPoint( + MonthKey: GetMonthKey(month.Year, month.Month), + Label: month.ToString("MMM", CultureInfo.InvariantCulture), + MileageRateSavings: monthSavings.HasMileageRateSavings + ? RoundMoney(monthSavings.MileageRateSavings) + : null, + FuelCostAvoided: monthSavings.HasFuelCostAvoided + ? RoundMoney(monthSavings.FuelCostAvoided) + : null, + CombinedSavings: RoundMoney(combinedSavings) + ); + }) + .ToList(); + } + + private static SavingsAggregate AggregateSavings(IEnumerable rides) + { + var mileageRateSavings = 0m; + var fuelCostAvoided = 0m; + var qualifiedRideCount = 0; + var hasMileageRateSavings = false; + var hasFuelCostAvoided = false; + + foreach (var ride in rides) + { + var rideMileageRateSavings = CalculateMileageRateSavings(ride); + var rideFuelCostAvoided = CalculateFuelCostAvoided(ride); + + if (rideMileageRateSavings.HasValue || rideFuelCostAvoided.HasValue) + { + qualifiedRideCount++; + } + + if (rideMileageRateSavings.HasValue) + { + hasMileageRateSavings = true; + mileageRateSavings += rideMileageRateSavings.Value; + } + + if (rideFuelCostAvoided.HasValue) + { + hasFuelCostAvoided = true; + fuelCostAvoided += rideFuelCostAvoided.Value; + } + } + + return new SavingsAggregate( + MileageRateSavings: mileageRateSavings, + FuelCostAvoided: fuelCostAvoided, + QualifiedRideCount: qualifiedRideCount, + HasMileageRateSavings: hasMileageRateSavings, + HasFuelCostAvoided: hasFuelCostAvoided + ); + } + + private static IReadOnlyList BuildSuggestions( + UserSettingsEntity? settings, + decimal? gallonsAvoided, + decimal yearToDateMiles + ) + { + var yearlyGoalMiles = settings?.YearlyGoalMiles; + decimal? goalProgressPercent = + yearlyGoalMiles is decimal goal && goal > 0m + ? decimal.Round((yearToDateMiles / goal) * 100m, 1, MidpointRounding.AwayFromZero) + : null; + + return + [ + new DashboardMetricSuggestion( + MetricKey: "gallonsAvoided", + Title: "Gallons Avoided", + Description: "See how much gas your rides kept in the tank.", + IsEnabled: settings?.DashboardGallonsAvoidedEnabled ?? false, + Value: gallonsAvoided, + UnitLabel: "gal" + ), + new DashboardMetricSuggestion( + MetricKey: "goalProgress", + Title: "Goal Progress", + Description: "Compare your riding pace to your yearly mileage goal.", + IsEnabled: settings?.DashboardGoalProgressEnabled ?? false, + Value: goalProgressPercent, + UnitLabel: "%" + ), + ]; + } + + private static decimal? CalculateGallonsAvoided(IReadOnlyCollection rides) + { + var totalGallonsAvoided = rides + .Where(ride => + ride.SnapshotAverageCarMpg is decimal averageCarMpg && averageCarMpg > 0m + ) + .Select(ride => ride.Miles / ride.SnapshotAverageCarMpg!.Value) + .DefaultIfEmpty(0m) + .Sum(); + + return totalGallonsAvoided > 0m + ? decimal.Round(totalGallonsAvoided, 2, MidpointRounding.AwayFromZero) + : null; + } + + private static IEnumerable EnumerateRollingMonths(DateTime nowLocal) + { + var start = new DateTime(nowLocal.Year, nowLocal.Month, 1).AddMonths(-11); + + for (var offset = 0; offset < 12; offset++) + { + yield return start.AddMonths(offset); + } + } + + private static bool IsWithinMonth(DateTime value, int year, int month) + { + return value.Year == year && value.Month == month; + } + + private static string GetMonthKey(int year, int month) + { + return $"{year:D4}-{month:D2}"; + } + + private static decimal? CalculateMileageRateSavings(RideEntity ride) + { + return ride.SnapshotMileageRateCents is decimal mileageRateCents + ? ride.Miles * mileageRateCents / 100m + : null; + } + + private static decimal? CalculateFuelCostAvoided(RideEntity ride) + { + if (ride.SnapshotAverageCarMpg is not decimal averageCarMpg || averageCarMpg <= 0m) + { + return null; + } + + if (ride.GasPricePerGallon is not decimal gasPricePerGallon) + { + return null; + } + + return ride.Miles / averageCarMpg * gasPricePerGallon; + } + + private static decimal? RoundMoney(decimal? value) + { + return value.HasValue ? decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero) : null; + } + + private sealed record SavingsAggregate( + decimal MileageRateSavings, + decimal FuelCostAvoided, + int QualifiedRideCount, + bool HasMileageRateSavings, + bool HasFuelCostAvoided + ) + { + public bool HasAnySavings => HasMileageRateSavings || HasFuelCostAvoided; + } + + private sealed record SavingsComputation(DashboardMoneySaved Totals); +} diff --git a/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs b/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs index 0781e7d..5b653e8 100644 --- a/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs +++ b/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs @@ -19,6 +19,10 @@ public sealed record RideEditedEventPayload( int? CloudCoverPercent, string? PrecipitationType, bool WeatherUserOverridden, + decimal? SnapshotAverageCarMpg, + decimal? SnapshotMileageRateCents, + decimal? SnapshotYearlyGoalMiles, + decimal? SnapshotOilChangePrice, string Source ) { @@ -41,6 +45,10 @@ public static RideEditedEventPayload Create( int? cloudCoverPercent = null, string? precipitationType = null, bool weatherUserOverridden = false, + decimal? snapshotAverageCarMpg = null, + decimal? snapshotMileageRateCents = null, + decimal? snapshotYearlyGoalMiles = null, + decimal? snapshotOilChangePrice = null, DateTime? occurredAtUtc = null ) { @@ -63,6 +71,10 @@ public static RideEditedEventPayload Create( CloudCoverPercent: cloudCoverPercent, PrecipitationType: precipitationType, WeatherUserOverridden: weatherUserOverridden, + SnapshotAverageCarMpg: snapshotAverageCarMpg, + SnapshotMileageRateCents: snapshotMileageRateCents, + SnapshotYearlyGoalMiles: snapshotYearlyGoalMiles, + SnapshotOilChangePrice: snapshotOilChangePrice, Source: SourceName ); } diff --git a/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs b/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs index ec58b8a..1c7b5d5 100644 --- a/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs +++ b/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs @@ -16,6 +16,10 @@ public sealed record RideRecordedEventPayload( int? CloudCoverPercent, string? PrecipitationType, bool WeatherUserOverridden, + decimal? SnapshotAverageCarMpg, + decimal? SnapshotMileageRateCents, + decimal? SnapshotYearlyGoalMiles, + decimal? SnapshotOilChangePrice, string Source ) { @@ -35,6 +39,10 @@ public static RideRecordedEventPayload Create( int? cloudCoverPercent = null, string? precipitationType = null, bool weatherUserOverridden = false, + decimal? snapshotAverageCarMpg = null, + decimal? snapshotMileageRateCents = null, + decimal? snapshotYearlyGoalMiles = null, + decimal? snapshotOilChangePrice = null, DateTime? occurredAtUtc = null ) { @@ -54,6 +62,10 @@ public static RideRecordedEventPayload Create( CloudCoverPercent: cloudCoverPercent, PrecipitationType: precipitationType, WeatherUserOverridden: weatherUserOverridden, + SnapshotAverageCarMpg: snapshotAverageCarMpg, + SnapshotMileageRateCents: snapshotMileageRateCents, + SnapshotYearlyGoalMiles: snapshotYearlyGoalMiles, + SnapshotOilChangePrice: snapshotOilChangePrice, Source: SourceName ); } diff --git a/src/BikeTracking.Api/Application/Rides/EditRideService.cs b/src/BikeTracking.Api/Application/Rides/EditRideService.cs index bf459f5..c3bf4bb 100644 --- a/src/BikeTracking.Api/Application/Rides/EditRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/EditRideService.cs @@ -88,14 +88,13 @@ public async Task ExecuteAsync( var existingRideDateTimeLocal = ride.RideDateTimeLocal; var rideDateTimeChanged = request.RideDateTimeLocal != existingRideDateTimeLocal; + var userSettings = await dbContext + .UserSettings.AsNoTracking() + .SingleOrDefaultAsync(settings => settings.UserId == riderId, cancellationToken); WeatherData? fetchedWeather = null; if (!request.WeatherUserOverridden && rideDateTimeChanged) { - var userSettings = await dbContext - .UserSettings.AsNoTracking() - .SingleOrDefaultAsync(settings => settings.UserId == riderId, cancellationToken); - if ( userSettings?.Latitude is decimal latitude && userSettings.Longitude is decimal longitude @@ -124,6 +123,10 @@ public async Task ExecuteAsync( ride.RideMinutes = request.RideMinutes; ride.Temperature = temperature; ride.GasPricePerGallon = request.GasPricePerGallon; + ride.SnapshotAverageCarMpg = userSettings?.AverageCarMpg; + ride.SnapshotMileageRateCents = userSettings?.MileageRateCents; + ride.SnapshotYearlyGoalMiles = userSettings?.YearlyGoalMiles; + ride.SnapshotOilChangePrice = userSettings?.OilChangePrice; ride.WindSpeedMph = windSpeedMph; ride.WindDirectionDeg = windDirectionDeg; ride.RelativeHumidityPercent = relativeHumidityPercent; @@ -150,6 +153,10 @@ public async Task ExecuteAsync( cloudCoverPercent: cloudCoverPercent, precipitationType: precipitationType, weatherUserOverridden: request.WeatherUserOverridden, + snapshotAverageCarMpg: ride.SnapshotAverageCarMpg, + snapshotMileageRateCents: ride.SnapshotMileageRateCents, + snapshotYearlyGoalMiles: ride.SnapshotYearlyGoalMiles, + snapshotOilChangePrice: ride.SnapshotOilChangePrice, occurredAtUtc: utcNow ); diff --git a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs index 23d9802..7f100bc 100644 --- a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs @@ -36,13 +36,13 @@ ILogger logger ); } + var userSettings = await dbContext + .UserSettings.AsNoTracking() + .SingleOrDefaultAsync(settings => settings.UserId == riderId, cancellationToken); + WeatherData? weatherData = null; if (!request.WeatherUserOverridden) { - var userSettings = await dbContext - .UserSettings.AsNoTracking() - .SingleOrDefaultAsync(settings => settings.UserId == riderId, cancellationToken); - if ( userSettings?.Latitude is decimal latitude && userSettings.Longitude is decimal longitude @@ -81,6 +81,10 @@ ILogger logger RideMinutes = request.RideMinutes, Temperature = temperature, GasPricePerGallon = request.GasPricePerGallon, + SnapshotAverageCarMpg = userSettings?.AverageCarMpg, + SnapshotMileageRateCents = userSettings?.MileageRateCents, + SnapshotYearlyGoalMiles = userSettings?.YearlyGoalMiles, + SnapshotOilChangePrice = userSettings?.OilChangePrice, WindSpeedMph = windSpeedMph, WindDirectionDeg = windDirectionDeg, RelativeHumidityPercent = relativeHumidityPercent, @@ -105,7 +109,11 @@ ILogger logger relativeHumidityPercent: relativeHumidityPercent, cloudCoverPercent: cloudCoverPercent, precipitationType: precipitationType, - weatherUserOverridden: request.WeatherUserOverridden + weatherUserOverridden: request.WeatherUserOverridden, + snapshotAverageCarMpg: rideEntity.SnapshotAverageCarMpg, + snapshotMileageRateCents: rideEntity.SnapshotMileageRateCents, + snapshotYearlyGoalMiles: rideEntity.SnapshotYearlyGoalMiles, + snapshotOilChangePrice: rideEntity.SnapshotOilChangePrice ); logger.LogInformation( diff --git a/src/BikeTracking.Api/Application/Users/UserSettingsService.cs b/src/BikeTracking.Api/Application/Users/UserSettingsService.cs index 1d54050..41a0526 100644 --- a/src/BikeTracking.Api/Application/Users/UserSettingsService.cs +++ b/src/BikeTracking.Api/Application/Users/UserSettingsService.cs @@ -16,6 +16,8 @@ public sealed class UserSettingsService(BikeTrackingDbContext dbContext) "locationlabel", "latitude", "longitude", + "dashboardgallonsavoidedenabled", + "dashboardgoalprogressenabled", ]; private readonly BikeTrackingDbContext _dbContext = dbContext; @@ -100,6 +102,16 @@ public async Task SaveAsync( request.Longitude, normalizedFields.Contains("longitude") ); + var dashboardGallonsAvoidedEnabled = ResolveBoolean( + existing?.DashboardGallonsAvoidedEnabled ?? false, + request.DashboardGallonsAvoidedEnabled, + normalizedFields.Contains("dashboardgallonsavoidedenabled") + ); + var dashboardGoalProgressEnabled = ResolveBoolean( + existing?.DashboardGoalProgressEnabled ?? false, + request.DashboardGoalProgressEnabled, + normalizedFields.Contains("dashboardgoalprogressenabled") + ); if (averageCarMpg is <= 0) return UserSettingsResult.Failure( @@ -158,6 +170,8 @@ public async Task SaveAsync( LocationLabel = locationLabel, Latitude = mergedLatitude, Longitude = mergedLongitude, + DashboardGallonsAvoidedEnabled = dashboardGallonsAvoidedEnabled, + DashboardGoalProgressEnabled = dashboardGoalProgressEnabled, UpdatedAtUtc = DateTime.UtcNow, }; @@ -172,6 +186,8 @@ public async Task SaveAsync( existing.LocationLabel = locationLabel; existing.Latitude = mergedLatitude; existing.Longitude = mergedLongitude; + existing.DashboardGallonsAvoidedEnabled = dashboardGallonsAvoidedEnabled; + existing.DashboardGoalProgressEnabled = dashboardGoalProgressEnabled; existing.UpdatedAtUtc = DateTime.UtcNow; } @@ -191,6 +207,8 @@ private static UserSettingsResponse ToResponse(UserSettingsEntity entity) LocationLabel: entity.LocationLabel, Latitude: entity.Latitude, Longitude: entity.Longitude, + DashboardGallonsAvoidedEnabled: entity.DashboardGallonsAvoidedEnabled, + DashboardGoalProgressEnabled: entity.DashboardGoalProgressEnabled, UpdatedAtUtc: entity.UpdatedAtUtc ) ); @@ -226,6 +244,11 @@ bool isProvided { return isProvided ? requested : existing; } + + private static bool ResolveBoolean(bool existing, bool? requested, bool isProvided) + { + return isProvided ? requested ?? false : existing; + } } public sealed record UserSettingsResult( diff --git a/src/BikeTracking.Api/BikeTracking.Api.http b/src/BikeTracking.Api/BikeTracking.Api.http index fb8460b..baf3aad 100644 --- a/src/BikeTracking.Api/BikeTracking.Api.http +++ b/src/BikeTracking.Api/BikeTracking.Api.http @@ -99,6 +99,34 @@ X-User-Id: {{RiderId}} "mileageRateCents": 70.0 } +### Save user settings (dashboard optional approvals) +PUT {{ApiService_HostAddress}}/api/users/me/settings +Content-Type: application/json +X-User-Id: {{RiderId}} + +{ + "dashboardGallonsAvoidedEnabled": true, + "dashboardGoalProgressEnabled": true +} + +### Save user settings (disable one optional metric) +PUT {{ApiService_HostAddress}}/api/users/me/settings +Content-Type: application/json +X-User-Id: {{RiderId}} + +{ + "dashboardGoalProgressEnabled": false +} + +### Get dashboard (authenticated) +GET {{ApiService_HostAddress}}/api/dashboard +Accept: application/json +X-User-Id: {{RiderId}} + +### Edge case: unauthenticated dashboard request (expects 401) +GET {{ApiService_HostAddress}}/api/dashboard +Accept: application/json + ### Edge case: unauthenticated settings request (expects 401) GET {{ApiService_HostAddress}}/api/users/me/settings Accept: application/json diff --git a/src/BikeTracking.Api/Contracts/DashboardContracts.cs b/src/BikeTracking.Api/Contracts/DashboardContracts.cs new file mode 100644 index 0000000..75d4ef1 --- /dev/null +++ b/src/BikeTracking.Api/Contracts/DashboardContracts.cs @@ -0,0 +1,63 @@ +namespace BikeTracking.Api.Contracts; + +public sealed record DashboardResponse( + DashboardTotals Totals, + DashboardAverages Averages, + DashboardCharts Charts, + IReadOnlyList Suggestions, + DashboardMissingData MissingData, + DateTime GeneratedAtUtc +); + +public sealed record DashboardTotals( + DashboardMileageMetric CurrentMonthMiles, + DashboardMileageMetric YearToDateMiles, + DashboardMileageMetric AllTimeMiles, + DashboardMoneySaved MoneySaved +); + +public sealed record DashboardMileageMetric(decimal Miles, int RideCount, string Period); + +public sealed record DashboardMoneySaved( + decimal? MileageRateSavings, + decimal? FuelCostAvoided, + decimal? CombinedSavings, + int QualifiedRideCount +); + +public sealed record DashboardAverages( + decimal? AverageTemperature, + decimal? AverageMilesPerRide, + decimal? AverageRideMinutes +); + +public sealed record DashboardCharts( + IReadOnlyList MileageByMonth, + IReadOnlyList SavingsByMonth +); + +public sealed record DashboardMileagePoint(string MonthKey, string Label, decimal Miles); + +public sealed record DashboardSavingsPoint( + string MonthKey, + string Label, + decimal? MileageRateSavings, + decimal? FuelCostAvoided, + decimal? CombinedSavings +); + +public sealed record DashboardMetricSuggestion( + string MetricKey, + string Title, + string Description, + bool IsEnabled, + decimal? Value = null, + string? UnitLabel = null +); + +public sealed record DashboardMissingData( + int RidesMissingSavingsSnapshot, + int RidesMissingGasPrice, + int RidesMissingTemperature, + int RidesMissingDuration +); diff --git a/src/BikeTracking.Api/Contracts/UsersContracts.cs b/src/BikeTracking.Api/Contracts/UsersContracts.cs index 765b87d..5ca3722 100644 --- a/src/BikeTracking.Api/Contracts/UsersContracts.cs +++ b/src/BikeTracking.Api/Contracts/UsersContracts.cs @@ -20,7 +20,9 @@ public sealed record UserSettingsUpsertRequest( decimal? MileageRateCents, string? LocationLabel, decimal? Latitude, - decimal? Longitude + decimal? Longitude, + bool? DashboardGallonsAvoidedEnabled = null, + bool? DashboardGoalProgressEnabled = null ); public sealed record UserSettingsView( @@ -31,7 +33,9 @@ public sealed record UserSettingsView( string? LocationLabel, decimal? Latitude, decimal? Longitude, - DateTime? UpdatedAtUtc + bool DashboardGallonsAvoidedEnabled = false, + bool DashboardGoalProgressEnabled = false, + DateTime? UpdatedAtUtc = null ); public sealed record UserSettingsResponse(bool HasSettings, UserSettingsView Settings); diff --git a/src/BikeTracking.Api/Endpoints/DashboardEndpoints.cs b/src/BikeTracking.Api/Endpoints/DashboardEndpoints.cs new file mode 100644 index 0000000..dc1188f --- /dev/null +++ b/src/BikeTracking.Api/Endpoints/DashboardEndpoints.cs @@ -0,0 +1,37 @@ +using BikeTracking.Api.Application.Dashboard; +using BikeTracking.Api.Contracts; +using Microsoft.AspNetCore.Mvc; + +namespace BikeTracking.Api.Endpoints; + +public static class DashboardEndpoints +{ + public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints + .MapGet("/api/dashboard", GetDashboardAsync) + .RequireAuthorization() + .WithName("GetDashboard") + .WithSummary("Get the authenticated rider dashboard") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized); + + return endpoints; + } + + private static async Task GetDashboardAsync( + HttpContext context, + [FromServices] GetDashboardService dashboardService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + { + return Results.Unauthorized(); + } + + var response = await dashboardService.GetAsync(riderId, cancellationToken); + return Results.Ok(response); + } +} diff --git a/src/BikeTracking.Api/Endpoints/UsersEndpoints.cs b/src/BikeTracking.Api/Endpoints/UsersEndpoints.cs index c41a770..882435b 100644 --- a/src/BikeTracking.Api/Endpoints/UsersEndpoints.cs +++ b/src/BikeTracking.Api/Endpoints/UsersEndpoints.cs @@ -52,7 +52,7 @@ public static IEndpointRouteBuilder MapUsersEndpoints(this IEndpointRouteBuilder private static async Task SignupAsync( [FromBody] SignupRequest request, - SignupService signupService, + [FromServices] SignupService signupService, CancellationToken cancellationToken ) { @@ -76,7 +76,7 @@ CancellationToken cancellationToken private static async Task IdentifyAsync( [FromBody] IdentifyRequest request, - IdentifyService identifyService, + [FromServices] IdentifyService identifyService, HttpContext httpContext, CancellationToken cancellationToken ) @@ -112,7 +112,7 @@ private static IResult ToThrottleResult(IdentifyResult result, HttpContext httpC private static async Task GetUserSettings( HttpContext context, - UserSettingsService userSettingsService, + [FromServices] UserSettingsService userSettingsService, CancellationToken cancellationToken ) { @@ -133,7 +133,7 @@ CancellationToken cancellationToken private static async Task PutUserSettings( HttpContext context, [FromBody] JsonElement requestBody, - UserSettingsService userSettingsService, + [FromServices] UserSettingsService userSettingsService, CancellationToken cancellationToken ) { diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs index 77f5931..a171368 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs @@ -95,6 +95,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0" ); + tableBuilder.HasCheckConstraint( + "CK_Rides_SnapshotAverageCarMpg_Positive", + "\"SnapshotAverageCarMpg\" IS NULL OR CAST(\"SnapshotAverageCarMpg\" AS REAL) > 0" + ); + tableBuilder.HasCheckConstraint( + "CK_Rides_SnapshotMileageRateCents_Positive", + "\"SnapshotMileageRateCents\" IS NULL OR CAST(\"SnapshotMileageRateCents\" AS REAL) > 0" + ); + tableBuilder.HasCheckConstraint( + "CK_Rides_SnapshotYearlyGoalMiles_Positive", + "\"SnapshotYearlyGoalMiles\" IS NULL OR CAST(\"SnapshotYearlyGoalMiles\" AS REAL) > 0" + ); + tableBuilder.HasCheckConstraint( + "CK_Rides_SnapshotOilChangePrice_Positive", + "\"SnapshotOilChangePrice\" IS NULL OR CAST(\"SnapshotOilChangePrice\" AS REAL) > 0" + ); } ); entity.HasKey(static x => x.Id); @@ -102,6 +118,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(static x => x.RideDateTimeLocal).IsRequired(); entity.Property(static x => x.Miles).IsRequired(); entity.Property(static x => x.GasPricePerGallon).HasPrecision(10, 4); + entity.Property(static x => x.SnapshotAverageCarMpg).HasPrecision(10, 4); + entity.Property(static x => x.SnapshotMileageRateCents).HasPrecision(10, 4); + entity.Property(static x => x.SnapshotYearlyGoalMiles).HasPrecision(10, 4); + entity.Property(static x => x.SnapshotOilChangePrice).HasPrecision(10, 4); entity.Property(static x => x.WindSpeedMph).HasPrecision(10, 4); entity.Property(static x => x.WindDirectionDeg); entity.Property(static x => x.RelativeHumidityPercent); @@ -212,6 +232,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(static x => x.LocationLabel).HasMaxLength(200); entity.Property(static x => x.Latitude); entity.Property(static x => x.Longitude); + entity + .Property(static x => x.DashboardGallonsAvoidedEnabled) + .IsRequired() + .HasDefaultValue(false); + entity + .Property(static x => x.DashboardGoalProgressEnabled) + .IsRequired() + .HasDefaultValue(false); entity.Property(static x => x.UpdatedAtUtc).IsRequired(); entity diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs index 55cf80b..f689ff3 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs @@ -16,6 +16,14 @@ public sealed class RideEntity public decimal? GasPricePerGallon { get; set; } + public decimal? SnapshotAverageCarMpg { get; set; } + + public decimal? SnapshotMileageRateCents { get; set; } + + public decimal? SnapshotYearlyGoalMiles { get; set; } + + public decimal? SnapshotOilChangePrice { get; set; } + public decimal? WindSpeedMph { get; set; } public int? WindDirectionDeg { get; set; } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs index 6fc469e..0d149b1 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs @@ -18,5 +18,9 @@ public sealed class UserSettingsEntity public decimal? Longitude { get; set; } + public bool DashboardGallonsAvoidedEnabled { get; set; } + + public bool DashboardGoalProgressEnabled { get; set; } + public DateTime UpdatedAtUtc { get; set; } } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260406183601_AddDashboardSnapshotsAndPreferences.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260406183601_AddDashboardSnapshotsAndPreferences.Designer.cs new file mode 100644 index 0000000..1f020de --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260406183601_AddDashboardSnapshotsAndPreferences.Designer.cs @@ -0,0 +1,460 @@ +// +using System; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(BikeTrackingDbContext))] + [Migration("20260406183601_AddDashboardSnapshotsAndPreferences")] + partial class AddDashboardSnapshotsAndPreferences + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("ConsecutiveWrongCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DelayUntilUtc") + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulAuthUtc") + .HasColumnType("TEXT"); + + b.Property("LastWrongAttemptUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("AuthAttemptStates", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.GasPriceLookupEntity", b => + { + b.Property("GasPriceLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EiaPeriodDate") + .HasColumnType("TEXT"); + + b.Property("PriceDate") + .HasColumnType("TEXT"); + + b.Property("PricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("GasPriceLookupId"); + + b.HasIndex("PriceDate") + .IsUnique(); + + b.ToTable("GasPriceLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("GasPricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RideDateTimeLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SnapshotAverageCarMpg") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotMileageRateCents") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotOilChangePrice") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotYearlyGoalMiles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("WeatherUserOverridden") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_Rides_RiderId_CreatedAtUtc_Desc"); + + b.ToTable("Rides", null, t => + { + t.HasCheckConstraint("CK_Rides_Miles_GreaterThanZero", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); + + t.HasCheckConstraint("CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotAverageCarMpg_Positive", "\"SnapshotAverageCarMpg\" IS NULL OR CAST(\"SnapshotAverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotMileageRateCents_Positive", "\"SnapshotMileageRateCents\" IS NULL OR CAST(\"SnapshotMileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotOilChangePrice_Positive", "\"SnapshotOilChangePrice\" IS NULL OR CAST(\"SnapshotOilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotYearlyGoalMiles_Positive", "\"SnapshotYearlyGoalMiles\" IS NULL OR CAST(\"SnapshotYearlyGoalMiles\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("AverageCarMpg") + .HasColumnType("TEXT"); + + b.Property("DashboardGallonsAvoidedEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("DashboardGoalProgressEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasColumnType("TEXT"); + + b.Property("LocationLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("TEXT"); + + b.Property("MileageRateCents") + .HasColumnType("TEXT"); + + b.Property("OilChangePrice") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("YearlyGoalMiles") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserSettings", null, t => + { + t.HasCheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"); + + t.HasCheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"); + + t.HasCheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.WeatherLookupEntity", b => + { + b.Property("WeatherLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LatitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LongitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LookupHourUtc") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("WeatherLookupId"); + + b.HasIndex("LookupHourUtc", "LatitudeRounded", "LongitudeRounded") + .IsUnique(); + + b.ToTable("WeatherLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => + { + b.Property("OutboxEventId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AggregateId") + .HasColumnType("INTEGER"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EventPayloadJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("NextAttemptUtc") + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PublishedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("OutboxEventId"); + + b.HasIndex("AggregateType", "AggregateId"); + + b.HasIndex("PublishedAtUtc", "NextAttemptUtc"); + + b.ToTable("OutboxEvents", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.Property("UserCredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CredentialVersion") + .HasColumnType("INTEGER"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IterationCount") + .HasColumnType("INTEGER"); + + b.Property("PinHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PinSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("UserCredentialId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserCredentials", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("AuthAttemptState") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("Credential") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Navigation("AuthAttemptState"); + + b.Navigation("Credential"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260406183601_AddDashboardSnapshotsAndPreferences.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260406183601_AddDashboardSnapshotsAndPreferences.cs new file mode 100644 index 0000000..2a2412a --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260406183601_AddDashboardSnapshotsAndPreferences.cs @@ -0,0 +1,124 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddDashboardSnapshotsAndPreferences : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DashboardGallonsAvoidedEnabled", + table: "UserSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DashboardGoalProgressEnabled", + table: "UserSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SnapshotAverageCarMpg", + table: "Rides", + type: "TEXT", + precision: 10, + scale: 4, + nullable: true); + + migrationBuilder.AddColumn( + name: "SnapshotMileageRateCents", + table: "Rides", + type: "TEXT", + precision: 10, + scale: 4, + nullable: true); + + migrationBuilder.AddColumn( + name: "SnapshotOilChangePrice", + table: "Rides", + type: "TEXT", + precision: 10, + scale: 4, + nullable: true); + + migrationBuilder.AddColumn( + name: "SnapshotYearlyGoalMiles", + table: "Rides", + type: "TEXT", + precision: 10, + scale: 4, + nullable: true); + + migrationBuilder.AddCheckConstraint( + name: "CK_Rides_SnapshotAverageCarMpg_Positive", + table: "Rides", + sql: "\"SnapshotAverageCarMpg\" IS NULL OR CAST(\"SnapshotAverageCarMpg\" AS REAL) > 0"); + + migrationBuilder.AddCheckConstraint( + name: "CK_Rides_SnapshotMileageRateCents_Positive", + table: "Rides", + sql: "\"SnapshotMileageRateCents\" IS NULL OR CAST(\"SnapshotMileageRateCents\" AS REAL) > 0"); + + migrationBuilder.AddCheckConstraint( + name: "CK_Rides_SnapshotOilChangePrice_Positive", + table: "Rides", + sql: "\"SnapshotOilChangePrice\" IS NULL OR CAST(\"SnapshotOilChangePrice\" AS REAL) > 0"); + + migrationBuilder.AddCheckConstraint( + name: "CK_Rides_SnapshotYearlyGoalMiles_Positive", + table: "Rides", + sql: "\"SnapshotYearlyGoalMiles\" IS NULL OR CAST(\"SnapshotYearlyGoalMiles\" AS REAL) > 0"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_Rides_SnapshotAverageCarMpg_Positive", + table: "Rides"); + + migrationBuilder.DropCheckConstraint( + name: "CK_Rides_SnapshotMileageRateCents_Positive", + table: "Rides"); + + migrationBuilder.DropCheckConstraint( + name: "CK_Rides_SnapshotOilChangePrice_Positive", + table: "Rides"); + + migrationBuilder.DropCheckConstraint( + name: "CK_Rides_SnapshotYearlyGoalMiles_Positive", + table: "Rides"); + + migrationBuilder.DropColumn( + name: "DashboardGallonsAvoidedEnabled", + table: "UserSettings"); + + migrationBuilder.DropColumn( + name: "DashboardGoalProgressEnabled", + table: "UserSettings"); + + migrationBuilder.DropColumn( + name: "SnapshotAverageCarMpg", + table: "Rides"); + + migrationBuilder.DropColumn( + name: "SnapshotMileageRateCents", + table: "Rides"); + + migrationBuilder.DropColumn( + name: "SnapshotOilChangePrice", + table: "Rides"); + + migrationBuilder.DropColumn( + name: "SnapshotYearlyGoalMiles", + table: "Rides"); + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs index 368f5ce..8b24e29 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs @@ -108,6 +108,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RiderId") .HasColumnType("INTEGER"); + b.Property("SnapshotAverageCarMpg") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotMileageRateCents") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotOilChangePrice") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotYearlyGoalMiles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + b.Property("Temperature") .HasColumnType("TEXT"); @@ -140,6 +156,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) t.HasCheckConstraint("CK_Rides_Miles_GreaterThanZero", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); t.HasCheckConstraint("CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotAverageCarMpg_Positive", "\"SnapshotAverageCarMpg\" IS NULL OR CAST(\"SnapshotAverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotMileageRateCents_Positive", "\"SnapshotMileageRateCents\" IS NULL OR CAST(\"SnapshotMileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotOilChangePrice_Positive", "\"SnapshotOilChangePrice\" IS NULL OR CAST(\"SnapshotOilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotYearlyGoalMiles_Positive", "\"SnapshotYearlyGoalMiles\" IS NULL OR CAST(\"SnapshotYearlyGoalMiles\" AS REAL) > 0"); }); }); @@ -151,6 +175,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AverageCarMpg") .HasColumnType("TEXT"); + b.Property("DashboardGallonsAvoidedEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("DashboardGoalProgressEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + b.Property("Latitude") .HasColumnType("TEXT"); diff --git a/src/BikeTracking.Api/Program.cs b/src/BikeTracking.Api/Program.cs index 7bd3ec8..e2416df 100644 --- a/src/BikeTracking.Api/Program.cs +++ b/src/BikeTracking.Api/Program.cs @@ -1,4 +1,5 @@ -using BikeTracking.Api.Application.Events; +using BikeTracking.Api.Application.Dashboard; +using BikeTracking.Api.Application.Events; using BikeTracking.Api.Application.Rides; using BikeTracking.Api.Application.Users; using BikeTracking.Api.Endpoints; @@ -34,6 +35,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder .Services.AddAuthentication(UserIdHeaderAuthenticationHandler.SchemeName) @@ -143,6 +145,7 @@ app.UseHttpLogging(); app.UseAuthentication(); app.UseAuthorization(); +app.MapDashboardEndpoints(); app.MapUsersEndpoints(); app.MapRidesEndpoints(); app.MapDefaultEndpoints(); diff --git a/src/BikeTracking.Frontend/package-lock.json b/src/BikeTracking.Frontend/package-lock.json index 8093ccb..4cf5285 100644 --- a/src/BikeTracking.Frontend/package-lock.json +++ b/src/BikeTracking.Frontend/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.13.2" + "react-router-dom": "^7.13.2", + "recharts": "^3.8.1" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -925,6 +926,42 @@ "node": ">=18" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -1194,7 +1231,12 @@ }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@testing-library/dom": { @@ -1305,6 +1347,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "dev": true, @@ -1330,7 +1435,7 @@ }, "node_modules/@types/react": { "version": "19.2.14", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1344,6 +1449,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -2037,6 +2148,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "dev": true, @@ -2156,9 +2276,130 @@ }, "node_modules/csstype": { "version": "3.2.3", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "dev": true, @@ -2192,6 +2433,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -2262,6 +2509,16 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "dev": true, @@ -2453,6 +2710,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "dev": true, @@ -2782,6 +3045,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "dev": true, @@ -2827,6 +3100,15 @@ "dev": true, "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "dev": true, @@ -3818,7 +4100,6 @@ }, "node_modules/react": { "version": "19.2.4", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3826,7 +4107,6 @@ }, "node_modules/react-dom": { "version": "19.2.4", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -3839,9 +4119,31 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.13.2", "dev": true, @@ -3878,6 +4180,36 @@ "react-dom": ">=18" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "dev": true, @@ -3890,6 +4222,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "dev": true, @@ -3898,6 +4245,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "dev": true, @@ -3987,7 +4340,6 @@ }, "node_modules/scheduler": { "version": "0.27.0", - "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -4391,6 +4743,12 @@ "node": ">=8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "dev": true, @@ -4629,11 +4987,42 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "dev": true, "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", diff --git a/src/BikeTracking.Frontend/package.json b/src/BikeTracking.Frontend/package.json index 9551134..7ca3785 100644 --- a/src/BikeTracking.Frontend/package.json +++ b/src/BikeTracking.Frontend/package.json @@ -6,7 +6,8 @@ "dependencies": { "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.13.2" + "react-router-dom": "^7.13.2", + "recharts": "^3.8.1" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/src/BikeTracking.Frontend/src/App.tsx b/src/BikeTracking.Frontend/src/App.tsx index b540121..9389c21 100644 --- a/src/BikeTracking.Frontend/src/App.tsx +++ b/src/BikeTracking.Frontend/src/App.tsx @@ -3,6 +3,7 @@ import { AuthProvider } from './context/auth-context' import { ProtectedRoute } from './components/protected-route' import { LoginPage } from './pages/login/login-page' import { SignupPage } from './pages/signup/signup-page' +import { DashboardPage } from './pages/dashboard/dashboard-page' import { MilesShellPage } from './pages/miles/miles-shell-page' import { RecordRidePage } from './pages/RecordRidePage' import { HistoryPage } from './pages/HistoryPage' @@ -17,6 +18,7 @@ function App() { } /> } /> }> + } /> } /> } /> } /> diff --git a/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx b/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx index 6bae130..d8935f4 100644 --- a/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx +++ b/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx @@ -8,13 +8,13 @@ export function AppHeader() { return (
- + Commute Bike Tracker
} /> + Dashboard Page} /> Signup Page} /> @@ -101,7 +101,7 @@ describe('LoginPage component', () => { expect(screen.getByLabelText('Name')).toHaveValue('Prefilled Rider') }) - it('submits valid credentials, stores session, and navigates to miles', async () => { + it('submits valid credentials, stores session, and navigates to dashboard', async () => { const user = userEvent.setup() const successResult: LoginResult = { ok: true, @@ -117,7 +117,7 @@ describe('LoginPage component', () => { await user.click(screen.getByRole('button', { name: 'Log in' })) expect(mockedLoginUser).toHaveBeenCalledWith({ name: 'Alice', pin: '1234' }) - await screen.findByText('Miles Page') + await screen.findByText('Dashboard Page') const rawSession = sessionStorage.getItem('bike_tracking_auth_session') expect(rawSession).toContain('Alice') diff --git a/src/BikeTracking.Frontend/src/pages/login/login-page.tsx b/src/BikeTracking.Frontend/src/pages/login/login-page.tsx index cba3764..7e1d24f 100644 --- a/src/BikeTracking.Frontend/src/pages/login/login-page.tsx +++ b/src/BikeTracking.Frontend/src/pages/login/login-page.tsx @@ -44,7 +44,7 @@ export function LoginPage() { if (response.ok && response.data) { auth.login({ userId: response.data.userId, userName: response.data.userName }) - navigate('/miles') + navigate('/dashboard') return } diff --git a/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.test.tsx b/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.test.tsx index 73f46d1..dbc1357 100644 --- a/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.test.tsx @@ -1,105 +1,49 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { BrowserRouter } from 'react-router-dom' -import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { render, screen } from '@testing-library/react' import { MilesShellPage } from './miles-shell-page' -import * as ridesService from '../../services/ridesService' - -vi.mock('../../services/ridesService', () => ({ - getRideHistory: vi.fn(), -})) - -vi.mock('../../context/auth-context', () => ({ - useAuth: () => ({ user: { userId: 1, userName: 'Riley' } }), -})) - -const mockGetRideHistory = vi.mocked(ridesService.getRideHistory) describe('MilesShellPage', () => { beforeEach(() => { vi.clearAllMocks() }) - it('renders this year and all time summary cards using shared component', async () => { - mockGetRideHistory.mockResolvedValue({ - summaries: { - thisMonth: { miles: 10, rideCount: 1, period: 'thisMonth' }, - thisYear: { miles: 120, rideCount: 12, period: 'thisYear' }, - allTime: { miles: 580, rideCount: 56, period: 'allTime' }, - }, - filteredTotal: { miles: 580, rideCount: 56, period: 'filtered' }, - rides: [], - page: 1, - pageSize: 1, - totalRows: 0, - }) - + it('redirects from miles route to dashboard', async () => { render( - - - + + + } /> + Dashboard Page} /> + + ) - await waitFor(() => { - expect(screen.getByText(/this year/i)).toBeInTheDocument() - expect(screen.getByText(/all time/i)).toBeInTheDocument() - expect(screen.getByText('120.0 mi')).toBeInTheDocument() - expect(screen.getByText('580.0 mi')).toBeInTheDocument() - }) + expect(await screen.findByText('Dashboard Page')).toBeInTheDocument() }) - it('loads dashboard summary data from ride history service on mount', async () => { - mockGetRideHistory.mockResolvedValue({ - summaries: { - thisMonth: { miles: 0, rideCount: 0, period: 'thisMonth' }, - thisYear: { miles: 0, rideCount: 0, period: 'thisYear' }, - allTime: { miles: 0, rideCount: 0, period: 'allTime' }, - }, - filteredTotal: { miles: 0, rideCount: 0, period: 'filtered' }, - rides: [], - page: 1, - pageSize: 1, - totalRows: 0, - }) - + it('replaces browser history when redirecting to dashboard', async () => { render( - - - + + + } /> + Dashboard Page} /> + + ) - await waitFor(() => { - expect(mockGetRideHistory).toHaveBeenCalledWith({ page: 1, pageSize: 1 }) - }) + expect(await screen.findByText('Dashboard Page')).toBeInTheDocument() }) - it('renders a Settings navigation link in the placeholder region', async () => { - mockGetRideHistory.mockResolvedValue({ - summaries: { - thisMonth: { miles: 0, rideCount: 0, period: 'thisMonth' }, - thisYear: { miles: 0, rideCount: 0, period: 'thisYear' }, - allTime: { miles: 0, rideCount: 0, period: 'allTime' }, - }, - filteredTotal: { miles: 0, rideCount: 0, period: 'filtered' }, - rides: [], - page: 1, - pageSize: 1, - totalRows: 0, - }) - + it('does not render legacy miles placeholder content', () => { render( - - - + + + } /> + Dashboard Page} /> + + ) - await waitFor(() => { - expect(screen.getByRole('link', { name: /settings/i })).toHaveAttribute( - 'href', - '/settings' - ) - expect( - screen.getByLabelText(/miles content placeholder/i) - ).toBeInTheDocument() - }) + expect(screen.queryByLabelText(/miles content placeholder/i)).not.toBeInTheDocument() }) }) diff --git a/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.tsx b/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.tsx index 8a40c4b..d093467 100644 --- a/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.tsx +++ b/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.tsx @@ -1,62 +1,5 @@ -import { useEffect, useState } from 'react' -import { Link } from 'react-router-dom' -import { MileageSummaryCard } from '../../components/mileage-summary-card/mileage-summary-card' -import { useAuth } from '../../context/auth-context' -import type { RideHistoryResponse } from '../../services/ridesService' -import { getRideHistory } from '../../services/ridesService' -import './miles-shell-page.css' +import { Navigate } from 'react-router-dom' export function MilesShellPage() { - const { user } = useAuth() - const [history, setHistory] = useState(null) - const [error, setError] = useState('') - - useEffect(() => { - let isMounted = true - - async function loadDashboardSummaries(): Promise { - setError('') - try { - const response = await getRideHistory({ page: 1, pageSize: 1 }) - if (isMounted) { - setHistory(response) - } - } catch (err: unknown) { - if (isMounted) { - setError(err instanceof Error ? err.message : 'Failed to load dashboard summaries') - } - } - } - - void loadDashboardSummaries() - - return () => { - isMounted = false - } - }, []) - - return ( -
-
-

Welcome, {user?.userName}.

-

Track your year-to-date and all-time progress at a glance.

-
- - {error ?

{error}

: null} - - {history ? ( -
- - -
- ) : null} - -
-

Ride history and trends can be explored from the History page.

-

- Manage your profile assumptions from Settings. -

-
-
- ) + return } diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css index 8ad7247..2936a6f 100644 --- a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css @@ -35,6 +35,25 @@ color: #24324a; } +.settings-checkbox-group { + border: 1px solid #d4dbe5; + border-radius: 0.5rem; + padding: 0.65rem 0.75rem; +} + +.settings-checkbox-group legend { + padding: 0 0.25rem; + font-weight: 700; + color: #24324a; +} + +.settings-checkbox-row { + display: flex; + align-items: center; + gap: 0.55rem; + font-weight: 500; +} + .settings-field input { border: 1px solid #b9c5d6; border-radius: 0.5rem; diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx index 397ff4b..648df42 100644 --- a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx @@ -58,6 +58,8 @@ describe('SettingsPage', () => { locationLabel: null, latitude: null, longitude: null, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: '2026-03-30T10:00:00Z', }, }, @@ -91,6 +93,8 @@ describe('SettingsPage', () => { locationLabel: null, latitude: null, longitude: null, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: null, }, }, @@ -109,6 +113,8 @@ describe('SettingsPage', () => { locationLabel: null, latitude: null, longitude: null, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: '2026-03-30T10:00:00Z', }, }, @@ -165,6 +171,8 @@ describe('SettingsPage', () => { locationLabel: 'Downtown Office', latitude: 42.3601, longitude: -71.0589, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: '2026-03-30T10:00:00Z', }, }, @@ -183,6 +191,8 @@ describe('SettingsPage', () => { locationLabel: 'HQ Campus', latitude: 41.9, longitude: -87.6, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: '2026-03-30T10:10:00Z', }, }, @@ -237,6 +247,8 @@ describe('SettingsPage', () => { locationLabel: 'Home', latitude: 41.881, longitude: -87.623, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: '2026-03-30T10:00:00Z', }, }, @@ -255,6 +267,8 @@ describe('SettingsPage', () => { locationLabel: 'Home', latitude: 41.881, longitude: -87.623, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: '2026-03-30T10:05:00Z', }, }, @@ -297,6 +311,8 @@ describe('SettingsPage', () => { locationLabel: null, latitude: null, longitude: null, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: null, }, }, @@ -344,6 +360,8 @@ describe('SettingsPage', () => { locationLabel: null, latitude: null, longitude: null, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, updatedAtUtc: null, }, }, @@ -365,4 +383,70 @@ describe('SettingsPage', () => { expect(screen.getByRole('alert')).toHaveTextContent(/unable to read browser location/i) }) }) + + it('loads and saves dashboard optional metric approvals', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: true, + settings: { + averageCarMpg: 30, + yearlyGoalMiles: 1600, + oilChangePrice: 70, + mileageRateCents: 60, + locationLabel: null, + latitude: null, + longitude: null, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, + updatedAtUtc: '2026-03-30T10:00:00Z', + }, + }, + }) + + mockSaveUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: true, + settings: { + averageCarMpg: 30, + yearlyGoalMiles: 1600, + oilChangePrice: 70, + mileageRateCents: 60, + locationLabel: null, + latitude: null, + longitude: null, + dashboardGallonsAvoidedEnabled: true, + dashboardGoalProgressEnabled: true, + updatedAtUtc: '2026-03-30T10:15:00Z', + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByLabelText(/show gallons avoided metric/i)).not.toBeChecked() + expect(screen.getByLabelText(/show goal progress metric/i)).not.toBeChecked() + }) + + fireEvent.click(screen.getByLabelText(/show gallons avoided metric/i)) + fireEvent.click(screen.getByLabelText(/show goal progress metric/i)) + fireEvent.click(screen.getByRole('button', { name: /save settings/i })) + + await waitFor(() => { + expect(mockSaveUserSettings).toHaveBeenCalledWith( + expect.objectContaining({ + dashboardGallonsAvoidedEnabled: true, + dashboardGoalProgressEnabled: true, + }) + ) + }) + }) }) diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx index 98600d0..f35b938 100644 --- a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx @@ -15,6 +15,8 @@ interface SettingsFormSnapshot { locationLabel: string | null latitude: number | null longitude: number | null + dashboardGallonsAvoidedEnabled: boolean + dashboardGoalProgressEnabled: boolean } function toSnapshot(response: UserSettingsResponse): SettingsFormSnapshot { @@ -26,6 +28,8 @@ function toSnapshot(response: UserSettingsResponse): SettingsFormSnapshot { locationLabel: response.settings.locationLabel, latitude: response.settings.latitude, longitude: response.settings.longitude, + dashboardGallonsAvoidedEnabled: response.settings.dashboardGallonsAvoidedEnabled, + dashboardGoalProgressEnabled: response.settings.dashboardGoalProgressEnabled, } } @@ -42,6 +46,8 @@ export function SettingsPage() { const [locationLabel, setLocationLabel] = useState('') const [latitude, setLatitude] = useState('') const [longitude, setLongitude] = useState('') + const [dashboardGallonsAvoidedEnabled, setDashboardGallonsAvoidedEnabled] = useState(false) + const [dashboardGoalProgressEnabled, setDashboardGoalProgressEnabled] = useState(false) const [locating, setLocating] = useState(false) const [initialSnapshot, setInitialSnapshot] = useState({ averageCarMpg: null, @@ -51,6 +57,8 @@ export function SettingsPage() { locationLabel: null, latitude: null, longitude: null, + dashboardGallonsAvoidedEnabled: false, + dashboardGoalProgressEnabled: false, }) const [loading, setLoading] = useState(true) @@ -76,6 +84,8 @@ export function SettingsPage() { setLocationLabel(settings.locationLabel ?? '') setLatitude(settings.latitude ?? '') setLongitude(settings.longitude ?? '') + setDashboardGallonsAvoidedEnabled(settings.dashboardGallonsAvoidedEnabled) + setDashboardGoalProgressEnabled(settings.dashboardGoalProgressEnabled) setInitialSnapshot(toSnapshot(response.data)) } else { setError(response.error?.message ?? 'Failed to load settings') @@ -141,6 +151,8 @@ export function SettingsPage() { locationLabel: normalizeLocationLabel(locationLabel), latitude: latitude === '' ? null : latitude, longitude: longitude === '' ? null : longitude, + dashboardGallonsAvoidedEnabled, + dashboardGoalProgressEnabled, } const payload: UserSettingsUpsertRequest = {} @@ -158,6 +170,18 @@ export function SettingsPage() { payload.latitude = currentSnapshot.latitude if (currentSnapshot.longitude !== initialSnapshot.longitude) payload.longitude = currentSnapshot.longitude + if ( + currentSnapshot.dashboardGallonsAvoidedEnabled !== + initialSnapshot.dashboardGallonsAvoidedEnabled + ) { + payload.dashboardGallonsAvoidedEnabled = currentSnapshot.dashboardGallonsAvoidedEnabled + } + if ( + currentSnapshot.dashboardGoalProgressEnabled !== + initialSnapshot.dashboardGoalProgressEnabled + ) { + payload.dashboardGoalProgressEnabled = currentSnapshot.dashboardGoalProgressEnabled + } if (Object.keys(payload).length === 0) { setSaving(false) @@ -176,6 +200,8 @@ export function SettingsPage() { setLocationLabel(settings.locationLabel ?? '') setLatitude(settings.latitude ?? '') setLongitude(settings.longitude ?? '') + setDashboardGallonsAvoidedEnabled(settings.dashboardGallonsAvoidedEnabled) + setDashboardGoalProgressEnabled(settings.dashboardGoalProgressEnabled) setInitialSnapshot(toSnapshot(response.data)) setSuccess('Settings saved successfully.') } else { @@ -246,7 +272,7 @@ export function SettingsPage() {
- +
+ +
+ Dashboard Optional Metrics + + + + +
diff --git a/src/BikeTracking.Frontend/src/services/dashboard-api.test.ts b/src/BikeTracking.Frontend/src/services/dashboard-api.test.ts new file mode 100644 index 0000000..30bb6a6 --- /dev/null +++ b/src/BikeTracking.Frontend/src/services/dashboard-api.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); + +function jsonResponse( + body: unknown, + ok: boolean, + status: number = 200, +): Response { + return new Response(JSON.stringify(body), { + status: ok ? status : status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("dashboard-api", () => { + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("requests GET /api/dashboard and returns dashboard data", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + totals: { + currentMonthMiles: { miles: 10, rideCount: 1, period: "thisMonth" }, + yearToDateMiles: { miles: 45, rideCount: 4, period: "thisYear" }, + allTimeMiles: { miles: 120, rideCount: 12, period: "allTime" }, + moneySaved: { + mileageRateSavings: 15, + fuelCostAvoided: 7, + combinedSavings: 22, + qualifiedRideCount: 3, + }, + }, + averages: { + averageTemperature: 61, + averageMilesPerRide: 10, + averageRideMinutes: 27, + }, + charts: { mileageByMonth: [], savingsByMonth: [] }, + suggestions: [], + missingData: { + ridesMissingSavingsSnapshot: 0, + ridesMissingGasPrice: 0, + ridesMissingTemperature: 0, + ridesMissingDuration: 0, + }, + generatedAtUtc: new Date().toISOString(), + }, + true, + ), + ); + + const module = await import("./dashboard-api"); + const result = await module.getDashboard(); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/dashboard"), + expect.objectContaining({ method: "GET" }), + ); + expect(result.totals.allTimeMiles.miles).toBe(120); + }); +}); diff --git a/src/BikeTracking.Frontend/src/services/dashboard-api.ts b/src/BikeTracking.Frontend/src/services/dashboard-api.ts new file mode 100644 index 0000000..8f5b81c --- /dev/null +++ b/src/BikeTracking.Frontend/src/services/dashboard-api.ts @@ -0,0 +1,103 @@ +export interface DashboardMileageMetric { + miles: number; + rideCount: number; + period: string; +} + +export interface DashboardMoneySaved { + mileageRateSavings: number | null; + fuelCostAvoided: number | null; + combinedSavings: number | null; + qualifiedRideCount: number; +} + +export interface DashboardTotals { + currentMonthMiles: DashboardMileageMetric; + yearToDateMiles: DashboardMileageMetric; + allTimeMiles: DashboardMileageMetric; + moneySaved: DashboardMoneySaved; +} + +export interface DashboardAverages { + averageTemperature: number | null; + averageMilesPerRide: number | null; + averageRideMinutes: number | null; +} + +export interface DashboardCharts { + mileageByMonth: Array<{ monthKey: string; label: string; miles: number }>; + savingsByMonth: Array<{ + monthKey: string; + label: string; + mileageRateSavings: number | null; + fuelCostAvoided: number | null; + combinedSavings: number | null; + }>; +} + +export interface DashboardMetricSuggestion { + metricKey: "gallonsAvoided" | "goalProgress"; + title: string; + description: string; + isEnabled: boolean; + value?: number | null; + unitLabel?: string | null; +} + +export interface DashboardMissingData { + ridesMissingSavingsSnapshot: number; + ridesMissingGasPrice: number; + ridesMissingTemperature: number; + ridesMissingDuration: number; +} + +export interface DashboardResponse { + totals: DashboardTotals; + averages: DashboardAverages; + charts: DashboardCharts; + suggestions: DashboardMetricSuggestion[]; + missingData: DashboardMissingData; + generatedAtUtc: string; +} + +const API_BASE_URL = + (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( + /\/$/, + "", + ) ?? "http://localhost:5436"; +const SESSION_KEY = "bike_tracking_auth_session"; + +function getAuthHeaders(): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) { + return headers; + } + + const parsed = JSON.parse(raw) as { userId?: number }; + if (typeof parsed.userId === "number" && parsed.userId > 0) { + headers["X-User-Id"] = parsed.userId.toString(); + } + } catch { + return headers; + } + + return headers; +} + +export async function getDashboard(): Promise { + const response = await fetch(`${API_BASE_URL}/api/dashboard`, { + method: "GET", + headers: getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("Failed to load dashboard"); + } + + return response.json() as Promise; +} diff --git a/src/BikeTracking.Frontend/src/services/users-api.ts b/src/BikeTracking.Frontend/src/services/users-api.ts index ef9eca1..3f569f1 100644 --- a/src/BikeTracking.Frontend/src/services/users-api.ts +++ b/src/BikeTracking.Frontend/src/services/users-api.ts @@ -36,6 +36,8 @@ export interface UserSettingsUpsertRequest { locationLabel?: string | null; latitude?: number | null; longitude?: number | null; + dashboardGallonsAvoidedEnabled?: boolean | null; + dashboardGoalProgressEnabled?: boolean | null; } export interface UserSettingsView { @@ -46,6 +48,8 @@ export interface UserSettingsView { locationLabel: string | null; latitude: number | null; longitude: number | null; + dashboardGallonsAvoidedEnabled: boolean; + dashboardGoalProgressEnabled: boolean; updatedAtUtc: string | null; } diff --git a/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts b/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts new file mode 100644 index 0000000..9ce2794 --- /dev/null +++ b/src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from "@playwright/test"; +import { uniqueUser } from "./support/auth-helpers"; + +async function saveDashboardSettings( + page: import("@playwright/test").Page, + mpg: string, + mileageRate: string, +) { + await page.goto("/settings"); + await page.getByLabel("Average Car MPG").fill(mpg); + await page.getByLabel("Mileage Rate (cents per mile)").fill(mileageRate); + await page.getByRole("button", { name: "Save Settings" }).click(); + await expect(page.getByText(/settings saved successfully/i)).toBeVisible(); +} + +async function recordRideWithGasPrice( + page: import("@playwright/test").Page, + miles: string, + gasPrice: string, +) { + await page.goto("/rides/record"); + await page.getByLabel("Miles (required)").fill(miles); + await page.getByLabel("Gas Price ($/gal) (optional)").fill(gasPrice); + await page.getByRole("button", { name: "Record Ride" }).click(); + await expect(page.getByText(/ride recorded successfully/i)).toBeVisible(); +} + +test.describe("012-dashboard-stats e2e", () => { + test("authenticated login lands on dashboard", async ({ page }) => { + const userName = uniqueUser("e2e-dashboard"); + + await page.goto("/signup"); + await page.getByLabel("Name").fill(userName); + await page.getByLabel("PIN").fill("12345678"); + await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/login"); + + await page.getByLabel("Name").fill(userName); + await page.getByLabel("PIN").fill("12345678"); + await page.getByRole("button", { name: "Log in" }).click(); + + await expect(page).toHaveURL("/dashboard"); + }); + + test("historical savings stay stable after settings change", async ({ + page, + }) => { + const userName = uniqueUser("e2e-dashboard-snapshot"); + + await page.goto("/signup"); + await page.getByLabel("Name").fill(userName); + await page.getByLabel("PIN").fill("12345678"); + await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/login"); + + await page.getByLabel("Name").fill(userName); + await page.getByLabel("PIN").fill("12345678"); + await page.getByRole("button", { name: "Log in" }).click(); + await expect(page).toHaveURL("/dashboard"); + + await saveDashboardSettings(page, "20", "50"); + await recordRideWithGasPrice(page, "10", "3.00"); + + await page.goto("/dashboard"); + await expect(page.getByText("$6.50", { exact: true })).toBeVisible(); + + await saveDashboardSettings(page, "40", "70"); + await recordRideWithGasPrice(page, "10", "3.00"); + + await page.goto("/dashboard"); + await expect(page.getByText("$14.25", { exact: true })).toBeVisible(); + }); + + test("optional metrics appear only after approval", async ({ page }) => { + const userName = uniqueUser("e2e-dashboard-optional"); + + await page.goto("/signup"); + await page.getByLabel("Name").fill(userName); + await page.getByLabel("PIN").fill("12345678"); + await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/login"); + + await page.getByLabel("Name").fill(userName); + await page.getByLabel("PIN").fill("12345678"); + await page.getByRole("button", { name: "Log in" }).click(); + await expect(page).toHaveURL("/dashboard"); + + await expect(page.getByText("More metrics are available")).toBeVisible(); + await expect(page.getByText("Gallons Avoided:")).toBeVisible(); + await expect(page.getByText("Goal Progress:")).toBeVisible(); + await expect(page.getByText("Approved Metric")).not.toBeVisible(); + + await page.goto("/settings"); + await page.getByLabel("Show gallons avoided metric").check(); + await page.getByLabel("Show goal progress metric").check(); + await page.getByRole("button", { name: "Save Settings" }).click(); + await expect(page.getByText(/settings saved successfully/i)).toBeVisible(); + + await page.goto("/dashboard"); + await expect(page.getByText("Approved Metric")).toHaveCount(2); + await expect(page.getByText("Gallons Avoided")).toBeVisible(); + await expect(page.getByText("Goal Progress")).toBeVisible(); + await expect( + page.getByText("More metrics are available"), + ).not.toBeVisible(); + }); +}); diff --git a/src/BikeTracking.Frontend/tests/e2e/login-smoke.spec.ts b/src/BikeTracking.Frontend/tests/e2e/login-smoke.spec.ts index b9f07f2..1fcc222 100644 --- a/src/BikeTracking.Frontend/tests/e2e/login-smoke.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/login-smoke.spec.ts @@ -8,8 +8,8 @@ import { loginUser, signupUser, uniqueUser } from "./support/auth-helpers"; * 1. `/` redirects to `/login` * 2. Unauthenticated `/miles` redirects to `/login` * 3. Incorrect credentials show an error and stay on `/login` - * 4. Successful login redirects to `/miles` and displays the user's name - * 5. Logout from `/miles` returns to `/login` + * 4. Successful login redirects to `/dashboard` + * 5. Logout from `/dashboard` returns to `/login` * * The Playwright config starts API + Vite when needed. * @@ -49,17 +49,18 @@ test.describe("003-user-login smoke tests", () => { await expect(page).toHaveURL("/login"); }); - test("successful login redirects to /miles and shows user name", async ({ - page, - }) => { + test("successful login redirects to /dashboard", async ({ page }) => { const userName = uniqueUser("e2e-login-ok"); await createUserViaSignup(page, userName, TEST_PIN); await loginUser(page, userName, TEST_PIN); - await expect(page.getByText(`Welcome, ${userName}`)).toBeVisible(); + await expect(page).toHaveURL("/dashboard"); + await expect( + page.getByRole("heading", { name: /your riding story/i }), + ).toBeVisible(); }); - test("logout from /miles returns to /login", async ({ page }) => { + test("logout from /dashboard returns to /login", async ({ page }) => { const userName = uniqueUser("e2e-logout"); await createUserViaSignup(page, userName, TEST_PIN); diff --git a/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts b/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts index c2cbddf..1637cf1 100644 --- a/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts +++ b/src/BikeTracking.Frontend/tests/e2e/support/auth-helpers.ts @@ -25,7 +25,7 @@ export async function loginUser( await page.getByLabel("Name").fill(userName); await page.getByLabel("PIN").fill(pin); await page.getByRole("button", { name: "Log in" }).click(); - await expect(page).toHaveURL("/miles"); + await expect(page).toHaveURL("/dashboard"); } export async function createAndLoginUser(