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
4 changed files with 87 additions and 57 deletions
Showing only changes of commit 66bc76af1c - Show all commits

No files matched your search

+12
View File
@@ -68,4 +68,16 @@ public record Event
/// </summary> /// </summary>
[JsonPropertyName("public")] [JsonPropertyName("public")]
public bool Public { get; init; } public bool Public { get; init; }
/// <summary>
/// Gets a value indicating whether the event has a wall-clock time, as opposed
/// to being an all-day event.
/// </summary>
/// <remarks>
/// PocketBase has no all-day flag; the convention is that a start of 00:00
/// means all-day. This is the single definition of that rule -- formatting and
/// the calendar feed both read it here instead of re-deriving it.
/// </remarks>
[JsonIgnore]
public bool HasTime => Start.TimeOfDay != TimeSpan.Zero;
} }
+7 -18
View File
@@ -14,6 +14,12 @@ namespace Elternbeirat.PocketBase;
/// PocketBase via query parameters. /// PocketBase via query parameters.
/// </para> /// </para>
/// <para> /// <para>
/// Responses are deserialized with the web defaults. Date conversion from UTC
/// to Berlin wall-clock time is declared on the contracts themselves via
/// <c>[JsonConverter]</c> (e.g. <see cref="Event.Start"/>, <see cref="Post.Date"/>),
/// so no serializer options are configured here.
/// </para>
/// <para>
/// Register the class as a typed client via <c>AddHttpClient</c>, with /// Register the class as a typed client via <c>AddHttpClient</c>, with
/// <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL. /// <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL.
/// </para> /// </para>
@@ -23,11 +29,6 @@ namespace Elternbeirat.PocketBase;
/// </param> /// </param>
public sealed class PocketBaseClient(HttpClient http) public sealed class PocketBaseClient(HttpClient http)
{ {
/// <summary>
/// Shared serializer options for every request; see <see cref="CreateJsonOptions"/>.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
/// <summary> /// <summary>
/// Gets all public pages, ordered by <see cref="Page.Order"/>. /// Gets all public pages, ordered by <see cref="Page.Order"/>.
/// </summary> /// </summary>
@@ -82,18 +83,6 @@ public sealed class PocketBaseClient(HttpClient http)
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default) public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
=> GetRecordsAsync<Faq>("faqs", "topic", ct); => GetRecordsAsync<Faq>("faqs", "topic", ct);
/// <summary>
/// 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() =>
new() { PropertyNameCaseInsensitive = true };
/// <summary> /// <summary>
/// Gets all public records of a collection in a single request. /// Gets all public records of a collection in a single request.
/// </summary> /// </summary>
@@ -123,7 +112,7 @@ public sealed class PocketBaseClient(HttpClient http)
+ $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}"
+ $"&sort={Uri.EscapeDataString(sort)}"; + $"&sort={Uri.EscapeDataString(sort)}";
var result = await http.GetFromJsonAsync<RecordList<T>>(url, JsonOptions, ct); var result = await http.GetFromJsonAsync<RecordList<T>>(url, ct);
return result?.Items ?? []; return result?.Items ?? [];
} }
} }
@@ -45,7 +45,7 @@ public static class IcsCalendar
// between requests, so caches and clients do not see spurious changes. // between requests, so caches and clients do not see spurious changes.
AppendLine(sb, "DTSTAMP:20000101T000000Z"); AppendLine(sb, "DTSTAMP:20000101T000000Z");
if (HasTime(ev)) if (ev.HasTime)
{ {
AppendLine(sb, $"DTSTART:{Local(ev.Start)}"); AppendLine(sb, $"DTSTART:{Local(ev.Start)}");
if (ev.End is { } end) if (ev.End is { } end)
@@ -75,12 +75,6 @@ public static class IcsCalendar
AppendLine(sb, "END:VEVENT"); AppendLine(sb, "END:VEVENT");
} }
/// <summary>
/// An entry counts as all-day when its start has no wall-clock time (00:00).
/// PocketBase has no separate flag; the convention is a midnight start.
/// </summary>
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
/// <summary> /// <summary>
/// A UID that stays the same as long as the entry's title and start do, so a /// A UID that stays the same as long as the entry's title and start do, so a
/// re-subscribe updates the event instead of creating a duplicate. /// re-subscribe updates the event instead of creating a duplicate.
+67 -32
View File
@@ -4,45 +4,80 @@ using Elternbeirat.Contracts;
namespace Elternbeirat.Web.Shared; namespace Elternbeirat.Web.Shared;
/// <summary> /// <summary>
/// Formats an <see cref="Event"/>'s date range for display in German. Shared by /// Formats the date range of an <see cref="Event"/> for display in German.
/// the full event list and the home-page events embed so both render dates the
/// same way.
/// </summary> /// </summary>
/// <remarks>
/// Shared by the full event list and the home-page events embed, so both render
/// dates the same way.
/// </remarks>
public static class EventFormat public static class EventFormat
{ {
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); /// <summary>
/// Date format for the start, and for a single-day event.
// An event counts as all-day when its start carries no wall-clock time /// </summary>
// (00:00); PocketBase has no separate flag. private const string LongDate = "dddd, d. MMMM yyyy";
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
/// <summary> /// <summary>
/// Formats the entry's date range: a single day, a date with a time, or a /// Date format for the end of a multi-day event; the weekday is omitted.
/// span across days.
/// </summary> /// </summary>
public static string Range(Event ev) private const string ShortDate = "d. MMMM yyyy";
{
var start = HasTime(ev)
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
if (ev.End is not { } end) /// <summary>
/// The culture used for month and weekday names.
/// </summary>
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
/// <summary>
/// Formats the date range of an event.
/// </summary>
/// <param name="event">
/// The event to format.
/// </param>
/// <returns>
/// The formatted range, depending on <see cref="Event.End"/> and <see cref="Event.HasTime"/>:
/// <list type="bullet">
/// <item>
/// <description>No end: <c>Donnerstag, 8. Oktober 2026, 19:30 Uhr</c></description>
/// </item>
/// <item>
/// <description>Same day: <c>Donnerstag, 8. Oktober 2026, 19:30–21:00 Uhr</c></description>
/// </item>
/// <item>
/// <description>Several days: <c>Freitag, 9. Oktober 2026 – 11. Oktober 2026</c></description>
/// </item>
/// </list>
/// Without a time, the time parts are omitted.
/// </returns>
public static string Range(Event @event)
=> (@event.Start, @event.End, @event.HasTime) switch
{ {
return start; (var start, null, var hasTime)
} => Stamp(start, LongDate, hasTime),
(var start, { } end, true) when end.Date == start.Date
=> $"{start.ToString(LongDate, German)}, {start.ToString("HH:mm", German)}–{end.ToString("HH:mm", German)} Uhr",
(var start, { } end, false) when end.Date == start.Date
=> Stamp(start, LongDate, withTime: false),
(var start, { } end, var hasTime)
=> $"{Stamp(start, LongDate, hasTime)} – {Stamp(end, ShortDate, hasTime)}",
};
// Same day: append just the end time. Different days: append the full /// <summary>
// end date. /// Formats a single point in time as a date with an optional time.
if (end.Date == ev.Start.Date) /// </summary>
{ /// <param name="value">
return HasTime(ev) /// The date and time to format.
? start + "–" + end.ToString("HH:mm", German) + " Uhr" /// </param>
: start; /// <param name="dateFormat">
} /// The date format, e.g. <see cref="LongDate"/>.
/// </param>
var endText = HasTime(ev) /// <param name="withTime">
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr" /// <see langword="true"/> to append the time, e.g. <c>, 19:30 Uhr</c>.
: end.ToString("d. MMMM yyyy", German); /// </param>
return start + " – " + endText; /// <returns>
} /// The formatted date, with the time if requested.
/// </returns>
private static string Stamp(DateTime value, string dateFormat, bool withTime)
=> withTime
? value.ToString(dateFormat + ", HH:mm", German) + " Uhr"
: value.ToString(dateFormat, German);
} }