Wetterdienst: Verhersage verlängert, Anzeige im Stundenplan.

This commit is contained in:
2026-08-23 21:31:15 +02:00
parent 1b32166ce0
commit e76884a58e
7 changed files with 390 additions and 5 deletions
@@ -0,0 +1,154 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.ViewModels.Planning;
public enum SchoolDayWeatherKind { Sunny, Cloudy, Rain, Storm, Thunderstorm }
/// <summary>Verdichtet die stündliche MOSMIX-Prognose auf ein planungsrelevantes Schultagsymbol.
/// Extremere Bedingungen haben bewusst Vorrang vor Mittelwerten.</summary>
public sealed class SchoolDayWeatherSummary
{
public DateOnly Date { get; init; }
public SchoolDayWeatherKind Kind { get; init; }
public double? MinTemperatureC { get; init; }
public double? MaxTemperatureC { get; init; }
public double PrecipitationMm { get; init; }
public double? MaxWindGustKmh { get; init; }
public IReadOnlyList<WeatherWarning> Warnings { get; init; } = [];
public string Symbol => Kind switch
{
SchoolDayWeatherKind.Thunderstorm => "⛈️",
SchoolDayWeatherKind.Storm => "💨",
SchoolDayWeatherKind.Rain => "🌧️",
SchoolDayWeatherKind.Cloudy => "☁️",
_ => "☀️",
};
public string Label => Kind switch
{
SchoolDayWeatherKind.Thunderstorm => "Gewitter",
SchoolDayWeatherKind.Storm => "Sturm",
SchoolDayWeatherKind.Rain => "Regen",
SchoolDayWeatherKind.Cloudy => "Bewölkt",
_ => "Sonnig",
};
public string Tooltip
{
get
{
var lines = new List<string> { Label };
if (MinTemperatureC is { } min && MaxTemperatureC is { } max)
lines.Add(Math.Abs(max - min) < 0.05 ? $"{min:0.#} °C" : $"{min:0.#}{max:0.#} °C");
if (PrecipitationMm >= 0.05) lines.Add($"Niederschlag: {PrecipitationMm:0.#} mm");
if (MaxWindGustKmh is { } gust) lines.Add($"Max. Böen: {gust:0.#} km/h");
foreach (var warning in Warnings)
{
var period = WarningPeriod(warning);
lines.Add($"⚠ {warning.Headline}{(period.Length > 0 ? $" ({period})" : "")}");
if (!string.IsNullOrWhiteSpace(warning.Instruction)) lines.Add(warning.Instruction.Trim());
}
return string.Join("\n", lines);
}
}
public static SchoolDayWeatherSummary? From(WeatherSnapshot snapshot, DateOnly date)
{
var allHours = snapshot.Forecast
.Where(x => DateOnly.FromDateTime(Local(x.ValidAt)) == date)
.ToList();
// Für die Unterrichtsplanung zählt vor allem der Zeitraum, in dem Schule stattfindet.
// Falls MOSMIX für diesen Zeitraum noch keine Werte liefert, bleibt der Tagesrest nutzbar.
var schoolHours = allHours.Where(x =>
{
var hour = Local(x.ValidAt).Hour;
return hour >= 6 && hour <= 18;
}).ToList();
var hours = schoolHours.Count > 0 ? schoolHours : allHours;
if (hours.Count == 0) return null;
var warnings = snapshot.Warnings.Where(x => WarningTouchesDate(x, date)).ToList();
var codes = hours.Where(x => x.WeatherCode.HasValue).Select(x => x.WeatherCode!.Value).ToList();
var precipitation = hours.Sum(x => x.PrecipitationMm ?? 0);
var gust = hours.Where(x => x.WindGustKmh.HasValue).Select(x => x.WindGustKmh!.Value).DefaultIfEmpty().Max();
var hasGust = hours.Any(x => x.WindGustKmh.HasValue);
var warningText = string.Join(" ", warnings.SelectMany(x => new[] { x.Event, x.Headline }));
var kind = codes.Any(x => x is >= 95 and <= 99) ||
warningText.Contains("GEWITTER", StringComparison.OrdinalIgnoreCase)
? SchoolDayWeatherKind.Thunderstorm
: (hasGust && gust >= 62) || ContainsStormWarning(warningText)
? SchoolDayWeatherKind.Storm
: precipitation >= 0.2 || codes.Any(IsRainCode)
? SchoolDayWeatherKind.Rain
: IsCloudy(hours, codes)
? SchoolDayWeatherKind.Cloudy
: SchoolDayWeatherKind.Sunny;
var temperatures = hours.Where(x => x.TemperatureC.HasValue)
.Select(x => x.TemperatureC!.Value).ToList();
return new SchoolDayWeatherSummary
{
Date = date,
Kind = kind,
MinTemperatureC = temperatures.Count > 0 ? temperatures.Min() : null,
MaxTemperatureC = temperatures.Count > 0 ? temperatures.Max() : null,
PrecipitationMm = Math.Round(precipitation, 1, MidpointRounding.AwayFromZero),
MaxWindGustKmh = hasGust ? Math.Round(gust, 1, MidpointRounding.AwayFromZero) : null,
Warnings = warnings,
};
}
public static bool WarningTouchesDate(WeatherWarning warning, DateOnly date)
{
if (warning.Onset is null && warning.Expires is null) return false;
var start = warning.Onset is { } onset ? Local(onset) : Local(warning.Expires!.Value).AddHours(-12);
var end = warning.Expires is { } expires ? Local(expires) : start.AddHours(12);
var dayStart = date.ToDateTime(TimeOnly.MinValue);
var dayEnd = date.ToDateTime(TimeOnly.MaxValue);
return start <= dayEnd && end >= dayStart;
}
public static string WarningPeriod(WeatherWarning warning)
{
var onset = warning.Onset is { } start ? Local(start) : (DateTime?)null;
var expires = warning.Expires is { } end ? Local(end) : (DateTime?)null;
return (onset, expires) switch
{
({ } a, { } b) when DateOnly.FromDateTime(a) == DateOnly.FromDateTime(b) =>
$"{a:dd.MM., HH:mm}{b:HH:mm} Uhr",
({ } a, { } b) => $"{a:dd.MM., HH:mm}{b:dd.MM., HH:mm} Uhr",
({ } a, null) => $"ab {a:dd.MM., HH:mm} Uhr",
(null, { } b) => $"bis {b:dd.MM., HH:mm} Uhr",
_ => "",
};
}
private static bool IsRainCode(int code) => code is >= 51 and <= 67 or >= 80 and <= 82;
private static bool IsCloudy(IEnumerable<WeatherForecastHour> hours, IEnumerable<int> codes)
{
var cloudValues = hours.Where(x => x.CloudCoverPercent.HasValue)
.Select(x => x.CloudCoverPercent!.Value).ToList();
return codes.Any(x => x is 2 or 3 or 45 or 48) ||
cloudValues.Count > 0 && cloudValues.Average() >= 60;
}
private static bool ContainsStormWarning(string text) =>
text.Contains("STURM", StringComparison.OrdinalIgnoreCase) ||
text.Contains("ORKAN", StringComparison.OrdinalIgnoreCase) ||
text.Contains("WIND", StringComparison.OrdinalIgnoreCase) ||
text.Contains("BÖEN", StringComparison.OrdinalIgnoreCase);
private static DateTime Local(DateTime value) => value.Kind == DateTimeKind.Utc
? value.ToLocalTime() : value;
}
public sealed class WeekWeatherWarningItem(WeatherWarning warning)
{
public string Headline { get; } = string.IsNullOrWhiteSpace(warning.Headline) ? warning.Event : warning.Headline;
public string PeriodDisplay { get; } = SchoolDayWeatherSummary.WarningPeriod(warning);
public string Tooltip { get; } = string.Join("\n", new[] { warning.Description, warning.Instruction }
.Where(x => !string.IsNullOrWhiteSpace(x)));
}
@@ -65,6 +65,9 @@ public partial class TimetableViewModel : ObservableObject
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IUntisSlotMappingRepository _untisMappings;
private readonly WebUntisSettingsService _untisSettings;
private readonly SchoolWeatherService? _schoolWeather;
private readonly SemaphoreSlim _weatherGate = new(1, 1);
private WeatherSnapshot? _weatherSnapshot;
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
@@ -73,6 +76,7 @@ public partial class TimetableViewModel : ObservableObject
public ObservableCollection<TodaySupervisionItem> TodaySupervisions { get; } = [];
public ObservableCollection<TodaySpecialAssignmentItem> TodaySpecialAssignments { get; } = [];
public ObservableCollection<UpcomingExamItem> UpcomingExams { get; } = [];
public ObservableCollection<WeekWeatherWarningItem> WeekWeatherWarnings { get; } = [];
/// Cells/WeekItems zeilenweise gruppiert (GridColumns Zellen je Zeile), zusätzlich zur
/// flachen Liste - Grundlage für die zeilenweise Höhensteuerung in der View (Aufsicht-Zeilen
@@ -108,13 +112,15 @@ public partial class TimetableViewModel : ObservableObject
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
PublicHolidayService publicHolidays, SchoolYearService schoolYear,
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions,
IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings)
IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings,
SchoolWeatherService? schoolWeather = null)
{
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
_supervisionDuties = supervisionDuties; _substitutions = substitutions;
_untisMappings = untisMappings; _untisSettings = untisSettings;
_schoolWeather = schoolWeather;
Load();
}
@@ -152,12 +158,58 @@ public partial class TimetableViewModel : ObservableObject
BuildGrid(holidayBadges, duties);
BuildWeekOverview(today, publicHolidayDates, duties);
ApplyWeatherToWeekHeaders(today);
_ = LoadWeatherAsync(today);
BuildToday(today);
BuildHoursWarnings();
BuildUpcomingExams(today);
LoadUntisMismatch();
}
private async Task LoadWeatherAsync(DateOnly today)
{
if (_schoolWeather?.IsAvailable != true) return;
if (_weatherSnapshot is not null &&
DateTime.UtcNow - _weatherSnapshot.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5))
return;
if (!await _weatherGate.WaitAsync(0)) return;
try
{
_weatherSnapshot = await _schoolWeather.GetWeatherAsync();
ApplyWeatherToWeekHeaders(today);
}
catch (SchoolWeatherException) { /* Wetter ist eine optionale Planungshilfe. */ }
finally { _weatherGate.Release(); }
}
private void ApplyWeatherToWeekHeaders(DateOnly today)
{
WeekWeatherWarnings.Clear();
var datedHeaders = WeekItems.Where(x => x.IsHeader && x.Date.HasValue).ToList();
foreach (var header in datedHeaders)
{
var date = header.Date!.Value;
var warnings = _weatherSnapshot?.Warnings
.Where(x => date >= today && SchoolDayWeatherSummary.WarningTouchesDate(x, date))
.ToList() ?? [];
var summary = date >= today && _weatherSnapshot is not null
? SchoolDayWeatherSummary.From(_weatherSnapshot, date)
: null;
header.SetWeather(summary, warnings);
}
if (_weatherSnapshot is null || datedHeaders.Count == 0) return;
var from = datedHeaders.Min(x => x.Date!.Value);
var until = datedHeaders.Max(x => x.Date!.Value);
foreach (var warning in _weatherSnapshot.Warnings
.Where(x => Enumerable.Range(0, until.DayNumber - from.DayNumber + 1)
.Select(from.AddDays)
.Any(date => date >= today && SchoolDayWeatherSummary.WarningTouchesDate(x, date)))
.DistinctBy(x => x.Identifier)
.OrderBy(x => x.Onset))
WeekWeatherWarnings.Add(new WeekWeatherWarningItem(warning));
}
private void LoadUntisMismatch()
{
if (!_untisSettings.Enabled || !_untisSettings.IsConfigured) { HasUntisMismatch = false; return; }
@@ -689,7 +741,7 @@ public class TimetableRowItem(IReadOnlyList<TimetableCellItem> cells)
}
/// <summary>Zelle im schreibgeschützten Wochenraster der "Heute"-Ansicht.</summary>
public class WeekCellItem
public partial class WeekCellItem : ObservableObject
{
public bool IsHeader { get; private init; }
public bool IsPeriodLabel { get; private init; }
@@ -705,6 +757,7 @@ public class WeekCellItem
public DayOfWeek? Weekday { get; private init; }
public int PeriodNumber { get; private init; }
public bool IsToday { get; private init; }
public DateOnly? Date { get; private init; }
public Guid GroupId { get; private init; }
public string SubjectLabel { get; private init; } = "";
public string GroupName { get; private init; } = "";
@@ -726,6 +779,20 @@ public class WeekCellItem
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
public Lesson? Lesson { get; private init; }
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
[ObservableProperty] private string _weatherSymbol = "";
[ObservableProperty] private string _weatherTooltip = "";
[ObservableProperty] private bool _hasWeatherWarning;
public bool HasWeather => WeatherSymbol.Length > 0;
partial void OnWeatherSymbolChanged(string value) => OnPropertyChanged(nameof(HasWeather));
public void SetWeather(SchoolDayWeatherSummary? summary, IReadOnlyList<WeatherWarning> warnings)
{
WeatherSymbol = summary?.Symbol ?? "";
WeatherTooltip = summary?.Tooltip ?? string.Join("\n", warnings.Select(x =>
$"⚠ {x.Headline} ({SchoolDayWeatherSummary.WarningPeriod(x)})"));
HasWeatherWarning = warnings.Count > 0;
}
public static WeekCellItem Corner() => new() { IsHeader = true };
@@ -733,6 +800,7 @@ public class WeekCellItem
{
IsHeader = true,
IsToday = isToday,
Date = date,
Text = (day switch
{
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",