Wetterdienst

This commit is contained in:
2026-08-23 20:51:19 +02:00
parent cc973418f3
commit 1b32166ce0
21 changed files with 1186 additions and 3 deletions
@@ -36,6 +36,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private readonly SchoolWeatherService? _schoolWeather;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
@@ -56,6 +57,11 @@ public partial class DashboardViewModel : ObservableObject
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
[ObservableProperty] private string _selectedDayLabel = "";
[ObservableProperty] private bool _isDashboardSettingsOpen;
[ObservableProperty] private bool _isWeatherPanelVisible;
[ObservableProperty] private string _weatherSummary = "";
[ObservableProperty] private string _weatherDetails = "";
[ObservableProperty] private string _weatherStatus = "";
[ObservableProperty] private bool _hasWeatherWarnings;
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", De);
@@ -72,6 +78,7 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
// Navigation-Callback wird von App.axaml.cs verdrahtet
@@ -111,7 +118,7 @@ public partial class DashboardViewModel : ObservableObject
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null)
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -122,6 +129,7 @@ public partial class DashboardViewModel : ObservableObject
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_substitutions = substitutions;
_annualPlanEvents = annualPlanEvents;
_schoolWeather = schoolWeather;
if (annualPlanSync is not null)
{
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
@@ -142,6 +150,7 @@ public partial class DashboardViewModel : ObservableObject
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
CurrentSchoolYear = _sy.CurrentSchoolYear();
Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend";
_ = LoadWeatherAsync();
TodaysLessons.Clear();
var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id);
@@ -197,6 +206,57 @@ public partial class DashboardViewModel : ObservableObject
LoadAlerts(groups, today);
}
private async Task LoadWeatherAsync()
{
if (_schoolWeather?.IsAvailable != true) return;
try
{
var snapshot = await _schoolWeather.GetWeatherAsync();
if (snapshot is null) return;
var current = snapshot.Forecast
.Where(x => x.ValidAt.ToUniversalTime() >= DateTime.UtcNow.AddHours(-1))
.MinBy(x => Math.Abs((x.ValidAt.ToUniversalTime() - DateTime.UtcNow).TotalMinutes));
WeatherWarnings.Clear();
foreach (var warning in snapshot.Warnings)
WeatherWarnings.Add(new DashboardWeatherWarningItem(warning));
HasWeatherWarnings = WeatherWarnings.Count > 0;
if (current is not null)
{
var temperature = current.TemperatureC is { } t ? $"{t:0.#} °C" : "Temperatur unbekannt";
WeatherSummary = $"{temperature} · {WeatherDescription(current.WeatherCode)}";
var details = new List<string>();
if (current.WindSpeedKmh is { } wind) details.Add($"Wind {wind:0.#} km/h");
if (current.WindGustKmh is { } gust) details.Add($"Böen {gust:0.#} km/h");
if (current.PrecipitationMm is > 0) details.Add($"Niederschlag {current.PrecipitationMm:0.#} mm");
details.Add($"DWD-Station {snapshot.StationName} ({snapshot.StationDistanceKm:0.#} km)");
WeatherDetails = string.Join(" · ", details);
}
WeatherStatus = snapshot.IsStale
? $"Letzter verfügbarer Stand vom {snapshot.RetrievedAt.ToLocalTime():dd.MM., HH:mm} Uhr"
: "";
IsWeatherPanelVisible = current is not null || HasWeatherWarnings;
}
catch (SchoolWeatherException ex)
{
WeatherStatus = ex.Message;
IsWeatherPanelVisible = true;
}
}
private static string WeatherDescription(int? code) => code switch
{
0 => "klar",
>= 1 and <= 3 => "bewölkt",
45 or 48 => "Nebel",
>= 51 and <= 67 => "Regen",
>= 71 and <= 77 => "Schnee",
>= 80 and <= 82 => "Regenschauer",
85 or 86 => "Schneeschauer",
>= 95 and <= 99 => "Gewitter",
_ => "Wettervorhersage",
};
// ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────
private void LoadAttendanceWarnings(DateOnly today)
@@ -761,6 +821,32 @@ public partial class DashboardViewModel : ObservableObject
}
}
public sealed class DashboardWeatherWarningItem
{
public string Headline { get; }
public string Description { get; }
public string Instruction { get; }
public string PeriodDisplay { get; }
public string SeverityColor { get; }
public DashboardWeatherWarningItem(WeatherWarning warning)
{
Headline = string.IsNullOrWhiteSpace(warning.Headline) ? warning.Event : warning.Headline;
Description = warning.Description;
Instruction = warning.Instruction;
PeriodDisplay = (warning.Onset, warning.Expires) switch
{
({ } onset, { } expires) => $"{onset.ToLocalTime():dd.MM., HH:mm}{expires.ToLocalTime():HH:mm} Uhr",
({ } onset, null) => $"ab {onset.ToLocalTime():dd.MM., HH:mm} Uhr",
_ => "",
};
SeverityColor = warning.Severity switch
{
"Extreme" => "#7E0023", "Severe" => "#D32F2F", "Moderate" => "#F59E0B", _ => "#FDD835",
};
}
}
public class LessonItem
{
public Guid LessonId { get; set; }
@@ -162,6 +162,16 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _newHolidayEndText = "";
[ObservableProperty] private string _holidayNameError = "";
[ObservableProperty] private string _holidayDateError = "";
[ObservableProperty] private string _schoolName = "";
[ObservableProperty] private string _schoolStreet = "";
[ObservableProperty] private string _schoolPostalCode = "";
[ObservableProperty] private string _schoolCity = "";
[ObservableProperty] private string _schoolNameError = "";
[ObservableProperty] private string _schoolStreetError = "";
[ObservableProperty] private string _schoolPostalCodeError = "";
[ObservableProperty] private string _schoolCityError = "";
[ObservableProperty] private string _schoolLocationStatus = "";
[ObservableProperty] private string _resolvedSchoolAddress = "";
public List<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = [];
@@ -283,6 +293,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly SchoolWeatherService? _schoolWeather;
private readonly PeriodScheduleService _periodSchedule;
private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings;
@@ -318,7 +329,8 @@ public partial class SettingsViewModel : ObservableObject
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null)
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
SchoolWeatherService? schoolWeather = null)
{
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
@@ -341,6 +353,7 @@ public partial class SettingsViewModel : ObservableObject
_shorthandCodes = shorthandCodes;
_schoolHolidays = schoolHolidays;
_calendarSettings = calendarSettings;
_schoolWeather = schoolWeather;
_periodSchedule = periodSchedule;
_supervisionDuties = supervisionDuties;
_letterTemplates = letterTemplates;
@@ -930,6 +943,60 @@ public partial class SettingsViewModel : ObservableObject
partial void OnSelectedStateNameChanged(string value) =>
_calendarSettings.SetState(GermanStateDisplay.FromLabel(value));
[RelayCommand]
private async Task LoadSchoolLocation()
{
if (_schoolWeather is null) return;
try
{
var location = await _schoolWeather.GetLocationAsync();
if (location is null) return;
SchoolName = location.SchoolName;
SchoolStreet = location.Street;
SchoolPostalCode = location.PostalCode;
SchoolCity = location.City;
SelectedStateName = GermanStateDisplay.Label(location.State);
ResolvedSchoolAddress = location.ResolvedAddress;
}
catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; }
}
[RelayCommand]
private async Task SaveSchoolLocation()
{
SchoolNameError = ""; SchoolStreetError = ""; SchoolPostalCodeError = "";
SchoolCityError = ""; SchoolLocationStatus = "";
var valid = true;
if (string.IsNullOrWhiteSpace(SchoolName))
{ SchoolNameError = "Name der Schule erforderlich."; valid = false; }
if (string.IsNullOrWhiteSpace(SchoolStreet))
{ SchoolStreetError = "Straße und Hausnummer erforderlich."; valid = false; }
if (!System.Text.RegularExpressions.Regex.IsMatch(SchoolPostalCode.Trim(), @"^\d{5}$"))
{ SchoolPostalCodeError = "Bitte eine fünfstellige PLZ angeben."; valid = false; }
if (string.IsNullOrWhiteSpace(SchoolCity))
{ SchoolCityError = "Ort erforderlich."; valid = false; }
if (!valid) return;
if (_schoolWeather is null)
{
SchoolLocationStatus = "Der Wetterdienst ist nicht verfügbar.";
return;
}
SchoolLocationStatus = "Adresse wird geprüft …";
try
{
var profile = await _schoolWeather.SaveLocationAsync(new SchoolLocationRequest
{
SchoolName = SchoolName.Trim(), Street = SchoolStreet.Trim(),
PostalCode = SchoolPostalCode.Trim(), City = SchoolCity.Trim(),
State = GermanStateDisplay.FromLabel(SelectedStateName),
});
ResolvedSchoolAddress = profile.ResolvedAddress;
SchoolLocationStatus = "Schulstandort gespeichert. Wetterdaten werden serverseitig abgerufen.";
}
catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; }
}
private void LoadSchoolHolidays()
{
SchoolHolidayEntries.Clear();