Convert PocketBase dates to Berlin time; document code conventions
This commit is contained in:
1 parent
4b93434679
commit
aef05fb090
11 files changed
+405
-128
No files matched your search
@@ -83,11 +83,41 @@ Inhalt liegt in PocketBase (`pb_data`), das separat gesichert wird (siehe
|
|||||||
- Datenzugriff über den typisierten `PocketBaseClient` (registriert via
|
- Datenzugriff über den typisierten `PocketBaseClient` (registriert via
|
||||||
`AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit
|
`AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit
|
||||||
Inhalten. Komponenten liegen feature-basiert unter `Features/<Bereich>/`.
|
Inhalten. Komponenten liegen feature-basiert unter `Features/<Bereich>/`.
|
||||||
- Öffentliche Typen und Methoden im `PocketBaseClient` und in `Contracts`
|
|
||||||
bekommen XML-Doc (auf Englisch, leicht verständlich), Razor-Markup nicht.
|
|
||||||
- **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare,
|
- **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare,
|
||||||
Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht
|
Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht
|
||||||
`Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein
|
`Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein
|
||||||
Besucher liest oder ein Redakteur pflegt: UI-Texte sowie die **Werte** der
|
Besucher liest oder ein Redakteur pflegt: UI-Texte sowie die **Werte** der
|
||||||
PocketBase-Records (z. B. `title: Vorstandsteam`, der Markdown-`body`). Die
|
PocketBase-Records (z. B. `title: Vorstandsteam`, der Markdown-`body`). Die
|
||||||
Feldnamen und Slugs bleiben dagegen englisch (`title`, `slug`, `/board`).
|
Feldnamen und Slugs bleiben dagegen englisch (`title`, `slug`, `/board`).
|
||||||
|
|
||||||
|
## Coding Convention
|
||||||
|
|
||||||
|
- **Ausdruckskörper (`=>`) sind Pflicht, wo syntaktisch möglich** — Methoden,
|
||||||
|
Properties, Konstruktoren, Operatoren, lokale Funktionen. Ein `if/else`, das
|
||||||
|
einen Wert liefert, wird zum ternären Ausdruck oder zur `switch`-Expression, kein
|
||||||
|
Block-Körper mit `return`. Ein Block-Körper nur, wo ein Ausdruck sprachlich nicht
|
||||||
|
geht (mehrere Anweisungen ohne Rückgabe, `ref`/`out`, `yield`).
|
||||||
|
- Ternäre und `switch`-Expressions dürfen dafür mehrzeilig umgebrochen werden;
|
||||||
|
Lesbarkeit entsteht durch Einrückung, nicht durch einen Block.
|
||||||
|
- Nullable aktiv nutzen: `?`, `??`, `??=` statt Nullprüfungen im Block. Ein
|
||||||
|
ungültiger `null`-Fall wird als Ausdruck geworfen (`?? throw new …`).
|
||||||
|
- Argumente/Rückgaben früh und knapp validieren, bevorzugt als Ausdruck.
|
||||||
|
|
||||||
|
## Documentation Convention
|
||||||
|
|
||||||
|
Vorbild ist `Elternbeirat.PocketBase/LocalDateTimeConverter.cs` — daran
|
||||||
|
ausrichten.
|
||||||
|
|
||||||
|
- **Jeder öffentliche (`public`/`protected`) Typ und Member bekommt XML-Doc** —
|
||||||
|
nicht nur `PocketBaseClient` und `Contracts`. Interne Helfer, die Teil der
|
||||||
|
fachlichen Erklärung sind (wie `WallClock`), ebenfalls. Razor-Markup nicht.
|
||||||
|
- Voller Umfang, wo zutreffend: `<summary>`, dazu `<param>`, `<returns>`,
|
||||||
|
`<exception>` (jede geworfene Bedingung), `<remarks>` für Kontext/Fallstricke,
|
||||||
|
`<example>` mit `<code>` für nicht offensichtliche Nutzung, `<seealso>` auf
|
||||||
|
verwandte Typen. `<inheritdoc/>` bei Interface-/Basis-Implementierungen.
|
||||||
|
- Code im Text als Markup referenzieren, nicht als Prosa: `<see cref="…"/>`,
|
||||||
|
`<see langword="null"/>`/`<see langword="false"/>`, `<c>…</c>` für Literale.
|
||||||
|
- Einrückung: der Textinhalt steht mit vier Leerzeichen unter dem `///`-Tag
|
||||||
|
(`/// Text`), Tags sauber verschachtelt.
|
||||||
|
- Englisch, leicht verständlich, erklärt **warum**, nicht was der Code ohnehin
|
||||||
|
zeigt.
|
||||||
@@ -2,38 +2,68 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace Elternbeirat.Contracts;
|
namespace Elternbeirat.Contracts;
|
||||||
|
|
||||||
/// <summary>A calendar entry. Sorted by <see cref="Start"/>.</summary>
|
/// <summary>
|
||||||
|
/// A calendar entry of the Elternbeirat, as stored in PocketBase.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Event lists are ordered by <see cref="Start"/>. Unset optional text fields are
|
||||||
|
/// <see cref="string.Empty"/>, never <see langword="null"/>.
|
||||||
|
/// </remarks>
|
||||||
public record Event
|
public record Event
|
||||||
{
|
{
|
||||||
/// <summary>PocketBase record id.</summary>
|
/// <summary>
|
||||||
|
/// Gets the PocketBase record id.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("id")]
|
[JsonPropertyName("id")]
|
||||||
public string Id { get; init; } = "";
|
public string Id { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Start of the event with date and time. Stored as UTC by PocketBase but
|
/// Gets the start of the event as Europe/Berlin wall-clock time.
|
||||||
/// read as local time (Europe/Berlin) by convention. An all-day event uses
|
|
||||||
/// 00:00 as the time.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// PocketBase stores the value in UTC; it is converted to Berlin local time on
|
||||||
|
/// deserialization, with daylight saving applied. An all-day event uses 00:00
|
||||||
|
/// as the time.
|
||||||
|
/// </remarks>
|
||||||
[JsonPropertyName("start")]
|
[JsonPropertyName("start")]
|
||||||
public DateTime Start { get; init; }
|
public DateTime Start { get; init; }
|
||||||
|
|
||||||
/// <summary>Optional end of the event; null when unset.</summary>
|
/// <summary>
|
||||||
|
/// Gets the optional end of the event as Europe/Berlin wall-clock time.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// The end time, or <see langword="null"/> if the event has no end.
|
||||||
|
/// </value>
|
||||||
[JsonPropertyName("end")]
|
[JsonPropertyName("end")]
|
||||||
public DateTime? End { get; init; }
|
public DateTime? End { get; init; }
|
||||||
|
|
||||||
/// <summary>Event name, e.g. "Elternbeiratssitzung".</summary>
|
/// <summary>
|
||||||
|
/// Gets the event name, e.g. <c>"Elternbeiratssitzung"</c>.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("title")]
|
[JsonPropertyName("title")]
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Optional location, e.g. "Aula".</summary>
|
/// <summary>
|
||||||
|
/// Gets the optional location, e.g. <c>"Aula"</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// The location, or <see cref="string.Empty"/> if none is set.
|
||||||
|
/// </value>
|
||||||
[JsonPropertyName("location")]
|
[JsonPropertyName("location")]
|
||||||
public string Location { get; init; } = "";
|
public string Location { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Optional note, e.g. "Anmeldung erforderlich".</summary>
|
/// <summary>
|
||||||
|
/// Gets the optional note, e.g. <c>"Anmeldung erforderlich"</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// The note, or <see cref="string.Empty"/> if none is set.
|
||||||
|
/// </value>
|
||||||
[JsonPropertyName("note")]
|
[JsonPropertyName("note")]
|
||||||
public string Note { get; init; } = "";
|
public string Note { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Whether the event is visible to visitors.</summary>
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the event is visible to visitors of the site.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("public")]
|
[JsonPropertyName("public")]
|
||||||
public bool Public { get; init; }
|
public bool Public { get; init; }
|
||||||
}
|
}
|
||||||
@@ -3,27 +3,40 @@ using System.Text.Json.Serialization;
|
|||||||
namespace Elternbeirat.Contracts;
|
namespace Elternbeirat.Contracts;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single question and answer, grouped on the FAQ page by <see cref="Topic"/>.
|
/// A single question and answer of the FAQ, as stored in PocketBase.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// On the FAQ page, entries are grouped by <see cref="Topic"/>.
|
||||||
|
/// </remarks>
|
||||||
public record Faq
|
public record Faq
|
||||||
{
|
{
|
||||||
/// <summary>PocketBase record id.</summary>
|
/// <summary>
|
||||||
|
/// Gets the PocketBase record id.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("id")]
|
[JsonPropertyName("id")]
|
||||||
public string Id { get; init; } = "";
|
public string Id { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>The question as a parent would phrase it.</summary>
|
/// <summary>
|
||||||
|
/// Gets the question as a parent would phrase it.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("question")]
|
[JsonPropertyName("question")]
|
||||||
public string Question { get; init; } = "";
|
public string Question { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>The answer in Markdown.</summary>
|
/// <summary>
|
||||||
|
/// Gets the answer as Markdown.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("answer")]
|
[JsonPropertyName("answer")]
|
||||||
public string Answer { get; init; } = "";
|
public string Answer { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Topic the question is grouped under, e.g. "mensa".</summary>
|
/// <summary>
|
||||||
|
/// Gets the topic the question is grouped under, e.g. <c>"mensa"</c>.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("topic")]
|
[JsonPropertyName("topic")]
|
||||||
public string Topic { get; init; } = "";
|
public string Topic { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Whether the question is visible to visitors.</summary>
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the question is visible to visitors of the site.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("public")]
|
[JsonPropertyName("public")]
|
||||||
public bool Public { get; init; }
|
public bool Public { get; init; }
|
||||||
}
|
}
|
||||||
@@ -3,45 +3,77 @@ using System.Text.Json.Serialization;
|
|||||||
namespace Elternbeirat.Contracts;
|
namespace Elternbeirat.Contracts;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A content page. Pages also drive the site navigation: <see cref="Location"/>
|
/// A content page of the site, as stored in PocketBase.
|
||||||
/// and <see cref="Order"/> decide where and in which order a page appears in the
|
|
||||||
/// header or footer menu, and <see cref="Embed"/> lists dynamic blocks (posts,
|
|
||||||
/// events, faqs) rendered below the page body.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Pages also drive the site navigation: <see cref="Location"/> and
|
||||||
|
/// <see cref="Order"/> decide where and in which order a page appears in the
|
||||||
|
/// header or footer menu. <see cref="Embed"/> lists dynamic blocks rendered
|
||||||
|
/// below the page body.
|
||||||
|
/// </remarks>
|
||||||
public record Page
|
public record Page
|
||||||
{
|
{
|
||||||
/// <summary>PocketBase record id.</summary>
|
/// <summary>
|
||||||
|
/// Gets the PocketBase record id.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("id")]
|
[JsonPropertyName("id")]
|
||||||
public string Id { get; init; } = "";
|
public string Id { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Heading shown to visitors; may contain umlauts and spaces.</summary>
|
/// <summary>
|
||||||
|
/// Gets the heading shown to visitors.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
|
||||||
|
/// </remarks>
|
||||||
[JsonPropertyName("title")]
|
[JsonPropertyName("title")]
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Page body in Markdown.</summary>
|
/// <summary>
|
||||||
|
/// Gets the page body as Markdown.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("body")]
|
[JsonPropertyName("body")]
|
||||||
public string Body { get; init; } = "";
|
public string Body { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Where the page appears in the navigation: "header" or "footer".</summary>
|
/// <summary>
|
||||||
|
/// Gets the navigation menu the page appears in.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// Either <c>"header"</c> or <c>"footer"</c>.
|
||||||
|
/// </value>
|
||||||
[JsonPropertyName("location")]
|
[JsonPropertyName("location")]
|
||||||
public string Location { get; init; } = "";
|
public string Location { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Sort order within its navigation location; smaller is earlier.</summary>
|
/// <summary>
|
||||||
|
/// Gets the sort order within the navigation menu given by <see cref="Location"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// The sort key; pages with smaller values appear first.
|
||||||
|
/// </value>
|
||||||
[JsonPropertyName("order")]
|
[JsonPropertyName("order")]
|
||||||
public double Order { get; init; }
|
public double Order { get; init; }
|
||||||
|
|
||||||
/// <summary>URL slug (lowercase, no umlauts), e.g. "board" -> /board.</summary>
|
/// <summary>
|
||||||
|
/// Gets the URL slug of the page, e.g. <c>"board"</c> for <c>/board</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// A lowercase path segment without umlauts.
|
||||||
|
/// </value>
|
||||||
[JsonPropertyName("slug")]
|
[JsonPropertyName("slug")]
|
||||||
public string Slug { get; init; } = "";
|
public string Slug { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dynamic blocks to render below the body: any of "posts", "events", "faqs".
|
/// Gets the dynamic blocks rendered below the <see cref="Body"/>.
|
||||||
/// Empty for a plain text page.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// Any of <c>"posts"</c>, <c>"events"</c> and <c>"faqs"</c>, or an empty list
|
||||||
|
/// for a plain text page.
|
||||||
|
/// </value>
|
||||||
[JsonPropertyName("embed")]
|
[JsonPropertyName("embed")]
|
||||||
public IReadOnlyList<string> Embed { get; init; } = [];
|
public IReadOnlyList<string> Embed { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>Whether the page is visible to visitors.</summary>
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the page is visible to visitors of the site.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("public")]
|
[JsonPropertyName("public")]
|
||||||
public bool Public { get; init; }
|
public bool Public { get; init; }
|
||||||
}
|
}
|
||||||
@@ -2,33 +2,57 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace Elternbeirat.Contracts;
|
namespace Elternbeirat.Contracts;
|
||||||
|
|
||||||
/// <summary>A news post. Sorted by <see cref="Date"/>, newest first.</summary>
|
/// <summary>
|
||||||
|
/// A news post of the site, as stored in PocketBase.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Post lists are ordered by <see cref="Date"/>, newest first.
|
||||||
|
/// </remarks>
|
||||||
public record Post
|
public record Post
|
||||||
{
|
{
|
||||||
/// <summary>PocketBase record id.</summary>
|
/// <summary>
|
||||||
|
/// Gets the PocketBase record id.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("id")]
|
[JsonPropertyName("id")]
|
||||||
public string Id { get; init; } = "";
|
public string Id { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Publication date. Stored as UTC by PocketBase but read as local time
|
/// Gets the publication date as Europe/Berlin wall-clock time.
|
||||||
/// (Europe/Berlin) by convention; only the date part is shown.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// PocketBase stores the value in UTC; it is converted to Berlin local time on
|
||||||
|
/// deserialization, with daylight saving applied. Only the date part is shown.
|
||||||
|
/// </remarks>
|
||||||
[JsonPropertyName("date")]
|
[JsonPropertyName("date")]
|
||||||
public DateTime Date { get; init; }
|
public DateTime Date { get; init; }
|
||||||
|
|
||||||
/// <summary>Post heading shown to visitors; may contain umlauts and spaces.</summary>
|
/// <summary>
|
||||||
|
/// Gets the heading shown to visitors.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
|
||||||
|
/// </remarks>
|
||||||
[JsonPropertyName("title")]
|
[JsonPropertyName("title")]
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Post body in Markdown.</summary>
|
/// <summary>
|
||||||
|
/// Gets the post body as Markdown.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("body")]
|
[JsonPropertyName("body")]
|
||||||
public string Body { get; init; } = "";
|
public string Body { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>URL slug (lowercase, no umlauts), e.g. "herbstbasar" -> /posts/herbstbasar.</summary>
|
/// <summary>
|
||||||
|
/// Gets the URL slug of the post, e.g. <c>"herbstbasar"</c> for <c>/posts/herbstbasar</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// A lowercase path segment without umlauts.
|
||||||
|
/// </value>
|
||||||
[JsonPropertyName("slug")]
|
[JsonPropertyName("slug")]
|
||||||
public string Slug { get; init; } = "";
|
public string Slug { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Whether the post is visible to visitors.</summary>
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the post is visible to visitors of the site.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("public")]
|
[JsonPropertyName("public")]
|
||||||
public bool Public { get; init; }
|
public bool Public { get; init; }
|
||||||
}
|
}
|
||||||
@@ -5,71 +5,127 @@ using System.Text.Json.Serialization;
|
|||||||
namespace Elternbeirat.PocketBase;
|
namespace Elternbeirat.PocketBase;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared parsing of a PocketBase date string as wall-clock time. The trailing
|
/// Converts between PocketBase date strings and Europe/Berlin wall-clock time.
|
||||||
/// "Z" is stripped rather than honoured, so the number is taken at face value and
|
|
||||||
/// the result carries <see cref="DateTimeKind.Unspecified"/> -- no timezone shift.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// PocketBase stores every date in UTC and serializes it with a trailing <c>Z</c>
|
||||||
|
/// (e.g. <c>"2026-10-08 17:30:00.000Z"</c>). 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
|
||||||
|
/// <see cref="DateTimeKind.Unspecified"/> so that no later formatting shifts them again.
|
||||||
|
/// </remarks>
|
||||||
internal static class WallClock
|
internal static class WallClock
|
||||||
{
|
{
|
||||||
public static DateTime Parse(string raw)
|
/// <summary>
|
||||||
{
|
/// IANA id; resolves on every platform .NET supports, Windows included.
|
||||||
// Drop a trailing "Z" so DateTime.Parse does not treat the value as UTC
|
/// </summary>
|
||||||
// and convert it to local time (which would shift 19:30 to 20:30/21:30).
|
private static readonly TimeZoneInfo Berlin =
|
||||||
var value = raw.EndsWith('Z') ? raw[..^1] : raw;
|
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||||
var parsed = DateTime.Parse(value, CultureInfo.InvariantCulture,
|
|
||||||
DateTimeStyles.None);
|
/// <summary>
|
||||||
return DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
|
/// Parses a PocketBase UTC date string into Berlin wall-clock time.
|
||||||
}
|
/// </summary>
|
||||||
|
/// <param name="raw">
|
||||||
|
/// The raw PocketBase value; may be <see langword="null"/> or empty.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// The Berlin wall-clock time with <see cref="DateTimeKind.Unspecified"/>, or
|
||||||
|
/// <see langword="null"/> if <paramref name="raw"/> is <see langword="null"/> or empty
|
||||||
|
/// (PocketBase's representation of an unset date).
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="FormatException">
|
||||||
|
/// <paramref name="raw"/> is not a valid date string.
|
||||||
|
/// </exception>
|
||||||
|
public static DateTime? Parse(string? raw) =>
|
||||||
|
string.IsNullOrEmpty(raw)
|
||||||
|
? null
|
||||||
|
: TimeZoneInfo.ConvertTime(
|
||||||
|
DateTimeOffset.Parse(raw, CultureInfo.InvariantCulture), Berlin).DateTime;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a Berlin wall-clock time as the UTC string PocketBase stores.
|
||||||
|
/// Inverse of <see cref="Parse"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="berlin">
|
||||||
|
/// A Berlin wall-clock time. Its <see cref="DateTime.Kind"/> is ignored, and the
|
||||||
|
/// value is always interpreted as Berlin local time.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// The UTC value in PocketBase format, e.g. <c>"2026-10-08 17:30:00.000Z"</c>.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// <paramref name="berlin"/> does not exist in Berlin, because it falls in the
|
||||||
|
/// daylight-saving gap in spring.
|
||||||
|
/// </exception>
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads PocketBase date values as local wall-clock time.
|
/// JSON converter that reads and writes PocketBase dates as Europe/Berlin
|
||||||
/// <para>
|
/// wall-clock <see cref="DateTime"/> values.
|
||||||
/// PocketBase stores every date in UTC and serializes it with a trailing "Z"
|
|
||||||
/// (e.g. "2026-10-08 19:30:00.000Z"). By project convention the stored number
|
|
||||||
/// IS the local time (Europe/Berlin) and the "Z" is ignored -- see the timezone
|
|
||||||
/// decision in the data model. This converter therefore parses the value and
|
|
||||||
/// returns it as an <see cref="DateTimeKind.Unspecified"/> instant, so no
|
|
||||||
/// timezone shift is ever applied when the value is later formatted.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An empty string (unset date) is read as <see langword="default"/>(<see cref="DateTime"/>).
|
||||||
|
/// For optional dates, use <see cref="NullableLocalDateTimeConverter"/> instead.
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// [JsonConverter(typeof(LocalDateTimeConverter))]
|
||||||
|
/// public DateTime Start { get; init; }
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
/// <seealso cref="WallClock"/>
|
||||||
public sealed class LocalDateTimeConverter : JsonConverter<DateTime>
|
public sealed class LocalDateTimeConverter : JsonConverter<DateTime>
|
||||||
{
|
{
|
||||||
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
/// <inheritdoc/>
|
||||||
{
|
public override DateTime Read(
|
||||||
var raw = reader.GetString();
|
ref Utf8JsonReader reader,
|
||||||
return string.IsNullOrEmpty(raw)
|
Type typeToConvert,
|
||||||
? default
|
JsonSerializerOptions options)
|
||||||
: WallClock.Parse(raw);
|
=> WallClock.Parse(reader.GetString()) ?? default;
|
||||||
}
|
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
/// <inheritdoc/>
|
||||||
=> writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'",
|
public override void Write(
|
||||||
CultureInfo.InvariantCulture));
|
Utf8JsonWriter writer,
|
||||||
|
DateTime value,
|
||||||
|
JsonSerializerOptions options)
|
||||||
|
=> writer.WriteStringValue(WallClock.Format(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Nullable counterpart of <see cref="LocalDateTimeConverter"/>. PocketBase sends
|
/// JSON converter that reads and writes optional PocketBase dates as Europe/Berlin
|
||||||
/// an empty string for an unset optional date (e.g. an event without an end);
|
/// wall-clock <see cref="DateTime"/> values.
|
||||||
/// that maps to null.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// PocketBase sends an empty string for an unset optional date (e.g. an event
|
||||||
|
/// without an end). This value, like JSON <c>null</c>, is read as <see langword="null"/>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// [JsonConverter(typeof(NullableLocalDateTimeConverter))]
|
||||||
|
/// public DateTime? End { get; init; }
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
/// <seealso cref="WallClock"/>
|
||||||
public sealed class NullableLocalDateTimeConverter : JsonConverter<DateTime?>
|
public sealed class NullableLocalDateTimeConverter : JsonConverter<DateTime?>
|
||||||
{
|
{
|
||||||
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
/// <inheritdoc/>
|
||||||
{
|
public override DateTime? Read(
|
||||||
var raw = reader.GetString();
|
ref Utf8JsonReader reader,
|
||||||
if (string.IsNullOrEmpty(raw))
|
Type typeToConvert,
|
||||||
return null;
|
JsonSerializerOptions options)
|
||||||
|
=> WallClock.Parse(reader.GetString());
|
||||||
|
|
||||||
return WallClock.Parse(raw);
|
/// <inheritdoc/>
|
||||||
}
|
/// <remarks>
|
||||||
|
/// Only called for non-null values: <see cref="JsonConverter{T}.HandleNull"/> is
|
||||||
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
|
/// <see langword="false"/>, so the serializer writes <see langword="null"/> itself.
|
||||||
{
|
/// </remarks>
|
||||||
if (value is null)
|
public override void Write(
|
||||||
writer.WriteStringValue("");
|
Utf8JsonWriter writer,
|
||||||
else
|
DateTime? value,
|
||||||
writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'",
|
JsonSerializerOptions options)
|
||||||
CultureInfo.InvariantCulture));
|
=> writer.WriteStringValue(WallClock.Format(value ?? throw new ArgumentNullException(nameof(value))));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -6,18 +6,88 @@ namespace Elternbeirat.PocketBase;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads published content from a PocketBase instance over its REST API.
|
/// Reads published content from a PocketBase instance over its REST API.
|
||||||
/// <para>
|
|
||||||
/// One method per collection (pages, posts, events, faqs). Each returns only
|
|
||||||
/// records with <c>public = true</c> and lets PocketBase do the filtering and
|
|
||||||
/// sorting via query parameters. The <see cref="HttpClient"/> is expected to have
|
|
||||||
/// its <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL, so it
|
|
||||||
/// is registered as a typed client via <c>AddHttpClient</c>.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// There is one method per collection (pages, posts, events, faqs). Each returns
|
||||||
|
/// only records with <c>public = true</c> and leaves filtering and sorting to
|
||||||
|
/// PocketBase via query parameters.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Register the class as a typed client via <c>AddHttpClient</c>, with
|
||||||
|
/// <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="http">
|
||||||
|
/// The HTTP client; its <see cref="HttpClient.BaseAddress"/> must point to PocketBase.
|
||||||
|
/// </param>
|
||||||
public sealed class PocketBaseClient(HttpClient http)
|
public sealed class PocketBaseClient(HttpClient http)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Serializer options that map PocketBase UTC dates to Berlin wall-clock time.
|
||||||
|
/// </summary>
|
||||||
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all public pages, ordered by <see cref="Page.Order"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">
|
||||||
|
/// A token to cancel the request.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// The public pages, or an empty list if there are none.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="HttpRequestException">
|
||||||
|
/// PocketBase could not be reached or returned a non-success status code.
|
||||||
|
/// </exception>
|
||||||
|
/// <exception cref="JsonException">
|
||||||
|
/// The response could not be deserialized.
|
||||||
|
/// </exception>
|
||||||
|
/// <exception cref="OperationCanceledException">
|
||||||
|
/// <paramref name="ct"/> was canceled or the request timed out.
|
||||||
|
/// </exception>
|
||||||
|
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
|
||||||
|
=> GetRecordsAsync<Page>("pages", "order", ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all public posts, ordered by <see cref="Post.Date"/>, newest first.
|
||||||
|
/// </summary>
|
||||||
|
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
|
||||||
|
/// <returns>
|
||||||
|
/// The public posts, or an empty list if there are none.
|
||||||
|
/// </returns>
|
||||||
|
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
|
||||||
|
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
|
||||||
|
=> GetRecordsAsync<Post>("posts", "-date", ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all public events, ordered by <see cref="Event.Start"/>, earliest first.
|
||||||
|
/// </summary>
|
||||||
|
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
|
||||||
|
/// <returns>
|
||||||
|
/// The public events, or an empty list if there are none.
|
||||||
|
/// </returns>
|
||||||
|
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
|
||||||
|
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default)
|
||||||
|
=> GetRecordsAsync<Event>("events", "start", ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all public FAQ entries, ordered by <see cref="Faq.Topic"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
|
||||||
|
/// <returns>
|
||||||
|
/// The public FAQ entries, or an empty list if there are none.
|
||||||
|
/// </returns>
|
||||||
|
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
|
||||||
|
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
|
||||||
|
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the serializer options with both wall-clock date converters registered.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The configured options.
|
||||||
|
/// </returns>
|
||||||
private static JsonSerializerOptions CreateJsonOptions()
|
private static JsonSerializerOptions CreateJsonOptions()
|
||||||
{
|
{
|
||||||
var options = new JsonSerializerOptions
|
var options = new JsonSerializerOptions
|
||||||
@@ -29,23 +99,28 @@ public sealed class PocketBaseClient(HttpClient http)
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gets all public pages, ordered for navigation.</summary>
|
/// <summary>
|
||||||
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
|
/// Gets all public records of a collection in a single request.
|
||||||
=> GetRecordsAsync<Page>("pages", "order", ct);
|
/// </summary>
|
||||||
|
/// <typeparam name="T">
|
||||||
/// <summary>Gets all public posts, newest first.</summary>
|
/// The record type to deserialize into.
|
||||||
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
|
/// </typeparam>
|
||||||
=> GetRecordsAsync<Post>("posts", "-date", ct);
|
/// <param name="collection">
|
||||||
|
/// The PocketBase collection name, e.g. <c>"posts"</c>.
|
||||||
/// <summary>Gets all public events, earliest start first.</summary>
|
/// </param>
|
||||||
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default)
|
/// <param name="sort">
|
||||||
=> GetRecordsAsync<Event>("events", "start", ct);
|
/// The PocketBase sort expression; a leading <c>-</c> sorts descending.
|
||||||
|
/// </param>
|
||||||
/// <summary>Gets all public FAQ entries.</summary>
|
/// <param name="ct">
|
||||||
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
|
/// A token to cancel the request.
|
||||||
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(string collection, string sort, CancellationToken ct)
|
/// The public records, or an empty list if there are none.
|
||||||
|
/// </returns>
|
||||||
|
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(
|
||||||
|
string collection,
|
||||||
|
string sort,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// filter=public=true keeps drafts out; perPage is large enough to fetch
|
// filter=public=true keeps drafts out; perPage is large enough to fetch
|
||||||
// every record in a single page given the small content volume.
|
// every record in a single page given the small content volume.
|
||||||
|
|||||||
@@ -3,13 +3,25 @@ using System.Text.Json.Serialization;
|
|||||||
namespace Elternbeirat.PocketBase;
|
namespace Elternbeirat.PocketBase;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The envelope PocketBase wraps a records list response in. Only <see cref="Items"/>
|
/// The envelope PocketBase wraps a records list response in.
|
||||||
/// is used; the paging fields are ignored because content volumes are small and the
|
|
||||||
/// client requests a large page size in a single call.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The record type inside <c>items</c>.</typeparam>
|
/// <remarks>
|
||||||
|
/// Only <see cref="Items"/> is mapped. The paging fields (<c>page</c>,
|
||||||
|
/// <c>perPage</c>, <c>totalItems</c>, <c>totalPages</c>) are ignored, because
|
||||||
|
/// content volumes are small and <see cref="PocketBaseClient"/> fetches every
|
||||||
|
/// record in a single request.
|
||||||
|
/// </remarks>
|
||||||
|
/// <typeparam name="T">
|
||||||
|
/// The record type inside <c>items</c>.
|
||||||
|
/// </typeparam>
|
||||||
internal sealed record RecordList<T>
|
internal sealed record RecordList<T>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the records of the requested page.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// The records, or an empty list if the collection has no matching records.
|
||||||
|
/// </value>
|
||||||
[JsonPropertyName("items")]
|
[JsonPropertyName("items")]
|
||||||
public IReadOnlyList<T> Items { get; init; } = [];
|
public IReadOnlyList<T> Items { get; init; } = [];
|
||||||
}
|
}
|
||||||
@@ -23,14 +23,15 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Event_time_is_read_as_wall_clock_not_shifted()
|
public async Task Event_utc_is_converted_to_berlin_wall_clock()
|
||||||
{
|
{
|
||||||
var client = pocketBase.CreateClient();
|
var client = pocketBase.CreateClient();
|
||||||
|
|
||||||
var events = await client.GetEventsAsync();
|
var events = await client.GetEventsAsync();
|
||||||
|
|
||||||
// The meeting is seeded as 19:30; by the timezone convention the number is
|
// The meeting is seeded as 17:30Z. October is summer time (UTC+2), so the
|
||||||
// taken at face value, so no shift to 20:30/21:30 happens.
|
// converter turns it into 19:30 Berlin wall-clock -- the time an editor
|
||||||
|
// entered in the admin form.
|
||||||
var meeting = events.FirstOrDefault(e =>
|
var meeting = events.FirstOrDefault(e =>
|
||||||
e.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal));
|
e.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal));
|
||||||
meeting.ShouldNotBeNull();
|
meeting.ShouldNotBeNull();
|
||||||
|
|||||||
@@ -295,15 +295,18 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
body = "Text", slug = "new-hall", @public = true,
|
body = "Text", slug = "new-hall", @public = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Events: the meeting carries the wall-clock time the timezone test checks.
|
// Events: PocketBase stores UTC. The meeting is 19:30 Berlin; October is
|
||||||
|
// summer time (UTC+2), so it is stored as 17:30Z and the timezone test
|
||||||
|
// expects 19:30 back. The Herbstbasar is 09:00-13:00 Berlin in November
|
||||||
|
// (winter, UTC+1), stored as 08:00Z-12:00Z.
|
||||||
await CreateRecordAsync(http, "events", new
|
await CreateRecordAsync(http, "events", new
|
||||||
{
|
{
|
||||||
start = "2026-10-08 19:30:00.000Z", title = "Elternbeiratssitzung",
|
start = "2026-10-08 17:30:00.000Z", title = "Elternbeiratssitzung",
|
||||||
location = "Aula", note = "", @public = true,
|
location = "Aula", note = "", @public = true,
|
||||||
});
|
});
|
||||||
await CreateRecordAsync(http, "events", new
|
await CreateRecordAsync(http, "events", new
|
||||||
{
|
{
|
||||||
start = "2026-11-22 09:00:00.000Z", end = "2026-11-22 13:00:00.000Z",
|
start = "2026-11-22 08:00:00.000Z", end = "2026-11-22 12:00:00.000Z",
|
||||||
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
|
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -128,11 +128,12 @@ Die Übersicht `/events` trennt automatisch in kommende und vergangene Termine;
|
|||||||
die Reihenfolge der Records spielt keine Rolle. Besucher können `/events.ics` in
|
die Reihenfolge der Records spielt keine Rolle. Besucher können `/events.ics` in
|
||||||
ihrer Kalender-App abonnieren — der Feed entsteht aus denselben Records.
|
ihrer Kalender-App abonnieren — der Feed entsteht aus denselben Records.
|
||||||
|
|
||||||
> **Uhrzeit = Ortszeit.** Das Admin-Formular rechnet Datumsfelder in die
|
> **Trage einfach die Ortszeit ein.** Gib die Uhrzeit ein, die auf der Seite
|
||||||
> Browser-Zeitzone um und zeigt eine gespeicherte `19:30` je nach Sommer-/Winter-
|
> stehen soll (Europe/Berlin) — z. B. `08:00`. PocketBase speichert intern in UTC
|
||||||
> zeit als 20:30/21:30 an. Das ist **kein** Fehler, nur zwei Bezugssysteme: der
|
> und zeigt dir nach dem Speichern deshalb einen um 1–2 Stunden früheren Wert an
|
||||||
> gespeicherte Zahlenwert **ist** die Ortszeit (Europe/Berlin), die App zeigt ihn
|
> (aus `08:00` wird im Sommer `06:00`). Das ist **kein** Fehler: Die Website
|
||||||
> unverändert. Trage die Uhrzeit ein, die auf der Seite stehen soll.
|
> rechnet beim Anzeigen automatisch nach Ortszeit zurück und zeigt wieder `08:00`,
|
||||||
|
> Sommer- und Winterzeit inklusive. Du musst dich um UTC nicht kümmern.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user