Skip to content

Commit f08bc1d

Browse files
author
aligneddev
committed
show gallons and goal, cents per mile for mileage
1 parent 45531b3 commit f08bc1d

7 files changed

Lines changed: 116 additions & 12 deletions

File tree

src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,58 @@ public async Task GetDashboardService_ExcludesLegacyRideWithoutSnapshot_FromSavi
113113
Assert.Equal(8m, dashboard.Totals.AllTimeMiles.Miles);
114114
}
115115

116+
[Fact]
117+
public async Task GetDashboardService_IncludesOptionalMetricValues_WhenDataIsAvailable()
118+
{
119+
using var dbContext = CreateDbContext();
120+
var rider = new UserEntity
121+
{
122+
DisplayName = "Optional Metric Rider",
123+
NormalizedName = "optional metric rider",
124+
CreatedAtUtc = DateTime.UtcNow,
125+
};
126+
dbContext.Users.Add(rider);
127+
await dbContext.SaveChangesAsync();
128+
129+
dbContext.UserSettings.Add(
130+
new UserSettingsEntity
131+
{
132+
UserId = rider.UserId,
133+
YearlyGoalMiles = 100m,
134+
DashboardGallonsAvoidedEnabled = true,
135+
DashboardGoalProgressEnabled = true,
136+
UpdatedAtUtc = DateTime.UtcNow,
137+
}
138+
);
139+
140+
dbContext.Rides.Add(
141+
new RideEntity
142+
{
143+
RiderId = rider.UserId,
144+
RideDateTimeLocal = DateTime.Now,
145+
Miles = 20m,
146+
SnapshotAverageCarMpg = 10m,
147+
CreatedAtUtc = DateTime.UtcNow,
148+
}
149+
);
150+
await dbContext.SaveChangesAsync();
151+
152+
var service = new GetDashboardService(dbContext);
153+
var dashboard = await service.GetAsync(rider.UserId);
154+
155+
var gallonsSuggestion = dashboard.Suggestions.Single(metric =>
156+
metric.MetricKey == "gallonsAvoided"
157+
);
158+
var goalSuggestion = dashboard.Suggestions.Single(metric =>
159+
metric.MetricKey == "goalProgress"
160+
);
161+
162+
Assert.Equal(2m, gallonsSuggestion.Value);
163+
Assert.Equal("gal", gallonsSuggestion.UnitLabel);
164+
Assert.Equal(20m, goalSuggestion.Value);
165+
Assert.Equal("%", goalSuggestion.UnitLabel);
166+
}
167+
116168
private static BikeTrackingDbContext CreateDbContext()
117169
{
118170
var options = new DbContextOptionsBuilder<BikeTrackingDbContext>()

src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ public async Task<DashboardResponse> GetAsync(
4040
ride.RideDateTimeLocal >= currentYearStart && ride.RideDateTimeLocal < nextYearStart
4141
)
4242
.ToList();
43+
var yearToDateMiles = currentYearRides.Sum(ride => ride.Miles);
44+
var gallonsAvoided = CalculateGallonsAvoided(rides);
4345

4446
var savings = CalculateSavings(rides);
4547

@@ -59,7 +61,7 @@ public async Task<DashboardResponse> GetAsync(
5961
MileageByMonth: BuildMileageSeries(rides, nowLocal),
6062
SavingsByMonth: BuildSavingsSeries(rides, nowLocal)
6163
),
62-
Suggestions: BuildSuggestions(settings),
64+
Suggestions: BuildSuggestions(settings, gallonsAvoided, yearToDateMiles),
6365
MissingData: new DashboardMissingData(
6466
RidesMissingSavingsSnapshot: rides.Count(ride =>
6567
ride.SnapshotMileageRateCents is null || ride.SnapshotAverageCarMpg is null
@@ -228,26 +230,53 @@ private static SavingsAggregate AggregateSavings(IEnumerable<RideEntity> rides)
228230
}
229231

230232
private static IReadOnlyList<DashboardMetricSuggestion> BuildSuggestions(
231-
UserSettingsEntity? settings
233+
UserSettingsEntity? settings,
234+
decimal? gallonsAvoided,
235+
decimal yearToDateMiles
232236
)
233237
{
238+
var yearlyGoalMiles = settings?.YearlyGoalMiles;
239+
decimal? goalProgressPercent =
240+
yearlyGoalMiles is decimal goal && goal > 0m
241+
? decimal.Round((yearToDateMiles / goal) * 100m, 1, MidpointRounding.AwayFromZero)
242+
: null;
243+
234244
return
235245
[
236246
new DashboardMetricSuggestion(
237247
MetricKey: "gallonsAvoided",
238248
Title: "Gallons Avoided",
239249
Description: "See how much gas your rides kept in the tank.",
240-
IsEnabled: settings?.DashboardGallonsAvoidedEnabled ?? false
250+
IsEnabled: settings?.DashboardGallonsAvoidedEnabled ?? false,
251+
Value: gallonsAvoided,
252+
UnitLabel: "gal"
241253
),
242254
new DashboardMetricSuggestion(
243255
MetricKey: "goalProgress",
244256
Title: "Goal Progress",
245257
Description: "Compare your riding pace to your yearly mileage goal.",
246-
IsEnabled: settings?.DashboardGoalProgressEnabled ?? false
258+
IsEnabled: settings?.DashboardGoalProgressEnabled ?? false,
259+
Value: goalProgressPercent,
260+
UnitLabel: "%"
247261
),
248262
];
249263
}
250264

265+
private static decimal? CalculateGallonsAvoided(IReadOnlyCollection<RideEntity> rides)
266+
{
267+
var totalGallonsAvoided = rides
268+
.Where(ride =>
269+
ride.SnapshotAverageCarMpg is decimal averageCarMpg && averageCarMpg > 0m
270+
)
271+
.Select(ride => ride.Miles / ride.SnapshotAverageCarMpg!.Value)
272+
.DefaultIfEmpty(0m)
273+
.Sum();
274+
275+
return totalGallonsAvoided > 0m
276+
? decimal.Round(totalGallonsAvoided, 2, MidpointRounding.AwayFromZero)
277+
: null;
278+
}
279+
251280
private static IEnumerable<DateTime> EnumerateRollingMonths(DateTime nowLocal)
252281
{
253282
var start = new DateTime(nowLocal.Year, nowLocal.Month, 1).AddMonths(-11);

src/BikeTracking.Api/Contracts/DashboardContracts.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ public sealed record DashboardMetricSuggestion(
5050
string MetricKey,
5151
string Title,
5252
string Description,
53-
bool IsEnabled
53+
bool IsEnabled,
54+
decimal? Value = null,
55+
string? UnitLabel = null
5456
);
5557

5658
public sealed record DashboardMissingData(

src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,26 @@ function formatCurrency(value: number | null): string {
8484
return new Intl.NumberFormat('en-US', {
8585
style: 'currency',
8686
currency: 'USD',
87-
maximumFractionDigits: 0,
87+
maximumFractionDigits: 2,
8888
}).format(value)
8989
}
9090

91+
function formatOptionalMetricValue(value: number | null | undefined, unitLabel?: string | null): string {
92+
if (value === null || value === undefined) {
93+
return '—'
94+
}
95+
96+
if (unitLabel === '%') {
97+
return `${value.toFixed(1)}%`
98+
}
99+
100+
if (unitLabel === 'gal') {
101+
return `${value.toFixed(2)} gal`
102+
}
103+
104+
return `${value}`
105+
}
106+
91107
export function DashboardPage() {
92108
const hasSession = Boolean(sessionStorage.getItem(SESSION_KEY))
93109
const [dashboard, setDashboard] = useState<DashboardResponse>(() => buildEmptyDashboard())
@@ -187,7 +203,7 @@ export function DashboardPage() {
187203
accentClassName="dashboard-summary-card-accent-savings"
188204
>
189205
<div className="dashboard-summary-split">
190-
<span>Rate {formatCurrency(dashboard.totals.moneySaved.mileageRateSavings)}</span>
206+
<span>Rate (cents per mile) {formatCurrency(dashboard.totals.moneySaved.mileageRateSavings)}</span>
191207
<span>Fuel {formatCurrency(dashboard.totals.moneySaved.fuelCostAvoided)}</span>
192208
</div>
193209
</DashboardSummaryCard>
@@ -255,7 +271,10 @@ export function DashboardPage() {
255271
{enabledSuggestions.map((suggestion) => (
256272
<article key={suggestion.metricKey} className="dashboard-average-card">
257273
<p className="dashboard-average-label">Approved Metric</p>
258-
<p className="dashboard-average-value">{suggestion.title}</p>
274+
<p className="dashboard-average-value">
275+
{formatOptionalMetricValue(suggestion.value, suggestion.unitLabel)}
276+
</p>
277+
<p className="dashboard-average-label">{suggestion.title}</p>
259278
<p className="dashboard-summary-card-detail">{suggestion.description}</p>
260279
</article>
261280
))}

src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ export function SettingsPage() {
272272
</div>
273273

274274
<div className="settings-field">
275-
<label htmlFor="mileageRateCents">Mileage Rate</label>
275+
<label htmlFor="mileageRateCents">Mileage Rate (cents per mile)</label>
276276
<input
277277
id="mileageRateCents"
278278
type="number"

src/BikeTracking.Frontend/src/services/dashboard-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export interface DashboardMetricSuggestion {
4040
title: string;
4141
description: string;
4242
isEnabled: boolean;
43+
value?: number | null;
44+
unitLabel?: string | null;
4345
}
4446

4547
export interface DashboardMissingData {

src/BikeTracking.Frontend/tests/e2e/dashboard.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ async function saveDashboardSettings(
88
) {
99
await page.goto("/settings");
1010
await page.getByLabel("Average Car MPG").fill(mpg);
11-
await page.getByLabel("Mileage Rate").fill(mileageRate);
11+
await page.getByLabel("Mileage Rate (cents per mile)").fill(mileageRate);
1212
await page.getByRole("button", { name: "Save Settings" }).click();
1313
await expect(page.getByText(/settings saved successfully/i)).toBeVisible();
1414
}
@@ -62,13 +62,13 @@ test.describe("012-dashboard-stats e2e", () => {
6262
await recordRideWithGasPrice(page, "10", "3.00");
6363

6464
await page.goto("/dashboard");
65-
await expect(page.getByText("$7", { exact: true })).toBeVisible();
65+
await expect(page.getByText("$6.50", { exact: true })).toBeVisible();
6666

6767
await saveDashboardSettings(page, "40", "70");
6868
await recordRideWithGasPrice(page, "10", "3.00");
6969

7070
await page.goto("/dashboard");
71-
await expect(page.getByText("$14", { exact: true })).toBeVisible();
71+
await expect(page.getByText("$14.25", { exact: true })).toBeVisible();
7272
});
7373

7474
test("optional metrics appear only after approval", async ({ page }) => {

0 commit comments

Comments
 (0)