forked from admin/VolcanicAgenticCore
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:
@@ -233,7 +233,9 @@ Behaviour follows the reference's recurring-events spec:
|
||||
- Zone `Z` = the event's `timeZone` argument, else the resolved zone (§4).
|
||||
- `YYYY-MM-DD` is an all-day date (`{date}`). All-day `end` is **exclusive**, as in Google. A missing `end` on create
|
||||
means one day.
|
||||
- A date-time with an offset (`2026-09-30T10:00:00+02:00` or `Z`) is sent as-is, as `dateTime`, with `timeZone: Z`.
|
||||
- A date-time with an offset (`2026-09-30T10:00:00+02:00` or `Z`) keeps its instant and offset and is sent as
|
||||
`dateTime`, with `timeZone: Z`. It is normalised to full RFC 3339 (`T` separator, seconds always present, so
|
||||
`2026-09-30T10:00+02:00` goes out as `2026-09-30T10:00:00+02:00`); a canonical value goes out unchanged.
|
||||
- A date-time without an offset (`2026-09-30T10:00`) is local wall time in `Z`: sent as `dateTime` without an offset
|
||||
plus `timeZone: Z`, which Google interprets in that zone. Recurring events are expanded in `Z`, so a weekly 10:00
|
||||
stays 10:00 across DST.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Text.Json;
|
||||
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
|
||||
public class CalendarTimeTests
|
||||
{
|
||||
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
|
||||
private static JsonElement El(string json) => JsonDocument.Parse(json).RootElement.Clone();
|
||||
|
||||
[Fact]
|
||||
public void A_date_is_an_all_day_value()
|
||||
{
|
||||
var value = CalendarTime.Parse("2026-09-30", "start");
|
||||
Assert.Equal(CalendarTimeKind.Date, value.Kind);
|
||||
Assert.Equal("""{"date":"2026-09-30"}""", CalendarTime.ToEventTime(value, "Europe/Berlin").ToJsonString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-09-30T10:00:00+02:00")]
|
||||
[InlineData("2026-09-30T08:00:00Z")]
|
||||
public void An_offset_date_time_is_sent_as_is_with_the_zone(string text)
|
||||
{
|
||||
var json = CalendarTime.ToEventTime(CalendarTime.Parse(text, "start"), "Europe/Berlin");
|
||||
Assert.Equal(text, json["dateTime"]!.GetValue<string>());
|
||||
Assert.Equal("Europe/Berlin", json["timeZone"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_offset_date_time_without_seconds_is_sent_as_full_rfc3339()
|
||||
{
|
||||
var json = CalendarTime.ToEventTime(CalendarTime.Parse("2026-09-30T10:00+02:00", "start"), "Europe/Berlin");
|
||||
Assert.Equal("2026-09-30T10:00:00+02:00", json["dateTime"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-09-30T10:00")]
|
||||
[InlineData("2026-09-30T10:00:00")]
|
||||
[InlineData("2026-09-30 10:00")]
|
||||
public void A_local_date_time_is_sent_without_offset_with_the_zone(string text)
|
||||
{
|
||||
var json = CalendarTime.ToEventTime(CalendarTime.Parse(text, "start"), "Europe/Berlin");
|
||||
Assert.Equal("""{"dateTime":"2026-09-30T10:00:00","timeZone":"Europe/Berlin"}""", json.ToJsonString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_all_day_defaults_end_to_the_next_day()
|
||||
{
|
||||
var (start, end) = CalendarTime.ForCreate("2026-09-30", null, "Europe/Berlin");
|
||||
Assert.Equal("""{"date":"2026-09-30"}""", start.ToJsonString());
|
||||
Assert.Equal("""{"date":"2026-10-01"}""", end.ToJsonString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_timed_defaults_end_to_one_hour_later()
|
||||
{
|
||||
var (_, local) = CalendarTime.ForCreate("2026-09-30T10:00", null, "Europe/Berlin");
|
||||
Assert.Equal("2026-09-30T11:00:00", local["dateTime"]!.GetValue<string>());
|
||||
var (_, offset) = CalendarTime.ForCreate("2026-09-30T10:00:00+02:00", null, "Europe/Berlin");
|
||||
Assert.Equal("2026-09-30T11:00:00+02:00", offset["dateTime"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-09-30", "2026-09-30T11:00")]
|
||||
[InlineData("2026-09-30T10:00", "2026-10-01")]
|
||||
public void Mixed_date_and_date_time_is_rejected(string start, string end)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => CalendarTime.ForCreate(start, end, "Europe/Berlin"));
|
||||
Assert.Equal("start and end must both be dates (YYYY-MM-DD) or both date-times.", ex.Message);
|
||||
Assert.Throws<ArgumentException>(() => CalendarTime.ForUpdate(start, end, "Europe/Berlin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_sends_only_the_given_side()
|
||||
{
|
||||
var (start, end) = CalendarTime.ForUpdate("2026-09-30T09:00", null, "Europe/Berlin");
|
||||
Assert.NotNull(start);
|
||||
Assert.Null(end);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unparsable_time_is_rejected_with_the_field_name()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => CalendarTime.Parse("next tuesday", "timeMin"));
|
||||
Assert.StartsWith("timeMin must be YYYY-MM-DD", ex.Message);
|
||||
Assert.Contains("next tuesday", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-09-30", "2026-09-30T00:00:00+02:00")]
|
||||
[InlineData("2026-12-01T10:00", "2026-12-01T10:00:00+01:00")]
|
||||
[InlineData("2026-09-30T10:00:00-04:00", "2026-09-30T10:00:00-04:00")]
|
||||
[InlineData("2026-09-30T08:00:00Z", "2026-09-30T08:00:00+00:00")]
|
||||
public void TimeMin_is_rfc3339_with_an_offset_in_the_zone(string text, string expected) =>
|
||||
Assert.Equal(expected, CalendarTime.ToRfc3339(text, Berlin, "timeMin"));
|
||||
|
||||
[Fact]
|
||||
public void Utc_formats_with_Z() =>
|
||||
Assert.Equal("2026-09-26T08:00:00Z", CalendarTime.Utc(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.FromHours(2))));
|
||||
|
||||
[Fact]
|
||||
public void Formats_a_timed_range_in_the_zone()
|
||||
{
|
||||
var start = El("""{"dateTime":"2026-09-30T10:00:00+02:00","timeZone":"Europe/Berlin"}""");
|
||||
var end = El("""{"dateTime":"2026-09-30T11:00:00+02:00"}""");
|
||||
Assert.Equal("Wed 30 Sep 10:00–11:00", CalendarTime.FormatRange(start, end, Berlin));
|
||||
Assert.Equal("Wed 30 Sep 04:00–05:00", CalendarTime.FormatRange(start, end, NewYork));
|
||||
Assert.Equal("Wed 30 Sep 23:00 – Thu 1 Oct 01:00",
|
||||
CalendarTime.FormatRange(El("""{"dateTime":"2026-09-30T23:00:00+02:00"}"""), El("""{"dateTime":"2026-10-01T01:00:00+02:00"}"""), Berlin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Formats_an_all_day_range_with_an_exclusive_end()
|
||||
{
|
||||
Assert.Equal("Wed 30 Sep (all day)", CalendarTime.FormatRange(El("""{"date":"2026-09-30"}"""), El("""{"date":"2026-10-01"}"""), Berlin));
|
||||
Assert.Equal("Wed 30 Sep – Fri 2 Oct (all day)", CalendarTime.FormatRange(El("""{"date":"2026-09-30"}"""), El("""{"date":"2026-10-03"}"""), Berlin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Header_names_one_zone_or_both()
|
||||
{
|
||||
Assert.Equal("Times in Europe/Berlin.", CalendarTime.Header(Berlin, Berlin));
|
||||
Assert.Equal("Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).", CalendarTime.Header(NewYork, Berlin));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user