This commit is contained in:
kurt-mcrae
2024-10-26 22:41:43 +11:00
commit b040e736bb
106 changed files with 10859 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
backend/.env
backend/obj/
backend/bin/
backend/appsettings.Production.json
backend/.idea*

30
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,30 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md.
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/backend/bin/Debug/net8.0/backend.dll",
"args": [],
"cwd": "${workspaceFolder}/backend",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

41
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/backend/backend.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/backend/backend.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/backend/backend.csproj"
],
"problemMatcher": "$msCompile"
}
]
}

25
backend/.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

35
backend/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,35 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md.
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/bin/Debug/net8.0/backend.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
// Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

1
backend/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

41
backend/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/backend.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/backend.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/backend.csproj"
],
"problemMatcher": "$msCompile"
}
]
}

View File

@@ -0,0 +1,9 @@
namespace backend.Config;
public class NswFuelApiConfig
{
public required string BaseUrl { get; init; }
public required string ApiKey { get; init; }
public required string ApiSecret { get; init; }
public required string AuthorisationHeader { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace backend.Config;
public class TimescaleDbConfig
{
public required string ConnectionString { get; init; }
}

View File

@@ -0,0 +1,58 @@
using backend.Models.NswFuelApi;
using backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Swashbuckle.AspNetCore.Annotations;
namespace backend.Controllers;
[ApiController]
[EnableRateLimiting("sliding")]
[Route("api/brands")]
public class BrandController : ControllerBase
{
private readonly IBrandService _brandService;
private readonly ILogger<BrandController> _logger;
public BrandController(IBrandService brandService, ILogger<BrandController> logger)
{
_brandService = brandService;
_logger = logger;
}
[HttpGet("all")]
[SwaggerOperation(Summary = "Get all brands", Description = "Retrieves a list of all available brands.")]
public async Task<ActionResult<IEnumerable<BrandType>>> GetAllBrands()
{
_logger.LogInformation("Processing api/brands/all request");
var brands = await _brandService.GetAllBrands();
if (!brands.Any()) return NoContent();
return Ok(brands);
}
[HttpGet("state")]
[SwaggerOperation(Summary = "Get brands by state",
Description = "Retrieves a list of all brands by the state they are located in.")]
public async Task<ActionResult<IEnumerable<BrandType>>> GetBrandsByState([FromQuery] string state)
{
_logger.LogInformation("Processing api/brands/state request");
var brands = await _brandService.GetBrandsByState(state);
if (!brands.Any()) return NotFound($"No brands found in state: {state}");
return Ok(brands);
}
[HttpGet("name")]
[SwaggerOperation(Summary = "Get brands by name",
Description =
"Retrieves a list of all available brands, where the brand name contains the provided search string. Case-insensitive.")]
public async Task<ActionResult<IEnumerable<BrandType>>> GetBrandsByName([FromQuery] string name)
{
_logger.LogInformation("Processing api/brands/name request");
var brands = await _brandService.GetBrandsByName(name);
if (!brands.Any()) return NotFound($"No brands found with name containing: {name}");
return Ok(brands);
}
}

View File

@@ -0,0 +1,33 @@
using backend.Models.NswFuelApi;
using backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Swashbuckle.AspNetCore.Annotations;
namespace backend.Controllers;
[ApiController]
[EnableRateLimiting("sliding")]
[Route("api/fuel-types")]
public class FuelTypeController : ControllerBase
{
private readonly IFuelTypeService _fuelTypeService;
private readonly ILogger<FuelTypeController> _logger;
public FuelTypeController(IFuelTypeService fuelTypeService, ILogger<FuelTypeController> logger)
{
_fuelTypeService = fuelTypeService;
_logger = logger;
}
[HttpGet("all")]
[SwaggerOperation(Summary = "Get all fuel types", Description = "Retrieves a list of all available fuel types.")]
public async Task<ActionResult<IEnumerable<FuelType>>> GetAllFuelTypes()
{
_logger.LogInformation("Processing api/fuel-types/all request");
var fuelTypes = await _fuelTypeService.GetAllFuelTypes();
if (!fuelTypes.Any()) return NoContent();
return Ok(fuelTypes);
}
}

View File

@@ -0,0 +1,100 @@
using backend.Models.NswFuelApi;
using backend.Models.Utilities;
using backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Swashbuckle.AspNetCore.Annotations;
namespace backend.Controllers;
[ApiController]
[EnableRateLimiting("sliding")]
[Route("api/prices")]
public class PriceController : ControllerBase
{
private readonly ILogger<PriceController> _logger;
private readonly IPriceService _priceService;
public PriceController(IPriceService priceService, ILogger<PriceController> logger)
{
_priceService = priceService;
_logger = logger;
}
[HttpGet("current")]
[SwaggerOperation(Summary = "Get all most recent prices",
Description = "Retrieves a list of all available prices, with only the most recent displayed.")]
public async Task<ActionResult<Pagination<Price>>> GetAllCurrentPrices(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
_logger.LogInformation("Processing api/prices/current request");
var prices = await _priceService.GetAllCurrentPrices(pageNumber, pageSize);
if (!prices.Data.Any()) return NoContent();
return Ok(prices);
}
[HttpGet("current/station")]
[SwaggerOperation(Summary = "Get current prices by station code",
Description = "Retrieves a list of all current prices for the station with the code provided.")]
public async Task<ActionResult<Pagination<Price>>> GetPricesByStationCode(
[FromQuery] int stationCode,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
_logger.LogInformation("Processing api/prices/current/station request");
var prices = await _priceService.GetCurrentPricesByStationCode(stationCode, pageNumber, pageSize);
if (!prices.Data.Any()) return NotFound($"No prices found for station code {stationCode}");
return Ok(prices);
}
[HttpGet("current/fuel-type")]
[SwaggerOperation(Summary = "Get all most recent prices by fuel type",
Description = "Retrieves a list of all most recent prices for the provided fuel type.")]
public async Task<ActionResult<Pagination<Price>>> GetCurrentPricesByFuelType(
[FromQuery] string fuelType,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
_logger.LogInformation("Processing api/prices/current/fuel-type request");
var prices = await _priceService.GetCurrentPricesByFuelType(fuelType.Trim().ToUpper(), pageNumber, pageSize);
if (!prices.Data.Any()) return NotFound($"No prices found for fuel type {fuelType.Trim().ToUpper()}");
return Ok(prices);
}
[HttpGet("current/fuel-type/lowest")]
[SwaggerOperation(Summary = "Get lowest prices by fuel type",
Description = "Retrieves a list of the lowest prices for the provided fuel type.")]
public async Task<ActionResult<Pagination<Price>>> GetLowestPricesByFuelType(
[FromQuery] string fuelType,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
_logger.LogInformation("Processing api/prices/current/fuel-type/lowest request");
var prices =
await _priceService.GetCurrentLowestPricesByFuelType(fuelType.Trim().ToUpper(), pageNumber, pageSize);
if (!prices.Data.Any()) return NotFound($"No prices found for fuel type {fuelType.Trim().ToUpper()}");
return Ok(prices);
}
[HttpGet("period")]
[SwaggerOperation(Summary = "Get prices for a specific time period",
Description =
"Retrieves a list of prices aggregated by a default time bucket of one day. Time values are UTC, in ISO 8601 format: yyyy-MM-ddTHH:mm:ss.SSSZ")]
public async Task<ActionResult<Pagination<Price>>> GetPricesForTimePeriod(
[FromQuery] DateTimeOffset start,
[FromQuery] DateTimeOffset end,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
_logger.LogInformation("Processing api/prices/period request");
var prices = await _priceService.GetPricesForTimePeriod(start, end, pageNumber, pageSize);
if (!prices.Data.Any()) return NoContent();
return Ok(prices);
}
}

View File

@@ -0,0 +1,39 @@
using backend.Models.Trends;
using backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Swashbuckle.AspNetCore.Annotations;
namespace backend.Controllers;
[ApiController]
[EnableRateLimiting("sliding")]
[Route("api/trends")]
public class PriceTrendsController : ControllerBase
{
private readonly ILogger<PriceTrendsController> _logger;
private readonly IPriceTrendsService _priceTrendsTrendsService;
public PriceTrendsController(IPriceTrendsService priceTrendsService, ILogger<PriceTrendsController> logger)
{
_priceTrendsTrendsService = priceTrendsService;
_logger = logger;
}
[HttpGet("daily-averages")]
[SwaggerOperation(Summary = "Get a list of daily price averages",
Description =
"Retrieves a list of days, and the average price for that day, within the given period, and for the fuel type specified.")]
public async Task<ActionResult<DailyPriceTrends>> GetPricesByStationCode(
[FromQuery] DateTimeOffset startDate,
[FromQuery] DateTimeOffset endDate,
[FromQuery] string fuelType)
{
_logger.LogInformation("Processing api/trends/daily-averages request");
var priceTrends =
await _priceTrendsTrendsService.GetDailyPriceTrendsForPeriod(startDate, endDate, fuelType.Trim().ToUpper());
if (!priceTrends.DailyAverages.Any()) return NotFound("No price trend data found");
return Ok(priceTrends);
}
}

View File

@@ -0,0 +1,60 @@
using backend.Models.NswFuelApi;
using backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Swashbuckle.AspNetCore.Annotations;
namespace backend.Controllers;
[ApiController]
[EnableRateLimiting("sliding")]
[Route("api/stations")]
public class StationController : ControllerBase
{
private readonly ILogger<StationController> _logger;
private readonly IStationService _stationService;
public StationController(IStationService stationService, ILogger<StationController> logger)
{
_stationService = stationService;
_logger = logger;
}
[HttpGet("all")]
[SwaggerOperation(Summary = "Get all stations", Description = "Retrieves a list of all available stations.")]
public async Task<ActionResult<IEnumerable<Station>>> GetAllStations()
{
_logger.LogInformation("Processing api/stations/all request");
var stations = await _stationService.GetAllStations();
if (!stations.Any()) return NoContent();
return Ok(stations);
}
[HttpGet("station-code")]
[SwaggerOperation(Summary = "Get a station by station code",
Description = "Retrieves a single station, where the station code is equal to the provided station code.")]
public async Task<ActionResult<Station>> GetStationsByStationCode([FromQuery] int stationCode)
{
_logger.LogInformation("Processing api/stations/station-code request");
var station = await _stationService.GetStationByStationCode(stationCode);
return Ok(station);
}
[HttpGet("within-radius")]
[SwaggerOperation(Summary = "Get stations within radius",
Description =
"Retrieves a list of all stations within the area described by a circle with centre at the provided coordinates and a radius of the provided value in metres.")]
public async Task<ActionResult<IEnumerable<Station>>> GetStationsWithinRadius(
[FromQuery] double latitude,
[FromQuery] double longitude,
[FromQuery] double radiusInMetres)
{
_logger.LogInformation("Processing api/stations/within-radius request");
var stations = await _stationService.GetStationsWithinRadius(latitude, longitude, radiusInMetres);
if (!stations.Any()) return NoContent();
return Ok(stations);
}
}

View File

@@ -0,0 +1,88 @@
using backend.Models.Aggregations;
using backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Swashbuckle.AspNetCore.Annotations;
namespace backend.Controllers;
[ApiController]
[EnableRateLimiting("sliding")]
[Route("api/stations-with-prices")]
public class StationWithPricesController : ControllerBase
{
private readonly ILogger<StationWithPricesController> _logger;
private readonly IStationWithPricesService _stationWithPricesService;
public StationWithPricesController(IStationWithPricesService stationWithPricesService,
ILogger<StationWithPricesController> logger)
{
_stationWithPricesService = stationWithPricesService;
_logger = logger;
}
[HttpGet("station-code")]
[SwaggerOperation(Summary = "Get a station with prices by station code",
Description =
"Retrieves a single station along with its current prices, where the station code is equal to the provided station code.")]
public async Task<ActionResult<StationWithPrices>> GetStationWithPricesByStationCode([FromQuery] int stationCode)
{
_logger.LogInformation("Processing api/stations-with-prices/station-code request");
var result = await _stationWithPricesService.GetStationWithPricesByStationCode(stationCode);
if (result == null) return NotFound($"Station with code {stationCode} not found.");
return Ok(result);
}
[HttpGet("within-radius")]
[SwaggerOperation(Summary = "Get stations with prices within radius",
Description =
"Retrieves a list of all stations within a specified radius of a given location along with their current prices.")]
public async Task<ActionResult<IEnumerable<StationWithPrices>>> GetStationsWithPricesWithinRadius(
[FromQuery] double latitude,
[FromQuery] double longitude,
[FromQuery] double radiusInMetres)
{
_logger.LogInformation("Processing api/stations-with-prices/within-radius request");
var result =
await _stationWithPricesService.GetStationsWithPricesWithinRadius(latitude, longitude, radiusInMetres);
if (!result.Any()) return NotFound("No stations found within radius");
return Ok(result);
}
[HttpGet("lowest-prices")]
[SwaggerOperation(Summary = "Get stations with lowest prices by fuel type",
Description = "Retrieves a list of stations with the lowest prices for a specific fuel type.")]
public async Task<ActionResult<IEnumerable<StationWithPrices>>> GetStationsWithLowestPricesByFuelType(
[FromQuery] string fuelType,
[FromQuery] int numberOfResults = 10)
{
_logger.LogInformation("Processing api/stations-with-prices/lowest-prices request");
var result =
await _stationWithPricesService.GetStationsWithLowestPricesByFuelType(fuelType.Trim().ToUpper(),
numberOfResults);
if (!result.Any()) return NotFound($"No stations found with fuel type {fuelType.ToUpper()}");
return Ok(result);
}
[HttpGet("lowest-prices/by-fuel-type/within-radius")]
[SwaggerOperation(Summary = "Get stations with lowest prices by fuel type, within a radius",
Description =
"Retrieves a list of stations with the lowest prices for a specific fuel type, within the specified radius.")]
public async Task<ActionResult<IEnumerable<StationWithPrices>>> GetStationsWithLowestPricesByFuelTypeWithinRadius(
[FromQuery] string fuelType,
[FromQuery] double latitude,
[FromQuery] double longitude,
[FromQuery] double radiusInMetres,
[FromQuery] int numberOfResults = 3)
{
_logger.LogInformation("Processing api/stations-with-prices/lowest-prices/by-fuel-type/within-radius request");
var result =
await _stationWithPricesService.GetStationsWithLowestPricesByFuelTypeWithinRadius(fuelType.Trim().ToUpper(),
numberOfResults, latitude, longitude, radiusInMetres);
if (!result.Any())
return NotFound($"No stations found with fuel type {fuelType.ToUpper()} within the radius specified");
return Ok(result);
}
}

View File

@@ -0,0 +1,11 @@
version: '3'
services:
timescaledb:
image: timescale/timescaledb-ha:pg16
ports:
- "5432:5432"
environment:
- POSTGRES_PASSWORD=password
restart: unless-stopped
container_name: timescaledb

23
backend/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["backend.csproj", "./"]
RUN dotnet restore "backend.csproj"
COPY . .
WORKDIR "/src/"
RUN dotnet build "backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "backend.dll"]

View File

@@ -0,0 +1,80 @@
using System.Threading.RateLimiting;
using backend.Config;
using backend.Services;
using backend.Services.Init;
using Hangfire;
using Hangfire.PostgreSql;
using Microsoft.AspNetCore.RateLimiting;
using ServiceStack.Data;
using ServiceStack.OrmLite;
namespace backend.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection ConfigureServices(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration
.GetSection(nameof(TimescaleDbConfig))
.Get<TimescaleDbConfig>()!
.ConnectionString;
return services
.AddSingleton<INswFuelApiService, NswFuelApiService>()
.AddSingleton<IBrandService, BrandService>()
.AddSingleton<IPriceService, PriceService>()
.AddSingleton<IStationService, StationService>()
.AddSingleton<IFuelTypeService, FuelTypeService>()
.AddSingleton<IStationWithPricesService, StationWithPricesService>()
.AddSingleton<IPriceTrendsService, PriceTrendsService>()
.AddSingleton<DatabaseInitialiser>()
.AddSingleton<HangfireInitialiser>()
.AddLogging(options =>
options.AddSimpleConsole(c =>
c.TimestampFormat = "[dd-MM-yyyy HH:mm:ss] "))
.AddHttpClient()
.AddRateLimiter(rateLimiterOptions =>
rateLimiterOptions.AddSlidingWindowLimiter("sliding", options =>
{
//TODO: this is something we should grab from config
options.PermitLimit = 100;
options.Window = TimeSpan.FromSeconds(60);
options.SegmentsPerWindow = 60;
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
//just show the rate limit message immediately, don't queue up any requests
options.QueueLimit = 0;
}).OnRejected = async (context, token) =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiter");
logger.LogWarning("Client rate limit exceeded for {ConnectionRemoteIpAddress}",
context.HttpContext.Connection.RemoteIpAddress);
context.HttpContext.Response.StatusCode = 429;
await context.HttpContext.Response.WriteAsync("Rate limit exceeded, please try again in one minute",
token);
})
.AddHangfireServer()
.AddHangfire(config =>
config.UsePostgreSqlStorage(c =>
c.UseNpgsqlConnection(connectionString)));
}
public static void SetupConfiguration(this IServiceCollection services, IConfiguration configuration)
{
//TODO: in prod, get the config values from a secret manager (see TODO.md)
services
.AddSingleton(configuration.GetSection(nameof(NswFuelApiConfig)).Get<NswFuelApiConfig>()!);
}
public static void SetupDatabase(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration
.GetSection(nameof(TimescaleDbConfig))
.Get<TimescaleDbConfig>()!
.ConnectionString;
services
.AddSingleton<IDbConnectionFactory>(_ =>
new OrmLiteConnectionFactory(connectionString, PostgreSqlDialect.Provider));
}
}

