Move date converters onto the contracts

This commit is contained in:
tleininger committed 2026-09-24 09:20:14 +02:00
1 parent 6d3260db49
commit 31b114982e
6 files changed
+29 -23

No files matched your search

@@ -1,44 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elternbeirat.PocketBase;
/// <summary>
/// JSON converter that reads and writes a PocketBase date as a calendar date
/// (<see cref="DateOnly"/>), ignoring the time-of-day and the time zone.
/// </summary>
/// <remarks>
/// 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 <c>2 Jan 00:00</c> Berlin is
/// stored as <c>"2026-01-01 22:00:00.000Z"</c>). The day the editor meant is
/// therefore the day <em>in Berlin</em>, not the day of the raw UTC string. This
/// converter reuses the same UTC-&gt;Berlin conversion as
/// <see cref="LocalDateTimeConverter"/> 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.
/// </remarks>
/// <remarks>
/// Registered on the client's <see cref="System.Text.Json.JsonSerializerOptions"/>
/// and matched by the <see cref="DateOnly"/> field type, so a contract needs no
/// per-field attribute (which would couple the contract to this project).
/// </remarks>
public sealed class DateOnlyConverter : JsonConverter<DateOnly>
{
/// <inheritdoc/>
/// <exception cref="FormatException">
/// The stored value is not a valid date string.
/// </exception>
public override DateOnly Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
=> WallClock.Parse(reader.GetString()) is { } berlin
? DateOnly.FromDateTime(berlin)
: default;
/// <inheritdoc/>
public override void Write(
Utf8JsonWriter writer,
DateOnly value,
JsonSerializerOptions options)
=> writer.WriteStringValue(WallClock.Format(value.ToDateTime(TimeOnly.MinValue)));
}
@@ -1,131 +0,0 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elternbeirat.PocketBase;
/// <summary>
/// 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
{
/// <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>
/// 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>
{
/// <inheritdoc/>
public override DateTime Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
=> WallClock.Parse(reader.GetString()) ?? default;
/// <inheritdoc/>
public override void Write(
Utf8JsonWriter writer,
DateTime value,
JsonSerializerOptions options)
=> writer.WriteStringValue(WallClock.Format(value));
}
/// <summary>
/// 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?>
{
/// <inheritdoc/>
public override DateTime? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
=> WallClock.Parse(reader.GetString());
/// <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))));
}
+7 -14
View File
@@ -24,7 +24,7 @@ namespace Elternbeirat.PocketBase;
public sealed class PocketBaseClient(HttpClient http)
{
/// <summary>
/// Serializer options that map PocketBase UTC dates to Berlin wall-clock time.
/// Shared serializer options for every request; see <see cref="CreateJsonOptions"/>.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
@@ -83,23 +83,16 @@ public sealed class PocketBaseClient(HttpClient http)
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
/// <summary>
/// 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.
/// Creates the serializer options. Date handling lives on the contracts
/// themselves via <c>[JsonConverter]</c> on each date field (e.g.
/// <see cref="Contracts.Event.Start"/>, <see cref="Contracts.Post.Date"/>), so
/// nothing date-related is registered here.
/// </summary>
/// <returns>
/// The configured options.
/// </returns>
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
};
options.Converters.Add(new LocalDateTimeConverter());
options.Converters.Add(new NullableLocalDateTimeConverter());
options.Converters.Add(new DateOnlyConverter());
return options;
}
private static JsonSerializerOptions CreateJsonOptions() =>
new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// Gets all public records of a collection in a single request.