diff --git a/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs b/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs
index 80ab3b0..7722cbe 100644
--- a/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs
+++ b/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs
@@ -55,6 +55,29 @@ public sealed class DwdWeatherServiceTests
Assert.Equal(61, hour.WeatherCode);
}
+ [Fact]
+ public void ParseMosmix_LiefertMehrAlsDreiTageFuerFolgewoche()
+ {
+ var start = DateTime.UtcNow.AddHours(1);
+ var timeSteps = string.Join("", Enumerable.Range(0, 100)
+ .Select(i => $"{start.AddHours(i):yyyy-MM-dd'T'HH:mm:ss'Z'}"));
+ var values = string.Join(' ', Enumerable.Repeat("293.15", 100));
+ var kml = $$"""
+
+
+
+ {{timeSteps}}
+
+ {{values}}
+
+
+ """;
+
+ var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
+
+ Assert.Equal(100, parsed.Hours.Count);
+ }
+
[Fact]
public void ParseCapArchive_OrdnetWarnpolygonGeografischZu()
{
diff --git a/LehrerApp.Api/DwdWeatherService.cs b/LehrerApp.Api/DwdWeatherService.cs
index 83911d5..6f6aa45 100644
--- a/LehrerApp.Api/DwdWeatherService.cs
+++ b/LehrerApp.Api/DwdWeatherService.cs
@@ -138,7 +138,9 @@ public sealed class DwdWeatherService(HttpClient http)
? series[i] : null;
var hours = new List();
var earliest = DateTime.UtcNow.AddHours(-1);
- for (var i = 0; i < times.Count && hours.Count < 72; i++)
+ // MOSMIX_L reicht bis +240 h. Die vollständige Spanne ist für die Unterrichtsplanung
+ // sinnvoll, weil das Wochenraster auch in die Folgewoche geblättert werden kann.
+ for (var i = 0; i < times.Count && hours.Count < 240; i++)
{
if (times[i] < earliest) continue;
hours.Add(new WeatherForecastHour
diff --git a/LehrerApp.Desktop.Tests/SchoolDayWeatherSummaryTests.cs b/LehrerApp.Desktop.Tests/SchoolDayWeatherSummaryTests.cs
new file mode 100644
index 0000000..a453f81
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/SchoolDayWeatherSummaryTests.cs
@@ -0,0 +1,102 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Desktop.ViewModels.Planning;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class SchoolDayWeatherSummaryTests
+{
+ private static readonly DateOnly Day = new(2026, 8, 24);
+
+ [Fact]
+ public void From_GewitterHatVorrangVorSturmUndRegen()
+ {
+ var snapshot = Snapshot(
+ Hour(8, code: 61, rain: 2, gust: 70),
+ Hour(10, code: 95, rain: 1, gust: 80));
+
+ var summary = SchoolDayWeatherSummary.From(snapshot, Day);
+
+ Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind);
+ Assert.Equal("⛈️", summary.Symbol);
+ }
+
+ [Fact]
+ public void From_BoeAbBeaufortAcht_WirdAlsSturmAngezeigt()
+ {
+ var summary = SchoolDayWeatherSummary.From(Snapshot(Hour(10, gust: 62)), Day);
+
+ Assert.Equal(SchoolDayWeatherKind.Storm, summary!.Kind);
+ Assert.Equal("💨", summary.Symbol);
+ }
+
+ [Fact]
+ public void From_NaechtlicheBoeBeeinflusstSchultagNicht()
+ {
+ var snapshot = Snapshot(Hour(10, cloud: 10), Hour(23, gust: 90));
+
+ var summary = SchoolDayWeatherSummary.From(snapshot, Day);
+
+ Assert.Equal(20, summary!.MaxWindGustKmh);
+ Assert.Equal(SchoolDayWeatherKind.Sunny, summary!.Kind);
+ }
+
+ [Fact]
+ public void From_AmtlicheGewitterwarnung_PraegtSymbolUndTooltip()
+ {
+ var snapshot = Snapshot(Hour(10, cloud: 20));
+ snapshot.Warnings.Add(new WeatherWarning
+ {
+ Identifier = "w1", Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter",
+ Instruction = "Aufenthalt im Freien vermeiden.", Severity = "Severe",
+ Onset = Day.ToDateTime(new TimeOnly(12, 0)), Expires = Day.ToDateTime(new TimeOnly(16, 0)),
+ });
+
+ var summary = SchoolDayWeatherSummary.From(snapshot, Day);
+
+ Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind);
+ Assert.Contains("Amtliche Warnung", summary.Tooltip);
+ Assert.Contains("Aufenthalt im Freien", summary.Tooltip);
+ }
+
+ [Fact]
+ public void From_RegenSummiertStundenUndZeigtTemperaturspanne()
+ {
+ var snapshot = Snapshot(
+ Hour(8, temperature: 12, rain: 0.1),
+ Hour(12, temperature: 17, rain: 0.4));
+
+ var summary = SchoolDayWeatherSummary.From(snapshot, Day);
+
+ Assert.Equal(SchoolDayWeatherKind.Rain, summary!.Kind);
+ Assert.Equal(0.5, summary.PrecipitationMm);
+ Assert.Contains("12–17 °C", summary.Tooltip);
+ }
+
+ [Fact]
+ public void WarningTouchesDate_MehrtaegigeVorwarnung_TrifftAlleUeberlapptenTage()
+ {
+ var warning = new WeatherWarning
+ {
+ Onset = Day.ToDateTime(new TimeOnly(18, 0)),
+ Expires = Day.AddDays(2).ToDateTime(new TimeOnly(8, 0)),
+ };
+
+ Assert.True(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(1)));
+ Assert.False(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(3)));
+ }
+
+ private static WeatherSnapshot Snapshot(params WeatherForecastHour[] hours) => new()
+ {
+ RetrievedAt = DateTime.UtcNow,
+ Forecast = hours.ToList(),
+ };
+
+ private static WeatherForecastHour Hour(int hour, int? code = 0, double? temperature = 20,
+ double? rain = 0, double? gust = 20, double? cloud = 20) => new()
+ {
+ ValidAt = Day.ToDateTime(new TimeOnly(hour, 0)), WeatherCode = code,
+ TemperatureC = temperature, PrecipitationMm = rain, WindGustKmh = gust,
+ CloudCoverPercent = cloud,
+ };
+}
diff --git a/LehrerApp.Desktop/ViewModels/Planning/SchoolDayWeatherSummary.cs b/LehrerApp.Desktop/ViewModels/Planning/SchoolDayWeatherSummary.cs
new file mode 100644
index 0000000..dce154a
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Planning/SchoolDayWeatherSummary.cs
@@ -0,0 +1,154 @@
+using LehrerApp.Core.Models;
+
+namespace LehrerApp.Desktop.ViewModels.Planning;
+
+public enum SchoolDayWeatherKind { Sunny, Cloudy, Rain, Storm, Thunderstorm }
+
+/// Verdichtet die stündliche MOSMIX-Prognose auf ein planungsrelevantes Schultagsymbol.
+/// Extremere Bedingungen haben bewusst Vorrang vor Mittelwerten.
+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 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 { 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 hours, IEnumerable 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)));
+}
diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs
index faf2441..67f6822 100644
--- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs
@@ -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 Cells { get; } = [];
public ObservableCollection WeekItems { get; } = [];
@@ -73,6 +76,7 @@ public partial class TimetableViewModel : ObservableObject
public ObservableCollection TodaySupervisions { get; } = [];
public ObservableCollection TodaySpecialAssignments { get; } = [];
public ObservableCollection UpcomingExams { get; } = [];
+ public ObservableCollection 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 cells)
}
/// Zelle im schreibgeschützten Wochenraster der "Heute"-Ansicht.
-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 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",
diff --git a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml
index a0f6cd9..4f33eb8 100644
--- a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml
+++ b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml
@@ -154,6 +154,24 @@
ToolTip.Tip="Einstellungen (Ferien, Aufsichten, Stundenraster)"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+