Add real-time SBB transport integration with enhanced UI feedback and improved timing logic (#2)

* Initial plan

* Initial project setup - update to .NET 8.0 for compatibility

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Complete SBB transport integration implementation

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Revert to .NET 9 and separate transport info into ShiftTransport class

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Make safety buffer minutes configurable via appsettings

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Migrade pdf export service

* Update gitignore

* Implement new approach: real-time transport data per shift selection with ShiftWithTransport view model

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Refactor export methods to directly use ShiftWithTransport instead of converting to separate parameters

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Move ShiftWithTransport class to its own file

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Code cleanup

* Fix transport timing logic to respect safety buffer and find best available connections

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
Co-authored-by: clayschaad <c.schaad@pog.ch>
Co-authored-by: Claudio Schaad <clayschaad@users.noreply.github.com>
This commit is contained in:
Copilot 2025-08-19 22:08:59 +02:00 committed by GitHub
parent e275cbd240
commit 2dc437ac9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 813 additions and 42 deletions

5
.gitignore vendored
View file

@ -55,4 +55,7 @@ nunit-*.xml
# Temporary installation files # Temporary installation files
*.deb *.deb
*.rpm *.rpm
# mac files
.DS_Store

View file

@ -14,13 +14,27 @@
{ {
<div class="tooltip-wrapper"> <div class="tooltip-wrapper">
<button class="shift-button @(SelectedSchedule.ContainsKey(day) && SelectedSchedule[day] == shift.Name ? "selected" : "")" <button class="shift-button @(SelectedSchedule.ContainsKey(day) && SelectedSchedule[day] == shift.Name ? "selected" : "")"
@onclick="() => SelectShift(day, shift.Name)"> @onclick="() => SelectShift(day, shift.Name)"
@shift.Icon disabled="@_isLoadingTransport">
@if (_isLoadingTransport && SelectedSchedule.ContainsKey(day) && SelectedSchedule[day] == shift.Name)
{
<span class="loading-spinner">⏳</span>
}
else
{
@shift.Icon
}
</button> </button>
<span class="tooltip">@shift.Name</span> <span class="tooltip">@shift.Name</span>
</div> </div>
} }
</div> </div>
@if (SelectedSchedule.ContainsKey(day))
{
<div class="transport-info">
<small>@GetTransportSummary(day)</small>
</div>
}
</div> </div>
} }
</div> </div>

View file

@ -15,8 +15,10 @@ namespace ShiftScheduler.Client.Pages
private List<DateTime> DaysInMonth { get; set; } = new(); private List<DateTime> DaysInMonth { get; set; } = new();
private List<Shift> Shifts { get; set; } = new(); private List<Shift> Shifts { get; set; } = new();
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 int EditMonth { get; set; } private int EditMonth { get; set; }
private int EditYear { get; set; } private int EditYear { get; set; }
private bool _isLoadingTransport = false;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@ -42,12 +44,54 @@ namespace ShiftScheduler.Client.Pages
private async Task SelectShift(DateTime day, string shiftName) private async Task SelectShift(DateTime day, string shiftName)
{ {
SelectedSchedule[day] = 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<ShiftWithTransport>();
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(); await SaveScheduleToStorage();
} }
private async Task ExportToIcs() 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 bytes = await response.Content.ReadAsByteArrayAsync();
var base64 = Convert.ToBase64String(bytes); var base64 = Convert.ToBase64String(bytes);
var fileUrl = $"data:text/calendar;base64,{base64}"; var fileUrl = $"data:text/calendar;base64,{base64}";
@ -56,7 +100,8 @@ namespace ShiftScheduler.Client.Pages
private async Task ExportToPdf() 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 bytes = await response.Content.ReadAsByteArrayAsync();
var base64 = Convert.ToBase64String(bytes); var base64 = Convert.ToBase64String(bytes);
var fileUrl = $"data:application/octet-stream;base64,{base64}"; var fileUrl = $"data:application/octet-stream;base64,{base64}";
@ -99,18 +144,63 @@ namespace ShiftScheduler.Client.Pages
kvp => DateTime.Parse(kvp.Key), kvp => DateTime.Parse(kvp.Key),
kvp => kvp.Value kvp => kvp.Value
); );
// Reload transport data for all selected shifts
await ReloadTransportDataForSchedule();
} }
} }
} }
catch (Exception) catch (Exception)
{ {
SelectedSchedule = new Dictionary<DateTime, string>(); SelectedSchedule = new Dictionary<DateTime, string>();
SelectedShiftsWithTransport = new Dictionary<DateTime, ShiftWithTransport>();
}
}
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<ShiftWithTransport>();
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() private async Task ResetSchedule()
{ {
SelectedSchedule.Clear(); SelectedSchedule.Clear();
SelectedShiftsWithTransport.Clear();
try try
{ {
@ -122,5 +212,24 @@ namespace ShiftScheduler.Client.Pages
// ignored // 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<string>();
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);
}
} }
} }

