diff --git a/.gitignore b/.gitignore
index c60101d..c629877 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,4 +55,7 @@ nunit-*.xml
# Temporary installation files
*.deb
-*.rpm
\ No newline at end of file
+*.rpm
+
+# mac files
+.DS_Store
diff --git a/Client/Pages/Index.razor b/Client/Pages/Index.razor
index fa4ad60..b444288 100644
--- a/Client/Pages/Index.razor
+++ b/Client/Pages/Index.razor
@@ -14,13 +14,27 @@
{
@shift.Name
}
+ @if (SelectedSchedule.ContainsKey(day))
+ {
+
+ @GetTransportSummary(day)
+
+ }
}
diff --git a/Client/Pages/Index.razor.cs b/Client/Pages/Index.razor.cs
index ea90886..8b95a79 100644
--- a/Client/Pages/Index.razor.cs
+++ b/Client/Pages/Index.razor.cs
@@ -15,8 +15,10 @@ namespace ShiftScheduler.Client.Pages
private List DaysInMonth { get; set; } = new();
private List Shifts { get; set; } = new();
private Dictionary SelectedSchedule { get; set; } = new();
+ private Dictionary SelectedShiftsWithTransport { get; set; } = new();
private int EditMonth { get; set; }
private int EditYear { get; set; }
+ private bool _isLoadingTransport = false;
protected override async Task OnInitializedAsync()
{
@@ -42,12 +44,54 @@ namespace ShiftScheduler.Client.Pages
private async Task SelectShift(DateTime day, string shiftName)
{
SelectedSchedule[day] = shiftName;
+
+ // Get transport information for this shift and date
+ _isLoadingTransport = true;
+ StateHasChanged();
+
+ try
+ {
+ var request = new { ShiftName = shiftName, Date = day };
+ var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var shiftWithTransport = await response.Content.ReadFromJsonAsync();
+ if (shiftWithTransport != null)
+ {
+ SelectedShiftsWithTransport[day] = shiftWithTransport;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error fetching transport data: {ex.Message}");
+ // Create a fallback with just the shift data
+ var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
+ if (shift != null)
+ {
+ SelectedShiftsWithTransport[day] = new ShiftWithTransport
+ {
+ Date = day,
+ Shift = shift,
+ MorningTransport = null,
+ AfternoonTransport = null
+ };
+ }
+ }
+ finally
+ {
+ _isLoadingTransport = false;
+ StateHasChanged();
+ }
+
await SaveScheduleToStorage();
}
private async Task ExportToIcs()
{
- var response = await HttpClient.PostAsJsonAsync("api/shift/export_ics", SelectedSchedule);
+ var shiftsWithTransportList = SelectedShiftsWithTransport.Values.ToList();
+ var response = await HttpClient.PostAsJsonAsync("api/shift/export_ics", shiftsWithTransportList);
var bytes = await response.Content.ReadAsByteArrayAsync();
var base64 = Convert.ToBase64String(bytes);
var fileUrl = $"data:text/calendar;base64,{base64}";
@@ -56,7 +100,8 @@ namespace ShiftScheduler.Client.Pages
private async Task ExportToPdf()
{
- var response = await HttpClient.PostAsJsonAsync("api/shift/export_pdf", SelectedSchedule);
+ var shiftsWithTransportList = SelectedShiftsWithTransport.Values.ToList();
+ var response = await HttpClient.PostAsJsonAsync("api/shift/export_pdf", shiftsWithTransportList);
var bytes = await response.Content.ReadAsByteArrayAsync();
var base64 = Convert.ToBase64String(bytes);
var fileUrl = $"data:application/octet-stream;base64,{base64}";
@@ -99,18 +144,63 @@ namespace ShiftScheduler.Client.Pages
kvp => DateTime.Parse(kvp.Key),
kvp => kvp.Value
);
+
+ // Reload transport data for all selected shifts
+ await ReloadTransportDataForSchedule();
}
}
}
catch (Exception)
{
SelectedSchedule = new Dictionary();
+ SelectedShiftsWithTransport = new Dictionary();
+ }
+ }
+
+ private async Task ReloadTransportDataForSchedule()
+ {
+ foreach (var kvp in SelectedSchedule)
+ {
+ var day = kvp.Key;
+ var shiftName = kvp.Value;
+
+ try
+ {
+ var request = new { ShiftName = shiftName, Date = day };
+ var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var shiftWithTransport = await response.Content.ReadFromJsonAsync();
+ if (shiftWithTransport != null)
+ {
+ SelectedShiftsWithTransport[day] = shiftWithTransport;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}");
+ // Create fallback without transport
+ var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
+ if (shift != null)
+ {
+ SelectedShiftsWithTransport[day] = new ShiftWithTransport
+ {
+ Date = day,
+ Shift = shift,
+ MorningTransport = null,
+ AfternoonTransport = null
+ };
+ }
+ }
}
}
private async Task ResetSchedule()
{
SelectedSchedule.Clear();
+ SelectedShiftsWithTransport.Clear();
try
{
@@ -122,5 +212,24 @@ namespace ShiftScheduler.Client.Pages
// ignored
}
}
+
+ // Helper method to get transport summary for display
+ public string GetTransportSummary(DateTime date)
+ {
+ if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport))
+ return string.Empty;
+
+ var summaries = new List();
+
+ var morningTransport = shiftWithTransport.GetMorningTransportSummary();
+ if (!string.IsNullOrEmpty(morningTransport))
+ summaries.Add($"π
{morningTransport}");
+
+ var afternoonTransport = shiftWithTransport.GetAfternoonTransportSummary();
+ if (!string.IsNullOrEmpty(afternoonTransport))
+ summaries.Add($"π {afternoonTransport}");
+
+ return string.Join(" | ", summaries);
+ }
}
}
diff --git a/Client/Pages/Index.razor.css b/Client/Pages/Index.razor.css
index c823f7e..1a04a16 100644
--- a/Client/Pages/Index.razor.css
+++ b/Client/Pages/Index.razor.css
@@ -141,3 +141,25 @@
visibility: visible;
opacity: 1;
}
+
+/* Transport information styling */
+.transport-info {
+ margin-top: 8px;
+ padding: 4px;
+ background-color: #e8f4fd;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ color: #2c5aa0;
+ word-wrap: break-word;
+ min-height: 20px;
+}
+
+/* Loading spinner */
+.loading-spinner {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
diff --git a/Server/Controllers/ShiftController.cs b/Server/Controllers/ShiftController.cs
index 585bf6b..5c8ca09 100644
--- a/Server/Controllers/ShiftController.cs
+++ b/Server/Controllers/ShiftController.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using ShiftScheduler.Services;
+using ShiftScheduler.Shared.Models;
namespace ShiftScheduler.Server.Controllers
{
@@ -10,12 +11,21 @@ namespace ShiftScheduler.Server.Controllers
private readonly ShiftService _shiftService;
private readonly IcsExportService _icsService;
private readonly PdfExportService _pdfExportService;
+ private readonly TransportService _transportService;
+ private readonly ShiftEnrichmentService _enrichmentService;
- public ShiftController(ShiftService shiftService, IcsExportService icsService, PdfExportService pdfExportService)
+ public ShiftController(
+ ShiftService shiftService,
+ IcsExportService icsService,
+ PdfExportService pdfExportService,
+ TransportService transportService,
+ ShiftEnrichmentService enrichmentService)
{
_shiftService = shiftService;
_icsService = icsService;
_pdfExportService = pdfExportService;
+ _transportService = transportService;
+ _enrichmentService = enrichmentService;
}
[HttpGet("shifts")]
@@ -24,18 +34,97 @@ namespace ShiftScheduler.Server.Controllers
return Ok(_shiftService.GetShifts());
}
- [HttpPost("export_ics")]
- public IActionResult ExportIcs([FromBody] Dictionary schedule)
+ [HttpPost("transport_connection")]
+ public async Task GetTransportConnection([FromBody] TransportConnectionRequest request)
{
- var ics = _icsService.GenerateIcs(schedule, _shiftService.GetShifts());
+ var connection = await _transportService.GetConnectionAsync(request.ArrivalTime, request.EndStation);
+ return Ok(connection);
+ }
+
+ [HttpPost("shift_transport")]
+ public async Task GetShiftTransport([FromBody] ShiftTransportRequest request)
+ {
+ var shift = _shiftService.GetShifts().FirstOrDefault(s => s.Name == request.ShiftName);
+ if (shift == null)
+ {
+ return NotFound($"Shift '{request.ShiftName}' not found");
+ }
+
+ TransportConnection? morningTransport = null;
+ TransportConnection? afternoonTransport = null;
+
+ // Get transport for morning shift if it has morning time
+ if (!string.IsNullOrEmpty(shift.MorningTime))
+ {
+ var morningStartTime = ParseShiftTime(request.Date, shift.MorningTime);
+ if (morningStartTime.HasValue)
+ {
+ morningTransport = await _transportService.GetConnectionAsync(morningStartTime.Value);
+ }
+ }
+
+ // Get transport for afternoon shift if it has afternoon time
+ if (!string.IsNullOrEmpty(shift.AfternoonTime))
+ {
+ var afternoonStartTime = ParseShiftTime(request.Date, shift.AfternoonTime);
+ if (afternoonStartTime.HasValue)
+ {
+ afternoonTransport = await _transportService.GetConnectionAsync(afternoonStartTime.Value);
+ }
+ }
+
+ var shiftWithTransport = new ShiftWithTransport
+ {
+ Date = request.Date,
+ Shift = shift,
+ MorningTransport = morningTransport,
+ AfternoonTransport = afternoonTransport
+ };
+
+ 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;
+ }
+
+ [HttpPost("export_ics")]
+ public IActionResult ExportIcsWithTransport([FromBody] List shiftsWithTransport)
+ {
+ var ics = _icsService.GenerateIcs(shiftsWithTransport);
return File(System.Text.Encoding.UTF8.GetBytes(ics), "text/calendar", "schedule.ics");
}
- [HttpPost("export_pdf")]
- public IActionResult ExportPdf([FromBody] Dictionary schedule)
+ [HttpPost("export_pdf")]
+ public IActionResult ExportPdfWithTransport([FromBody] List shiftsWithTransport)
{
- var pdf = _pdfExportService.GenerateMonthlySchedulePdf(schedule, _shiftService.GetShifts());
+ var pdf = _pdfExportService.GenerateMonthlySchedulePdf(shiftsWithTransport);
return File(pdf, "application/pdf", "schedule.pdf");
}
}
+
+ public class TransportConnectionRequest
+ {
+ public DateTime ArrivalTime { get; set; }
+ public string? EndStation { get; set; }
+ }
+
+ public class ShiftTransportRequest
+ {
+ public string ShiftName { get; set; } = string.Empty;
+ public DateTime Date { get; set; }
+ }
}
diff --git a/Server/Program.cs b/Server/Program.cs
index 2b5cf73..715efa4 100644
--- a/Server/Program.cs
+++ b/Server/Program.cs
@@ -3,13 +3,19 @@ using ShiftScheduler.Services;
var builder = WebApplication.CreateBuilder(args);
-// Load shifts from appsettings.json
+// Load configurations from appsettings.json
var shifts = builder.Configuration.GetSection("Shifts").Get>() ?? new();
+var transportConfig = builder.Configuration.GetSection("Transport").Get() ?? new();
+// Register services
builder.Services.AddSingleton(shifts);
+builder.Services.AddSingleton(transportConfig);
+builder.Services.AddHttpClient();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
diff --git a/Server/appsettings.json b/Server/appsettings.json
index 175b91d..4f52399 100644
--- a/Server/appsettings.json
+++ b/Server/appsettings.json
@@ -7,6 +7,13 @@
},
"AllowedHosts": "*",
+ "Transport": {
+ "StartStation": "Zurich",
+ "EndStation": "Basel",
+ "ApiBaseUrl": "http://transport.opendata.ch/v1",
+ "SafetyBufferMinutes": 30
+ },
+
"Shifts": [
{
"Name": "Frei",
@@ -19,6 +26,24 @@
"Icon": "π΄",
"MorningTime": "",
"AfternoonTime": ""
+ },
+ {
+ "Name": "FrΓΌh",
+ "Icon": "π
",
+ "MorningTime": "06:00-14:00",
+ "AfternoonTime": ""
+ },
+ {
+ "Name": "SpΓ€t",
+ "Icon": "π",
+ "MorningTime": "",
+ "AfternoonTime": "14:00-22:00"
+ },
+ {
+ "Name": "Tag",
+ "Icon": "βοΈ",
+ "MorningTime": "08:00-12:00",
+ "AfternoonTime": "13:00-17:00"
}
]
}
diff --git a/Services/IcsExportService.cs b/Services/IcsExportService.cs
index 982c344..98217f9 100644
--- a/Services/IcsExportService.cs
+++ b/Services/IcsExportService.cs
@@ -8,25 +8,34 @@ namespace ShiftScheduler.Services
{
public class IcsExportService
{
- public string GenerateIcs(Dictionary schedule, List shifts)
+ public string GenerateIcs(List shiftsWithTransport)
{
var calendar = new Calendar();
- foreach (var kvp in schedule)
+ foreach (var shiftWithTransport in shiftsWithTransport)
{
- var date = kvp.Key;
- var shiftName = kvp.Value;
- var shift = shifts.FirstOrDefault(s => s.Name == shiftName);
+ var date = shiftWithTransport.Date;
+ var shift = shiftWithTransport.Shift;
- if (shift == null || (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime)))
+ if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))
continue;
if (!string.IsNullOrEmpty(shift.MorningTime))
{
var times = shift.MorningTime.Split('-');
+ var summary = $"{shift.Name} (Morning)";
+ var description = "";
+
+ if (shiftWithTransport.MorningTransport != null && !string.IsNullOrEmpty(shiftWithTransport.MorningTransport.DepartureTime))
+ {
+ var transportSummary = FormatTransportInfo(shiftWithTransport.MorningTransport);
+ description = $"Transport: {transportSummary}";
+ }
+
calendar.Events.Add(new CalendarEvent
{
- Summary = $"{shift.Name} (Morning)",
+ Summary = summary,
+ Description = description,
Start = new CalDateTime(date.Add(TimeSpan.Parse(times[0]))),
End = new CalDateTime(date.Add(TimeSpan.Parse(times[1])))
});
@@ -35,9 +44,19 @@ namespace ShiftScheduler.Services
if (!string.IsNullOrEmpty(shift.AfternoonTime))
{
var times = shift.AfternoonTime.Split('-');
+ var summary = $"{shift.Name} (Afternoon)";
+ var description = "";
+
+ if (shiftWithTransport.AfternoonTransport != null && !string.IsNullOrEmpty(shiftWithTransport.AfternoonTransport.DepartureTime))
+ {
+ var transportSummary = FormatTransportInfo(shiftWithTransport.AfternoonTransport);
+ description = $"Transport: {transportSummary}";
+ }
+
calendar.Events.Add(new CalendarEvent
{
- Summary = $"{shift.Name} (Afternoon)",
+ Summary = summary,
+ Description = description,
Start = new CalDateTime(date.Add(TimeSpan.Parse(times[0]))),
End = new CalDateTime(date.Add(TimeSpan.Parse(times[1])))
});
@@ -46,5 +65,19 @@ namespace ShiftScheduler.Services
return new CalendarSerializer().SerializeToString(calendar);
}
+
+ private string FormatTransportInfo(TransportConnection transport)
+ {
+ var departure = DateTime.TryParse(transport.DepartureTime, out var dep) ? dep.ToString("HH:mm") : transport.DepartureTime;
+ var arrival = DateTime.TryParse(transport.ArrivalTime, out var arr) ? arr.ToString("HH:mm") : transport.ArrivalTime;
+
+ var mainJourney = transport.Sections?.FirstOrDefault()?.Journey;
+ if (mainJourney != null)
+ {
+ return $"{mainJourney.Category} {mainJourney.Number}: {departure} β {arrival}";
+ }
+
+ return $"{departure} β {arrival}";
+ }
}
}
diff --git a/Services/PdfExportService.cs b/Services/PdfExportService.cs
index c22f12e..607fabe 100644
--- a/Services/PdfExportService.cs
+++ b/Services/PdfExportService.cs
@@ -16,10 +16,13 @@ namespace ShiftScheduler.Services
QuestPDF.Settings.License = LicenseType.Community;
}
- public byte[] GenerateMonthlySchedulePdf(Dictionary schedule, List shifts)
+ public byte[] GenerateMonthlySchedulePdf(List shiftsWithTransport)
{
- var year = schedule.First().Key.Year;
- var month = schedule.First().Key.Month;
+ if (!shiftsWithTransport.Any())
+ return new byte[0];
+
+ var year = shiftsWithTransport.First().Date.Year;
+ var month = shiftsWithTransport.First().Date.Month;
var document = Document.Create(container =>
{
@@ -33,14 +36,14 @@ namespace ShiftScheduler.Services
.Text($"Shift Schedule for {CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(month)} {year}")
.FontSize(16).Bold().AlignCenter();
- page.Content().Element(e => ComposeTable(e, schedule, shifts));
+ page.Content().Element(e => ComposeTable(e, shiftsWithTransport));
});
});
return document.GeneratePdf();
}
-
- void ComposeTable(IContainer container, IReadOnlyDictionary schedule, IReadOnlyList shifts)
+
+ void ComposeTable(IContainer container, List shiftsWithTransport)
{
container.Table(table =>
{
@@ -55,8 +58,9 @@ namespace ShiftScheduler.Services
columns.RelativeColumn();
});
- var year = schedule.First().Key.Year;
- var month = schedule.First().Key.Month;
+ var scheduleDictionary = shiftsWithTransport.ToDictionary(s => s.Date, s => s);
+ var year = shiftsWithTransport.First().Date.Year;
+ var month = shiftsWithTransport.First().Date.Month;
var daysInMonth = DateTime.DaysInMonth(year, month);
var monthDates = Enumerable.Range(1, daysInMonth)
.Select(day => new DateTime(year, month, day))
@@ -67,13 +71,13 @@ namespace ShiftScheduler.Services
.ToList();
foreach (var week in weeks)
{
- RenderShiftForWeek(table, week, schedule, shifts);
+ RenderShiftForWeek(table, week, scheduleDictionary);
}
});
}
- private static void RenderShiftForWeek(TableDescriptor table, IGrouping week,
- IReadOnlyDictionary schedule, IReadOnlyList shifts)
+ private static void RenderShiftForWeek(TableDescriptor table, IGrouping week,
+ IReadOnlyDictionary shiftsWithTransport)
{
var weekDates = week.ToList();
var dayInWeek = weekDates.Count;
@@ -86,46 +90,93 @@ namespace ShiftScheduler.Services
{
RenderEmptyCell(table);
}
-
+
foreach (var day in Enumerable.Range(0, dayInWeek))
{
- var shiftName = shifts.Single(s => s.Name == schedule[weekDates.ElementAtOrDefault(day)]).Name;
+ var shiftWithTransport = shiftsWithTransport.GetValueOrDefault(weekDates.ElementAtOrDefault(day));
+ var shiftName = shiftWithTransport?.Shift.Name ?? "";
table.Cell().Element(CellStyleMiddle).Text(shiftName);
}
foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek))
{
RenderEmptyCell(table);
}
-
+
foreach (var day in Enumerable.Range(0, dayInWeek))
{
- var morningTime = shifts.Single(s => s.Name == schedule[weekDates.ElementAtOrDefault(day)]).MorningTime;
+ var shiftWithTransport = shiftsWithTransport.GetValueOrDefault(weekDates.ElementAtOrDefault(day));
+ var morningTime = shiftWithTransport?.Shift.MorningTime ?? "";
table.Cell().Element(CellStyleMiddle).Text(morningTime);
}
foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek))
{
RenderEmptyCell(table);
}
-
+
foreach (var day in Enumerable.Range(0, dayInWeek))
{
- var afternoonTime = shifts.Single(s => s.Name == schedule[weekDates.ElementAtOrDefault(day)]).AfternoonTime;
+ var shiftWithTransport = shiftsWithTransport.GetValueOrDefault(weekDates.ElementAtOrDefault(day));
+ var afternoonTime = shiftWithTransport?.Shift.AfternoonTime ?? "";
table.Cell().Element(CellStyleMiddle).Text(afternoonTime);
}
foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek))
{
RenderEmptyCell(table);
}
-
+
foreach (var day in Enumerable.Range(0, dayInWeek))
{
- var icon = shifts.Single(s => s.Name == schedule[weekDates.ElementAtOrDefault(day)]).Icon;
- table.Cell().Element(CellStyleLast).Text(icon);
+ var shiftWithTransport = shiftsWithTransport.GetValueOrDefault(weekDates.ElementAtOrDefault(day));
+ var icon = shiftWithTransport?.Shift.Icon ?? "";
+ table.Cell().Element(CellStyleMiddle).Text(icon);
}
foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek))
{
RenderEmptyCell(table);
}
+
+ foreach (var day in Enumerable.Range(0, dayInWeek))
+ {
+ var shiftWithTransport = shiftsWithTransport.GetValueOrDefault(weekDates.ElementAtOrDefault(day));
+ var transportInfo = GetTransportSummary(shiftWithTransport);
+ table.Cell().Element(CellStyleLast).Text(transportInfo);
+ }
+ foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek))
+ {
+ RenderEmptyCell(table);
+ }
+ }
+
+ private static string GetTransportSummary(ShiftWithTransport? shiftWithTransport)
+ {
+ if (shiftWithTransport == null) return "-";
+
+ var transportLines = new List();
+
+ if (shiftWithTransport.MorningTransport != null && !string.IsNullOrEmpty(shiftWithTransport.MorningTransport.DepartureTime))
+ {
+ var morningInfo = FormatTransportConnection(shiftWithTransport.MorningTransport, "Morning");
+ transportLines.Add(morningInfo);
+ }
+
+ if (shiftWithTransport.AfternoonTransport != null && !string.IsNullOrEmpty(shiftWithTransport.AfternoonTransport.DepartureTime))
+ {
+ var afternoonInfo = FormatTransportConnection(shiftWithTransport.AfternoonTransport, "Afternoon");
+ transportLines.Add(afternoonInfo);
+ }
+
+ return transportLines.Count > 0 ? string.Join("\n", transportLines) : "-";
+ }
+
+ private static string FormatTransportConnection(TransportConnection transport, string timeOfDay)
+ {
+ var departure = DateTime.TryParse(transport.DepartureTime, out var dep) ? dep.ToString("HH:mm") : transport.DepartureTime;
+ var arrival = DateTime.TryParse(transport.ArrivalTime, out var arr) ? arr.ToString("HH:mm") : transport.ArrivalTime;
+
+ var mainJourney = transport.Sections?.FirstOrDefault()?.Journey;
+ var trainInfo = mainJourney != null ? $"{mainJourney.Category} {mainJourney.Number}" : "Train";
+
+ return $"{timeOfDay}: {trainInfo} {departure}β{arrival}";
}
private static void RenderEmptyCell(TableDescriptor table)
@@ -177,4 +228,4 @@ namespace ShiftScheduler.Services
.BorderRight(0);
}
}
-}
+}
\ No newline at end of file
diff --git a/Services/ShiftEnrichmentService.cs b/Services/ShiftEnrichmentService.cs
new file mode 100644
index 0000000..26ecb4e
--- /dev/null
+++ b/Services/ShiftEnrichmentService.cs
@@ -0,0 +1,77 @@
+using ShiftScheduler.Shared.Models;
+
+namespace ShiftScheduler.Services
+{
+ public class ShiftEnrichmentService
+ {
+ private readonly TransportService _transportService;
+
+ public ShiftEnrichmentService(TransportService transportService)
+ {
+ _transportService = transportService;
+ }
+
+ public async Task> EnrichShiftsWithTransportAsync(List shifts, Dictionary schedule)
+ {
+ var shiftTransports = new List();
+
+ foreach (var shift in shifts)
+ {
+ var shiftTransport = new ShiftTransport
+ {
+ ShiftName = shift.Name
+ };
+
+ // Find dates where this shift is scheduled
+ var scheduledDates = schedule.Where(kvp => kvp.Value == shift.Name).Select(kvp => kvp.Key).ToList();
+
+ if (scheduledDates.Count > 0)
+ {
+ // Use the first scheduled date to calculate transport times
+ var sampleDate = scheduledDates.First();
+
+ // Get transport for morning shift
+ if (!string.IsNullOrEmpty(shift.MorningTime))
+ {
+ var morningStartTime = ParseShiftTime(sampleDate, shift.MorningTime);
+ if (morningStartTime.HasValue)
+ {
+ shiftTransport.MorningTransport = await _transportService.GetConnectionAsync(morningStartTime.Value);
+ }
+ }
+
+ // Get transport for afternoon shift
+ if (!string.IsNullOrEmpty(shift.AfternoonTime))
+ {
+ var afternoonStartTime = ParseShiftTime(sampleDate, shift.AfternoonTime);
+ if (afternoonStartTime.HasValue)
+ {
+ shiftTransport.AfternoonTransport = await _transportService.GetConnectionAsync(afternoonStartTime.Value);
+ }
+ }
+ }
+
+ shiftTransports.Add(shiftTransport);
+ }
+
+ return shiftTransports;
+ }
+
+ private 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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Services/TransportService.cs b/Services/TransportService.cs
new file mode 100644
index 0000000..6213bbf
--- /dev/null
+++ b/Services/TransportService.cs
@@ -0,0 +1,210 @@
+using ShiftScheduler.Shared.Models;
+using System.Text.Json;
+
+namespace ShiftScheduler.Services
+{
+ public class TransportService
+ {
+ private readonly HttpClient _httpClient;
+ private readonly TransportConfiguration _config;
+
+ public TransportService(HttpClient httpClient, TransportConfiguration config)
+ {
+ _httpClient = httpClient;
+ _config = config;
+ }
+
+ public async Task 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 connections starting from a reasonable time before the latest acceptable arrival
+ // Start searching from 90 minutes before the latest acceptable time
+ var searchTime = latestArrivalTime.AddMinutes(-90);
+ 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}&limit=15";
+
+ var response = await _httpClient.GetStringAsync(url);
+ var apiResponse = JsonSerializer.Deserialize(response, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ if (apiResponse?.Connections?.Count > 0)
+ {
+ // Find the best connection that arrives before the required time
+ var bestConnection = FindBestConnection(apiResponse.Connections, latestArrivalTime);
+ return MapToTransportConnection(bestConnection);
+ }
+
+ 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
+ {
+ 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 connections, DateTime latestArrivalTime)
+ {
+ // Filter connections that arrive before the latest acceptable time
+ var validConnections = new List();
+
+ 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)
+ {
+ // Sort by arrival time (earliest first) and take the one that arrives
+ // with reasonable margin but not too early
+ var sortedValid = validConnections
+ .OrderBy(c => DateTime.Parse(c.To?.Arrival ?? "00:00"))
+ .ToList();
+
+ // Prefer a connection that arrives with 10-45 minutes margin
+ var preferredMarginMin = 10;
+ var preferredMarginMax = 45;
+
+ var idealConnection = sortedValid.LastOrDefault(c =>
+ {
+ var arrivalTime = DateTime.Parse(c.To?.Arrival ?? "00:00");
+ var marginMinutes = (latestArrivalTime - arrivalTime).TotalMinutes;
+ return marginMinutes >= preferredMarginMin && marginMinutes <= preferredMarginMax;
+ });
+
+ // If we found an ideal connection, use it; otherwise use the latest valid one
+ return idealConnection ?? 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)
+ {
+ if (apiConnection == null)
+ return new TransportConnection();
+
+ return new TransportConnection
+ {
+ DepartureTime = apiConnection.From?.Departure ?? string.Empty,
+ ArrivalTime = apiConnection.To?.Arrival ?? string.Empty,
+ Duration = apiConnection.Duration ?? 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 ?? string.Empty,
+ Category = s.Journey.Category ?? string.Empty,
+ Number = s.Journey.Number ?? string.Empty
+ } : 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
+ } : null,
+ Departure = s.Departure.Departure ?? string.Empty,
+ Arrival = s.Departure.Arrival ?? string.Empty,
+ Platform = s.Departure.Platform ?? string.Empty
+ } : 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
+ } : null,
+ Departure = s.Arrival.Departure ?? string.Empty,
+ Arrival = s.Arrival.Arrival ?? string.Empty,
+ Platform = s.Arrival.Platform ?? string.Empty
+ } : null
+ }).ToList() ?? new List()
+ };
+ }
+
+ 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}";
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared/ShiftWithTransport.cs b/Shared/ShiftWithTransport.cs
new file mode 100644
index 0000000..2c794cd
--- /dev/null
+++ b/Shared/ShiftWithTransport.cs
@@ -0,0 +1,34 @@
+namespace ShiftScheduler.Shared.Models
+{
+ // View model that combines shift information with transport data for a specific date
+ public record ShiftWithTransport
+ {
+ public DateTime Date { get; init; }
+ public Shift Shift { get; init; } = new();
+ public TransportConnection? MorningTransport { get; init; }
+ public TransportConnection? AfternoonTransport { get; init; }
+
+ public string GetMorningTransportSummary()
+ {
+ return MorningTransport != null ? FormatTransportSummary(MorningTransport) : string.Empty;
+ }
+
+ public string GetAfternoonTransportSummary()
+ {
+ return AfternoonTransport != null ? FormatTransportSummary(AfternoonTransport) : string.Empty;
+ }
+
+ private static string FormatTransportSummary(TransportConnection connection)
+ {
+ if (string.IsNullOrEmpty(connection.DepartureTime)) return string.Empty;
+
+ 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}";
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared/TransportModels.cs b/Shared/TransportModels.cs
new file mode 100644
index 0000000..db85461
--- /dev/null
+++ b/Shared/TransportModels.cs
@@ -0,0 +1,98 @@
+namespace ShiftScheduler.Shared.Models
+{
+ public class ShiftTransport
+ {
+ public string ShiftName { get; set; } = string.Empty;
+ public TransportConnection? MorningTransport { get; set; }
+ public TransportConnection? AfternoonTransport { get; set; }
+ }
+
+ public class TransportConfiguration
+ {
+ public string StartStation { get; set; } = string.Empty;
+ public string EndStation { get; set; } = string.Empty;
+ public string ApiBaseUrl { get; set; } = string.Empty;
+ public int SafetyBufferMinutes { get; set; } = 30;
+ }
+
+ public class TransportConnection
+ {
+ public string DepartureTime { get; set; } = string.Empty;
+ public string ArrivalTime { get; set; } = string.Empty;
+ public string Duration { get; set; } = string.Empty;
+ public string Platform { get; set; } = string.Empty;
+ public List Sections { get; set; } = new();
+ }
+
+ 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;
+ }
+
+ // API Response models for OpenData CH Transport
+ public class TransportApiResponse
+ {
+ public List 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 Sections { get; set; } = new();
+ }
+
+ 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 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;
+ }
+
+
+}
\ No newline at end of file