init
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
backend/.env
|
||||
backend/obj/
|
||||
backend/bin/
|
||||
backend/appsettings.Production.json
|
||||
backend/.idea*
|
||||
30
.vscode/launch.json
vendored
Normal file
30
.vscode/launch.json
vendored
Normal 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
41
.vscode/tasks.json
vendored
Normal 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
25
backend/.dockerignore
Normal 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
35
backend/.vscode/launch.json
vendored
Normal 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
1
backend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
41
backend/.vscode/tasks.json
vendored
Normal file
41
backend/.vscode/tasks.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
backend/Config/NswFuelApiConfig.cs
Normal file
9
backend/Config/NswFuelApiConfig.cs
Normal 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; }
|
||||
}
|
||||
6
backend/Config/TimescaleDBConfig.cs
Normal file
6
backend/Config/TimescaleDBConfig.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace backend.Config;
|
||||
|
||||
public class TimescaleDbConfig
|
||||
{
|
||||
public required string ConnectionString { get; init; }
|
||||
}
|
||||
58
backend/Controllers/BrandController.cs
Normal file
58
backend/Controllers/BrandController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
33
backend/Controllers/FuelTypeController.cs
Normal file
33
backend/Controllers/FuelTypeController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
100
backend/Controllers/PriceController.cs
Normal file
100
backend/Controllers/PriceController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
39
backend/Controllers/PriceTrendsController.cs
Normal file
39
backend/Controllers/PriceTrendsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
60
backend/Controllers/StationController.cs
Normal file
60
backend/Controllers/StationController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
88
backend/Controllers/StationWithPricesController.cs
Normal file
88
backend/Controllers/StationWithPricesController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
backend/Database/Deployment/compose.yaml
Normal file
11
backend/Database/Deployment/compose.yaml
Normal 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
23
backend/Dockerfile
Normal 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"]
|
||||
80
backend/Extensions/ServiceCollectionExtensions.cs
Normal file
80
backend/Extensions/ServiceCollectionExtensions.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
9
backend/Models/Aggregations/StationWithPrices.cs
Normal file
9
backend/Models/Aggregations/StationWithPrices.cs
Normal 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; }
|
||||
}
|
||||
43
backend/Models/NswFuelApi/AccessTokenResponse.cs
Normal file
43
backend/Models/NswFuelApi/AccessTokenResponse.cs
Normal 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; }
|
||||
}
|
||||
14
backend/Models/NswFuelApi/BrandType.cs
Normal file
14
backend/Models/NswFuelApi/BrandType.cs
Normal 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;
|
||||
}
|
||||
17
backend/Models/NswFuelApi/FuelType.cs
Normal file
17
backend/Models/NswFuelApi/FuelType.cs
Normal 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;
|
||||
}
|
||||
10
backend/Models/NswFuelApi/Location.cs
Normal file
10
backend/Models/NswFuelApi/Location.cs
Normal 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; }
|
||||
}
|
||||
15
backend/Models/NswFuelApi/LocationPrices.cs
Normal file
15
backend/Models/NswFuelApi/LocationPrices.cs
Normal 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; }
|
||||
}
|
||||
38
backend/Models/NswFuelApi/Price.cs
Normal file
38
backend/Models/NswFuelApi/Price.cs
Normal 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; }
|
||||
}
|
||||
31
backend/Models/NswFuelApi/ReferenceData.cs
Normal file
31
backend/Models/NswFuelApi/ReferenceData.cs
Normal 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; }
|
||||
}
|
||||
16
backend/Models/NswFuelApi/ReferencePoint.cs
Normal file
16
backend/Models/NswFuelApi/ReferencePoint.cs
Normal 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; }
|
||||
}
|
||||
10
backend/Models/NswFuelApi/SortField.cs
Normal file
10
backend/Models/NswFuelApi/SortField.cs
Normal 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; }
|
||||
}
|
||||
64
backend/Models/NswFuelApi/Station.cs
Normal file
64
backend/Models/NswFuelApi/Station.cs
Normal 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; }
|
||||
}
|
||||
10
backend/Models/NswFuelApi/TrendPeriod.cs
Normal file
10
backend/Models/NswFuelApi/TrendPeriod.cs
Normal 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; }
|
||||
}
|
||||
22
backend/Models/Trends/DailyPriceTrends.cs
Normal file
22
backend/Models/Trends/DailyPriceTrends.cs
Normal 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; }
|
||||
}
|
||||
21
backend/Models/Utilities/Pagination.cs
Normal file
21
backend/Models/Utilities/Pagination.cs
Normal 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
64
backend/Program.cs
Normal 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();
|
||||
41
backend/Properties/launchSettings.json
Normal file
41
backend/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
backend/Services/BrandService.cs
Normal file
37
backend/Services/BrandService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
22
backend/Services/FuelTypeService.cs
Normal file
22
backend/Services/FuelTypeService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
10
backend/Services/IBrandService.cs
Normal file
10
backend/Services/IBrandService.cs
Normal 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);
|
||||
}
|
||||
8
backend/Services/IFuelTypeService.cs
Normal file
8
backend/Services/IFuelTypeService.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using backend.Models.NswFuelApi;
|
||||
|
||||
namespace backend.Services;
|
||||
|
||||
public interface IFuelTypeService
|
||||
{
|
||||
public Task<IEnumerable<FuelType>> GetAllFuelTypes();
|
||||
}
|
||||
7
backend/Services/INswFuelApiService.cs
Normal file
7
backend/Services/INswFuelApiService.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace backend.Services;
|
||||
|
||||
public interface INswFuelApiService
|
||||
{
|
||||
Task<bool> GetLovsAsync();
|
||||
Task<bool> GetCurrentPricesAsync();
|
||||
}
|
||||
17
backend/Services/IPriceService.cs
Normal file
17
backend/Services/IPriceService.cs
Normal 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);
|
||||
}
|
||||
9
backend/Services/IPriceTrendsService.cs
Normal file
9
backend/Services/IPriceTrendsService.cs
Normal 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);
|
||||
}
|
||||
10
backend/Services/IStationService.cs
Normal file
10
backend/Services/IStationService.cs
Normal 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);
|
||||
}
|
||||
16
backend/Services/IStationWithPricesService.cs
Normal file
16
backend/Services/IStationWithPricesService.cs
Normal 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);
|
||||
}
|
||||
40
backend/Services/Init/DatabaseInitialiser.cs
Normal file
40
backend/Services/Init/DatabaseInitialiser.cs
Normal 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;");
|
||||
}
|
||||
}
|
||||
21
backend/Services/Init/HangfireInitialiser.cs
Normal file
21
backend/Services/Init/HangfireInitialiser.cs
Normal 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 * * * *");
|
||||
}
|
||||
}
|
||||
224
backend/Services/NswFuelApiService.cs
Normal file
224
backend/Services/NswFuelApiService.cs
Normal 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
|
||||
}
|
||||
152
backend/Services/PriceService.cs
Normal file
152
backend/Services/PriceService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
64
backend/Services/PriceTrendsService.cs
Normal file
64
backend/Services/PriceTrendsService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
56
backend/Services/StationService.cs
Normal file
56
backend/Services/StationService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
92
backend/Services/StationWithPricesService.cs
Normal file
92
backend/Services/StationWithPricesService.cs
Normal 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
21
backend/TODO.md
Normal 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?
|
||||
17
backend/appsettings.Development.json
Normal file
17
backend/appsettings.Development.json
Normal 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
18
backend/backend.csproj
Normal 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
17
backend/compose.yaml
Normal 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
1
documents/api_spec.json
Normal file
File diff suppressed because one or more lines are too long
2
frontend/.env.sample
Normal file
2
frontend/.env.sample
Normal file
@@ -0,0 +1,2 @@
|
||||
DEPLOYMENT_ENVIRONMENT=development
|
||||
API_BASE_URL=http://localhost:5161
|
||||
3
frontend/.eslintignore
Normal file
3
frontend/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
*.js
|
||||
*.mjs
|
||||
*.cjs
|
||||
3
frontend/.eslintrc.json
Normal file
3
frontend/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
132
frontend/.gitignore
vendored
Normal file
132
frontend/.gitignore
vendored
Normal 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
|
||||
59
frontend/app/Homepage.module.css
Normal file
59
frontend/app/Homepage.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
82
frontend/app/charts/page.tsx
Normal file
82
frontend/app/charts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
frontend/app/components/custom-table/CustomTable.tsx
Normal file
171
frontend/app/components/custom-table/CustomTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/app/components/dates/DateRangePicker.tsx
Normal file
69
frontend/app/components/dates/DateRangePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/app/components/layout/MainLayout.tsx
Normal file
20
frontend/app/components/layout/MainLayout.tsx
Normal 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 >
|
||||
);
|
||||
}
|
||||
22
frontend/app/components/layout/footer/Footer.module.css
Normal file
22
frontend/app/components/layout/footer/Footer.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
35
frontend/app/components/layout/footer/Footer.tsx
Normal file
35
frontend/app/components/layout/footer/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/app/components/layout/header/Header.module.css
Normal file
39
frontend/app/components/layout/header/Header.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
48
frontend/app/components/layout/header/Header.tsx
Normal file
48
frontend/app/components/layout/header/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.dark {
|
||||
@mixin dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.light {
|
||||
@mixin light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
181
frontend/app/components/map/CustomMap.tsx
Normal file
181
frontend/app/components/map/CustomMap.tsx
Normal 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='© <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;
|
||||
}
|
||||
15
frontend/app/faq/FaqPage.module.css
Normal file
15
frontend/app/faq/FaqPage.module.css
Normal 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
42
frontend/app/faq/page.tsx
Normal 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's the catch?</AccordionControl>
|
||||
<AccordionPanel>{placeholder}</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem className={classes.item} value="advertising">
|
||||
<AccordionControl>Why don'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
33
frontend/app/layout.tsx
Normal 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
86
frontend/app/map/page.tsx
Normal 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
61
frontend/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/app/prices/page.tsx
Normal file
30
frontend/app/prices/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/app/stations/page.tsx
Normal file
79
frontend/app/stations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/eslint.config.mjs
Normal file
17
frontend/eslint.config.mjs
Normal 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
4
frontend/generated/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
wwwroot/*.js
|
||||
node_modules
|
||||
typings
|
||||
dist
|
||||
1
frontend/generated/.npmignore
Normal file
1
frontend/generated/.npmignore
Normal file
@@ -0,0 +1 @@
|
||||
# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm
|
||||
23
frontend/generated/.openapi-generator-ignore
Normal file
23
frontend/generated/.openapi-generator-ignore
Normal 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
|
||||
8
frontend/generated/.openapi-generator/FILES
Normal file
8
frontend/generated/.openapi-generator/FILES
Normal file
@@ -0,0 +1,8 @@
|
||||
.gitignore
|
||||
.npmignore
|
||||
api.ts
|
||||
base.ts
|
||||
common.ts
|
||||
configuration.ts
|
||||
git_push.sh
|
||||
index.ts
|
||||
1
frontend/generated/.openapi-generator/VERSION
Normal file
1
frontend/generated/.openapi-generator/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
7.8.0
|
||||
2329
frontend/generated/api.ts
Normal file
2329
frontend/generated/api.ts
Normal file
File diff suppressed because it is too large
Load Diff
86
frontend/generated/base.ts
Normal file
86
frontend/generated/base.ts
Normal 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 = {
|
||||
}
|
||||
150
frontend/generated/common.ts
Normal file
150
frontend/generated/common.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
110
frontend/generated/configuration.ts
Normal file
110
frontend/generated/configuration.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
57
frontend/generated/git_push.sh
Normal file
57
frontend/generated/git_push.sh
Normal 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'
|
||||
18
frontend/generated/index.ts
Normal file
18
frontend/generated/index.ts
Normal 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";
|
||||
|
||||
4
frontend/models/DateTimeRange.ts
Normal file
4
frontend/models/DateTimeRange.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type DateTimeRange = {
|
||||
startDate: Date | null,
|
||||
endDate: Date | null
|
||||
}
|
||||
15
frontend/models/TimePeriod.ts
Normal file
15
frontend/models/TimePeriod.ts
Normal 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
5
frontend/next-env.d.ts
vendored
Normal 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
10
frontend/next.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
experimental: {
|
||||
optimizePackageImports: ['@mantine/core', '@mantine/hooks'],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7
frontend/openapitools.json
Normal file
7
frontend/openapitools.json
Normal 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
46
frontend/package.json
Normal 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
4537
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
frontend/postcss.config.cjs
Normal file
14
frontend/postcss.config.cjs
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
5
frontend/public/FA5_Map_Marker-default.svg
Normal file
5
frontend/public/FA5_Map_Marker-default.svg
Normal 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 |
9
frontend/public/FA5_Map_Marker-lime.svg
Normal file
9
frontend/public/FA5_Map_Marker-lime.svg
Normal 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
Reference in New Issue
Block a user