Add events calendar with ICS feed and switch content naming to English

This commit is contained in:
tleininger committed 2026-09-21 16:22:56 +02:00
1 parent f24a4e73a2
commit 9459d2aeaa
28 files changed
+607 -29

No files matched your search

@@ -9,6 +9,7 @@
<NavLink href="/vorstandsteam">Vorstandsteam</NavLink>
<NavLink href="/foerderverein">Förderverein</NavLink>
<NavLink href="/beitraege">Beiträge</NavLink>
<NavLink href="/termine">Termine</NavLink>
<NavLink href="/faq">FAQ</NavLink>
<NavLink href="/downloads">Downloads</NavLink>
<NavLink href="/kontakt">Kontakt</NavLink>
@@ -0,0 +1,91 @@
@page "/termine"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject EventService EventService
<PageTitle>Termine</PageTitle>
<h1>Termine</h1>
<p class="intro">
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
<a href="/termine.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
aktuell halten.
</p>
<h2>Kommende Termine</h2>
@if (EventService.Upcoming.Count == 0)
{
<p>Zurzeit sind keine Termine geplant.</p>
}
else
{
<ul class="event-list">
@foreach (var ev in EventService.Upcoming)
{
<li>
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
@if (!string.IsNullOrWhiteSpace(ev.Note))
{
<p class="event-note">@ev.Note</p>
}
</li>
}
</ul>
}
@if (EventService.Past.Count > 0)
{
<h2>Vergangene Termine</h2>
<ul class="event-list event-list-past">
@foreach (var ev in EventService.Past)
{
<li>
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
</li>
}
</ul>
}
@code {
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
// Formats the entry's date range for display: a single day, a date with a
// time, or a span across days.
private static string Format(Event ev)
{
var start = ev.HasTime
? 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)
{
return start;
}
// Same day: append just the end time. Different days: append the full
// end date.
if (end.Date == ev.Start.Date)
{
return ev.HasTime
? start + "–" + end.ToString("HH:mm", _german) + " Uhr"
: start;
}
var endText = ev.HasTime
? end.ToString("d. MMMM yyyy, HH:mm", _german) + " Uhr"
: end.ToString("d. MMMM yyyy", _german);
return start + " – " + endText;
}
}
+26
View File
@@ -0,0 +1,26 @@
# Calendar entries of the Elternbeirat.
#
# One entry per event. Required keys: title, start.
# start/end:
# date only -> "2026-03-15" (all-day)
# date with time -> "2026-03-15 19:30"
# location and note are optional.
- title: Elternbeiratssitzung
start: "2026-10-08 19:30"
location: Lehrerzimmer
note: Themen bitte vorab per E-Mail einreichen.
- title: Herbstbasar
start: "2026-11-14 10:00"
end: "2026-11-14 16:00"
location: Aula
- title: Weihnachtsferien
start: "2026-12-21"
end: "2027-01-06"
- title: Elternsprechtag
start: "2026-09-05 15:00"
end: "2026-09-05 18:00"
location: Klassenräume
@@ -1,5 +1,5 @@
---
titel: Datenschutz
title: Datenschutz
---
# Datenschutzerklärung
+1 -1
View File
@@ -1,5 +1,5 @@
---
titel: Downloads
title: Downloads
---
# Downloads
@@ -1,5 +1,5 @@
---
titel: FAQ Elternarbeit
title: FAQ Elternarbeit
---
# Elternarbeit
@@ -1,5 +1,5 @@
---
titel: FAQ Elterneuro
title: FAQ Elterneuro
---
# Elterneuro
+1 -1
View File
@@ -1,5 +1,5 @@
---
titel: FAQ Mensa
title: FAQ Mensa
---
# Mensa
@@ -1,5 +1,5 @@
---
titel: FAQ Schließfach
title: FAQ Schließfach
---
# Schließfächer
+1 -1
View File
@@ -1,5 +1,5 @@
---
titel: FAQ
title: FAQ
---
# Häufige Fragen
@@ -1,5 +1,5 @@
---
titel: Förderverein
title: Förderverein
---
# Förderverein „Freunde der IGMH"
+1 -1
View File
@@ -1,5 +1,5 @@
---
titel: Impressum
title: Impressum
---
# Impressum
+1 -1
View File
@@ -1,5 +1,5 @@
---
titel: Kontakt
title: Kontakt
---
# Kontakt
@@ -1,5 +1,5 @@
---
titel: Vorstandsteam
title: Vorstandsteam
---
# Das Vorstandsteam
@@ -1,6 +1,6 @@
---
titel: Neue Sporthalle feierlich eröffnet
datum: 2025-10-05
title: Neue Sporthalle feierlich eröffnet
date: 2025-10-05
---
# Neue Sporthalle feierlich eröffnet
@@ -1,6 +1,6 @@
---
titel: Neuer Vorstand des Elternbeirats gewählt
datum: 2025-02-26
title: Neuer Vorstand des Elternbeirats gewählt
date: 2025-02-26
---
# Neuer Vorstand des Elternbeirats gewählt
+1
View File
@@ -15,6 +15,7 @@
<ItemGroup>
<PackageReference Include="Markdig" Version="0.38.0" />
<PackageReference Include="YamlDotNet" Version="16.2.1" />
</ItemGroup>
<!-- Content ships as files inside the image, not in a volume. Copy it to
+6
View File
@@ -10,6 +10,7 @@ builder.Services.AddRazorComponents();
// Content is read once at startup and cached -> singletons.
builder.Services.AddSingleton<PageService>();
builder.Services.AddSingleton<PostService>();
builder.Services.AddSingleton<EventService>();
var app = builder.Build();
@@ -40,4 +41,9 @@ app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>();
// Subscribable calendar feed of all events. A minimal API endpoint rather than
// a Razor page because it returns text/calendar, not HTML.
app.MapGet("/termine.ics", (EventService events) =>
Results.Text(IcsCalendar.Build(events.All), "text/calendar; charset=utf-8"));
app.Run();
+33
View File
@@ -0,0 +1,33 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// A single calendar entry (parents' evening, meeting, deadline). Read from
/// <c>Content/events.yml</c> at startup. All display text is German because it
/// is visitor-facing content.
/// </summary>
public sealed class Event
{
/// <summary>Short title shown in the list and the calendar.</summary>
public required string Title { get; init; }
/// <summary>
/// When the entry starts. A date-only entry (no time) is treated as an
/// all-day event; see <see cref="HasTime"/>.
/// </summary>
public required DateTime Start { get; init; }
/// <summary>
/// True when <see cref="Start"/> carries a wall-clock time, false for an
/// all-day entry. Drives both the list formatting and the ICS export.
/// </summary>
public required bool HasTime { get; init; }
/// <summary>Optional end, for multi-hour or multi-day entries.</summary>
public DateTime? End { get; init; }
/// <summary>Optional location, e.g. "Aula" or "Mensa".</summary>
public string? Location { get; init; }
/// <summary>Optional free-text note shown below the entry.</summary>
public string? Note { get; init; }
}
+137
View File
@@ -0,0 +1,137 @@
using System.Globalization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads the calendar entries from <c>Content/events.yml</c> once at startup and
/// keeps them in memory. Registered as a singleton because the content lives in
/// the image and does not change at runtime.
/// </summary>
public sealed class EventService
{
private readonly IReadOnlyList<Event> _events;
/// <summary>
/// Reads and parses <c>Content/events.yml</c> below the application root.
/// A missing file yields an empty list rather than an error.
/// </summary>
public EventService(IWebHostEnvironment environment)
{
var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml");
_events = File.Exists(path)
? Parse(File.ReadAllText(path))
: [];
}
/// <summary>
/// Entries that start today or later, earliest first — the upcoming section.
/// "Today" is compared by date, so an entry earlier today still counts as
/// upcoming.
/// </summary>
public IReadOnlyList<Event> Upcoming =>
_events
.Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today))
.OrderBy(e => e.Start)
.ToList();
/// <summary>
/// Entries that already passed, most recent first — the past section shown
/// below the upcoming ones.
/// </summary>
public IReadOnlyList<Event> Past =>
_events
.Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today))
.OrderByDescending(e => e.Start)
.ToList();
/// <summary>All entries in chronological order, for the ICS export.</summary>
public IReadOnlyList<Event> All =>
_events.OrderBy(e => e.Start).ToList();
private static List<Event> Parse(string yaml)
{
// Keys are lowercase single words (title, start, ...), which the camel
// case convention maps onto the PascalCase properties.
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var raw = deserializer.Deserialize<List<EventEntry>?>(yaml) ?? [];
return raw
.Where(entry => entry.Title is not null && entry.Start is not null)
.Select(ToEvent)
.OfType<Event>()
.ToList();
}
private static Event? ToEvent(EventEntry entry)
{
if (!TryParseWhen(entry.Start!, out var start, out var hasTime))
{
// A malformed date drops the entry rather than crashing startup.
return null;
}
DateTime? end = null;
if (entry.End is not null && TryParseWhen(entry.End, out var parsedEnd, out _))
{
end = parsedEnd;
}
return new Event
{
Title = entry.Title!,
Start = start,
HasTime = hasTime,
End = end,
Location = entry.Location,
Note = entry.Note,
};
}
/// <summary>
/// Parses a value that is either a date (<c>2026-03-15</c>) or a date with a
/// time (<c>2026-03-15 19:30</c>). Sets <paramref name="hasTime"/> so the
/// caller knows whether to treat the entry as all-day.
/// </summary>
private static bool TryParseWhen(string value, out DateTime when, out bool hasTime)
{
var trimmed = value.Trim();
if (DateOnly.TryParseExact(trimmed, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
{
when = dateOnly.ToDateTime(TimeOnly.MinValue);
hasTime = false;
return true;
}
string[] formats = ["yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm"];
if (DateTime.TryParseExact(trimmed, formats,
CultureInfo.InvariantCulture, DateTimeStyles.None, out when))
{
hasTime = true;
return true;
}
when = default;
hasTime = false;
return false;
}
/// <summary>
/// Raw shape of one YAML entry, before validation. String-typed on purpose so
/// we control date parsing and can distinguish date-only from date-and-time.
/// </summary>
private sealed class EventEntry
{
public string? Title { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
public string? Location { get; set; }
public string? Note { get; set; }
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Builds an iCalendar (RFC 5545) document from the events so visitors can
/// subscribe to the calendar in their own app. Times are written as local
/// wall-clock times without a time zone, which is what a school calendar needs:
/// "19:30" should read as 19:30 everywhere.
/// </summary>
public static class IcsCalendar
{
private const string ProductId = "-//Elternbeirat IGMH//Termine//DE";
/// <summary>
/// Serialises the given entries into a single VCALENDAR document.
/// </summary>
public static string Build(IReadOnlyList<Event> events)
{
var sb = new StringBuilder();
AppendLine(sb, "BEGIN:VCALENDAR");
AppendLine(sb, "VERSION:2.0");
AppendLine(sb, $"PRODID:{ProductId}");
AppendLine(sb, "CALSCALE:GREGORIAN");
AppendLine(sb, "METHOD:PUBLISH");
AppendLine(sb, "X-WR-CALNAME:Elternbeirat IGMH");
foreach (var ev in events)
{
AppendEvent(sb, ev);
}
AppendLine(sb, "END:VCALENDAR");
return sb.ToString();
}
private static void AppendEvent(StringBuilder sb, Event ev)
{
AppendLine(sb, "BEGIN:VEVENT");
AppendLine(sb, $"UID:{Uid(ev)}");
// No live timestamp: a stable DTSTAMP keeps the feed byte-identical
// between requests, so caches and clients do not see spurious changes.
AppendLine(sb, "DTSTAMP:20000101T000000Z");
if (ev.HasTime)
{
AppendLine(sb, $"DTSTART:{Local(ev.Start)}");
if (ev.End is { } end)
{
AppendLine(sb, $"DTEND:{Local(end)}");
}
}
else
{
AppendLine(sb, $"DTSTART;VALUE=DATE:{Date(ev.Start)}");
// For an all-day event DTEND is exclusive: add one day so a single
// day shows as one day, and a range covers its last day.
var end = ev.End ?? ev.Start;
AppendLine(sb, $"DTEND;VALUE=DATE:{Date(end.AddDays(1))}");
}
AppendLine(sb, $"SUMMARY:{Escape(ev.Title)}");
if (!string.IsNullOrWhiteSpace(ev.Location))
{
AppendLine(sb, $"LOCATION:{Escape(ev.Location)}");
}
if (!string.IsNullOrWhiteSpace(ev.Note))
{
AppendLine(sb, $"DESCRIPTION:{Escape(ev.Note)}");
}
AppendLine(sb, "END:VEVENT");
}
/// <summary>
/// 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.
/// </summary>
private static string Uid(Event ev)
{
var seed = $"{ev.Title}|{ev.Start:O}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"{Convert.ToHexString(hash)[..16].ToLowerInvariant()}@elternbeirat-igmh";
}
private static string Local(DateTime value) =>
value.ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture);
private static string Date(DateTime value) =>
value.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
/// <summary>Escapes the characters that are special in an ICS text value.</summary>
private static string Escape(string value) => value
.Replace("\\", "\\\\")
.Replace(";", "\\;")
.Replace(",", "\\,")
.Replace("\r\n", "\\n")
.Replace("\n", "\\n");
// ICS lines are terminated with CRLF regardless of platform.
private static void AppendLine(StringBuilder sb, string line) =>
sb.Append(line).Append("\r\n");
}
+1 -1
View File
@@ -49,7 +49,7 @@ public sealed class PageService
var source = File.ReadAllText(path);
var document = Markdown.Parse(source, pipeline);
var title = FrontMatter.Read("titel", document, source) ?? slug;
var title = FrontMatter.Read("title", document, source) ?? slug;
var html = Markdown.ToHtml(source, pipeline);
return new Page { Slug = slug, Title = title, ContentHtml = html };
+2 -2
View File
@@ -1,8 +1,8 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// A news post ("Beitrag"), read from a Markdown file in
/// <c>Content/beitraege/</c>. Unlike a <see cref="Page"/>, a post has a date and
/// A news post, read from a Markdown file in <c>Content/posts/</c>. Unlike a
/// <see cref="Page"/>, a post has a date and
/// is shown in a chronological list. The file name (without extension) is the
/// slug.
/// </summary>
+5 -5
View File
@@ -4,7 +4,7 @@ using Markdig;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads the news posts from <c>Content/beitraege/*.md</c>, renders them to HTML
/// Reads the news posts from <c>Content/posts/*.md</c>, renders them to HTML
/// once at startup and keeps them in memory, sorted newest first. Registered as
/// a singleton because the content lives in the image and does not change at
/// runtime.
@@ -15,7 +15,7 @@ public sealed class PostService
private readonly IReadOnlyDictionary<string, Post> _postsBySlug;
/// <summary>
/// Reads every Markdown post from the <c>Content/beitraege</c> directory
/// Reads every Markdown post from the <c>Content/posts</c> directory
/// below the application root and caches it.
/// </summary>
public PostService(IWebHostEnvironment environment)
@@ -24,7 +24,7 @@ public sealed class PostService
.UseYamlFrontMatter()
.Build();
var directory = Path.Combine(environment.ContentRootPath, "Content", "beitraege");
var directory = Path.Combine(environment.ContentRootPath, "Content", "posts");
var posts = new List<Post>();
if (Directory.Exists(directory))
@@ -64,8 +64,8 @@ public sealed class PostService
var source = File.ReadAllText(path);
var document = Markdown.Parse(source, pipeline);
var title = FrontMatter.Read("titel", document, source) ?? slug;
var date = ParseDate(FrontMatter.Read("datum", document, source));
var title = FrontMatter.Read("title", document, source) ?? slug;
var date = ParseDate(FrontMatter.Read("date", document, source));
var html = Markdown.ToHtml(source, pipeline);
return new Post { Slug = slug, Title = title, Date = date, ContentHtml = html };
+44
View File
@@ -63,6 +63,50 @@ h1:focus {
margin-bottom: 1rem;
}
/* Event list on /termine */
.event-list {
list-style: none;
padding: 0;
}
.event-list li {
padding: 0.75rem 0;
border-bottom: 1px solid #eee;
}
.event-list time {
display: block;
color: #777;
font-size: 0.85rem;
}
.event-title {
font-size: 1.1rem;
font-weight: 600;
}
.event-location::before {
content: " · ";
color: #777;
}
.event-location {
color: #555;
}
.event-note {
margin: 0.25rem 0 0;
color: #444;
}
.event-list-past {
color: #777;
}
.event-list-past .event-title {
font-weight: 400;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}