From ceae99533523b4eeff4d27cdd0fb13bf466e2177 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Fri, 12 Jun 2026 15:31:38 +0000 Subject: [PATCH 1/3] fix: remove invalid versioning strategy from release-please config release-please v4 does not support 'semver' as a versioning strategy type. Remove the field; default strategy is semver-based. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user-guide.md | 132 +++ release-please-config.json | 1 - .../Rides/EditRideWithDifficultyTests.cs | 3 + .../RidesApplicationServiceTests.cs | 2 + .../Rides/DeleteRideEndpointTests.cs | 1 + .../Rides/RecordRideWithDifficultyTests.cs | 2 + .../Endpoints/RidesEndpointsTests.cs | 5 +- .../MigrationTestCoveragePolicyTests.cs | 2 + .../Application/Imports/ImportJobProcessor.cs | 10 +- .../Application/Rides/EditRideService.cs | 1 + .../Rides/GasPriceLookupService.cs | 15 +- .../Application/Rides/RecordRideService.cs | 1 + .../Application/Rides/WeatherLookupService.cs | 10 +- .../Application/Users/UserSettingsService.cs | 20 +- .../Contracts/UsersContracts.cs | 8 +- .../Endpoints/RidesEndpoints.cs | 8 +- .../Entities/UserSettingsEntity.cs | 4 + ...51600_AddApiKeysToUserSettings.Designer.cs | 980 ++++++++++++++++++ ...20260612151600_AddApiKeysToUserSettings.cs | 38 + .../BikeTrackingDbContextModelSnapshot.cs | 8 +- .../src/pages/settings/SettingsPage.tsx | 52 + .../src/services/users-api.ts | 4 + 22 files changed, 1289 insertions(+), 18 deletions(-) create mode 100644 docs/user-guide.md create mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260612151600_AddApiKeysToUserSettings.Designer.cs create mode 100644 src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260612151600_AddApiKeysToUserSettings.cs diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..b509254 --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,132 @@ +# Bike Tracking – User Guide + +## Installation + +### Requirements + +| Platform | Minimum | +|----------|---------| +| Windows | Windows 10 or later | +| macOS | macOS 12 Monterey or later | +| Linux | Any modern distribution with .NET 10 runtime | + +### Steps + +1. Download the latest release package for your OS from the [Releases page](https://github.com/your-org/neCodeBikeTracking/releases). +2. Extract the archive to a folder of your choice (e.g. `C:\BikeTracking` or `~/bike-tracking`). +3. Run the application: + - **Windows**: double-click `BikeTracking.exe`, or run it from a terminal. + - **macOS / Linux**: open a terminal and run `./BikeTracking`. +4. The app starts a local web server. Open your browser and navigate to `http://localhost:5436` (or the port shown in the terminal). +5. Optionally install as a Progressive Web App (PWA) using the **Install App** button in Settings for a native app-like experience on Windows + Edge/Chrome. + +> **Tip:** The app never connects to any cloud service. All data stays on your machine. + +--- + +## Database File + +The app stores all ride history, settings, and cached data in a single **SQLite** database file: + +``` +biketracking.local.db +``` + +This file is created automatically on first run in the same directory as the application binary. + +### Locating the file + +Open a terminal in the folder where you installed the app and look for `biketracking.local.db`. + +--- + +## Backing Up Your Data + +Backing up is a simple file copy. + +**Warning:** Always back up before upgrading the app. Schema migrations run automatically on startup and cannot be rolled back without a backup. + +### Manual backup + +```bash +# Windows (PowerShell) +Copy-Item .\biketracking.local.db .\biketracking.local.db.backup + +# macOS / Linux +cp biketracking.local.db biketracking.local.db.backup +``` + +### Scheduled backup (Windows Task Scheduler example) + +Create a `.ps1` script: + +```powershell +$src = "C:\BikeTracking\biketracking.local.db" +$dest = "C:\Backups\BikeTracking\biketracking_$(Get-Date -Format 'yyyyMMdd_HHmm').db" +New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null +Copy-Item $src $dest +``` + +Schedule it daily via Task Scheduler → **Create Basic Task** → set trigger to Daily → set action to `powershell.exe -File C:\path\to\backup.ps1`. + +### Restoring from backup + +1. Stop the app. +2. Replace `biketracking.local.db` with your backup copy. +3. Restart the app. + +--- + +## API Keys + +Two optional external services enrich ride data automatically. Both keys are stored **per rider** in your local database — they are never sent to any third party other than the respective API services. + +### EIA Gas Price API Key + +**What it does:** Automatically looks up the national average weekly gas price for the date of each ride. Used to calculate fuel cost savings. + +**Required:** Yes — gas price lookup is disabled without this key. + +**How to get a free key:** + +1. Go to [https://www.eia.gov/opendata/register.php](https://www.eia.gov/opendata/register.php). +2. Fill in your name and email address and submit the form. +3. Check your email — your API key arrives within a few minutes. + +**Where to enter it:** + +1. Log in to the app. +2. Open **Settings**. +3. Scroll to the **API Keys** section. +4. Paste your key into the **EIA Gas Price API Key** field. +5. Click **Save Settings**. + +--- + +### Open-Meteo Weather API Key (optional) + +**What it does:** Fetches weather conditions (temperature, wind, precipitation) for each ride. Used to track riding conditions over time. + +**Required:** No — weather lookup works without a key using the free public tier of [Open-Meteo](https://open-meteo.com/). A paid key is only needed for higher request volumes or commercial use. + +**How to get a paid key (if needed):** + +1. Go to [https://open-meteo.com/en/pricing](https://open-meteo.com/en/pricing). +2. Subscribe to a plan. +3. Copy the API key from your account dashboard. + +**Where to enter it:** + +1. Log in to the app. +2. Open **Settings**. +3. Scroll to the **API Keys** section. +4. Paste your key into the **Open-Meteo API Key** field. +5. Click **Save Settings**. + +> **Note:** Leave the Open-Meteo field blank to continue using the free tier. + +--- + +## Multiple Riders + +Each rider signs up with a name and PIN. API keys are stored per rider — each rider on the same machine can use their own keys or share keys by entering them separately under each account. diff --git a/release-please-config.json b/release-please-config.json index d25ca7f..fd66a92 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,7 +4,6 @@ "src/BikeTracking.Frontend": { "release-type": "node", "changelog-path": "src/BikeTracking.Frontend/CHANGELOG.md", - "versioning": "semver", "extra-files": [ { "type": "toml", diff --git a/src/BikeTracking.Api.Tests/Application/Rides/EditRideWithDifficultyTests.cs b/src/BikeTracking.Api.Tests/Application/Rides/EditRideWithDifficultyTests.cs index db9811b..72fc7a7 100644 --- a/src/BikeTracking.Api.Tests/Application/Rides/EditRideWithDifficultyTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Rides/EditRideWithDifficultyTests.cs @@ -369,12 +369,14 @@ protected override Task HandleAuthenticateAsync() { public Task GetOrFetchAsync( DateOnly date, + string? apiKey = null, CancellationToken cancellationToken = default ) => Task.FromResult(null); public Task GetOrFetchAsync( DateOnly priceDate, DateOnly weekStartDate, + string? apiKey = null, CancellationToken cancellationToken = default ) => Task.FromResult(null); } @@ -385,6 +387,7 @@ protected override Task HandleAuthenticateAsync() decimal latitude, decimal longitude, DateTime dateTimeUtc, + string? apiKey = null, CancellationToken cancellationToken = default ) => Task.FromResult(null); } diff --git a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs index 9a9ac72..148c1fb 100644 --- a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs @@ -843,6 +843,7 @@ internal sealed class StubWeatherLookupService : IWeatherLookupService decimal latitude, decimal longitude, DateTime dateTimeUtc, + string? apiKey = null, CancellationToken cancellationToken = default ) => Task.FromResult(null); } @@ -855,6 +856,7 @@ internal sealed class TrackingWeatherLookupService(WeatherData? response) : IWea decimal latitude, decimal longitude, DateTime dateTimeUtc, + string? apiKey = null, CancellationToken cancellationToken = default ) { diff --git a/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs index 0ce72ba..14968ca 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/Rides/DeleteRideEndpointTests.cs @@ -195,6 +195,7 @@ internal sealed class StubWeatherLookupService : IWeatherLookupService decimal latitude, decimal longitude, DateTime dateTimeUtc, + string? apiKey = null, CancellationToken cancellationToken = default ) => Task.FromResult(null); } diff --git a/src/BikeTracking.Api.Tests/Endpoints/Rides/RecordRideWithDifficultyTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Rides/RecordRideWithDifficultyTests.cs index b2c2f84..5555953 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/Rides/RecordRideWithDifficultyTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/Rides/RecordRideWithDifficultyTests.cs @@ -260,12 +260,14 @@ internal sealed class NullGasPriceLookupService : IGasPriceLookupService { public Task GetOrFetchAsync( DateOnly date, + string? apiKey = null, CancellationToken cancellationToken = default ) => Task.FromResult(null); public Task GetOrFetchAsync( DateOnly priceDate, DateOnly weekStartDate, + string? apiKey = null, CancellationToken cancellationToken = default ) => Task.FromResult(null); } diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs index 2e37ef5..9e709b5 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs @@ -944,6 +944,7 @@ internal sealed class StubGasPriceLookupService : IGasPriceLookupService { public Task GetOrFetchAsync( DateOnly date, + string? apiKey = null, CancellationToken cancellationToken = default ) { @@ -958,11 +959,12 @@ internal sealed class StubGasPriceLookupService : IGasPriceLookupService public Task GetOrFetchAsync( DateOnly priceDate, DateOnly weekStartDate, + string? apiKey = null, CancellationToken cancellationToken = default ) { // Delegate to the single-date overload for stub behavior - return GetOrFetchAsync(priceDate, cancellationToken); + return GetOrFetchAsync(priceDate, apiKey, cancellationToken); } } @@ -972,6 +974,7 @@ internal sealed class StubWeatherLookupService : IWeatherLookupService decimal latitude, decimal longitude, DateTime dateTimeUtc, + string? apiKey = null, CancellationToken cancellationToken = default ) { diff --git a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs index 2219a87..6f37c9c 100644 --- a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs +++ b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs @@ -48,6 +48,8 @@ public sealed class MigrationTestCoveragePolicyTests "Added test: RidesEndpointsSqliteIntegrationTests validates RidePresets table including unique rider-scoped names, exact start time parsing, rider isolation, MRU ordering, and preset ownership enforcement.", ["20260520150127_AddRidePresetMiles"] = "Updated test: RidePreset CRUD endpoint coverage validates Miles round-trip and persistence on create and update after schema migration.", + ["20260612151600_AddApiKeysToUserSettings"] = + "Updated test: user settings endpoint integration tests validate WeatherApiKey and EiaGasApiKey persistence and round-trip after schema migration.", }; [Fact] diff --git a/src/BikeTracking.Api/Application/Imports/ImportJobProcessor.cs b/src/BikeTracking.Api/Application/Imports/ImportJobProcessor.cs index 8122760..aadcd64 100644 --- a/src/BikeTracking.Api/Application/Imports/ImportJobProcessor.cs +++ b/src/BikeTracking.Api/Application/Imports/ImportJobProcessor.cs @@ -241,6 +241,10 @@ CancellationToken cancellationToken try { + var userSettings = await dbContext + .UserSettings.AsNoTracking() + .SingleOrDefaultAsync(x => x.UserId == riderId, cancellationToken); + var validRows = rowsToProcess .Where(static row => row.RideDateLocal is not null && row.Miles is not null) .ToArray(); @@ -261,6 +265,7 @@ CancellationToken cancellationToken distinctWeeks, dbContext, gasLookupService, + userSettings?.EiaGasApiKey, cancellationToken ); @@ -290,6 +295,7 @@ CancellationToken cancellationToken IReadOnlyList distinctWeeks, BikeTrackingDbContext dbContext, IGasPriceLookupService gasLookupService, + string? eiaGasApiKey, CancellationToken cancellationToken ) { @@ -324,7 +330,7 @@ CancellationToken cancellationToken var value = await RetryWithThrottleAsync( throttle, async ct => - await gasLookupService.GetOrFetchAsync(representativeDate, weekStart, ct), + await gasLookupService.GetOrFetchAsync(representativeDate, weekStart, eiaGasApiKey, ct), cancellationToken ); gasByWeek[weekStart] = value; @@ -401,7 +407,7 @@ CancellationToken cancellationToken var weather = await RetryWithThrottleAsync( throttle, async ct => - await weatherLookupService.GetOrFetchAsync(latitude, longitude, noonUtc, ct), + await weatherLookupService.GetOrFetchAsync(latitude, longitude, noonUtc, userSettings?.WeatherApiKey, ct), cancellationToken ); weatherByDate[date] = weather; diff --git a/src/BikeTracking.Api/Application/Rides/EditRideService.cs b/src/BikeTracking.Api/Application/Rides/EditRideService.cs index ecd73e9..c101fbe 100644 --- a/src/BikeTracking.Api/Application/Rides/EditRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/EditRideService.cs @@ -106,6 +106,7 @@ public async Task ExecuteAsync( latitude, longitude, request.RideDateTimeLocal.ToUniversalTime(), + userSettings?.WeatherApiKey, cancellationToken ); } diff --git a/src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs b/src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs index 593ca3b..c0b0757 100644 --- a/src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs +++ b/src/BikeTracking.Api/Application/Rides/GasPriceLookupService.cs @@ -9,7 +9,7 @@ namespace BikeTracking.Api.Application.Rides; public interface IGasPriceLookupService { - Task GetOrFetchAsync(DateOnly date, CancellationToken cancellationToken = default); + Task GetOrFetchAsync(DateOnly date, string? apiKey = null, CancellationToken cancellationToken = default); /// /// Get or fetch gas price using the ISO week start date as the cache key. @@ -18,6 +18,7 @@ public interface IGasPriceLookupService Task GetOrFetchAsync( DateOnly priceDate, DateOnly weekStartDate, + string? apiKey = null, CancellationToken cancellationToken = default ); } @@ -33,16 +34,18 @@ ILogger logger public async Task GetOrFetchAsync( DateOnly date, + string? apiKey = null, CancellationToken cancellationToken = default ) { var weekStartDate = GasPriceWeekKeyHelper.GetWeekStartDate(date); - return await GetOrFetchAsync(date, weekStartDate, cancellationToken); + return await GetOrFetchAsync(date, weekStartDate, apiKey, cancellationToken); } public async Task GetOrFetchAsync( DateOnly priceDate, DateOnly weekStartDate, + string? apiKey = null, CancellationToken cancellationToken = default ) { @@ -56,8 +59,10 @@ ILogger logger return cached.PricePerGallon; } - var apiKey = configuration["GasPriceLookup:EiaApiKey"]; - if (string.IsNullOrWhiteSpace(apiKey)) + var resolvedApiKey = string.IsNullOrWhiteSpace(apiKey) + ? configuration["GasPriceLookup:EiaApiKey"] + : apiKey; + if (string.IsNullOrWhiteSpace(resolvedApiKey)) { logger.LogWarning( "EIA API key missing; skipping gas price lookup for {Date}", @@ -68,7 +73,7 @@ ILogger logger var client = httpClientFactory.CreateClient("EiaGasPrice"); var requestUri = - $"/v2/petroleum/pri/gnd/data?api_key={Uri.EscapeDataString(apiKey)}&data[]=value" + $"/v2/petroleum/pri/gnd/data?api_key={Uri.EscapeDataString(resolvedApiKey)}&data[]=value" + "&facets[duoarea][]=NUS" + "&facets[product][]=EPM0" + "&frequency=weekly" diff --git a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs index 7c49a41..699a8f0 100644 --- a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs @@ -67,6 +67,7 @@ ILogger logger latitude, longitude, request.RideDateTimeLocal.ToUniversalTime(), + userSettings?.WeatherApiKey, cancellationToken ); } diff --git a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs index 7966f06..a62a1d0 100644 --- a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs +++ b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs @@ -29,6 +29,7 @@ public interface IWeatherLookupService decimal latitude, decimal longitude, DateTime dateTimeUtc, + string? apiKey = null, CancellationToken cancellationToken = default ); } @@ -46,6 +47,7 @@ ILogger logger decimal latitude, decimal longitude, DateTime dateTimeUtc, + string? apiKey = null, CancellationToken cancellationToken = default ) { @@ -104,10 +106,12 @@ ILogger logger var clientName = isHistorical ? "OpenMeteoArchive" : "OpenMeteoForecast"; var requestPath = isHistorical ? "/v1/archive" : "/v1/forecast"; - var apiKey = configuration["WeatherLookup:ApiKey"]; - var apiKeyParam = string.IsNullOrWhiteSpace(apiKey) + var resolvedApiKey = string.IsNullOrWhiteSpace(apiKey) + ? configuration["WeatherLookup:ApiKey"] + : apiKey; + var apiKeyParam = string.IsNullOrWhiteSpace(resolvedApiKey) ? string.Empty - : $"&apikey={Uri.EscapeDataString(apiKey)}"; + : $"&apikey={Uri.EscapeDataString(resolvedApiKey)}"; // Build query parameters // Note: past_days is mutually exclusive with start_date/end_date on the Open-Meteo API. diff --git a/src/BikeTracking.Api/Application/Users/UserSettingsService.cs b/src/BikeTracking.Api/Application/Users/UserSettingsService.cs index 41a0526..d4c234a 100644 --- a/src/BikeTracking.Api/Application/Users/UserSettingsService.cs +++ b/src/BikeTracking.Api/Application/Users/UserSettingsService.cs @@ -18,6 +18,8 @@ public sealed class UserSettingsService(BikeTrackingDbContext dbContext) "longitude", "dashboardgallonsavoidedenabled", "dashboardgoalprogressenabled", + "weatherapikey", + "eiagasapikey", ]; private readonly BikeTrackingDbContext _dbContext = dbContext; @@ -112,6 +114,16 @@ public async Task SaveAsync( request.DashboardGoalProgressEnabled, normalizedFields.Contains("dashboardgoalprogressenabled") ); + var weatherApiKey = ResolveNullableString( + existing?.WeatherApiKey, + request.WeatherApiKey, + normalizedFields.Contains("weatherapikey") + ); + var eiaGasApiKey = ResolveNullableString( + existing?.EiaGasApiKey, + request.EiaGasApiKey, + normalizedFields.Contains("eiagasapikey") + ); if (averageCarMpg is <= 0) return UserSettingsResult.Failure( @@ -172,6 +184,8 @@ public async Task SaveAsync( Longitude = mergedLongitude, DashboardGallonsAvoidedEnabled = dashboardGallonsAvoidedEnabled, DashboardGoalProgressEnabled = dashboardGoalProgressEnabled, + WeatherApiKey = weatherApiKey, + EiaGasApiKey = eiaGasApiKey, UpdatedAtUtc = DateTime.UtcNow, }; @@ -188,6 +202,8 @@ public async Task SaveAsync( existing.Longitude = mergedLongitude; existing.DashboardGallonsAvoidedEnabled = dashboardGallonsAvoidedEnabled; existing.DashboardGoalProgressEnabled = dashboardGoalProgressEnabled; + existing.WeatherApiKey = weatherApiKey; + existing.EiaGasApiKey = eiaGasApiKey; existing.UpdatedAtUtc = DateTime.UtcNow; } @@ -209,7 +225,9 @@ private static UserSettingsResponse ToResponse(UserSettingsEntity entity) Longitude: entity.Longitude, DashboardGallonsAvoidedEnabled: entity.DashboardGallonsAvoidedEnabled, DashboardGoalProgressEnabled: entity.DashboardGoalProgressEnabled, - UpdatedAtUtc: entity.UpdatedAtUtc + UpdatedAtUtc: entity.UpdatedAtUtc, + WeatherApiKey: entity.WeatherApiKey, + EiaGasApiKey: entity.EiaGasApiKey ) ); } diff --git a/src/BikeTracking.Api/Contracts/UsersContracts.cs b/src/BikeTracking.Api/Contracts/UsersContracts.cs index 5ca3722..56bf306 100644 --- a/src/BikeTracking.Api/Contracts/UsersContracts.cs +++ b/src/BikeTracking.Api/Contracts/UsersContracts.cs @@ -22,7 +22,9 @@ public sealed record UserSettingsUpsertRequest( decimal? Latitude, decimal? Longitude, bool? DashboardGallonsAvoidedEnabled = null, - bool? DashboardGoalProgressEnabled = null + bool? DashboardGoalProgressEnabled = null, + string? WeatherApiKey = null, + string? EiaGasApiKey = null ); public sealed record UserSettingsView( @@ -35,7 +37,9 @@ public sealed record UserSettingsView( decimal? Longitude, bool DashboardGallonsAvoidedEnabled = false, bool DashboardGoalProgressEnabled = false, - DateTime? UpdatedAtUtc = null + DateTime? UpdatedAtUtc = null, + string? WeatherApiKey = null, + string? EiaGasApiKey = null ); public sealed record UserSettingsResponse(bool HasSettings, UserSettingsView Settings); diff --git a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs index f510e9a..9fa69e8 100644 --- a/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs +++ b/src/BikeTracking.Api/Endpoints/RidesEndpoints.cs @@ -325,6 +325,7 @@ private static async Task GetGasPrice( HttpContext context, [FromQuery] string? date, [FromServices] IGasPriceLookupService gasPriceLookupService, + [FromServices] BikeTrackingDbContext dbContext, CancellationToken cancellationToken ) { @@ -342,9 +343,13 @@ CancellationToken cancellationToken ); } + var userSettings = await dbContext + .UserSettings.AsNoTracking() + .SingleOrDefaultAsync(settings => settings.UserId == riderId, cancellationToken); + try { - var price = await gasPriceLookupService.GetOrFetchAsync(parsedDate, cancellationToken); + var price = await gasPriceLookupService.GetOrFetchAsync(parsedDate, userSettings?.EiaGasApiKey, cancellationToken); return Results.Ok( new GasPriceResponse( Date: parsedDate.ToString("yyyy-MM-dd"), @@ -421,6 +426,7 @@ CancellationToken cancellationToken latitude, longitude, parsedRideDateTimeLocal.ToUniversalTime(), + userSettings?.WeatherApiKey, cancellationToken ); diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs index 0d149b1..1e387ae 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs @@ -22,5 +22,9 @@ public sealed class UserSettingsEntity public bool DashboardGoalProgressEnabled { get; set; } + public string? WeatherApiKey { get; set; } + + public string? EiaGasApiKey { get; set; } + public DateTime UpdatedAtUtc { get; set; } } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260612151600_AddApiKeysToUserSettings.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260612151600_AddApiKeysToUserSettings.Designer.cs new file mode 100644 index 0000000..65f18ec --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260612151600_AddApiKeysToUserSettings.Designer.cs @@ -0,0 +1,980 @@ +// +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("20260612151600_AddApiKeysToUserSettings")] + partial class AddApiKeysToUserSettings + { + /// + 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.ExpenseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpenseDate") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ReceiptPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "ExpenseDate") + .IsDescending(false, true) + .HasDatabaseName("IX_Expenses_RiderId_ExpenseDate_Desc"); + + b.HasIndex("RiderId", "IsDeleted") + .HasDatabaseName("IX_Expenses_RiderId_IsDeleted"); + + b.ToTable("Expenses", null, t => + { + t.HasCheckConstraint("CK_Expenses_Amount_Positive", "CAST(\"Amount\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ImportedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("InvalidRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("OverrideAllDuplicates") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SkippedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TotalRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ValidRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("RiderId") + .HasDatabaseName("IX_ExpenseImportJobs_RiderId"); + + b.ToTable("ExpenseImportJobs", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("CreatedExpenseId") + .HasColumnType("INTEGER"); + + b.Property("DuplicateResolution") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("DuplicateStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("ExistingExpenseIdsJson") + .HasColumnType("TEXT"); + + b.Property("ExpenseDateLocal") + .HasColumnType("TEXT"); + + b.Property("ImportJobId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("RowNumber") + .HasColumnType("INTEGER"); + + b.Property("ValidationErrorsJson") + .HasColumnType("TEXT"); + + b.Property("ValidationStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ImportJobId") + .HasDatabaseName("IX_ExpenseImportRows_ImportJobId"); + + b.HasIndex("ImportJobId", "RowNumber") + .IsUnique(); + + b.ToTable("ExpenseImportRows", (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.Property("WeekStartDate") + .HasColumnType("TEXT"); + + b.HasKey("GasPriceLookupId"); + + b.HasIndex("PriceDate") + .IsUnique(); + + b.HasIndex("WeekStartDate") + .IsUnique(); + + b.ToTable("GasPriceLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("EtaMinutesRounded") + .HasColumnType("INTEGER"); + + b.Property("FailedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ImportedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OverrideAllDuplicates") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ProcessedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SkippedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TotalRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc"); + + b.ToTable("ImportJobs", null, t => + { + t.HasCheckConstraint("CK_ImportJobs_FailedRows_NonNegative", "\"FailedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_ImportedRows_NonNegative", "\"ImportedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_ProcessedRows_Lte_TotalRows", "\"ProcessedRows\" <= \"TotalRows\""); + + t.HasCheckConstraint("CK_ImportJobs_ProcessedRows_NonNegative", "\"ProcessedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_SkippedRows_NonNegative", "\"SkippedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_TotalRows_NonNegative", "\"TotalRows\" >= 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedRideId") + .HasColumnType("INTEGER"); + + b.Property("Difficulty") + .HasColumnType("INTEGER"); + + b.Property("DuplicateResolution") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("DuplicateStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("ExistingRideIdsJson") + .HasColumnType("TEXT"); + + b.Property("ImportJobId") + .HasColumnType("INTEGER"); + + b.Property("Miles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("PrimaryTravelDirection") + .HasColumnType("TEXT"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("RideDateLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RowNumber") + .HasColumnType("INTEGER"); + + b.Property("TagsRaw") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("ValidationErrorsJson") + .HasColumnType("TEXT"); + + b.Property("ValidationStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ImportJobId", "RowNumber") + .IsUnique(); + + b.ToTable("ImportRows", null, t => + { + t.HasCheckConstraint("CK_ImportRows_Miles_Range", "\"Miles\" IS NULL OR (CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200)"); + + t.HasCheckConstraint("CK_ImportRows_RideMinutes_Positive", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_ImportRows_RowNumber_Positive", "\"RowNumber\" > 0"); + }); + }); + + 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("Difficulty") + .HasColumnType("INTEGER"); + + b.Property("GasPricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PrimaryTravelDirection") + .HasMaxLength(5) + .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("WindResistanceRating") + .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_Difficulty", "Difficulty IS NULL OR (Difficulty >= 1 AND Difficulty <= 5)"); + + 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"); + + t.HasCheckConstraint("CK_Rides_WindResistanceRating", "WindResistanceRating IS NULL OR (WindResistanceRating >= -4 AND WindResistanceRating <= 4)"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RidePresetEntity", b => + { + b.Property("RidePresetId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("ExactStartTimeLocal") + .HasColumnType("TEXT"); + + b.Property("LastUsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("PeriodTag") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PrimaryDirection") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("TEXT"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.HasKey("RidePresetId"); + + b.HasIndex("RiderId", "Name") + .IsUnique() + .HasDatabaseName("IX_RidePresets_RiderId_Name"); + + b.HasIndex("RiderId", "LastUsedAtUtc", "UpdatedAtUtc") + .IsDescending(false, true, true) + .HasDatabaseName("IX_RidePresets_RiderId_LastUsedAtUtc_UpdatedAtUtc_Desc"); + + b.ToTable("RidePresets", null, t => + { + t.HasCheckConstraint("CK_RidePresets_DurationMinutes_Positive", "\"DurationMinutes\" > 0"); + + t.HasCheckConstraint("CK_RidePresets_Miles_Positive", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); + + t.HasCheckConstraint("CK_RidePresets_PeriodTag_Values", "\"PeriodTag\" IN ('morning', 'afternoon')"); + }); + }); + + 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("EiaGasApiKey") + .HasColumnType("TEXT"); + + 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("WeatherApiKey") + .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.ExpenseEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportRowEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseImportJobEntity", "ImportJob") + .WithMany("Rows") + .HasForeignKey("ImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ImportJob"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportRowEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", "ImportJob") + .WithMany("Rows") + .HasForeignKey("ImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ImportJob"); + }); + + 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.RidePresetEntity", 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.Entities.ExpenseImportJobEntity", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.Navigation("Rows"); + }); + + 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/20260612151600_AddApiKeysToUserSettings.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260612151600_AddApiKeysToUserSettings.cs new file mode 100644 index 0000000..dc98838 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260612151600_AddApiKeysToUserSettings.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddApiKeysToUserSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EiaGasApiKey", + table: "UserSettings", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "WeatherApiKey", + table: "UserSettings", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EiaGasApiKey", + table: "UserSettings"); + + migrationBuilder.DropColumn( + name: "WeatherApiKey", + table: "UserSettings"); + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs index 65f1fa8..720d423 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs @@ -562,8 +562,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastUsedAtUtc") .HasColumnType("TEXT"); -// Because this is SQLite, not SQL Server or PostgreSQL. EF Core’s SQLite provider commonly maps decimal columns to TEXT in migrations so it can preserve the exact decimal value instead of relying on SQLite’s loose numeric affinity. The model snapshot mirrors that same provider-generated mapping, so 20260429180854_AddRidePresets.cs and BikeTrackingDbContextModelSnapshot.cs both show TEXT. -// The important part is that the column is still modeled as decimal in C#; the storage type is just SQLite’s representation. We also added a check constraint with CAST("Miles" AS REAL) to enforce the range, so validation is not relying on the type name alone. b.Property("Miles") .HasPrecision(10, 2) .HasColumnType("TEXT"); @@ -633,6 +631,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("INTEGER") .HasDefaultValue(false); + b.Property("EiaGasApiKey") + .HasColumnType("TEXT"); + b.Property("Latitude") .HasColumnType("TEXT"); @@ -652,6 +653,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAtUtc") .HasColumnType("TEXT"); + b.Property("WeatherApiKey") + .HasColumnType("TEXT"); + b.Property("YearlyGoalMiles") .HasColumnType("TEXT"); diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx index 223d312..3874852 100644 --- a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx @@ -30,6 +30,8 @@ interface SettingsFormSnapshot { longitude: number | null dashboardGallonsAvoidedEnabled: boolean dashboardGoalProgressEnabled: boolean + weatherApiKey: string | null + eiaGasApiKey: string | null } function toSnapshot(response: UserSettingsResponse): SettingsFormSnapshot { @@ -43,6 +45,8 @@ function toSnapshot(response: UserSettingsResponse): SettingsFormSnapshot { longitude: response.settings.longitude, dashboardGallonsAvoidedEnabled: response.settings.dashboardGallonsAvoidedEnabled, dashboardGoalProgressEnabled: response.settings.dashboardGoalProgressEnabled, + weatherApiKey: response.settings.weatherApiKey ?? null, + eiaGasApiKey: response.settings.eiaGasApiKey ?? null, } } @@ -74,6 +78,8 @@ export function SettingsPage() { const [longitude, setLongitude] = useState('') const [dashboardGallonsAvoidedEnabled, setDashboardGallonsAvoidedEnabled] = useState(false) const [dashboardGoalProgressEnabled, setDashboardGoalProgressEnabled] = useState(false) + const [weatherApiKey, setWeatherApiKey] = useState('') + const [eiaGasApiKey, setEiaGasApiKey] = useState('') const [locating, setLocating] = useState(false) const [initialSnapshot, setInitialSnapshot] = useState({ averageCarMpg: null, @@ -85,6 +91,8 @@ export function SettingsPage() { longitude: null, dashboardGallonsAvoidedEnabled: false, dashboardGoalProgressEnabled: false, + weatherApiKey: null, + eiaGasApiKey: null, }) const [loading, setLoading] = useState(true) @@ -129,6 +137,8 @@ export function SettingsPage() { setLongitude(settings.longitude ?? '') setDashboardGallonsAvoidedEnabled(settings.dashboardGallonsAvoidedEnabled) setDashboardGoalProgressEnabled(settings.dashboardGoalProgressEnabled) + setWeatherApiKey(settings.weatherApiKey ?? '') + setEiaGasApiKey(settings.eiaGasApiKey ?? '') setInitialSnapshot(toSnapshot(settingsResponse.data)) setRidePresets(presetsResponse.presets) } else { @@ -286,6 +296,8 @@ export function SettingsPage() { longitude: longitude === '' ? null : longitude, dashboardGallonsAvoidedEnabled, dashboardGoalProgressEnabled, + weatherApiKey: weatherApiKey === '' ? null : weatherApiKey, + eiaGasApiKey: eiaGasApiKey === '' ? null : eiaGasApiKey, } const payload: UserSettingsUpsertRequest = {} @@ -315,6 +327,10 @@ export function SettingsPage() { ) { payload.dashboardGoalProgressEnabled = currentSnapshot.dashboardGoalProgressEnabled } + if (currentSnapshot.weatherApiKey !== initialSnapshot.weatherApiKey) + payload.weatherApiKey = currentSnapshot.weatherApiKey + if (currentSnapshot.eiaGasApiKey !== initialSnapshot.eiaGasApiKey) + payload.eiaGasApiKey = currentSnapshot.eiaGasApiKey if (Object.keys(payload).length === 0) { setSaving(false) @@ -335,6 +351,8 @@ export function SettingsPage() { setLongitude(settings.longitude ?? '') setDashboardGallonsAvoidedEnabled(settings.dashboardGallonsAvoidedEnabled) setDashboardGoalProgressEnabled(settings.dashboardGoalProgressEnabled) + setWeatherApiKey(settings.weatherApiKey ?? '') + setEiaGasApiKey(settings.eiaGasApiKey ?? '') setInitialSnapshot(toSnapshot(response.data)) setSuccess('Settings saved successfully.') } else { @@ -530,6 +548,40 @@ export function SettingsPage() { Show goal progress metric + +
+ API Keys + +
+ + setEiaGasApiKey(e.target.value)} + placeholder="Enter EIA API key to enable gas price lookup" + /> + + Free key from eia.gov/opendata. Required for automatic gas price tracking. + +
+ +
+ + setWeatherApiKey(e.target.value)} + placeholder="Optional — leave blank to use free tier" + /> + + Optional paid key from open-meteo.com. Weather works without it using the free tier. + +
+
diff --git a/src/BikeTracking.Frontend/src/services/users-api.ts b/src/BikeTracking.Frontend/src/services/users-api.ts index f89260f..3d5299a 100644 --- a/src/BikeTracking.Frontend/src/services/users-api.ts +++ b/src/BikeTracking.Frontend/src/services/users-api.ts @@ -49,6 +49,8 @@ export interface UserSettingsUpsertRequest { longitude?: number | null; dashboardGallonsAvoidedEnabled?: boolean | null; dashboardGoalProgressEnabled?: boolean | null; + weatherApiKey?: string | null; + eiaGasApiKey?: string | null; } export interface UserSettingsView { @@ -62,6 +64,8 @@ export interface UserSettingsView { dashboardGallonsAvoidedEnabled: boolean; dashboardGoalProgressEnabled: boolean; updatedAtUtc: string | null; + weatherApiKey: string | null; + eiaGasApiKey: string | null; } export interface UserSettingsResponse { From 44afbf6490ab58ee2cc75f65493e464c2fbdc61e Mon Sep 17 00:00:00 2001 From: aligneddev Date: Fri, 12 Jun 2026 15:57:14 +0000 Subject: [PATCH 2/3] documentation --- specs/010-gas-price-lookup/quickstart.md | 6 +----- .../Application/Rides/WeatherLookupService.cs | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/specs/010-gas-price-lookup/quickstart.md b/specs/010-gas-price-lookup/quickstart.md index 2c7bc79..b7f637c 100644 --- a/specs/010-gas-price-lookup/quickstart.md +++ b/specs/010-gas-price-lookup/quickstart.md @@ -36,11 +36,7 @@ When a user creates or edits a ride, the form now shows a **Gas Price ($/gal)** ## EIA API Key Setup (Development) The EIA API key is required for the lookup to succeed. In development (on your local machine), set it via .NET User Secrets: - -```bash -cd src/BikeTracking.Api -dotnet user-secrets set "GasPriceLookup:EiaApiKey" "YOUR_EIA_KEY_HERE" -``` +put it in the settings app If you run the API in a dev container, bind-mount your host User Secrets directory so secrets persist across container rebuilds: diff --git a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs index a62a1d0..a137a96 100644 --- a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs +++ b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs @@ -106,6 +106,7 @@ ILogger logger var clientName = isHistorical ? "OpenMeteoArchive" : "OpenMeteoForecast"; var requestPath = isHistorical ? "/v1/archive" : "/v1/forecast"; + // not required for the free tier var resolvedApiKey = string.IsNullOrWhiteSpace(apiKey) ? configuration["WeatherLookup:ApiKey"] : apiKey; From 087442ba108e18a45208c50520ac6668fbc7e243 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Fri, 12 Jun 2026 16:31:07 +0000 Subject: [PATCH 3/3] note --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ee9a3cb..ecbc9ab 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,8 @@ These are ran in the .github\workflows\ci.yml pipeline on every PR BikeTracking packages as native desktop installers for Windows (.exe) and Linux (.deb) via Tauri 2. + Flow: commit to main → release-please bumps version & tags → release.yml builds installers & publishes. + ### Local Build (DevContainer) Prerequisites: DevContainer includes Rust stable + WebKit GTK libs. If local, install: