feat(google-calendar): calendar time rules and rendering

Offset date-times are normalised to full RFC 3339 (seconds, T separator)
before they are sent; the spec's time rules say so.
This commit is contained in:
EugeneTes
2026-09-26 20:08:10 +02:00
parent 8baa79f074
commit a30a4e4d03
3 changed files with 272 additions and 1 deletions

View File

@@ -0,0 +1,143 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
internal enum CalendarTimeKind { Date, Local, Offset }
/// <summary>A parsed tool time. <see cref="Raw"/> is the RFC 3339 text sent for an <see cref="CalendarTimeKind.Offset"/> value.</summary>
internal readonly record struct CalendarTimeValue(CalendarTimeKind Kind, string Raw, DateOnly Date, DateTime Local, DateTimeOffset Instant);
/// <summary>
/// The tool's time rules: <c>YYYY-MM-DD</c> is all-day (end exclusive), a date-time with an offset is sent as-is, one
/// without is wall time in the event zone (sent without an offset plus <c>timeZone</c>). Query bounds are RFC 3339.
/// </summary>
internal static class CalendarTime
{
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
private static readonly string[] LocalFormats =
["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"];
private const string OffsetFormat = "yyyy-MM-dd'T'HH:mm:sszzz";
private const string LocalFormat = "yyyy-MM-dd'T'HH:mm:ss";
private const string UtcFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'";
public static CalendarTimeValue Parse(string text, string field)
{
var s = (text ?? "").Trim();
if (DateOnly.TryParseExact(s, "yyyy-MM-dd", Inv, DateTimeStyles.None, out var date))
return new(CalendarTimeKind.Date, s, date, default, default);
if (HasOffset(s))
{
if (DateTimeOffset.TryParse(s, Inv, DateTimeStyles.None, out var instant))
return new(CalendarTimeKind.Offset, Rfc3339(instant, IsUtcDesignator(s)), default, default, instant);
}
else if (DateTime.TryParseExact(s, LocalFormats, Inv, DateTimeStyles.None, out var local))
{
return new(CalendarTimeKind.Local, s, default, DateTime.SpecifyKind(local, DateTimeKind.Unspecified), default);
}
throw new ArgumentException(
$"{field} must be YYYY-MM-DD, a local date-time like 2026-09-30T10:00, or one with an offset like 2026-09-30T10:00:00+02:00 (got \"{text}\").");
}
public static JsonObject ToEventTime(CalendarTimeValue value, string zoneId) => value.Kind switch
{
CalendarTimeKind.Date => new JsonObject { ["date"] = value.Date.ToString("yyyy-MM-dd", Inv) },
CalendarTimeKind.Local => new JsonObject { ["dateTime"] = value.Local.ToString(LocalFormat, Inv), ["timeZone"] = zoneId },
_ => new JsonObject { ["dateTime"] = value.Raw, ["timeZone"] = zoneId },
};
/// <summary>Start and end for a new event; a missing end is one day (all-day) or one hour (timed) after start.</summary>
public static (JsonObject Start, JsonObject End) ForCreate(string start, string? end, string zoneId)
{
var s = Parse(start, "start");
var e = end is null ? DefaultEnd(s) : Parse(end, "end");
EnsureSameKind(s, e);
return (ToEventTime(s, zoneId), ToEventTime(e, zoneId));
}
/// <summary>Only the sides that were given; both given must be the same kind.</summary>
public static (JsonObject? Start, JsonObject? End) ForUpdate(string? start, string? end, string zoneId)
{
CalendarTimeValue? s = start is null ? null : Parse(start, "start");
CalendarTimeValue? e = end is null ? null : Parse(end, "end");
if (s is { } sv && e is { } ev) EnsureSameKind(sv, ev);
return (s is { } a ? ToEventTime(a, zoneId) : null, e is { } b ? ToEventTime(b, zoneId) : null);
}
/// <summary>A query bound (<c>timeMin</c>/<c>timeMax</c>) as RFC 3339 with an offset; dates and local times are in <paramref name="zone"/>.</summary>
public static string ToRfc3339(string text, TimeZoneInfo zone, string field)
{
var value = Parse(text, field);
return value.Kind switch
{
CalendarTimeKind.Date => InZone(value.Date.ToDateTime(TimeOnly.MinValue), zone),
CalendarTimeKind.Local => InZone(value.Local, zone),
_ => value.Instant.ToString(OffsetFormat, Inv),
};
}
public static string Utc(DateTimeOffset instant) => instant.ToUniversalTime().ToString(UtcFormat, Inv);
/// <summary>An event's start/end as <c>Wed 30 Sep 10:00–11:00</c> in <paramref name="zone"/>, or an all-day range.</summary>
public static string FormatRange(JsonElement start, JsonElement end, TimeZoneInfo zone)
{
if (start.TryGetProperty("date", out var startDate))
{
var s = DateOnly.ParseExact(startDate.GetString()!, "yyyy-MM-dd", Inv);
var last = end.TryGetProperty("date", out var endDate)
? DateOnly.ParseExact(endDate.GetString()!, "yyyy-MM-dd", Inv).AddDays(-1)
: s;
return last <= s ? $"{Day(s)} (all day)" : $"{Day(s)} – {Day(last)} (all day)";
}
var from = TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(start.GetProperty("dateTime").GetString()!, Inv), zone);
var to = end.TryGetProperty("dateTime", out var endTime)
? TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(endTime.GetString()!, Inv), zone)
: from;
return from.Date == to.Date
? $"{from.ToString("ddd d MMM HH:mm", Inv)}–{to.ToString("HH:mm", Inv)}"
: $"{from.ToString("ddd d MMM HH:mm", Inv)} – {to.ToString("ddd d MMM HH:mm", Inv)}";
}
/// <summary>The header line of every tool result; names both zones when the calendar's differs from the chat's.</summary>
public static string Header(TimeZoneInfo resolved, TimeZoneInfo chat)
{
var r = ZoneIds.Name(resolved);
var c = ZoneIds.Name(chat);
return r == c ? $"Times in {r}." : $"Times in {r} (the calendar's zone; this chat is in {c}).";
}
private static string Day(DateOnly d) => d.ToString("ddd d MMM", Inv);
private static string InZone(DateTime local, TimeZoneInfo zone) =>
new DateTimeOffset(local, zone.GetUtcOffset(local)).ToString(OffsetFormat, Inv);
private static string Rfc3339(DateTimeOffset instant, bool utc) =>
utc && instant.Offset == TimeSpan.Zero ? instant.ToString(UtcFormat, Inv) : instant.ToString(OffsetFormat, Inv);
private static CalendarTimeValue DefaultEnd(CalendarTimeValue start) => start.Kind switch
{
CalendarTimeKind.Date => start with { Date = start.Date.AddDays(1) },
CalendarTimeKind.Local => start with { Local = start.Local.AddHours(1) },
_ => start with
{
Instant = start.Instant.AddHours(1),
Raw = Rfc3339(start.Instant.AddHours(1), start.Raw.EndsWith('Z')),
},
};
private static void EnsureSameKind(CalendarTimeValue start, CalendarTimeValue end)
{
if ((start.Kind == CalendarTimeKind.Date) != (end.Kind == CalendarTimeKind.Date))
throw new ArgumentException("start and end must both be dates (YYYY-MM-DD) or both date-times.");
}
private static bool IsUtcDesignator(string s) => s.EndsWith('Z') || s.EndsWith('z');
private static bool HasOffset(string s)
{
if (IsUtcDesignator(s)) return true;
var t = s.IndexOfAny(['T', ' ']);
return t > 0 && s.AsSpan(t).IndexOfAny('+', '-') >= 0;
}
}