diff --git a/src/JSCalendar.Net/Converters/DurationJsonConverter.cs b/src/JSCalendar.Net/Converters/DurationJsonConverter.cs
index 9e71ade..fb5b496 100644
--- a/src/JSCalendar.Net/Converters/DurationJsonConverter.cs
+++ b/src/JSCalendar.Net/Converters/DurationJsonConverter.cs
@@ -48,7 +48,7 @@ private static Duration ParseDuration(string durationString)
};
}
- private static int? ParseIntGroup(Group group)
+ private static int? ParseIntGroup(System.Text.RegularExpressions.Group group)
{
if (group.Success && int.TryParse(group.Value, out var value)) return value;
diff --git a/src/JSCalendar.Net/Converters/JSCalendarObjectConverter.cs b/src/JSCalendar.Net/Converters/JSCalendarObjectConverter.cs
new file mode 100644
index 0000000..51216e0
--- /dev/null
+++ b/src/JSCalendar.Net/Converters/JSCalendarObjectConverter.cs
@@ -0,0 +1,41 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace JSCalendar.Net.Converters;
+
+///
+/// JSON converter for IJSCalendarObject interface to support polymorphic deserialization.
+/// This converter reads the @type property to determine whether to deserialize an Event or Task.
+///
+public class JSCalendarObjectConverter : JsonConverter
+{
+ public override IJSCalendarObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ // Read the JSON as a JsonDocument to inspect the @type property
+ using var jsonDoc = JsonDocument.ParseValue(ref reader);
+ var root = jsonDoc.RootElement;
+
+ // Check for @type property
+ if (!root.TryGetProperty("@type", out var typeProperty))
+ {
+ throw new JsonException("Missing @type property in JSCalendar object");
+ }
+
+ var typeName = typeProperty.GetString();
+
+ // Deserialize based on the @type value
+ var json = root.GetRawText();
+ return typeName switch
+ {
+ "Event" => JsonSerializer.Deserialize(json, options),
+ "Task" => JsonSerializer.Deserialize(json, options),
+ _ => throw new JsonException($"Unknown JSCalendar object type: {typeName}")
+ };
+ }
+
+ public override void Write(Utf8JsonWriter writer, IJSCalendarObject value, JsonSerializerOptions options)
+ {
+ // Serialize the concrete type
+ JsonSerializer.Serialize(writer, value, value.GetType(), options);
+ }
+}
diff --git a/src/JSCalendar.Net/Event.cs b/src/JSCalendar.Net/Event.cs
index ed28137..998081a 100644
--- a/src/JSCalendar.Net/Event.cs
+++ b/src/JSCalendar.Net/Event.cs
@@ -7,7 +7,7 @@ namespace JSCalendar.Net;
/// Represents a calendar event in JSCalendar format (RFC 8984).
/// This is the main type for calendar events.
///
-public sealed class Event
+public sealed class Event : IJSCalendarObject
{
// Metadata Properties (Section 4.1)
diff --git a/src/JSCalendar.Net/Group.cs b/src/JSCalendar.Net/Group.cs
new file mode 100644
index 0000000..de030b3
--- /dev/null
+++ b/src/JSCalendar.Net/Group.cs
@@ -0,0 +1,120 @@
+using System.Text.Json.Serialization;
+
+namespace JSCalendar.Net;
+
+///
+/// Represents a collection of events and/or tasks in JSCalendar format (RFC 8984 Section 2.3).
+/// A Group is used to organize related calendar objects together.
+///
+public sealed class Group
+{
+ // Metadata Properties (Section 4.1)
+
+ ///
+ /// Type identifier. MUST be "Group".
+ ///
+ [JsonPropertyName("@type")]
+ public string Type { get; init; } = "Group";
+
+ ///
+ /// Globally unique identifier for this group (Section 4.1.2).
+ /// REQUIRED property.
+ ///
+ [JsonPropertyName("uid")]
+ public required string Uid { get; init; }
+
+ ///
+ /// Producer identifier that created this group (Section 4.1.4).
+ ///
+ [JsonPropertyName("prodId")]
+ public string? ProdId { get; init; }
+
+ ///
+ /// Date and time this group was created (Section 4.1.5).
+ ///
+ [JsonPropertyName("created")]
+ public DateTimeOffset? Created { get; init; }
+
+ ///
+ /// Date and time this group was last updated (Section 4.1.6).
+ /// REQUIRED property.
+ ///
+ [JsonPropertyName("updated")]
+ public required DateTimeOffset Updated { get; init; }
+
+ // What and Where Properties (Section 4.2)
+
+ ///
+ /// Short summary or name of the group (Section 4.2.1).
+ /// Default: empty String
+ ///
+ [JsonPropertyName("title")]
+ public string Title { get; init; } = "";
+
+ ///
+ /// Detailed description of the group (Section 4.2.2).
+ /// Default: empty String
+ ///
+ [JsonPropertyName("description")]
+ public string Description { get; init; } = "";
+
+ ///
+ /// Content type of the description (Section 4.2.3).
+ /// Default: "text/plain"
+ ///
+ [JsonPropertyName("descriptionContentType")]
+ public string DescriptionContentType { get; init; } = "text/plain";
+
+ ///
+ /// Links to external resources associated with this group (Section 4.2.7).
+ ///
+ [JsonPropertyName("links")]
+ public Dictionary? Links { get; init; }
+
+ ///
+ /// Language tag for this group (Section 4.2.8).
+ ///
+ [JsonPropertyName("locale")]
+ public string? Locale { get; init; }
+
+ ///
+ /// Keywords or tags for this group (Section 4.2.9).
+ ///
+ [JsonPropertyName("keywords")]
+ public Dictionary? Keywords { get; init; }
+
+ ///
+ /// Categories for this group (Section 4.2.10).
+ ///
+ [JsonPropertyName("categories")]
+ public Dictionary? Categories { get; init; }
+
+ ///
+ /// Color to use when displaying this group (Section 4.2.11).
+ ///
+ [JsonPropertyName("color")]
+ public string? Color { get; init; }
+
+ // Time Zone Properties (Section 4.7)
+
+ ///
+ /// Time zone definitions referenced by entries in this group (Section 4.7.2).
+ ///
+ [JsonPropertyName("timeZones")]
+ public Dictionary? TimeZones { get; init; }
+
+ // Group-specific Properties (Section 5.3)
+
+ ///
+ /// Collection of Event and/or Task objects in this group (Section 5.3.1).
+ /// REQUIRED property.
+ ///
+ [JsonPropertyName("entries")]
+ public required List Entries { get; init; }
+
+ ///
+ /// Source URI from which updated versions of this group may be retrieved (Section 5.3.2).
+ ///
+ [JsonPropertyName("source")]
+ public string? Source { get; init; }
+}
diff --git a/src/JSCalendar.Net/IJSCalendarObject.cs b/src/JSCalendar.Net/IJSCalendarObject.cs
new file mode 100644
index 0000000..dc4ede4
--- /dev/null
+++ b/src/JSCalendar.Net/IJSCalendarObject.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+using JSCalendar.Net.Converters;
+
+namespace JSCalendar.Net;
+
+///
+/// Marker interface for JSCalendar objects that can be entries in a Group.
+/// This interface is implemented by Event and Task objects.
+///
+[JsonConverter(typeof(JSCalendarObjectConverter))]
+public interface IJSCalendarObject
+{
+ ///
+ /// Type identifier for this JSCalendar object.
+ ///
+ string Type { get; }
+
+ ///
+ /// Globally unique identifier for this object.
+ ///
+ string Uid { get; }
+
+ ///
+ /// Date and time this object was last updated.
+ ///
+ DateTimeOffset Updated { get; }
+}
diff --git a/src/JSCalendar.Net/Task.cs b/src/JSCalendar.Net/Task.cs
index ab52d0e..82cdb23 100644
--- a/src/JSCalendar.Net/Task.cs
+++ b/src/JSCalendar.Net/Task.cs
@@ -6,7 +6,7 @@ namespace JSCalendar.Net;
///
/// Represents a task/todo item in JSCalendar format (RFC 8984 Section 2.2).
///
-public sealed class Task
+public sealed class Task : IJSCalendarObject
{
// Metadata Properties (Section 4.1)
diff --git a/tests/JSCalendar.Net.Tests/GroupTests.cs b/tests/JSCalendar.Net.Tests/GroupTests.cs
new file mode 100644
index 0000000..75fef93
--- /dev/null
+++ b/tests/JSCalendar.Net.Tests/GroupTests.cs
@@ -0,0 +1,187 @@
+using System.Text.Json;
+
+namespace JSCalendar.Net.Tests;
+
+public class GroupTests
+{
+ private readonly JsonSerializerOptions _options = new() { WriteIndented = false };
+
+ [Fact]
+ public void Group_MinimalProperties_SerializesCorrectly()
+ {
+ // Arrange
+ var group = new Group
+ {
+ Uid = "group-123",
+ Updated = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero),
+ Entries = []
+ };
+
+ // Act
+ var json = JsonSerializer.Serialize(group, _options);
+
+ // Assert
+ Assert.Contains("\"@type\":\"Group\"", json);
+ Assert.Contains("\"uid\":\"group-123\"", json);
+ Assert.Contains("\"updated\":\"2024-12-01T10:00:00+00:00\"", json);
+ Assert.Contains("\"entries\":[]", json);
+ }
+
+ [Fact]
+ public void Group_WithEventAndTask_SerializesCorrectly()
+ {
+ // Arrange
+ var evt = new Event
+ {
+ Uid = "event-001",
+ Updated = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero),
+ Start = new LocalDateTime(new DateTime(2024, 12, 15, 14, 0, 0)),
+ Title = "Team Meeting"
+ };
+
+ var task = new Task
+ {
+ Uid = "task-001",
+ Updated = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero),
+ Title = "Prepare slides"
+ };
+
+ var group = new Group
+ {
+ Uid = "group-123",
+ Updated = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero),
+ Title = "Work Items",
+ Entries = [evt, task]
+ };
+
+ // Act
+ var json = JsonSerializer.Serialize(group, _options);
+
+ // Assert
+ Assert.Contains("\"@type\":\"Group\"", json);
+ Assert.Contains("\"uid\":\"group-123\"", json);
+ Assert.Contains("\"title\":\"Work Items\"", json);
+ Assert.Contains("\"entries\":[", json);
+ Assert.Contains("\"@type\":\"Event\"", json);
+ Assert.Contains("\"@type\":\"Task\"", json);
+ Assert.Contains("\"uid\":\"event-001\"", json);
+ Assert.Contains("\"uid\":\"task-001\"", json);
+ }
+
+ [Fact]
+ public void Group_WithAllProperties_SerializesCorrectly()
+ {
+ // Arrange
+ var group = new Group
+ {
+ Uid = "group-456",
+ Created = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero),
+ Updated = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero),
+ ProdId = "//example.com//calendar//EN",
+ Title = "Conference Events",
+ Description = "All events related to the annual conference",
+ DescriptionContentType = "text/plain",
+ Locale = "en",
+ Color = "blue",
+ Keywords = new Dictionary
+ {
+ ["conference"] = true,
+ ["annual"] = true
+ },
+ Categories = new Dictionary
+ {
+ ["http://example.com/categories/work"] = true
+ },
+ Links = new Dictionary
+ {
+ ["website"] = new Link
+ {
+ Href = "https://conference.example.com"
+ }
+ },
+ Source = "https://calendar.example.com/groups/456",
+ Entries = []
+ };
+
+ // Act
+ var json = JsonSerializer.Serialize(group, _options);
+
+ // Assert
+ Assert.Contains("\"@type\":\"Group\"", json);
+ Assert.Contains("\"uid\":\"group-456\"", json);
+ Assert.Contains("\"title\":\"Conference Events\"", json);
+ Assert.Contains("\"description\":\"All events related to the annual conference\"", json);
+ Assert.Contains("\"locale\":\"en\"", json);
+ Assert.Contains("\"color\":\"blue\"", json);
+ Assert.Contains("\"keywords\":{\"conference\":true,\"annual\":true}", json);
+ Assert.Contains("\"source\":\"https://calendar.example.com/groups/456\"", json);
+ }
+
+ [Fact]
+ public void Group_Deserialize_WorksCorrectly()
+ {
+ // Arrange
+ var json = """
+ {
+ "@type": "Group",
+ "uid": "group-789",
+ "updated": "2024-12-01T10:00:00+00:00",
+ "title": "My Group",
+ "entries": [
+ {
+ "@type": "Event",
+ "uid": "event-001",
+ "updated": "2024-12-01T10:00:00+00:00",
+ "start": "2024-12-01T10:00:00+00:00",
+ "title": "Event Title"
+ },
+ {
+ "@type": "Task",
+ "uid": "task-001",
+ "updated": "2024-12-02T11:00:00Z",
+ "title": "Task Title"
+ }
+ ]
+ }
+ """;
+
+ // Act
+ var group = JsonSerializer.Deserialize(json);
+
+ // Assert
+ Assert.NotNull(group);
+ Assert.Equal("Group", group.Type);
+ Assert.Equal("group-789", group.Uid);
+ Assert.Equal("My Group", group.Title);
+ Assert.NotNull(group.Entries);
+ Assert.Equal(2, group.Entries.Count);
+
+ // Check first entry is Event
+ var firstEntry = group.Entries[0];
+ Assert.Equal("Event", firstEntry.Type);
+ Assert.Equal("event-001", firstEntry.Uid);
+
+ // Check second entry is Task
+ var secondEntry = group.Entries[1];
+ Assert.Equal("Task", secondEntry.Type);
+ Assert.Equal("task-001", secondEntry.Uid);
+ }
+
+ [Fact]
+ public void Group_DefaultValues_AreCorrect()
+ {
+ // Arrange & Act
+ var group = new Group
+ {
+ Uid = "test-group",
+ Updated = DateTimeOffset.UtcNow,
+ Entries = []
+ };
+
+ // Assert
+ Assert.Equal("Group", group.Type);
+ Assert.Equal("", group.Title);
+ Assert.Equal("", group.Description);
+ Assert.Equal("text/plain", group.DescriptionContentType);
+ }
+}