Skip to content

Commit 7d14d6b

Browse files
author
aligneddev
committed
load weather with button
1 parent a5df95a commit 7d14d6b

17 files changed

Lines changed: 607 additions & 8 deletions

File tree

.specify/memory/constitution.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ All development follows Trunk-Based Development with git worktrees for parallel
279279
1. Create a GitHub issue describing the work
280280
2. Create a short-lived feature branch from `main` (e.g., `feature/issue-42-record-ride`)
281281
3. Use `git worktree add` to work on the branch in a separate directory when parallel work is needed
282-
4. Commit frequently with meaningful messages; push to remote regularly
282+
4. Commit frequently with meaningful messages using `semantic commits or conventional commits` format; push to remote regularly
283283
5. Open a PR referencing the GitHub issue (e.g., "Closes #42") as soon as the first commit is ready (draft PR for work-in-progress)
284284
6. Keep the branch up-to-date with `main` via rebase
285285
7. Once CI passes and review feedback is addressed, the owner completes the PR

specs/011-ride-weather-data/contracts/api-contracts.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,35 @@ data is fetched server-side at save time inside `RecordRideService` and `EditRid
117117
The existing `GET /api/rides/gas-price` endpoint pattern is not replicated for weather because
118118
the weather lookup is tightly coupled to save time and user location (which is server-held).
119119

120+
## Weather Preview Endpoint
121+
122+
The explicit load-weather action uses a server-side preview endpoint so the browser never talks to
123+
Open-Meteo directly.
124+
125+
### `GET /api/rides/weather?rideDateTimeLocal={iso}`
126+
127+
Returns the weather snapshot for the authenticated rider's configured location and the supplied
128+
ride timestamp.
129+
130+
```csharp
131+
public sealed record RideWeatherResponse(
132+
DateTime RideDateTimeLocal,
133+
decimal? Temperature,
134+
decimal? WindSpeedMph,
135+
int? WindDirectionDeg,
136+
int? RelativeHumidityPercent,
137+
int? CloudCoverPercent,
138+
string? PrecipitationType,
139+
bool IsAvailable
140+
);
141+
```
142+
143+
Behavior:
144+
- Returns `200` with `IsAvailable = true` when weather data is found.
145+
- Returns `200` with null weather fields and `IsAvailable = false` when location is missing or no weather is available.
146+
- Returns `400` when `rideDateTimeLocal` is missing or invalid.
147+
- Returns `401` when the caller is unauthenticated.
148+
120149
---
121150

122151
## Frontend TypeScript Contracts

specs/011-ride-weather-data/quickstart.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,14 @@ Map new `RideEntity` weather columns to `RideHistoryRow` and `RideDefaultsRespon
171171

172172
---
173173

174+
### Step 7.5 — Add Explicit Weather Preview Endpoint
175+
176+
Add an authenticated endpoint that accepts `rideDateTimeLocal` and returns weather fields for the
177+
authenticated rider's configured location. This endpoint is used by create/edit form buttons to
178+
fill weather values before save while keeping the weather provider server-side.
179+
180+
---
181+
174182
### Step 8 — Frontend: Ride Create/Edit Form
175183

176184
**Files** (locate existing ride form components):
@@ -192,6 +200,7 @@ Add optional weather fields to both forms:
192200
**UI behavior**:
193201
- All weather fields are optional — user can leave them empty
194202
- Pre-populated on edit from the stored ride values
203+
- Add a `Load Weather` button on create and edit that fetches weather for the currently selected ride timestamp
195204
- When user manually changes any field, set `weatherUserOverridden = true` before submit
196205
- Show in RideHistoryRow in the history table
197206

specs/011-ride-weather-data/spec.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ As a rider creating or editing a ride entry, I want weather details for the ride
1717

1818
**Acceptance Scenarios**:
1919

