diff --git a/Elternbeirat.Contracts/Event.cs b/Elternbeirat.Contracts/Event.cs index cd6e9ff..aef9878 100644 --- a/Elternbeirat.Contracts/Event.cs +++ b/Elternbeirat.Contracts/Event.cs @@ -68,4 +68,16 @@ public record Event /// [JsonPropertyName("public")] public bool Public { get; init; } + + /// + /// Gets a value indicating whether the event has a wall-clock time, as opposed + /// to being an all-day event. + /// + /// + /// 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. + /// + [JsonIgnore] + public bool HasTime => Start.TimeOfDay != TimeSpan.Zero; } diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index 3a008c0..5e68cfa 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -14,6 +14,12 @@ namespace Elternbeirat.PocketBase; /// PocketBase via query parameters. /// /// +/// Responses are deserialized with the web defaults. Date conversion from UTC +/// to Berlin wall-clock time is declared on the contracts themselves via +/// [JsonConverter] (e.g. , ), +/// so no serializer options are configured here. +/// +/// /// Register the class as a typed client via AddHttpClient, with /// set to the PocketBase base URL. /// @@ -23,11 +29,6 @@ namespace Elternbeirat.PocketBase; /// public sealed class PocketBaseClient(HttpClient http) { - /// - /// Shared serializer options for every request; see . - /// - private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); - /// /// Gets all public pages, ordered by . /// @@ -82,18 +83,6 @@ public sealed class PocketBaseClient(HttpClient http) public Task> GetFaqsAsync(CancellationToken ct = default) => GetRecordsAsync("faqs", "topic", ct); - /// - /// Creates the serializer options. Date handling lives on the contracts - /// themselves via [JsonConverter] on each date field (e.g. - /// , ), so - /// nothing date-related is registered here. - /// - /// - /// The configured options. - /// - private static JsonSerializerOptions CreateJsonOptions() => - new() { PropertyNameCaseInsensitive = true }; - /// /// Gets all public records of a collection in a single request. /// @@ -123,7 +112,7 @@ public sealed class PocketBaseClient(HttpClient http) + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + $"&sort={Uri.EscapeDataString(sort)}"; - var result = await http.GetFromJsonAsync>(url, JsonOptions, ct); + var result = await http.GetFromJsonAsync>(url, ct); return result?.Items ?? []; } } diff --git a/Elternbeirat.Web/Features/Events/IcsCalendar.cs b/Elternbeirat.Web/Features/Events/IcsCalendar.cs index 2d85388..b593156 100644 --- a/Elternbeirat.Web/Features/Events/IcsCalendar.cs +++ b/Elternbeirat.Web/Features/Events/IcsCalendar.cs @@ -45,7 +45,7 @@ public static class IcsCalendar // between requests, so caches and clients do not see spurious changes. AppendLine(sb, "DTSTAMP:20000101T000000Z"); - if (HasTime(ev)) + if (ev.HasTime) { AppendLine(sb, $"DTSTART:{Local(ev.Start)}"); if (ev.End is { } end) @@ -75,12 +75,6 @@ public static class IcsCalendar AppendLine(sb, "END:VEVENT"); } - /// - /// 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. - /// - private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero; - /// /// 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. diff --git a/Elternbeirat.Web/Shared/EventFormat.cs b/Elternbeirat.Web/Shared/EventFormat.cs index 274e8ea..e9272cb 100644 --- a/Elternbeirat.Web/Shared/EventFormat.cs +++ b/Elternbeirat.Web/Shared/EventFormat.cs @@ -4,45 +4,80 @@ using Elternbeirat.Contracts; namespace Elternbeirat.Web.Shared; /// -/// Formats an 's date range for display in German. Shared by -/// the full event list and the home-page events embed so both render dates the -/// same way. +/// Formats the date range of an for display in German. /// +/// +/// Shared by the full event list and the home-page events embed, so both render +/// dates the same way. +/// public static class EventFormat { - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); - - // An event counts as all-day when its start carries no wall-clock time - // (00:00); PocketBase has no separate flag. - private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero; + /// + /// Date format for the start, and for a single-day event. + /// + private const string LongDate = "dddd, d. MMMM yyyy"; /// - /// Formats the entry's date range: a single day, a date with a time, or a - /// span across days. + /// Date format for the end of a multi-day event; the weekday is omitted. /// - public static string Range(Event ev) - { - var start = HasTime(ev) - ? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr" - : ev.Start.ToString("dddd, d. MMMM yyyy", German); + private const string ShortDate = "d. MMMM yyyy"; - if (ev.End is not { } end) + /// + /// The culture used for month and weekday names. + /// + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + + /// + /// Formats the date range of an event. + /// + /// + /// The event to format. + /// + /// + /// The formatted range, depending on and : + /// + /// + /// No end: Donnerstag, 8. Oktober 2026, 19:30 Uhr + /// + /// + /// Same day: Donnerstag, 8. Oktober 2026, 19:30–21:00 Uhr + /// + /// + /// Several days: Freitag, 9. Oktober 2026 – 11. Oktober 2026 + /// + /// + /// Without a time, the time parts are omitted. + /// + 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 - // end date. - if (end.Date == ev.Start.Date) - { - return HasTime(ev) - ? start + "–" + end.ToString("HH:mm", German) + " Uhr" - : start; - } - - var endText = HasTime(ev) - ? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr" - : end.ToString("d. MMMM yyyy", German); - return start + " – " + endText; - } + /// + /// Formats a single point in time as a date with an optional time. + /// + /// + /// The date and time to format. + /// + /// + /// The date format, e.g. . + /// + /// + /// to append the time, e.g. , 19:30 Uhr. + /// + /// + /// The formatted date, with the time if requested. + /// + private static string Stamp(DateTime value, string dateFormat, bool withTime) + => withTime + ? value.ToString(dateFormat + ", HH:mm", German) + " Uhr" + : value.ToString(dateFormat, German); }