Fix find best connection with using of local date time offset (#46)

* Add logger
* Change strings to date time offset
* Fix logic for best connection
* Fix parsing shift times
* set time zone
This commit is contained in:
Claudio Schaad 2025-09-03 22:17:53 +02:00 committed by GitHub
parent 3c01b33070
commit a1f9f1313e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 280 additions and 258 deletions

View file

@ -15,7 +15,7 @@ namespace ShiftScheduler.Client.Pages
private Dictionary<DateTime, string> SelectedSchedule { get; set; } = new(); private Dictionary<DateTime, string> SelectedSchedule { get; set; } = new();
private Dictionary<DateTime, ShiftWithTransport> SelectedShiftsWithTransport { get; set; } = new(); private Dictionary<DateTime, ShiftWithTransport> SelectedShiftsWithTransport { get; set; } = new();
private bool _isCurrentMonth = false; private bool _isCurrentMonth = true;
private bool _isLoadingTransport = false; private bool _isLoadingTransport = false;
private bool _isLoadingInitial = false; private bool _isLoadingInitial = false;
private bool _showConfigDialog = false; private bool _showConfigDialog = false;

View file

@ -45,14 +45,15 @@ namespace ShiftScheduler.Server.Controllers
var transportConfig = _configurationService.GetTransportConfiguration(); var transportConfig = _configurationService.GetTransportConfiguration();
TransportConnection? morningTransport = null; TransportConnection? morningTransport = null;
TransportConnection? afternoonTransport = null; TransportConnection? afternoonTransport = null;
var shifTimes = _configurationService.ParseShiftTimes(request.Date, shift);
// Get transport for morning shift if it has morning time // Get transport for morning shift if it has morning time
if (!string.IsNullOrEmpty(shift.MorningTime)) if (!string.IsNullOrEmpty(shift.MorningTime))
{ {
var morningStartTime = ParseShiftTime(request.Date, shift.MorningTime); if (shifTimes.MorningStart.HasValue)
if (morningStartTime.HasValue)
{ {
morningTransport = await _transportService.GetConnectionAsync(morningStartTime.Value); morningTransport = await _transportService.GetConnectionAsync(shifTimes.MorningStart.Value);
} }
} }
@ -65,22 +66,18 @@ namespace ShiftScheduler.Server.Controllers
// If both morning and afternoon shifts exist, check break duration // If both morning and afternoon shifts exist, check break duration
if (!string.IsNullOrEmpty(shift.MorningTime) && !string.IsNullOrEmpty(shift.AfternoonTime)) if (!string.IsNullOrEmpty(shift.MorningTime) && !string.IsNullOrEmpty(shift.AfternoonTime))
{ {
var morningEndTime = ParseShiftEndTime(request.Date, shift.MorningTime); if (shifTimes.MorningEnd.HasValue && shifTimes.AfternoonStart.HasValue)
var afternoonStartTime = ParseShiftTime(request.Date, shift.AfternoonTime);
if (morningEndTime.HasValue && afternoonStartTime.HasValue)
{ {
var breakDurationMinutes = (afternoonStartTime.Value - morningEndTime.Value).TotalMinutes; var breakDurationMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes; shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes;
} }
} }
if (shouldLoadAfternoonTransport) if (shouldLoadAfternoonTransport)
{ {
var afternoonStartTime = ParseShiftTime(request.Date, shift.AfternoonTime); if (shifTimes.AfternoonStart.HasValue)
if (afternoonStartTime.HasValue)
{ {
afternoonTransport = await _transportService.GetConnectionAsync(afternoonStartTime.Value); afternoonTransport = await _transportService.GetConnectionAsync(shifTimes.AfternoonStart.Value);
} }
} }
} }
@ -96,40 +93,6 @@ namespace ShiftScheduler.Server.Controllers
return Ok(shiftWithTransport); return Ok(shiftWithTransport);
} }
private static DateTime? ParseShiftTime(DateTime date, string timeRange)
{
try
{
var times = timeRange.Split('-');
if (times.Length > 0 && TimeSpan.TryParse(times[0], out var startTime))
{
return date.Add(startTime);
}
}
catch
{
// Ignore parsing errors
}
return null;
}
private static DateTime? ParseShiftEndTime(DateTime date, string timeRange)
{
try
{
var times = timeRange.Split('-');
if (times.Length > 1 && TimeSpan.TryParse(times[1], out var endTime))
{
return date.Add(endTime);
}
}
catch
{
// Ignore parsing errors
}
return null;
}
[HttpPost("export_ics")] [HttpPost("export_ics")]
public IActionResult ExportIcsWithTransport([FromBody] List<ShiftWithTransport> shiftsWithTransport) public IActionResult ExportIcsWithTransport([FromBody] List<ShiftWithTransport> shiftsWithTransport)
{ {

View file

@ -12,12 +12,15 @@ namespace ShiftScheduler.Services
List<Shift> GetShifts(); List<Shift> GetShifts();
TransportConfiguration GetTransportConfiguration(); TransportConfiguration GetTransportConfiguration();
// Schedule persistence methods
Task SaveScheduleAsync(int year, int month, Dictionary<DateTime, string> schedule); Task SaveScheduleAsync(int year, int month, Dictionary<DateTime, string> schedule);
Task<Dictionary<DateTime, string>> LoadScheduleAsync(int year, int month); Task<Dictionary<DateTime, string>> LoadScheduleAsync(int year, int month);
Task DeleteScheduleAsync(int year, int month); Task DeleteScheduleAsync(int year, int month);
ShiftTimes ParseShiftTimes(DateTime date, Shift shift);
} }
public record ShiftTimes(DateTimeOffset? MorningStart, DateTimeOffset? MorningEnd, DateTimeOffset? AfternoonStart, DateTimeOffset? AfternoonEnd);
public class ConfigurationService : IConfigurationService public class ConfigurationService : IConfigurationService
{ {
private ApplicationConfiguration _configuration; private ApplicationConfiguration _configuration;
@ -26,6 +29,8 @@ namespace ShiftScheduler.Services
private readonly string _shiftsFilePath; private readonly string _shiftsFilePath;
private readonly string _transportFilePath; private readonly string _transportFilePath;
readonly TimeZoneInfo localTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Zurich");
public ConfigurationService(ApplicationConfiguration initialConfiguration) public ConfigurationService(ApplicationConfiguration initialConfiguration)
{ {
_configDirectory = Path.Combine(Directory.GetCurrentDirectory(), "config"); _configDirectory = Path.Combine(Directory.GetCurrentDirectory(), "config");
@ -201,7 +206,47 @@ namespace ShiftScheduler.Services
// Log error if needed, but don't throw // Log error if needed, but don't throw
} }
} }
public ShiftTimes ParseShiftTimes(DateTime date, Shift shift)
{
DateTimeOffset? morningStart = null;
DateTimeOffset? morningEnd = null;
DateTimeOffset? afternoonStart = null;
DateTimeOffset? afternoonEnd = null;
TimeSpan offset = localTimeZone.GetUtcOffset(DateTime.UtcNow);
if (!string.IsNullOrEmpty(shift.MorningTime))
{
var times = shift.MorningTime.Split('-');
if (times.Length == 2)
{
var start = $"{date:yyyy-MM-dd}T{times[0]}:00+{offset.Hours}:{offset.Minutes}";
var end = $"{date:yyyy-MM-dd}T{times[1]}:00+{offset.Hours}:{offset.Minutes}";
morningStart = DateTimeOffset.Parse(start);
morningEnd = DateTimeOffset.Parse(end);
}
}
if (!string.IsNullOrEmpty(shift.AfternoonTime))
{
var times = shift.AfternoonTime.Split('-');
if (times.Length == 2)
{
var start = $"{date:yyyy-MM-dd}T{times[0]}:00+{offset.Hours}:{offset.Minutes}";
var end = $"{date:yyyy-MM-dd}T{times[1]}:00+{offset.Hours}:{offset.Minutes}";
afternoonStart = DateTimeOffset.Parse(start);
afternoonEnd = DateTimeOffset.Parse(end);
}
}
return new ShiftTimes(
MorningStart: morningStart,
MorningEnd: morningEnd,
AfternoonStart: afternoonStart,
AfternoonEnd: afternoonEnd);
}
private ApplicationConfiguration LoadConfigurationFromFiles(ApplicationConfiguration fallbackConfiguration) private ApplicationConfiguration LoadConfigurationFromFiles(ApplicationConfiguration fallbackConfiguration)
{ {
try try

View file

@ -4,11 +4,11 @@ namespace ShiftScheduler.Services
{ {
public interface ITransportService public interface ITransportService
{ {
Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime); Task<TransportConnection?> GetConnectionAsync(DateTimeOffset shiftStartTime);
} }
public interface ITransportApiService public interface ITransportApiService
{ {
Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime); Task<TransportConnection?> GetConnectionAsync(DateTimeOffset shiftStartTime);
} }
} }

