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. ---