20-
1. **Given** a user submits a new ride with a valid ride timestamp and weather data is available, **When** the server processes the save request, **Then** temperature, wind speed, wind direction, humidity, cloud cover, and precipitation type are fetched server-side and stored with the ride created event.
21-
2. **Given** a user edits an existing ride and updates the ride timestamp, **When** they save, **Then** the server fetches weather for the new time and stores the refreshed values with the ride updated event.
20+
1. **Given** a user opens the create ride page or edits a ride and chooses to load weather for the selected ride timestamp, **When** the server processes that explicit load-weather request, **Then** temperature, wind speed, wind direction, humidity, cloud cover, and precipitation type are fetched server-side and returned to the form so the fields can be filled before save.
21+
2. **Given** a user submits a new ride with a valid ride timestamp and weather data is available, **When** the server processes the save request, **Then** temperature, wind speed, wind direction, humidity, cloud cover, and precipitation type are fetched server-side and stored with the ride created event.
22+
3. **Given** a user edits an existing ride and updates the ride timestamp, **When** they save, **Then** the server fetches weather for the new time and stores the refreshed values with the ride updated event.
2223

2324
---
2425

@@ -32,8 +33,9 @@ As a rider, I want to manually adjust weather fields when automatic values are i
3233

3334
**Acceptance Scenarios**:
3435

35-
1. **Given** a ride has been saved and its weather values are shown on the ride edit page, **When** the user changes one or more weather fields and saves, **Then** the user-provided values are stored as authoritative and the server does not overwrite them with a new weather fetch.
36-
2. **Given** automatic weather retrieval fails and a ride is saved with empty weather fields, **When** the user re-opens the ride for editing and enters weather values manually and saves, **Then** those values are stored and displayed for that ride.
36+
1. **Given** a ride create or edit form is visible, **When** the user clicks the load-weather button and weather data is available, **Then** the returned weather values populate the weather fields without requiring a save.
37+
2. **Given** a ride has been saved and its weather values are shown on the ride edit page, **When** the user changes one or more weather fields and saves, **Then** the user-provided values are stored as authoritative and the server does not overwrite them with a new weather fetch.
38+
3. **Given** automatic weather retrieval fails and a ride is saved with empty weather fields, **When** the user re-opens the ride for editing and enters weather values manually and saves, **Then** those values are stored and displayed for that ride.
3739

3840
---
3941

@@ -55,6 +57,7 @@ As a rider repeatedly adding or editing rides for the same date/time context, I
5557
- Weather provider has no record for the ride timestamp; ride save still succeeds and weather fields remain empty unless user enters values.
5658
- Weather provider returns only partial weather data; available fields are populated and unavailable fields remain empty.
5759
- Weather provider is unreachable or times out during server-side fetch; ride save completes and weather fields are stored empty.
60+
- Weather preview lookup triggered from the form is unreachable or returns no data; the form remains usable and weather fields stay empty.
5861
- User manually enters or clears a weather field on the edit form; the submitted value (including blank) is preserved and the server does not overwrite it with a fresh weather fetch.
5962
- Ride time is changed during edit; the server re-fetches weather for the new time at save, but only for fields the user has not manually provided.
6063

@@ -63,6 +66,8 @@ As a rider repeatedly adding or editing rides for the same date/time context, I
6366
### Functional Requirements
6467

6568
- **FR-001**: The server MUST perform a weather lookup at the time the ride create request is received; the weather fetch MUST support both historical ride times and current/live ride times.
69+
- **FR-001c**: The system MUST provide an explicit user action on ride create and ride edit forms to load weather for the currently selected ride timestamp before save.
70+
- **FR-001d**: The explicit load-weather action MUST call the server, not the frontend directly, and MUST fill the weather form fields with returned values when available.
6671
- **FR-001a**: The weather provider API key MUST be configurable by the user or administrator in the application's settings; the app MUST behave gracefully (empty weather fields, no error blocking save) when no API key is configured.
6772
- **FR-001b**: Weather fetches MUST be performed server-side only; the API key MUST NOT be exposed to or used by the frontend.
6873
- **FR-002**: The server MUST perform a weather lookup at the time the ride update request is received when the ride timestamp has changed; the same historical/current support applies.
@@ -81,7 +86,7 @@ As a rider repeatedly adding or editing rides for the same date/time context, I
8186

