From aef05fb090aa39607b5846269331b28473a6817e Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 08:53:47 +0200 Subject: [PATCH 1/9] Convert PocketBase dates to Berlin time; document code conventions --- CLAUDE.md | 34 +++- Elternbeirat.Contracts/Event.cs | 50 ++++-- Elternbeirat.Contracts/Faq.cs | 25 ++- Elternbeirat.Contracts/Page.cs | 58 +++++-- Elternbeirat.Contracts/Post.cs | 40 ++++- .../LocalDateTimeConverter.cs | 156 ++++++++++++------ Elternbeirat.PocketBase/PocketBaseClient.cs | 125 +++++++++++--- Elternbeirat.PocketBase/RecordList.cs | 20 ++- .../PocketBaseClientTests.cs | 7 +- Elternbeirat.Web.Tests/PocketBaseFixture.cs | 9 +- docs/redaktion.md | 11 +- 11 files changed, 406 insertions(+), 129 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index be11c9e..0356428 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,11 +83,41 @@ Inhalt liegt in PocketBase (`pb_data`), das separat gesichert wird (siehe - Datenzugriff über den typisierten `PocketBaseClient` (registriert via `AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit Inhalten. Komponenten liegen feature-basiert unter `Features//`. -- Öffentliche Typen und Methoden im `PocketBaseClient` und in `Contracts` - bekommen XML-Doc (auf Englisch, leicht verständlich), Razor-Markup nicht. - **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare, Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht `Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein Besucher liest oder ein Redakteur pflegt: UI-Texte sowie die **Werte** der PocketBase-Records (z. B. `title: Vorstandsteam`, der Markdown-`body`). Die Feldnamen und Slugs bleiben dagegen englisch (`title`, `slug`, `/board`). + +## Coding Convention + +- **Ausdruckskörper (`=>`) sind Pflicht, wo syntaktisch möglich** — Methoden, + Properties, Konstruktoren, Operatoren, lokale Funktionen. Ein `if/else`, das + einen Wert liefert, wird zum ternären Ausdruck oder zur `switch`-Expression, kein + Block-Körper mit `return`. Ein Block-Körper nur, wo ein Ausdruck sprachlich nicht + geht (mehrere Anweisungen ohne Rückgabe, `ref`/`out`, `yield`). +- Ternäre und `switch`-Expressions dürfen dafür mehrzeilig umgebrochen werden; + Lesbarkeit entsteht durch Einrückung, nicht durch einen Block. +- Nullable aktiv nutzen: `?`, `??`, `??=` statt Nullprüfungen im Block. Ein + ungültiger `null`-Fall wird als Ausdruck geworfen (`?? throw new …`). +- Argumente/Rückgaben früh und knapp validieren, bevorzugt als Ausdruck. + +## Documentation Convention + +Vorbild ist `Elternbeirat.PocketBase/LocalDateTimeConverter.cs` — daran +ausrichten. + +- **Jeder öffentliche (`public`/`protected`) Typ und Member bekommt XML-Doc** — + nicht nur `PocketBaseClient` und `Contracts`. Interne Helfer, die Teil der + fachlichen Erklärung sind (wie `WallClock`), ebenfalls. Razor-Markup nicht. +- Voller Umfang, wo zutreffend: ``, dazu ``, ``, + `` (jede geworfene Bedingung), `` für Kontext/Fallstricke, + `` mit `` für nicht offensichtliche Nutzung, `` auf + verwandte Typen. `` bei Interface-/Basis-Implementierungen. +- Code im Text als Markup referenzieren, nicht als Prosa: ``, + ``/``, `…` für Literale. +- Einrückung: der Textinhalt steht mit vier Leerzeichen unter dem `///`-Tag + (`/// Text`), Tags sauber verschachtelt. +- Englisch, leicht verständlich, erklärt **warum**, nicht was der Code ohnehin + zeigt. diff --git a/Elternbeirat.Contracts/Event.cs b/Elternbeirat.Contracts/Event.cs index 22201ef..dd255f2 100644 --- a/Elternbeirat.Contracts/Event.cs +++ b/Elternbeirat.Contracts/Event.cs @@ -2,38 +2,68 @@ using System.Text.Json.Serialization; namespace Elternbeirat.Contracts; -/// A calendar entry. Sorted by . +/// +/// A calendar entry of the Elternbeirat, as stored in PocketBase. +/// +/// +/// Event lists are ordered by . Unset optional text fields are +/// , never . +/// public record Event { - /// PocketBase record id. + /// + /// Gets the PocketBase record id. + /// [JsonPropertyName("id")] public string Id { get; init; } = ""; /// - /// Start of the event with date and time. Stored as UTC by PocketBase but - /// read as local time (Europe/Berlin) by convention. An all-day event uses - /// 00:00 as the time. + /// Gets the start of the event as Europe/Berlin wall-clock time. /// + /// + /// PocketBase stores the value in UTC; it is converted to Berlin local time on + /// deserialization, with daylight saving applied. An all-day event uses 00:00 + /// as the time. + /// [JsonPropertyName("start")] public DateTime Start { get; init; } - /// Optional end of the event; null when unset. + /// + /// Gets the optional end of the event as Europe/Berlin wall-clock time. + /// + /// + /// The end time, or if the event has no end. + /// [JsonPropertyName("end")] public DateTime? End { get; init; } - /// Event name, e.g. "Elternbeiratssitzung". + /// + /// Gets the event name, e.g. "Elternbeiratssitzung". + /// [JsonPropertyName("title")] public string Title { get; init; } = ""; - /// Optional location, e.g. "Aula". + /// + /// Gets the optional location, e.g. "Aula". + /// + /// + /// The location, or if none is set. + /// [JsonPropertyName("location")] public string Location { get; init; } = ""; - /// Optional note, e.g. "Anmeldung erforderlich". + /// + /// Gets the optional note, e.g. "Anmeldung erforderlich". + /// + /// + /// The note, or if none is set. + /// [JsonPropertyName("note")] public string Note { get; init; } = ""; - /// Whether the event is visible to visitors. + /// + /// Gets a value indicating whether the event is visible to visitors of the site. + /// [JsonPropertyName("public")] public bool Public { get; init; } } diff --git a/Elternbeirat.Contracts/Faq.cs b/Elternbeirat.Contracts/Faq.cs index 70ac2a2..bc8dd38 100644 --- a/Elternbeirat.Contracts/Faq.cs +++ b/Elternbeirat.Contracts/Faq.cs @@ -3,27 +3,40 @@ using System.Text.Json.Serialization; namespace Elternbeirat.Contracts; /// -/// A single question and answer, grouped on the FAQ page by . +/// A single question and answer of the FAQ, as stored in PocketBase. /// +/// +/// On the FAQ page, entries are grouped by . +/// public record Faq { - /// PocketBase record id. + /// + /// Gets the PocketBase record id. + /// [JsonPropertyName("id")] public string Id { get; init; } = ""; - /// The question as a parent would phrase it. + /// + /// Gets the question as a parent would phrase it. + /// [JsonPropertyName("question")] public string Question { get; init; } = ""; - /// The answer in Markdown. + /// + /// Gets the answer as Markdown. + /// [JsonPropertyName("answer")] public string Answer { get; init; } = ""; - /// Topic the question is grouped under, e.g. "mensa". + /// + /// Gets the topic the question is grouped under, e.g. "mensa". + /// [JsonPropertyName("topic")] public string Topic { get; init; } = ""; - /// Whether the question is visible to visitors. + /// + /// Gets a value indicating whether the question is visible to visitors of the site. + /// [JsonPropertyName("public")] public bool Public { get; init; } } diff --git a/Elternbeirat.Contracts/Page.cs b/Elternbeirat.Contracts/Page.cs index 4c1ac14..5bfa2fb 100644 --- a/Elternbeirat.Contracts/Page.cs +++ b/Elternbeirat.Contracts/Page.cs @@ -3,45 +3,77 @@ using System.Text.Json.Serialization; namespace Elternbeirat.Contracts; /// -/// A content page. Pages also drive the site navigation: -/// and decide where and in which order a page appears in the -/// header or footer menu, and lists dynamic blocks (posts, -/// events, faqs) rendered below the page body. +/// A content page of the site, as stored in PocketBase. /// +/// +/// Pages also drive the site navigation: and +/// decide where and in which order a page appears in the +/// header or footer menu. lists dynamic blocks rendered +/// below the page body. +/// public record Page { - /// PocketBase record id. + /// + /// Gets the PocketBase record id. + /// [JsonPropertyName("id")] public string Id { get; init; } = ""; - /// Heading shown to visitors; may contain umlauts and spaces. + /// + /// Gets the heading shown to visitors. + /// + /// + /// Free text; may contain umlauts and spaces. For the URL, see . + /// [JsonPropertyName("title")] public string Title { get; init; } = ""; - /// Page body in Markdown. + /// + /// Gets the page body as Markdown. + /// [JsonPropertyName("body")] public string Body { get; init; } = ""; - /// Where the page appears in the navigation: "header" or "footer". + /// + /// Gets the navigation menu the page appears in. + /// + /// + /// Either "header" or "footer". + /// [JsonPropertyName("location")] public string Location { get; init; } = ""; - /// Sort order within its navigation location; smaller is earlier. + /// + /// Gets the sort order within the navigation menu given by . + /// + /// + /// The sort key; pages with smaller values appear first. + /// [JsonPropertyName("order")] public double Order { get; init; } - /// URL slug (lowercase, no umlauts), e.g. "board" -> /board. + /// + /// Gets the URL slug of the page, e.g. "board" for /board. + /// + /// + /// A lowercase path segment without umlauts. + /// [JsonPropertyName("slug")] public string Slug { get; init; } = ""; /// - /// Dynamic blocks to render below the body: any of "posts", "events", "faqs". - /// Empty for a plain text page. + /// Gets the dynamic blocks rendered below the . /// + /// + /// Any of "posts", "events" and "faqs", or an empty list + /// for a plain text page. + /// [JsonPropertyName("embed")] public IReadOnlyList Embed { get; init; } = []; - /// Whether the page is visible to visitors. + /// + /// Gets a value indicating whether the page is visible to visitors of the site. + /// [JsonPropertyName("public")] public bool Public { get; init; } } diff --git a/Elternbeirat.Contracts/Post.cs b/Elternbeirat.Contracts/Post.cs index 382200c..0fb6db4 100644 --- a/Elternbeirat.Contracts/Post.cs +++ b/Elternbeirat.Contracts/Post.cs @@ -2,33 +2,57 @@ using System.Text.Json.Serialization; namespace Elternbeirat.Contracts; -/// A news post. Sorted by , newest first. +/// +/// A news post of the site, as stored in PocketBase. +/// +/// +/// Post lists are ordered by , newest first. +/// public record Post { - /// PocketBase record id. + /// + /// Gets the PocketBase record id. + /// [JsonPropertyName("id")] public string Id { get; init; } = ""; /// - /// Publication date. Stored as UTC by PocketBase but read as local time - /// (Europe/Berlin) by convention; only the date part is shown. + /// Gets the publication date as Europe/Berlin wall-clock time. /// + /// + /// PocketBase stores the value in UTC; it is converted to Berlin local time on + /// deserialization, with daylight saving applied. Only the date part is shown. + /// [JsonPropertyName("date")] public DateTime Date { get; init; } - /// Post heading shown to visitors; may contain umlauts and spaces. + /// + /// Gets the heading shown to visitors. + /// + /// + /// Free text; may contain umlauts and spaces. For the URL, see . + /// [JsonPropertyName("title")] public string Title { get; init; } = ""; - /// Post body in Markdown. + /// + /// Gets the post body as Markdown. + /// [JsonPropertyName("body")] public string Body { get; init; } = ""; - /// URL slug (lowercase, no umlauts), e.g. "herbstbasar" -> /posts/herbstbasar. + /// + /// Gets the URL slug of the post, e.g. "herbstbasar" for /posts/herbstbasar. + /// + /// + /// A lowercase path segment without umlauts. + /// [JsonPropertyName("slug")] public string Slug { get; init; } = ""; - /// Whether the post is visible to visitors. + /// + /// Gets a value indicating whether the post is visible to visitors of the site. + /// [JsonPropertyName("public")] public bool Public { get; init; } } diff --git a/Elternbeirat.PocketBase/LocalDateTimeConverter.cs b/Elternbeirat.PocketBase/LocalDateTimeConverter.cs index ec9ee0d..968f622 100644 --- a/Elternbeirat.PocketBase/LocalDateTimeConverter.cs +++ b/Elternbeirat.PocketBase/LocalDateTimeConverter.cs @@ -5,71 +5,127 @@ using System.Text.Json.Serialization; namespace Elternbeirat.PocketBase; /// -/// Shared parsing of a PocketBase date string as wall-clock time. The trailing -/// "Z" is stripped rather than honoured, so the number is taken at face value and -/// the result carries -- no timezone shift. +/// Converts between PocketBase date strings and Europe/Berlin wall-clock time. /// +/// +/// PocketBase stores every date in UTC and serializes it with a trailing Z +/// (e.g. "2026-10-08 17:30:00.000Z"). The values produced here are local +/// wall-clock numbers: an editor who typed 19:30 in the admin form gets 19:30 back, +/// with daylight saving applied by the time zone. They carry +/// so that no later formatting shifts them again. +/// internal static class WallClock { - public static DateTime Parse(string raw) - { - // Drop a trailing "Z" so DateTime.Parse does not treat the value as UTC - // and convert it to local time (which would shift 19:30 to 20:30/21:30). - var value = raw.EndsWith('Z') ? raw[..^1] : raw; - var parsed = DateTime.Parse(value, CultureInfo.InvariantCulture, - DateTimeStyles.None); - return DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified); - } + /// + /// IANA id; resolves on every platform .NET supports, Windows included. + /// + private static readonly TimeZoneInfo Berlin = + TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + + /// + /// Parses a PocketBase UTC date string into Berlin wall-clock time. + /// + /// + /// The raw PocketBase value; may be or empty. + /// + /// + /// The Berlin wall-clock time with , or + /// if is or empty + /// (PocketBase's representation of an unset date). + /// + /// + /// is not a valid date string. + /// + public static DateTime? Parse(string? raw) => + string.IsNullOrEmpty(raw) + ? null + : TimeZoneInfo.ConvertTime( + DateTimeOffset.Parse(raw, CultureInfo.InvariantCulture), Berlin).DateTime; + + /// + /// Formats a Berlin wall-clock time as the UTC string PocketBase stores. + /// Inverse of . + /// + /// + /// A Berlin wall-clock time. Its is ignored, and the + /// value is always interpreted as Berlin local time. + /// + /// + /// The UTC value in PocketBase format, e.g. "2026-10-08 17:30:00.000Z". + /// + /// + /// does not exist in Berlin, because it falls in the + /// daylight-saving gap in spring. + /// + public static string Format(DateTime berlin) => + TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(berlin, DateTimeKind.Unspecified), Berlin) + .ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); } /// -/// Reads PocketBase date values as local wall-clock time. -/// -/// PocketBase stores every date in UTC and serializes it with a trailing "Z" -/// (e.g. "2026-10-08 19:30:00.000Z"). By project convention the stored number -/// IS the local time (Europe/Berlin) and the "Z" is ignored -- see the timezone -/// decision in the data model. This converter therefore parses the value and -/// returns it as an instant, so no -/// timezone shift is ever applied when the value is later formatted. -/// +/// JSON converter that reads and writes PocketBase dates as Europe/Berlin +/// wall-clock values. /// +/// +/// An empty string (unset date) is read as (). +/// For optional dates, use instead. +/// +/// +/// +/// [JsonConverter(typeof(LocalDateTimeConverter))] +/// public DateTime Start { get; init; } +/// +/// +/// public sealed class LocalDateTimeConverter : JsonConverter { - public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var raw = reader.GetString(); - return string.IsNullOrEmpty(raw) - ? default - : WallClock.Parse(raw); - } + /// + public override DateTime Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + => WallClock.Parse(reader.GetString()) ?? default; - public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) - => writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", - CultureInfo.InvariantCulture)); + /// + public override void Write( + Utf8JsonWriter writer, + DateTime value, + JsonSerializerOptions options) + => writer.WriteStringValue(WallClock.Format(value)); } /// -/// Nullable counterpart of . PocketBase sends -/// an empty string for an unset optional date (e.g. an event without an end); -/// that maps to null. +/// JSON converter that reads and writes optional PocketBase dates as Europe/Berlin +/// wall-clock values. /// +/// +/// PocketBase sends an empty string for an unset optional date (e.g. an event +/// without an end). This value, like JSON null, is read as . +/// +/// +/// +/// [JsonConverter(typeof(NullableLocalDateTimeConverter))] +/// public DateTime? End { get; init; } +/// +/// +/// public sealed class NullableLocalDateTimeConverter : JsonConverter { - public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var raw = reader.GetString(); - if (string.IsNullOrEmpty(raw)) - return null; + /// + public override DateTime? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + => WallClock.Parse(reader.GetString()); - return WallClock.Parse(raw); - } - - public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) - { - if (value is null) - writer.WriteStringValue(""); - else - writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", - CultureInfo.InvariantCulture)); - } + /// + /// + /// Only called for non-null values: is + /// , so the serializer writes itself. + /// + public override void Write( + Utf8JsonWriter writer, + DateTime? value, + JsonSerializerOptions options) + => writer.WriteStringValue(WallClock.Format(value ?? throw new ArgumentNullException(nameof(value)))); } diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index cb96f81..91ec0ce 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -5,19 +5,89 @@ using Elternbeirat.Contracts; namespace Elternbeirat.PocketBase; /// -/// Reads published content from a PocketBase instance over its REST API. -/// -/// One method per collection (pages, posts, events, faqs). Each returns only -/// records with public = true and lets PocketBase do the filtering and -/// sorting via query parameters. The is expected to have -/// its set to the PocketBase base URL, so it -/// is registered as a typed client via AddHttpClient. -/// +/// Reads published content from a PocketBase instance over its REST API. /// +/// +/// +/// There is one method per collection (pages, posts, events, faqs). Each returns +/// only records with public = true and leaves filtering and sorting to +/// PocketBase via query parameters. +/// +/// +/// Register the class as a typed client via AddHttpClient, with +/// set to the PocketBase base URL. +/// +/// +/// +/// The HTTP client; its must point to PocketBase. +/// public sealed class PocketBaseClient(HttpClient http) { + /// + /// Serializer options that map PocketBase UTC dates to Berlin wall-clock time. + /// private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + /// + /// Gets all public pages, ordered by . + /// + /// + /// A token to cancel the request. + /// + /// + /// The public pages, or an empty list if there are none. + /// + /// + /// PocketBase could not be reached or returned a non-success status code. + /// + /// + /// The response could not be deserialized. + /// + /// + /// was canceled or the request timed out. + /// + public Task> GetPagesAsync(CancellationToken ct = default) + => GetRecordsAsync("pages", "order", ct); + + /// + /// Gets all public posts, ordered by , newest first. + /// + /// + /// + /// The public posts, or an empty list if there are none. + /// + /// + public Task> GetPostsAsync(CancellationToken ct = default) + => GetRecordsAsync("posts", "-date", ct); + + /// + /// Gets all public events, ordered by , earliest first. + /// + /// + /// + /// The public events, or an empty list if there are none. + /// + /// + public Task> GetEventsAsync(CancellationToken ct = default) + => GetRecordsAsync("events", "start", ct); + + /// + /// Gets all public FAQ entries, ordered by . + /// + /// + /// + /// The public FAQ entries, or an empty list if there are none. + /// + /// + public Task> GetFaqsAsync(CancellationToken ct = default) + => GetRecordsAsync("faqs", "topic", ct); + + /// + /// Creates the serializer options with both wall-clock date converters registered. + /// + /// + /// The configured options. + /// private static JsonSerializerOptions CreateJsonOptions() { var options = new JsonSerializerOptions @@ -29,23 +99,28 @@ public sealed class PocketBaseClient(HttpClient http) return options; } - /// Gets all public pages, ordered for navigation. - public Task> GetPagesAsync(CancellationToken ct = default) - => GetRecordsAsync("pages", "order", ct); - - /// Gets all public posts, newest first. - public Task> GetPostsAsync(CancellationToken ct = default) - => GetRecordsAsync("posts", "-date", ct); - - /// Gets all public events, earliest start first. - public Task> GetEventsAsync(CancellationToken ct = default) - => GetRecordsAsync("events", "start", ct); - - /// Gets all public FAQ entries. - public Task> GetFaqsAsync(CancellationToken ct = default) - => GetRecordsAsync("faqs", "topic", ct); - - private async Task> GetRecordsAsync(string collection, string sort, CancellationToken ct) + /// + /// Gets all public records of a collection in a single request. + /// + /// + /// The record type to deserialize into. + /// + /// + /// The PocketBase collection name, e.g. "posts". + /// + /// + /// The PocketBase sort expression; a leading - sorts descending. + /// + /// + /// A token to cancel the request. + /// + /// + /// The public records, or an empty list if there are none. + /// + private async Task> GetRecordsAsync( + string collection, + string sort, + CancellationToken ct) { // filter=public=true keeps drafts out; perPage is large enough to fetch // every record in a single page given the small content volume. diff --git a/Elternbeirat.PocketBase/RecordList.cs b/Elternbeirat.PocketBase/RecordList.cs index 8987ae2..68bb67e 100644 --- a/Elternbeirat.PocketBase/RecordList.cs +++ b/Elternbeirat.PocketBase/RecordList.cs @@ -3,13 +3,25 @@ using System.Text.Json.Serialization; namespace Elternbeirat.PocketBase; /// -/// The envelope PocketBase wraps a records list response in. Only -/// is used; the paging fields are ignored because content volumes are small and the -/// client requests a large page size in a single call. +/// The envelope PocketBase wraps a records list response in. /// -/// The record type inside items. +/// +/// Only is mapped. The paging fields (page, +/// perPage, totalItems, totalPages) are ignored, because +/// content volumes are small and fetches every +/// record in a single request. +/// +/// +/// The record type inside items. +/// internal sealed record RecordList { + /// + /// Gets the records of the requested page. + /// + /// + /// The records, or an empty list if the collection has no matching records. + /// [JsonPropertyName("items")] public IReadOnlyList Items { get; init; } = []; } diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index bdd1828..a6fdd32 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -23,14 +23,15 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) } [Fact] - public async Task Event_time_is_read_as_wall_clock_not_shifted() + public async Task Event_utc_is_converted_to_berlin_wall_clock() { var client = pocketBase.CreateClient(); var events = await client.GetEventsAsync(); - // The meeting is seeded as 19:30; by the timezone convention the number is - // taken at face value, so no shift to 20:30/21:30 happens. + // The meeting is seeded as 17:30Z. October is summer time (UTC+2), so the + // converter turns it into 19:30 Berlin wall-clock -- the time an editor + // entered in the admin form. var meeting = events.FirstOrDefault(e => e.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal)); meeting.ShouldNotBeNull(); diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index c8a75ee..191f50d 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -295,15 +295,18 @@ public sealed class PocketBaseFixture : IAsyncLifetime body = "Text", slug = "new-hall", @public = true, }); - // Events: the meeting carries the wall-clock time the timezone test checks. + // Events: PocketBase stores UTC. The meeting is 19:30 Berlin; October is + // summer time (UTC+2), so it is stored as 17:30Z and the timezone test + // expects 19:30 back. The Herbstbasar is 09:00-13:00 Berlin in November + // (winter, UTC+1), stored as 08:00Z-12:00Z. await CreateRecordAsync(http, "events", new { - start = "2026-10-08 19:30:00.000Z", title = "Elternbeiratssitzung", + start = "2026-10-08 17:30:00.000Z", title = "Elternbeiratssitzung", location = "Aula", note = "", @public = true, }); await CreateRecordAsync(http, "events", new { - start = "2026-11-22 09:00:00.000Z", end = "2026-11-22 13:00:00.000Z", + start = "2026-11-22 08:00:00.000Z", end = "2026-11-22 12:00:00.000Z", title = "Herbstbasar", location = "Schulhof", note = "", @public = true, }); diff --git a/docs/redaktion.md b/docs/redaktion.md index 13d09f3..64aed43 100644 --- a/docs/redaktion.md +++ b/docs/redaktion.md @@ -128,11 +128,12 @@ Die Übersicht `/events` trennt automatisch in kommende und vergangene Termine; die Reihenfolge der Records spielt keine Rolle. Besucher können `/events.ics` in ihrer Kalender-App abonnieren — der Feed entsteht aus denselben Records. -> **Uhrzeit = Ortszeit.** Das Admin-Formular rechnet Datumsfelder in die -> Browser-Zeitzone um und zeigt eine gespeicherte `19:30` je nach Sommer-/Winter- -> zeit als 20:30/21:30 an. Das ist **kein** Fehler, nur zwei Bezugssysteme: der -> gespeicherte Zahlenwert **ist** die Ortszeit (Europe/Berlin), die App zeigt ihn -> unverändert. Trage die Uhrzeit ein, die auf der Seite stehen soll. +> **Trage einfach die Ortszeit ein.** Gib die Uhrzeit ein, die auf der Seite +> stehen soll (Europe/Berlin) — z. B. `08:00`. PocketBase speichert intern in UTC +> und zeigt dir nach dem Speichern deshalb einen um 1–2 Stunden früheren Wert an +> (aus `08:00` wird im Sommer `06:00`). Das ist **kein** Fehler: Die Website +> rechnet beim Anzeigen automatisch nach Ortszeit zurück und zeigt wieder `08:00`, +> Sommer- und Winterzeit inklusive. Du musst dich um UTC nicht kümmern. --- From 6d3260db491827b45d894c013e0a02cfe5e55a89 Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 09:11:37 +0200 Subject: [PATCH 2/9] Read a post's date as the Berlin calendar day, not the raw UTC day --- Elternbeirat.Contracts/Post.cs | 11 +++-- Elternbeirat.PocketBase/DateOnlyConverter.cs | 44 +++++++++++++++++++ Elternbeirat.PocketBase/PocketBaseClient.cs | 4 +- .../PocketBaseClientTests.cs | 14 ++++++ Elternbeirat.Web.Tests/PocketBaseFixture.cs | 7 ++- docs/redaktion.md | 4 +- 6 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 Elternbeirat.PocketBase/DateOnlyConverter.cs diff --git a/Elternbeirat.Contracts/Post.cs b/Elternbeirat.Contracts/Post.cs index 0fb6db4..fdc5a95 100644 --- a/Elternbeirat.Contracts/Post.cs +++ b/Elternbeirat.Contracts/Post.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Text.Json.Serialization; namespace Elternbeirat.Contracts; @@ -17,14 +18,16 @@ public record Post public string Id { get; init; } = ""; /// - /// Gets the publication date as Europe/Berlin wall-clock time. + /// Gets the publication date as a calendar day. /// /// - /// PocketBase stores the value in UTC; it is converted to Berlin local time on - /// deserialization, with daylight saving applied. Only the date part is shown. + /// A post's date is a plain day, not a point in time. It is read as the + /// Berlin calendar day: the UTC value is converted to Europe/Berlin and only + /// the day is kept, so the day an editor picked is the day shown -- no + /// time-of-day, no off-by-one at midnight. See . /// [JsonPropertyName("date")] - public DateTime Date { get; init; } + public DateOnly Date { get; init; } /// /// Gets the heading shown to visitors. diff --git a/Elternbeirat.PocketBase/DateOnlyConverter.cs b/Elternbeirat.PocketBase/DateOnlyConverter.cs new file mode 100644 index 0000000..29e6849 --- /dev/null +++ b/Elternbeirat.PocketBase/DateOnlyConverter.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elternbeirat.PocketBase; + +/// +/// JSON converter that reads and writes a PocketBase date as a calendar date +/// (), ignoring the time-of-day and the time zone. +/// +/// +/// PocketBase has no date-only field type: it stores every date as a UTC +/// instant with a time-of-day (e.g. an editor's 2 Jan 00:00 Berlin is +/// stored as "2026-01-01 22:00:00.000Z"). The day the editor meant is +/// therefore the day in Berlin, not the day of the raw UTC string. This +/// converter reuses the same UTC->Berlin conversion as +/// and then keeps only the date, so the day +/// shown is the day that was entered -- with no time-of-day and no off-by-one. +/// +/// +/// Registered on the client's +/// and matched by the field type, so a contract needs no +/// per-field attribute (which would couple the contract to this project). +/// +public sealed class DateOnlyConverter : JsonConverter +{ + /// + /// + /// The stored value is not a valid date string. + /// + public override DateOnly Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + => WallClock.Parse(reader.GetString()) is { } berlin + ? DateOnly.FromDateTime(berlin) + : default; + + /// + public override void Write( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options) + => writer.WriteStringValue(WallClock.Format(value.ToDateTime(TimeOnly.MinValue))); +} diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index 91ec0ce..0979d81 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -83,7 +83,8 @@ public sealed class PocketBaseClient(HttpClient http) => GetRecordsAsync("faqs", "topic", ct); /// - /// Creates the serializer options with both wall-clock date converters registered. + /// Creates the serializer options with the date converters registered: the + /// wall-clock ones for points in time and the calendar-day one for plain dates. /// /// /// The configured options. @@ -96,6 +97,7 @@ public sealed class PocketBaseClient(HttpClient http) }; options.Converters.Add(new LocalDateTimeConverter()); options.Converters.Add(new NullableLocalDateTimeConverter()); + options.Converters.Add(new DateOnlyConverter()); return options; } diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index a6fdd32..26b2ecc 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -64,6 +64,20 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) posts.Select(p => p.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]); } + [Fact] + public async Task Post_date_is_the_berlin_calendar_day() + { + var client = pocketBase.CreateClient(); + + var posts = await client.GetPostsAsync(); + + // The post is seeded as 2026-03-01 23:30Z, which is 2 March 00:30 in Berlin. + // The date is the Berlin day the editor picked (2 March), not the raw UTC + // day (1 March) -- the time zone is applied before the day is taken. + var post = posts.Single(p => p.Title == "Neuer Vorstand"); + post.Date.ShouldBe(new DateOnly(2026, 3, 2)); + } + [Fact] public async Task Pages_exclude_non_public_records() { diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index 191f50d..7dfc87f 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -283,10 +283,13 @@ public sealed class PocketBaseFixture : IAsyncLifetime order = 9, slug = "draft", embed = Array.Empty(), @public = false, }); - // Posts: newest first once sorted by -date. + // Posts: newest first once sorted by -date. The first post is stored as + // 2026-03-01 23:30Z on purpose: that is 2 March 00:30 in Berlin (winter, + // UTC+1). An editor who picked 2 March must get 2 March back -- the date is + // the Berlin day, so it must NOT be read as the raw UTC day (1 March). await CreateRecordAsync(http, "posts", new { - date = "2026-03-01 00:00:00.000Z", title = "Neuer Vorstand", + date = "2026-03-01 23:30:00.000Z", title = "Neuer Vorstand", body = "Text", slug = "new-board", @public = true, }); await CreateRecordAsync(http, "posts", new diff --git a/docs/redaktion.md b/docs/redaktion.md index 64aed43..f317199 100644 --- a/docs/redaktion.md +++ b/docs/redaktion.md @@ -113,7 +113,9 @@ Ein Beitrag hat `date`, `title`, `body`, `slug` und `public`. Er erscheint unter `/posts` (neueste zuerst) und unter `/posts/`; die neuesten werden auch auf der Startseite als Vorschau angeteasert. -- `date` steuert Sortierung und angezeigtes Datum. +- `date` steuert Sortierung und angezeigtes Datum. Anders als bei Terminen zählt + hier nur der **Tag** — eine Uhrzeit wird nie angezeigt, und die UTC-Verschiebung + aus dem Termine-Hinweis spielt keine Rolle. Trage einfach das Datum ein. - `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`). --- From 31b114982e097932c9714199c4b09e19241766c2 Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 09:20:14 +0200 Subject: [PATCH 3/9] Move date converters onto the contracts --- CLAUDE.md | 6 ++++++ .../DateOnlyConverter.cs | 17 ++++++++------- Elternbeirat.Contracts/Event.cs | 2 ++ .../LocalDateTimeConverter.cs | 4 +++- Elternbeirat.Contracts/Post.cs | 2 +- Elternbeirat.PocketBase/PocketBaseClient.cs | 21 +++++++------------ 6 files changed, 29 insertions(+), 23 deletions(-) rename {Elternbeirat.PocketBase => Elternbeirat.Contracts}/DateOnlyConverter.cs (78%) rename {Elternbeirat.PocketBase => Elternbeirat.Contracts}/LocalDateTimeConverter.cs (97%) diff --git a/CLAUDE.md b/CLAUDE.md index 0356428..9fd5eec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,12 @@ Inhalt liegt in PocketBase (`pb_data`), das separat gesichert wird (siehe - Datenzugriff über den typisierten `PocketBaseClient` (registriert via `AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit Inhalten. Komponenten liegen feature-basiert unter `Features//`. +- JSON-Konverter liegen in `Contracts` und werden per `[JsonConverter]` **am Feld** + zugeordnet, nicht global auf den `JsonSerializerOptions` im Client registriert. So + ist am Contract sichtbar, wie ein Feld behandelt wird (z. B. `Event.Start` als + Zeitpunkt, `Post.Date` als Kalendertag), und `Contracts` bekommt keine Referenz + auf `PocketBase` (Zirkel). Datumsfelder: `DateTime`/`DateTime?` = Zeitpunkt + (Europe/Berlin), `DateOnly` = Kalendertag. - **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare, Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht `Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein diff --git a/Elternbeirat.PocketBase/DateOnlyConverter.cs b/Elternbeirat.Contracts/DateOnlyConverter.cs similarity index 78% rename from Elternbeirat.PocketBase/DateOnlyConverter.cs rename to Elternbeirat.Contracts/DateOnlyConverter.cs index 29e6849..84eadf3 100644 --- a/Elternbeirat.PocketBase/DateOnlyConverter.cs +++ b/Elternbeirat.Contracts/DateOnlyConverter.cs @@ -1,11 +1,11 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Elternbeirat.PocketBase; +namespace Elternbeirat.Contracts; /// /// JSON converter that reads and writes a PocketBase date as a calendar date -/// (), ignoring the time-of-day and the time zone. +/// (), keeping the day and dropping the time-of-day. /// /// /// PocketBase has no date-only field type: it stores every date as a UTC @@ -16,11 +16,14 @@ namespace Elternbeirat.PocketBase; /// and then keeps only the date, so the day /// shown is the day that was entered -- with no time-of-day and no off-by-one. /// -/// -/// Registered on the client's -/// and matched by the field type, so a contract needs no -/// per-field attribute (which would couple the contract to this project). -/// +/// +/// Apply it to a calendar-day field of a contract: +/// +/// [JsonConverter(typeof(DateOnlyConverter))] +/// public DateOnly Date { get; init; } +/// +/// +/// public sealed class DateOnlyConverter : JsonConverter { /// diff --git a/Elternbeirat.Contracts/Event.cs b/Elternbeirat.Contracts/Event.cs index dd255f2..cd6e9ff 100644 --- a/Elternbeirat.Contracts/Event.cs +++ b/Elternbeirat.Contracts/Event.cs @@ -26,6 +26,7 @@ public record Event /// as the time. /// [JsonPropertyName("start")] + [JsonConverter(typeof(LocalDateTimeConverter))] public DateTime Start { get; init; } /// @@ -35,6 +36,7 @@ public record Event /// The end time, or if the event has no end. /// [JsonPropertyName("end")] + [JsonConverter(typeof(NullableLocalDateTimeConverter))] public DateTime? End { get; init; } /// diff --git a/Elternbeirat.PocketBase/LocalDateTimeConverter.cs b/Elternbeirat.Contracts/LocalDateTimeConverter.cs similarity index 97% rename from Elternbeirat.PocketBase/LocalDateTimeConverter.cs rename to Elternbeirat.Contracts/LocalDateTimeConverter.cs index 968f622..babe902 100644 --- a/Elternbeirat.PocketBase/LocalDateTimeConverter.cs +++ b/Elternbeirat.Contracts/LocalDateTimeConverter.cs @@ -2,7 +2,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace Elternbeirat.PocketBase; +namespace Elternbeirat.Contracts; /// /// Converts between PocketBase date strings and Europe/Berlin wall-clock time. @@ -71,6 +71,7 @@ internal static class WallClock /// For optional dates, use instead. /// /// +/// Apply it to a required date field of a contract: /// /// [JsonConverter(typeof(LocalDateTimeConverter))] /// public DateTime Start { get; init; } @@ -103,6 +104,7 @@ public sealed class LocalDateTimeConverter : JsonConverter /// without an end). This value, like JSON null, is read as . /// /// +/// Apply it to an optional date field of a contract: /// /// [JsonConverter(typeof(NullableLocalDateTimeConverter))] /// public DateTime? End { get; init; } diff --git a/Elternbeirat.Contracts/Post.cs b/Elternbeirat.Contracts/Post.cs index fdc5a95..51933e7 100644 --- a/Elternbeirat.Contracts/Post.cs +++ b/Elternbeirat.Contracts/Post.cs @@ -1,4 +1,3 @@ -using System.ComponentModel; using System.Text.Json.Serialization; namespace Elternbeirat.Contracts; @@ -27,6 +26,7 @@ public record Post /// time-of-day, no off-by-one at midnight. See . /// [JsonPropertyName("date")] + [JsonConverter(typeof(DateOnlyConverter))] public DateOnly Date { get; init; } /// diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index 0979d81..3a008c0 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -24,7 +24,7 @@ namespace Elternbeirat.PocketBase; public sealed class PocketBaseClient(HttpClient http) { /// - /// Serializer options that map PocketBase UTC dates to Berlin wall-clock time. + /// Shared serializer options for every request; see . /// private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); @@ -83,23 +83,16 @@ public sealed class PocketBaseClient(HttpClient http) => GetRecordsAsync("faqs", "topic", ct); /// - /// Creates the serializer options with the date converters registered: the - /// wall-clock ones for points in time and the calendar-day one for plain dates. + /// Creates the serializer options. Date handling lives on the contracts + /// themselves via [JsonConverter] on each date field (e.g. + /// , ), so + /// nothing date-related is registered here. /// /// /// The configured options. /// - private static JsonSerializerOptions CreateJsonOptions() - { - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }; - options.Converters.Add(new LocalDateTimeConverter()); - options.Converters.Add(new NullableLocalDateTimeConverter()); - options.Converters.Add(new DateOnlyConverter()); - return options; - } + private static JsonSerializerOptions CreateJsonOptions() => + new() { PropertyNameCaseInsensitive = true }; /// /// Gets all public records of a collection in a single request. From 66bc76af1c2b5829ce63c2e9340d549e9d0eee04 Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 10:08:04 +0200 Subject: [PATCH 4/9] Define the all-day rule once as Event.HasTime --- Elternbeirat.Contracts/Event.cs | 12 +++ Elternbeirat.PocketBase/PocketBaseClient.cs | 25 ++--- .../Features/Events/IcsCalendar.cs | 8 +- Elternbeirat.Web/Shared/EventFormat.cs | 99 +++++++++++++------ 4 files changed, 87 insertions(+), 57 deletions(-) diff --git a/Elternbeirat.Contracts/Event.cs b/Elternbeirat.Contracts/Event.cs index cd6e9ff..aef9878 100644 --- a/Elternbeirat.Contracts/Event.cs +++ b/Elternbeirat.Contracts/Event.cs @@ -68,4 +68,16 @@ public record Event /// [JsonPropertyName("public")] public bool Public { get; init; } + + /// + /// Gets a value indicating whether the event has a wall-clock time, as opposed + /// to being an all-day event. + /// + /// + /// PocketBase has no all-day flag; the convention is that a start of 00:00 + /// means all-day. This is the single definition of that rule -- formatting and + /// the calendar feed both read it here instead of re-deriving it. + /// + [JsonIgnore] + public bool HasTime => Start.TimeOfDay != TimeSpan.Zero; } diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index 3a008c0..5e68cfa 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -14,6 +14,12 @@ namespace Elternbeirat.PocketBase; /// PocketBase via query parameters. /// /// +/// Responses are deserialized with the web defaults. Date conversion from UTC +/// to Berlin wall-clock time is declared on the contracts themselves via +/// [JsonConverter] (e.g. , ), +/// so no serializer options are configured here. +/// +/// /// Register the class as a typed client via AddHttpClient, with /// set to the PocketBase base URL. /// @@ -23,11 +29,6 @@ namespace Elternbeirat.PocketBase; /// public sealed class PocketBaseClient(HttpClient http) { - /// - /// Shared serializer options for every request; see . - /// - private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); - /// /// Gets all public pages, ordered by . /// @@ -82,18 +83,6 @@ public sealed class PocketBaseClient(HttpClient http) public Task> GetFaqsAsync(CancellationToken ct = default) => GetRecordsAsync("faqs", "topic", ct); - /// - /// Creates the serializer options. Date handling lives on the contracts - /// themselves via [JsonConverter] on each date field (e.g. - /// , ), so - /// nothing date-related is registered here. - /// - /// - /// The configured options. - /// - private static JsonSerializerOptions CreateJsonOptions() => - new() { PropertyNameCaseInsensitive = true }; - /// /// Gets all public records of a collection in a single request. /// @@ -123,7 +112,7 @@ public sealed class PocketBaseClient(HttpClient http) + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + $"&sort={Uri.EscapeDataString(sort)}"; - var result = await http.GetFromJsonAsync>(url, JsonOptions, ct); + var result = await http.GetFromJsonAsync>(url, ct); return result?.Items ?? []; } } diff --git a/Elternbeirat.Web/Features/Events/IcsCalendar.cs b/Elternbeirat.Web/Features/Events/IcsCalendar.cs index 2d85388..b593156 100644 --- a/Elternbeirat.Web/Features/Events/IcsCalendar.cs +++ b/Elternbeirat.Web/Features/Events/IcsCalendar.cs @@ -45,7 +45,7 @@ public static class IcsCalendar // between requests, so caches and clients do not see spurious changes. AppendLine(sb, "DTSTAMP:20000101T000000Z"); - if (HasTime(ev)) + if (ev.HasTime) { AppendLine(sb, $"DTSTART:{Local(ev.Start)}"); if (ev.End is { } end) @@ -75,12 +75,6 @@ public static class IcsCalendar AppendLine(sb, "END:VEVENT"); } - /// - /// An entry counts as all-day when its start has no wall-clock time (00:00). - /// PocketBase has no separate flag; the convention is a midnight start. - /// - private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero; - /// /// A UID that stays the same as long as the entry's title and start do, so a /// re-subscribe updates the event instead of creating a duplicate. diff --git a/Elternbeirat.Web/Shared/EventFormat.cs b/Elternbeirat.Web/Shared/EventFormat.cs index 274e8ea..e9272cb 100644 --- a/Elternbeirat.Web/Shared/EventFormat.cs +++ b/Elternbeirat.Web/Shared/EventFormat.cs @@ -4,45 +4,80 @@ using Elternbeirat.Contracts; namespace Elternbeirat.Web.Shared; /// -/// Formats an 's date range for display in German. Shared by -/// the full event list and the home-page events embed so both render dates the -/// same way. +/// Formats the date range of an for display in German. /// +/// +/// Shared by the full event list and the home-page events embed, so both render +/// dates the same way. +/// public static class EventFormat { - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); - - // An event counts as all-day when its start carries no wall-clock time - // (00:00); PocketBase has no separate flag. - private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero; + /// + /// Date format for the start, and for a single-day event. + /// + private const string LongDate = "dddd, d. MMMM yyyy"; /// - /// Formats the entry's date range: a single day, a date with a time, or a - /// span across days. + /// Date format for the end of a multi-day event; the weekday is omitted. /// - public static string Range(Event ev) - { - var start = HasTime(ev) - ? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr" - : ev.Start.ToString("dddd, d. MMMM yyyy", German); + private const string ShortDate = "d. MMMM yyyy"; - if (ev.End is not { } end) + /// + /// The culture used for month and weekday names. + /// + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + + /// + /// Formats the date range of an event. + /// + /// + /// The event to format. + /// + /// + /// The formatted range, depending on and : + /// + /// + /// No end: Donnerstag, 8. Oktober 2026, 19:30 Uhr + /// + /// + /// Same day: Donnerstag, 8. Oktober 2026, 19:30–21:00 Uhr + /// + /// + /// Several days: Freitag, 9. Oktober 2026 – 11. Oktober 2026 + /// + /// + /// Without a time, the time parts are omitted. + /// + public static string Range(Event @event) + => (@event.Start, @event.End, @event.HasTime) switch { - return start; - } + (var start, null, var hasTime) + => Stamp(start, LongDate, hasTime), + (var start, { } end, true) when end.Date == start.Date + => $"{start.ToString(LongDate, German)}, {start.ToString("HH:mm", German)}–{end.ToString("HH:mm", German)} Uhr", + (var start, { } end, false) when end.Date == start.Date + => Stamp(start, LongDate, withTime: false), + (var start, { } end, var hasTime) + => $"{Stamp(start, LongDate, hasTime)} – {Stamp(end, ShortDate, hasTime)}", + }; - // Same day: append just the end time. Different days: append the full - // end date. - if (end.Date == ev.Start.Date) - { - return HasTime(ev) - ? start + "–" + end.ToString("HH:mm", German) + " Uhr" - : start; - } - - var endText = HasTime(ev) - ? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr" - : end.ToString("d. MMMM yyyy", German); - return start + " – " + endText; - } + /// + /// Formats a single point in time as a date with an optional time. + /// + /// + /// The date and time to format. + /// + /// + /// The date format, e.g. . + /// + /// + /// to append the time, e.g. , 19:30 Uhr. + /// + /// + /// The formatted date, with the time if requested. + /// + private static string Stamp(DateTime value, string dateFormat, bool withTime) + => withTime + ? value.ToString(dateFormat + ", HH:mm", German) + " Uhr" + : value.ToString(dateFormat, German); } From d06af2891a649c6089df384f7e9e4e929daa99db Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 10:23:30 +0200 Subject: [PATCH 5/9] Centralize the home slug check on Page, case-insensitively --- Elternbeirat.Contracts/Page.cs | 20 +++++++++++++++++++ Elternbeirat.Web/Features/Home/Home.razor.cs | 20 +++++++++++-------- .../Features/Layout/NavBuilder.cs | 6 ++++-- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/Elternbeirat.Contracts/Page.cs b/Elternbeirat.Contracts/Page.cs index 5bfa2fb..7204d72 100644 --- a/Elternbeirat.Contracts/Page.cs +++ b/Elternbeirat.Contracts/Page.cs @@ -13,6 +13,12 @@ namespace Elternbeirat.Contracts; /// public record Page { + /// + /// The slug of the home page, which is served at the site root / rather + /// than at /home. Reserved: no other page may use it. + /// + private const string HomeSlug = "home"; + /// /// Gets the PocketBase record id. /// @@ -76,4 +82,18 @@ public record Page /// [JsonPropertyName("public")] public bool Public { get; init; } + + /// + /// Tests whether a slug is the home page's, ignoring case. This is the single + /// definition of that comparison, so every caller treats home and + /// Home alike. + /// + /// + /// The slug to test. + /// + /// + /// if is the home page's slug. + /// + public static bool IsHomeSlug(string slug) => + string.Equals(slug, HomeSlug, StringComparison.OrdinalIgnoreCase); } diff --git a/Elternbeirat.Web/Features/Home/Home.razor.cs b/Elternbeirat.Web/Features/Home/Home.razor.cs index e5739c1..6753823 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor.cs +++ b/Elternbeirat.Web/Features/Home/Home.razor.cs @@ -1,3 +1,4 @@ +using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Microsoft.AspNetCore.Components; @@ -5,14 +6,18 @@ namespace Elternbeirat.Web.Features.Home; public partial class Home { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load the home page from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load the home page from PocketBase.")] + private static partial void LogLoadFailed( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; // Which dynamic blocks the home page shows is editorial: it comes from the // "home" page's embed field in PocketBase, not from hard-coded markup. @@ -23,14 +28,13 @@ public partial class Home try { var pages = await PocketBase.GetPagesAsync(); - var home = pages.FirstOrDefault( - p => string.Equals(p.Slug, "home", StringComparison.Ordinal)); + var home = pages.FirstOrDefault(page => Page.IsHomeSlug(page.Slug)); _embed = home?.Embed ?? []; } - catch (HttpRequestException ex) + catch (HttpRequestException exception) { // Source unreachable -> render the intro without embeds instead of a 500. - LogLoadFailed(Logger, ex); + LogLoadFailed(Logger, exception); } } } diff --git a/Elternbeirat.Web/Features/Layout/NavBuilder.cs b/Elternbeirat.Web/Features/Layout/NavBuilder.cs index 0e82666..c1f9df0 100644 --- a/Elternbeirat.Web/Features/Layout/NavBuilder.cs +++ b/Elternbeirat.Web/Features/Layout/NavBuilder.cs @@ -17,7 +17,9 @@ public static class NavBuilder public static IReadOnlyList Footer(IReadOnlyList pages) => Build(pages, "footer"); - private static List Build(IReadOnlyList pages, string location) => + private static List Build( + IReadOnlyList pages, + string location) => [.. pages .Where(page => string.Equals(page.Location, location, StringComparison.Ordinal)) .OrderBy(page => page.Order) @@ -27,5 +29,5 @@ public static class NavBuilder // reached at "/{slug}", where either a literal route or the catch-all content // page renders it. private static string HrefFor(string slug) => - string.Equals(slug, "home", StringComparison.Ordinal) ? "/" : $"/{slug}"; + Page.IsHomeSlug(slug) ? "/" : $"/{slug}"; } From 0f7e968c16d70b185a66bf8cf5e8a5b4d3e1ff4f Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 12:08:56 +0200 Subject: [PATCH 6/9] Harden the content contracts and enforce the slug rule --- .editorconfig | 6 +++ Elternbeirat.Contracts/Event.cs | 27 ++++++---- Elternbeirat.Contracts/Faq.cs | 31 +++++++---- Elternbeirat.Contracts/Page.cs | 51 ++++++++++++------- Elternbeirat.Contracts/Post.cs | 31 +++++++---- Elternbeirat.PocketBase/PocketBaseClient.cs | 30 +++++------ Elternbeirat.Web.Tests/NavBuilderTests.cs | 4 +- .../PocketBaseClientTests.cs | 1 - Elternbeirat.Web.Tests/PocketBaseFixture.cs | 2 +- .../Features/Pages/ContentPage.razor.cs | 16 +++--- .../Features/Posts/PostDetail.razor.cs | 2 +- docs/redaktion.md | 29 +++++++++-- pb/pb_migrations/collections_schema.json | 8 +-- 13 files changed, 157 insertions(+), 81 deletions(-) diff --git a/.editorconfig b/.editorconfig index 74d6a2f..cf29b5f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -54,3 +54,9 @@ dotnet_diagnostic.CA1812.severity = none # layout and is harmless (Blazor routing is not namespace-based). Renaming would # break the folder convention for no benefit. dotnet_diagnostic.CA1724.severity = none + +# CA1308 wants ToUpperInvariant over ToLowerInvariant. That advice is for +# normalizing before a comparison, where a few characters do not round-trip. Here +# lowercasing is the goal itself (URL slugs, which must be lowercase), not a step +# before a comparison -- comparisons use StringComparison.OrdinalIgnoreCase instead. +dotnet_diagnostic.CA1308.severity = none diff --git a/Elternbeirat.Contracts/Event.cs b/Elternbeirat.Contracts/Event.cs index aef9878..7b5d894 100644 --- a/Elternbeirat.Contracts/Event.cs +++ b/Elternbeirat.Contracts/Event.cs @@ -14,8 +14,13 @@ public record Event /// /// Gets the PocketBase record id. /// + /// + /// : every stored record has an id. Modeling it as + /// required states which fields a record must carry, independent of the store, + /// so a future data source has to supply them too. + /// [JsonPropertyName("id")] - public string Id { get; init; } = ""; + public required string Id { get; init; } /// /// Gets the start of the event as Europe/Berlin wall-clock time. @@ -24,10 +29,12 @@ public record Event /// PocketBase stores the value in UTC; it is converted to Berlin local time on /// deserialization, with daylight saving applied. An all-day event uses 00:00 /// as the time. + /// : an event without a start has no place on the + /// calendar, and the field is required in PocketBase. /// [JsonPropertyName("start")] [JsonConverter(typeof(LocalDateTimeConverter))] - public DateTime Start { get; init; } + public required DateTime Start { get; init; } /// /// Gets the optional end of the event as Europe/Berlin wall-clock time. @@ -42,8 +49,12 @@ public record Event /// /// Gets the event name, e.g. "Elternbeiratssitzung". /// + /// + /// : an event without a name is incomplete, and the + /// field is required in PocketBase. + /// [JsonPropertyName("title")] - public string Title { get; init; } = ""; + public required string Title { get; init; } /// /// Gets the optional location, e.g. "Aula". @@ -52,7 +63,7 @@ public record Event /// The location, or if none is set. /// [JsonPropertyName("location")] - public string Location { get; init; } = ""; + public string Location { get; init; } = string.Empty; /// /// Gets the optional note, e.g. "Anmeldung erforderlich". @@ -61,13 +72,7 @@ public record Event /// The note, or if none is set. /// [JsonPropertyName("note")] - public string Note { get; init; } = ""; - - /// - /// Gets a value indicating whether the event is visible to visitors of the site. - /// - [JsonPropertyName("public")] - public bool Public { get; init; } + public string Note { get; init; } = string.Empty; /// /// Gets a value indicating whether the event has a wall-clock time, as opposed diff --git a/Elternbeirat.Contracts/Faq.cs b/Elternbeirat.Contracts/Faq.cs index bc8dd38..f95f388 100644 --- a/Elternbeirat.Contracts/Faq.cs +++ b/Elternbeirat.Contracts/Faq.cs @@ -13,30 +13,41 @@ public record Faq /// /// Gets the PocketBase record id. /// + /// + /// : every stored record has an id. Modeling it as + /// required states which fields a record must carry, independent of the store, + /// so a future data source has to supply them too. + /// [JsonPropertyName("id")] - public string Id { get; init; } = ""; + public required string Id { get; init; } /// /// Gets the question as a parent would phrase it. /// + /// + /// : a FAQ entry without a question is incomplete, and + /// the field is required in PocketBase. + /// [JsonPropertyName("question")] - public string Question { get; init; } = ""; + public required string Question { get; init; } /// /// Gets the answer as Markdown. /// + /// + /// : a FAQ entry exists to answer its question, so an + /// entry without an answer is incomplete. The field is required in PocketBase. + /// [JsonPropertyName("answer")] - public string Answer { get; init; } = ""; + public required string Answer { get; init; } /// /// Gets the topic the question is grouped under, e.g. "mensa". /// + /// + /// : the FAQ page groups entries by topic, so an entry + /// without one has no place. The field is required in PocketBase. + /// [JsonPropertyName("topic")] - public string Topic { get; init; } = ""; - - /// - /// Gets a value indicating whether the question is visible to visitors of the site. - /// - [JsonPropertyName("public")] - public bool Public { get; init; } + public required string Topic { get; init; } } diff --git a/Elternbeirat.Contracts/Page.cs b/Elternbeirat.Contracts/Page.cs index 7204d72..ee17931 100644 --- a/Elternbeirat.Contracts/Page.cs +++ b/Elternbeirat.Contracts/Page.cs @@ -22,23 +22,30 @@ public record Page /// /// Gets the PocketBase record id. /// + /// + /// : every stored record has an id. Modeling it as + /// required states which fields a record must carry, independent of the store, + /// so a future data source has to supply them too. + /// [JsonPropertyName("id")] - public string Id { get; init; } = ""; + public required string Id { get; init; } /// /// Gets the heading shown to visitors. /// /// /// Free text; may contain umlauts and spaces. For the URL, see . + /// : a page without a heading is incomplete, and the + /// field is required in PocketBase. /// [JsonPropertyName("title")] - public string Title { get; init; } = ""; + public required string Title { get; init; } /// /// Gets the page body as Markdown. /// [JsonPropertyName("body")] - public string Body { get; init; } = ""; + public string Body { get; init; } = string.Empty; /// /// Gets the navigation menu the page appears in. @@ -46,8 +53,12 @@ public record Page /// /// Either "header" or "footer". /// + /// + /// : the navigation is built from this, and the field + /// is required in PocketBase, so every page belongs to one of the two menus. + /// [JsonPropertyName("location")] - public string Location { get; init; } = ""; + public required string Location { get; init; } /// /// Gets the sort order within the navigation menu given by . @@ -62,10 +73,18 @@ public record Page /// Gets the URL slug of the page, e.g. "board" for /board. /// /// - /// A lowercase path segment without umlauts. + /// A URL path segment: lowercase letters, digits and single hyphens as + /// separators, no umlauts. PocketBase enforces this on save via a field + /// pattern (^[a-z0-9]+(-[a-z0-9]+)*$), so every stored slug is already + /// canonical and the app can use it verbatim in comparisons and generated URLs. /// + /// + /// : the slug is how a page is addressed, so a record + /// without one is broken. It is a required field in PocketBase, so deserializing + /// one that lacks it should fail loudly rather than yield a page with no URL. + /// [JsonPropertyName("slug")] - public string Slug { get; init; } = ""; + public required string Slug { get; init; } /// /// Gets the dynamic blocks rendered below the . @@ -78,22 +97,20 @@ public record Page public IReadOnlyList Embed { get; init; } = []; /// - /// Gets a value indicating whether the page is visible to visitors of the site. - /// - [JsonPropertyName("public")] - public bool Public { get; init; } - - /// - /// Tests whether a slug is the home page's, ignoring case. This is the single - /// definition of that comparison, so every caller treats home and - /// Home alike. + /// Tests whether a slug is the home page's. This is the single definition of + /// that comparison, so every caller identifies the home page the same way. /// + /// + /// An ordinal (case-sensitive) comparison is enough because slugs are always + /// lowercase: PocketBase enforces that with a field pattern (see + /// ), and this is only ever called with a stored slug. + /// /// - /// The slug to test. + /// The slug to test, e.g. from . /// /// /// if is the home page's slug. /// public static bool IsHomeSlug(string slug) => - string.Equals(slug, HomeSlug, StringComparison.OrdinalIgnoreCase); + string.Equals(slug, HomeSlug, StringComparison.Ordinal); } diff --git a/Elternbeirat.Contracts/Post.cs b/Elternbeirat.Contracts/Post.cs index 51933e7..0622c12 100644 --- a/Elternbeirat.Contracts/Post.cs +++ b/Elternbeirat.Contracts/Post.cs @@ -13,8 +13,13 @@ public record Post /// /// Gets the PocketBase record id. /// + /// + /// : every stored record has an id. Modeling it as + /// required states which fields a record must carry, independent of the store, + /// so a future data source has to supply them too. + /// [JsonPropertyName("id")] - public string Id { get; init; } = ""; + public required string Id { get; init; } /// /// Gets the publication date as a calendar day. @@ -34,28 +39,32 @@ public record Post /// /// /// Free text; may contain umlauts and spaces. For the URL, see . + /// : a post without a heading is incomplete, and the + /// field is required in PocketBase. /// [JsonPropertyName("title")] - public string Title { get; init; } = ""; + public required string Title { get; init; } /// /// Gets the post body as Markdown. /// [JsonPropertyName("body")] - public string Body { get; init; } = ""; + public string Body { get; init; } = string.Empty; /// /// Gets the URL slug of the post, e.g. "herbstbasar" for /posts/herbstbasar. /// /// - /// A lowercase path segment without umlauts. + /// A URL path segment: lowercase letters, digits and single hyphens as + /// separators, no umlauts. PocketBase enforces this on save via a field + /// pattern (^[a-z0-9]+(-[a-z0-9]+)*$), so every stored slug is already + /// canonical and the app can use it verbatim in comparisons and generated URLs. /// + /// + /// : the slug is how a post is addressed, so a record + /// without one is broken. It is a required field in PocketBase, so deserializing + /// one that lacks it should fail loudly rather than yield a post with no URL. + /// [JsonPropertyName("slug")] - public string Slug { get; init; } = ""; - - /// - /// Gets a value indicating whether the post is visible to visitors of the site. - /// - [JsonPropertyName("public")] - public bool Public { get; init; } + public required string Slug { get; init; } } diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index 5e68cfa..5438431 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -24,15 +24,15 @@ namespace Elternbeirat.PocketBase; /// set to the PocketBase base URL. /// /// -/// +/// /// The HTTP client; its must point to PocketBase. /// -public sealed class PocketBaseClient(HttpClient http) +public sealed class PocketBaseClient(HttpClient httpClient) { /// /// Gets all public pages, ordered by . /// - /// + /// /// A token to cancel the request. /// /// @@ -45,10 +45,10 @@ public sealed class PocketBaseClient(HttpClient http) /// The response could not be deserialized. /// /// - /// was canceled or the request timed out. + /// was canceled or the request timed out. /// - public Task> GetPagesAsync(CancellationToken ct = default) - => GetRecordsAsync("pages", "order", ct); + public Task> GetPagesAsync(CancellationToken token = default) + => GetRecordsAsync("pages", "order", token); /// /// Gets all public posts, ordered by , newest first. @@ -58,8 +58,8 @@ public sealed class PocketBaseClient(HttpClient http) /// The public posts, or an empty list if there are none. /// /// - public Task> GetPostsAsync(CancellationToken ct = default) - => GetRecordsAsync("posts", "-date", ct); + public Task> GetPostsAsync(CancellationToken token = default) + => GetRecordsAsync("posts", "-date", token); /// /// Gets all public events, ordered by , earliest first. @@ -69,8 +69,8 @@ public sealed class PocketBaseClient(HttpClient http) /// The public events, or an empty list if there are none. /// /// - public Task> GetEventsAsync(CancellationToken ct = default) - => GetRecordsAsync("events", "start", ct); + public Task> GetEventsAsync(CancellationToken token = default) + => GetRecordsAsync("events", "start", token); /// /// Gets all public FAQ entries, ordered by . @@ -80,8 +80,8 @@ public sealed class PocketBaseClient(HttpClient http) /// The public FAQ entries, or an empty list if there are none. /// /// - public Task> GetFaqsAsync(CancellationToken ct = default) - => GetRecordsAsync("faqs", "topic", ct); + public Task> GetFaqsAsync(CancellationToken token = default) + => GetRecordsAsync("faqs", "topic", token); /// /// Gets all public records of a collection in a single request. @@ -95,7 +95,7 @@ public sealed class PocketBaseClient(HttpClient http) /// /// The PocketBase sort expression; a leading - sorts descending. /// - /// + /// /// A token to cancel the request. /// /// @@ -104,7 +104,7 @@ public sealed class PocketBaseClient(HttpClient http) private async Task> GetRecordsAsync( string collection, string sort, - CancellationToken ct) + CancellationToken token) { // filter=public=true keeps drafts out; perPage is large enough to fetch // every record in a single page given the small content volume. @@ -112,7 +112,7 @@ public sealed class PocketBaseClient(HttpClient http) + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + $"&sort={Uri.EscapeDataString(sort)}"; - var result = await http.GetFromJsonAsync>(url, ct); + var result = await httpClient.GetFromJsonAsync>(url, token); return result?.Items ?? []; } } diff --git a/Elternbeirat.Web.Tests/NavBuilderTests.cs b/Elternbeirat.Web.Tests/NavBuilderTests.cs index c8cdedc..f12c2e1 100644 --- a/Elternbeirat.Web.Tests/NavBuilderTests.cs +++ b/Elternbeirat.Web.Tests/NavBuilderTests.cs @@ -8,8 +8,10 @@ namespace Elternbeirat.Web.Tests; /// public class NavBuilderTests { + // Id is required on the record but irrelevant to navigation, so a fixed + // placeholder keeps these cases focused on slug/location/order/title. private static Page Page(string slug, string location, double order, string title) => - new() { Slug = slug, Location = location, Order = order, Title = title }; + new() { Id = "test", Slug = slug, Location = location, Order = order, Title = title }; [Fact] public void Header_keeps_only_header_pages_ordered_by_order() diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index 26b2ecc..c8bc2ba 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -19,7 +19,6 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) // Two seeded events, earliest start first. events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]); - events.ShouldAllBe(e => e.Public); } [Fact] diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index 7dfc87f..3f4bc47 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -335,6 +335,6 @@ public sealed class PocketBaseFixture : IAsyncLifetime private sealed record AuthResponse { [System.Text.Json.Serialization.JsonPropertyName("token")] - public string Token { get; init; } = ""; + public string Token { get; init; } = string.Empty; } } diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs index ed809fc..8c187ce 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs @@ -2,23 +2,27 @@ using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Http; namespace Elternbeirat.Web.Features.Pages; public partial class ContentPage { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load page '{slug}' from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load page '{slug}' from PocketBase.")] + private static partial void LogLoadFailed( + ILogger logger, + string slug, + Exception exception); [Parameter] - public string Slug { get; set; } = ""; + public string Slug { get; set; } = string.Empty; [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; [CascadingParameter] private HttpContext? HttpContext { get; set; } diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs index 39e1df1..b3a68d6 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs @@ -15,7 +15,7 @@ public partial class PostDetail private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception); [Parameter] - public string Slug { get; set; } = ""; + public string Slug { get; set; } = string.Empty; [Inject] private PocketBaseClient PocketBase { get; set; } = default!; diff --git a/docs/redaktion.md b/docs/redaktion.md index f317199..4dc88be 100644 --- a/docs/redaktion.md +++ b/docs/redaktion.md @@ -87,8 +87,9 @@ sofort live, ohne Code und ohne Rebuild: 5. Optional `location` (`header`/`footer`) **und** `order` setzen, damit die Seite ins Menü kommt; optional `embed` für eingebettete Blöcke (siehe unten). -- **Slug bleibt englisch und ohne Umlaute.** `imprint`, `privacy`, `contact`, - `board`, `patrons`. Der `title` darf deutsch mit Umlauten sein (`Förderverein`). +- **Slug bleibt englisch und ohne Umlaute** — `imprint`, `privacy`, `contact`, + `board`, `patrons`; erzwungen per Pattern (siehe *Slug-Regel wird erzwungen* + unten). Der `title` darf deutsch mit Umlauten sein (`Förderverein`). - **Reservierte Slugs.** `posts`, `events`, `faqs` und `/` (die Startseite `home`) gehören den festen Listen-/Sonderansichten. Eine `pages`-Seite mit einem dieser Slugs wird von der jeweiligen Ansicht verdeckt und **nicht** angezeigt; in der @@ -105,6 +106,26 @@ sofort live, ohne Code und ohne Rebuild: > Diese Slugs nicht ändern — sonst laufen die rechtlich verlinkten Adressen ins > Leere (404). +### Slug-Regel wird erzwungen + +Ein Slug muss ein sauberes URL-Segment sein: **Kleinbuchstaben, Ziffern und +einzelne Bindestriche als Trenner, keine Umlaute** (`ueber-uns`, nicht `Über uns`). +Das ist keine Bitte, sondern eine Feldregel: PocketBase weist einen Slug ab, der +sie verletzt, und zeigt beim Speichern einen Fehler. Gibst du `Overview` ein, +kommst du erst weiter, wenn du `overview` daraus machst. Die Website verlässt sich +darauf — sie repariert einen Slug nicht mehr nachträglich. + +Die Regel steckt als **Pattern** am Feld `slug`. So ist sie gesetzt (einmalig, +pro Collection mit einem Slug — `pages` **und** `posts`): + +1. Collection öffnen → *Edit collection* (oben rechts) → Feld `slug`. +2. Am Feld auf das **Zahnrad** (Feldoptionen) → **Pattern**. +3. Als Pattern eintragen: `^[a-z0-9]+(-[a-z0-9]+)*$` +4. **Save** (Feld) und **Save** (Collection). + +Zum Prüfen: `board`, `ueber-uns`, `faq-lunch` werden angenommen; `Overview`, +`über-uns`, `my page`, `-x` und `a--b` werden abgewiesen. + --- ## Beiträge (`posts`) @@ -116,7 +137,9 @@ der Startseite als Vorschau angeteasert. - `date` steuert Sortierung und angezeigtes Datum. Anders als bei Terminen zählt hier nur der **Tag** — eine Uhrzeit wird nie angezeigt, und die UTC-Verschiebung aus dem Termine-Hinweis spielt keine Rolle. Trage einfach das Datum ein. -- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`). +- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`) — + dieselbe erzwungene Feldregel wie bei Seiten (siehe *Seiten → Slug-Regel wird + erzwungen*). --- diff --git a/pb/pb_migrations/collections_schema.json b/pb/pb_migrations/collections_schema.json index 37c7848..302f8da 100644 --- a/pb/pb_migrations/collections_schema.json +++ b/pb/pb_migrations/collections_schema.json @@ -74,7 +74,7 @@ "max": 0, "min": 0, "name": "slug", - "pattern": "", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", "presentable": false, "primaryKey": false, "required": true, @@ -298,7 +298,7 @@ "max": 0, "min": 0, "name": "slug", - "pattern": "", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", "presentable": false, "primaryKey": false, "required": true, @@ -388,7 +388,7 @@ "pattern": "", "presentable": false, "primaryKey": false, - "required": false, + "required": true, "system": false, "type": "text" }, @@ -399,7 +399,7 @@ "maxSelect": 1, "name": "topic", "presentable": false, - "required": false, + "required": true, "system": false, "type": "select", "values": [ From e242eaf79a9e30ff9d3a454dd2a18096560b5396 Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 13:00:14 +0200 Subject: [PATCH 7/9] Serve 503 when PocketBase is unavailable across all content pages --- Elternbeirat.PocketBase/PocketBaseClient.cs | 48 ++++++++-- .../PocketBaseUnavailableException.cs | 49 ++++++++++ .../Components/Layout/MainLayout.razor.cs | 23 +++-- .../Features/Embeds/EventsEmbed.razor | 17 ++-- .../Features/Embeds/EventsEmbed.razor.cs | 43 ++++++--- .../Features/Embeds/FaqsEmbed.razor | 17 ++-- .../Features/Embeds/FaqsEmbed.razor.cs | 32 +++++-- .../Features/Embeds/PostsEmbed.razor | 21 ++-- .../Features/Embeds/PostsEmbed.razor.cs | 32 ++++--- .../Features/Events/EventList.razor | 95 ++++++++++--------- .../Features/Events/EventList.razor.cs | 53 +++++++---- Elternbeirat.Web/Features/Faqs/FaqList.razor | 6 +- .../Features/Faqs/FaqList.razor.cs | 37 +++++--- Elternbeirat.Web/Features/Home/Home.razor.cs | 16 ++-- .../Features/Pages/ContentPage.razor | 10 +- .../Features/Pages/ContentPage.razor.cs | 37 +++++--- .../Features/Posts/PostDetail.razor | 22 +++-- .../Features/Posts/PostDetail.razor.cs | 48 ++++++---- .../Features/Posts/PostList.razor | 10 +- .../Features/Posts/PostList.razor.cs | 36 ++++--- Elternbeirat.Web/Program.cs | 29 +++--- Elternbeirat.Web/Shared/Cultures.cs | 21 ++++ Elternbeirat.Web/Shared/EventFormat.cs | 12 +-- Elternbeirat.Web/Shared/SourceUnavailable.cs | 30 ++++++ Elternbeirat.Web/_Imports.razor | 1 + 25 files changed, 492 insertions(+), 253 deletions(-) create mode 100644 Elternbeirat.PocketBase/PocketBaseUnavailableException.cs create mode 100644 Elternbeirat.Web/Shared/Cultures.cs create mode 100644 Elternbeirat.Web/Shared/SourceUnavailable.cs diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index 5438431..7b7189f 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -38,14 +38,11 @@ public sealed class PocketBaseClient(HttpClient httpClient) /// /// The public pages, or an empty list if there are none. /// - /// - /// PocketBase could not be reached or returned a non-success status code. - /// - /// - /// The response could not be deserialized. + /// + /// PocketBase could not be reached, timed out, or returned an unreadable response. /// /// - /// was canceled or the request timed out. + /// was canceled by the caller. /// public Task> GetPagesAsync(CancellationToken token = default) => GetRecordsAsync("pages", "order", token); @@ -73,15 +70,22 @@ public sealed class PocketBaseClient(HttpClient httpClient) => GetRecordsAsync("events", "start", token); /// - /// Gets all public FAQ entries, ordered by . + /// Gets all public FAQ entries, ordered by , then by + /// . /// + /// + /// The second sort key makes the order total: sorting by topic alone leaves + /// entries within a topic in an undefined order, so the same content could + /// render differently between requests. Adding the question as a tie-breaker + /// keeps the FAQ list and any teaser drawn from it stable. + /// /// /// /// The public FAQ entries, or an empty list if there are none. /// /// public Task> GetFaqsAsync(CancellationToken token = default) - => GetRecordsAsync("faqs", "topic", token); + => GetRecordsAsync("faqs", "topic,question", token); /// /// Gets all public records of a collection in a single request. @@ -101,6 +105,12 @@ public sealed class PocketBaseClient(HttpClient httpClient) /// /// The public records, or an empty list if there are none. /// + /// + /// PocketBase could not be reached, timed out, or returned an unreadable response. + /// + /// + /// was canceled by the caller. + /// private async Task> GetRecordsAsync( string collection, string sort, @@ -112,7 +122,25 @@ public sealed class PocketBaseClient(HttpClient httpClient) + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + $"&sort={Uri.EscapeDataString(sort)}"; - var result = await httpClient.GetFromJsonAsync>(url, token); - return result?.Items ?? []; + try + { + var result = await httpClient.GetFromJsonAsync>(url, token); + return result?.Items ?? []; + } + // These three mean PocketBase itself failed: it was unreachable + // (HttpRequestException), the request to it timed out (a TaskCanceledException + // whose inner exception is a TimeoutException, which is how HttpClient surfaces + // its own timeout), or it answered with something we could not read + // (JsonException). A plain TaskCanceledException with no inner TimeoutException + // is the caller's own cancellation via token and is deliberately not caught -- + // it propagates as OperationCanceledException so an aborted request stays an + // abort, not a fault. + catch (Exception exception) when ( + exception is HttpRequestException + or JsonException + or TaskCanceledException { InnerException: TimeoutException }) + { + throw new PocketBaseUnavailableException($"Could not read collection '{collection}'.", exception); + } } } diff --git a/Elternbeirat.PocketBase/PocketBaseUnavailableException.cs b/Elternbeirat.PocketBase/PocketBaseUnavailableException.cs new file mode 100644 index 0000000..28aae56 --- /dev/null +++ b/Elternbeirat.PocketBase/PocketBaseUnavailableException.cs @@ -0,0 +1,49 @@ +namespace Elternbeirat.PocketBase; + +/// +/// Thrown when PocketBase could not be reached, timed out, or returned a +/// response that could not be read. +/// +/// +/// This is the one failure the content pages need to distinguish: the site is +/// up but its data store is not, which is an HTTP 503, not a 404. Which +/// low-level exceptions mean that (a transport error, a timeout, unreadable +/// JSON) is 's concern; callers catch only this +/// type and need not know how the client talks to PocketBase. +/// +public sealed class PocketBaseUnavailableException : Exception +{ + /// + /// Initializes a new instance with a default message. + /// + public PocketBaseUnavailableException() + : base("PocketBase is unavailable.") + { + } + + /// + /// Initializes a new instance with the given message. + /// + /// + /// The message that describes the error. + /// + public PocketBaseUnavailableException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance with the given message and the underlying + /// failure that caused it. + /// + /// + /// The message that describes the error. + /// + /// + /// The transport, timeout, or deserialization exception that was caught. + /// + public PocketBaseUnavailableException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs b/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs index 7edd795..c29e02e 100644 --- a/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs +++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs @@ -6,14 +6,19 @@ namespace Elternbeirat.Web.Components.Layout; public partial class MainLayout { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load navigation pages from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load navigation pages from PocketBase; the menu is empty.")] + private static partial void LogSourceUnavailable(ILogger logger, Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _header = []; private IReadOnlyList _footer = []; @@ -22,15 +27,15 @@ public partial class MainLayout { try { - var pages = await PocketBase.GetPagesAsync(); + var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default); _header = NavBuilder.Header(pages); _footer = NavBuilder.Footer(pages); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException ex) { - // Source unreachable -> render the chrome with an empty menu instead of - // a 500. The brand link still leads home. - LogLoadFailed(Logger, ex); + // Only the menu fails: render the chrome with an empty menu instead of a + // 500, so every page stays reachable. The brand link still leads home. + LogSourceUnavailable(Logger, ex); } } } diff --git a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor index 080ed5e..467b810 100644 --- a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor +++ b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor @@ -1,12 +1,7 @@ -
-

Kommende Termine

- - @if (_upcoming.Count == 0) - { -

Zurzeit sind keine Termine geplant.

- } - else - { +@if (_upcoming.Count > 0) +{ +
+

Kommende Termine

    @foreach (var ev in _upcoming) { @@ -21,5 +16,5 @@ }

Alle Termine

- } -
+
+} diff --git a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs index 5c42bcd..7d17223 100644 --- a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs +++ b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs @@ -6,14 +6,24 @@ namespace Elternbeirat.Web.Features.Embeds; public partial class EventsEmbed { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events for an embed from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + // Number of upcoming events shown in the teaser; the full list is one link away. + private const int TeaserCount = 3; + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load events for an embed from PocketBase; the teaser is omitted.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _upcoming = []; @@ -22,22 +32,25 @@ public partial class EventsEmbed IReadOnlyList events; try { - events = await PocketBase.GetEventsAsync(); + events = await PocketBase.GetEventsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show no teaser instead of a 500. - LogLoadFailed(Logger, ex); + // Only part of the page fails: omit the teaser, keep the page (and its + // 200) intact. A missing teaser is degraded, not broken. + LogSourceUnavailable(Logger, exception); return; } - // Teaser: the three soonest upcoming events, earliest first. An event - // earlier today still counts as upcoming. + // Teaser: the soonest upcoming events, earliest first. An event earlier + // today still counts as upcoming. var today = DateOnly.FromDateTime(DateTime.Today); - _upcoming = events - .Where(e => DateOnly.FromDateTime(e.Start) >= today) - .OrderBy(e => e.Start) - .Take(3) - .ToList(); + _upcoming = + [ + .. events + .Where(@event => DateOnly.FromDateTime(@event.Start) >= today) + .OrderBy(@event => @event.Start) + .Take(TeaserCount) + ]; } } diff --git a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor index 278e395..893d725 100644 --- a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor +++ b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor @@ -1,12 +1,7 @@ -
-

Häufige Fragen

- - @if (_faqs.Count == 0) - { -

Zurzeit gibt es keine Fragen.

- } - else - { +@if (_faqs.Count > 0) +{ +
+

Häufige Fragen

@foreach (var faq in _faqs) {
@@ -17,5 +12,5 @@
}

Alle Fragen

- } -
+
+} diff --git a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs index 5a90562..5d421ba 100644 --- a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs +++ b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs @@ -6,14 +6,24 @@ namespace Elternbeirat.Web.Features.Embeds; public partial class FaqsEmbed { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs for an embed from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + // Number of questions shown in the teaser; the full hub is one link away. + private const int TeaserCount = 3; + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load FAQs for an embed from PocketBase; the teaser is omitted.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _faqs = []; @@ -21,14 +31,16 @@ public partial class FaqsEmbed { try { - // Teaser: the first three questions, with a link to the full hub. - var faqs = await PocketBase.GetFaqsAsync(); - _faqs = [.. faqs.Take(3)]; + // GetFaqsAsync is ordered by topic, then question, so the first entries + // the teaser takes are stable across requests. + var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); + _faqs = [.. faqs.Take(TeaserCount)]; } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show no teaser instead of a 500. - LogLoadFailed(Logger, ex); + // Only part of the page fails: omit the teaser, keep the page (and its + // 200) intact. A missing teaser is degraded, not broken. + LogSourceUnavailable(Logger, exception); } } } diff --git a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor index a70df14..fccbcb1 100644 --- a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor +++ b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor @@ -1,23 +1,18 @@ -
-

Aktuelle Beiträge

- - @if (_latest.Count == 0) - { -

Zurzeit gibt es keine Beiträge.

- } - else - { +@if (_latest.Count > 0) +{ +
+

Aktuelle Beiträge

    @foreach (var post in _latest) {
  • @post.Title -
  • }

Alle Beiträge

- } -
+
+} diff --git a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs index 6468fc2..9bb2e0a 100644 --- a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs +++ b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Microsoft.AspNetCore.Components; @@ -7,16 +6,24 @@ namespace Elternbeirat.Web.Features.Embeds; public partial class PostsEmbed { - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + // Number of posts shown in the teaser; the full list is one link away. + private const int TeaserCount = 3; - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts for an embed from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load posts for an embed from PocketBase; the teaser is omitted.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _latest = []; @@ -24,14 +31,15 @@ public partial class PostsEmbed { try { - // Teaser: the three most recent posts, with a link to the full list. - var posts = await PocketBase.GetPostsAsync(); - _latest = [.. posts.Take(3)]; + // GetPostsAsync returns newest first, so the first entries are the latest. + var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); + _latest = [.. posts.Take(TeaserCount)]; } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show no teaser instead of a 500. - LogLoadFailed(Logger, ex); + // Only part of the page fails: omit the teaser, keep the page (and its + // 200) intact. A missing teaser is degraded, not broken. + LogSourceUnavailable(Logger, exception); } } } diff --git a/Elternbeirat.Web/Features/Events/EventList.razor b/Elternbeirat.Web/Features/Events/EventList.razor index bf6af2e..f946897 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor +++ b/Elternbeirat.Web/Features/Events/EventList.razor @@ -4,53 +4,60 @@

Termine

-

- Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch - abonnieren und in Ihrer Kalender-App automatisch - aktuell halten. -

- -

Kommende Termine

- -@if (_upcoming.Count == 0) +@if (_unavailable) { -

Zurzeit sind keine Termine geplant.

+

Die Termine sind gerade nicht erreichbar. Bitte später erneut versuchen.

} else { -
    - @foreach (var ev in _upcoming) - { -
  • - - @ev.Title - @if (!string.IsNullOrWhiteSpace(ev.Location)) - { - @ev.Location - } - @if (!string.IsNullOrWhiteSpace(ev.Note)) - { -

    @ev.Note

    - } -
  • - } -
-} +

+ Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch + abonnieren und in Ihrer Kalender-App automatisch + aktuell halten. +

-@if (_past.Count > 0) -{ -

Vergangene Termine

-
    - @foreach (var ev in _past) - { -
  • - - @ev.Title - @if (!string.IsNullOrWhiteSpace(ev.Location)) - { - @ev.Location - } -
  • - } -
+

Kommende Termine

+ + @if (_upcoming.Count == 0) + { +

Zurzeit sind keine Termine geplant.

+ } + else + { +
    + @foreach (var ev in _upcoming) + { +
  • + + @ev.Title + @if (!string.IsNullOrWhiteSpace(ev.Location)) + { + @ev.Location + } + @if (!string.IsNullOrWhiteSpace(ev.Note)) + { +

    @ev.Note

    + } +
  • + } +
+ } + + @if (_past.Count > 0) + { +

Vergangene Termine

+
    + @foreach (var ev in _past) + { +
  • + + @ev.Title + @if (!string.IsNullOrWhiteSpace(ev.Location)) + { + @ev.Location + } +
  • + } +
+ } } diff --git a/Elternbeirat.Web/Features/Events/EventList.razor.cs b/Elternbeirat.Web/Features/Events/EventList.razor.cs index 67bf41f..01c9659 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor.cs +++ b/Elternbeirat.Web/Features/Events/EventList.razor.cs @@ -1,48 +1,67 @@ using Elternbeirat.Contracts; using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; namespace Elternbeirat.Web.Features.Events; public partial class EventList { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not load events from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _upcoming = []; private IReadOnlyList _past = []; + // Set when the content source could not be reached or read. The markup then + // shows a "temporarily unavailable" note instead of "no events", matching the + // 503 this sets on the response. + private bool _unavailable; + protected override async Task OnInitializedAsync() { IReadOnlyList events; try { - events = await PocketBase.GetEventsAsync(); + events = await PocketBase.GetEventsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show empty sections instead of a 500. The page - // stays up; the "keine Termine" note is rendered. - LogLoadFailed(Logger, ex); + // Empty sections would claim there are no events. The site works, its + // data store is down: 503 and an honest note. + LogSourceUnavailable(Logger, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); return; } // Split into upcoming and past by date, so an event earlier today still // counts as upcoming. Upcoming earliest first, past most recent first. var today = DateOnly.FromDateTime(DateTime.Today); - _upcoming = events - .Where(e => DateOnly.FromDateTime(e.Start) >= today) - .OrderBy(e => e.Start) - .ToList(); - _past = events - .Where(e => DateOnly.FromDateTime(e.Start) < today) - .OrderByDescending(e => e.Start) - .ToList(); + _upcoming = + [ + .. events + .Where(@event => DateOnly.FromDateTime(@event.Start) >= today) + .OrderBy(@event => @event.Start) + ]; + _past = + [ + .. events + .Where(@event => DateOnly.FromDateTime(@event.Start) < today) + .OrderByDescending(@event => @event.Start) + ]; } } diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor b/Elternbeirat.Web/Features/Faqs/FaqList.razor index 07053a7..fdb5102 100644 --- a/Elternbeirat.Web/Features/Faqs/FaqList.razor +++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor @@ -4,7 +4,11 @@

Häufige Fragen

-@if (_groups.Count == 0) +@if (_unavailable) +{ +

Die Fragen sind gerade nicht erreichbar. Bitte später erneut versuchen.

+} +else if (_groups.Count == 0) {

Zurzeit gibt es keine Fragen.

} diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs b/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs index 90a2aed..fd93102 100644 --- a/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs +++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs @@ -1,13 +1,18 @@ using Elternbeirat.Contracts; using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; namespace Elternbeirat.Web.Features.Faqs; public partial class FaqList { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not load FAQs from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); // German heading per topic. Unknown topics fall back to the raw key so a new // topic in PocketBase still shows up rather than being silently dropped. @@ -21,28 +26,38 @@ public partial class FaqList }; [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList> _groups = []; + // Set when the content source could not be reached or read. The markup then + // shows a "temporarily unavailable" note instead of "no questions", matching + // the 503 this sets on the response. + private bool _unavailable; + protected override async Task OnInitializedAsync() { try { - var faqs = await PocketBase.GetFaqsAsync(); + var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); _groups = [.. faqs.GroupBy(faq => faq.Topic)]; } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show an empty page instead of a 500. The page - // stays up; the "keine Fragen" note is rendered. - LogLoadFailed(Logger, ex); + // An empty page would claim there are no questions. The site works, its + // data store is down: 503 and an honest note. + LogSourceUnavailable(Logger, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); } } - private static string TopicLabel(string topic) => - TopicLabels.TryGetValue(topic, out var label) ? label : topic; + private static string TopicLabel(string topic) + => TopicLabels.GetValueOrDefault(topic, topic); } diff --git a/Elternbeirat.Web/Features/Home/Home.razor.cs b/Elternbeirat.Web/Features/Home/Home.razor.cs index 6753823..e4d7c4d 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor.cs +++ b/Elternbeirat.Web/Features/Home/Home.razor.cs @@ -8,8 +8,8 @@ public partial class Home { [LoggerMessage( Level = LogLevel.Warning, - Message = "Could not load the home page from PocketBase.")] - private static partial void LogLoadFailed( + Message = "Could not load the home page from PocketBase; embeds are omitted.")] + private static partial void LogSourceUnavailable( ILogger logger, Exception exception); @@ -19,6 +19,9 @@ public partial class Home [Inject] private ILogger Logger { get; set; } = null!; + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + // Which dynamic blocks the home page shows is editorial: it comes from the // "home" page's embed field in PocketBase, not from hard-coded markup. private IReadOnlyList _embed = []; @@ -27,14 +30,15 @@ public partial class Home { try { - var pages = await PocketBase.GetPagesAsync(); + var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default); var home = pages.FirstOrDefault(page => Page.IsHomeSlug(page.Slug)); _embed = home?.Embed ?? []; } - catch (HttpRequestException exception) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> render the intro without embeds instead of a 500. - LogLoadFailed(Logger, exception); + // Only the embeds fail: render the static intro without them instead of a + // 500. The home page's own text is markup, so it stays intact. + LogSourceUnavailable(Logger, exception); } } } diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor b/Elternbeirat.Web/Features/Pages/ContentPage.razor index 69f6617..720419f 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor @@ -1,14 +1,18 @@ @page "/{Slug}" -@if (_page is null) +@if (_unavailable) { - Nicht gefunden + Vorübergehend nicht erreichbar +

Die Seite ist gerade nicht erreichbar. Bitte später erneut versuchen.

} -else +else if (_page is not null) { @_page.Title
+

@_page.Title

@Body
} +@* No page and not unavailable: the slug was unknown; NavigationManager.NotFound() + already ran in OnParametersSetAsync and renders the not-found page. *@ diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs index 8c187ce..1ed7a52 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs @@ -8,9 +8,9 @@ namespace Elternbeirat.Web.Features.Pages; public partial class ContentPage { [LoggerMessage( - Level = LogLevel.Warning, - Message = "Could not load page '{slug}' from PocketBase.")] - private static partial void LogLoadFailed( + Level = LogLevel.Error, + Message = "Could not load page '{Slug}' from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( ILogger logger, string slug, Exception exception); @@ -24,11 +24,19 @@ public partial class ContentPage [Inject] private ILogger Logger { get; set; } = null!; + [Inject] + private NavigationManager Navigation { get; set; } = null!; + [CascadingParameter] private HttpContext? HttpContext { get; set; } private Page? _page; + // Set when the content source could not be reached or read. The markup then + // shows a "temporarily unavailable" note instead of a not-found page, matching + // the 503 this sets on the response. + private bool _unavailable; + // The page body rendered from Markdown to HTML, ready to emit as a // MarkupString in the markup. private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body); @@ -37,24 +45,23 @@ public partial class ContentPage { try { - var pages = await PocketBase.GetPagesAsync(); + var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? CancellationToken.None); _page = pages.FirstOrDefault( - p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase)); + page => string.Equals(page.Slug, Slug, StringComparison.OrdinalIgnoreCase)); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> treat as "not found" rather than a 500. The - // 404 branch below serves the friendly /not-found page. - LogLoadFailed(Logger, Slug, ex); - _page = null; + // The site works, its data store is down: 503, not 404. + LogSourceUnavailable(Logger, Slug, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); + return; } - // Unknown slug (or unreachable source) -> 404, so - // UseStatusCodePagesWithReExecute serves the /not-found page instead of an - // empty 200 response. - if (_page is null && HttpContext is not null) + if (_page is null) { - HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; + // The source answered, but there is no page with this slug: a genuine 404. + Navigation.NotFound(); } } } diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor b/Elternbeirat.Web/Features/Posts/PostDetail.razor index 718f4c2..38b78e2 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor @@ -1,19 +1,25 @@ @page "/posts/{Slug}" -@if (_post is null) +@if (_unavailable) { - Nicht gefunden + Vorübergehend nicht erreichbar +

Der Beitrag ist gerade nicht erreichbar. Bitte später erneut versuchen.

} -else +else if (_post is not null) { @_post.Title +
-
+

@_post.Title

+ -

+
@Body
} +@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound() + already ran in OnParametersSetAsync and renders the not-found page. *@ diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs index b3a68d6..7eb1aef 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs @@ -1,33 +1,42 @@ -using System.Globalization; using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Http; namespace Elternbeirat.Web.Features.Posts; public partial class PostDetail { - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load post '{slug}' from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception); + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not load post '{Slug}' from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( + ILogger logger, + string slug, + Exception exception); [Parameter] public string Slug { get; set; } = string.Empty; [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [Inject] + private NavigationManager Navigation { get; set; } = null!; [CascadingParameter] private HttpContext? HttpContext { get; set; } private Post? _post; + // Set when the content source could not be reached or read. The markup then + // shows a "temporarily unavailable" note instead of a not-found page, matching + // the 503 this sets on the response. + private bool _unavailable; + // The post body rendered from Markdown to HTML, ready to emit as a // MarkupString in the markup. private MarkupString Body => (MarkupString)Markdown.ToHtml(_post?.Body); @@ -36,24 +45,23 @@ public partial class PostDetail { try { - var posts = await PocketBase.GetPostsAsync(); + var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); _post = posts.FirstOrDefault( - p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase)); + post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase)); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> treat as "not found" rather than a 500. The - // 404 branch below serves the friendly /not-found page. - LogLoadFailed(Logger, Slug, ex); - _post = null; + // The site works, its data store is down: 503, not 404. + LogSourceUnavailable(Logger, Slug, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); + return; } - // Unknown slug (or unreachable source) -> 404, so - // UseStatusCodePagesWithReExecute serves the /not-found page instead of an - // empty 200 response. - if (_post is null && HttpContext is not null) + if (_post is null) { - HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; + // The source answered, but there is no post with this slug: a genuine 404. + Navigation.NotFound(); } } } diff --git a/Elternbeirat.Web/Features/Posts/PostList.razor b/Elternbeirat.Web/Features/Posts/PostList.razor index c294cc4..29a9c39 100644 --- a/Elternbeirat.Web/Features/Posts/PostList.razor +++ b/Elternbeirat.Web/Features/Posts/PostList.razor @@ -4,7 +4,11 @@

Beiträge

-@if (_posts.Count == 0) +@if (_unavailable) +{ +

Die Beiträge sind gerade nicht erreichbar. Bitte später erneut versuchen.

+} +else if (_posts.Count == 0) {

Zurzeit gibt es keine Beiträge.

} @@ -15,8 +19,8 @@ else {
  • @post.Title -
  • } diff --git a/Elternbeirat.Web/Features/Posts/PostList.razor.cs b/Elternbeirat.Web/Features/Posts/PostList.razor.cs index 38d358b..979e6c4 100644 --- a/Elternbeirat.Web/Features/Posts/PostList.razor.cs +++ b/Elternbeirat.Web/Features/Posts/PostList.razor.cs @@ -1,36 +1,48 @@ -using System.Globalization; using Elternbeirat.Contracts; using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; namespace Elternbeirat.Web.Features.Posts; public partial class PostList { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); - - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not load posts from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _posts = []; + // Set when the content source could not be reached or read. The markup then + // shows a "temporarily unavailable" note instead of "no posts", matching the + // 503 this sets on the response. + private bool _unavailable; + protected override async Task OnInitializedAsync() { try { - _posts = await PocketBase.GetPostsAsync(); + _posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show an empty list instead of a 500. The page - // stays up; the "keine Beiträge" note is rendered. - LogLoadFailed(Logger, ex); + // An empty list would claim there are no posts. The site works, its + // data store is down: 503 and an honest note. + LogSourceUnavailable(Logger, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); } } } diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs index 49ac70e..185408c 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -13,7 +13,7 @@ builder.Services.AddRazorComponents(); // comes from configuration: appsettings for local dev, the PocketBase__BaseUrl // env var on the server (see compose.yaml). var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"] - ?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured."); + ?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured."); builder.Services.AddHttpClient(client => client.BaseAddress = new Uri(pocketBaseUrl)); @@ -24,23 +24,26 @@ var app = builder.Build(); // as HTTP and with the proxy IP instead of the client IP. // KnownNetworks/KnownProxies are deliberately empty because only NPM reaches // the container. -app.UseForwardedHeaders(new ForwardedHeadersOptions -{ - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, - KnownIPNetworks = { }, - KnownProxies = { } -}); +app.UseForwardedHeaders( + new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto + }); // Configure the HTTP request pipeline. -if (!app.Environment.IsDevelopment()) +if (app.Environment.IsDevelopment() is false) { app.UseExceptionHandler("/Error", createScopeForErrors: true); // No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates // TLS. Both here would create a redirect loop behind the proxy. } -app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); - +// No UseStatusCodePagesWithReExecute: the Router renders the NotFoundPage +// (Pages.NotFound) in process for both an unmatched route and an explicit +// NavigationManager.NotFound() from a component (see Routes.razor). Re-executing +// every error code to /not-found would also turn a 503 (content source down) into +// a "not found" page; leaving it out lets those components keep their own +// "temporarily unavailable" markup and 503 status. app.UseAntiforgery(); app.MapStaticAssets(); @@ -49,14 +52,14 @@ app.MapRazorComponents(); // Subscribable calendar feed of all events. A minimal API endpoint rather than // a Razor page because it returns text/calendar, not HTML. Reads from PocketBase // per request; an unreachable source yields an empty calendar, not a 500. -app.MapGet("/events.ics", async (PocketBaseClient pocketBase) => +app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) => { IReadOnlyList events; try { - events = await pocketBase.GetEventsAsync(); + events = await pocketBase.GetEventsAsync(token); } - catch (HttpRequestException) + catch (PocketBaseUnavailableException) { events = []; } diff --git a/Elternbeirat.Web/Shared/Cultures.cs b/Elternbeirat.Web/Shared/Cultures.cs new file mode 100644 index 0000000..800df49 --- /dev/null +++ b/Elternbeirat.Web/Shared/Cultures.cs @@ -0,0 +1,21 @@ +using System.Globalization; + +namespace Elternbeirat.Web.Shared; + +/// +/// Shared instances used to format visitor-facing +/// text. +/// +/// +/// Kept in one place so every component renders month and weekday names the +/// same way, rather than each holding its own de-DE lookup. This is only +/// for display text; machine-readable output (e.g. an HTML datetime +/// attribute) uses instead. +/// +internal static class Cultures +{ + /// + /// German culture, for the month and weekday names visitors read. + /// + public static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); +} diff --git a/Elternbeirat.Web/Shared/EventFormat.cs b/Elternbeirat.Web/Shared/EventFormat.cs index e9272cb..db6c5fe 100644 --- a/Elternbeirat.Web/Shared/EventFormat.cs +++ b/Elternbeirat.Web/Shared/EventFormat.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Elternbeirat.Contracts; namespace Elternbeirat.Web.Shared; @@ -22,11 +21,6 @@ public static class EventFormat ///
    private const string ShortDate = "d. MMMM yyyy"; - /// - /// The culture used for month and weekday names. - /// - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); - /// /// Formats the date range of an event. /// @@ -54,7 +48,7 @@ public static class EventFormat (var start, null, var hasTime) => Stamp(start, LongDate, hasTime), (var start, { } end, true) when end.Date == start.Date - => $"{start.ToString(LongDate, German)}, {start.ToString("HH:mm", German)}–{end.ToString("HH:mm", German)} Uhr", + => $"{start.ToString(LongDate, Cultures.German)}, {start.ToString("HH:mm", Cultures.German)}–{end.ToString("HH:mm", Cultures.German)} Uhr", (var start, { } end, false) when end.Date == start.Date => Stamp(start, LongDate, withTime: false), (var start, { } end, var hasTime) @@ -78,6 +72,6 @@ public static class EventFormat /// private static string Stamp(DateTime value, string dateFormat, bool withTime) => withTime - ? value.ToString(dateFormat + ", HH:mm", German) + " Uhr" - : value.ToString(dateFormat, German); + ? value.ToString(dateFormat + ", HH:mm", Cultures.German) + " Uhr" + : value.ToString(dateFormat, Cultures.German); } diff --git a/Elternbeirat.Web/Shared/SourceUnavailable.cs b/Elternbeirat.Web/Shared/SourceUnavailable.cs new file mode 100644 index 0000000..5ce0d12 --- /dev/null +++ b/Elternbeirat.Web/Shared/SourceUnavailable.cs @@ -0,0 +1,30 @@ +namespace Elternbeirat.Web.Shared; + +/// +/// Marks the current response as HTTP 503 when the content source is down. +/// +/// +/// Several components handle a +/// the same way: the site works but PocketBase does not, so the response is a +/// 503 and the component renders a "temporarily unavailable" note instead of +/// empty content that would read as 200 "there is nothing here". This puts the +/// status-setting in one place so those components do not each repeat it. +/// +internal static class SourceUnavailable +{ + /// + /// Sets the response status to 503 Service Unavailable, if there is a + /// response to set it on. + /// + /// + /// The current HTTP context, or when the component + /// renders outside a request (in which case there is nothing to do). + /// + public static void SetStatus(HttpContext? context) + { + if (context is not null) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + } + } +} diff --git a/Elternbeirat.Web/_Imports.razor b/Elternbeirat.Web/_Imports.razor index 403085e..0c04751 100644 --- a/Elternbeirat.Web/_Imports.razor +++ b/Elternbeirat.Web/_Imports.razor @@ -1,3 +1,4 @@ +@using System.Globalization @using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms From 01d25eb04bfbfa5b9c0ce3983679ed7813ecdd3f Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 14:26:35 +0200 Subject: [PATCH 8/9] Take the page heading from the Markdown body, not a separate

    --- Elternbeirat.Web/Features/Pages/ContentPage.razor | 5 +---- Elternbeirat.Web/Features/Posts/PostDetail.razor | 9 +++------ Elternbeirat.Web/Features/Posts/PostDetail.razor.css | 10 ++-------- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor b/Elternbeirat.Web/Features/Pages/ContentPage.razor index 720419f..022b071 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor @@ -8,10 +8,7 @@ else if (_page is not null) { @_page.Title -
    -

    @_page.Title

    - @Body -
    +
    @Body
    } @* No page and not unavailable: the slug was unknown; NavigationManager.NotFound() diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor b/Elternbeirat.Web/Features/Posts/PostDetail.razor index 38b78e2..5a1a6ce 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor @@ -12,12 +12,9 @@ else if (_post is not null) ← Alle Beiträge
    -
    -

    @_post.Title

    - -
    + @Body
    } diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor.css b/Elternbeirat.Web/Features/Posts/PostDetail.razor.css index d0cf3e2..f604b94 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor.css +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor.css @@ -1,13 +1,7 @@ .post-meta { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 1rem; - margin-bottom: 1rem; -} - -.post-meta time { display: block; color: var(--color-text-soft); /* token, so it adapts in dark mode */ font-size: 0.85rem; + margin-top: 1.5rem; /* space from the "all posts" link above */ + margin-bottom: 0.25rem; /* keep the body close below */ } From c7c55c0c6c6b9a960a514e66d9ef0c4d7d975292 Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 14:49:15 +0200 Subject: [PATCH 9/9] Let editors define FAQ topics freely instead of a hard-coded map --- Elternbeirat.Contracts/Faq.cs | 7 ++++++- Elternbeirat.Web.Tests/PocketBaseClientTests.cs | 2 +- Elternbeirat.Web.Tests/PocketBaseFixture.cs | 4 ++-- Elternbeirat.Web/Features/Faqs/FaqList.razor | 2 +- Elternbeirat.Web/Features/Faqs/FaqList.razor.cs | 14 -------------- docs/redaktion.md | 13 +++++++++---- pb/pb_migrations/1727200000_dev_seed.js | 2 +- pb/pb_migrations/collections_schema.json | 14 ++++++-------- 8 files changed, 26 insertions(+), 32 deletions(-) diff --git a/Elternbeirat.Contracts/Faq.cs b/Elternbeirat.Contracts/Faq.cs index f95f388..c7b22ca 100644 --- a/Elternbeirat.Contracts/Faq.cs +++ b/Elternbeirat.Contracts/Faq.cs @@ -42,11 +42,16 @@ public record Faq public required string Answer { get; init; } /// - /// Gets the topic the question is grouped under, e.g. "mensa". + /// Gets the topic the question is grouped under, e.g. + /// "Mensa und Mittagessen". /// /// /// : the FAQ page groups entries by topic, so an entry /// without one has no place. The field is required in PocketBase. + /// + /// This is the heading shown on the page verbatim, not a slug: editors add a + /// new group by typing its heading here, no code change. Entries with the same + /// topic text land in one group. /// [JsonPropertyName("topic")] public required string Topic { get; init; } diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index c8bc2ba..59b1305 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -111,7 +111,7 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) var faqs = await client.GetFaqsAsync(); - faqs.Select(f => f.Topic).ShouldBe(["mensa", "schliessfach"], ignoreOrder: true); + faqs.Select(f => f.Topic).ShouldBe(["Mensa und Mittagessen", "Schließfächer"], ignoreOrder: true); faqs.ShouldAllBe(f => !string.IsNullOrWhiteSpace(f.Question)); } } diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index 3f4bc47..6d58445 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -317,12 +317,12 @@ public sealed class PocketBaseFixture : IAsyncLifetime await CreateRecordAsync(http, "faqs", new { question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.", - topic = "mensa", @public = true, + topic = "Mensa und Mittagessen", @public = true, }); await CreateRecordAsync(http, "faqs", new { question = "Wie viel kostet ein Schließfach?", answer = "20 Euro.", - topic = "schliessfach", @public = true, + topic = "Schließfächer", @public = true, }); } diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor b/Elternbeirat.Web/Features/Faqs/FaqList.razor index fdb5102..c1d9d53 100644 --- a/Elternbeirat.Web/Features/Faqs/FaqList.razor +++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor @@ -17,7 +17,7 @@ else @foreach (var group in _groups) {
    -

    @TopicLabel(group.Key)

    +

    @group.Key

    @foreach (var faq in group) {
    diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs b/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs index fd93102..f2e1802 100644 --- a/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs +++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs @@ -14,17 +14,6 @@ public partial class FaqList ILogger logger, Exception exception); - // German heading per topic. Unknown topics fall back to the raw key so a new - // topic in PocketBase still shows up rather than being silently dropped. - private static readonly Dictionary TopicLabels = - new(StringComparer.Ordinal) - { - ["mensa"] = "Mensa und Mittagessen", - ["schliessfach"] = "Schließfächer", - ["elterneuro"] = "Eltern-Euro", - ["elternarbeit"] = "Elternarbeit und Mitmachen", - }; - [Inject] private PocketBaseClient PocketBase { get; set; } = null!; @@ -57,7 +46,4 @@ public partial class FaqList SourceUnavailable.SetStatus(HttpContext); } } - - private static string TopicLabel(string topic) - => TopicLabels.GetValueOrDefault(topic, topic); } diff --git a/docs/redaktion.md b/docs/redaktion.md index 4dc88be..2cda244 100644 --- a/docs/redaktion.md +++ b/docs/redaktion.md @@ -164,10 +164,15 @@ ihrer Kalender-App abonnieren — der Feed entsteht aus denselben Records. ## Häufige Fragen (`faqs`) -Eine Frage hat `question`, `answer` (Markdown), `topic` (eines von -`mensa`/`schliessfach`/`elterneuro`/`elternarbeit`) und `public`. Die App baut -die FAQ-Seite generiert: sie gruppiert die Fragen nach `topic`. Der Rahmentext -oben auf `/faqs` ist eine eigene Seite in `pages` (Slug `faqs`). +Eine Frage hat `question`, `answer` (Markdown), `topic` und `public`. Die App +baut die FAQ-Seite generiert: sie gruppiert die Fragen nach `topic` und zeigt den +`topic`-Text unverändert als Gruppen-Überschrift. `topic` ist also **keine feste +Auswahl**, sondern der Überschriftentext selbst (z. B. `Mensa und Mittagessen`) — +eine neue Gruppe entsteht einfach, indem eine Frage einen neuen `topic`-Text +bekommt; Fragen mit gleichem `topic` landen in derselben Gruppe. Auf Groß-/ +Kleinschreibung und Leerzeichen achten, sonst entstehen versehentlich zwei +Gruppen. Der Rahmentext oben auf `/faqs` ist eine eigene Seite in `pages` +(Slug `faqs`). --- diff --git a/pb/pb_migrations/1727200000_dev_seed.js b/pb/pb_migrations/1727200000_dev_seed.js index d70ec5c..3477406 100644 --- a/pb/pb_migrations/1727200000_dev_seed.js +++ b/pb/pb_migrations/1727200000_dev_seed.js @@ -27,7 +27,7 @@ migrate((app) => { , events: [{"end":"","location":"Aula","note":"Gäste sind willkommen. Um Anmeldung im Sekretariat wird gebeten.","public":true,"start":"2026-10-08 19:30:00.000Z","title":"Elternbeiratssitzung"},{"end":"2026-10-20 18:30:00.000Z","location":"Klassenräume","note":"Terminvergabe über das Schulportal.","public":true,"start":"2026-10-20 16:00:00.000Z","title":"Elternsprechtag"},{"end":"2026-11-22 13:00:00.000Z","location":"Schulhof","note":"Kuchenspenden willkommen.","public":true,"start":"2026-11-22 09:00:00.000Z","title":"Herbstbasar"},{"end":"2027-01-06 00:00:00.000Z","location":"","note":"","public":true,"start":"2026-12-19 00:00:00.000Z","title":"Weihnachtsferien"},{"end":"","location":"Mensa","note":"Kennenlernen bei Butterbrezeln und Kaffee.","public":true,"start":"2026-09-10 18:00:00.000Z","title":"Willkommensabend der neuen Fünftklässler"}] , - faqs: [{"answer":"Die Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.","public":true,"question":"Wie melde ich mein Kind zum Mittagessen an?","topic":"mensa"},{"answer":"Ein Mittagessen kostet aktuell **4,20 €**. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei – sprechen Sie das Sekretariat an.","public":true,"question":"Was kostet ein Mittagessen?","topic":"mensa"},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"mensa"},{"answer":"Schließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).","public":true,"question":"Wie bekommt mein Kind ein Schließfach?","topic":"schliessfach"},{"answer":"Die Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.","public":true,"question":"Was kostet die Miete?","topic":"schliessfach"},{"answer":"Wenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","public":true,"question":"Der Schlüssel ist verloren – was nun?","topic":"schliessfach"},{"answer":"Der Eltern-Euro ist ein **freiwilliger** Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden – etwa Bastelmaterial oder Ausflüge.","public":true,"question":"Was ist der Eltern-Euro?","topic":"elterneuro"},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"elterneuro"},{"answer":"Ob Schulfest, Basar oder eine helfende Hand bei Ausflügen – über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).","public":true,"question":"Wie kann ich mich engagieren?","topic":"elternarbeit"},{"answer":"Nein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.","public":true,"question":"Muss ich in den Elternbeirat gewählt sein, um zu helfen?","topic":"elternarbeit"}] + faqs: [{"answer":"Die Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.","public":true,"question":"Wie melde ich mein Kind zum Mittagessen an?","topic":"Mensa und Mittagessen"},{"answer":"Ein Mittagessen kostet aktuell **4,20 €**. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei – sprechen Sie das Sekretariat an.","public":true,"question":"Was kostet ein Mittagessen?","topic":"Mensa und Mittagessen"},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"Mensa und Mittagessen"},{"answer":"Schließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).","public":true,"question":"Wie bekommt mein Kind ein Schließfach?","topic":"Schließfächer"},{"answer":"Die Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.","public":true,"question":"Was kostet die Miete?","topic":"Schließfächer"},{"answer":"Wenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","public":true,"question":"Der Schlüssel ist verloren – was nun?","topic":"Schließfächer"},{"answer":"Der Eltern-Euro ist ein **freiwilliger** Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden – etwa Bastelmaterial oder Ausflüge.","public":true,"question":"Was ist der Eltern-Euro?","topic":"Eltern-Euro"},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"Eltern-Euro"},{"answer":"Ob Schulfest, Basar oder eine helfende Hand bei Ausflügen – über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).","public":true,"question":"Wie kann ich mich engagieren?","topic":"Elternarbeit und Mitmachen"},{"answer":"Nein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.","public":true,"question":"Muss ich in den Elternbeirat gewählt sein, um zu helfen?","topic":"Elternarbeit und Mitmachen"}] , } diff --git a/pb/pb_migrations/collections_schema.json b/pb/pb_migrations/collections_schema.json index 302f8da..db11602 100644 --- a/pb/pb_migrations/collections_schema.json +++ b/pb/pb_migrations/collections_schema.json @@ -393,21 +393,19 @@ "type": "text" }, { + "autogeneratePattern": "", "help": "", "hidden": false, "id": "select2638274075", - "maxSelect": 1, + "max": 0, + "min": 0, "name": "topic", + "pattern": "", "presentable": false, + "primaryKey": false, "required": true, "system": false, - "type": "select", - "values": [ - "mensa", - "schliessfach", - "elterneuro", - "elternarbeit" - ] + "type": "text" }, { "help": "",