Cleanup transport service

This commit is contained in:
clayschaad 2025-08-23 19:06:38 +02:00
parent 991dd5f623
commit 5aea13e3f5
12 changed files with 128 additions and 549 deletions

View file

@ -1,8 +1,8 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ShiftScheduler.Shared.Models;
using System.Net.Http.Json;
using System.Text.Json;
using ShiftScheduler.Shared;
namespace ShiftScheduler.Client.Pages
{

View file

@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using ShiftScheduler.Services;
using ShiftScheduler.Shared.Models;
using ShiftScheduler.Shared;
namespace ShiftScheduler.Server.Controllers
{

View file

@ -1,5 +1,5 @@
using ShiftScheduler.Shared.Models;
using ShiftScheduler.Services;
using ShiftScheduler.Shared;
var builder = WebApplication.CreateBuilder(args);

View file

@ -2,7 +2,7 @@ using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using Ical.Net.Serialization;
using ShiftScheduler.Shared.Models;
using ShiftScheduler.Shared;
namespace ShiftScheduler.Services
{

View file

@ -1,8 +1,8 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using ShiftScheduler.Shared.Models;
using System.Globalization;
using ShiftScheduler.Shared;
namespace ShiftScheduler.Services
{

View file

@ -1,5 +1,5 @@

using ShiftScheduler.Shared.Models;
using ShiftScheduler.Shared;
namespace ShiftScheduler.Services
{

View file

@ -0,0 +1,36 @@
using ShiftScheduler.Shared;
namespace ShiftScheduler.Services;
public static class TransportConnectionCalculator
{
public static TransportConnection? FindBestConnection(IReadOnlyList<TransportConnection> connections, DateTime latestArrivalTime)
{
var validConnections = new List<TransportConnection>();
foreach (var connection in connections)
{
if (DateTime.TryParse(connection.ArrivalTime, out var arrivalTime))
{
if (arrivalTime <= latestArrivalTime)
{
validConnections.Add(connection);
}
}
}
if (validConnections.Count > 0)
{
var sortedValid = validConnections
.OrderBy(c => DateTime.Parse(c.ArrivalTime ?? "00:00"))
.ToList();
return sortedValid.Last();
}
// If no valid connections, return the earliest available
return connections
.OrderBy(c => DateTime.Parse(c.ArrivalTime ?? "23:59"))
.FirstOrDefault();
}
}

View file

@ -1,137 +1,31 @@
using ShiftScheduler.Shared.Models;
using System.Text.Json;
using ShiftScheduler.Shared;
namespace ShiftScheduler.Services
{
public class TransportService
public class TransportService(HttpClient httpClient, TransportConfiguration config)
{
private readonly HttpClient _httpClient;
private readonly TransportConfiguration _config;
public TransportService(HttpClient httpClient, TransportConfiguration config)
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
{
_httpClient = httpClient;
_config = config;
}
var latestArrivalTime = shiftStartTime.AddMinutes(-config.SafetyBufferMinutes);
var searchDate = shiftStartTime.ToString("yyyy-MM-dd");
var searchTime = shiftStartTime.ToString("HH:mm");
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime, string? customEndStation = null)
{
try
{
var endStation = customEndStation ?? _config.EndStation;
// Calculate the latest acceptable arrival time (shift start time - safety buffer)
var latestArrivalTime = shiftStartTime.AddMinutes(-_config.SafetyBufferMinutes);
// Search for arrival time
var searchTime = shiftStartTime;
var searchTimeStr = searchTime.ToString("yyyy-MM-dd");
var searchHourStr = searchTime.ToString("HH:mm");
var url = $"{_config.ApiBaseUrl}/connections?from={Uri.EscapeDataString(_config.StartStation)}&to={Uri.EscapeDataString(endStation)}&date={searchTimeStr}&time={searchHourStr}&isArrivalTime=1&limit=5";
var response = await _httpClient.GetStringAsync(url);
var url = $"{config.ApiBaseUrl}/connections?from={Uri.EscapeDataString(config.StartStation)}&to={Uri.EscapeDataString(config.EndStation)}&date={searchDate}&time={searchTime}&isArrivalTime=1&limit=5";
var response = await httpClient.GetStringAsync(url);
var apiResponse = JsonSerializer.Deserialize<TransportApiResponse>(response, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (apiResponse?.Connections?.Count > 0)
if (apiResponse?.Connections.Count > 0)
{
// Find the best connection that arrives before the required time
var bestConnection = FindBestConnection(apiResponse.Connections, latestArrivalTime);
return MapToTransportConnection(bestConnection);
var allConnections = apiResponse.Connections.Select(MapToTransportConnection).ToList();
return TransportConnectionCalculator.FindBestConnection(allConnections, latestArrivalTime);
}
return null;
}
catch (Exception ex)
{
// Log the exception and return a mock connection for demonstration
Console.WriteLine($"Error fetching transport connection: {ex.Message}");
return CreateMockConnection(shiftStartTime);
}
}
private TransportConnection CreateMockConnection(DateTime shiftStartTime)
{
// Create a realistic mock connection that arrives before shift start with safety buffer
var latestArrivalTime = shiftStartTime.AddMinutes(-_config.SafetyBufferMinutes);
var arrivalTime = latestArrivalTime.AddMinutes(-5); // Arrive 5 minutes before the latest acceptable time
var departureTime = arrivalTime.AddMinutes(-45); // 45 minutes journey time
return new TransportConnection
{
DepartureTime = departureTime.ToString("HH:mm"),
ArrivalTime = arrivalTime.ToString("HH:mm"),
Duration = "00:45:00",
Platform = "3",
Sections = new List<TransportSection>
{
new TransportSection
{
Journey = new TransportJourney
{
Name = "S1",
Category = "S",
Number = "1"
},
Departure = new TransportCheckpoint
{
Station = new TransportStation
{
Name = _config.StartStation,
Id = "start"
},
Departure = departureTime.ToString("HH:mm"),
Platform = "3"
},
Arrival = new TransportCheckpoint
{
Station = new TransportStation
{
Name = _config.EndStation,
Id = "end"
},
Arrival = arrivalTime.ToString("HH:mm"),
Platform = "1"
}
}
}
};
}
private TransportApiConnection? FindBestConnection(List<TransportApiConnection> connections, DateTime latestArrivalTime)
{
// Filter connections that arrive before the latest acceptable time
var validConnections = new List<TransportApiConnection>();
foreach (var connection in connections)
{
if (DateTime.TryParse(connection.To?.Arrival, out var arrivalTime))
{
// Compare arrival time with latest acceptable time
if (arrivalTime <= latestArrivalTime)
{
validConnections.Add(connection);
}
}
}
if (validConnections.Count > 0)
{
var sortedValid = validConnections
.OrderBy(c => DateTime.Parse(c.To?.Arrival ?? "00:00"))
.ToList();
return sortedValid.Last();
}
// If no valid connections, return the earliest available
return connections
.OrderBy(c => DateTime.Parse(c.To?.Arrival ?? "23:59"))
.FirstOrDefault();
}
private TransportConnection MapToTransportConnection(TransportApiConnection? apiConnection)
{
@ -142,54 +36,92 @@ namespace ShiftScheduler.Services
{
DepartureTime = apiConnection.From?.Departure ?? string.Empty,
ArrivalTime = apiConnection.To?.Arrival ?? string.Empty,
Duration = apiConnection.Duration ?? string.Empty,
Duration = apiConnection.Duration,
Platform = apiConnection.From?.Platform ?? string.Empty,
Sections = apiConnection.Sections?.Select(s => new TransportSection
Sections = apiConnection.Sections.Select(s => new TransportSection
{
Journey = s.Journey != null ? new TransportJourney
{
Name = s.Journey.Name ?? string.Empty,
Category = s.Journey.Category ?? string.Empty,
Number = s.Journey.Number ?? string.Empty
Name = s.Journey.Name,
Category = s.Journey.Category,
Number = s.Journey.Number
} : null,
Departure = s.Departure != null ? new TransportCheckpoint
{
Station = s.Departure.Station != null ? new TransportStation
{
Name = s.Departure.Station.Name ?? string.Empty,
Id = s.Departure.Station.Id ?? string.Empty
Name = s.Departure.Station.Name,
Id = s.Departure.Station.Id
} : null,
Departure = s.Departure.Departure ?? string.Empty,
Arrival = s.Departure.Arrival ?? string.Empty,
Platform = s.Departure.Platform ?? string.Empty
Departure = s.Departure.Departure,
Arrival = s.Departure.Arrival,
Platform = s.Departure.Platform
} : null,
Arrival = s.Arrival != null ? new TransportCheckpoint
{
Station = s.Arrival.Station != null ? new TransportStation
{
Name = s.Arrival.Station.Name ?? string.Empty,
Id = s.Arrival.Station.Id ?? string.Empty
Name = s.Arrival.Station.Name,
Id = s.Arrival.Station.Id
} : null,
Departure = s.Arrival.Departure ?? string.Empty,
Arrival = s.Arrival.Arrival ?? string.Empty,
Platform = s.Arrival.Platform ?? string.Empty
Departure = s.Arrival.Departure,
Arrival = s.Arrival.Arrival,
Platform = s.Arrival.Platform
} : null
}).ToList() ?? new List<TransportSection>()
}).ToList()
};
}
public string FormatConnectionSummary(TransportConnection? connection)
{
if (connection == null || string.IsNullOrEmpty(connection.DepartureTime))
return "No transport info";
var departure = DateTime.TryParse(connection.DepartureTime, out var dep) ? dep.ToString("HH:mm") : connection.DepartureTime;
var arrival = DateTime.TryParse(connection.ArrivalTime, out var arr) ? arr.ToString("HH:mm") : connection.ArrivalTime;
var mainJourney = connection.Sections?.FirstOrDefault()?.Journey;
var trainInfo = mainJourney != null ? $"{mainJourney.Category} {mainJourney.Number}" : "Train";
return $"{trainInfo}: {departure} → {arrival}";
}
// API Response models for OpenData CH Transport
public class TransportApiResponse
{
public List<TransportApiConnection> Connections { get; set; } = new();
}
public class TransportApiConnection
{
public TransportApiCheckpoint? From { get; set; }
public TransportApiCheckpoint? To { get; set; }
public string Duration { get; set; } = string.Empty;
public List<TransportApiSection> Sections { get; set; } = new();
public override string ToString()
{
return $"{From} - {To}";
}
}
public class TransportApiCheckpoint
{
public TransportApiStation? Station { get; set; }
public string Departure { get; set; } = string.Empty;
public string Arrival { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public override string ToString()
{
return $"{Station?.Name}: {Departure} - {Arrival}";
}
}
public class TransportApiStation
{
public string Name { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
}
public class TransportApiSection
{
public TransportApiJourney? Journey { get; set; }
public TransportApiCheckpoint? Departure { get; set; }
public TransportApiCheckpoint? Arrival { get; set; }
}
public class TransportApiJourney
{
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Number { get; set; } = string.Empty;
}
}

View file

@ -1,4 +1,4 @@
namespace ShiftScheduler.Shared.Models
namespace ShiftScheduler.Shared
{
public class Shift
{

View file

@ -1,4 +1,4 @@
namespace ShiftScheduler.Shared.Models
namespace ShiftScheduler.Shared
{
// View model that combines shift information with transport data for a specific date
public record ShiftWithTransport

View file

@ -1,4 +1,4 @@
namespace ShiftScheduler.Shared.Models
namespace ShiftScheduler.Shared
{
public class TransportConfiguration
{
@ -45,58 +45,4 @@ namespace ShiftScheduler.Shared.Models
public string Name { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
}
// API Response models for OpenData CH Transport
public class TransportApiResponse
{
public List<TransportApiConnection> Connections { get; set; } = new();
}
public class TransportApiConnection
{
public TransportApiCheckpoint? From { get; set; }
public TransportApiCheckpoint? To { get; set; }
public string Duration { get; set; } = string.Empty;
public List<TransportApiSection> Sections { get; set; } = new();
public override string ToString()
{
return $"{From} - {To}";
}
}
public class TransportApiCheckpoint
{
public TransportApiStation? Station { get; set; }
public string Departure { get; set; } = string.Empty;
public string Arrival { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public override string ToString()
{
return $"{Station?.Name}: {Departure} - {Arrival}";
}
}
public class TransportApiStation
{
public string Name { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
}
public class TransportApiSection
{
public TransportApiJourney? Journey { get; set; }
public TransportApiCheckpoint? Departure { get; set; }
public TransportApiCheckpoint? Arrival { get; set; }
}
public class TransportApiJourney
{
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Number { get; set; } = string.Empty;
}
}

View file

@ -3,7 +3,7 @@ using System.Text.Json;
using Moq;
using Moq.Protected;
using ShiftScheduler.Services;
using ShiftScheduler.Shared.Models;
using ShiftScheduler.Shared;
using Shouldly;
namespace ShiftScheduler.Services.Tests;
@ -12,7 +12,6 @@ public class TransportServiceTests
{
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly HttpClient _httpClient;
private readonly TransportConfiguration _config;
private readonly TransportService _transportService;
public TransportServiceTests()
@ -20,7 +19,7 @@ public class TransportServiceTests
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_config = new TransportConfiguration
var config = new TransportConfiguration
{
StartStation = "Zurich HB",
EndStation = "Bern",
@ -29,7 +28,7 @@ public class TransportServiceTests
MinBreakMinutes = 60
};
_transportService = new TransportService(_httpClient, _config);
_transportService = new TransportService(_httpClient, config);
}
[Fact]
@ -60,47 +59,6 @@ public class TransportServiceTests
section.Journey?.Number.ShouldBe("1");
}
[Fact]
public async Task GetConnectionAsync_WithCustomEndStation_ShouldUseCustomStation()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var customEndStation = "Basel";
var apiResponse = CreateValidApiResponse();
var jsonResponse = JsonSerializer.Serialize(apiResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act
await _transportService.GetConnectionAsync(shiftStartTime, customEndStation);
// Assert
_httpMessageHandlerMock.Protected()
.Verify("SendAsync", Times.Once(),
ItExpr.Is<HttpRequestMessage>(req =>
req.RequestUri!.ToString().Contains(Uri.EscapeDataString(customEndStation))),
ItExpr.IsAny<CancellationToken>());
}
[Fact]
public async Task GetConnectionAsync_WithHttpException_ShouldReturnMockConnection()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
SetupHttpMockResponse(HttpStatusCode.InternalServerError, "Server Error");
// Act
var result = await _transportService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldNotBeNull();
result.DepartureTime.ShouldNotBeNullOrEmpty();
result.ArrivalTime.ShouldNotBeNullOrEmpty();
result.Duration.ShouldBe("00:45:00");
result.Platform.ShouldBe("3");
}
[Fact]
public async Task GetConnectionAsync_WithEmptyApiResponse_ShouldReturnNull()
{
@ -155,299 +113,6 @@ public class TransportServiceTests
result.ArrivalTime.ShouldBe("2023-12-15T07:25:00");
}
[Fact]
public void FormatConnectionSummary_WithValidConnection_ShouldReturnFormattedString()
{
// Arrange
var connection = new TransportConnection
{
DepartureTime = "06:45",
ArrivalTime = "07:30",
Sections = new List<TransportSection>
{
new TransportSection
{
Journey = new TransportJourney
{
Category = "IC",
Number = "1"
}
}
}
};
// Act
var result = _transportService.FormatConnectionSummary(connection);
// Assert
result.ShouldBe("IC 1: 06:45 → 07:30");
}
[Fact]
public void FormatConnectionSummary_WithNullConnection_ShouldReturnNoTransportInfo()
{
// Act
var result = _transportService.FormatConnectionSummary(null);
// Assert
result.ShouldBe("No transport info");
}
[Fact]
public void FormatConnectionSummary_WithEmptyDepartureTime_ShouldReturnNoTransportInfo()
{
// Arrange
var connection = new TransportConnection
{
DepartureTime = "",
ArrivalTime = "07:30"
};
// Act
var result = _transportService.FormatConnectionSummary(connection);
// Assert
result.ShouldBe("No transport info");
}
[Fact]
public void FormatConnectionSummary_WithNoJourney_ShouldUseDefaultTrainLabel()
{
// Arrange
var connection = new TransportConnection
{
DepartureTime = "06:45",
ArrivalTime = "07:30",
Sections = new List<TransportSection>()
};
// Act
var result = _transportService.FormatConnectionSummary(connection);
// Assert
result.ShouldBe("Train: 06:45 → 07:30");
}
[Fact]
public void FormatConnectionSummary_WithDateTimeParsing_ShouldFormatCorrectly()
{
// Arrange
var connection = new TransportConnection
{
DepartureTime = "2023-12-15T06:45:00",
ArrivalTime = "2023-12-15T07:30:00",
Sections = new List<TransportSection>
{
new TransportSection
{
Journey = new TransportJourney
{
Category = "S",
Number = "3"
}
}
}
};
// Act
var result = _transportService.FormatConnectionSummary(connection);
// Assert
result.ShouldBe("S 3: 06:45 → 07:30");
}
[Theory]
[InlineData(30, "07:25")] // Should arrive 5 minutes before latest acceptable time
[InlineData(60, "06:55")] // Should arrive 5 minutes before latest acceptable time with 60 min buffer
[InlineData(15, "07:40")] // Should arrive 5 minutes before latest acceptable time with 15 min buffer
public async Task GetConnectionAsync_WithDifferentSafetyBuffers_ShouldCreateCorrectMockConnection(
int safetyBufferMinutes, string expectedArrivalTime)
{
// Arrange
var config = new TransportConfiguration
{
StartStation = "Test Start",
EndStation = "Test End",
ApiBaseUrl = "https://test.api",
SafetyBufferMinutes = safetyBufferMinutes
};
var service = new TransportService(_httpClient, config);
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
SetupHttpMockResponse(HttpStatusCode.InternalServerError, "Error");
// Act
var result = await service.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldNotBeNull();
result.ArrivalTime.ShouldBe(expectedArrivalTime);
}
[Fact]
public async Task GetConnectionAsync_WithInvalidJson_ShouldReturnMockConnection()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
SetupHttpMockResponse(HttpStatusCode.OK, "invalid json {[}");
// Act
var result = await _transportService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldNotBeNull();
result.Duration.ShouldBe("00:45:00");
result.Platform.ShouldBe("3");
}
[Fact]
public async Task GetConnectionAsync_WithNetworkTimeout_ShouldReturnMockConnection()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network timeout"));
// Act
var result = await _transportService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldNotBeNull();
result.Duration.ShouldBe("00:45:00");
result.Platform.ShouldBe("3");
}
[Fact]
public async Task GetConnectionAsync_WithConnectionsWithInvalidTimes_ShouldFallbackToMockConnection()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var apiResponse = new TransportApiResponse
{
Connections = new List<TransportApiConnection>
{
new TransportApiConnection
{
From = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Start", Id = "start" },
Departure = "invalid-time",
Platform = "1"
},
To = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "End", Id = "end" },
Arrival = "not-a-time",
Platform = "2"
},
Duration = "00:30:00"
}
}
};
var jsonResponse = JsonSerializer.Serialize(apiResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act
var result = await _transportService.GetConnectionAsync(shiftStartTime);
// Assert
// When API contains invalid time formats, the service should fall back to mock connection
// because the FindBestConnection method can't parse invalid times
result.ShouldNotBeNull();
result.Duration.ShouldBe("00:45:00"); // Mock connection duration
result.Platform.ShouldBe("3"); // Mock connection platform
}
[Fact]
public async Task GetConnectionAsync_WithNullStations_ShouldHandleGracefully()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var apiResponse = new TransportApiResponse
{
Connections = new List<TransportApiConnection>
{
new TransportApiConnection
{
From = null,
To = null,
Duration = "00:30:00"
}
}
};
var jsonResponse = JsonSerializer.Serialize(apiResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act
var result = await _transportService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldNotBeNull();
result.DepartureTime.ShouldBe(string.Empty);
result.ArrivalTime.ShouldBe(string.Empty);
result.Platform.ShouldBe(string.Empty);
}
[Fact]
public void FormatConnectionSummary_WithNullJourneySection_ShouldUseDefaultTrainLabel()
{
// Arrange
var connection = new TransportConnection
{
DepartureTime = "06:45",
ArrivalTime = "07:30",
Sections = new List<TransportSection>
{
new TransportSection
{
Journey = null
}
}
};
// Act
var result = _transportService.FormatConnectionSummary(connection);
// Assert
result.ShouldBe("Train: 06:45 → 07:30");
}
[Fact]
public void FormatConnectionSummary_WithEmptyJourneyFields_ShouldUseTrainLabel()
{
// Arrange
var connection = new TransportConnection
{
DepartureTime = "06:45",
ArrivalTime = "07:30",
Sections = new List<TransportSection>
{
new TransportSection
{
Journey = new TransportJourney
{
Category = "",
Number = ""
}
}
}
};
// Act
var result = _transportService.FormatConnectionSummary(connection);
// Assert
result.ShouldBe(" : 06:45 → 07:30");
}
private void SetupHttpMockResponse(HttpStatusCode statusCode, string content)
{
var response = new HttpResponseMessage(statusCode)