8287
- **Ride Event Weather Snapshot**: Weather attributes stored directly on ride created and ride updated events; includes temperature, wind speed, wind direction, humidity, cloud cover, precipitation type, plus indication of whether values were user-overridden.
8388
- **Weather Lookup Record**: Reusable stored weather result keyed by ride timestamp rounded to the nearest hour and the user's configured location; includes retrieved weather fields, lookup timestamp, and retrieval status (success/partial/unavailable/error). All ride times within the same hour at the same location share one cache entry.
84-
- **Ride Entry Form Weather Fields**: Editable fields shown on both create and edit pages. On create, fields start empty and are optionally filled by the user before saving; server auto-fills only unsubmitted fields. On edit, fields are pre-populated with the previously saved weather values so the user can review and override before saving.
89+
- **Ride Entry Form Weather Fields**: Editable fields shown on both create and edit pages. On create, fields may start empty or with prior ride defaults and can be explicitly filled by a load-weather action before saving; server auto-fills only unsubmitted fields at save. On edit, fields are pre-populated with the previously saved weather values so the user can review, reload weather for the current timestamp, and override before saving.
8590

8691
## Clarifications
8792

specs/011-ride-weather-data/tasks.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,21 @@
133133

134134
---
135135

136+
## Phase 7: Explicit Weather Load Actions
137+
138+
**Purpose**: Add a user-triggered weather fetch button on create and edit forms that fills weather fields before save.
139+
140+
- [X] T047 [P] [US1] Update the feature spec for explicit load-weather actions on create and edit forms in `specs/011-ride-weather-data/spec.md`
141+
- [X] T048 [P] [US1] Add backend/frontend contract coverage for a weather preview endpoint in `src/BikeTracking.Api/Contracts/RidesContracts.cs` and `src/BikeTracking.Frontend/src/services/ridesService.ts`
142+
- [X] T049 [P] [US1] Add endpoint coverage for explicit weather loading in `src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs`
143+
- [X] T050 [P] [US2] Add frontend tests for load-weather buttons in `src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx` and `src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx`
144+
- [X] T051 [US1] Add authenticated weather preview endpoint in `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs`
145+
- [X] T052 [US1] Add create-page load-weather action in `src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx`
146+
- [X] T053 [US2] Add edit-page load-weather action in `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx`
147+
- [X] T054 [US1] Update supporting docs/examples for the preview endpoint in `specs/011-ride-weather-data/quickstart.md`, `specs/011-ride-weather-data/contracts/api-contracts.md`, and `src/BikeTracking.Api/BikeTracking.Api.http`
148+
149+
---
150+
136151
## Dependencies
137152

