using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; namespace Elternbeirat.Contracts; /// /// Converts between PocketBase date strings and Europe/Berlin wall-clock time. /// /// /// PocketBase stores every date in UTC and serializes it with a trailing Z /// (e.g. "2026-10-08 17:30:00.000Z"). 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 /// so that no later formatting shifts them again. /// internal static class WallClock { /// /// IANA id; resolves on every platform .NET supports, Windows included. /// private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); /// /// Parses a PocketBase UTC date string into Berlin wall-clock time. /// /// /// The raw PocketBase value; may be or empty. /// /// /// The Berlin wall-clock time with , or /// if is or empty /// (PocketBase's representation of an unset date). /// /// /// is not a valid date string. /// public static DateTime? Parse(string? raw) => string.IsNullOrEmpty(raw) ? null : TimeZoneInfo.ConvertTime( DateTimeOffset.Parse(raw, CultureInfo.InvariantCulture), Berlin).DateTime; /// /// Formats a Berlin wall-clock time as the UTC string PocketBase stores. /// Inverse of . /// /// /// A Berlin wall-clock time. Its is ignored, and the /// value is always interpreted as Berlin local time. /// /// /// The UTC value in PocketBase format, e.g. "2026-10-08 17:30:00.000Z". /// /// /// does not exist in Berlin, because it falls in the /// daylight-saving gap in spring. /// 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); } /// /// JSON converter that reads and writes PocketBase dates as Europe/Berlin /// wall-clock values. /// /// /// An empty string (unset date) is read as (). /// For optional dates, use instead. /// /// /// Apply it to a required date field of a contract: /// /// [JsonConverter(typeof(LocalDateTimeConverter))] /// public DateTime Start { get; init; } /// /// /// public sealed class LocalDateTimeConverter : JsonConverter { /// 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(WallClock.Format(value)); } /// /// JSON converter that reads and writes optional PocketBase dates as Europe/Berlin /// wall-clock values. /// /// /// PocketBase sends an empty string for an unset optional date (e.g. an event /// without an end). This value, like JSON null, is read as . /// /// /// Apply it to an optional date field of a contract: /// /// [JsonConverter(typeof(NullableLocalDateTimeConverter))] /// public DateTime? End { get; init; } /// /// /// public sealed class NullableLocalDateTimeConverter : JsonConverter { /// public override DateTime? Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => WallClock.Parse(reader.GetString()); /// /// /// Only called for non-null values: is /// , so the serializer writes itself. /// public override void Write( Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) => writer.WriteStringValue(WallClock.Format(value ?? throw new ArgumentNullException(nameof(value)))); }