Convert PocketBase dates to Berlin time; document code conventions

This commit is contained in:
tleininger committed 2026-09-24 08:53:47 +02:00
1 parent 4b93434679
commit aef05fb090
11 files changed
+406 -129

No files matched your search

+106 -50
View File
@@ -5,71 +5,127 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.PocketBase;
/// <summary>
/// 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 <see cref="DateTimeKind.Unspecified"/> -- no timezone shift.
/// Converts between PocketBase date strings and Europe/Berlin wall-clock time.
/// </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
{
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);
}
/// <summary>
/// IANA id; resolves on every platform .NET supports, Windows included.
/// </summary>
private static readonly TimeZoneInfo Berlin =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>
/// 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>
/// Reads PocketBase date values as local wall-clock time.
/// <para>
/// 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>
/// JSON converter that reads and writes PocketBase dates as Europe/Berlin
/// wall-clock <see cref="DateTime"/> values.
/// </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 override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString();
return string.IsNullOrEmpty(raw)
? default
: WallClock.Parse(raw);
}
/// <inheritdoc/>
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));
/// <inheritdoc/>
public override void Write(
Utf8JsonWriter writer,
DateTime value,
JsonSerializerOptions options)
=> writer.WriteStringValue(WallClock.Format(value));
}
/// <summary>
/// Nullable counterpart of <see cref="LocalDateTimeConverter"/>. 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 <see cref="DateTime"/> values.
/// </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 override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString();
if (string.IsNullOrEmpty(raw))
return null;
/// <inheritdoc/>
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));
}
/// <inheritdoc/>
/// <remarks>
/// Only called for non-null values: <see cref="JsonConverter{T}.HandleNull"/> is
/// <see langword="false"/>, so the serializer writes <see langword="null"/> itself.
/// </remarks>
public override void Write(
Utf8JsonWriter writer,
DateTime? value,
JsonSerializerOptions options)
=> writer.WriteStringValue(WallClock.Format(value ?? throw new ArgumentNullException(nameof(value))));
}
+100 -25
View File
@@ -5,19 +5,89 @@ using Elternbeirat.Contracts;
namespace Elternbeirat.PocketBase;
/// <summary>
/// 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>
/// Reads published content from a PocketBase instance over its REST API.
/// </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)
{
/// <summary>
/// Serializer options that map PocketBase UTC dates to Berlin wall-clock time.
/// </summary>
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()
{
var options = new JsonSerializerOptions
@@ -29,23 +99,28 @@ public sealed class PocketBaseClient(HttpClient http)
return options;
}
/// <summary>Gets all public pages, ordered for navigation.</summary>
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
=> GetRecordsAsync<Page>("pages", "order", ct);
/// <summary>Gets all public posts, newest first.</summary>
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
=> GetRecordsAsync<Post>("posts", "-date", ct);
/// <summary>Gets all public events, earliest start first.</summary>
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default)
=> GetRecordsAsync<Event>("events", "start", ct);
/// <summary>Gets all public FAQ entries.</summary>
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(string collection, string sort, CancellationToken ct)
/// <summary>
/// Gets all public records of a collection in a single request.
/// </summary>
/// <typeparam name="T">
/// The record type to deserialize into.
/// </typeparam>
/// <param name="collection">
/// The PocketBase collection name, e.g. <c>"posts"</c>.
/// </param>
/// <param name="sort">
/// The PocketBase sort expression; a leading <c>-</c> sorts descending.
/// </param>
/// <param name="ct">
/// A token to cancel the request.
/// </param>
/// <returns>
/// 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
// 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;
/// <summary>
/// The envelope PocketBase wraps a records list response in. Only <see cref="Items"/>
/// 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.
/// </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>
{
/// <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")]
public IReadOnlyList<T> Items { get; init; } = [];
}