138153
```text

src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,61 @@ public async Task GetGasPrice_WithoutAuth_Returns401()
234234
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
235235
}
236236

237+
[Fact]
238+
public async Task GetRideWeather_WithConfiguredLocation_ReturnsWeatherData()
239+
{
240+
await using var host = await RecordRideApiHost.StartAsync();
241+
var userId = await host.SeedUserAsync("WeatherPreview");
242+
await host.SeedUserSettingsAsync(userId, latitude: 40.71m, longitude: -74.01m);
243+
244+
var response = await host.Client.GetWithAuthAsync(
245+
"/api/rides/weather?rideDateTimeLocal=2026-03-20T10:30:00",
246+
userId
247+
);
248+
249+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
250+
var payload = await response.Content.ReadFromJsonAsync<RideWeatherResponse>();
251+
Assert.NotNull(payload);
252+
Assert.True(payload.IsAvailable);
253+
Assert.Equal(72.5m, payload.Temperature);
254+
Assert.Equal(10.3m, payload.WindSpeedMph);
255+
Assert.Equal(250, payload.WindDirectionDeg);
256+
Assert.Equal(65, payload.RelativeHumidityPercent);
257+
Assert.Equal(30, payload.CloudCoverPercent);
258+
}
259+
260+
[Fact]
261+
public async Task GetRideWeather_WithInvalidDateTime_Returns400()
262+
{
263+
await using var host = await RecordRideApiHost.StartAsync();
264+
var userId = await host.SeedUserAsync("WeatherPreviewBadDate");
265+
266+
var response = await host.Client.GetWithAuthAsync(
267+
"/api/rides/weather?rideDateTimeLocal=not-a-date-time",
268+
userId
269+
);
270+
271+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
272+
}
273+
274+
[Fact]
275+
public async Task GetRideWeather_WithoutLocation_ReturnsUnavailableShape()
276+
{
277+
await using var host = await RecordRideApiHost.StartAsync();
278+
var userId = await host.SeedUserAsync("WeatherPreviewNoLocation");
279+
280+
var response = await host.Client.GetWithAuthAsync(
281+
"/api/rides/weather?rideDateTimeLocal=2026-03-20T10:30:00",
282+
userId
283+
);
284+
285+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
286+
var payload = await response.Content.ReadFromJsonAsync<RideWeatherResponse>();
287+
Assert.NotNull(payload);
288+
Assert.False(payload.IsAvailable);
289+
Assert.Null(payload.Temperature);
290+
}
291+
237292
[Fact]
238293
public async Task PostRecordRide_WithGasPrice_PersistsGasPrice()
239294
{
@@ -763,6 +818,24 @@ public async Task<long> SeedUserAsync(string displayName)
763818
return user.UserId;
764819
}
765820

821+
public async Task SeedUserSettingsAsync(long userId, decimal latitude, decimal longitude)
822+
{
823+
using var scope = app.Services.CreateScope();
824+
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
825+
826+
dbContext.UserSettings.Add(
827+
new UserSettingsEntity
828+
{
829+
UserId = userId,
830+
Latitude = latitude,
831+
Longitude = longitude,
832+
UpdatedAtUtc = DateTime.UtcNow,
833+
}
834+
);
835+
836+
await dbContext.SaveChangesAsync();
837+
}
838+
766839
public async Task<int> RecordRideAsync(
767840
long userId,
768841
decimal miles,

src/BikeTracking.Api/BikeTracking.Api.http

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ GET {{ApiService_HostAddress}}/api/rides/defaults
4040
Accept: application/json
4141
X-User-Id: {{RiderId}}
4242

43+
### Load weather for create/edit form
44+
GET {{ApiService_HostAddress}}/api/rides/weather?rideDateTimeLocal=2026-03-26T07:30:00
45+
Accept: application/json
46+
X-User-Id: {{RiderId}}
47+
4348
### Get quick ride options
4449
GET {{ApiService_HostAddress}}/api/rides/quick-options
4550
Accept: application/json

src/BikeTracking.Api/Contracts/RidesContracts.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ public sealed record GasPriceResponse(
5757
string? DataSource
5858
);
5959

60+
public sealed record RideWeatherResponse(
61+
DateTime RideDateTimeLocal,
62+
decimal? Temperature,
63+
decimal? WindSpeedMph,
64+
int? WindDirectionDeg,
65+
int? RelativeHumidityPercent,
66+
int? CloudCoverPercent,
67+
string? PrecipitationType,
68+
bool IsAvailable
69+
);
70+
6071
public sealed record QuickRideOption(decimal Miles, int RideMinutes, DateTime LastUsedAtLocal);
6172

6273
public sealed record QuickRideOptionsResponse(

src/BikeTracking.Api/Endpoints/RidesEndpoints.cs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using BikeTracking.Api.Application.Rides;
22
using BikeTracking.Api.Contracts;
3+
using BikeTracking.Api.Infrastructure.Persistence;
34
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.EntityFrameworkCore;
46

57
namespace BikeTracking.Api.Endpoints;
68

@@ -38,6 +40,15 @@ public static IEndpointRouteBuilder MapRidesEndpoints(this IEndpointRouteBuilder
3840
.Produces<ErrorResponse>(StatusCodes.Status401Unauthorized)
3941
.RequireAuthorization();
4042

43+
group
44+
.MapGet("/weather", GetRideWeather)
45+
.WithName("GetRideWeather")
46+
.WithSummary("Get weather preview for a ride timestamp")
47+
.Produces<RideWeatherResponse>(StatusCodes.Status200OK)
48+
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
49+
.Produces<ErrorResponse>(StatusCodes.Status401Unauthorized)
50+
.RequireAuthorization();
51+
4152
group
4253
.MapGet("/quick-options", GetQuickRideOptions)
4354
.WithName("GetQuickRideOptions")
@@ -191,6 +202,93 @@ CancellationToken cancellationToken
191202
}
192203
}
193204

205+
private static async Task<IResult> GetRideWeather(
206+
HttpContext context,
207+
[FromQuery] string? rideDateTimeLocal,
208+
[FromServices] BikeTrackingDbContext dbContext,
209+
[FromServices] IWeatherLookupService weatherLookupService,
210+
CancellationToken cancellationToken
211+
)
212+
{
213+
var userIdString = context.User.FindFirst("sub")?.Value;
214+
if (!long.TryParse(userIdString, out var riderId) || riderId <= 0)
215+
return Results.Unauthorized();
216+
217+
if (
218+
string.IsNullOrWhiteSpace(rideDateTimeLocal)
219+
|| !DateTime.TryParse(rideDateTimeLocal, out var parsedRideDateTimeLocal)
220+
)
221+
{
222+
return Results.BadRequest(
223+
new ErrorResponse(
224+
"INVALID_REQUEST",
225+
"rideDateTimeLocal query parameter is required and must be a valid date time."
226+
)
227+
);
228+
}
229+
230+
try
231+
{
232+
var userSettings = await dbContext
233+
.UserSettings.AsNoTracking()
234+
.SingleOrDefaultAsync(settings => settings.UserId == riderId, cancellationToken);
235+
236+
if (
237+
userSettings?.Latitude is not decimal latitude
238+
|| userSettings.Longitude is not decimal longitude
239+
)
240+
{
241+
return Results.Ok(
242+
new RideWeatherResponse(
243+
RideDateTimeLocal: parsedRideDateTimeLocal,
244+
Temperature: null,
245+
WindSpeedMph: null,
246+
WindDirectionDeg: null,
247+
RelativeHumidityPercent: null,
248+
CloudCoverPercent: null,
249+
PrecipitationType: null,
250+
IsAvailable: false
251+
)
252+
);
253+
}
254+
255+
var weather = await weatherLookupService.GetOrFetchAsync(
256+
latitude,
257+
longitude,
258+
parsedRideDateTimeLocal.ToUniversalTime(),
259+
cancellationToken
260+
);
261+
262+
return Results.Ok(
263+
new RideWeatherResponse(
264+
RideDateTimeLocal: parsedRideDateTimeLocal,
265+
Temperature: weather?.Temperature,
266+
WindSpeedMph: weather?.WindSpeedMph,
267+
WindDirectionDeg: weather?.WindDirectionDeg,
268+
RelativeHumidityPercent: weather?.RelativeHumidityPercent,
269+
CloudCoverPercent: weather?.CloudCoverPercent,
270+
PrecipitationType: weather?.PrecipitationType,
271+
IsAvailable: weather is not null
272+
)
273+
);
274+
}
275+
catch
276+
{
277+
return Results.Ok(
278+
new RideWeatherResponse(
279+
RideDateTimeLocal: parsedRideDateTimeLocal,
280+
Temperature: null,
281+
WindSpeedMph: null,
282+
WindDirectionDeg: null,
283+
RelativeHumidityPercent: null,
284+
CloudCoverPercent: null,
285+
PrecipitationType: null,
286+
IsAvailable: false
287+
)
288+
);
289+
}
290+
}
291+
194292
private static async Task<IResult> GetRideHistory(
195293
HttpContext context,
196294
GetRideHistoryService historyService,

0 commit comments

Comments
 (0)