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);
}