using System.Globalization;
using Elternbeirat.Web.Services;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Elternbeirat.Web.Features.Events;
///
/// 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; }
}
}