using System.Globalization; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace Elternbeirat.Web.Services; /// /// Reads the calendar entries from Content/events.yml 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. /// public sealed class EventService { private readonly IReadOnlyList _events; /// /// Reads and parses Content/events.yml below the application root. /// A missing file yields an empty list rather than an error. /// public EventService(IWebHostEnvironment environment) { var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml"); _events = File.Exists(path) ? Parse(File.ReadAllText(path)) : []; } /// /// 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. /// public IReadOnlyList Upcoming => _events .Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today)) .OrderBy(e => e.Start) .ToList(); /// /// Entries that already passed, most recent first — the past section shown /// below the upcoming ones. /// public IReadOnlyList Past => _events .Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today)) .OrderByDescending(e => e.Start) .ToList(); /// All entries in chronological order, for the ICS export. public IReadOnlyList All => _events.OrderBy(e => e.Start).ToList(); private static List 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?>(yaml) ?? []; return raw .Where(entry => entry.Title is not null && entry.Start is not null) .Select(ToEvent) .OfType() .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, }; } /// /// Parses a value that is either a date (2026-03-15) or a date with a /// time (2026-03-15 19:30). Sets so the /// caller knows whether to treat the entry as all-day. /// 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; } /// /// 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. /// 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; } } }