View file

@ -26,7 +26,7 @@ namespace ShiftScheduler.Services
var summary = $"{shift.Name} (Morning)"; var summary = $"{shift.Name} (Morning)";
var description = ""; var description = "";
if (shiftWithTransport.MorningTransport != null && !string.IsNullOrEmpty(shiftWithTransport.MorningTransport.DepartureTime)) if (shiftWithTransport.MorningTransport != null)
{ {
var transportSummary = FormatTransportInfo(shiftWithTransport.MorningTransport); var transportSummary = FormatTransportInfo(shiftWithTransport.MorningTransport);
description = $"Transport: {transportSummary}"; description = $"Transport: {transportSummary}";
@ -47,7 +47,7 @@ namespace ShiftScheduler.Services
var summary = $"{shift.Name} (Afternoon)"; var summary = $"{shift.Name} (Afternoon)";
var description = ""; var description = "";
if (shiftWithTransport.AfternoonTransport != null && !string.IsNullOrEmpty(shiftWithTransport.AfternoonTransport.DepartureTime)) if (shiftWithTransport.AfternoonTransport != null)
{ {
var transportSummary = FormatTransportInfo(shiftWithTransport.AfternoonTransport); var transportSummary = FormatTransportInfo(shiftWithTransport.AfternoonTransport);
description = $"Transport: {transportSummary}"; description = $"Transport: {transportSummary}";
@ -68,15 +68,8 @@ namespace ShiftScheduler.Services
private string FormatTransportInfo(TransportConnection transport) private string FormatTransportInfo(TransportConnection transport)
{ {
var departure = DateTime.TryParse(transport.DepartureTime, out var dep) ? dep.ToString("HH:mm") : transport.DepartureTime; var departure =transport.DepartureTime.ToString("HH:mm");
var arrival = DateTime.TryParse(transport.ArrivalTime, out var arr) ? arr.ToString("HH:mm") : transport.ArrivalTime; var arrival = transport.ArrivalTime.ToString("HH:mm");
var mainJourney = transport.Sections?.FirstOrDefault()?.Journey;
if (mainJourney != null)
{
return $"{mainJourney.Category} {mainJourney.Number}: {departure} → {arrival}";
}
return $"{departure} → {arrival}"; return $"{departure} → {arrival}";
} }
} }

View file

@ -176,13 +176,13 @@ namespace ShiftScheduler.Services
var transportLines = new List<string>(); var transportLines = new List<string>();
if (shiftWithTransport.MorningTransport != null && !string.IsNullOrEmpty(shiftWithTransport.MorningTransport.DepartureTime)) if (shiftWithTransport.MorningTransport != null)
{ {
var morningInfo = FormatTransportConnection(shiftWithTransport.MorningTransport); var morningInfo = FormatTransportConnection(shiftWithTransport.MorningTransport);
transportLines.Add(morningInfo); transportLines.Add(morningInfo);
} }
if (shiftWithTransport.AfternoonTransport != null && !string.IsNullOrEmpty(shiftWithTransport.AfternoonTransport.DepartureTime)) if (shiftWithTransport.AfternoonTransport != null)
{ {
var afternoonInfo = FormatTransportConnection(shiftWithTransport.AfternoonTransport); var afternoonInfo = FormatTransportConnection(shiftWithTransport.AfternoonTransport);
transportLines.Add(afternoonInfo); transportLines.Add(afternoonInfo);
@ -193,9 +193,8 @@ namespace ShiftScheduler.Services
private static string FormatTransportConnection(TransportConnection transport) private static string FormatTransportConnection(TransportConnection transport)
{ {
var departure = DateTime.TryParse(transport.DepartureTime, out var dep) ? dep.ToString("HH:mm") : transport.DepartureTime; var departure = transport.DepartureTime.ToString("HH:mm");
var arrival = DateTime.TryParse(transport.ArrivalTime, out var arr) ? arr.ToString("HH:mm") : transport.ArrivalTime; var arrival = transport.ArrivalTime.ToString("HH:mm");
return $"🚂 {departure}→{arrival}"; return $"🚂 {departure}→{arrival}";
} }

View file

@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ShiftScheduler.Shared; using ShiftScheduler.Shared;
@ -6,7 +7,7 @@ namespace ShiftScheduler.Services
{ {
public class TransportApiService(HttpClient httpClient, IConfigurationService configurationService, ILogger<TransportApiService> logger) : ITransportApiService public class TransportApiService(HttpClient httpClient, IConfigurationService configurationService, ILogger<TransportApiService> logger) : ITransportApiService
{ {
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime) public async Task<TransportConnection?> GetConnectionAsync(DateTimeOffset shiftStartTime)
{ {
var config = configurationService.GetTransportConfiguration(); var config = configurationService.GetTransportConfiguration();
@ -28,17 +29,11 @@ namespace ShiftScheduler.Services
foreach (var connection in allConnections) foreach (var connection in allConnections)
{ {
logger.LogInformation($"Found connection {connection}"); logger.LogDebug($"Found connection {connection}");
} }
var bestConnection = TransportConnectionCalculator.FindBestConnection( var bestConnection = TransportConnectionCalculator.FindBestConnection(
allConnections, allConnections, new ConnectionPickArgument(shiftStartTime, config.SafetyBufferMinutes, config.MaxEarlyArrivalMinutes, config.MaxLateArrivalMinutes), logger);
shiftStartTime,
config.SafetyBufferMinutes,
config.MaxEarlyArrivalMinutes,
config.MaxLateArrivalMinutes);
logger.LogInformation($"Found best {bestConnection}");
return bestConnection; return bestConnection;
} }
@ -51,43 +46,13 @@ namespace ShiftScheduler.Services
if (apiConnection == null) if (apiConnection == null)
return new TransportConnection(); return new TransportConnection();
var formats = new[] { @"dd'd'hh\:mm\:ss", @"d'd'hh\:mm\:ss" };
return new TransportConnection return new TransportConnection
{ {
DepartureTime = apiConnection.From?.Departure ?? string.Empty, DepartureTime = DateTimeOffset.Parse(apiConnection.From!.Departure),
ArrivalTime = apiConnection.To?.Arrival ?? string.Empty, ArrivalTime = DateTimeOffset.Parse(apiConnection.To!.Arrival),
Duration = apiConnection.Duration, Duration = TimeSpan.ParseExact(apiConnection.Duration, formats, CultureInfo.InvariantCulture),
Platform = apiConnection.From?.Platform ?? string.Empty, Platform = apiConnection.From?.Platform ?? string.Empty,
Sections = apiConnection.Sections.Select(s => new TransportSection
{
Journey = s.Journey != null ? new TransportJourney
{
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,
Id = s.Departure.Station.Id
} : null,
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,
Id = s.Arrival.Station.Id
} : null,
Departure = s.Arrival.Departure,
Arrival = s.Arrival.Arrival,
Platform = s.Arrival.Platform
} : null
}).ToList()
}; };
} }
} }

View file

@ -1,73 +1,65 @@
using Microsoft.Extensions.Logging;
using ShiftScheduler.Shared; using ShiftScheduler.Shared;
namespace ShiftScheduler.Services; namespace ShiftScheduler.Services;
public record ConnectionPickArgument(
DateTimeOffset ShiftStartTime,
int SafetyBufferMinutes,
int MaxEarlyArrivalMinutes,
int MaxLateArrivalMinutes)
{
public override string ToString()
{
return $"ShiftStartTime: {ShiftStartTime}, SafetyBufferMinutes: {SafetyBufferMinutes}, MaxEarlyArrivalMinutes{MaxEarlyArrivalMinutes}, MaxLateArrivalMinutes:{MaxLateArrivalMinutes}";
}
}
public static class TransportConnectionCalculator public static class TransportConnectionCalculator
{ {
public static TransportConnection? FindBestConnection( public static TransportConnection? FindBestConnection(
IReadOnlyList<TransportConnection> connections, IReadOnlyList<TransportConnection> connections, ConnectionPickArgument args, ILogger logger)
DateTime shiftStartTime,
int safetyBufferMinutes,
int maxEarlyArrivalMinutes,
int maxLateArrivalMinutes)
{ {
var latestArrivalTime = shiftStartTime.AddMinutes(-safetyBufferMinutes); var latestArrivalTime = args.ShiftStartTime.AddMinutes(-args.SafetyBufferMinutes);
var earliestAcceptableTime = shiftStartTime.AddMinutes(-maxEarlyArrivalMinutes); var earliestAcceptableTime = args.ShiftStartTime.AddMinutes(-args.MaxEarlyArrivalMinutes);
var latestAcceptableTime = shiftStartTime.AddMinutes(maxLateArrivalMinutes); var latestAcceptableTime = args.ShiftStartTime.AddMinutes(args.MaxLateArrivalMinutes);
logger.LogDebug(args.ToString());
logger.LogDebug($"latestArrivalTime: {latestArrivalTime}");
logger.LogDebug($"earliestAcceptableTime: {earliestAcceptableTime}");
logger.LogDebug($"latestAcceptableTime: {latestAcceptableTime}");
var validConnections = new List<TransportConnection>(); var validConnections = new List<TransportConnection>();
var lateValidConnections = new List<TransportConnection>(); var lateValidConnections = new List<TransportConnection>();
foreach (var connection in connections) foreach (var connection in connections.OrderBy(c => c.ArrivalTime))
{ {
if (DateTime.TryParse(connection.ArrivalTime, out var arrivalTime)) if (connection.ArrivalTime <= latestArrivalTime)
{ {
if (arrivalTime <= latestArrivalTime) validConnections.Add(connection);
{ }
validConnections.Add(connection); else if (connection.ArrivalTime <= latestAcceptableTime)
} {
else if (arrivalTime <= latestAcceptableTime) lateValidConnections.Add(connection);
{
lateValidConnections.Add(connection);
}
} }
} }
// If we have valid connections (arriving before latest arrival time) // If we have valid connections (arriving before latest arrival time)
if (validConnections.Count > 0) if (validConnections.Count > 0)
{ {
var sortedValid = validConnections var bestValidConnection = validConnections.Last();
.OrderBy(c => DateTime.Parse(c.ArrivalTime ?? "00:00")) if (bestValidConnection.ArrivalTime > earliestAcceptableTime)
.ToList();
var bestValidConnection = sortedValid.Last();
var bestValidArrivalTime = DateTime.Parse(bestValidConnection.ArrivalTime!);
// Check if the best valid connection arrives too early (more than maxEarlyArrivalMinutes before shift)
if (bestValidArrivalTime < earliestAcceptableTime && lateValidConnections.Count > 0)
{ {
// Return the earliest connection that arrives after latest arrival time but within acceptable range return bestValidConnection;
var sortedLateValid = lateValidConnections
.OrderBy(c => DateTime.Parse(c.ArrivalTime ?? "23:59"))
.ToList();
return sortedLateValid.First();
} }
return bestValidConnection;
} }
// If no connections arrive before latest arrival time, check if any arrive within acceptable late range // If no connections arrive before latest arrival time, check if any arrive within acceptable late range
if (lateValidConnections.Count > 0) if (lateValidConnections.Count > 0)
{ {
var sortedLateValid = lateValidConnections return lateValidConnections.First();
.OrderBy(c => DateTime.Parse(c.ArrivalTime ?? "23:59"))
.ToList();
return sortedLateValid.First();
} }
// No suitable connections found return validConnections.FirstOrDefault();
return null;
} }
} }

View file

@ -5,7 +5,7 @@ namespace ShiftScheduler.Services
{ {
public class TransportService(ITransportApiService transportService, IConfigurationService configurationService, IMemoryCache cache) : ITransportService public class TransportService(ITransportApiService transportService, IConfigurationService configurationService, IMemoryCache cache) : ITransportService
{ {
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime) public async Task<TransportConnection?> GetConnectionAsync(DateTimeOffset shiftStartTime)
{ {
var config = configurationService.GetTransportConfiguration(); var config = configurationService.GetTransportConfiguration();
var searchDate = shiftStartTime.ToString("yyyy-MM-dd"); var searchDate = shiftStartTime.ToString("yyyy-MM-dd");

View file

@ -1,6 +1,5 @@
namespace ShiftScheduler.Shared namespace ShiftScheduler.Shared
{ {
// View model that combines shift information with transport data for a specific date
public record ShiftWithTransport public record ShiftWithTransport
{ {
public DateTime Date { get; init; } public DateTime Date { get; init; }
@ -20,15 +19,9 @@ namespace ShiftScheduler.Shared
private string FormatTransportSummary(TransportConnection connection) private string FormatTransportSummary(TransportConnection connection)
{ {
if (string.IsNullOrEmpty(connection.DepartureTime)) return string.Empty; var departure = connection.DepartureTime.ToString("HH:mm");
var arrival = connection.ArrivalTime.ToString("HH:mm");
var departure = DateTime.TryParse(connection.DepartureTime, out var dep) ? dep.ToString("HH:mm") : connection.DepartureTime; return $"{departure}→{arrival}";
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}";
} }
} }
} }

View file

@ -14,43 +14,23 @@ namespace ShiftScheduler.Shared
public class TransportConnection public class TransportConnection
{ {
public string DepartureTime { get; set; } = string.Empty; public TransportConnection()
public string ArrivalTime { get; set; } = string.Empty; {
public string Duration { get; set; } = string.Empty; }
public TransportConnection(string arrivalTime)
{
ArrivalTime = DateTimeOffset.Parse(arrivalTime);
}
public DateTimeOffset DepartureTime { get; set; }
public DateTimeOffset ArrivalTime { get; set; }
public TimeSpan Duration { get; set; }
public string? Platform { get; set; } = string.Empty; public string? Platform { get; set; } = string.Empty;
public List<TransportSection> Sections { get; set; } = new();
public override string ToString() public override string ToString()
{ {
return $"{Platform}: {DepartureTime} - {ArrivalTime} ({Duration})"; return $"{Platform}: {DepartureTime} - {ArrivalTime} ({Duration})";
} }
} }
public class TransportSection
{
public TransportJourney? Journey { get; set; }
public TransportCheckpoint? Departure { get; set; }
public TransportCheckpoint? Arrival { get; set; }
}
public class TransportJourney
{
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Number { get; set; } = string.Empty;
}
public class TransportCheckpoint
{
public TransportStation? 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 class TransportStation
{
public string Name { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
}
} }

View file

@ -0,0 +1,47 @@
using ShiftScheduler.Shared;
using Shouldly;
namespace ShiftScheduler.Services.Tests;
public class ConfigurationServiceTests
{
[Fact]
public void ParseShiftTimes_WithMorningAndAfternoon_ShouldReturnValid()
{
var applicationConfig = new ApplicationConfiguration();
var configurationService = new ConfigurationService(applicationConfig);
var shift = new Shift
{
MorningTime = "06:00-12:30",
AfternoonTime = "13:00-17:30"
};
var shiftTimes = configurationService.ParseShiftTimes(DateTime.Parse("2025-07-13T13:00:00"), shift);
shiftTimes.MorningStart.ShouldBe(T("2025-07-13T06:00:00+02:00"));
shiftTimes.MorningEnd.ShouldBe(T("2025-07-13T12:30:00+02:00"));
shiftTimes.AfternoonStart.ShouldBe(T("2025-07-13T13:00:00+02:00"));
shiftTimes.AfternoonEnd.ShouldBe(T("2025-07-13T17:30:00+02:00"));
}
[Fact]
public void ParseShiftTimes_WithMorning_ShouldReturnValid()
{
var applicationConfig = new ApplicationConfiguration();
var configurationService = new ConfigurationService(applicationConfig);
var shift = new Shift
{
MorningTime = "06:00-12:30"
};
var shiftTimes = configurationService.ParseShiftTimes(DateTime.Parse("2025-07-13T13:00:00"), shift);
shiftTimes.MorningStart.ShouldBe(T("2025-07-13T06:00:00+02:00"));
shiftTimes.MorningEnd.ShouldBe(T("2025-07-13T12:30:00+02:00"));
shiftTimes.AfternoonStart.ShouldBeNull();
shiftTimes.AfternoonEnd.ShouldBeNull();
}
private DateTimeOffset T(string dateTimeString)
{
return DateTimeOffset.Parse(dateTimeString);
}
}

View file

@ -58,17 +58,10 @@ public class TransportApiServiceTests
// Assert // Assert
result.ShouldNotBeNull(); result.ShouldNotBeNull();
result.DepartureTime.ShouldBe("2023-12-15T06:45:00"); result.DepartureTime.ShouldBe(T("2023-12-15T06:45:00"));
result.ArrivalTime.ShouldBe("2023-12-15T07:30:00"); result.ArrivalTime.ShouldBe(T("2023-12-15T07:30:00"));
result.Duration.ShouldBe("00:45:00"); result.Duration.ShouldBe(TimeSpan.Parse("00:45:00"));
result.Platform.ShouldBe("5"); result.Platform.ShouldBe("5");
result.Sections.ShouldNotBeEmpty();
result.Sections.Count.ShouldBe(1);
var section = result.Sections.First();
section.Journey?.Name.ShouldBe("IC 1");
section.Journey?.Category.ShouldBe("IC");
section.Journey?.Number.ShouldBe("1");
} }
[Fact] [Fact]
@ -122,7 +115,7 @@ public class TransportApiServiceTests
// Latest acceptable arrival: 8:00 - 30 min = 7:30 // Latest acceptable arrival: 8:00 - 30 min = 7:30
// Valid connections: 07:15 and 07:25 (both arrive before 7:30) // Valid connections: 07:15 and 07:25 (both arrive before 7:30)
// Algorithm should return the latest valid: 07:25 // Algorithm should return the latest valid: 07:25
result.ArrivalTime.ShouldBe("2023-12-15T07:25:00"); result.ArrivalTime.ShouldBe(T("2023-12-15T07:25:00"));
} }
private void SetupHttpMockResponse(HttpStatusCode statusCode, string content) private void SetupHttpMockResponse(HttpStatusCode statusCode, string content)
@ -158,7 +151,7 @@ public class TransportApiServiceTests
Arrival = "2023-12-15T07:30:00", Arrival = "2023-12-15T07:30:00",
Platform = "3" Platform = "3"
}, },
Duration = "00:45:00", Duration = "00d00:45:00",
Sections = new List<TransportApiSection> Sections = new List<TransportApiSection>
{ {
new TransportApiSection new TransportApiSection
@ -209,7 +202,7 @@ public class TransportApiServiceTests
Arrival = "2023-12-15T07:15:00", Arrival = "2023-12-15T07:15:00",
Platform = "3" Platform = "3"
}, },
Duration = "00:45:00" Duration = "00d00:45:00"
}, },
// Later valid connection - should be selected as best // Later valid connection - should be selected as best
new TransportApiConnection new TransportApiConnection
@ -226,7 +219,7 @@ public class TransportApiServiceTests
Arrival = "2023-12-15T07:25:00", Arrival = "2023-12-15T07:25:00",
Platform = "2" Platform = "2"
}, },
Duration = "00:45:00" Duration = "00d00:45:00"
}, },
// Too late connection - invalid (arrives after 07:30 which is shift start - 30 min buffer) // Too late connection - invalid (arrives after 07:30 which is shift start - 30 min buffer)
new TransportApiConnection new TransportApiConnection
@ -243,9 +236,14 @@ public class TransportApiServiceTests
Arrival = "2023-12-15T07:35:00", Arrival = "2023-12-15T07:35:00",
Platform = "1" Platform = "1"
}, },
Duration = "00:45:00" Duration = "00d00:45:00"
} }
} }
}; };
} }
private DateTimeOffset T(string dateTimeString)
{
return DateTimeOffset.Parse(dateTimeString);
}
} }

