diff --git a/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs b/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs
new file mode 100644
index 0000000..80ab3b0
--- /dev/null
+++ b/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs
@@ -0,0 +1,91 @@
+using System.IO.Compression;
+using System.Text;
+using Xunit;
+
+namespace LehrerApp.Api.Tests;
+
+public sealed class DwdWeatherServiceTests
+{
+ [Fact]
+ public void ParseStationCatalog_LiestFestbreitenformatUndFindetNaechsteStation()
+ {
+ const string catalog = """
+ ID ICAO NAME LAT LON ELEV
+ ----- ---- -------------------- ----- ------- -----
+ 10641 EDDF OFFENBACH-WETTERPARK 50.08 8.78 119
+ 10382 EDDT BERLIN-TEGEL 52.56 13.31 37
+ """;
+
+ var stations = DwdWeatherService.ParseStationCatalog(catalog);
+ var nearest = DwdWeatherService.FindNearestStation(stations, 50.1, 8.7);
+
+ Assert.Equal(2, stations.Count);
+ Assert.Equal("10641", nearest!.Id);
+ Assert.Equal("OFFENBACH-WETTERPARK", nearest.Name);
+ }
+
+ [Fact]
+ public void ParseMosmix_KonvertiertEinheitenUndBehaeltFehlwerteAlsNull()
+ {
+ var now = DateTime.UtcNow.AddHours(1).ToString("yyyy-MM-dd'T'HH:00:00.000'Z'");
+ var kml = $$"""
+
+
+
+ 2026-08-23T15:00:00Z
+ {{now}}
+
+ 293.15
+ 5
+ -
+ 1.25
+ 75
+ 61
+
+
+ """;
+
+ var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
+ var hour = Assert.Single(parsed.Hours);
+
+ Assert.Equal(20, hour.TemperatureC);
+ Assert.Equal(18, hour.WindSpeedKmh);
+ Assert.Null(hour.WindGustKmh);
+ Assert.Equal(1.3, hour.PrecipitationMm);
+ Assert.Equal(61, hour.WeatherCode);
+ }
+
+ [Fact]
+ public void ParseCapArchive_OrdnetWarnpolygonGeografischZu()
+ {
+ var xml = """
+
+ warnung-1Actual
+ de-DEGEWITTERSevere
+ 2026-08-23T18:00:00Z2099-08-23T20:00:00Z
+ Amtliche WarnungTest
+ 50.0,8.0 50.0,9.0 51.0,9.0 51.0,8.0 50.0,8.0
+
+
+ """;
+
+ var warning = Assert.Single(DwdWeatherService.ParseCapArchive(
+ Zip("warning.xml", xml), DateTime.UtcNow));
+
+ Assert.True(DwdWeatherService.PointInPolygon(warning.Polygons[0], 50.5, 8.5));
+ Assert.False(DwdWeatherService.PointInPolygon(warning.Polygons[0], 52.0, 8.5));
+ Assert.Equal("GEWITTER", warning.Warning.Event);
+ }
+
+ private static byte[] Zip(string fileName, string content)
+ {
+ using var memory = new MemoryStream();
+ using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ var entry = archive.CreateEntry(fileName);
+ using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false));
+ writer.Write(content);
+ }
+ return memory.ToArray();
+ }
+}
diff --git a/LehrerApp.Api.Tests/NominatimGeocoderTests.cs b/LehrerApp.Api.Tests/NominatimGeocoderTests.cs
new file mode 100644
index 0000000..e260808
--- /dev/null
+++ b/LehrerApp.Api.Tests/NominatimGeocoderTests.cs
@@ -0,0 +1,46 @@
+using System.Net;
+using System.Text;
+using LehrerApp.Core.Models;
+using Xunit;
+
+namespace LehrerApp.Api.Tests;
+
+public sealed class NominatimGeocoderTests
+{
+ [Fact]
+ public async Task GeocodeAsync_NutztStrukturierteDeutschlandSucheUndLiestDisplayName()
+ {
+ Uri? requestedUri = null;
+ var handler = new RecordingHandler(request =>
+ {
+ requestedUri = request.RequestUri;
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ """[{"lat":"50.105","lon":"8.761","display_name":"Testschule, Offenbach, Deutschland"}]""",
+ Encoding.UTF8, "application/json"),
+ };
+ });
+ var http = new HttpClient(handler) { BaseAddress = new Uri("https://nominatim.openstreetmap.org/") };
+ var geocoder = new NominatimGeocoder(http);
+
+ var result = await geocoder.GeocodeAsync(new SchoolLocationRequest
+ {
+ SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "63000", City = "Offenbach",
+ }, CancellationToken.None);
+
+ Assert.NotNull(result);
+ Assert.Equal(50.105, result!.Latitude);
+ Assert.Equal("Testschule, Offenbach, Deutschland", result.DisplayName);
+ Assert.Contains("countrycodes=de", requestedUri!.Query);
+ Assert.Contains("street=Schulweg%201", requestedUri.Query);
+ Assert.Contains("postalcode=63000", requestedUri.Query);
+ }
+
+ private sealed class RecordingHandler(Func response)
+ : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken) => Task.FromResult(response(request));
+ }
+}
diff --git a/LehrerApp.Api.Tests/SchoolWeatherStoreTests.cs b/LehrerApp.Api.Tests/SchoolWeatherStoreTests.cs
new file mode 100644
index 0000000..489e6ef
--- /dev/null
+++ b/LehrerApp.Api.Tests/SchoolWeatherStoreTests.cs
@@ -0,0 +1,49 @@
+using LehrerApp.Core.Models;
+using Xunit;
+
+namespace LehrerApp.Api.Tests;
+
+public sealed class SchoolWeatherStoreTests
+{
+ [Fact]
+ public void SaveLocation_TrenntNutzerUndErsetztVorhandenenStandort()
+ {
+ using var temp = new TempStore();
+ temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9));
+ temp.Store.SaveLocation("zwei", Location("Berlin", 52.5, 13.4));
+ temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1));
+
+ Assert.Equal("Bonn", temp.Store.GetLocation("eins")!.City);
+ Assert.Equal("Berlin", temp.Store.GetLocation("zwei")!.City);
+ }
+
+ [Fact]
+ public void SaveLocation_AenderungEntferntWettercacheDesAltenOrts()
+ {
+ using var temp = new TempStore();
+ temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9));
+ temp.Store.SaveWeather("eins", new WeatherSnapshot { StationId = "alt", RetrievedAt = DateTime.UtcNow });
+
+ temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1));
+
+ Assert.Null(temp.Store.GetWeather("eins"));
+ }
+
+ private static SchoolLocationProfile Location(string city, double lat, double lon) => new()
+ {
+ SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "12345", City = city,
+ Latitude = lat, Longitude = lon, ResolvedAddress = city, UpdatedAt = DateTime.UtcNow,
+ };
+
+ private sealed class TempStore : IDisposable
+ {
+ private readonly string _path = Path.Combine(Path.GetTempPath(), $"lehrerapp-weather-tests-{Guid.NewGuid():N}");
+ public SchoolWeatherStore Store { get; }
+ public TempStore() { Directory.CreateDirectory(_path); Store = new(_path); }
+ public void Dispose()
+ {
+ Store.Dispose();
+ if (Directory.Exists(_path)) Directory.Delete(_path, recursive: true);
+ }
+ }
+}
diff --git a/LehrerApp.Api/DwdWeatherService.cs b/LehrerApp.Api/DwdWeatherService.cs
new file mode 100644
index 0000000..83911d5
--- /dev/null
+++ b/LehrerApp.Api/DwdWeatherService.cs
@@ -0,0 +1,266 @@
+using System.Globalization;
+using System.IO.Compression;
+using System.Text.RegularExpressions;
+using System.Xml.Linq;
+using LehrerApp.Core.Models;
+
+namespace LehrerApp.Api;
+
+/// Lädt DWD-MOSMIX-Punktvorhersagen und CAP-Warnungen. Alle externen Antworten werden
+/// pro Serverinstanz gecacht; pro Benutzer wird zusätzlich der letzte erfolgreiche Snapshot im
+/// SchoolWeatherStore persistiert.
+public sealed class DwdWeatherService(HttpClient http)
+{
+ private const string StationCatalogUrl =
+ "https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication";
+ private const string ForecastUrl =
+ "https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{0}/kml/MOSMIX_L_LATEST_{0}.kmz";
+ private const string WarningsUrl =
+ "https://opendata.dwd.de/weather/alerts/cap/COMMUNEUNION_DWD_STAT/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_DE.zip";
+
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private List? _stations;
+ private DateTime _stationsLoadedAt;
+ private readonly Dictionary _forecastCache = [];
+ private CachedWarnings? _warningsCache;
+
+ public async Task GetAsync(SchoolLocationProfile location,
+ CancellationToken cancellationToken)
+ {
+ await _gate.WaitAsync(cancellationToken);
+ try
+ {
+ var stations = await GetStationsAsync(cancellationToken);
+ var station = FindNearestStation(stations, location.Latitude, location.Longitude)
+ ?? throw new InvalidOperationException("Der DWD-Stationskatalog enthält keine Stationen.");
+ var forecast = await GetForecastAsync(station, cancellationToken);
+ var warnings = await GetWarningsAsync(cancellationToken);
+
+ return new WeatherSnapshot
+ {
+ StationId = station.Id,
+ StationName = station.Name,
+ StationDistanceKm = Math.Round(DistanceKm(
+ location.Latitude, location.Longitude, station.Latitude, station.Longitude), 1),
+ ForecastIssuedAt = forecast.IssuedAt,
+ RetrievedAt = DateTime.UtcNow,
+ Forecast = forecast.Hours,
+ Warnings = warnings.Where(w => Contains(w.Polygons, location.Latitude, location.Longitude))
+ .Select(w => w.Warning)
+ .OrderByDescending(w => SeverityRank(w.Severity))
+ .ThenBy(w => w.Onset)
+ .ToList(),
+ };
+ }
+ finally { _gate.Release(); }
+ }
+
+ private async Task> GetStationsAsync(CancellationToken cancellationToken)
+ {
+ if (_stations is not null && DateTime.UtcNow - _stationsLoadedAt < TimeSpan.FromDays(7))
+ return _stations;
+ var text = await http.GetStringAsync(StationCatalogUrl, cancellationToken);
+ _stations = ParseStationCatalog(text);
+ _stationsLoadedAt = DateTime.UtcNow;
+ return _stations;
+ }
+
+ private async Task GetForecastAsync(MosmixStation station,
+ CancellationToken cancellationToken)
+ {
+ if (_forecastCache.TryGetValue(station.Id, out var cached) &&
+ DateTime.UtcNow - cached.LoadedAt < TimeSpan.FromMinutes(45))
+ return cached.Forecast;
+
+ var bytes = await http.GetByteArrayAsync(string.Format(CultureInfo.InvariantCulture,
+ ForecastUrl, station.Id), cancellationToken);
+ var parsed = ParseMosmix(bytes);
+ _forecastCache[station.Id] = new(DateTime.UtcNow, parsed);
+ return parsed;
+ }
+
+ private async Task> GetWarningsAsync(CancellationToken cancellationToken)
+ {
+ if (_warningsCache is not null &&
+ DateTime.UtcNow - _warningsCache.LoadedAt < TimeSpan.FromMinutes(5))
+ return _warningsCache.Warnings;
+ var bytes = await http.GetByteArrayAsync(WarningsUrl, cancellationToken);
+ var warnings = ParseCapArchive(bytes, DateTime.UtcNow);
+ _warningsCache = new(DateTime.UtcNow, warnings);
+ return warnings;
+ }
+
+ public static List ParseStationCatalog(string text)
+ {
+ var regex = new Regex(@"^(\S+)\s+\S+\s+(.{20})\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+", RegexOptions.Compiled);
+ var result = new List();
+ foreach (var line in text.Split('\n'))
+ {
+ var match = regex.Match(line.TrimEnd('\r'));
+ if (!match.Success ||
+ !double.TryParse(match.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) ||
+ !double.TryParse(match.Groups[4].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lon))
+ continue;
+ result.Add(new(match.Groups[1].Value, match.Groups[2].Value.Trim(), lat, lon));
+ }
+ return result;
+ }
+
+ public static MosmixStation? FindNearestStation(IEnumerable stations,
+ double latitude, double longitude) => stations.MinBy(s =>
+ DistanceKm(latitude, longitude, s.Latitude, s.Longitude));
+
+ public static ParsedForecast ParseMosmix(byte[] kmz)
+ {
+ using var memory = new MemoryStream(kmz);
+ using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
+ var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".kml", StringComparison.OrdinalIgnoreCase))
+ ?? throw new InvalidDataException("Die MOSMIX-Datei enthält kein KML-Dokument.");
+ using var stream = entry.Open();
+ var document = XDocument.Load(stream);
+ XNamespace dwd = "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd";
+
+ var times = document.Descendants(dwd + "TimeStep")
+ .Select(x => ParseDate(x.Value))
+ .Where(x => x.HasValue)
+ .Select(x => x!.Value)
+ .ToList();
+ if (times.Count == 0) throw new InvalidDataException("MOSMIX enthält keine Zeitschritte.");
+
+ var values = document.Descendants(dwd + "Forecast")
+ .Where(x => x.Attribute(dwd + "elementName") is not null)
+ .ToDictionary(
+ x => x.Attribute(dwd + "elementName")!.Value,
+ x => ParseValues(x.Element(dwd + "value")?.Value ?? ""),
+ StringComparer.Ordinal);
+
+ double? Value(string key, int i) => values.TryGetValue(key, out var series) && i < series.Count
+ ? series[i] : null;
+ var hours = new List();
+ var earliest = DateTime.UtcNow.AddHours(-1);
+ for (var i = 0; i < times.Count && hours.Count < 72; i++)
+ {
+ if (times[i] < earliest) continue;
+ hours.Add(new WeatherForecastHour
+ {
+ ValidAt = times[i],
+ TemperatureC = Round(Value("TTT", i) - 273.15, 1),
+ WindSpeedKmh = Round(Value("FF", i) * 3.6, 1),
+ WindGustKmh = Round(Value("FX1", i) * 3.6, 1),
+ PrecipitationMm = Round(Value("RR1c", i), 1),
+ CloudCoverPercent = Round(Value("Neff", i), 0),
+ WeatherCode = Value("ww", i) is { } code ? (int)Math.Round(code) : null,
+ });
+ }
+
+ return new(ParseDate(document.Descendants(dwd + "IssueTime").FirstOrDefault()?.Value), hours);
+ }
+
+ public static List ParseCapArchive(byte[] zip, DateTime nowUtc)
+ {
+ var result = new List();
+ using var memory = new MemoryStream(zip);
+ using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
+ foreach (var entry in archive.Entries.Where(x => x.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)))
+ {
+ using var stream = entry.Open();
+ var document = XDocument.Load(stream);
+ XNamespace cap = "urn:oasis:names:tc:emergency:cap:1.2";
+ var root = document.Root;
+ if (root is null || root.Element(cap + "status")?.Value != "Actual") continue;
+ var info = root.Elements(cap + "info").FirstOrDefault(x =>
+ x.Element(cap + "language")?.Value.StartsWith("de", StringComparison.OrdinalIgnoreCase) == true)
+ ?? root.Elements(cap + "info").FirstOrDefault();
+ if (info is null) continue;
+ var expires = ParseDate(info.Element(cap + "expires")?.Value);
+ if (expires is { } until && until < nowUtc) continue;
+
+ var polygons = info.Elements(cap + "area")
+ .SelectMany(x => x.Elements(cap + "polygon"))
+ .Select(x => ParsePolygon(x.Value))
+ .Where(x => x.Count >= 3)
+ .ToList();
+ // Warngebiete ohne Polygon (z.B. Seegebiete) können einer Schulkoordinate nicht
+ // zuverlässig zugeordnet werden und werden daher bewusst nicht global angezeigt.
+ if (polygons.Count == 0) continue;
+ result.Add(new ParsedWarning(new WeatherWarning
+ {
+ Identifier = root.Element(cap + "identifier")?.Value ?? "",
+ Event = info.Element(cap + "event")?.Value ?? "",
+ Headline = info.Element(cap + "headline")?.Value ?? "",
+ Description = info.Element(cap + "description")?.Value ?? "",
+ Instruction = info.Element(cap + "instruction")?.Value ?? "",
+ Severity = info.Element(cap + "severity")?.Value ?? "Unknown",
+ Onset = ParseDate(info.Element(cap + "onset")?.Value),
+ Expires = expires,
+ }, polygons));
+ }
+ return result;
+ }
+
+ private static List ParseValues(string raw) => raw
+ .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
+ .Select(x => double.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
+ ? (double?)value : null)
+ .ToList();
+
+ private static List ParsePolygon(string raw) => raw
+ .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
+ .Select(x => x.Split(','))
+ .Where(x => x.Length >= 2 &&
+ double.TryParse(x[0], NumberStyles.Float, CultureInfo.InvariantCulture, out _) &&
+ double.TryParse(x[1], NumberStyles.Float, CultureInfo.InvariantCulture, out _))
+ .Select(x => new GeoPoint(
+ double.Parse(x[0], CultureInfo.InvariantCulture),
+ double.Parse(x[1], CultureInfo.InvariantCulture)))
+ .ToList();
+
+ public static bool PointInPolygon(IReadOnlyList polygon, double latitude, double longitude)
+ {
+ var inside = false;
+ for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++)
+ {
+ var a = polygon[i];
+ var b = polygon[j];
+ if ((a.Latitude > latitude) != (b.Latitude > latitude) &&
+ longitude < (b.Longitude - a.Longitude) * (latitude - a.Latitude) /
+ (b.Latitude - a.Latitude) + a.Longitude)
+ inside = !inside;
+ }
+ return inside;
+ }
+
+ private static bool Contains(IEnumerable> polygons,
+ double latitude, double longitude) => polygons.Any(p => PointInPolygon(p, latitude, longitude));
+
+ private static double DistanceKm(double lat1, double lon1, double lat2, double lon2)
+ {
+ const double earthRadiusKm = 6371.0;
+ static double Rad(double degrees) => degrees * Math.PI / 180.0;
+ var dLat = Rad(lat2 - lat1);
+ var dLon = Rad(lon2 - lon1);
+ var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
+ Math.Cos(Rad(lat1)) * Math.Cos(Rad(lat2)) *
+ Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
+ return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
+ }
+
+ private static DateTime? ParseDate(string? value) =>
+ DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces,
+ out var parsed) ? parsed.UtcDateTime : null;
+
+ private static double? Round(double? value, int digits) => value is null
+ ? null : Math.Round(value.Value, digits, MidpointRounding.AwayFromZero);
+
+ private static int SeverityRank(string severity) => severity switch
+ {
+ "Extreme" => 4, "Severe" => 3, "Moderate" => 2, "Minor" => 1, _ => 0,
+ };
+
+ public sealed record MosmixStation(string Id, string Name, double Latitude, double Longitude);
+ public sealed record ParsedForecast(DateTime? IssuedAt, List Hours);
+ public sealed record GeoPoint(double Latitude, double Longitude);
+ public sealed record ParsedWarning(WeatherWarning Warning, List> Polygons);
+ private sealed record CachedForecast(DateTime LoadedAt, ParsedForecast Forecast);
+ private sealed record CachedWarnings(DateTime LoadedAt, List Warnings);
+}
diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs
index f4f8303..e7c696d 100644
--- a/LehrerApp.Api/Endpoints/Endpoints.cs
+++ b/LehrerApp.Api/Endpoints/Endpoints.cs
@@ -149,6 +149,123 @@ public static class Endpoints
});
}
+ // ── Schulstandort und DWD-Wetter ─────────────────────────────────────────
+
+ public static void MapSchoolWeatherEndpoints(this WebApplication app)
+ {
+ var g = app.MapGroup("/api/school").RequireAuthorization();
+
+ g.MapGet("/location", (ClaimsPrincipal user, SchoolWeatherStore store) =>
+ {
+ var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ if (uid is null) return Results.Unauthorized();
+ var location = store.GetLocation(uid);
+ return location is null
+ ? Results.NotFound("Noch kein Schulstandort hinterlegt.")
+ : Results.Ok(location);
+ });
+
+ g.MapPut("/location", async ([FromBody] SchoolLocationRequest request,
+ ClaimsPrincipal user, SchoolWeatherStore store, IGeocoder geocoder,
+ CancellationToken cancellationToken) =>
+ {
+ var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ if (uid is null) return Results.Unauthorized();
+
+ var errors = ValidateLocation(request);
+ if (errors.Count > 0) return Results.ValidationProblem(errors);
+
+ var existing = store.GetLocation(uid);
+ if (existing is not null && SameLocation(existing, request))
+ return Results.Ok(existing);
+
+ GeocodingResult? result;
+ try { result = await geocoder.GeocodeAsync(request, cancellationToken); }
+ catch (HttpRequestException)
+ {
+ return Results.Problem("Der Geocoding-Dienst ist momentan nicht erreichbar.",
+ statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+ catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return Results.Problem("Die Adressprüfung hat zu lange gedauert.",
+ statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+ if (result is null)
+ return Results.UnprocessableEntity(new
+ {
+ detail = "Die Schuladresse konnte nicht eindeutig gefunden werden. Bitte Schreibweise, PLZ und Ort prüfen.",
+ });
+
+ var profile = new SchoolLocationProfile
+ {
+ SchoolName = request.SchoolName.Trim(),
+ Street = request.Street.Trim(),
+ PostalCode = request.PostalCode.Trim(),
+ City = request.City.Trim(),
+ State = request.State,
+ Latitude = result.Latitude,
+ Longitude = result.Longitude,
+ ResolvedAddress = result.DisplayName,
+ UpdatedAt = DateTime.UtcNow,
+ };
+ store.SaveLocation(uid, profile);
+ return Results.Ok(profile);
+ });
+
+ g.MapGet("/weather", async (ClaimsPrincipal user, SchoolWeatherStore store,
+ DwdWeatherService weather, CancellationToken cancellationToken) =>
+ {
+ var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ if (uid is null) return Results.Unauthorized();
+ var location = store.GetLocation(uid);
+ if (location is null) return Results.NotFound("Noch kein Schulstandort hinterlegt.");
+
+ var cached = store.GetWeather(uid);
+ if (cached is not null && DateTime.UtcNow - cached.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5))
+ return Results.Ok(cached);
+ try
+ {
+ var current = await weather.GetAsync(location, cancellationToken);
+ store.SaveWeather(uid, current);
+ return Results.Ok(current);
+ }
+ catch (Exception ex) when (ex is HttpRequestException or InvalidDataException or InvalidOperationException)
+ {
+ if (cached is not null)
+ {
+ cached.IsStale = true;
+ return Results.Ok(cached);
+ }
+ return Results.Problem("Der DWD ist momentan nicht erreichbar und es liegt noch kein Wetter-Cache vor.",
+ statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+ });
+ }
+
+ private static Dictionary ValidateLocation(SchoolLocationRequest request)
+ {
+ var errors = new Dictionary();
+ if (string.IsNullOrWhiteSpace(request.SchoolName))
+ errors[nameof(request.SchoolName)] = ["Bitte den Namen der Schule angeben."];
+ if (string.IsNullOrWhiteSpace(request.Street))
+ errors[nameof(request.Street)] = ["Bitte Straße und Hausnummer angeben."];
+ if (!System.Text.RegularExpressions.Regex.IsMatch(request.PostalCode?.Trim() ?? "", @"^\d{5}$"))
+ errors[nameof(request.PostalCode)] = ["Bitte eine fünfstellige deutsche PLZ angeben."];
+ if (string.IsNullOrWhiteSpace(request.City))
+ errors[nameof(request.City)] = ["Bitte den Ort angeben."];
+ if (!Enum.IsDefined(request.State))
+ errors[nameof(request.State)] = ["Das Bundesland ist ungültig."];
+ return errors;
+ }
+
+ private static bool SameLocation(SchoolLocationProfile existing, SchoolLocationRequest request) =>
+ string.Equals(existing.SchoolName, request.SchoolName.Trim(), StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(existing.Street, request.Street.Trim(), StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(existing.PostalCode, request.PostalCode.Trim(), StringComparison.Ordinal) &&
+ string.Equals(existing.City, request.City.Trim(), StringComparison.OrdinalIgnoreCase) &&
+ existing.State == request.State;
+
// ── JWT ───────────────────────────────────────────────────────────────────
private static string Jwt(string userId, string secret)
diff --git a/LehrerApp.Api/NominatimGeocoder.cs b/LehrerApp.Api/NominatimGeocoder.cs
new file mode 100644
index 0000000..db81d30
--- /dev/null
+++ b/LehrerApp.Api/NominatimGeocoder.cs
@@ -0,0 +1,78 @@
+using System.Globalization;
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+using LehrerApp.Core.Models;
+
+namespace LehrerApp.Api;
+
+public interface IGeocoder
+{
+ Task GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken);
+}
+
+public sealed record GeocodingResult(double Latitude, double Longitude, string DisplayName);
+
+/// Einmalige, explizit ausgelöste Adressauflösung über den öffentlichen OSM-Nominatim-
+/// Dienst. Kein Autocomplete; die dauerhafte Ergebnisspeicherung übernimmt SchoolWeatherStore.
+public sealed class NominatimGeocoder(HttpClient http) : IGeocoder
+{
+ private static readonly SemaphoreSlim RequestGate = new(1, 1);
+ private static DateTime _lastRequestAt = DateTime.MinValue;
+
+ public async Task GeocodeAsync(SchoolLocationRequest address,
+ CancellationToken cancellationToken)
+ {
+ var structured = Query(new Dictionary
+ {
+ ["street"] = address.Street,
+ ["postalcode"] = address.PostalCode,
+ ["city"] = address.City,
+ });
+ var result = await SearchRespectingRateLimitAsync(structured, cancellationToken);
+ if (result is not null) return result;
+
+ // Manche Schulen/Ortsteile sind bei OSM anders adressiert. Ein kontrollierter zweiter
+ // Versuch mit der gesamten Suchzeile ist robuster, bleibt aber weit weg vom 1-rps-Limit.
+ var freeForm = string.Join(", ", new[]
+ {
+ address.SchoolName, address.Street, $"{address.PostalCode} {address.City}", "Deutschland",
+ }.Where(x => !string.IsNullOrWhiteSpace(x)));
+ return await SearchRespectingRateLimitAsync(
+ Query(new Dictionary { ["q"] = freeForm }), cancellationToken);
+ }
+
+ private async Task SearchRespectingRateLimitAsync(string query,
+ CancellationToken cancellationToken)
+ {
+ await RequestGate.WaitAsync(cancellationToken);
+ try
+ {
+ var wait = TimeSpan.FromSeconds(1) - (DateTime.UtcNow - _lastRequestAt);
+ if (wait > TimeSpan.Zero) await Task.Delay(wait, cancellationToken);
+ _lastRequestAt = DateTime.UtcNow;
+ var candidates = await http.GetFromJsonAsync>(
+ $"search?format=jsonv2&limit=1&countrycodes=de{query}", cancellationToken);
+ var candidate = candidates?.FirstOrDefault();
+ if (candidate is null ||
+ !double.TryParse(candidate.Lat, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) ||
+ !double.TryParse(candidate.Lon, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude))
+ return null;
+ return new(latitude, longitude, candidate.DisplayName ?? "");
+ }
+ finally { RequestGate.Release(); }
+ }
+
+ private static string Query(IReadOnlyDictionary values) => string.Concat(
+ values.Where(x => !string.IsNullOrWhiteSpace(x.Value))
+ .Select(x => $"&{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value.Trim())}"));
+
+ private sealed class NominatimCandidate
+ {
+ [JsonPropertyName("lat")]
+ public string? Lat { get; set; }
+ [JsonPropertyName("lon")]
+ public string? Lon { get; set; }
+ [JsonPropertyName("display_name")]
+ public string? DisplayName { get; set; }
+ }
+}
diff --git a/LehrerApp.Api/Program.cs b/LehrerApp.Api/Program.cs
index 34dca9d..fb4c224 100644
--- a/LehrerApp.Api/Program.cs
+++ b/LehrerApp.Api/Program.cs
@@ -26,6 +26,8 @@ if (args.Length > 0 && args[0] == "set-password")
var secret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
+var outboundUserAgent = builder.Configuration["Geocoding:UserAgent"]
+ ?? "LehrerApp-Server/1.0 (+https://science-teaching.de)";
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new()
@@ -81,6 +83,20 @@ builder.Services.AddSingleton(_ => new SnapshotStore(data));
builder.Services.AddSingleton(_ => new ReadableSnapshotStore(data));
builder.Services.AddSingleton(sp =>
new PlainEventStore(sp.GetRequiredService()));
+builder.Services.AddSingleton(_ => new SchoolWeatherStore(data));
+builder.Services.AddHttpClient(client =>
+{
+ client.BaseAddress = new Uri("https://nominatim.openstreetmap.org/");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
+ client.Timeout = TimeSpan.FromSeconds(15);
+});
+builder.Services.AddHttpClient("dwd", client =>
+{
+ client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
+ client.Timeout = TimeSpan.FromSeconds(30);
+});
+builder.Services.AddSingleton(sp => new DwdWeatherService(
+ sp.GetRequiredService().CreateClient("dwd")));
var app = builder.Build();
app.UseForwardedHeaders();
@@ -95,5 +111,6 @@ app.MapAttachmentEndpoints();
app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints();
+app.MapSchoolWeatherEndpoints();
app.Run();
return 0;
diff --git a/LehrerApp.Api/SchoolWeatherStore.cs b/LehrerApp.Api/SchoolWeatherStore.cs
new file mode 100644
index 0000000..629833c
--- /dev/null
+++ b/LehrerApp.Api/SchoolWeatherStore.cs
@@ -0,0 +1,83 @@
+using LehrerApp.Core.Models;
+using LiteDB;
+
+namespace LehrerApp.Api;
+
+/// Unverschlüsselte Serverdaten, die der Server zur Abfrage standortbezogener öffentlicher
+/// Wetterdaten benötigt. Bewusst getrennt vom clientseitig verschlüsselten EventStore.
+public sealed class SchoolWeatherStore : IDisposable
+{
+ private readonly LiteDatabase _db;
+
+ public SchoolWeatherStore(string dataPath)
+ {
+ Directory.CreateDirectory(dataPath);
+ _db = new LiteDatabase(Path.Combine(dataPath, "school-weather.db"));
+ }
+
+ private ILiteCollection Locations
+ {
+ get
+ {
+ var col = _db.GetCollection("locations");
+ col.EnsureIndex(x => x.UserId, unique: true);
+ return col;
+ }
+ }
+
+ private ILiteCollection Weather
+ {
+ get
+ {
+ var col = _db.GetCollection("weather");
+ col.EnsureIndex(x => x.UserId, unique: true);
+ return col;
+ }
+ }
+
+ public SchoolLocationProfile? GetLocation(string userId) =>
+ Locations.FindOne(x => x.UserId == userId)?.Profile;
+
+ public void SaveLocation(string userId, SchoolLocationProfile profile)
+ {
+ var existing = Locations.FindOne(x => x.UserId == userId);
+ Locations.Upsert(new LocationEntry
+ {
+ Id = existing?.Id ?? ObjectId.NewObjectId(),
+ UserId = userId,
+ Profile = profile,
+ });
+ // Ein geänderter Standort darf niemals den Wetterstand des alten Orts liefern.
+ Weather.DeleteMany(x => x.UserId == userId);
+ }
+
+ public WeatherSnapshot? GetWeather(string userId) =>
+ Weather.FindOne(x => x.UserId == userId)?.Snapshot;
+
+ public void SaveWeather(string userId, WeatherSnapshot snapshot)
+ {
+ var existing = Weather.FindOne(x => x.UserId == userId);
+ Weather.Upsert(new WeatherEntry
+ {
+ Id = existing?.Id ?? ObjectId.NewObjectId(),
+ UserId = userId,
+ Snapshot = snapshot,
+ });
+ }
+
+ public void Dispose() => _db.Dispose();
+
+ private sealed class LocationEntry
+ {
+ public ObjectId Id { get; set; } = ObjectId.NewObjectId();
+ public string UserId { get; set; } = "";
+ public SchoolLocationProfile Profile { get; set; } = new();
+ }
+
+ private sealed class WeatherEntry
+ {
+ public ObjectId Id { get; set; } = ObjectId.NewObjectId();
+ public string UserId { get; set; } = "";
+ public WeatherSnapshot Snapshot { get; set; } = new();
+ }
+}
diff --git a/LehrerApp.Core/Models/Weather.cs b/LehrerApp.Core/Models/Weather.cs
new file mode 100644
index 0000000..93927f3
--- /dev/null
+++ b/LehrerApp.Core/Models/Weather.cs
@@ -0,0 +1,59 @@
+namespace LehrerApp.Core.Models;
+
+/// Vom Nutzer eingegebene Schuladresse. Die Geocodierung erfolgt ausschließlich
+/// serverseitig beim expliziten Speichern, nicht während der Eingabe.
+public class SchoolLocationRequest
+{
+ public string SchoolName { get; set; } = "";
+ public string Street { get; set; } = "";
+ public string PostalCode { get; set; } = "";
+ public string City { get; set; } = "";
+ public GermanState State { get; set; } = GermanState.NW;
+}
+
+/// Serverseitig gespeicherter Schulstandort einschließlich des bestätigbaren
+/// Geocoding-Ergebnisses. Koordinaten werden nie an den DWD als Straßenadresse übertragen.
+public sealed class SchoolLocationProfile : SchoolLocationRequest
+{
+ public double Latitude { get; set; }
+ public double Longitude { get; set; }
+ public string ResolvedAddress { get; set; } = "";
+ public DateTime UpdatedAt { get; set; }
+}
+
+public sealed class WeatherSnapshot
+{
+ public string StationId { get; set; } = "";
+ public string StationName { get; set; } = "";
+ public double StationDistanceKm { get; set; }
+ public DateTime? ForecastIssuedAt { get; set; }
+ public DateTime RetrievedAt { get; set; }
+ /// True, wenn der DWD vorübergehend nicht erreichbar war und der Server den zuletzt
+ /// erfolgreich gespeicherten Stand zurückgibt.
+ public bool IsStale { get; set; }
+ public List Forecast { get; set; } = [];
+ public List Warnings { get; set; } = [];
+}
+
+public sealed class WeatherForecastHour
+{
+ public DateTime ValidAt { get; set; }
+ public double? TemperatureC { get; set; }
+ public double? WindSpeedKmh { get; set; }
+ public double? WindGustKmh { get; set; }
+ public double? PrecipitationMm { get; set; }
+ public double? CloudCoverPercent { get; set; }
+ public int? WeatherCode { get; set; }
+}
+
+public sealed class WeatherWarning
+{
+ public string Identifier { get; set; } = "";
+ public string Event { get; set; } = "";
+ public string Headline { get; set; } = "";
+ public string Description { get; set; } = "";
+ public string Instruction { get; set; } = "";
+ public string Severity { get; set; } = "";
+ public DateTime? Onset { get; set; }
+ public DateTime? Expires { get; set; }
+}
diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs
index 098d707..c9d089c 100644
--- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs
@@ -523,4 +523,22 @@ public sealed class DashboardViewModelTests
Assert.Empty(tasks.GetAll());
}
+
+ [Fact]
+ public void WeatherWarningItem_BereitetAmtlicheWarnungFuerDashboardAuf()
+ {
+ var warning = new WeatherWarning
+ {
+ Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter",
+ Description = "Es treten Gewitter auf.", Instruction = "Gebäude aufsuchen.",
+ Severity = "Severe", Onset = new DateTime(2026, 8, 23, 16, 0, 0, DateTimeKind.Utc),
+ Expires = new DateTime(2026, 8, 23, 18, 0, 0, DateTimeKind.Utc),
+ };
+
+ var item = new DashboardWeatherWarningItem(warning);
+
+ Assert.Equal("Amtliche Warnung vor Gewitter", item.Headline);
+ Assert.Equal("#D32F2F", item.SeverityColor);
+ Assert.Contains("Uhr", item.PeriodDisplay);
+ }
}
diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs
index 7006aed..9d99a8c 100644
--- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs
@@ -275,6 +275,34 @@ public sealed class SettingsViewModelTests
Assert.Empty(vm.SchoolHolidayEntries);
}
+ [Fact]
+ public async Task SaveSchoolLocation_FehlendeFelder_ZeigtAlleFeldfehler()
+ {
+ var vm = BuildViewModel();
+
+ await vm.SaveSchoolLocationCommand.ExecuteAsync(null);
+
+ Assert.NotEmpty(vm.SchoolNameError);
+ Assert.NotEmpty(vm.SchoolStreetError);
+ Assert.NotEmpty(vm.SchoolPostalCodeError);
+ Assert.NotEmpty(vm.SchoolCityError);
+ }
+
+ [Fact]
+ public async Task SaveSchoolLocation_UngueltigePlz_RuftKeinenServerAuf()
+ {
+ var vm = BuildViewModel();
+ vm.SchoolName = "Testschule";
+ vm.SchoolStreet = "Schulweg 1";
+ vm.SchoolPostalCode = "1234";
+ vm.SchoolCity = "Teststadt";
+
+ await vm.SaveSchoolLocationCommand.ExecuteAsync(null);
+
+ Assert.Contains("fünfstellige", vm.SchoolPostalCodeError);
+ Assert.Empty(vm.SchoolLocationStatus);
+ }
+
[Fact]
public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings()
{
diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs
index 90a2359..4938975 100644
--- a/LehrerApp.Desktop/AppBootstrapper.cs
+++ b/LehrerApp.Desktop/AppBootstrapper.cs
@@ -222,6 +222,7 @@ public static class AppBootstrapper
var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings);
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
+ services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
diff --git a/LehrerApp.Desktop/Services/SchoolWeatherService.cs b/LehrerApp.Desktop/Services/SchoolWeatherService.cs
new file mode 100644
index 0000000..a095e44
--- /dev/null
+++ b/LehrerApp.Desktop/Services/SchoolWeatherService.cs
@@ -0,0 +1,73 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using LehrerApp.Core.Models;
+
+namespace LehrerApp.Desktop.Services;
+
+public sealed class SchoolWeatherException(string message) : Exception(message);
+
+/// Client für das serverseitige Schulstandort-/Wetterprofil. Liest URL und Token je
+/// Aufruf, damit ein Login oder Serverwechsel in den Einstellungen sofort berücksichtigt wird.
+public sealed class SchoolWeatherService(HttpClient http, SyncSettingsService syncSettings)
+{
+ public bool IsAvailable => !string.IsNullOrWhiteSpace(syncSettings.ServerUrl) && syncSettings.IsLoggedIn;
+
+ public async Task GetLocationAsync(CancellationToken cancellationToken = default)
+ {
+ using var response = await SendAsync(HttpMethod.Get, "/api/school/location", null, cancellationToken);
+ if (response.StatusCode == HttpStatusCode.NotFound) return null;
+ await EnsureSuccessAsync(response);
+ return await response.Content.ReadFromJsonAsync(cancellationToken);
+ }
+
+ public async Task SaveLocationAsync(SchoolLocationRequest location,
+ CancellationToken cancellationToken = default)
+ {
+ using var response = await SendAsync(HttpMethod.Put, "/api/school/location", location, cancellationToken);
+ await EnsureSuccessAsync(response);
+ return await response.Content.ReadFromJsonAsync(cancellationToken)
+ ?? throw new SchoolWeatherException("Der Server hat keine Standortdaten zurückgegeben.");
+ }
+
+ public async Task GetWeatherAsync(CancellationToken cancellationToken = default)
+ {
+ using var response = await SendAsync(HttpMethod.Get, "/api/school/weather", null, cancellationToken);
+ if (response.StatusCode == HttpStatusCode.NotFound) return null;
+ await EnsureSuccessAsync(response);
+ return await response.Content.ReadFromJsonAsync(cancellationToken);
+ }
+
+ private async Task SendAsync(HttpMethod method, string path, object? body,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(syncSettings.ServerUrl) || !syncSettings.IsLoggedIn)
+ throw new SchoolWeatherException("Bitte zuerst im Reiter „Synchronisation“ am Server anmelden.");
+ using var request = new HttpRequestMessage(method, new Uri(new Uri(syncSettings.ServerUrl), path));
+ var token = syncSettings.GetToken();
+ if (token is not null)
+ request.Headers.Authorization = new("Bearer", token);
+ if (body is not null) request.Content = JsonContent.Create(body);
+ try { return await http.SendAsync(request, cancellationToken); }
+ catch (HttpRequestException)
+ {
+ throw new SchoolWeatherException("Der LehrerApp-Server ist nicht erreichbar.");
+ }
+ }
+
+ private static async Task EnsureSuccessAsync(HttpResponseMessage response)
+ {
+ if (response.IsSuccessStatusCode) return;
+ if (response.StatusCode == HttpStatusCode.Unauthorized)
+ throw new SchoolWeatherException("Die Server-Anmeldung ist abgelaufen. Bitte erneut anmelden.");
+ try
+ {
+ using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ if (document.RootElement.TryGetProperty("detail", out var detail) &&
+ !string.IsNullOrWhiteSpace(detail.GetString()))
+ throw new SchoolWeatherException(detail.GetString()!);
+ }
+ catch (JsonException) { }
+ throw new SchoolWeatherException("Der Schulstandort konnte nicht gespeichert werden.");
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
index 0349001..5ac6cae 100644
--- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
@@ -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 Alerts { get; } = [];
public ObservableCollection SelectedDayEvents { get; } = [];
public ObservableCollection DashboardCards { get; } = [];
+ public ObservableCollection 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();
+ 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; }
diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
index 0645af9..c05da80 100644
--- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
@@ -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 StateOptions { get; } = GermanStateDisplay.Options.ToList();
public ObservableCollection 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();
diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
index c56c025..673a171 100644
--- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
+++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
@@ -54,6 +54,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
index ef23130..66d0108 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
@@ -415,6 +415,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
docker compose up -d
```
-Alle Server-Daten (Ereignis-Logs, Snapshots, Anhänge, Nutzer) liegen im Named Volume `api-data` –
+Alle Server-Daten (Ereignis-Logs, Snapshots, Anhänge, Nutzer, Schulstandorte und Wetter-Cache)
+liegen im Named Volume `api-data` –
bei Neustarts/Updates bleibt es erhalten. Bewusst **kein** Bind-Mount auf einen Host-Pfad: bei
Git-basierten Deployments (z. B. Dokploy) wird das Checkout-Verzeichnis bei jedem Redeploy neu
geklont, ein dort liegendes `./data` wäre dabei jedes Mal leer (siehe TODO.md 10.2.4 für den
@@ -74,6 +75,16 @@ Endpunkte sind zusätzlich global auf 120 Anfragen pro Minute je IP begrenzt. An
15 MB Anfragegröße gedeckelt (Kestrel `MaxRequestBodySize`), einzelne Dateien clientseitig
zusätzlich auf 10 MB (`IAttachmentStorage.MaxSizeBytes`).
+## Schulstandort und Wetter
+
+Die authentifizierten Endpunkte unter `/api/school` geocodieren eine Schuladresse einmalig über
+Nominatim/OpenStreetMap und laden danach ausschließlich mit den ermittelten Koordinaten
+MOSMIX-Vorhersagen und CAP-Warnungen des DWD. Ergebnisse werden gecacht; bei einem vorübergehenden
+DWD-Ausfall liefert der Server den letzten erfolgreichen Stand. Für eine eigene Installation sollte
+`GEOCODING_USER_AGENT` auf eine Kennung mit eigener Kontakt-Webseite gesetzt werden, beispielsweise
+`LehrerApp-Server/1.0 (+https://schule.example)`. Der Container benötigt ausgehenden HTTPS-Zugriff
+auf `nominatim.openstreetmap.org`, `www.dwd.de` und `opendata.dwd.de`.
+
## Deployment über Dokploy
Kein manuelles Bauen/Hochladen nötig – Dokploy zieht das Repo direkt per Git und baut das Image
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index e09aefe..7be6e0e 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -12,6 +12,7 @@ services:
- api-data:/app/data
environment:
- JWT_SECRET=${JWT_SECRET}
+ - Geocoding__UserAgent=${GEOCODING_USER_AGENT:-LehrerApp-Server/1.0 (+https://science-teaching.de)}
- ASPNETCORE_ENVIRONMENT=Production
restart: unless-stopped