Convert PocketBase dates to Berlin time; document code conventions
This commit is contained in:
1 parent
4b93434679
commit
aef05fb090
11 files changed
+406
-129
No files matched your search
@@ -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))));
|
||||
}
|
||||
Reference in new issue
Block a user