View File

@@ -0,0 +1,9 @@
using backend.Models.NswFuelApi;
namespace backend.Models.Aggregations;
public class StationWithPrices
{
public Station Station { get; set; }
public IEnumerable<Price> Prices { get; set; }
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
namespace backend.Models.NswFuelApi;
public class AccessTokenResponse
{
[JsonPropertyName("refresh_token_expires_in")]
public string RefreshTokenExpiresIn { get; set; }
[JsonPropertyName("api_product_list_json")]
public IEnumerable<string> ApiProductListJson { get; set; }
[JsonPropertyName("organization_name")]
public string OrganisationName { get; set; }
[JsonPropertyName("developer.email")] public string DeveloperEmail { get; set; }
[JsonPropertyName("token_type")] public string TokenType { get; set; }
/// <summary>
/// When the token was issued, in UNIX timestamp format
/// </summary>
[JsonPropertyName("issued_at")]
public string IssuedAt { get; set; }
[JsonPropertyName("client_id")] public string ClientId { get; set; }
[JsonPropertyName("access_token")] public string AccessToken { get; set; }
[JsonPropertyName("application_name")] public string ApplicationName { get; set; }
[JsonPropertyName("scope")] public string Scope { get; set; }
/// <summary>
/// How long until the token expires, in seconds - seems to default to 12 hours
/// </summary>
[JsonPropertyName("expires_in")]
public string ExpiresIn { get; set; }
[JsonPropertyName("refresh_count")] public string RefreshCount { get; set; }
[JsonPropertyName("status")] public string Status { get; set; }
}

View File

@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
using ServiceStack.DataAnnotations;
namespace backend.Models.NswFuelApi;
public class BrandType
{
[PrimaryKey] public Guid Id { get; init; } = Guid.NewGuid();
[JsonPropertyName("name")] public string Name { get; set; }
[JsonPropertyName("state")] public string State { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
using ServiceStack.DataAnnotations;
namespace backend.Models.NswFuelApi;
public class FuelType
{
[PrimaryKey] public Guid Id { get; init; } = Guid.NewGuid();
[JsonPropertyName("code")] public string Code { get; set; }
[JsonPropertyName("name")] public string Name { get; set; }
[JsonPropertyName("state")] public string State { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace backend.Models.NswFuelApi;
public class Location
{
[JsonPropertyName("latitude")] public double Latitude { get; set; }
[JsonPropertyName("longitude")] public double Longitude { get; set; }
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace backend.Models.NswFuelApi;
public class LocationPrices
{
[JsonPropertyName("stations")] public IEnumerable<Station> Stations { get; set; }
/// <summary>
/// Use the PriceWithStationCodeAsInt type, due to the inconsistency in the types returned
/// by the onegov API
/// </summary>
[JsonPropertyName("prices")]
public IEnumerable<PriceDto> PriceDtos { get; set; }
}

View File

@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
using ServiceStack.DataAnnotations;
namespace backend.Models.NswFuelApi;
public class Price
{
public Guid Id { get; init; } = Guid.NewGuid();
[JsonPropertyName("stationcode")] public int StationCode { get; set; }
[JsonPropertyName("fueltype")] public string FuelType { get; set; }
[JsonPropertyName("price")] public double PriceValue { get; set; }
[JsonPropertyName("lastupdated")] public DateTimeOffset LastUpdated { get; set; }
[JsonPropertyName("state")] public string? State { get; set; }
[PrimaryKey] public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class PriceDto
{
[JsonPropertyName("stationcode")] public int StationCode { get; set; }
[JsonPropertyName("fueltype")] public string FuelType { get; set; }
[JsonPropertyName("price")] public double PriceValue { get; set; }
/// <summary>
/// Received from onegov API, appears to be in local time
/// </summary>
[JsonPropertyName("lastupdated")]
public string LastUpdated { get; set; }
[JsonPropertyName("state")] public string? State { get; set; }
}

View File

@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace backend.Models.NswFuelApi;
public class ReferenceData
{
[JsonPropertyName("brands")] public Brands Brands { get; set; }
[JsonPropertyName("fueltypes")] public FuelTypes FuelTypes { get; set; }
[JsonPropertyName("stations")] public Stations Stations { get; set; }
//TODO: Implement this once we know what the data looks like (undocumented)
// [JsonPropertyName("evchargingconnectortypes")]
// public EvChargingConnectorTypes EvChargingConnectorTypes { get; set; }
}
public class Brands
{
[JsonPropertyName("items")] public IEnumerable<BrandType?> Items { get; set; }
}
public class FuelTypes
{
[JsonPropertyName("items")] public IEnumerable<FuelType?> Items { get; set; }
}
public class Stations
{
[JsonPropertyName("items")] public IEnumerable<StationDto?> Items { get; set; }
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace backend.Models.NswFuelApi;
public class ReferencePoint
{
[JsonPropertyName("latitude")] public string Latitude { get; set; }
[JsonPropertyName("longitude")] public string Longitude { get; set; }
}
public class NumericReferencePoint
{
public double Latitude { get; set; }
public double Longitude { get; set; }
}

View File

@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace backend.Models.NswFuelApi;
public class SortField
{
[JsonPropertyName("code")] public string Code { get; set; }
[JsonPropertyName("name")] public string Name { get; set; }
}

View File

@@ -0,0 +1,64 @@
using System.Text.Json.Serialization;
using ServiceStack.DataAnnotations;
namespace backend.Models.NswFuelApi;
public class Station
{
[PrimaryKey] public Guid Id { get; init; } = Guid.NewGuid();
public string Brand { get; set; }
public int Code { get; set; }
public string Name { get; set; }
public string Address { get; set; }
public string Geography { get; set; }
public string State { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
//computed property to extract lat/long from Geography
[Ignore]
public NumericReferencePoint? Coordinates
{
get
{
if (string.IsNullOrWhiteSpace(Geography)) return null;
try
{
var pointData = Geography.Split(';')[1].Replace("POINT(", "").Replace(")", "");
var coordinates = pointData.Split(' ');
var referencePoint = new NumericReferencePoint
{
Latitude = double.Parse(coordinates[1]),
Longitude = double.Parse(coordinates[0])
};
return referencePoint;
}
catch
{
return null;
}
}
}
}
public class StationDto
{
[JsonPropertyName("brand")] public string Brand { get; set; }
[JsonPropertyName("code")] public string Code { get; set; }
[JsonPropertyName("name")] public string Name { get; set; }
[JsonPropertyName("address")] public string Address { get; set; }
[JsonPropertyName("location")] public Location Location { get; set; }
[JsonPropertyName("state")] public string State { get; set; }
}

View File

@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace backend.Models.NswFuelApi;
public class TrendPeriod
{
[JsonPropertyName("period")] public string Period { get; set; }
[JsonPropertyName("description")] public string Description { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace backend.Models.Trends;
public class DailyPriceTrends
{
public IEnumerable<DailyPriceAverage> DailyAverages { get; set; }
public DailyPriceTrendsStatistics Statistics { get; set; }
}
public class DailyPriceTrendsStatistics
{
public double MaxPriceValue { get; set; } //the absolute max and min of the price values
public double MinPriceValue { get; set; }
public double PeriodAverage { get; set; }
public double DailyAverageMax { get; set; } //the highest/lowest per-day average value
public double DailyAverageMin { get; set; }
}
public class DailyPriceAverage
{
public DateTimeOffset Date { get; set; }
public double AveragePriceValue { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace backend.Models.Utilities;
public class Pagination<T>
{
public Pagination(IEnumerable<T> items, long count, int pageNumber, int pageSize)
{
PageNumber = pageNumber;
PageSize = pageSize;
TotalCount = count;
TotalPages = (long)Math.Ceiling(count / (double)pageSize);
Data = items;
}
public long PageNumber { get; }
public long PageSize { get; private set; }
public long TotalPages { get; }
public long TotalCount { get; private set; }
public bool HasPrevious => PageNumber > 1;
public bool HasNext => PageNumber < TotalPages;
public IEnumerable<T> Data { get; private set; }
}

64
backend/Program.cs Normal file
View File

@@ -0,0 +1,64 @@
using backend.Extensions;
using backend.Services.Init;
using Hangfire;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var isDevelopment = environment == Environments.Development;
var configurationBuilder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile(
isDevelopment ? "appsettings.Development.json" : "appsettings.Production.json",
false,
true
)
.AddEnvironmentVariables();
IConfiguration configuration = configurationBuilder.Build();
builder.Services.SetupConfiguration(configuration);
builder.Services.ConfigureServices(configuration);
builder.Services.SetupDatabase(configuration);
builder.Services.AddDirectoryBrowser();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.EnableAnnotations());
var app = builder.Build();
//adjust the Swagger endpoint according to the base path
app.UseSwagger(c =>
{
c.PreSerializeFilters.Add((doc, req) =>
{
var basePath = req.PathBase.HasValue ? req.PathBase.Value : string.Empty;
doc.Servers = new List<OpenApiServer>
{
new() { Url = $"{req.Scheme}://{req.Host}{basePath}" }
};
});
});
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.MapControllers();
app.UseRateLimiter();
//TODO: set up CORS correctly
app.UseCors(c => c.AllowAnyOrigin());
if (isDevelopment) app.UseHangfireDashboard();
using (var scope = app.Services.CreateScope())
{
//initialise the database
var databaseInitialiser = scope.ServiceProvider.GetRequiredService<DatabaseInitialiser>();
databaseInitialiser.Initialise();
//register the recurring jobs
var hangfireInitialiser = scope.ServiceProvider.GetRequiredService<HangfireInitialiser>();
hangfireInitialiser.RegisterFuelApiRefreshJobs();
}
app.Run();

View File

@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:48319",
"sslPort": 44359
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5161",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7212;http://localhost:5161",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,37 @@
using backend.Models.NswFuelApi;
using ServiceStack.Data;
using ServiceStack.OrmLite;
namespace backend.Services;
public class BrandService : IBrandService
{
private readonly IDbConnectionFactory _dbConnectionFactory;
public BrandService(IDbConnectionFactory dbConnectionFactory)
{
_dbConnectionFactory = dbConnectionFactory;
}
public async Task<IEnumerable<BrandType>> GetAllBrands()
{
using var db = _dbConnectionFactory.OpenDbConnection();
var brands = await db.SelectAsync<BrandType>();
return brands;
}
public async Task<IEnumerable<BrandType>> GetBrandsByState(string state)
{
using var db = _dbConnectionFactory.OpenDbConnection();
var brands = await db.SelectAsync<BrandType>(x => x.State.ToLower().Contains(state.ToLower()));
return brands;
}
public async Task<IEnumerable<BrandType>> GetBrandsByName(string name)
{
using var db = _dbConnectionFactory.OpenDbConnection();
var brands =
await db.SelectAsync<BrandType>(x => x.Name.ToLower().Contains(name.ToLower()));
return brands;
}
}

View File

@@ -0,0 +1,22 @@
using backend.Models.NswFuelApi;
using ServiceStack.Data;
using ServiceStack.OrmLite;
namespace backend.Services;
public class FuelTypeService : IFuelTypeService
{
private readonly IDbConnectionFactory _dbConnectionFactory;
public FuelTypeService(IDbConnectionFactory dbConnectionFactory)
{
_dbConnectionFactory = dbConnectionFactory;
}
public async Task<IEnumerable<FuelType>> GetAllFuelTypes()
{
using var db = _dbConnectionFactory.OpenDbConnection();
var stations = await db.SelectAsync<FuelType>();
return stations;
}
}

View File

@@ -0,0 +1,10 @@
using backend.Models.NswFuelApi;
namespace backend.Services;
public interface IBrandService
{
public Task<IEnumerable<BrandType>> GetAllBrands();
public Task<IEnumerable<BrandType>> GetBrandsByState(string state);
public Task<IEnumerable<BrandType>> GetBrandsByName(string name);
}

View File

@@ -0,0 +1,8 @@
using backend.Models.NswFuelApi;
namespace backend.Services;
public interface IFuelTypeService
{
public Task<IEnumerable<FuelType>> GetAllFuelTypes();
}

View File

@@ -0,0 +1,7 @@
namespace backend.Services;
public interface INswFuelApiService
{
Task<bool> GetLovsAsync();
Task<bool> GetCurrentPricesAsync();
}

View File

@@ -0,0 +1,17 @@
using backend.Models.NswFuelApi;
using backend.Models.Utilities;
namespace backend.Services;
public interface IPriceService
{
Task<Pagination<Price>> GetAllCurrentPrices(int pageNumber, int pageSize);
Task<Pagination<Price>> GetCurrentPricesByStationCode(int stationCode, int pageNumber, int pageSize);
Task<Pagination<Price>> GetCurrentPricesByFuelType(string fuelType, int pageNumber, int pageSize);
Task<Pagination<Price>> GetCurrentLowestPricesByFuelType(string fuelType,
int pageNumber, int pageSize);
Task<Pagination<Price>> GetPricesForTimePeriod(DateTimeOffset start, DateTimeOffset end, int pageNumber,
int pageSize);
}

View File

@@ -0,0 +1,9 @@
using backend.Models.Trends;
namespace backend.Services;
public interface IPriceTrendsService
{
public Task<DailyPriceTrends> GetDailyPriceTrendsForPeriod(DateTimeOffset startDate,
DateTimeOffset endDate, string fuelType);
}

View File

@@ -0,0 +1,10 @@
using backend.Models.NswFuelApi;
namespace backend.Services;
public interface IStationService
{
public Task<IEnumerable<Station>> GetAllStations();
public Task<Station> GetStationByStationCode(int stationCode);
public Task<IEnumerable<Station>> GetStationsWithinRadius(double latitude, double longitude, double radiusInMetres);
}

View File

@@ -0,0 +1,16 @@
using backend.Models.Aggregations;
namespace backend.Services;
public interface IStationWithPricesService
{
Task<StationWithPrices?> GetStationWithPricesByStationCode(int stationCode);
Task<IEnumerable<StationWithPrices>> GetStationsWithPricesWithinRadius(double latitude, double longitude,
double radiusInMetres);
Task<IEnumerable<StationWithPrices>> GetStationsWithLowestPricesByFuelType(string fuelType, int numberOfResults);
Task<IEnumerable<StationWithPrices>> GetStationsWithLowestPricesByFuelTypeWithinRadius(string fuelType,
int numberOfResults, double latitude, double longitude, double radiusInMetres);
}

View File

@@ -0,0 +1,40 @@
using backend.Models.NswFuelApi;
using ServiceStack.Data;
using ServiceStack.OrmLite;
namespace backend.Services.Init;
public class DatabaseInitialiser
{
private readonly IDbConnectionFactory _dbFactory;
public DatabaseInitialiser(IDbConnectionFactory dbFactory)
{
_dbFactory = dbFactory;
}
public void Initialise()
{
using var db = _dbFactory.Open();
//create the standard Postgres tables for basic data
db.CreateTableIfNotExists<BrandType>();
db.CreateTableIfNotExists<FuelType>();
db.CreateTableIfNotExists<Station>();
//check if the prices hypertable already exists, otherwise the raw SQL will fail
if (!db.TableExists<Price>())
{
//create the Postgres table for prices, this will make it work correctly with OrmLite as well
db.CreateTable<Price>();
//now we need to convert the Price table into a hypertable for time-series use in Timescale
//time interval is set to 1 day here, but with more memory available to the database, this
//could easily be expanded to 1 week, perhaps even 1 month
db.ExecuteSql("SELECT create_hypertable('price', by_range('created_at', INTERVAL '1 day'))");
}
//check if PostGIS is installed, and add it if not
var postGisInstalled = db.SqlScalar<int>("SELECT COUNT(*) FROM pg_extension WHERE extname = 'postgis';");
if (postGisInstalled == 0) db.ExecuteSql("CREATE EXTENSION postgis;");
}
}

View File

@@ -0,0 +1,21 @@
using Hangfire;
namespace backend.Services.Init;
public class HangfireInitialiser
{
private readonly ILogger<HangfireInitialiser> _logger;
public HangfireInitialiser(ILogger<HangfireInitialiser> logger)
{
_logger = logger;
}
public void RegisterFuelApiRefreshJobs()
{
//hourly refresh for reference data
RecurringJob.AddOrUpdate<INswFuelApiService>("refresh_lovs", x => x.GetLovsAsync(), "0 * * * *");
//hourly refresh for prices
RecurringJob.AddOrUpdate<INswFuelApiService>("refresh_prices", x => x.GetCurrentPricesAsync(), "0 * * * *");
}
}

View File

@@ -0,0 +1,224 @@
using System.Net.Http.Headers;
using System.Text.Json;
using backend.Config;
using backend.Models.NswFuelApi;
using ServiceStack.Data;
using ServiceStack.OrmLite;
namespace backend.Services;
public class NswFuelApiService : INswFuelApiService
{
private readonly IDbConnectionFactory _dbConnectionFactory;
private readonly HttpClient _httpClient;
private readonly ILogger<NswFuelApiService> _logger;
private readonly NswFuelApiConfig _nswFuelApiConfig;
private AccessTokenResponse? _cachedToken;
public NswFuelApiService(HttpClient httpClient, ILogger<NswFuelApiService> logger,
NswFuelApiConfig nswFuelApiConfig, IDbConnectionFactory dbConnectionFactory)
{
_httpClient = httpClient;
_nswFuelApiConfig = nswFuelApiConfig;
_logger = logger;
_dbConnectionFactory = dbConnectionFactory;
}
public async Task<bool> GetLovsAsync()
{
var token = await GetAccessTokenAsync();
var url = $"{_nswFuelApiConfig.BaseUrl}/FuelCheckRefData/v2/fuel/lovs";
var request = CreateRequestMessage(HttpMethod.Get, url,
token?.AccessToken);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStreamAsync();
var referenceData = await JsonSerializer.DeserializeAsync<ReferenceData>(content);
if (referenceData == null)
{
_logger.LogError("No reference data returned from onegov API");
return false;
}
using var db = _dbConnectionFactory.OpenDbConnection();
var transaction = db.OpenTransaction();
try
{
var existingBrands = await db.SelectAsync<BrandType>();
var newBrandsToAdd = referenceData.Brands.Items.Where(b =>
existingBrands.All(eb =>
eb.Name != b?.Name));
db.BulkInsert(newBrandsToAdd);
var existingFuelTypes = await db.SelectAsync<FuelType>();
var newFuelTypesToAdd = referenceData.FuelTypes.Items.Where(ft =>
existingFuelTypes.All(eft =>
eft.Code != ft?.Code));
db.BulkInsert(newFuelTypesToAdd);
var existingStations = await db.SelectAsync<Station>();
var mappedStationData = referenceData.Stations.Items.Select(dto => new Station
{
State = dto!.State,
Code = int.Parse(dto.Code),
Address = dto.Address,
Brand = dto.Brand,
Name = dto.Name,
Geography = $"SRID=4326;POINT({dto.Location.Longitude} {dto.Location.Latitude})"
});
var newStationsToAdd = mappedStationData.Where(s =>
existingStations.All(es =>
es.Code != s.Code));
db.BulkInsert(newStationsToAdd);
transaction.Commit();
}
catch (Exception e)
{
_logger.LogError(e.ToString());
transaction.Rollback();
return false;
}
_logger.LogInformation("Successfully updated reference data");
return true;
}
public async Task<bool> GetCurrentPricesAsync()
{
var token = await GetAccessTokenAsync();
var url = $"{_nswFuelApiConfig.BaseUrl}/FuelPriceCheck/v2/fuel/prices";
var request = CreateRequestMessage(HttpMethod.Get, url, token?.AccessToken);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStreamAsync();
var locationPrices = await JsonSerializer.DeserializeAsync<LocationPrices>(content);
if (locationPrices == null)
{
_logger.LogError("No price data returned from onegov API");
return false;
}
var mappedPrices = locationPrices.PriceDtos.Select(dto => new Price
{
StationCode = dto.StationCode,
FuelType = dto.FuelType.Trim().ToUpper(), //ensure that fuel type is always upper
PriceValue = dto.PriceValue,
LastUpdated = DateTimeOffset.ParseExact(dto.LastUpdated, "dd/MM/yyyy HH:mm:ss", null).ToUniversalTime(),
State = dto.State
}).ToList();
using var db = _dbConnectionFactory.OpenDbConnection();
var transaction = db.OpenTransaction();
try
{
//stage 1: retrieve the latest price for each StationCode and FuelType combination
var latestPricesQuery = db.From<Price>()
.Where(p => mappedPrices.Select(mp => mp.StationCode).Contains(p.StationCode)
&& mappedPrices.Select(mp => mp.FuelType).Contains(p.FuelType))
.Select(p => new { p.StationCode, p.FuelType, p.LastUpdated });
var latestPrices = db.Select(latestPricesQuery)
.GroupBy(p => new { p.StationCode, p.FuelType })
.Select(g => new
{
g.Key.StationCode,
g.Key.FuelType,
LastUpdated = g.Max(x => x.LastUpdated)
})
.ToList();
//stage 2: filter out prices that are not new or updated
var pricesToAdd = mappedPrices.Where(newPrice =>
{
var latestPrice = latestPrices.FirstOrDefault(lp =>
lp.StationCode == newPrice.StationCode && lp.FuelType == newPrice.FuelType);
return latestPrice == null || newPrice.LastUpdated > latestPrice.LastUpdated;
}).ToList();
//stage 3: insert only new or updated prices
if (pricesToAdd.Count > 0)
{
_logger.LogInformation($"Attempting to insert {pricesToAdd.Count} new prices");
db.BulkInsert(pricesToAdd);
}
else
{
_logger.LogInformation("No new prices to insert");
}
transaction.Commit();
}
catch (Exception e)
{
_logger.LogError(e.ToString());
transaction.Rollback();
return false;
}
_logger.LogInformation("Successfully updated current prices");
return true;
}
private async Task<AccessTokenResponse?> GetAccessTokenAsync()
{
if (_cachedToken != null)
{
//parse the issued_at and expires_in strings to long
var issuedAtUnixTime = long.Parse(_cachedToken.IssuedAt);
var expiresInSeconds = long.Parse(_cachedToken.ExpiresIn);
//convert issued_at from UNIX timestamp to DateTime
var issuedAtDateTime = DateTimeOffset.FromUnixTimeMilliseconds(issuedAtUnixTime).DateTime;
//calculate expiration time
var expirationTime = issuedAtDateTime.AddSeconds(expiresInSeconds);
//check if the token is expired or about to expire within the next 5 minutes (300 seconds)
if (expirationTime > DateTime.Now.AddSeconds(300)) return _cachedToken;
}
_logger.LogInformation("Cached token has expired, getting a new one");
var url = $"{_nswFuelApiConfig.BaseUrl}/oauth/client_credential/accesstoken?grant_type=client_credentials";
var request = new HttpRequestMessage(HttpMethod.Get,
url);
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", _nswFuelApiConfig.AuthorisationHeader);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
_logger.LogInformation("Successfully retrieved a new access token");
var content = await response.Content.ReadAsStreamAsync();
var tokenResponse = await JsonSerializer.DeserializeAsync<AccessTokenResponse>(content);
_cachedToken = tokenResponse;
return tokenResponse;
}
#region Helpers
private HttpRequestMessage CreateRequestMessage(HttpMethod method, string url, string? accessToken)
{
var request = new HttpRequestMessage(method, url);
if (!string.IsNullOrEmpty(accessToken))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
request.Headers.Add("apikey", _nswFuelApiConfig.ApiKey);
request.Headers.Add("transactionid", Guid.NewGuid().ToString());
request.Headers.Add("requesttimestamp", DateTime.UtcNow.ToString("R"));
request.Headers.Add("if-modified-since", DateTime.UtcNow.ToString("R"));
return request;
}
#endregion
}

View File

@@ -0,0 +1,152 @@
using backend.Models.NswFuelApi;
using backend.Models.Utilities;
using ServiceStack.Data;
using ServiceStack.OrmLite;
namespace backend.Services;
public class PriceService : IPriceService
{
private readonly IDbConnectionFactory _dbConnectionFactory;
public PriceService(IDbConnectionFactory dbConnectionFactory)
{
_dbConnectionFactory = dbConnectionFactory;
}
public async Task<Pagination<Price>> GetAllCurrentPrices(int pageNumber, int pageSize)
{
using var db = _dbConnectionFactory.OpenDbConnection();
const string sql = """
SELECT DISTINCT ON (station_code, fuel_type)
id, station_code, fuel_type, price_value, last_updated, state, created_at
FROM
price
ORDER BY
station_code, fuel_type, created_at DESC
LIMIT @PageSize OFFSET @Offset;
""";
const string countSql = """
SELECT COUNT(*) FROM (
SELECT DISTINCT ON (station_code, fuel_type)
id, station_code, fuel_type, price_value, last_updated, state, created_at
FROM
price
) AS distinct_prices;
""";
var totalCount = await db.SqlScalarAsync<int>(countSql);
var prices =
await db.SqlListAsync<Price>(sql, new { Offset = (pageNumber - 1) * pageSize, PageSize = pageSize });
return new Pagination<Price>(prices, totalCount, pageNumber, pageSize);
}
public async Task<Pagination<Price>> GetCurrentPricesByStationCode(int stationCode, int pageNumber, int pageSize)
{
using var db = _dbConnectionFactory.OpenDbConnection();
const string sql = """
SELECT DISTINCT ON (station_code, fuel_type)
id, station_code, fuel_type, price_value, last_updated, state, created_at
FROM
price
WHERE
station_code = @StationCode
ORDER BY
station_code, fuel_type, created_at DESC
LIMIT @PageSize OFFSET @Offset;
""";
const string countSql = """
SELECT COUNT(*) FROM (
SELECT DISTINCT ON (station_code, fuel_type)
id, station_code, fuel_type, price_value, last_updated, state, created_at
FROM
price
WHERE
station_code = @StationCode
) AS distinct_prices;
""";
var totalCount = await db.SqlScalarAsync<int>(countSql, new { StationCode = stationCode });
var prices = await db.SqlListAsync<Price>(sql,
new { StationCode = stationCode, Offset = (pageNumber - 1) * pageSize, PageSize = pageSize });
return new Pagination<Price>(prices, totalCount, pageNumber, pageSize);
}
public async Task<Pagination<Price>> GetCurrentPricesByFuelType(string fuelType, int pageNumber, int pageSize)
{
using var db = _dbConnectionFactory.OpenDbConnection();
const string sql = """
SELECT DISTINCT ON (station_code, fuel_type)
id, station_code, fuel_type, price_value, last_updated, state, created_at
FROM
price
WHERE
fuel_type = @FuelType
ORDER BY
station_code, fuel_type, created_at DESC
LIMIT @PageSize OFFSET @Offset;
""";
const string countSql = """
SELECT COUNT(*) FROM (
SELECT DISTINCT ON (station_code, fuel_type)
id, station_code, fuel_type, price_value, last_updated, state, created_at
FROM
price
WHERE
fuel_type = @FuelType
) AS distinct_prices;
""";
var totalCount = await db.SqlScalarAsync<int>(countSql, new { FuelType = fuelType });
var prices = await db.SqlListAsync<Price>(sql,
new { FuelType = fuelType, Offset = (pageNumber - 1) * pageSize, PageSize = pageSize });
return new Pagination<Price>(prices, totalCount, pageNumber, pageSize);
}
public async Task<Pagination<Price>> GetCurrentLowestPricesByFuelType(string fuelType, int pageNumber, int pageSize)
{
using var db = _dbConnectionFactory.OpenDbConnection();
//stage 1: get all prices by fuel type
var prices = await db.SelectAsync<Price>(p => p.FuelType == fuelType);
//stage 2: group by station code and select the lowest price
var lowestPrices = prices
.GroupBy(p => p.StationCode)
.Select(g => g.OrderBy(p => p.PriceValue).ThenByDescending(p => p.CreatedAt).First())
.OrderBy(p => p.PriceValue)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToList();
//stage 3: count the distinct stations for pagination
var totalCount = prices.Select(p => p.StationCode).Distinct().Count();
return new Pagination<Price>(lowestPrices, totalCount, pageNumber, pageSize);
}
public async Task<Pagination<Price>> GetPricesForTimePeriod(DateTimeOffset start, DateTimeOffset end,
int pageNumber, int pageSize)
{
using var db = _dbConnectionFactory.OpenDbConnection();
var totalCount = await db.CountAsync<Price>(p => p.CreatedAt >= start && p.CreatedAt <= end);
var prices = await db.SelectAsync(db.From<Price>()
.Where(p => p.CreatedAt >= start && p.CreatedAt <= end)
.OrderBy(p => p.StationCode)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize));
return new Pagination<Price>(prices, totalCount, pageNumber, pageSize);
}
}

View File

@@ -0,0 +1,64 @@
using backend.Models.Trends;
using ServiceStack.Data;
using ServiceStack.OrmLite;
namespace backend.Services;
public class PriceTrendsService : IPriceTrendsService
{
private readonly IDbConnectionFactory _dbConnectionFactory;
public PriceTrendsService(IDbConnectionFactory dbConnectionFactory)
{
_dbConnectionFactory = dbConnectionFactory;
}
public async Task<DailyPriceTrends> GetDailyPriceTrendsForPeriod(
DateTimeOffset startDate, DateTimeOffset endDate, string fuelType)
{
using var db = _dbConnectionFactory.OpenDbConnection();
//query to get the daily averages
const string dailyAveragesSql = """
SELECT
time_bucket('1 day', created_at) AS Date,
AVG(price_value) AS AveragePriceValue
FROM price
WHERE created_at BETWEEN @StartDate AND @EndDate
AND fuel_type = @FuelType
GROUP BY Date
ORDER BY Date;
""";
//query to get the overall max, min, and period average values
const string statisticsSql = """
SELECT
MAX(price_value) AS MaxPriceValue,
MIN(price_value) AS MinPriceValue,
AVG(price_value) AS PeriodAverage
FROM price
WHERE created_at BETWEEN @StartDate AND @EndDate
AND fuel_type = @FuelType;
""";
var dailyAverages = await db.SelectAsync<DailyPriceAverage>(dailyAveragesSql,
new { StartDate = startDate, EndDate = endDate, FuelType = fuelType });
var statistics = await db.SingleAsync<DailyPriceTrendsStatistics>(statisticsSql,
new { StartDate = startDate, EndDate = endDate, FuelType = fuelType });
var dailyMax = dailyAverages.Select(x => x.AveragePriceValue).Max();
var dailyMin = dailyAverages.Select(x => x.AveragePriceValue).Min();
statistics.DailyAverageMax = dailyMax;
statistics.DailyAverageMin = dailyMin;
var trends = new DailyPriceTrends
{
DailyAverages = dailyAverages,
Statistics = statistics
};
return trends;
}
}

View File

@@ -0,0 +1,56 @@
using backend.Models.NswFuelApi;
using ServiceStack.Data;
using ServiceStack.OrmLite;
namespace backend.Services;
public class StationService : IStationService
{
private readonly IDbConnectionFactory _dbConnectionFactory;
public StationService(IDbConnectionFactory dbConnectionFactory)
{
_dbConnectionFactory = dbConnectionFactory;
}
public async Task<IEnumerable<Station>> GetAllStations()
{
using var db = _dbConnectionFactory.OpenDbConnection();
var stations = await db.SelectAsync<Station>();
return stations;
}
public async Task<Station> GetStationByStationCode(int stationCode)
{
using var db = _dbConnectionFactory.OpenDbConnection();
var station = await db.SingleAsync<Station>(x => x.Code == stationCode);
return station;
}
public async Task<IEnumerable<Station>> GetStationsWithinRadius(double latitude, double longitude,
double radiusInMetres)
{
using var db = _dbConnectionFactory.OpenDbConnection();
//sql to use PostGIS to find all stations within the radius from the point
const string sql = """
SELECT *,
ST_Distance(
geography::geography,
ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography
) AS distance
FROM station
WHERE ST_DWithin(
geography::geography,
ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography,
@Radius
)
ORDER BY distance
""";
//execute the sql, using the parameters provided
var stations = await db.SelectAsync<Station>(sql,
new { Latitude = latitude, Longitude = longitude, Radius = radiusInMetres });
return stations;
}
}

View File

@@ -0,0 +1,92 @@
using backend.Models.Aggregations;
namespace backend.Services;
public class StationWithPricesService : IStationWithPricesService
{
private readonly IPriceService _priceService;
private readonly IStationService _stationService;
public StationWithPricesService(IStationService stationService, IPriceService priceService)
{
_stationService = stationService;
_priceService = priceService;
}
public async Task<StationWithPrices?> GetStationWithPricesByStationCode(int stationCode)
{
var station = await _stationService.GetStationByStationCode(stationCode);
var prices = await _priceService.GetCurrentPricesByStationCode(stationCode, 1, int.MaxValue);
return new StationWithPrices
{
Station = station,
Prices = prices.Data.ToList()
};
}
public async Task<IEnumerable<StationWithPrices>> GetStationsWithPricesWithinRadius(double latitude,
double longitude, double radiusInMetres)
{
var stations = await _stationService.GetStationsWithinRadius(latitude, longitude, radiusInMetres);
var stationWithPricesList = new List<StationWithPrices>();
foreach (var station in stations)
{
var prices = await _priceService.GetCurrentPricesByStationCode(station.Code, 1, int.MaxValue);
stationWithPricesList.Add(new StationWithPrices
{
Station = station,
Prices = prices.Data.ToList()
});
}
return stationWithPricesList;
}
//TODO: optimise
public async Task<IEnumerable<StationWithPrices>> GetStationsWithLowestPricesByFuelType(string fuelType,
int numberOfResults)
{
var lowestPrices = await _priceService.GetCurrentLowestPricesByFuelType(fuelType, 1, numberOfResults);
var stationsWithPrices = new List<StationWithPrices>();
foreach (var price in lowestPrices.Data)
{
//TODO: this is opening/closing connections to the DB for each station, let's optimise this
var station = await _stationService.GetStationByStationCode(price.StationCode);
stationsWithPrices.Add(new StationWithPrices
{
Station = station,
Prices = [price]
});
}
return stationsWithPrices.OrderBy(x => x.Prices.First().PriceValue).Take(numberOfResults);
}
//TODO: optimise
public async Task<IEnumerable<StationWithPrices>> GetStationsWithLowestPricesByFuelTypeWithinRadius(string fuelType,
int numberOfResults, double latitude,
double longitude, double radiusInMetres)
{
var stations = await _stationService.GetStationsWithinRadius(latitude, longitude, radiusInMetres);
var stationWithPricesList = new List<StationWithPrices>();
foreach (var station in stations)
{
//TODO: this is opening/closing connections to the DB for each station, let's optimise this
var prices = await _priceService.GetCurrentPricesByStationCode(station.Code, 1, int.MaxValue);
var priceForFuelType = prices.Data.FirstOrDefault(x => x.FuelType == fuelType);
if (priceForFuelType == null) continue;
stationWithPricesList.Add(new StationWithPrices
{
Station = station,
Prices = [priceForFuelType]
});
}
return stationWithPricesList.OrderBy(x => x.Prices.First().PriceValue).Take(numberOfResults);
}
}

21
backend/TODO.md Normal file
View File

@@ -0,0 +1,21 @@
# TODO for this application
## Secret Management
- Currently, the development environment uses hard-coded creds for the sandbox provided by onegov.
- This is fine, as they are publicly available, however, we want our production creds stored in a secret manager of
some variety.
- I would like to set up something like Infiscal to manage secrets, since I also see there being a need to store certain
other information in a protected form (one day, we might need to store other sensitive credentials here, and this will
future-proof us.)
- I also like the idea of having secret management being kept separate from the application entirely, probably on an
air-gapped system.
- .NET secrets manager might be enough, but while Infiscal seems like overkill, it will cover us well into the future.
## Fluent Validator
- Use it
## SQL Injection
- How does Dapper handle this? We might need some custom middleware here?

View File

@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"NswFuelApiConfig": {
"BaseUrl": "https://api.onegov.nsw.gov.au",
"ApiKey": "1MYSRAx5yvqHUZc6VGtxix6oMA2qgfRT",
"ApiSecret": "BMvWacw15Et8uFGF",
"AuthorisationHeader": "MU1ZU1JBeDV5dnFIVVpjNlZHdHhpeDZvTUEycWdmUlQ6Qk12V2FjdzE1RXQ4dUZHRg=="
},
"TimescaleDbConfig": {
"ConnectionString": "User ID=postgres;Password=password;Host=localhost;Port=5432;Database=postgres;"
}
}

18
backend/backend.csproj Normal file
View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14"/>
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.10"/>
<PackageReference Include="ServiceStack.OrmLite.PostgreSQL" Version="8.4.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0"/>
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.9.0"/>
</ItemGroup>
</Project>

17
backend/compose.yaml Normal file
View File

@@ -0,0 +1,17 @@
services:
backend-prod:
image: backend-prod
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
container_name: backend-prod
timescaledb-prod:
image: timescale/timescaledb-ha:pg16
ports:
- "5432:5432"
environment:
- POSTGRES_PASSWORD=password
restart: unless-stopped
container_name: timescaledb-prod

1
documents/api_spec.json Normal file

File diff suppressed because one or more lines are too long

2
frontend/.env.sample Normal file
View File

@@ -0,0 +1,2 @@
DEPLOYMENT_ENVIRONMENT=development
API_BASE_URL=http://localhost:5161

3
frontend/.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
*.js
*.mjs
*.cjs

3
frontend/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

132
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,132 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_Store

View File

@@ -0,0 +1,59 @@
.logo {
fill: #a5e010;
}
.wrapper {
position: relative;
box-sizing: border-box;
}
.inner {
position: relative;
}
.title {
font-family:
Greycliff CF,
var(--mantine-font-family);
font-size: rem(62px);
font-weight: 900;
line-height: 1.1;
margin: 0;
padding: 0;
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(42px);
line-height: 1.2;
}
}
.description {
margin-top: var(--mantine-spacing-xl);
font-size: rem(24px);
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(18px);
}
}
.controls {
margin-top: calc(var(--mantine-spacing-xl) * 2);
@media (max-width: $mantine-breakpoint-sm) {
margin-top: var(--mantine-spacing-xl);
}
}
.control {
height: rem(54px);
padding-left: rem(38px);
padding-right: rem(38px);
@media (max-width: $mantine-breakpoint-sm) {
height: rem(54px);
padding-left: rem(18px);
padding-right: rem(18px);
flex: 1;
}
}

View File

@@ -0,0 +1,82 @@
'use client'
import { LineChart } from "@mantine/charts";
import { Button, Container, Group, Select, Stack } from "@mantine/core";
import axios from "axios";
import { useState } from "react";
import { DailyPriceTrends, FuelType, FuelTypeApi, PriceTrendsApi } from "../../generated";
import { DateTimeRange } from "../../models/DateTimeRange";
import { TimePeriod } from "../../models/TimePeriod";
import DateRangePicker from "../components/dates/DateRangePicker";
export default function ChartsPage() {
const fuelTypeApi = new FuelTypeApi(undefined, process.env.API_BASE_URL, axios);
const priceTrendsApi = new PriceTrendsApi(undefined, process.env.API_BASE_URL, axios);
const [fuelTypes, setFuelTypes] = useState<FuelType[]>();
const [priceTrendsData, setPriceTrendsData] = useState<DailyPriceTrends>();
const [selectedFuelType, setSelectedFuelType] = useState("U91");
const [selectedDateTimeRange, setSelectedDateTimeRange] = useState<DateTimeRange>();
async function fetchData() {
if (!fuelTypes) {
const fuelTypeResponse = await fuelTypeApi.apiFuelTypesAllGet();
setFuelTypes(fuelTypeResponse.data);
}
if (selectedDateTimeRange?.startDate && selectedDateTimeRange.endDate) {
const priceTrendsResponse = await priceTrendsApi.apiTrendsDailyAveragesGet(
{
startDate: selectedDateTimeRange?.startDate?.toISOString(),
endDate: selectedDateTimeRange?.endDate?.toISOString(),
fuelType: selectedFuelType
});
setPriceTrendsData(priceTrendsResponse.data);
}
};
if (!fuelTypes?.length || !priceTrendsData) {
fetchData();
}
const chartData = priceTrendsData?.dailyAverages?.map((dpa) => { return { date: dpa.date as string, averagePriceValue: dpa.averagePriceValue as number } });
return (
<Container size='xl'>
<h1>View historic fuel price trends.</h1>
<Stack
align="stretch"
justify="center"
gap="xl"
>
<Group align='end'>
<Select
label='Fuel Type'
description='Select a fuel type below'
placeholder='Select a fuel type'
data={fuelTypes?.map((ft) => { return { label: ft.name as string, value: ft.code as string } })}
value={selectedFuelType}
onChange={(value) => setSelectedFuelType(value as string)}
/>
<DateRangePicker defaultTimePeriod={TimePeriod.Last30Days} onChange={setSelectedDateTimeRange} />
<Button onClick={() => fetchData()}>Apply</Button>
</Group>
<LineChart
h={300}
data={chartData || []}
series={[{ name: 'averagePriceValue', label: 'Average Price per Litre' }]}
dataKey="date"
type="gradient"
strokeWidth={5}
curveType="monotone"
yAxisProps={{
domain: [
priceTrendsData?.statistics?.dailyAverageMin as number,
priceTrendsData?.statistics?.dailyAverageMax as number
]
}}
valueFormatter={(value) => `$${(value / 100).toFixed(3)}`}
/>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,171 @@
'use client'
import { Checkbox, Pagination, Skeleton, Table, TextInput } from '@mantine/core';
import { useEffect, useMemo, useState } from 'react';
type CustomTableProps<T> = {
data: T[];
columns: { label: string; accessor: keyof T }[];
sortableColumns?: (keyof T)[];
searchPlaceholder?: string;
itemsPerPage: number;
striped?: boolean;
highlightOnHover?: boolean;
selectable?: boolean;
onSelectionChange?: (selectedItems: T[]) => void;
loading?: boolean;
}
export function CustomTable<T>(props: CustomTableProps<T>) {
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<keyof T | null>(null);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(1);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const filteredData = useMemo(() => {
let filtered = [...props.data];
if (searchQuery) {
filtered = filtered.filter((item) =>
props.columns.some((column) =>
String(item[column.accessor])
.toLowerCase()
.includes(searchQuery.toLowerCase())
)
);
}
if (sortBy) {
filtered.sort((a, b) => {
if (a[sortBy] < b[sortBy]) {
return sortOrder === 'asc' ? -1 : 1;
}
if (a[sortBy] > b[sortBy]) {
return sortOrder === 'asc' ? 1 : -1;
}
return 0;
});
}
return filtered;
}, [props.data, searchQuery, sortBy, sortOrder, props.columns]);
const paginatedData = useMemo(() => {
const startIndex = (page - 1) * props.itemsPerPage;
return filteredData.slice(startIndex, startIndex + props.itemsPerPage);
}, [filteredData, page, props.itemsPerPage]);
const handleSort = (accessor: keyof T) => {
if (sortBy === accessor) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(accessor);
setSortOrder('asc');
}
};
const handleSelectRow = (index: number) => {
const newSelectedRows = new Set(selectedRows);
if (newSelectedRows.has(index)) {
newSelectedRows.delete(index);
} else {
newSelectedRows.add(index);
}
setSelectedRows(newSelectedRows);
};
useEffect(() => {
if (props.onSelectionChange) {
const selectedItems = Array.from(selectedRows).map(
(rowIndex) => paginatedData[rowIndex]
);
props.onSelectionChange(selectedItems);
}
}, [selectedRows, paginatedData, props]);
return (
<Skeleton animate visible={props.loading || false}>
<div style={{ overflowX: 'visible' }}>
<TextInput
placeholder={props.searchPlaceholder}
value={searchQuery}
onChange={(event) => setSearchQuery(event.currentTarget.value)}
mb="md"
/>
<Table
striped={props.striped}
highlightOnHover={props.highlightOnHover}
style={{ tableLayout: 'fixed', width: '100%' }}
>
<Table.Thead>
<Table.Tr>
{props.selectable && <Table.Th style={{ width: '2%' }}></Table.Th>}
{props.columns.map((column) => (
<Table.Th
key={String(column.accessor)}
onClick={() =>
props.sortableColumns?.includes(column.accessor) && handleSort(column.accessor)
}
style={{
cursor: props.sortableColumns?.includes(column.accessor) ? 'pointer' : 'default',
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
padding: '8px 16px',
}}
>
{column.label}
{sortBy === column.accessor && (sortOrder === 'asc' ? ' 🔼' : ' 🔽')}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{paginatedData.map((item, index) => {
const isSelected = selectedRows.has(index);
return (
<Table.Tr
key={index}
bd={isSelected ? '2px solid lime' : undefined}
>
{props.selectable && (
<Table.Td pr={16}>
<Checkbox
checked={isSelected}
onChange={() => handleSelectRow(index)}
color='lime'
/>
</Table.Td>
)}
{props.columns.map((column) => (
<Table.Td
key={String(column.accessor)}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
padding: '8px 16px',
}}
>
{String(item[column.accessor])}
</Table.Td>
))}
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
<Pagination
total={Math.ceil(filteredData.length / props.itemsPerPage)}
value={page}
onChange={setPage}
mt="md"
/>
</div>
</Skeleton>
);
}

View File

@@ -0,0 +1,69 @@
'use client'
import { Grid, Group, InputLabel, SegmentedControl } from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { useEffect, useState } from 'react';
import { DateTimeRange } from '../../../models/DateTimeRange';
import { TimePeriod, timePeriodOptions } from '../../../models/TimePeriod';
import { getDateRangeForPeriod } from '../../../utils/dateRangePickerUtils';
dayjs.extend(utc);
type DateRangePickerProps = {
defaultTimePeriod: TimePeriod;
onChange: (dateTimeRange: DateTimeRange) => void;
}
export default function DateRangePicker(props: DateRangePickerProps) {
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const [timePeriod, setTimePeriod] = useState<TimePeriod>(props.defaultTimePeriod);
//update state based on time period selection
useEffect(() => {
const { startDate, endDate } = getDateRangeForPeriod(timePeriod);
setStartDate(startDate);
setEndDate(endDate);
}, [timePeriod]);
//notify parent component of state changes
useEffect(() => {
const utcStartDate = startDate ? dayjs(startDate).utc().toDate() : null;
const utcEndDate = endDate ? dayjs(endDate).utc().toDate() : null;
props.onChange({ startDate: utcStartDate, endDate: utcEndDate });
}, [startDate, endDate, timePeriod]);
return (
<div>
<Grid mt="sm" align="center">
<Grid.Col span={12}>
<InputLabel pb={4}>Select a common date range here, or specify an interval below</InputLabel>
<SegmentedControl
data={timePeriodOptions.map((option) => ({ label: option.label, value: option.value }))}
value={timePeriod}
onChange={(value) => setTimePeriod(value as TimePeriod)}
size="sm"
fullWidth
/>
</Grid.Col>
</Grid>
<Group grow>
<DateTimePicker
label="Start Date"
value={startDate}
onChange={setStartDate}
clearable
/>
<DateTimePicker
label="End Date"
value={endDate}
onChange={setEndDate}
clearable
/>
</Group>
</div>
);
}

View File

@@ -0,0 +1,20 @@
'use client'
import { AppShell } from '@mantine/core';
import { ReactElement } from 'react';
import Footer from './footer/Footer';
import { Header } from './header/Header';
type MainLayoutProps = {
children: ReactElement;
}
export default function MainLayout(props: MainLayoutProps) {
return (
<AppShell>
<Header />
<AppShell.Main>{props.children}</AppShell.Main>
<Footer />
</AppShell >
);
}

View File

@@ -0,0 +1,22 @@
.footer {
margin-top: rem(24px);
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.inner {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: var(--mantine-spacing-xl);
padding-bottom: var(--mantine-spacing-xl);
@media (max-width: $mantine-breakpoint-xs) {
flex-direction: column;
}
}
.links {
@media (max-width: $mantine-breakpoint-xs) {
margin-top: var(--mantine-spacing-md);
}
}

View File

@@ -0,0 +1,35 @@
import { Anchor, Container, Group, Text } from '@mantine/core';
import Image from 'next/image';
import logo from '../../../../public/fuel-app-logo-lime.svg';
import classes from './Footer.module.css';
const links = [
{ link: '/', label: 'Contact' },
{ link: '/', label: 'Privacy' },
];
export default function Footer() {
const navLinks = links.map((link) => (
<Anchor<'a'>
c="dimmed"
key={link.label}
href={link.link}
onClick={(event) => event.preventDefault()}
size="sm"
>
{link.label}
</Anchor>
));
return (
<div className={classes.footer}>
<Container className={classes.inner}>
<Group>
<Image src={logo} alt='logo' className={classes.logo} height={28} />
<Text fz={28} variant="gradient" gradient={{ from: 'lime.7', to: 'green' }}>Fuel Wizard</Text>
</Group>
<Group className={classes.links}>{navLinks}</Group>
</Container>
</div>
);
}

View File

@@ -0,0 +1,39 @@
.header {
height: rem(56px);
margin-bottom: rem(24px);
background-color: var(--mantine-color-body);
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.inner {
height: rem(56px);
display: flex;
justify-content: space-between;
align-items: center;
}
.navLinks {
display: flex;
gap: rem(12px);
margin-right: auto;
}
.link {
display: block;
line-height: 1;
padding: rem(8px) rem(12px);
border-radius: var(--mantine-radius-sm);
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-sm);
font-weight: 500;
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
[data-mantine-color-scheme] &[data-active] {
background-color: var(--mantine-color-lime-7);
color: var(--mantine-color-white);
}
}

View File

@@ -0,0 +1,48 @@
import { Container, Group } from '@mantine/core';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useState } from 'react';
import ColourSchemeToggle from './colour-scheme/ColourSchemeToggle';
import classes from './Header.module.css';
const links = [
{ link: '/', label: 'Home' },
{ link: '/map', label: 'Map' },
{ link: '/charts', label: 'Charts' },
{ link: '/stations', label: 'Stations' },
{ link: '/prices', label: 'Prices' },
{ link: '/faq', label: 'FAQ' }
];
export function Header() {
const pathName = usePathname();
const [active, setActive] = useState(pathName);
const router = useRouter();
const navLinks = links.map((link) => (
<Link
color='lime'
key={link.label}
href={link.link}
className={classes.link}
data-active={active === link.link || undefined}
onClick={() => {
setActive(link.link);
router.push(link.link);
}}
>
{link.label}
</Link>
));
return (
<header className={classes.header}>
<Container size="md" className={classes.inner}>
<Group className={classes.navLinks} gap={5} visibleFrom="xs">
{navLinks}
</Group>
<ColourSchemeToggle />
</Container>
</header>
);
}

View File

@@ -0,0 +1,19 @@
.dark {
@mixin dark {
display: none;
}
@mixin light {
display: block;
}
}
.light {
@mixin light {
display: none;
}
@mixin dark {
display: block;
}
}

View File

@@ -0,0 +1,20 @@
import { ActionIcon, useComputedColorScheme, useMantineColorScheme } from '@mantine/core';
import { FaSun, FaMoon } from "react-icons/fa6";
import classes from './ColourSchemeToggle.module.css';
export default function ColourSchemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
return (
<ActionIcon
onClick={() => setColorScheme(computedColorScheme === 'light' ? 'dark' : 'light')}
variant="default"
size="xl"
aria-label="Toggle color scheme"
>
<FaSun className={classes.light} stroke='1.5' />
<FaMoon className={classes.dark} stroke='1.5' />
</ActionIcon>
);
}

View File

@@ -0,0 +1,181 @@
'use client'
import { useMantineColorScheme } from '@mantine/core';
import L from 'leaflet';
import shadow from 'leaflet/dist/images/marker-shadow.png';
import { ReactNode, useCallback, useEffect, useState } from 'react';
import { MapContainer, Marker, Popup, TileLayer, useMap, useMapEvent } from 'react-leaflet';
import markerDefault from '../../../public/FA5_Map_Marker-default.svg';
import markerLime from '../../../public/FA5_Map_Marker-lime.svg';
import markerWhite from '../../../public/FA5_Map_Marker-white.svg';
export type CustomMapPoint = {
lat: number;
long: number;
popup?: ReactNode;
}
type CustomMapProps = {
height?: number | string,
width?: number | string,
zoom?: number,
scrollWheelZoom?: boolean,
center?: [number, number],
points?: CustomMapPoint[],
clickToPlaceMarker?: boolean;
onMarkerPlaced?: (coordinates: { lat: number, long: number }) => void, //callback to pass coordinates up
markerPosition?: { lat: number, long: number } //external marker position override
}
//utility function to apply dark mode filter to map tiles
function applyTileFilter(colorScheme: string) {
const leafletTiles = document.querySelectorAll('.leaflet-tile');
const mapContainer = document.querySelector('.leaflet-container');
if (colorScheme === 'dark') {
leafletTiles.forEach(tile => {
(tile as HTMLElement).style.filter = 'brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)';
});
if (mapContainer) {
(mapContainer as HTMLElement).style.background = '#303030';
}
} else {
leafletTiles.forEach(tile => {
(tile as HTMLElement).style.filter = 'none';
});
if (mapContainer) {
(mapContainer as HTMLElement).style.background = '';
}
}
}
//custom hook to manage Leaflet map tile filtering based on color scheme
function useTileFilter(colorScheme: string) {
const map = useMap();
const handleTileLoad = useCallback(() => {
applyTileFilter(colorScheme);
}, [colorScheme]);
useEffect(() => {
//apply the filter whenever a tileload event is seen
map.on('tileload', handleTileLoad);
//gross hack to directly modify the css class
const leafletTileLayer = document.querySelector('.leaflet-tile-pane');
if (leafletTileLayer) {
(leafletTileLayer as HTMLElement).style.filter =
colorScheme === 'dark'
? 'brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)'
: 'none';
}
//cleanup event listener when component is unmounted or colorScheme changes
return () => {
map.off('tileload', handleTileLoad);
};
}, [map, colorScheme, handleTileLoad]);
}
//component to handle clicking and placing a marker
function ClickHandler({ onMarkerPlaced }: { onMarkerPlaced?: (coordinates: { lat: number, long: number }) => void }) {
useMapEvent('click', (e) => {
const { lat, lng } = e.latlng;
if (onMarkerPlaced) {
onMarkerPlaced({ lat, long: lng }); //pass the coordinates back to the parent
}
});
return null;
}
export default function CustomMap(props: CustomMapProps) {
const defaultIcon = L.icon({
iconUrl: markerDefault.src,
shadowUrl: shadow.src,
iconSize: [25, 41],
iconAnchor: [15, 30],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
const limeIcon = L.icon({
iconUrl: markerLime.src,
shadowUrl: shadow.src,
iconSize: [25, 41],
iconAnchor: [15, 30],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
const whiteIcon = L.icon({
iconUrl: markerWhite.src,
shadowUrl: shadow.src,
iconSize: [25, 41],
iconAnchor: [15, 30],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
const { colorScheme } = useMantineColorScheme();
const [markerPosition, setMarkerPosition] = useState<{ lat: number, long: number } | null>(null);
//update the marker position from the parent if `props.markerPosition` changes
useEffect(() => {
if (props.markerPosition) {
setMarkerPosition(props.markerPosition);
}
}, [props.markerPosition]);
const [firstPoint, ...remainingPoints] = props.points || [];
const handleMarkerPlaced = (coordinates: { lat: number, long: number }) => {
setMarkerPosition(coordinates); //update the state with new marker coordinates
if (props.onMarkerPlaced) {
props.onMarkerPlaced(coordinates); //notify parent of new coordinates
}
};
return (
<MapContainer
center={props.center || [-33.856758, 151.215295]}
zoom={props.zoom || 10}
scrollWheelZoom={props.scrollWheelZoom || false}
style={{ height: props.height || "50vh", width: props.width || "100%", zIndex: 1 }}
>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<TileLayerHandler colorScheme={colorScheme} />
{props.clickToPlaceMarker && <ClickHandler onMarkerPlaced={handleMarkerPlaced} />}
{markerPosition && (
<Marker position={[markerPosition.lat, markerPosition.long]} icon={colorScheme === 'dark' ? whiteIcon : defaultIcon}>
<Popup>
{`Marker placed at [${markerPosition.lat.toFixed(4)}, ${markerPosition.long.toFixed(4)}]`}
</Popup>
</Marker>
)}
{!props.clickToPlaceMarker && firstPoint && remainingPoints && <Marker key={0} zIndexOffset={remainingPoints.length} position={[firstPoint.lat, firstPoint.long]} icon={limeIcon}>
{firstPoint.popup && (
<Popup>
{firstPoint.popup}
</Popup>
)}
</Marker>}
{!props.clickToPlaceMarker && remainingPoints && remainingPoints.map((point, index) => (
<Marker key={index + 1} position={[point.lat, point.long]} icon={colorScheme === 'dark' ? whiteIcon : defaultIcon}>
{point.popup && (
<Popup>
{point.popup}
</Popup>
)}
</Marker>
))}
</MapContainer>
);
}
function TileLayerHandler({ colorScheme }: { colorScheme: string }) {
useTileFilter(colorScheme);
return null;
}

View File

@@ -0,0 +1,15 @@
.wrapper {
padding-top: calc(var(--mantine-spacing-xl) * 2);
padding-bottom: calc(var(--mantine-spacing-xl) * 2);
min-height: rem(650px);
}
.title {
margin-bottom: calc(var(--mantine-spacing-xl) * 1.5);
}
.item {
border-radius: var(--mantine-radius-md);
margin-bottom: var(--mantine-spacing-lg);
border: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}

42
frontend/app/faq/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { Accordion, AccordionControl, AccordionItem, AccordionPanel, Container, Title } from '@mantine/core';
import classes from './FaqPage.module.css';
export default function FaqPage() {
const placeholder = 'Lorem ipsum'
return (
<Container size="sm" className={classes.wrapper}>
<Title ta="center" className={classes.title}>
Frequently Asked Questions
</Title>
<Accordion variant="separated">
<AccordionItem className={classes.item} value="catch">
<AccordionControl>What&apos;s the catch?</AccordionControl>
<AccordionPanel>{placeholder}</AccordionPanel>
</AccordionItem>
<AccordionItem className={classes.item} value="advertising">
<AccordionControl>Why don&apos;t you advertise?</AccordionControl>
<AccordionPanel>{placeholder}</AccordionPanel>
</AccordionItem>
<AccordionItem className={classes.item} value="donate">
<AccordionControl>How can I help the development effort?</AccordionControl>
<AccordionPanel>{placeholder}</AccordionPanel>
</AccordionItem>
<AccordionItem className={classes.item} value="privacy">
<AccordionControl>What data do you collect?</AccordionControl>
<AccordionPanel>{placeholder}</AccordionPanel>
</AccordionItem>
<AccordionItem className={classes.item} value="data">
<AccordionControl>Where do you get the data from?</AccordionControl>
<AccordionPanel>{placeholder}</AccordionPanel>
</AccordionItem>
</Accordion>
</Container>
);
}

33
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import '@mantine/charts/styles.css';
import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import '@mantine/dates/styles.css';
import { ReactElement } from "react";
import { theme } from "../theme";
import MainLayout from "./components/layout/MainLayout";
export const metadata = {
title: "Fuel Wizard",
description: "Analyse fuel prices",
};
export default function RootLayout({ children }: { children: ReactElement }) {
return (
<html lang="en">
<head>
<link rel="shortcut icon" href="/fuel-app-logo-lime.svg" />
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
/>
</head>
<body>
<MantineProvider theme={theme}>
<MainLayout>
{children}
</MainLayout>
</MantineProvider>
</body>
</html>
);
}

86
frontend/app/map/page.tsx Normal file
View File

@@ -0,0 +1,86 @@
'use client'
import { Button, Container, Group, NumberInput, Select, Stack } from '@mantine/core';
import axios from 'axios';
import 'leaflet/dist/leaflet.css';
import dynamic from "next/dynamic";
import { useEffect, useState } from 'react';
import { FuelType, FuelTypeApi, StationWithPrices, StationWithPricesApi } from "../../generated";
import { CustomMapPoint } from '../components/map/CustomMap';
const CustomMap = dynamic(() => import("../components/map/CustomMap"), { ssr: false });
export default function MapPage() {
const stationWithPricesApi = new StationWithPricesApi(undefined, process.env.API_BASE_URL, axios);
const fuelTypeApi = new FuelTypeApi(undefined, process.env.API_BASE_URL, axios);
const [stationsWithPrices, setStationsWithPrices] = useState<StationWithPrices[]>();
const [fuelTypes, setFuelTypes] = useState<FuelType[]>();
const [selectedFuelType, setSelectedFuelType] = useState("U91");
const [numberOfResults, setNumberOfResults] = useState(10);
async function FetchData() {
try {
const [stationWithPricesResponse, fuelTypeResponse] = await Promise.all([
stationWithPricesApi.apiStationsWithPricesLowestPricesGet({ fuelType: selectedFuelType, numberOfResults }),
fuelTypeApi.apiFuelTypesAllGet()
]);
setStationsWithPrices(stationWithPricesResponse.data);
setFuelTypes(fuelTypeResponse.data);
} catch (error) {
console.error('Failed to fetch data', error);
}
};
useEffect(() => {
FetchData();
}, [selectedFuelType, numberOfResults]);
return (
<Container size='xl'>
<h1>View the lowest prices by fuel type.</h1>
<Stack
align="stretch"
justify="center"
gap="xl"
>
<Group align='end'>
<Select
label='Fuel Type'
description='Select a fuel type below'
placeholder='Select a fuel type'
searchable
data={fuelTypes?.map((ft) => { return { label: ft.name as string, value: ft.code as string } })}
value={selectedFuelType}
onChange={(value) => setSelectedFuelType(value as string)}
/>
<NumberInput
label="How many stations to show?"
description="Enter the number of stations to show on the map"
placeholder="Enter a number"
value={numberOfResults}
onChange={(value) => setNumberOfResults(value as number)}
/>
<Button onClick={() => FetchData()}>Apply</Button>
</Group>
<CustomMap
height={"72vh"}
scrollWheelZoom
points={stationsWithPrices?.map((swp) => {
return {
lat: swp.station?.coordinates?.latitude,
long: swp.station?.coordinates?.longitude,
popup: (
<div>
<h3>{swp.station?.name}</h3>
<h1>{swp.prices?.find((p) => p.fueltype === selectedFuelType)?.price}</h1>
<div>{swp.station?.address}</div>
</div>
)
} as CustomMapPoint
})}
/>
</Stack>
</Container>
);
}

61
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { Button, Container, Group, Text } from '@mantine/core';
import Image from 'next/image';
import { FaGitAlt, FaHandHoldingHeart } from "react-icons/fa6";
import logo from '../public/fuel-app-logo-lime.svg';
import classes from './Homepage.module.css';
export default function HomePage() {
return (
<div>
<Container size={700} pb={50} pt={100}>
<Group>
<Image src={logo} alt='logo' className={classes.logo} height={100} />
<Text fz={75} pl={30} variant="gradient" gradient={{ from: 'lime.7', to: 'green' }}>Fuel Wizard</Text>
</Group>
</Container>
<div className={classes.wrapper}>
<Container size={700} className={classes.inner}>
<h1 className={classes.title}>
Our mission is to leverage{' '}
<Text component="span" variant="gradient" gradient={{ from: 'lime.7', to: 'green' }} inherit>
open data
</Text>{' '}
to help you make{' '}
<Text component="span" variant="gradient" gradient={{ from: 'yellow', to: 'red' }} inherit>
informed choices.
</Text>
</h1>
<Text className={classes.description} c="dimmed">
We utilise publicly-available data to provide the best possible recommendations.
Our analysis is completely transparent, and our code is fully open source.
We do not collect your personal information, or use tracking/third-party cookies.
</Text>
<Group className={classes.controls}>
<Button
size="xl"
className={classes.control}
variant="gradient"
gradient={{ from: 'green', to: 'lime' }}
leftSection={<FaHandHoldingHeart />}
>
Help keep us running
</Button>
<Button
component="a"
href=""
size="xl"
variant="default"
className={classes.control}
leftSection={<FaGitAlt size={20} />}
>
Source Code
</Button>
</Group>
</Container>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Container } from '@mantine/core';
import axios from 'axios';
import { Price, PriceApi } from "../../generated";
import { CustomTable } from '../components/custom-table/CustomTable';
export default async function PricesPage() {
const priceApi = new PriceApi(undefined, undefined, axios);
const response = await priceApi.apiPricesCurrentFuelTypeLowestGet({ fuelType: "P98", pageNumber: 1, pageSize: 10 });
const prices = response.data.data;
return (
<Container size={1500}>
<CustomTable<Price>
data={prices as Price[]}
columns={[
{ label: 'Price', accessor: 'price' },
{ label: 'State', accessor: 'state' },
{ label: 'Station Code', accessor: 'stationcode' },
{ label: 'Last Updated', accessor: 'lastupdated' },
]}
sortableColumns={['price', 'state', 'stationcode', 'lastupdated']}
searchPlaceholder="Search prices..."
itemsPerPage={25}
striped
highlightOnHover
selectable
/>
</Container>
);
}

View File

@@ -0,0 +1,79 @@
'use client'
import { Container, Group, Select } from '@mantine/core';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { BrandApi, BrandType, FuelType, FuelTypeApi, Station, StationApi } from "../../generated";
import { CustomTable } from '../components/custom-table/CustomTable';
export default function StationsPage() {
const stationApi = new StationApi(undefined, process.env.API_BASE_URL, axios);
const brandApi = new BrandApi(undefined, process.env.API_BASE_URL, axios);
const fuelTypeApi = new FuelTypeApi(undefined, process.env.API_BASE_URL, axios);
const [stations, setStations] = useState<Station[]>([]);
const [brands, setBrands] = useState<BrandType[]>([]);
const [fuelTypes, setFuelTypes] = useState<FuelType[]>([]);
const [selectedBrand, setSelectedBrand] = useState<string>();
const [selectedFuelType, setSelectedFuelType] = useState<string>();
useEffect(() => {
async function FetchData() {
try {
const [stationResponse, brandResponse, fuelTypeResponse] = await Promise.all([
stationApi.apiStationsAllGet(),
brandApi.apiBrandsAllGet(),
fuelTypeApi.apiFuelTypesAllGet()
]);
setStations(stationResponse.data);
setBrands(brandResponse.data);
setFuelTypes(fuelTypeResponse.data);
} catch (error) {
console.error('Failed to fetch data', error);
}
}
FetchData();
}, []);
return (
<Container size={1500}>
<h1>View and search for stations.</h1>
<Group align='end' pb={18}>
<Select
label='Filter by fuel type'
description='Select a fuel type below'
placeholder='Select a fuel type'
data={fuelTypes?.map((ft) => { return { label: ft.name as string, value: ft.code as string } })}
value={selectedFuelType}
onChange={(value) => setSelectedFuelType(value as string)}
searchable
/>
<Select
label='Filter by brand'
description='Select a brand below'
placeholder='Select a brand'
data={brands?.map((b) => { return { label: b.name as string, value: b.id as string } })}
value={selectedBrand}
onChange={(value) => setSelectedBrand(value as string)}
searchable
/>
</Group>
<CustomTable<Station>
data={stations}
columns={[
{ label: 'Code', accessor: 'code' },
{ label: 'Name', accessor: 'name' },
{ label: 'Brand', accessor: 'brand' },
{ label: 'State', accessor: 'state' },
{ label: 'Address', accessor: 'address' },
]}
sortableColumns={['code', 'name', 'brand']}
searchPlaceholder="Text filter"
itemsPerPage={25}
striped
highlightOnHover
selectable
/>
</Container>
);
}

View File

@@ -0,0 +1,17 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
export default [
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"] },
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
...pluginReact.configs.flat.recommended,
rules: {
"react/react-in-jsx-scope": "off"
}
}
];

4
frontend/generated/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
wwwroot/*.js
node_modules
typings
dist

View File

@@ -0,0 +1 @@
# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm

View File

@@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@@ -0,0 +1,8 @@
.gitignore
.npmignore
api.ts
base.ts
common.ts
configuration.ts
git_push.sh
index.ts

View File

@@ -0,0 +1 @@
7.8.0

2329
frontend/generated/api.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
/* tslint:disable */
/* eslint-disable */
/**
* backend
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from './configuration';
// Some imports not used depending on template conditions
// @ts-ignore
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
export const BASE_PATH = "http://localhost:5161".replace(/\/+$/, "");
/**
*
* @export
*/
export const COLLECTION_FORMATS = {
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
};
/**
*
* @export
* @interface RequestArgs
*/
export interface RequestArgs {
url: string;
options: RawAxiosRequestConfig;
}
/**
*
* @export
* @class BaseAPI
*/
export class BaseAPI {
protected configuration: Configuration | undefined;
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath ?? basePath;
}
}
};
/**
*
* @export
* @class RequiredError
* @extends {Error}
*/
export class RequiredError extends Error {
constructor(public field: string, msg?: string) {
super(msg);
this.name = "RequiredError"
}
}
interface ServerMap {
[key: string]: {
url: string,
description: string,
}[];
}
/**
*
* @export
*/
export const operationServerMap: ServerMap = {
}

View File

@@ -0,0 +1,150 @@
/* tslint:disable */
/* eslint-disable */
/**
* backend
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "./configuration";
import type { RequestArgs } from "./base";
import type { AxiosInstance, AxiosResponse } from 'axios';
import { RequiredError } from "./base";
/**
*
* @export
*/
export const DUMMY_BASE_URL = 'https://example.com'
/**
*
* @throws {RequiredError}
* @export
*/
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
if (paramValue === null || paramValue === undefined) {
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
}
}
/**
*
* @export
*/
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
if (configuration && configuration.apiKey) {
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
? await configuration.apiKey(keyParamName)
: await configuration.apiKey;
object[keyParamName] = localVarApiKeyValue;
}
}
/**
*
* @export
*/
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
if (configuration && (configuration.username || configuration.password)) {
object["auth"] = { username: configuration.username, password: configuration.password };
}
}
/**
*
* @export
*/
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const accessToken = typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
object["Authorization"] = "Bearer " + accessToken;
}
}
/**
*
* @export
*/
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
? await configuration.accessToken(name, scopes)
: await configuration.accessToken;
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
}
}
function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void {
if (parameter == null) return;
if (typeof parameter === "object") {
if (Array.isArray(parameter)) {
(parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key));
}
else {
Object.keys(parameter).forEach(currentKey =>
setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`)
);
}
}
else {
if (urlSearchParams.has(key)) {
urlSearchParams.append(key, parameter);
}
else {
urlSearchParams.set(key, parameter);
}
}
}
/**
*
* @export
*/
export const setSearchParams = function (url: URL, ...objects: any[]) {
const searchParams = new URLSearchParams(url.search);
setFlattenedQueryParams(searchParams, objects);
url.search = searchParams.toString();
}
/**
*
* @export
*/
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
const nonString = typeof value !== 'string';
const needsSerialization = nonString && configuration && configuration.isJsonMime
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
: nonString;
return needsSerialization
? JSON.stringify(value !== undefined ? value : {})
: (value || "");
}
/**
*
* @export
*/
export const toPathString = function (url: URL) {
return url.pathname + url.search + url.hash
}
/**
*
* @export
*/
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url};
return axios.request<T, R>(axiosRequestArgs);
};
}

View File

@@ -0,0 +1,110 @@
/* tslint:disable */
/* eslint-disable */
/**
* backend
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface ConfigurationParameters {
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
basePath?: string;
serverIndex?: number;
baseOptions?: any;
formDataCtor?: new () => any;
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
username?: string;
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
/**
* override base path
*
* @type {string}
* @memberof Configuration
*/
basePath?: string;
/**
* override server index
*
* @type {number}
* @memberof Configuration
*/
serverIndex?: number;
/**
* base options for axios calls
*
* @type {any}
* @memberof Configuration
*/
baseOptions?: any;
/**
* The FormData constructor that will be used to create multipart form data
* requests. You can inject this here so that execution environments that
* do not support the FormData class can still run the generated client.
*
* @type {new () => FormData}
*/
formDataCtor?: new () => any;
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
this.serverIndex = param.serverIndex;
this.baseOptions = param.baseOptions;
this.formDataCtor = param.formDataCtor;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
}
}

View File

@@ -0,0 +1,57 @@
#!/bin/sh
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
#
# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
git_user_id=$1
git_repo_id=$2
release_note=$3
git_host=$4
if [ "$git_host" = "" ]; then
git_host="github.com"
echo "[INFO] No command line input provided. Set \$git_host to $git_host"
fi
if [ "$git_user_id" = "" ]; then
git_user_id="GIT_USER_ID"
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
fi
if [ "$git_repo_id" = "" ]; then
git_repo_id="GIT_REPO_ID"
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
fi
if [ "$release_note" = "" ]; then
release_note="Minor update"
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
fi
# Initialize the local directory as a Git repository
git init
# Adds the files in the local repository and stages them for commit.
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
git commit -m "$release_note"
# Sets the new remote
git_remote=$(git remote)
if [ "$git_remote" = "" ]; then # git remote not defined
if [ "$GIT_TOKEN" = "" ]; then
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
else
git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
fi
fi
git pull origin master
# Pushes (Forces) the changes in the local repository up to the remote repository
echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
git push origin master 2>&1 | grep -v 'To https'

View File

@@ -0,0 +1,18 @@
/* tslint:disable */
/* eslint-disable */
/**
* backend
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export * from "./api";
export * from "./configuration";

View File

@@ -0,0 +1,4 @@
export type DateTimeRange = {
startDate: Date | null,
endDate: Date | null
}

View File

@@ -0,0 +1,15 @@
export enum TimePeriod {
Last30Days = 'last30Days',
Last3Months = 'last3Months',
Last6Months = 'last6Months',
LastYear = 'lastYear',
}
//options for the select dropdown
export const timePeriodOptions = [
{ value: TimePeriod.Last30Days, label: 'Last 30 Days' },
{ value: TimePeriod.Last3Months, label: 'Last 3 Months' },
{ value: TimePeriod.Last6Months, label: 'Last 6 Months' },
{ value: TimePeriod.LastYear, label: 'Last Year' },
];

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

10
frontend/next.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
experimental: {
optimizePackageImports: ['@mantine/core', '@mantine/hooks'],
},
};
export default nextConfig;

View File

@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.8.0"
}
}

46
frontend/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "mantine-minimal-next-template",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"generate": "openapi-generator-cli generate -i http://localhost:5161/swagger/v1/swagger.json -g typescript-axios -o ./generated --additional-properties=supportsES6=true,useSingleRequestParameter=true,typescriptThreePlus=true,withInterfaces=true"
},
"dependencies": {
"@mantine/charts": "^7.13.3",
"@mantine/core": "7.13.3",
"@mantine/dates": "^7.13.3",
"@mantine/hooks": "7.13.3",
"@openapitools/openapi-generator-cli": "^2.14.0",
"axios": "^1.7.7",
"dayjs": "^1.11.13",
"dayjs-plugin-utc": "^0.1.2",
"leaflet": "^1.9.4",
"next": "14.2.14",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-icons": "^5.3.0",
"react-leaflet": "^4.2.1",
"recharts": "^2.13.0"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@types/leaflet": "^1.9.13",
"@types/node": "20.14.8",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"eslint": "8.57.0",
"eslint-config-next": "14.2.4",
"eslint-plugin-react": "^7.37.1",
"globals": "^15.11.0",
"postcss": "^8.4.47",
"postcss-preset-mantine": "1.17.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "5.5.2",
"typescript-eslint": "^8.10.0"
},
"packageManager": "pnpm@9.7.1"
}

4537
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
module.exports = {
plugins: {
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
"mantine-breakpoint-xs": "36em",
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em",
},
},
},
};

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M172.268 501.67C26.97 291.031 0 269.413 0 192 0 85.961 85.961 0 192 0s192 85.961 192 192c0 77.413-26.97 99.031-172.268 309.67-9.535 13.774-29.93 13.773-39.464 0zM192 272c44.183 0 80-35.817 80-80s-35.817-80-80-80-80 35.817-80 80 35.817 80 80 80z"/></svg>
<!--
Font Awesome Free 5.2.0 by @fontawesome - https://fontawesome.com
License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
-->

After

Width:  |  Height:  |  Size: 499 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
<g fill="#74b816">
<path d="M172.268 501.67C26.97 291.031 0 269.413 0 192 0 85.961 85.961 0 192 0s192 85.961 192 192c0 77.413-26.97 99.031-172.268 309.67-9.535 13.774-29.93 13.773-39.464 0zM192 272c44.183 0 80-35.817 80-80s-35.817-80-80-80-80 35.817-80 80 35.817 80 80 80z"/>
</g>
</svg>
<!--
Font Awesome Free 5.2.0 by @fontawesome - https://fontawesome.com
License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
-->

After

Width:  |  Height:  |  Size: 525 B

Some files were not shown because too many files have changed in this diff Show More