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
@@ -55,6 +55,29 @@ public sealed class DwdWeatherServiceTests
Assert.Equal(61, hour.WeatherCode); 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 => $"<dwd:TimeStep>{start.AddHours(i):yyyy-MM-dd'T'HH:mm:ss'Z'}</dwd:TimeStep>"));
var values = string.Join(' ', Enumerable.Repeat("293.15", 100));
var kml = $$"""
<?xml version="1.0" encoding="UTF-8"?>
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
<dwd:ForecastTimeSteps>{{timeSteps}}</dwd:ForecastTimeSteps>
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
<dwd:Forecast dwd:elementName="TTT"><dwd:value>{{values}}</dwd:value></dwd:Forecast>
</kml:ExtendedData></kml:Placemark></kml:Document>
</kml:kml>
""";
var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
Assert.Equal(100, parsed.Hours.Count);
}
[Fact] [Fact]
public void ParseCapArchive_OrdnetWarnpolygonGeografischZu() public void ParseCapArchive_OrdnetWarnpolygonGeografischZu()
{ {
+3 -1
View File
@@ -138,7 +138,9 @@ public sealed class DwdWeatherService(HttpClient http)
? series[i] : null; ? series[i] : null;
var hours = new List<WeatherForecastHour>(); var hours = new List<WeatherForecastHour>();
var earliest = DateTime.UtcNow.AddHours(-1); 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; if (times[i] < earliest) continue;
hours.Add(new WeatherForecastHour hours.Add(new WeatherForecastHour
@@ -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("1217 °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,
};
}
@@ -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 ISubstitutionEntryRepository _substitutions;
private readonly IUntisSlotMappingRepository _untisMappings; private readonly IUntisSlotMappingRepository _untisMappings;
private readonly WebUntisSettingsService _untisSettings; 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<TimetableCellItem> Cells { get; } = [];
public ObservableCollection<WeekCellItem> WeekItems { get; } = []; public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
@@ -73,6 +76,7 @@ public partial class TimetableViewModel : ObservableObject
public ObservableCollection<TodaySupervisionItem> TodaySupervisions { get; } = []; public ObservableCollection<TodaySupervisionItem> TodaySupervisions { get; } = [];
public ObservableCollection<TodaySpecialAssignmentItem> TodaySpecialAssignments { get; } = []; public ObservableCollection<TodaySpecialAssignmentItem> TodaySpecialAssignments { get; } = [];
public ObservableCollection<UpcomingExamItem> UpcomingExams { get; } = []; public ObservableCollection<UpcomingExamItem> UpcomingExams { get; } = [];
public ObservableCollection<WeekWeatherWarningItem> WeekWeatherWarnings { get; } = [];
/// Cells/WeekItems zeilenweise gruppiert (GridColumns Zellen je Zeile), zusätzlich zur /// 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 /// 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, ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
PublicHolidayService publicHolidays, SchoolYearService schoolYear, PublicHolidayService publicHolidays, SchoolYearService schoolYear,
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions, 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; _slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings; _schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
_publicHolidays = publicHolidays; _schoolYear = schoolYear; _publicHolidays = publicHolidays; _schoolYear = schoolYear;
_supervisionDuties = supervisionDuties; _substitutions = substitutions; _supervisionDuties = supervisionDuties; _substitutions = substitutions;
_untisMappings = untisMappings; _untisSettings = untisSettings; _untisMappings = untisMappings; _untisSettings = untisSettings;
_schoolWeather = schoolWeather;
Load(); Load();
} }
@@ -152,12 +158,58 @@ public partial class TimetableViewModel : ObservableObject
BuildGrid(holidayBadges, duties); BuildGrid(holidayBadges, duties);
BuildWeekOverview(today, publicHolidayDates, duties); BuildWeekOverview(today, publicHolidayDates, duties);
ApplyWeatherToWeekHeaders(today);
_ = LoadWeatherAsync(today);
BuildToday(today); BuildToday(today);
BuildHoursWarnings(); BuildHoursWarnings();
BuildUpcomingExams(today); BuildUpcomingExams(today);
LoadUntisMismatch(); 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() private void LoadUntisMismatch()
{ {
if (!_untisSettings.Enabled || !_untisSettings.IsConfigured) { HasUntisMismatch = false; return; } 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> /// <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 IsHeader { get; private init; }
public bool IsPeriodLabel { get; private init; } public bool IsPeriodLabel { get; private init; }
@@ -705,6 +757,7 @@ public class WeekCellItem
public DayOfWeek? Weekday { get; private init; } public DayOfWeek? Weekday { get; private init; }
public int PeriodNumber { get; private init; } public int PeriodNumber { get; private init; }
public bool IsToday { get; private init; } public bool IsToday { get; private init; }
public DateOnly? Date { get; private init; }
public Guid GroupId { get; private init; } public Guid GroupId { get; private init; }
public string SubjectLabel { get; private init; } = ""; public string SubjectLabel { get; private init; } = "";
public string GroupName { 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). /// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
public Lesson? Lesson { get; private init; } public Lesson? Lesson { get; private init; }
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe"; 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 }; public static WeekCellItem Corner() => new() { IsHeader = true };
@@ -733,6 +800,7 @@ public class WeekCellItem
{ {
IsHeader = true, IsHeader = true,
IsToday = isToday, IsToday = isToday,
Date = date,
Text = (day switch Text = (day switch
{ {
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi", DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
@@ -154,6 +154,24 @@
ToolTip.Tip="Einstellungen (Ferien, Aufsichten, Stundenraster)"/> ToolTip.Tip="Einstellungen (Ferien, Aufsichten, Stundenraster)"/>
</Grid> </Grid>
<Border Background="#33F59E0B" BorderBrush="#F59E0B" BorderThickness="1"
CornerRadius="6" Padding="10,7" IsVisible="{Binding WeekWeatherWarnings.Count}">
<StackPanel Spacing="4">
<TextBlock Text="⚠ AMTLICHE WETTERWARNUNGEN" FontSize="11" FontWeight="Bold"/>
<ItemsControl ItemsSource="{Binding WeekWeatherWarnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WeekWeatherWarningItem">
<TextBlock FontSize="11" TextWrapping="Wrap" Margin="0,1"
ToolTip.Tip="{Binding Tooltip}">
<Run Text="{Binding PeriodDisplay}" FontWeight="SemiBold"/><Run Text=" · "/>
<Run Text="{Binding Headline}"/>
</TextBlock>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Zeilenweise (WeekRows) statt eines einzigen flachen UniformGrid: so kann jede <!-- Zeilenweise (WeekRows) statt eines einzigen flachen UniformGrid: so kann jede
Zeile ihre eigene Höhe haben (RowHeight je WeekRowItem) - Aufsicht-Zeilen Zeile ihre eigene Höhe haben (RowHeight je WeekRowItem) - Aufsicht-Zeilen
deutlich schmaler als Stunden-/Kopfzeilen. Grid.RowDefinitions lässt sich in deutlich schmaler als Stunden-/Kopfzeilen. Grid.RowDefinitions lässt sich in
@@ -173,8 +191,16 @@
<Grid Margin="2"> <Grid Margin="2">
<Border Classes="weekheader" Classes.today="{Binding IsToday}" CornerRadius="4" <Border Classes="weekheader" Classes.today="{Binding IsToday}" CornerRadius="4"
IsVisible="{Binding IsHeader}"> IsVisible="{Binding IsHeader}">
<StackPanel Orientation="Horizontal" Spacing="5"
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="4">
<TextBlock Text="{Binding Text}" FontWeight="SemiBold" FontSize="12" Opacity="0.75" <TextBlock Text="{Binding Text}" FontWeight="SemiBold" FontSize="12" Opacity="0.75"
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="4"/> VerticalAlignment="Center"/>
<TextBlock Text="{Binding WeatherSymbol}" FontSize="16" VerticalAlignment="Center"
IsVisible="{Binding HasWeather}" ToolTip.Tip="{Binding WeatherTooltip}"/>
<TextBlock Text="⚠" FontSize="12" Foreground="#D85A30" FontWeight="Bold"
VerticalAlignment="Center" IsVisible="{Binding HasWeatherWarning}"
ToolTip.Tip="{Binding WeatherTooltip}"/>
</StackPanel>
</Border> </Border>
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}" <TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
FontWeight="SemiBold" FontSize="13" FontWeight="SemiBold" FontSize="13"
+10
View File
@@ -751,6 +751,16 @@ weiterhin keine automatisierte Testsuite (kein PHP-Testframework im Projekt).
hebt örtlich zutreffende Warnungen prominent hervor. Parser, Stationswahl, Persistenz, hebt örtlich zutreffende Warnungen prominent hervor. Parser, Stationswahl, Persistenz,
Standortwechsel und UI-Validierung sind durch API-/Desktop-Tests abgedeckt. Standortwechsel und UI-Validierung sind durch API-/Desktop-Tests abgedeckt.
**Nachtrag Wetter im Wochenstundenplan:** Die MOSMIX-Ausgabe des Servers umfasst nun die
vollständige Prognosespanne bis +240 Stunden statt nur 72 Stunden. Der schreibgeschützte
Wochenüberblick verdichtet die Stundenwerte von 0618 Uhr je Tag auf ein kleines Symbol im
Tageskopf (Priorität Gewitter → Sturm ab 62 km/h Böen → Regen → bewölkt → sonnig). Der
Tooltip nennt Temperaturspanne, Niederschlag, maximale Böen und betroffene Warnungen.
Amtliche DWD-Warnungen und Vorabinformationen erhalten zusätzlich ein Warnsymbol am Tag und
eine gut sichtbare Zusammenfassung oberhalb des Rasters; mehrtägige Warnungen werden jedem
überlappten Schultag zugeordnet. Vergangene Tage und Wochen außerhalb der verfügbaren
Prognose bleiben ohne Symbol, statt einen veralteten oder erfundenen Zustand anzuzeigen.
**Nachtrag zu 4.3 (Nutzer-Feedback nach Erstumsetzung):** **Nachtrag zu 4.3 (Nutzer-Feedback nach Erstumsetzung):**
- **Ferien/Feiertage-Pflege verschoben:** Die Bundesland-Auswahl und das Schulferien-CRUD standen - **Ferien/Feiertage-Pflege verschoben:** Die Bundesland-Auswahl und das Schulferien-CRUD standen
ursprünglich in der Seitenleiste des Stundenplans selbst — das wirkte dort deplatziert, da es ursprünglich in der Seitenleiste des Stundenplans selbst — das wirkte dort deplatziert, da es