Fix/pocketbase date timezone #33

Merged
Tom merged 9 commits from fix/pocketbase-date-timezone into main 2026-09-24 14:50:11 +02:00
11 changed files with 406 additions and 129 deletions
Showing only changes of commit aef05fb090 - Show all commits

No files matched your search

+32 -2
View File
@@ -83,11 +83,41 @@ Inhalt liegt in PocketBase (`pb_data`), das separat gesichert wird (siehe
- Datenzugriff über den typisierten `PocketBaseClient` (registriert via - Datenzugriff über den typisierten `PocketBaseClient` (registriert via
`AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit `AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit
Inhalten. Komponenten liegen feature-basiert unter `Features/<Bereich>/`. Inhalten. Komponenten liegen feature-basiert unter `Features/<Bereich>/`.
- Ö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, - **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare,
Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht
`Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein `Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein
Besucher liest oder ein Redakteur pflegt: UI-Texte sowie die **Werte** der Besucher liest oder ein Redakteur pflegt: UI-Texte sowie die **Werte** der
PocketBase-Records (z. B. `title: Vorstandsteam`, der Markdown-`body`). Die PocketBase-Records (z. B. `title: Vorstandsteam`, der Markdown-`body`). Die
Feldnamen und Slugs bleiben dagegen englisch (`title`, `slug`, `/board`). 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: `<summary>`, dazu `<param>`, `<returns>`,
`<exception>` (jede geworfene Bedingung), `<remarks>` für Kontext/Fallstricke,
`<example>` mit `<code>` für nicht offensichtliche Nutzung, `<seealso>` auf
verwandte Typen. `<inheritdoc/>` bei Interface-/Basis-Implementierungen.
- Code im Text als Markup referenzieren, nicht als Prosa: `<see cref="…"/>`,
`<see langword="null"/>`/`<see langword="false"/>`, `<c>…</c>` 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.
+40 -10
View File
@@ -2,38 +2,68 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts; namespace Elternbeirat.Contracts;
/// <summary>A calendar entry. Sorted by <see cref="Start"/>.</summary> /// <summary>
/// A calendar entry of the Elternbeirat, as stored in PocketBase.
/// </summary>
/// <remarks>
/// Event lists are ordered by <see cref="Start"/>. Unset optional text fields are
/// <see cref="string.Empty"/>, never <see langword="null"/>.
/// </remarks>
public record Event public record Event
{ {
/// <summary>PocketBase record id.</summary> /// <summary>
/// Gets the PocketBase record id.
/// </summary>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public string Id { get; init; } = "";
/// <summary> /// <summary>
/// Start of the event with date and time. Stored as UTC by PocketBase but /// Gets the start of the event as Europe/Berlin wall-clock time.
/// read as local time (Europe/Berlin) by convention. An all-day event uses
/// 00:00 as the time.
/// </summary> /// </summary>
/// <remarks>
/// 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.
/// </remarks>
[JsonPropertyName("start")] [JsonPropertyName("start")]
public DateTime Start { get; init; } public DateTime Start { get; init; }
/// <summary>Optional end of the event; null when unset.</summary> /// <summary>
/// Gets the optional end of the event as Europe/Berlin wall-clock time.
/// </summary>
/// <value>
/// The end time, or <see langword="null"/> if the event has no end.
/// </value>
[JsonPropertyName("end")] [JsonPropertyName("end")]
public DateTime? End { get; init; } public DateTime? End { get; init; }
/// <summary>Event name, e.g. "Elternbeiratssitzung".</summary> /// <summary>
/// Gets the event name, e.g. <c>"Elternbeiratssitzung"</c>.
/// </summary>
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string Title { get; init; } = ""; public string Title { get; init; } = "";
/// <summary>Optional location, e.g. "Aula".</summary> /// <summary>
/// Gets the optional location, e.g. <c>"Aula"</c>.
/// </summary>
/// <value>
/// The location, or <see cref="string.Empty"/> if none is set.
/// </value>
[JsonPropertyName("location")] [JsonPropertyName("location")]
public string Location { get; init; } = ""; public string Location { get; init; } = "";
/// <summary>Optional note, e.g. "Anmeldung erforderlich".</summary> /// <summary>
/// Gets the optional note, e.g. <c>"Anmeldung erforderlich"</c>.
/// </summary>
/// <value>
/// The note, or <see cref="string.Empty"/> if none is set.
/// </value>
[JsonPropertyName("note")] [JsonPropertyName("note")]
public string Note { get; init; } = ""; public string Note { get; init; } = "";
/// <summary>Whether the event is visible to visitors.</summary> /// <summary>
/// Gets a value indicating whether the event is visible to visitors of the site.
/// </summary>
[JsonPropertyName("public")] [JsonPropertyName("public")]
public bool Public { get; init; } public bool Public { get; init; }
} }
+19 -6
View File
@@ -3,27 +3,40 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts; namespace Elternbeirat.Contracts;
/// <summary> /// <summary>
/// A single question and answer, grouped on the FAQ page by <see cref="Topic"/>. /// A single question and answer of the FAQ, as stored in PocketBase.
/// </summary> /// </summary>
/// <remarks>
/// On the FAQ page, entries are grouped by <see cref="Topic"/>.
/// </remarks>
public record Faq public record Faq
{ {
/// <summary>PocketBase record id.</summary> /// <summary>
/// Gets the PocketBase record id.
/// </summary>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public string Id { get; init; } = "";
/// <summary>The question as a parent would phrase it.</summary> /// <summary>
/// Gets the question as a parent would phrase it.
/// </summary>
[JsonPropertyName("question")] [JsonPropertyName("question")]
public string Question { get; init; } = ""; public string Question { get; init; } = "";
/// <summary>The answer in Markdown.</summary> /// <summary>
/// Gets the answer as Markdown.
/// </summary>
[JsonPropertyName("answer")] [JsonPropertyName("answer")]
public string Answer { get; init; } = ""; public string Answer { get; init; } = "";
/// <summary>Topic the question is grouped under, e.g. "mensa".</summary> /// <summary>
/// Gets the topic the question is grouped under, e.g. <c>"mensa"</c>.
/// </summary>
[JsonPropertyName("topic")] [JsonPropertyName("topic")]
public string Topic { get; init; } = ""; public string Topic { get; init; } = "";
/// <summary>Whether the question is visible to visitors.</summary> /// <summary>
/// Gets a value indicating whether the question is visible to visitors of the site.
/// </summary>
[JsonPropertyName("public")] [JsonPropertyName("public")]
public bool Public { get; init; } public bool Public { get; init; }
} }
+45 -13
View File
@@ -3,45 +3,77 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts; namespace Elternbeirat.Contracts;
/// <summary> /// <summary>
/// A content page. Pages also drive the site navigation: <see cref="Location"/> /// A content page of the site, as stored in PocketBase.
/// and <see cref="Order"/> decide where and in which order a page appears in the
/// header or footer menu, and <see cref="Embed"/> lists dynamic blocks (posts,
/// events, faqs) rendered below the page body.
/// </summary> /// </summary>
/// <remarks>
/// Pages also drive the site navigation: <see cref="Location"/> and
/// <see cref="Order"/> decide where and in which order a page appears in the
/// header or footer menu. <see cref="Embed"/> lists dynamic blocks rendered
/// below the page body.
/// </remarks>
public record Page public record Page
{ {
/// <summary>PocketBase record id.</summary> /// <summary>
/// Gets the PocketBase record id.
/// </summary>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public string Id { get; init; } = "";
/// <summary>Heading shown to visitors; may contain umlauts and spaces.</summary> /// <summary>
/// Gets the heading shown to visitors.
/// </summary>
/// <remarks>
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
/// </remarks>
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string Title { get; init; } = ""; public string Title { get; init; } = "";
/// <summary>Page body in Markdown.</summary> /// <summary>
/// Gets the page body as Markdown.
/// </summary>
[JsonPropertyName("body")] [JsonPropertyName("body")]
public string Body { get; init; } = ""; public string Body { get; init; } = "";
/// <summary>Where the page appears in the navigation: "header" or "footer".</summary> /// <summary>
/// Gets the navigation menu the page appears in.
/// </summary>
/// <value>
/// Either <c>"header"</c> or <c>"footer"</c>.
/// </value>
[JsonPropertyName("location")] [JsonPropertyName("location")]
public string Location { get; init; } = ""; public string Location { get; init; } = "";
/// <summary>Sort order within its navigation location; smaller is earlier.</summary> /// <summary>
/// Gets the sort order within the navigation menu given by <see cref="Location"/>.
/// </summary>
/// <value>
/// The sort key; pages with smaller values appear first.
/// </value>
[JsonPropertyName("order")] [JsonPropertyName("order")]
public double Order { get; init; } public double Order { get; init; }
/// <summary>URL slug (lowercase, no umlauts), e.g. "board" -> /board.</summary> /// <summary>
/// Gets the URL slug of the page, e.g. <c>"board"</c> for <c>/board</c>.
/// </summary>
/// <value>
/// A lowercase path segment without umlauts.
/// </value>
[JsonPropertyName("slug")] [JsonPropertyName("slug")]
public string Slug { get; init; } = ""; public string Slug { get; init; } = "";
/// <summary> /// <summary>
/// Dynamic blocks to render below the body: any of "posts", "events", "faqs". /// Gets the dynamic blocks rendered below the <see cref="Body"/>.
/// Empty for a plain text page.
/// </summary> /// </summary>
/// <value>
/// Any of <c>"posts"</c>, <c>"events"</c> and <c>"faqs"</c>, or an empty list
/// for a plain text page.
/// </value>
[JsonPropertyName("embed")] [JsonPropertyName("embed")]
public IReadOnlyList<string> Embed { get; init; } = []; public IReadOnlyList<string> Embed { get; init; } = [];
/// <summary>Whether the page is visible to visitors.</summary> /// <summary>
/// Gets a value indicating whether the page is visible to visitors of the site.
/// </summary>
[JsonPropertyName("public")] [JsonPropertyName("public")]
public bool Public { get; init; } public bool Public { get; init; }
} }
+32 -8
View File
@@ -2,33 +2,57 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts; namespace Elternbeirat.Contracts;
/// <summary>A news post. Sorted by <see cref="Date"/>, newest first.</summary> /// <summary>
/// A news post of the site, as stored in PocketBase.
/// </summary>
/// <remarks>
/// Post lists are ordered by <see cref="Date"/>, newest first.
/// </remarks>
public record Post public record Post
{ {
/// <summary>PocketBase record id.</summary> /// <summary>
/// Gets the PocketBase record id.
/// </summary>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public string Id { get; init; } = "";
/// <summary> /// <summary>
/// Publication date. Stored as UTC by PocketBase but read as local time /// Gets the publication date as Europe/Berlin wall-clock time.
/// (Europe/Berlin) by convention; only the date part is shown.
/// </summary> /// </summary>
/// <remarks>
/// 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.
/// </remarks>
[JsonPropertyName("date")] [JsonPropertyName("date")]
public DateTime Date { get; init; } public DateTime Date { get; init; }
/// <summary>Post heading shown to visitors; may contain umlauts and spaces.</summary> /// <summary>
/// Gets the heading shown to visitors.
/// </summary>
/// <remarks>
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
/// </remarks>
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string Title { get; init; } = ""; public string Title { get; init; } = "";
/// <summary>Post body in Markdown.</summary> /// <summary>
/// Gets the post body as Markdown.
/// </summary>
[JsonPropertyName("body")] [JsonPropertyName("body")]
public string Body { get; init; } = ""; public string Body { get; init; } = "";
/// <summary>URL slug (lowercase, no umlauts), e.g. "herbstbasar" -> /posts/herbstbasar.</summary> /// <summary>
/// Gets the URL slug of the post, e.g. <c>"herbstbasar"</c> for <c>/posts/herbstbasar</c>.
/// </summary>
/// <value>
/// A lowercase path segment without umlauts.
/// </value>
[JsonPropertyName("slug")] [JsonPropertyName("slug")]
public string Slug { get; init; } = ""; public string Slug { get; init; } = "";
/// <summary>Whether the post is visible to visitors.</summary> /// <summary>
/// Gets a value indicating whether the post is visible to visitors of the site.
/// </summary>
[JsonPropertyName("public")] [JsonPropertyName("public")]
public bool Public { get; init; } public bool Public { get; init; }
} }
+106 -50
View File
@@ -5,71 +5,127 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.PocketBase; namespace Elternbeirat.PocketBase;
/// <summary> /// <summary>
/// Shared parsing of a PocketBase date string as wall-clock time. The trailing /// Converts between PocketBase date strings and Europe/Berlin wall-clock time.
/// "Z" is stripped rather than honoured, so the number is taken at face value and
/// the result carries <see cref="DateTimeKind.Unspecified"/> -- no timezone shift.
/// </summary> /// </summary>
/// <remarks>
/// PocketBase stores every date in UTC and serializes it with a trailing <c>Z</c>
/// (e.g. <c>"2026-10-08 17:30:00.000Z"</c>). 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
/// <see cref="DateTimeKind.Unspecified"/> so that no later formatting shifts them again.
/// </remarks>
internal static class WallClock internal static class WallClock
{ {
public static DateTime Parse(string raw) /// <summary>
{ /// IANA id; resolves on every platform .NET supports, Windows included.
// Drop a trailing "Z" so DateTime.Parse does not treat the value as UTC /// </summary>
// and convert it to local time (which would shift 19:30 to 20:30/21:30). private static readonly TimeZoneInfo Berlin =
var value = raw.EndsWith('Z') ? raw[..^1] : raw; TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
var parsed = DateTime.Parse(value, CultureInfo.InvariantCulture,
DateTimeStyles.None); /// <summary>
return DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified); /// Parses a PocketBase UTC date string into Berlin wall-clock time.
} /// </summary>
/// <param name="raw">
/// The raw PocketBase value; may be <see langword="null"/> or empty.
/// </param>
/// <returns>
/// The Berlin wall-clock time with <see cref="DateTimeKind.Unspecified"/>, or
/// <see langword="null"/> if <paramref name="raw"/> is <see langword="null"/> or empty
/// (PocketBase's representation of an unset date).
/// </returns>
/// <exception cref="FormatException">
/// <paramref name="raw"/> is not a valid date string.
/// </exception>
public static DateTime? Parse(string? raw) =>
string.IsNullOrEmpty(raw)
? null
: TimeZoneInfo.ConvertTime(
DateTimeOffset.Parse(raw, CultureInfo.InvariantCulture), Berlin).DateTime;
/// <summary>
/// Formats a Berlin wall-clock time as the UTC string PocketBase stores.
/// Inverse of <see cref="Parse"/>.
/// </summary>
/// <param name="berlin">
/// A Berlin wall-clock time. Its <see cref="DateTime.Kind"/> is ignored, and the
/// value is always interpreted as Berlin local time.
/// </param>
/// <returns>
/// The UTC value in PocketBase format, e.g. <c>"2026-10-08 17:30:00.000Z"</c>.
/// </returns>
/// <exception cref="ArgumentException">
/// <paramref name="berlin"/> does not exist in Berlin, because it falls in the
/// daylight-saving gap in spring.
/// </exception>
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);
} }
/// <summary> /// <summary>
/// Reads PocketBase date values as local wall-clock time. /// JSON converter that reads and writes PocketBase dates as Europe/Berlin
/// <para> /// wall-clock <see cref="DateTime"/> values.
/// 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 <see cref="DateTimeKind.Unspecified"/> instant, so no
/// timezone shift is ever applied when the value is later formatted.
/// </para>
/// </summary> /// </summary>
/// <remarks>
/// An empty string (unset date) is read as <see langword="default"/>(<see cref="DateTime"/>).
/// For optional dates, use <see cref="NullableLocalDateTimeConverter"/> instead.
/// </remarks>
/// <example>
/// <code>
/// [JsonConverter(typeof(LocalDateTimeConverter))]
/// public DateTime Start { get; init; }
/// </code>
/// </example>
/// <seealso cref="WallClock"/>
public sealed class LocalDateTimeConverter : JsonConverter<DateTime> public sealed class LocalDateTimeConverter : JsonConverter<DateTime>
{ {
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) /// <inheritdoc/>
{ public override DateTime Read(
var raw = reader.GetString(); ref Utf8JsonReader reader,
return string.IsNullOrEmpty(raw) Type typeToConvert,
? default JsonSerializerOptions options)
: WallClock.Parse(raw); => WallClock.Parse(reader.GetString()) ?? default;
}
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) /// <inheritdoc/>
=> writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", public override void Write(
CultureInfo.InvariantCulture)); Utf8JsonWriter writer,
DateTime value,
JsonSerializerOptions options)
=> writer.WriteStringValue(WallClock.Format(value));
} }
/// <summary> /// <summary>
/// Nullable counterpart of <see cref="LocalDateTimeConverter"/>. PocketBase sends /// JSON converter that reads and writes optional PocketBase dates as Europe/Berlin
/// an empty string for an unset optional date (e.g. an event without an end); /// wall-clock <see cref="DateTime"/> values.
/// that maps to null.
/// </summary> /// </summary>
/// <remarks>
/// PocketBase sends an empty string for an unset optional date (e.g. an event
/// without an end). This value, like JSON <c>null</c>, is read as <see langword="null"/>.
/// </remarks>
/// <example>
/// <code>
/// [JsonConverter(typeof(NullableLocalDateTimeConverter))]
/// public DateTime? End { get; init; }
/// </code>
/// </example>
/// <seealso cref="WallClock"/>
public sealed class NullableLocalDateTimeConverter : JsonConverter<DateTime?> public sealed class NullableLocalDateTimeConverter : JsonConverter<DateTime?>
{ {
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) /// <inheritdoc/>
{ public override DateTime? Read(
var raw = reader.GetString(); ref Utf8JsonReader reader,
if (string.IsNullOrEmpty(raw)) Type typeToConvert,
return null; JsonSerializerOptions options)
=> WallClock.Parse(reader.GetString());
return WallClock.Parse(raw); /// <inheritdoc/>
} /// <remarks>
/// Only called for non-null values: <see cref="JsonConverter{T}.HandleNull"/> is
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) /// <see langword="false"/>, so the serializer writes <see langword="null"/> itself.
{ /// </remarks>
if (value is null) public override void Write(
writer.WriteStringValue(""); Utf8JsonWriter writer,
else DateTime? value,
writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", JsonSerializerOptions options)
CultureInfo.InvariantCulture)); => writer.WriteStringValue(WallClock.Format(value ?? throw new ArgumentNullException(nameof(value))));
}
} }
+100 -25
View File
@@ -5,19 +5,89 @@ using Elternbeirat.Contracts;
namespace Elternbeirat.PocketBase; namespace Elternbeirat.PocketBase;
/// <summary> /// <summary>
/// Reads published content from a PocketBase instance over its REST API. /// Reads published content from a PocketBase instance over its REST API.
/// <para>
/// One method per collection (pages, posts, events, faqs). Each returns only
/// records with <c>public = true</c> and lets PocketBase do the filtering and
/// sorting via query parameters. The <see cref="HttpClient"/> is expected to have
/// its <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL, so it
/// is registered as a typed client via <c>AddHttpClient</c>.
/// </para>
/// </summary> /// </summary>
/// <remarks>
/// <para>
/// There is one method per collection (pages, posts, events, faqs). Each returns
/// only records with <c>public = true</c> and leaves filtering and sorting to
/// PocketBase via query parameters.
/// </para>
/// <para>
/// Register the class as a typed client via <c>AddHttpClient</c>, with
/// <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL.
/// </para>
/// </remarks>
/// <param name="http">
/// The HTTP client; its <see cref="HttpClient.BaseAddress"/> must point to PocketBase.
/// </param>
public sealed class PocketBaseClient(HttpClient http) public sealed class PocketBaseClient(HttpClient http)
{ {
/// <summary>
/// Serializer options that map PocketBase UTC dates to Berlin wall-clock time.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
/// <summary>
/// Gets all public pages, ordered by <see cref="Page.Order"/>.
/// </summary>
/// <param name="ct">
/// A token to cancel the request.
/// </param>
/// <returns>
/// The public pages, or an empty list if there are none.
/// </returns>
/// <exception cref="HttpRequestException">
/// PocketBase could not be reached or returned a non-success status code.
/// </exception>
/// <exception cref="JsonException">
/// The response could not be deserialized.
/// </exception>
/// <exception cref="OperationCanceledException">
/// <paramref name="ct"/> was canceled or the request timed out.
/// </exception>
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
=> GetRecordsAsync<Page>("pages", "order", ct);
/// <summary>
/// Gets all public posts, ordered by <see cref="Post.Date"/>, newest first.
/// </summary>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public posts, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
=> GetRecordsAsync<Post>("posts", "-date", ct);
/// <summary>
/// Gets all public events, ordered by <see cref="Event.Start"/>, earliest first.
/// </summary>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public events, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default)
=> GetRecordsAsync<Event>("events", "start", ct);
/// <summary>
/// Gets all public FAQ entries, ordered by <see cref="Faq.Topic"/>.
/// </summary>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public FAQ entries, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
/// <summary>
/// Creates the serializer options with both wall-clock date converters registered.
/// </summary>
/// <returns>
/// The configured options.
/// </returns>
private static JsonSerializerOptions CreateJsonOptions() private static JsonSerializerOptions CreateJsonOptions()
{ {
var options = new JsonSerializerOptions var options = new JsonSerializerOptions
@@ -29,23 +99,28 @@ public sealed class PocketBaseClient(HttpClient http)
return options; return options;
} }
/// <summary>Gets all public pages, ordered for navigation.</summary> /// <summary>
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default) /// Gets all public records of a collection in a single request.
=> GetRecordsAsync<Page>("pages", "order", ct); /// </summary>
/// <typeparam name="T">
/// <summary>Gets all public posts, newest first.</summary> /// The record type to deserialize into.
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default) /// </typeparam>
=> GetRecordsAsync<Post>("posts", "-date", ct); /// <param name="collection">
/// The PocketBase collection name, e.g. <c>"posts"</c>.
/// <summary>Gets all public events, earliest start first.</summary> /// </param>
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default) /// <param name="sort">
=> GetRecordsAsync<Event>("events", "start", ct); /// The PocketBase sort expression; a leading <c>-</c> sorts descending.
/// </param>
/// <summary>Gets all public FAQ entries.</summary> /// <param name="ct">
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default) /// A token to cancel the request.
=> GetRecordsAsync<Faq>("faqs", "topic", ct); /// </param>
/// <returns>
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(string collection, string sort, CancellationToken ct) /// The public records, or an empty list if there are none.
/// </returns>
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(
string collection,
string sort,
CancellationToken ct)
{ {
// filter=public=true keeps drafts out; perPage is large enough to fetch // filter=public=true keeps drafts out; perPage is large enough to fetch
// every record in a single page given the small content volume. // every record in a single page given the small content volume.
+16 -4
View File
@@ -3,13 +3,25 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.PocketBase; namespace Elternbeirat.PocketBase;
/// <summary> /// <summary>
/// The envelope PocketBase wraps a records list response in. Only <see cref="Items"/> /// The envelope PocketBase wraps a records list response in.
/// is used; the paging fields are ignored because content volumes are small and the
/// client requests a large page size in a single call.
/// </summary> /// </summary>
/// <typeparam name="T">The record type inside <c>items</c>.</typeparam> /// <remarks>
/// Only <see cref="Items"/> is mapped. The paging fields (<c>page</c>,
/// <c>perPage</c>, <c>totalItems</c>, <c>totalPages</c>) are ignored, because
/// content volumes are small and <see cref="PocketBaseClient"/> fetches every
/// record in a single request.
/// </remarks>
/// <typeparam name="T">
/// The record type inside <c>items</c>.
/// </typeparam>
internal sealed record RecordList<T> internal sealed record RecordList<T>
{ {
/// <summary>
/// Gets the records of the requested page.
/// </summary>
/// <value>
/// The records, or an empty list if the collection has no matching records.
/// </value>
[JsonPropertyName("items")] [JsonPropertyName("items")]
public IReadOnlyList<T> Items { get; init; } = []; public IReadOnlyList<T> Items { get; init; } = [];
} }
@@ -23,14 +23,15 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
} }
[Fact] [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 client = pocketBase.CreateClient();
var events = await client.GetEventsAsync(); var events = await client.GetEventsAsync();
// The meeting is seeded as 19:30; by the timezone convention the number is // The meeting is seeded as 17:30Z. October is summer time (UTC+2), so the
// taken at face value, so no shift to 20:30/21:30 happens. // converter turns it into 19:30 Berlin wall-clock -- the time an editor
// entered in the admin form.
var meeting = events.FirstOrDefault(e => var meeting = events.FirstOrDefault(e =>
e.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal)); e.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal));
meeting.ShouldNotBeNull(); meeting.ShouldNotBeNull();
+6 -3
View File
@@ -295,15 +295,18 @@ public sealed class PocketBaseFixture : IAsyncLifetime
body = "Text", slug = "new-hall", @public = true, 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 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, location = "Aula", note = "", @public = true,
}); });
await CreateRecordAsync(http, "events", new 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, title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
}); });
+6 -5
View File
@@ -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 die Reihenfolge der Records spielt keine Rolle. Besucher können `/events.ics` in
ihrer Kalender-App abonnieren — der Feed entsteht aus denselben Records. ihrer Kalender-App abonnieren — der Feed entsteht aus denselben Records.
> **Uhrzeit = Ortszeit.** Das Admin-Formular rechnet Datumsfelder in die > **Trage einfach die Ortszeit ein.** Gib die Uhrzeit ein, die auf der Seite
> Browser-Zeitzone um und zeigt eine gespeicherte `19:30` je nach Sommer-/Winter- > stehen soll (Europe/Berlin) — z. B. `08:00`. PocketBase speichert intern in UTC
> zeit als 20:30/21:30 an. Das ist **kein** Fehler, nur zwei Bezugssysteme: der > und zeigt dir nach dem Speichern deshalb einen um 1–2 Stunden früheren Wert an
> gespeicherte Zahlenwert **ist** die Ortszeit (Europe/Berlin), die App zeigt ihn > (aus `08:00` wird im Sommer `06:00`). Das ist **kein** Fehler: Die Website
> unverändert. Trage die Uhrzeit ein, die auf der Seite stehen soll. > 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.
--- ---