From 6d3260db491827b45d894c013e0a02cfe5e55a89 Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 09:11:37 +0200 Subject: [PATCH] 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`). ---