View file

@ -1,4 +1,5 @@
using ShiftScheduler.Services; using Microsoft.Extensions.Logging;
using Moq;
using ShiftScheduler.Shared; using ShiftScheduler.Shared;
using Shouldly; using Shouldly;
@ -6,26 +7,29 @@ namespace ShiftScheduler.Services.Tests;
public class TransportConnectionCalculatorTests public class TransportConnectionCalculatorTests
{ {
// Tests for new enhanced logic private readonly Mock<ILogger> loggerMock = new();
[Fact] [Fact]
public void FindBestConnectionEnhanced_WithGoodTimingConnection_ShouldReturnLatestValidConnection() public void FindBestConnectionEnhanced_WithGoodTimingConnection_ShouldReturnLatestValidConnection()
{ {
// Arrange - Shift starts at 8:00, safety buffer 30 min, so latest arrival is 7:30 // Arrange - Shift starts at 8:00, safety buffer 30 min, so latest arrival is 7:30
var connections = new List<TransportConnection> var connections = new List<TransportConnection>
{ {
new TransportConnection { ArrivalTime = "2023-12-15T07:15:00" }, new(arrivalTime: "2023-12-15T07:15:00"),
new TransportConnection { ArrivalTime = "2023-12-15T07:25:00" }, // Should be selected (latest valid) new(arrivalTime: "2023-12-15T07:25:00"), // Should be selected (latest valid)
new TransportConnection { ArrivalTime = "2023-12-15T08:05:00" } // Too late new(arrivalTime: "2023-12-15T08:05:00") // Too late
}; };
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); var shiftStartTime = T("2023-12-15T08:00:00");
// Act // Act
var result = TransportConnectionCalculator.FindBestConnection( var result = TransportConnectionCalculator.FindBestConnection(
connections, shiftStartTime, 30, 60, 15); connections,
new ConnectionPickArgument(shiftStartTime, 30, 60, 15),
loggerMock.Object);
// Assert // Assert
result.ShouldNotBeNull(); result.ShouldNotBeNull();
result.ArrivalTime.ShouldBe("2023-12-15T07:25:00"); result.ArrivalTime.ShouldBe(T("2023-12-15T07:25:00"));
} }
[Fact] [Fact]
@ -34,18 +38,20 @@ public class TransportConnectionCalculatorTests
// Arrange - Shift starts at 8:00, max early arrival 60 min, so earliest acceptable is 7:00 // Arrange - Shift starts at 8:00, max early arrival 60 min, so earliest acceptable is 7:00
var connections = new List<TransportConnection> var connections = new List<TransportConnection>
{ {
new TransportConnection { ArrivalTime = "2023-12-15T06:45:00" }, // Too early (more than 60 min before 8:00) new(arrivalTime: "2023-12-15T06:45:00"), // Too early (more than 60 min before 8:00)
new TransportConnection { ArrivalTime = "2023-12-15T08:05:00" } // Within acceptable late range (15 min after 8:00) new(arrivalTime: "2023-12-15T08:05:00") // Within acceptable late range (15 min after 8:00)
}; };
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); var shiftStartTime = T("2023-12-15T08:00:00");
// Act // Act
var result = TransportConnectionCalculator.FindBestConnection( var result = TransportConnectionCalculator.FindBestConnection(
connections, shiftStartTime, 30, 60, 15); connections,
new ConnectionPickArgument(shiftStartTime, 30, 60, 15),
loggerMock.Object);
// Assert // Assert
result.ShouldNotBeNull(); result.ShouldNotBeNull();
result.ArrivalTime.ShouldBe("2023-12-15T08:05:00"); result.ArrivalTime.ShouldBe(T("2023-12-15T08:05:00"));
} }
[Fact] [Fact]
@ -54,19 +60,21 @@ public class TransportConnectionCalculatorTests
// Arrange - Shift starts at 8:00, no valid early connections // Arrange - Shift starts at 8:00, no valid early connections
var connections = new List<TransportConnection> var connections = new List<TransportConnection>
{ {
new TransportConnection { ArrivalTime = "2023-12-15T08:10:00" }, new(arrivalTime: "2023-12-15T08:10:00"),
new TransportConnection { ArrivalTime = "2023-12-15T08:05:00" }, // Should be selected (earliest late) new(arrivalTime: "2023-12-15T08:05:00"), // Should be selected (earliest late)
new TransportConnection { ArrivalTime = "2023-12-15T08:12:00" } new(arrivalTime: "2023-12-15T08:12:00")
}; };
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); var shiftStartTime = T("2023-12-15T08:00:00");
// Act // Act
var result = TransportConnectionCalculator.FindBestConnection( var result = TransportConnectionCalculator.FindBestConnection(
connections, shiftStartTime, 30, 60, 15); connections,
new ConnectionPickArgument(shiftStartTime, 30, 60, 15),
loggerMock.Object);
// Assert // Assert
result.ShouldNotBeNull(); result.ShouldNotBeNull();
result.ArrivalTime.ShouldBe("2023-12-15T08:05:00"); result.ArrivalTime.ShouldBe(T("2023-12-15T08:05:00"));
} }
[Fact] [Fact]
@ -75,15 +83,17 @@ public class TransportConnectionCalculatorTests
// Arrange - All connections are too late (beyond max late arrival) // Arrange - All connections are too late (beyond max late arrival)
var connections = new List<TransportConnection> var connections = new List<TransportConnection>
{ {
new TransportConnection { ArrivalTime = "2023-12-15T08:20:00" }, new(arrivalTime: "2023-12-15T08:20:00"),
new TransportConnection { ArrivalTime = "2023-12-15T08:25:00" }, new(arrivalTime: "2023-12-15T08:25:00"),
new TransportConnection { ArrivalTime = "2023-12-15T08:30:00" } new(arrivalTime: "2023-12-15T08:30:00")
}; };
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); var shiftStartTime = T("2023-12-15T08:00:00");
// Act // Act
var result = TransportConnectionCalculator.FindBestConnection( var result = TransportConnectionCalculator.FindBestConnection(
connections, shiftStartTime, 30, 60, 15); connections,
new ConnectionPickArgument(shiftStartTime, 30, 60, 15),
loggerMock.Object);
// Assert // Assert
result.ShouldBeNull(); result.ShouldBeNull();
@ -95,30 +105,33 @@ public class TransportConnectionCalculatorTests
// Arrange - Early connection is acceptable (not too early), should prefer it over late // Arrange - Early connection is acceptable (not too early), should prefer it over late
var connections = new List<TransportConnection> var connections = new List<TransportConnection>
{ {
new TransportConnection { ArrivalTime = "2023-12-15T07:20:00" }, // 40 min before shift - acceptable new(arrivalTime: "2023-12-15T07:20:00"), // 40 min before shift - acceptable
new TransportConnection { ArrivalTime = "2023-12-15T08:05:00" } // 5 min after shift new(arrivalTime: "2023-12-15T08:05:00") // 5 min after shift
}; };
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); var shiftStartTime = T("2023-12-15T08:00:00");
// Act // Act
var result = TransportConnectionCalculator.FindBestConnection( var result = TransportConnectionCalculator.FindBestConnection(
connections, shiftStartTime, 30, 60, 15); connections,
new ConnectionPickArgument(shiftStartTime, 30, 60, 15),
loggerMock.Object);
// Assert // Assert
result.ShouldNotBeNull(); result.ShouldNotBeNull();
result.ArrivalTime.ShouldBe("2023-12-15T07:20:00"); result.ArrivalTime.ShouldBe(T("2023-12-15T07:20:00"));
} }
[Fact] [Fact]
public void FindBestConnectionEnhanced_WithEmptyConnections_ShouldReturnNull() public void FindBestConnectionEnhanced_WithEmptyConnections_ShouldReturnNull()
{ {
// Arrange // Arrange
var connections = new List<TransportConnection>(); var shiftStartTime = T("2023-12-15T08:00:00");
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
// Act // Act
var result = TransportConnectionCalculator.FindBestConnection( var result = TransportConnectionCalculator.FindBestConnection(
connections, shiftStartTime, 30, 60, 15); new List<TransportConnection>(),
new ConnectionPickArgument(shiftStartTime, 30, 60, 15),
loggerMock.Object);
// Assert // Assert
result.ShouldBeNull(); result.ShouldBeNull();
@ -130,19 +143,48 @@ public class TransportConnectionCalculatorTests
// Arrange - Test scenario where both valid and late connections exist // Arrange - Test scenario where both valid and late connections exist
var connections = new List<TransportConnection> var connections = new List<TransportConnection>
{ {
new TransportConnection { ArrivalTime = "2023-12-15T06:30:00" }, // Too early (90 min before shift) new(arrivalTime: "2023-12-15T06:30:00"), // Too early (90 min before shift)
new TransportConnection { ArrivalTime = "2023-12-15T07:25:00" }, // Valid but would be considered too early new(arrivalTime: "2023-12-15T07:25:00"), // Valid but would be considered too early
new TransportConnection { ArrivalTime = "2023-12-15T08:05:00" }, // Late but acceptable new(arrivalTime: "2023-12-15T08:05:00"), // Late but acceptable
new TransportConnection { ArrivalTime = "2023-12-15T08:20:00" } // Too late new(arrivalTime: "2023-12-15T08:20:00") // Too late
}; };
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); var shiftStartTime = T("2023-12-15T08:00:00");
// Act - MaxEarlyArrivalMinutes = 30, so earliest acceptable is 7:30 // Act - MaxEarlyArrivalMinutes = 30, so earliest acceptable is 7:30
var result = TransportConnectionCalculator.FindBestConnection( var result = TransportConnectionCalculator.FindBestConnection(
connections, shiftStartTime, 30, 30, 15); connections,
new ConnectionPickArgument(shiftStartTime, 30, 30, 15),
loggerMock.Object);
// Assert - 7:25 arrives at 7:25, which is 35 min before shift (more than 30 min), so should prefer late connection // Assert - 7:25 arrives at 7:25, which is 35 min before shift (more than 30 min), so should prefer late connection
result.ShouldNotBeNull(); result.ShouldNotBeNull();
result.ArrivalTime.ShouldBe("2023-12-15T08:05:00"); result.ArrivalTime.ShouldBe(T("2023-12-15T08:05:00"));
}
[Fact]
public void FindBestConnectionEnhanced_WithMyCase_ShouldChooseBasedOnTiming()
{
var connections = new List<TransportConnection>
{
new(arrivalTime: "2025-09-01T11:41:00"),
new(arrivalTime: "2025-09-01T12:41:00"),
new(arrivalTime: "2025-09-01T13:41:00"),
new(arrivalTime: "2025-09-01T14:41:00"),
new(arrivalTime: "2025-09-01T15:41:00")
};
var shiftStartTime = T("2025-09-01T15:30:00");
var result = TransportConnectionCalculator.FindBestConnection(
connections,
new ConnectionPickArgument(shiftStartTime, 10, 60, 15),
loggerMock.Object);
result.ShouldNotBeNull();
result.ArrivalTime.ShouldBe(T("2025-09-01T14:41:00"));
}
private DateTimeOffset T(string dateTimeString)
{
return DateTimeOffset.Parse(dateTimeString);
} }
} }