View file

@ -141,3 +141,25 @@
visibility: visible; visibility: visible;
opacity: 1; 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); }
}

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ShiftScheduler.Services; using ShiftScheduler.Services;
using ShiftScheduler.Shared.Models;
namespace ShiftScheduler.Server.Controllers namespace ShiftScheduler.Server.Controllers
{ {
@ -10,12 +11,21 @@ namespace ShiftScheduler.Server.Controllers
private readonly ShiftService _shiftService; private readonly ShiftService _shiftService;
private readonly IcsExportService _icsService; private readonly IcsExportService _icsService;
private readonly PdfExportService _pdfExportService; 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; _shiftService = shiftService;
_icsService = icsService; _icsService = icsService;
_pdfExportService = pdfExportService; _pdfExportService = pdfExportService;
_transportService = transportService;
_enrichmentService = enrichmentService;
} }
[HttpGet("shifts")] [HttpGet("shifts")]
@ -24,18 +34,97 @@ namespace ShiftScheduler.Server.Controllers
return Ok(_shiftService.GetShifts()); return Ok(_shiftService.GetShifts());
} }
[HttpPost("export_ics")] [HttpPost("transport_connection")]
public IActionResult ExportIcs([FromBody] Dictionary<DateTime, string> schedule) public async Task<IActionResult> 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<IActionResult> 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<ShiftWithTransport> shiftsWithTransport)
{
var ics = _icsService.GenerateIcs(shiftsWithTransport);
return File(System.Text.Encoding.UTF8.GetBytes(ics), "text/calendar", "schedule.ics"); return File(System.Text.Encoding.UTF8.GetBytes(ics), "text/calendar", "schedule.ics");
} }
[HttpPost("export_pdf")] [HttpPost("export_pdf")]
public IActionResult ExportPdf([FromBody] Dictionary<DateTime, string> schedule) public IActionResult ExportPdfWithTransport([FromBody] List<ShiftWithTransport> shiftsWithTransport)
{ {
var pdf = _pdfExportService.GenerateMonthlySchedulePdf(schedule, _shiftService.GetShifts()); var pdf = _pdfExportService.GenerateMonthlySchedulePdf(shiftsWithTransport);
return File(pdf, "application/pdf", "schedule.pdf"); 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; }
}
} }

View file

@ -3,13 +3,19 @@ using ShiftScheduler.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Load shifts from appsettings.json // Load configurations from appsettings.json
var shifts = builder.Configuration.GetSection("Shifts").Get<List<Shift>>() ?? new(); var shifts = builder.Configuration.GetSection("Shifts").Get<List<Shift>>() ?? new();
var transportConfig = builder.Configuration.GetSection("Transport").Get<TransportConfiguration>() ?? new();
// Register services
builder.Services.AddSingleton(shifts); builder.Services.AddSingleton(shifts);
builder.Services.AddSingleton(transportConfig);
builder.Services.AddHttpClient<TransportService>();
builder.Services.AddSingleton<ShiftService>(); builder.Services.AddSingleton<ShiftService>();
builder.Services.AddSingleton<IcsExportService>(); builder.Services.AddSingleton<IcsExportService>();
builder.Services.AddSingleton<PdfExportService>(); builder.Services.AddSingleton<PdfExportService>();
builder.Services.AddSingleton<TransportService>();
builder.Services.AddSingleton<ShiftEnrichmentService>();
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();

View file

