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