Wetterdienst: Verhersage verlängert, Anzeige im Stundenplan.
This commit is contained in:
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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("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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 06–18 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
|
||||||
|
|||||||
Reference in New Issue
Block a user