Fix/pocketbase date timezone #33
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Elternbeirat.Contracts;
|
namespace Elternbeirat.Contracts;
|
||||||
@@ -17,14 +18,16 @@ public record Post
|
|||||||
public string Id { get; init; } = "";
|
public string Id { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the publication date as Europe/Berlin wall-clock time.
|
/// Gets the publication date as a calendar day.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// PocketBase stores the value in UTC; it is converted to Berlin local time on
|
/// A post's date is a plain day, not a point in time. It is read as the
|
||||||
/// deserialization, with daylight saving applied. Only the date part is shown.
|
/// Berlin calendar day: the UTC value is converted to Europe/Berlin and only
|
||||||
|
/// the day is kept, so the day an editor picked is the day shown -- no
|
||||||
|
/// time-of-day, no off-by-one at midnight. See <see cref="DateOnlyConverter"/>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonPropertyName("date")]
|
[JsonPropertyName("date")]
|
||||||
public DateTime Date { get; init; }
|
public DateOnly Date { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the heading shown to visitors.
|
/// Gets the heading shown to visitors.
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Elternbeirat.PocketBase;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON converter that reads and writes a PocketBase date as a calendar date
|
||||||
|
/// (<see cref="DateOnly"/>), ignoring the time-of-day and the time zone.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// PocketBase has no date-only field type: it stores every date as a UTC
|
||||||
|
/// instant with a time-of-day (e.g. an editor's <c>2 Jan 00:00</c> Berlin is
|
||||||
|
/// stored as <c>"2026-01-01 22:00:00.000Z"</c>). The day the editor meant is
|
||||||
|
/// therefore the day <em>in Berlin</em>, not the day of the raw UTC string. This
|
||||||
|
/// converter reuses the same UTC->Berlin conversion as
|
||||||
|
/// <see cref="LocalDateTimeConverter"/> and then keeps only the date, so the day
|
||||||
|
/// shown is the day that was entered -- with no time-of-day and no off-by-one.
|
||||||
|
/// </remarks>
|
||||||
|
/// <remarks>
|
||||||
|
/// Registered on the client's <see cref="System.Text.Json.JsonSerializerOptions"/>
|
||||||
|
/// and matched by the <see cref="DateOnly"/> field type, so a contract needs no
|
||||||
|
/// per-field attribute (which would couple the contract to this project).
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DateOnlyConverter : JsonConverter<DateOnly>
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <exception cref="FormatException">
|
||||||
|
/// The stored value is not a valid date string.
|
||||||
|
/// </exception>
|
||||||
|
public override DateOnly Read(
|
||||||
|
ref Utf8JsonReader reader,
|
||||||
|
Type typeToConvert,
|
||||||
|
JsonSerializerOptions options)
|
||||||
|
=> WallClock.Parse(reader.GetString()) is { } berlin
|
||||||
|
? DateOnly.FromDateTime(berlin)
|
||||||
|
: default;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override void Write(
|
||||||
|
Utf8JsonWriter writer,
|
||||||
|
DateOnly value,
|
||||||
|
JsonSerializerOptions options)
|
||||||
|
=> writer.WriteStringValue(WallClock.Format(value.ToDateTime(TimeOnly.MinValue)));
|
||||||
|
}
|
||||||
@@ -83,7 +83,8 @@ public sealed class PocketBaseClient(HttpClient http)
|
|||||||
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
|
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates the serializer options with both wall-clock date converters registered.
|
/// Creates the serializer options with the date converters registered: the
|
||||||
|
/// wall-clock ones for points in time and the calendar-day one for plain dates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// The configured options.
|
/// The configured options.
|
||||||
@@ -96,6 +97,7 @@ public sealed class PocketBaseClient(HttpClient http)
|
|||||||
};
|
};
|
||||||
options.Converters.Add(new LocalDateTimeConverter());
|
options.Converters.Add(new LocalDateTimeConverter());
|
||||||
options.Converters.Add(new NullableLocalDateTimeConverter());
|
options.Converters.Add(new NullableLocalDateTimeConverter());
|
||||||
|
options.Converters.Add(new DateOnlyConverter());
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
|||||||
posts.Select(p => p.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
|
posts.Select(p => p.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Post_date_is_the_berlin_calendar_day()
|
||||||
|
{
|
||||||
|
var client = pocketBase.CreateClient();
|
||||||
|
|
||||||
|
var posts = await client.GetPostsAsync();
|
||||||
|
|
||||||
|
// The post is seeded as 2026-03-01 23:30Z, which is 2 March 00:30 in Berlin.
|
||||||
|
// The date is the Berlin day the editor picked (2 March), not the raw UTC
|
||||||
|
// day (1 March) -- the time zone is applied before the day is taken.
|
||||||
|
var post = posts.Single(p => p.Title == "Neuer Vorstand");
|
||||||
|
post.Date.ShouldBe(new DateOnly(2026, 3, 2));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Pages_exclude_non_public_records()
|
public async Task Pages_exclude_non_public_records()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -283,10 +283,13 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
order = 9, slug = "draft", embed = Array.Empty<string>(), @public = false,
|
order = 9, slug = "draft", embed = Array.Empty<string>(), @public = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Posts: newest first once sorted by -date.
|
// Posts: newest first once sorted by -date. The first post is stored as
|
||||||
|
// 2026-03-01 23:30Z on purpose: that is 2 March 00:30 in Berlin (winter,
|
||||||
|
// UTC+1). An editor who picked 2 March must get 2 March back -- the date is
|
||||||
|
// the Berlin day, so it must NOT be read as the raw UTC day (1 March).
|
||||||
await CreateRecordAsync(http, "posts", new
|
await CreateRecordAsync(http, "posts", new
|
||||||
{
|
{
|
||||||
date = "2026-03-01 00:00:00.000Z", title = "Neuer Vorstand",
|
date = "2026-03-01 23:30:00.000Z", title = "Neuer Vorstand",
|
||||||
body = "Text", slug = "new-board", @public = true,
|
body = "Text", slug = "new-board", @public = true,
|
||||||
});
|
});
|
||||||
await CreateRecordAsync(http, "posts", new
|
await CreateRecordAsync(http, "posts", new
|
||||||
|
|||||||
+3
-1
@@ -113,7 +113,9 @@ Ein Beitrag hat `date`, `title`, `body`, `slug` und `public`. Er erscheint unter
|
|||||||
`/posts` (neueste zuerst) und unter `/posts/<slug>`; die neuesten werden auch auf
|
`/posts` (neueste zuerst) und unter `/posts/<slug>`; die neuesten werden auch auf
|
||||||
der Startseite als Vorschau angeteasert.
|
der Startseite als Vorschau angeteasert.
|
||||||
|
|
||||||
- `date` steuert Sortierung und angezeigtes Datum.
|
- `date` steuert Sortierung und angezeigtes Datum. Anders als bei Terminen zählt
|
||||||
|
hier nur der **Tag** — eine Uhrzeit wird nie angezeigt, und die UTC-Verschiebung
|
||||||
|
aus dem Termine-Hinweis spielt keine Rolle. Trage einfach das Datum ein.
|
||||||
- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`).
|
- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in new issue
Block a user