@ -7,6 +7,13 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Transport": {
"StartStation": "Zurich",
"EndStation": "Basel",
"ApiBaseUrl": "http://transport.opendata.ch/v1",
"SafetyBufferMinutes": 30
},
"Shifts": [ "Shifts": [
{ {
"Name": "Frei", "Name": "Frei",
@ -19,6 +26,24 @@
"Icon": "🌴", "Icon": "🌴",
"MorningTime": "", "MorningTime": "",
"AfternoonTime": "" "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"
} }
] ]
} }

View file

@ -8,25 +8,34 @@ namespace ShiftScheduler.Services
{ {
public class IcsExportService public class IcsExportService
{ {
public string GenerateIcs(Dictionary<DateTime, string> schedule, List<Shift> shifts) public string GenerateIcs(List<ShiftWithTransport> shiftsWithTransport)
{ {
var calendar = new Calendar(); var calendar = new Calendar();
foreach (var kvp in schedule) foreach (var shiftWithTransport in shiftsWithTransport)
{ {
var date = kvp.Key; var date = shiftWithTransport.Date;
var shiftName = kvp.Value; var shift = shiftWithTransport.Shift;
var shift = shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift == null || (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))) if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))
continue; continue;
if (!string.IsNullOrEmpty(shift.MorningTime)) if (!string.IsNullOrEmpty(shift.MorningTime))
{ {
var times = shift.MorningTime.Split('-'); 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 calendar.Events.Add(new CalendarEvent
{ {
Summary = $"{shift.Name} (Morning)", Summary = summary,
Description = description,
Start = new CalDateTime(date.Add(TimeSpan.Parse(times[0]))), Start = new CalDateTime(date.Add(TimeSpan.Parse(times[0]))),
End = new CalDateTime(date.Add(TimeSpan.Parse(times[1]))) End = new CalDateTime(date.Add(TimeSpan.Parse(times[1])))
}); });
@ -35,9 +44,19 @@ namespace ShiftScheduler.Services
if (!string.IsNullOrEmpty(shift.AfternoonTime)) if (!string.IsNullOrEmpty(shift.AfternoonTime))
{ {
var times = shift.AfternoonTime.Split('-'); 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 calendar.Events.Add(new CalendarEvent
{ {
Summary = $"{shift.Name} (Afternoon)", Summary = summary,
Description = description,
Start = new CalDateTime(date.Add(TimeSpan.Parse(times[0]))), Start = new CalDateTime(date.Add(TimeSpan.Parse(times[0]))),
End = new CalDateTime(date.Add(TimeSpan.Parse(times[1]))) End = new CalDateTime(date.Add(TimeSpan.Parse(times[1])))
}); });
@ -46,5 +65,19 @@ namespace ShiftScheduler.Services
return new CalendarSerializer().SerializeToString(calendar); 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}";
}
} }
} }

View file

