diff --git a/OpenProject.Api.Test/Tests/WorkPackageScheduleJsonTests.cs b/OpenProject.Api.Test/Tests/WorkPackageScheduleJsonTests.cs
new file mode 100644
index 0000000..2f7dba9
--- /dev/null
+++ b/OpenProject.Api.Test/Tests/WorkPackageScheduleJsonTests.cs
@@ -0,0 +1,62 @@
+using OpenProject.Api.Data.Models;
+using OpenProject.Api.Data.Models.Update;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace OpenProject.Api.Test.Tests;
+
+///
+/// Work-package schedule fields are grouped in but stay flat in OpenProject's JSON.
+///
+public class WorkPackageScheduleJsonTests
+{
+ private static readonly JsonSerializerOptions Options = OpenProjectClient.CreateJsonOptions();
+
+ [Fact]
+ public void WorkPackage_ReadsFlatScheduleFieldsIntoSchedule()
+ {
+ var workPackage = JsonSerializer.Deserialize(
+ """
+ {"_type":"WorkPackage","_links":{},"id":7,"subject":"Ship it","percentageDone":10,"derivedPercentageDone":20,
+ "scheduleManually":true,"startDate":"2026-11-02T00:00:00","dueDate":"2026-11-06T00:00:00",
+ "date":null,"estimatedTime":"PT8H","derivedEstimatedTime":"PT16H"}
+ """,
+ Options)!;
+
+ workPackage.Id.Should().Be(7);
+ workPackage.Subject.Should().Be("Ship it");
+ workPackage.Schedule.ScheduleManually.Should().BeTrue();
+ workPackage.Schedule.StartDate.Should().Be(new DateTime(2026, 11, 2));
+ workPackage.Schedule.DueDate.Should().Be(new DateTime(2026, 11, 6));
+ workPackage.Schedule.Date.Should().BeNull();
+ workPackage.Schedule.EstimatedTime.Should().Be("PT8H");
+ workPackage.Schedule.DerivedEstimatedTime.Should().Be("PT16H");
+ }
+
+ [Fact]
+ public void WorkPackageUpdate_WritesScheduleFieldsFlatAndLeavesNullsOut()
+ {
+ var update = new WorkPackageUpdate
+ {
+ LockVersion = 3,
+ Schedule = new WorkPackageSchedule { StartDate = new DateTime(2026, 11, 2), EstimatedTime = "PT4H" }
+ };
+
+ var json = JsonNode.Parse(JsonSerializer.Serialize(update, Options))!.AsObject();
+
+ json.Select(property => property.Key).Should().BeEquivalentTo("lockVersion", "startDate", "estimatedTime");
+ json["lockVersion"]!.GetValue().Should().Be(3);
+ json["startDate"]!.GetValue().Should().Be("2026-11-02T00:00:00");
+ json["estimatedTime"]!.GetValue().Should().Be("PT4H");
+ }
+
+ [Fact]
+ public void WorkPackagesInACollection_AreReadThroughTheSameConverter()
+ {
+ var workPackages = JsonSerializer.Deserialize>(
+ """[{"_type":"WorkPackage","_links":{},"id":1,"subject":"a","percentageDone":0,"derivedPercentageDone":0,"dueDate":"2026-12-24T00:00:00"}]""",
+ Options)!;
+
+ workPackages.Should().ContainSingle().Which.Schedule.DueDate.Should().Be(new DateTime(2026, 12, 24));
+ }
+}
diff --git a/OpenProject.Api/Data/FlatScheduleConverterFactory.cs b/OpenProject.Api/Data/FlatScheduleConverterFactory.cs
new file mode 100644
index 0000000..2d3914c
--- /dev/null
+++ b/OpenProject.Api/Data/FlatScheduleConverterFactory.cs
@@ -0,0 +1,63 @@
+using OpenProject.Api.Data.Models;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Type = System.Type;
+
+namespace OpenProject.Api.Data;
+
+///
+/// Reads and writes models with their fields at the
+/// top level of the JSON object, where OpenProject puts them.
+///
+internal sealed class FlatScheduleConverterFactory : JsonConverterFactory
+{
+ public override bool CanConvert(Type typeToConvert)
+ => typeToConvert.IsClass && typeof(IHasSchedule).IsAssignableFrom(typeToConvert);
+
+ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ => (JsonConverter)Activator.CreateInstance(
+ typeof(FlatScheduleConverter<>).MakeGenericType(typeToConvert),
+ WithoutThisFactory(options))!;
+
+ // Without this, serializing the model inside the converter would re-enter the converter.
+ private static JsonSerializerOptions WithoutThisFactory(JsonSerializerOptions options)
+ {
+ var inner = new JsonSerializerOptions(options);
+ for (var i = inner.Converters.Count - 1; i >= 0; i--)
+ {
+ if (inner.Converters[i] is FlatScheduleConverterFactory)
+ {
+ inner.Converters.RemoveAt(i);
+ }
+ }
+
+ return inner;
+ }
+
+ private sealed class FlatScheduleConverter(JsonSerializerOptions inner) : JsonConverter
+ where T : class, IHasSchedule
+ {
+ public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ var item = document.RootElement.Deserialize(inner);
+ if (item is not null)
+ {
+ item.Schedule = document.RootElement.Deserialize(inner) ?? new();
+ }
+
+ return item;
+ }
+
+ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
+ {
+ var node = JsonSerializer.SerializeToNode(value, inner)!.AsObject();
+ foreach (var (name, field) in JsonSerializer.SerializeToNode(value.Schedule, inner)!.AsObject())
+ {
+ node[name] = field?.DeepClone();
+ }
+
+ node.WriteTo(writer, inner);
+ }
+ }
+}
diff --git a/OpenProject.Api/Data/Models/IHasSchedule.cs b/OpenProject.Api/Data/Models/IHasSchedule.cs
new file mode 100644
index 0000000..f794b71
--- /dev/null
+++ b/OpenProject.Api/Data/Models/IHasSchedule.cs
@@ -0,0 +1,12 @@
+namespace OpenProject.Api.Data.Models;
+
+///
+/// A model whose schedule fields are flat in OpenProject's JSON but grouped in .
+///
+public interface IHasSchedule
+{
+ ///
+ /// The scheduling and estimate fields.
+ ///
+ WorkPackageSchedule Schedule { get; set; }
+}
diff --git a/OpenProject.Api/Data/Models/Update/WorkPackageUpdate.cs b/OpenProject.Api/Data/Models/Update/WorkPackageUpdate.cs
index 1f093cf..10befcd 100644
--- a/OpenProject.Api/Data/Models/Update/WorkPackageUpdate.cs
+++ b/OpenProject.Api/Data/Models/Update/WorkPackageUpdate.cs
@@ -1,11 +1,12 @@
-using System.Text.Json.Serialization;
+using OpenProject.Api.Data.Models;
+using System.Text.Json.Serialization;
namespace OpenProject.Api.Data.Models.Update;
///
/// Represents the fields that can be updated on an existing work package.
///
-public class WorkPackageUpdate
+public class WorkPackageUpdate : IHasSchedule
{
///
/// The value of lockVersion is used to implement optimistic locking.
@@ -26,38 +27,8 @@ public class WorkPackageUpdate
public Formattable? Description { get; set; }
///
- /// If false (default) schedule automatically.
+ /// Scheduling and estimate fields to change; fields left null are not sent.
///
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public bool? ScheduleManually { get; set; }
-
- ///
- /// Scheduled beginning of a work package
- ///
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public DateTime? StartDate { get; set; }
-
- ///
- /// Scheduled end of a work package
- ///
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public DateTime? DueDate { get; set; }
-
- ///
- /// Date on which a milestone is achieved
- ///
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public DateTime? Date { get; set; }
-
- ///
- /// Corresponds to work. Time a work package likely needs to be completed.
- ///
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? EstimatedTime { get; set; }
-
- ///
- /// Corresponds to total work. Time a work package likely needs to be completed including itself and its descendants.
- ///
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? DerivedEstimatedTime { get; set; }
+ [JsonIgnore]
+ public WorkPackageSchedule Schedule { get; set; } = new();
}
diff --git a/OpenProject.Api/Data/Models/WorkPackage.cs b/OpenProject.Api/Data/Models/WorkPackage.cs
index 7c79877..80f400d 100644
--- a/OpenProject.Api/Data/Models/WorkPackage.cs
+++ b/OpenProject.Api/Data/Models/WorkPackage.cs
@@ -6,7 +6,7 @@ namespace OpenProject.Api.Data.Models;
/// A work package in OpenProject can basically be everything you need to keep track off within your projects. It can be e.g. a task, a feature, a bug, a risk, a milestone or a project phase.
/// See
///
-public class WorkPackage : IdentifiedItem, IHasTimestamps
+public class WorkPackage : IdentifiedItem, IHasTimestamps, IHasSchedule
{
///
/// The version of the item as used for optimistic locking
@@ -27,24 +27,10 @@ public class WorkPackage : IdentifiedItem, IHasTimestamps
public Formattable? Description { get; set; }
///
- /// If false (default) schedule automatically.
+ /// Scheduling and estimate fields: start, due and milestone dates, manual scheduling and estimated time.
///
- public bool? ScheduleManually { get; set; }
-
- ///
- /// Scheduled beginning of a work package
- ///
- public DateTime? StartDate { get; set; }
-
- ///
- /// Scheduled end of a work package
- ///
- public DateTime? DueDate { get; set; }
-
- ///
- /// Date on which a milestone is achieved
- ///
- public DateTime? Date { get; set; }
+ [JsonIgnore]
+ public WorkPackageSchedule Schedule { get; set; } = new();
///
/// Similar to start date but is not set by a client but rather deduced by the work packages’ descendants. If manual scheduleManually is active, the two dates can deviate.
@@ -61,16 +47,6 @@ public class WorkPackage : IdentifiedItem, IHasTimestamps
///
public string? Duration { get; set; }
- ///
- /// Corresponds to work. Time a work package likely needs to be completed.
- ///
- public string? EstimatedTime { get; set; }
-
- ///
- /// Corresponds to total work. Time a work package likely needs to be completed including itself and its descendants.
- ///
- public string? DerivedEstimatedTime { get; set; }
-
///
/// Corresponds to remaining work. Remaining time a work package likely needs to be completed.
///
diff --git a/OpenProject.Api/Data/Models/WorkPackageSchedule.cs b/OpenProject.Api/Data/Models/WorkPackageSchedule.cs
new file mode 100644
index 0000000..0331539
--- /dev/null
+++ b/OpenProject.Api/Data/Models/WorkPackageSchedule.cs
@@ -0,0 +1,46 @@
+using System.Text.Json.Serialization;
+
+namespace OpenProject.Api.Data.Models;
+
+///
+/// The scheduling and estimate fields of a work package. They are top-level fields in OpenProject's JSON;
+/// models group them here.
+///
+public class WorkPackageSchedule
+{
+ ///
+ /// If false (default) schedule automatically.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? ScheduleManually { get; set; }
+
+ ///
+ /// Scheduled beginning of a work package
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public DateTime? StartDate { get; set; }
+
+ ///
+ /// Scheduled end of a work package
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public DateTime? DueDate { get; set; }
+
+ ///
+ /// Date on which a milestone is achieved
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public DateTime? Date { get; set; }
+
+ ///
+ /// Corresponds to work. Time a work package likely needs to be completed.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? EstimatedTime { get; set; }
+
+ ///
+ /// Corresponds to total work. Time a work package likely needs to be completed including itself and its descendants.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? DerivedEstimatedTime { get; set; }
+}
diff --git a/OpenProject.Api/OpenProject.Api.csproj b/OpenProject.Api/OpenProject.Api.csproj
index 308e46f..5e4bc5a 100644
--- a/OpenProject.Api/OpenProject.Api.csproj
+++ b/OpenProject.Api/OpenProject.Api.csproj
@@ -38,4 +38,7 @@
+
+
+
\ No newline at end of file
diff --git a/OpenProject.Api/OpenProjectClient.cs b/OpenProject.Api/OpenProjectClient.cs
index 252be26..ce89623 100644
--- a/OpenProject.Api/OpenProjectClient.cs
+++ b/OpenProject.Api/OpenProjectClient.cs
@@ -29,6 +29,7 @@ public OpenProjectClient(HttpClient client)
_httpClient = client;
_refitSettings = new RefitSettings
{
+ ContentSerializer = new SystemTextJsonContentSerializer(CreateJsonOptions()),
//UrlParameterFormatter = new OpenProjectUrlParameterFormatter(),
};
@@ -59,6 +60,16 @@ public OpenProjectClient(HttpClient client)
Types = RefitFor();
}
+ ///
+ /// Refit's default JSON options, plus the converter that keeps work-package schedule fields flat on the wire.
+ ///
+ internal static System.Text.Json.JsonSerializerOptions CreateJsonOptions()
+ {
+ var options = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions();
+ options.Converters.Add(new FlatScheduleConverterFactory());
+ return options;
+ }
+
private T RefitFor()
=> RestService.For(_httpClient, _refitSettings);
diff --git a/version.json b/version.json
index 6f8af6b..4ecc882 100644
--- a/version.json
+++ b/version.json
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
- "version": "3.0",
+ "version": "3.1",
"publicReleaseRefSpec": [
"^refs/heads/main$",
"^refs/heads/release/\\d+\\.\\d+$",