Fix/pocketbase date timezone #33
No files matched your search
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
return start;
|
/// </summary>
|
||||||
}
|
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
|
||||||
// Same day: append just the end time. Different days: append the full
|
/// <summary>
|
||||||
// end date.
|
/// Formats the date range of an event.
|
||||||
if (end.Date == ev.Start.Date)
|
/// </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 HasTime(ev)
|
(var start, null, var hasTime)
|
||||||
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
|
=> Stamp(start, LongDate, hasTime),
|
||||||
: start;
|
(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)}",
|
||||||
|
};
|
||||||
|
|
||||||
var endText = HasTime(ev)
|
/// <summary>
|
||||||
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
|
/// Formats a single point in time as a date with an optional time.
|
||||||
: end.ToString("d. MMMM yyyy", German);
|
/// </summary>
|
||||||
return start + " – " + endText;
|
/// <param name="value">
|
||||||
}
|
/// The date and time to format.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="dateFormat">
|
||||||
|
/// The date format, e.g. <see cref="LongDate"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="withTime">
|
||||||
|
/// <see langword="true"/> to append the time, e.g. <c>, 19:30 Uhr</c>.
|
||||||
|
/// </param>
|
||||||
|
/// <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);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user