@ -16,10 +16,13 @@ namespace ShiftScheduler.Services
QuestPDF.Settings.License = LicenseType.Community; QuestPDF.Settings.License = LicenseType.Community;
} }
public byte[] GenerateMonthlySchedulePdf(Dictionary<DateTime, string> schedule, List<Shift> shifts) public byte[] GenerateMonthlySchedulePdf(List<ShiftWithTransport> shiftsWithTransport)
{ {
var year = schedule.First().Key.Year; if (!shiftsWithTransport.Any())
var month = schedule.First().Key.Month; return new byte[0];
var year = shiftsWithTransport.First().Date.Year;
var month = shiftsWithTransport.First().Date.Month;
var document = Document.Create(container => var document = Document.Create(container =>
{ {
@ -33,14 +36,14 @@ namespace ShiftScheduler.Services
.Text($"Shift Schedule for {CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(month)} {year}") .Text($"Shift Schedule for {CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(month)} {year}")
.FontSize(16).Bold().AlignCenter(); .FontSize(16).Bold().AlignCenter();
page.Content().Element(e => ComposeTable(e, schedule, shifts)); page.Content().Element(e => ComposeTable(e, shiftsWithTransport));
}); });
}); });
return document.GeneratePdf(); return document.GeneratePdf();
} }
void ComposeTable(IContainer container, IReadOnlyDictionary<DateTime, string> schedule, IReadOnlyList<Shift> shifts) void ComposeTable(IContainer container, List<ShiftWithTransport> shiftsWithTransport)
{ {
container.Table(table => container.Table(table =>
{ {
@ -55,8 +58,9 @@ namespace ShiftScheduler.Services
columns.RelativeColumn(); columns.RelativeColumn();
}); });
var year = schedule.First().Key.Year; var scheduleDictionary = shiftsWithTransport.ToDictionary(s => s.Date, s => s);
var month = schedule.First().Key.Month; var year = shiftsWithTransport.First().Date.Year;
var month = shiftsWithTransport.First().Date.Month;
var daysInMonth = DateTime.DaysInMonth(year, month); var daysInMonth = DateTime.DaysInMonth(year, month);
var monthDates = Enumerable.Range(1, daysInMonth) var monthDates = Enumerable.Range(1, daysInMonth)
.Select(day => new DateTime(year, month, day)) .Select(day => new DateTime(year, month, day))
@ -67,13 +71,13 @@ namespace ShiftScheduler.Services
.ToList(); .ToList();
foreach (var week in weeks) foreach (var week in weeks)
{ {
RenderShiftForWeek(table, week, schedule, shifts); RenderShiftForWeek(table, week, scheduleDictionary);
} }
}); });
} }
private static void RenderShiftForWeek(TableDescriptor table, IGrouping<int, DateTime> week, private static void RenderShiftForWeek(TableDescriptor table, IGrouping<int, DateTime> week,
IReadOnlyDictionary<DateTime, string> schedule, IReadOnlyList<Shift> shifts) IReadOnlyDictionary<DateTime, ShiftWithTransport> shiftsWithTransport)
{ {
var weekDates = week.ToList(); var weekDates = week.ToList();
var dayInWeek = weekDates.Count; var dayInWeek = weekDates.Count;
@ -86,46 +90,93 @@ namespace ShiftScheduler.Services
{ {
RenderEmptyCell(table); RenderEmptyCell(table);
} }
foreach (var day in Enumerable.Range(0, dayInWeek)) 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); table.Cell().Element(CellStyleMiddle).Text(shiftName);
} }
foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek)) foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek))
{ {
RenderEmptyCell(table); RenderEmptyCell(table);
} }
foreach (var day in Enumerable.Range(0, dayInWeek)) 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); table.Cell().Element(CellStyleMiddle).Text(morningTime);
} }
foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek)) foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek))
{ {
RenderEmptyCell(table); RenderEmptyCell(table);
} }
foreach (var day in Enumerable.Range(0, dayInWeek)) 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); table.Cell().Element(CellStyleMiddle).Text(afternoonTime);
} }
foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek)) foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek))
{ {
RenderEmptyCell(table); RenderEmptyCell(table);
} }
foreach (var day in Enumerable.Range(0, dayInWeek)) foreach (var day in Enumerable.Range(0, dayInWeek))
{ {
var icon = shifts.Single(s => s.Name == schedule[weekDates.ElementAtOrDefault(day)]).Icon; var shiftWithTransport = shiftsWithTransport.GetValueOrDefault(weekDates.ElementAtOrDefault(day));
table.Cell().Element(CellStyleLast).Text(icon); var icon = shiftWithTransport?.Shift.Icon ?? "";
table.Cell().Element(CellStyleMiddle).Text(icon);
} }
foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek)) foreach (var day in Enumerable.Range(dayInWeek, 7 - dayInWeek))
{ {
RenderEmptyCell(table); 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<string>();
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) private static void RenderEmptyCell(TableDescriptor table)
@ -177,4 +228,4 @@ namespace ShiftScheduler.Services
.BorderRight(0); .BorderRight(0);
} }
} }
} }

View file

@ -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<List<ShiftTransport>> EnrichShiftsWithTransportAsync(List<Shift> shifts, Dictionary<DateTime, string> schedule)
{
var shiftTransports = new List<ShiftTransport>();
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;
}
}
}

View file

@ -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<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 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<TransportApiResponse>(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<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)
{
// 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<TransportSection>()
};
}
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}";
}
}
}

View file

@ -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}";
}
}
}

98
Shared/TransportModels.cs Normal file
View file

@ -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<TransportSection> 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<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 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;
}
}