View file

@ -46,9 +46,9 @@ public class TransportServiceTests
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var connection = new TransportConnection var connection = new TransportConnection
{ {
DepartureTime = "2023-12-15T06:45:00", DepartureTime = T("2023-12-15T06:45:00"),
ArrivalTime = "2023-12-15T07:30:00", ArrivalTime = T("2023-12-15T07:30:00"),
Duration = "00:45:00", Duration = TimeSpan.Parse("00:45:00"),
Platform = "5" Platform = "5"
}; };
@ -100,11 +100,11 @@ public class TransportServiceTests
public async Task GetConnectionAsync_WithDifferentDates_ShouldCreateSeparateCacheEntries() public async Task GetConnectionAsync_WithDifferentDates_ShouldCreateSeparateCacheEntries()
{ {
// Arrange // Arrange
var shiftStartTime1 = new DateTime(2023, 12, 15, 8, 0, 0); var shiftStartTime1 = T("2023-12-15T08:00:00");
var shiftStartTime2 = new DateTime(2023, 12, 16, 8, 0, 0); var shiftStartTime2 = T("2023-12-16T08:00:00");
var connection1 = new TransportConnection { ArrivalTime = "2023-12-15T07:30:00" }; var connection1 = new TransportConnection { ArrivalTime = T("2023-12-15T07:30:00") };
var connection2 = new TransportConnection { ArrivalTime = "2023-12-16T07:30:00" }; var connection2 = new TransportConnection { ArrivalTime = T("2023-12-16T07:30:00") };
_transportServiceMock _transportServiceMock
.Setup(x => x.GetConnectionAsync(shiftStartTime1)) .Setup(x => x.GetConnectionAsync(shiftStartTime1))
@ -121,10 +121,15 @@ public class TransportServiceTests
// Assert // Assert
result1.ShouldNotBeNull(); result1.ShouldNotBeNull();
result2.ShouldNotBeNull(); result2.ShouldNotBeNull();
result1.ArrivalTime.ShouldBe("2023-12-15T07:30:00"); result1.ArrivalTime.ShouldBe(T("2023-12-15T07:30:00"));
result2.ArrivalTime.ShouldBe("2023-12-16T07:30:00"); result2.ArrivalTime.ShouldBe(T("2023-12-16T07:30:00"));
// Verify that transport service was called twice (different cache keys) // Verify that transport service was called twice (different cache keys)
_transportServiceMock.Verify(x => x.GetConnectionAsync(It.IsAny<DateTime>()), Times.Exactly(2)); _transportServiceMock.Verify(x => x.GetConnectionAsync(It.IsAny<DateTimeOffset>()), Times.Exactly(2));
}
private DateTimeOffset T(string dateTimeString)
{
return DateTimeOffset.Parse(dateTimeString);
} }
} }