From 8d7fef22a046b4f1364610d0130342885634e736 Mon Sep 17 00:00:00 2001 From: Gustav Persson Date: Thu, 2 Apr 2026 10:14:23 +0200 Subject: [PATCH 1/2] Add color property to projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow each project to have a color, which is used to derive activity colors. The first activity inherits the project color, and subsequent activities progressively darken it by 10%. The frontend provides a color picker with a curated palette of 16 visually distinct default colors. The backend API accepts color as an optional field for backwards compatibility — existing projects without a color fall back to #457b9d. --- .../Quarter.Core/Commands/CommandHandler.cs | 4 +- .../Quarter.Core/Commands/ProjectCommands.cs | 4 +- service/src/Quarter.Core/Models/Project.cs | 19 +++++- .../Repositories/ProjectRepository.cs | 3 +- .../Resources/ProjectResource.cs | 12 +++- .../Quarter.HttpApi/Services/ApiService.cs | 4 +- .../Commands/AddProjectCommandTest.cs | 13 +++- .../Commands/ArchiveProjectCommandTest.cs | 2 +- .../Commands/CommandTestBase.cs | 2 +- .../Commands/EditProjectCommandTest.cs | 40 +++++++++++- .../Commands/RemoveProjectCommandTest.cs | 2 +- .../Commands/RestoreProjectCommandTest.cs | 3 +- .../Models/ProjectTest.cs | 11 +++- .../Repositories/ProjectRepositoryTest.cs | 3 +- .../Repositories/RepositoryFactoryTest.cs | 2 +- .../CreateProjectResourceInputTest.cs | 6 +- .../Resources/ProjectResourceOutputTest.cs | 9 ++- .../UpdateProjectResourceInputTest.cs | 25 ++++++++ .../Services/ApiService/CreateProjectTest.cs | 14 ++++- .../Services/ApiService/TestCase.cs | 2 +- .../Services/ApiService/UpdateProjectTest.cs | 60 +++++++++++++++++- .../HttpApi/CreateProjectTest.cs | 2 + .../TestUtils/HttpTestCase.cs | 2 +- webapp/src/dialogs/activity_dialog.gleam | 20 +++++- webapp/src/dialogs/project_dialog.gleam | 32 +++++++++- webapp/src/domain/color.gleam | 63 +++++++++++++++++-- webapp/src/domain/project.gleam | 1 + webapp/src/model.gleam | 2 +- webapp/src/protocol.gleam | 6 ++ webapp/src/views/manage_projects.gleam | 7 +++ webapp/src/webapp.gleam | 2 + 31 files changed, 336 insertions(+), 41 deletions(-) diff --git a/service/src/Quarter.Core/Commands/CommandHandler.cs b/service/src/Quarter.Core/Commands/CommandHandler.cs index 08528cb0..4fe3a7b1 100644 --- a/service/src/Quarter.Core/Commands/CommandHandler.cs +++ b/service/src/Quarter.Core/Commands/CommandHandler.cs @@ -66,7 +66,7 @@ await repositoryFactory.UserRepository() private async Task ExecuteAsync(AddProjectCommand command, OperationContext oc, CancellationToken ct) { - var project = new Project(command.Name, command.Description); + var project = new Project(command.Name, command.Description, command.Color); await repositoryFactory.ProjectRepository(oc.UserId).CreateAsync(project, ct); } @@ -79,6 +79,8 @@ await repositoryFactory.ProjectRepository(oc.UserId).UpdateByIdAsync(command.Pro current.Name = command.Name; if (command.Description is not null) current.Description = command.Description; + if (command.Color is not null) + current.Color = command.Color; return current; }, ct); diff --git a/service/src/Quarter.Core/Commands/ProjectCommands.cs b/service/src/Quarter.Core/Commands/ProjectCommands.cs index eedc36e1..ea79cf32 100644 --- a/service/src/Quarter.Core/Commands/ProjectCommands.cs +++ b/service/src/Quarter.Core/Commands/ProjectCommands.cs @@ -3,8 +3,8 @@ namespace Quarter.Core.Commands; -public record AddProjectCommand(string Name, string Description) : ICommand; -public record EditProjectCommand(IdOf ProjectId, string? Name, string? Description) : ICommand; +public record AddProjectCommand(string Name, string Description, Color Color) : ICommand; +public record EditProjectCommand(IdOf ProjectId, string? Name, string? Description, Color? Color) : ICommand; public record RemoveProjectCommand(IdOf ProjectId) : ICommand; public record ArchiveProjectCommand(IdOf ProjectId) : ICommand; public record RestoreProjectCommand(IdOf ProjectId) : ICommand; diff --git a/service/src/Quarter.Core/Models/Project.cs b/service/src/Quarter.Core/Models/Project.cs index b2366c51..1d113cda 100644 --- a/service/src/Quarter.Core/Models/Project.cs +++ b/service/src/Quarter.Core/Models/Project.cs @@ -6,20 +6,34 @@ namespace Quarter.Core.Models; public class Project : IAggregate { + /// + /// Default color used for projects created before the color feature was added. + /// + public static readonly Color DefaultColor = Color.FromHexString("#457b9d"); + [JsonConverter(typeof(IdOfJsonConverter))] public IdOf Id { get; set; } public UtcDateTime Created { get; set; } public UtcDateTime? Updated { get; set; } public string Name { get; set; } public string Description { get; set; } + + private Color? _color; + public Color Color + { + get => _color ?? DefaultColor; + set => _color = value; + } + public bool IsArchived { get; set; } - public Project(string name, string description) + public Project(string name, string description, Color color) { Id = IdOf.Random(); Created = UtcDateTime.Now(); Name = name; Description = description; + Color = color; } #pragma warning disable CS8618 @@ -46,9 +60,10 @@ public override bool Equals(object? obj) Description == other.Description && Created.DateTime == other.Created.DateTime && Updated?.DateTime == other.Updated?.DateTime && + Color.Equals(other.Color) && IsArchived == other.IsArchived; } public override int GetHashCode() - => HashCode.Combine(Id, Name, Description, Created, Updated, IsArchived); + => HashCode.Combine(Id, Name, Description, Created, Updated, Color, IsArchived); } diff --git a/service/src/Quarter.Core/Repositories/ProjectRepository.cs b/service/src/Quarter.Core/Repositories/ProjectRepository.cs index 4635f1b7..0d241dcd 100644 --- a/service/src/Quarter.Core/Repositories/ProjectRepository.cs +++ b/service/src/Quarter.Core/Repositories/ProjectRepository.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Npgsql; using Quarter.Core.Models; +using Quarter.Core.Utils; namespace Quarter.Core.Repositories; @@ -16,7 +17,7 @@ public static class ProjectRepository { public static Task CreateSandboxProjectAsync(IProjectRepository self, CancellationToken ct) { - var project = new Project("Your first project", "A project is used to group activities."); + var project = new Project("Your first project", "A project is used to group activities.", Color.FromHexString("#457b9d")); return self.CreateAsync(project, ct); } } diff --git a/service/src/Quarter.HttpApi/Resources/ProjectResource.cs b/service/src/Quarter.HttpApi/Resources/ProjectResource.cs index 2e8c04b4..ffe433ac 100644 --- a/service/src/Quarter.HttpApi/Resources/ProjectResource.cs +++ b/service/src/Quarter.HttpApi/Resources/ProjectResource.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Quarter.Core.Models; +using Quarter.Core.Utils; namespace Quarter.HttpApi.Resources; @@ -11,17 +12,18 @@ namespace Quarter.HttpApi.Resources; /// The ID of the project /// The name of the project /// The project description +/// The project color in CSS HEX /// Whether or not the project is archived /// Timestamp for when the project was created (ISO-8601) /// Timestamp for when the project was last updated, or null if it has never been updated (ISO-8601) -public record ProjectResourceOutput(string id, string name, string description, bool isArchived, string created, string? updated) +public record ProjectResourceOutput(string id, string name, string description, string color, bool isArchived, string created, string? updated) { public Uri Location() => new($"/api/projects/{id}", UriKind.Relative); public static ProjectResourceOutput From(Project project) { - return new ProjectResourceOutput(project.Id.AsString(), project.Name, project.Description, project.IsArchived, project.Created.IsoString(), project.Updated?.IsoString()); + return new ProjectResourceOutput(project.Id.AsString(), project.Name, project.Description, project.Color.ToHex(), project.IsArchived, project.Created.IsoString(), project.Updated?.IsoString()); } } @@ -31,6 +33,9 @@ public class CreateProjectResourceInput public string? name { get; set; } public string? description { get; set; } + + [RegularExpression("^#([0-9a-fA-F]{3}){1,2}$", ErrorMessage = "The color field is invalid, must be a HEX value (e.g. #04a85b).")] + public string? color { get; set; } } public class UpdateProjectResourceInput @@ -39,5 +44,8 @@ public class UpdateProjectResourceInput public string? description { get; set; } + [RegularExpression("^#([0-9a-fA-F]{3}){1,2}$", ErrorMessage = "The color field is invalid, must be a HEX value (e.g. #04a85b).")] + public string? color { get; set; } + public bool? isArchived { get; set; } } diff --git a/service/src/Quarter.HttpApi/Services/ApiService.cs b/service/src/Quarter.HttpApi/Services/ApiService.cs index 18371d85..25a3ec30 100644 --- a/service/src/Quarter.HttpApi/Services/ApiService.cs +++ b/service/src/Quarter.HttpApi/Services/ApiService.cs @@ -43,7 +43,8 @@ public IAsyncEnumerable ProjectsForUserAsync(OperationCon public async Task CreateProjectAsync(CreateProjectResourceInput input, OperationContext oc, CancellationToken ct) { - var project = new Project(input.name!, input.description!); + var color = input.color is not null ? Color.FromHexString(input.color) : Project.DefaultColor; + var project = new Project(input.name!, input.description!, color); project = await repositoryFactory.ProjectRepository(oc.UserId).CreateAsync(project, ct); return ProjectResourceOutput.From(project); } @@ -54,6 +55,7 @@ public async Task UpdateProjectAsync(IdOf projec { if (input.name is not null) existing.Name = input.name; if (input.description is not null) existing.Description = input.description; + if (input.color is not null) existing.Color = Color.FromHexString(input.color); if (input.isArchived is {} isArchived) existing.IsArchived = isArchived; return existing; }, ct); diff --git a/service/test/unit/Quarter.Core.UnitTest/Commands/AddProjectCommandTest.cs b/service/test/unit/Quarter.Core.UnitTest/Commands/AddProjectCommandTest.cs index e441997d..831c8a2e 100644 --- a/service/test/unit/Quarter.Core.UnitTest/Commands/AddProjectCommandTest.cs +++ b/service/test/unit/Quarter.Core.UnitTest/Commands/AddProjectCommandTest.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using NUnit.Framework; using Quarter.Core.Commands; +using Quarter.Core.Utils; namespace Quarter.Core.UnitTest.Commands; @@ -14,7 +15,7 @@ public class WhenAddingProject : AddProjectCommandTest [OneTimeSetUp] public async Task AddingProject() { - var command = new AddProjectCommand("Sample project", "Something"); + var command = new AddProjectCommand("Sample project", "Something", Color.FromHexString("#457b9d")); await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None); } @@ -28,5 +29,15 @@ public async Task ItShouldHaveAddedTheProject() Assert.That(projects, Is.EquivalentTo(new[] { "Sample project" })); } + + [Test] + public async Task ItShouldHavePersistedTheColor() + { + var projects = await RepositoryFactory.ProjectRepository(ActingUser) + .GetAllAsync(CancellationToken.None) + .ToListAsync(); + + Assert.That(projects.Single().Color, Is.EqualTo(Color.FromHexString("#457b9d"))); + } } } diff --git a/service/test/unit/Quarter.Core.UnitTest/Commands/ArchiveProjectCommandTest.cs b/service/test/unit/Quarter.Core.UnitTest/Commands/ArchiveProjectCommandTest.cs index 2759c108..68aab59c 100644 --- a/service/test/unit/Quarter.Core.UnitTest/Commands/ArchiveProjectCommandTest.cs +++ b/service/test/unit/Quarter.Core.UnitTest/Commands/ArchiveProjectCommandTest.cs @@ -32,7 +32,7 @@ public class WhenProjectExist : RemoveProjectCommandTest public async Task AddingInitialProject() { _projectRepository = RepositoryFactory.ProjectRepository(ActingUser); - _initialProject = await _projectRepository.CreateAsync(new Project("a", "a"), CancellationToken.None); + _initialProject = await _projectRepository.CreateAsync(new Project("a", "a", Color.FromHexString("#457b9d")), CancellationToken.None); var command = new ArchiveProjectCommand(_initialProject.Id); await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None); diff --git a/service/test/unit/Quarter.Core.UnitTest/Commands/CommandTestBase.cs b/service/test/unit/Quarter.Core.UnitTest/Commands/CommandTestBase.cs index 20c731c0..fc36ae5e 100644 --- a/service/test/unit/Quarter.Core.UnitTest/Commands/CommandTestBase.cs +++ b/service/test/unit/Quarter.Core.UnitTest/Commands/CommandTestBase.cs @@ -33,7 +33,7 @@ protected OperationContext OperationContext() protected Task CreateProjectAsync(string name) { var repo = RepositoryFactory.ProjectRepository(ActingUser); - return repo.CreateAsync(new Project(name, $"Description for {name}"), CancellationToken.None); + return repo.CreateAsync(new Project(name, $"Description for {name}", Color.FromHexString("#457b9d")), CancellationToken.None); } protected Task CreateActivityAsync(IdOf projectId, string name) diff --git a/service/test/unit/Quarter.Core.UnitTest/Commands/EditProjectCommandTest.cs b/service/test/unit/Quarter.Core.UnitTest/Commands/EditProjectCommandTest.cs index 3ce3b8e8..e328d2cf 100644 --- a/service/test/unit/Quarter.Core.UnitTest/Commands/EditProjectCommandTest.cs +++ b/service/test/unit/Quarter.Core.UnitTest/Commands/EditProjectCommandTest.cs @@ -5,6 +5,7 @@ using Quarter.Core.Commands; using Quarter.Core.Exceptions; using Quarter.Core.Models; +using Quarter.Core.Utils; namespace Quarter.Core.UnitTest.Commands; @@ -16,7 +17,7 @@ public class WhenProjectDoesNotExist : EditProjectCommandTest [Test] public void ItShouldFail() { - var command = new EditProjectCommand(IdOf.Random(), null, null); + var command = new EditProjectCommand(IdOf.Random(), null, null, null); Assert.ThrowsAsync(() => Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None)); } } @@ -42,9 +43,9 @@ public static IEnumerable EditTests() public async Task ItShouldHaveFieldValue(string name, string description, string expectedName, string expectedDescription) { var projectRepository = RepositoryFactory.ProjectRepository(ActingUser); - var initialProject = await projectRepository.CreateAsync(new Project("Initial name", "Initial description"), + var initialProject = await projectRepository.CreateAsync(new Project("Initial name", "Initial description", Color.FromHexString("#457b9d")), CancellationToken.None); - var command = new EditProjectCommand(initialProject.Id, name, description); + var command = new EditProjectCommand(initialProject.Id, name, description, null); await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None); var readProject = await projectRepository.GetByIdAsync(initialProject.Id, CancellationToken.None); @@ -56,4 +57,37 @@ public async Task ItShouldHaveFieldValue(string name, string description, string }); } } + + public class WhenEditingProjectColor : EditProjectCommandTest + { + [Test] + public async Task ItShouldUpdateColorWhenProvided() + { + var projectRepository = RepositoryFactory.ProjectRepository(ActingUser); + var initialProject = await projectRepository.CreateAsync( + new Project("Name", "Desc", Color.FromHexString("#457b9d")), CancellationToken.None); + + var newColor = Color.FromHexString("#e63946"); + var command = new EditProjectCommand(initialProject.Id, null, null, newColor); + await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None); + + var readProject = await projectRepository.GetByIdAsync(initialProject.Id, CancellationToken.None); + Assert.That(readProject.Color, Is.EqualTo(newColor)); + } + + [Test] + public async Task ItShouldNotChangeColorWhenNull() + { + var projectRepository = RepositoryFactory.ProjectRepository(ActingUser); + var originalColor = Color.FromHexString("#457b9d"); + var initialProject = await projectRepository.CreateAsync( + new Project("Name", "Desc", originalColor), CancellationToken.None); + + var command = new EditProjectCommand(initialProject.Id, "New name", null, null); + await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None); + + var readProject = await projectRepository.GetByIdAsync(initialProject.Id, CancellationToken.None); + Assert.That(readProject.Color, Is.EqualTo(originalColor)); + } + } } diff --git a/service/test/unit/Quarter.Core.UnitTest/Commands/RemoveProjectCommandTest.cs b/service/test/unit/Quarter.Core.UnitTest/Commands/RemoveProjectCommandTest.cs index b40875c2..fceda881 100644 --- a/service/test/unit/Quarter.Core.UnitTest/Commands/RemoveProjectCommandTest.cs +++ b/service/test/unit/Quarter.Core.UnitTest/Commands/RemoveProjectCommandTest.cs @@ -32,7 +32,7 @@ public class WhenProjectExist : RemoveProjectCommandTest public async Task AddingInitialProject() { _projectRepository = RepositoryFactory.ProjectRepository(ActingUser); - _initialProject = await _projectRepository.CreateAsync(new Project("a", "a"), CancellationToken.None); + _initialProject = await _projectRepository.CreateAsync(new Project("a", "a", Color.FromHexString("#457b9d")), CancellationToken.None); var command = new RemoveProjectCommand(_initialProject.Id); await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None); diff --git a/service/test/unit/Quarter.Core.UnitTest/Commands/RestoreProjectCommandTest.cs b/service/test/unit/Quarter.Core.UnitTest/Commands/RestoreProjectCommandTest.cs index 86c55469..d9b0075e 100644 --- a/service/test/unit/Quarter.Core.UnitTest/Commands/RestoreProjectCommandTest.cs +++ b/service/test/unit/Quarter.Core.UnitTest/Commands/RestoreProjectCommandTest.cs @@ -5,6 +5,7 @@ using Quarter.Core.Exceptions; using Quarter.Core.Models; using Quarter.Core.Repositories; +using Quarter.Core.Utils; namespace Quarter.Core.UnitTest.Commands; @@ -30,7 +31,7 @@ public class WhenProjectExist : RemoveProjectCommandTest public async Task AddingInitialProject() { _projectRepository = RepositoryFactory.ProjectRepository(ActingUser); - var project = new Project("a", "a"); + var project = new Project("a", "a", Color.FromHexString("#457b9d")); project.Archive(); _initialProject = await _projectRepository.CreateAsync(project, CancellationToken.None); var command = new RestoreProjectCommand(_initialProject.Id); diff --git a/service/test/unit/Quarter.Core.UnitTest/Models/ProjectTest.cs b/service/test/unit/Quarter.Core.UnitTest/Models/ProjectTest.cs index 6d273bdd..3c6bc4f6 100644 --- a/service/test/unit/Quarter.Core.UnitTest/Models/ProjectTest.cs +++ b/service/test/unit/Quarter.Core.UnitTest/Models/ProjectTest.cs @@ -1,5 +1,6 @@ using NUnit.Framework; using Quarter.Core.Models; +using Quarter.Core.Utils; namespace Quarter.Core.UnitTest.Models { @@ -8,7 +9,7 @@ public class ProjectTest { public class WhenConstructed { - private readonly Project _project = new Project("Alpha", "Alpha:Description"); + private readonly Project _project = new Project("Alpha", "Alpha:Description", Color.FromHexString("#457b9d")); [Test] public void ItShouldHaveName() @@ -18,6 +19,10 @@ public void ItShouldHaveName() public void ItShouldHaveDescription() => Assert.That(_project.Description, Is.EqualTo("Alpha:Description")); + [Test] + public void ItShouldHaveColor() + => Assert.That(_project.Color, Is.EqualTo(Color.FromHexString("#457b9d"))); + [Test] public void ItShouldGetAnIdAssigned() => Assert.That(_project.Id, Is.Not.Null); @@ -37,7 +42,7 @@ public void ItShouldNotBeArchived() public class WhenArchived { - private readonly Project _project = new Project("Alpha", "Alpha:Description"); + private readonly Project _project = new Project("Alpha", "Alpha:Description", Color.FromHexString("#457b9d")); [OneTimeSetUp] public void WhenArchivedSetUp() @@ -52,7 +57,7 @@ public void ItShouldBeArchived() public class WhenRestored { - private readonly Project _project = new Project("Alpha", "Alpha:Description"); + private readonly Project _project = new Project("Alpha", "Alpha:Description", Color.FromHexString("#457b9d")); [OneTimeSetUp] public void WhenRestoredSetUp() diff --git a/service/test/unit/Quarter.Core.UnitTest/Repositories/ProjectRepositoryTest.cs b/service/test/unit/Quarter.Core.UnitTest/Repositories/ProjectRepositoryTest.cs index 78d85398..dbe10dc4 100644 --- a/service/test/unit/Quarter.Core.UnitTest/Repositories/ProjectRepositoryTest.cs +++ b/service/test/unit/Quarter.Core.UnitTest/Repositories/ProjectRepositoryTest.cs @@ -6,6 +6,7 @@ using Quarter.Core.Models; using Quarter.Core.Repositories; using Quarter.Core.UnitTest.TestUtils; +using Quarter.Core.Utils; namespace Quarter.Core.UnitTest.Repositories { @@ -17,7 +18,7 @@ protected override IdOf ArbitraryId() protected override Project ArbitraryAggregate() { var id = Guid.NewGuid(); - return new Project($"Name {id}", $"Description {id}"); + return new Project($"Name {id}", $"Description {id}", Color.FromHexString("#457b9d")); } protected override Project WithoutTimestamps(Project aggregate) diff --git a/service/test/unit/Quarter.Core.UnitTest/Repositories/RepositoryFactoryTest.cs b/service/test/unit/Quarter.Core.UnitTest/Repositories/RepositoryFactoryTest.cs index 9e352a21..e392f494 100644 --- a/service/test/unit/Quarter.Core.UnitTest/Repositories/RepositoryFactoryTest.cs +++ b/service/test/unit/Quarter.Core.UnitTest/Repositories/RepositoryFactoryTest.cs @@ -159,7 +159,7 @@ public async Task GetByDateShouldReturnTimesheetForUserOnly() } private static Project ArbitraryProject() - => new Project("Arbitrary", "Arbitrary"); + => new Project("Arbitrary", "Arbitrary", Color.FromHexString("#457b9d")); private static Activity ArbitraryActivity() => new Activity(IdOf.Random(), "Arbitrary", "Arbitrary", Color.FromHexString("#000")); diff --git a/service/test/unit/Quarter.HttpApi.UnitTest/Resources/CreateProjectResourceInputTest.cs b/service/test/unit/Quarter.HttpApi.UnitTest/Resources/CreateProjectResourceInputTest.cs index aaa1e1e1..9b2cd04f 100644 --- a/service/test/unit/Quarter.HttpApi.UnitTest/Resources/CreateProjectResourceInputTest.cs +++ b/service/test/unit/Quarter.HttpApi.UnitTest/Resources/CreateProjectResourceInputTest.cs @@ -14,6 +14,7 @@ public class WithValidInput { public static IEnumerable ValidResources() { + yield return [new CreateProjectResourceInput { name = "OK", description = "OK", color = "#457b9d" }]; yield return [new CreateProjectResourceInput { name = "OK", description = "OK" }]; } @@ -36,8 +37,9 @@ public class WithInvalidInput { public static IEnumerable InvalidResources() { - yield return [new CreateProjectResourceInput { name = null! }, "The name field is required."]; - yield return [new CreateProjectResourceInput { name = "" }, "The name field is required."]; + yield return [new CreateProjectResourceInput { name = null!, color = "#457b9d" }, "The name field is required."]; + yield return [new CreateProjectResourceInput { name = "", color = "#457b9d" }, "The name field is required."]; + yield return [new CreateProjectResourceInput { name = "OK", color = "invalid" }, "The color field is invalid, must be a HEX value (e.g. #04a85b)."]; } [TestCaseSource(nameof(InvalidResources))] diff --git a/service/test/unit/Quarter.HttpApi.UnitTest/Resources/ProjectResourceOutputTest.cs b/service/test/unit/Quarter.HttpApi.UnitTest/Resources/ProjectResourceOutputTest.cs index 0c5588a4..2bede2e1 100644 --- a/service/test/unit/Quarter.HttpApi.UnitTest/Resources/ProjectResourceOutputTest.cs +++ b/service/test/unit/Quarter.HttpApi.UnitTest/Resources/ProjectResourceOutputTest.cs @@ -2,6 +2,7 @@ using Quarter.Core.Models; using Quarter.Core.Utils; using Quarter.HttpApi.Resources; +using Color = Quarter.Core.Utils.Color; namespace Quarter.HttpApi.UnitTest.Resources; @@ -17,7 +18,7 @@ public class WhenConstructingMinimalProjectOutput [OneTimeSetUp] public void Setup() { - _project = new Project("Project name", "Project description"); + _project = new Project("Project name", "Project description", Color.FromHexString("#457b9d")); _output = ProjectResourceOutput.From(_project); } @@ -33,6 +34,10 @@ public void ItShouldMapName() public void ItShouldMapDescription() => Assert.That(_output?.description, Is.EqualTo("Project description")); + [Test] + public void ItShouldMapColor() + => Assert.That(_output?.color, Is.EqualTo("#457B9D")); + [Test] public void ItShouldMapIsArchived() => Assert.That(_output?.isArchived, Is.False); @@ -55,7 +60,7 @@ public class WhenConstructingFullProjectOutput [OneTimeSetUp] public void Setup() { - _project = new Project("Project name", "Project description"); + _project = new Project("Project name", "Project description", Color.FromHexString("#457b9d")); _project.Updated = UtcDateTime.Now(); _project.Archive(); _output = ProjectResourceOutput.From(_project); diff --git a/service/test/unit/Quarter.HttpApi.UnitTest/Resources/UpdateProjectResourceInputTest.cs b/service/test/unit/Quarter.HttpApi.UnitTest/Resources/UpdateProjectResourceInputTest.cs index b21c7362..737a0d2d 100644 --- a/service/test/unit/Quarter.HttpApi.UnitTest/Resources/UpdateProjectResourceInputTest.cs +++ b/service/test/unit/Quarter.HttpApi.UnitTest/Resources/UpdateProjectResourceInputTest.cs @@ -16,6 +16,8 @@ public static IEnumerable ValidResources() { yield return [new UpdateProjectResourceInput { name = "OK", description = "OK" }]; yield return [new UpdateProjectResourceInput { name = "OK", isArchived = true}]; + yield return [new UpdateProjectResourceInput { name = "OK", color = "#457b9d" }]; + yield return [new UpdateProjectResourceInput { name = "OK", color = "#FFF" }]; } [TestCaseSource(nameof(ValidResources))] @@ -31,4 +33,27 @@ public void ItShouldBeValid(UpdateProjectResourceInput input) }); } } + + [TestFixture] + public class WithInvalidInput + { + public static IEnumerable InvalidResources() + { + yield return [new UpdateProjectResourceInput { name = "OK", color = "invalid" }, "The color field is invalid, must be a HEX value (e.g. #04a85b)."]; + yield return [new UpdateProjectResourceInput { name = "OK", color = "457b9d" }, "The color field is invalid, must be a HEX value (e.g. #04a85b)."]; + } + + [TestCaseSource(nameof(InvalidResources))] + public void ItShouldNotBeValid(UpdateProjectResourceInput input, string expectedError) + { + var result = ObjectValidator.IsValid(input, out var errors); + var errorMessages = errors.Select(_ => _.ErrorMessage); + + Assert.Multiple(() => + { + Assert.That(result, Is.False); + Assert.That(errorMessages, Does.Contain(expectedError)); + }); + } + } } diff --git a/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/CreateProjectTest.cs b/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/CreateProjectTest.cs index 413cec85..114c3ae8 100644 --- a/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/CreateProjectTest.cs +++ b/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/CreateProjectTest.cs @@ -22,7 +22,8 @@ public async Task Setup() var input = new CreateProjectResourceInput { name = "Project alpha", - description = "Description alpha" + description = "Description alpha", + color = "#457b9d" }; _output = await ApiService.CreateProjectAsync(input, _oc, CancellationToken.None); } @@ -31,6 +32,10 @@ public async Task Setup() public void ItShouldReturnOutputResourceForProject() => Assert.That(_output?.name, Is.EqualTo("Project alpha")); + [Test] + public void ItShouldReturnOutputResourceWithColor() + => Assert.That(_output?.color, Is.EqualTo("#457B9D")); + [Test] public async Task ItShouldHaveCreatedProject() { @@ -38,5 +43,12 @@ public async Task ItShouldHaveCreatedProject() var projectNames = projects.Select(p => p.Name); Assert.That(projectNames, Is.EqualTo(new[] { "Project alpha" })); } + + [Test] + public async Task ItShouldHavePersistedProjectColor() + { + var projects = await ReadProjectsAsync(_oc.UserId); + Assert.That(projects.Single().Color, Is.EqualTo(Color.FromHexString("#457b9d"))); + } } } diff --git a/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/TestCase.cs b/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/TestCase.cs index 18a79bb4..6e74c7d2 100644 --- a/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/TestCase.cs +++ b/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/TestCase.cs @@ -33,7 +33,7 @@ protected Task AddUser(string email, Action? configure = null) } protected Task AddProject(IdOf userId, string name) { - var project = new Project(name, $"description:{name}"); + var project = new Project(name, $"description:{name}", Color.FromHexString("#457b9d")); return _repositoryFactory.ProjectRepository(userId).CreateAsync(project, CancellationToken.None); } diff --git a/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/UpdateProjectTest.cs b/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/UpdateProjectTest.cs index 3e80bbf2..96cd857f 100644 --- a/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/UpdateProjectTest.cs +++ b/service/test/unit/Quarter.HttpApi.UnitTest/Services/ApiService/UpdateProjectTest.cs @@ -42,12 +42,70 @@ public async Task ItShouldHaveUpdatedProject() } [TestFixture] - public class WhenArchiving : TestCase + public class WhenUpdatingColor : TestCase { private readonly OperationContext _oc = CreateOperationContext(); + private ProjectResourceOutput? _output; + + [OneTimeSetUp] + public async Task Setup() + { + var project = await AddProject(_oc.UserId, "Project Beta"); + var input = new UpdateProjectResourceInput + { + color = "#e63946" + }; + _output = await ApiService.UpdateProjectAsync(project.Id, input, _oc, CancellationToken.None); + } + [Test] + public void ItShouldReturnUpdatedColor() + => Assert.That(_output?.color, Is.EqualTo("#E63946")); + + [Test] + public async Task ItShouldHavePersistedUpdatedColor() + { + var projects = await ReadProjectsAsync(_oc.UserId); + var project = projects.Single(); + Assert.That(project.Color, Is.EqualTo(Color.FromHexString("#e63946"))); + } + } + + [TestFixture] + public class WhenColorIsOmitted : TestCase + { + private readonly OperationContext _oc = CreateOperationContext(); private ProjectResourceOutput? _output; + [OneTimeSetUp] + public async Task Setup() + { + var project = await AddProject(_oc.UserId, "Project Gamma"); + var input = new UpdateProjectResourceInput + { + name = "Project Gamma Updated" + }; + _output = await ApiService.UpdateProjectAsync(project.Id, input, _oc, CancellationToken.None); + } + + [Test] + public void ItShouldReturnOriginalColor() + => Assert.That(_output?.color, Is.EqualTo("#457B9D")); + + [Test] + public async Task ItShouldNotHaveChangedColor() + { + var projects = await ReadProjectsAsync(_oc.UserId); + var project = projects.Single(); + Assert.That(project.Color, Is.EqualTo(Color.FromHexString("#457b9d"))); + } + } + + [TestFixture] + public class WhenArchiving : TestCase + { + private readonly OperationContext _oc = CreateOperationContext(); + [OneTimeSetUp] public async Task Setup() { diff --git a/service/test/unit/Quarter.UnitTest/HttpApi/CreateProjectTest.cs b/service/test/unit/Quarter.UnitTest/HttpApi/CreateProjectTest.cs index 6953b0fa..0d5b9d45 100644 --- a/service/test/unit/Quarter.UnitTest/HttpApi/CreateProjectTest.cs +++ b/service/test/unit/Quarter.UnitTest/HttpApi/CreateProjectTest.cs @@ -23,6 +23,7 @@ public async Task SetUp() { name = "Test name", description = "Test description", + color = "#457b9d", }; _response = await PostAsync("/api/projects/", payload); } @@ -80,6 +81,7 @@ public async Task SetUp() { name = "Test name", description = "Test description", + color = "#457b9d", }; _response = await PostAsync("/api/projects/", payload); } diff --git a/service/test/unit/Quarter.UnitTest/TestUtils/HttpTestCase.cs b/service/test/unit/Quarter.UnitTest/TestUtils/HttpTestCase.cs index 633ede17..88013593 100644 --- a/service/test/unit/Quarter.UnitTest/TestUtils/HttpTestCase.cs +++ b/service/test/unit/Quarter.UnitTest/TestUtils/HttpTestCase.cs @@ -61,7 +61,7 @@ protected Task DeleteAsync(string? requestUri) protected Task AddProjectAsync(IdOf userId, string name) { var repoFactory = HttpTestSession.ResolveService(); - var project = new Project(name, $"description:{name}"); + var project = new Project(name, $"description:{name}", Color.FromHexString("#457b9d")); return repoFactory.ProjectRepository(userId).CreateAsync(project, CancellationToken.None); } diff --git a/webapp/src/dialogs/activity_dialog.gleam b/webapp/src/dialogs/activity_dialog.gleam index 5847760b..20b225c9 100644 --- a/webapp/src/dialogs/activity_dialog.gleam +++ b/webapp/src/dialogs/activity_dialog.gleam @@ -1,6 +1,7 @@ import domain/color import domain/input_value.{type InputValue} import domain/project +import gleam/list import gleam/string import types.{type FormValue} @@ -13,12 +14,25 @@ pub type State { ) } -/// Create the dialog state for creating a new activity. I.e. an empty state. -pub fn new() -> State { +/// Create the dialog state for creating a new activity. +/// The first activity in a project gets the project's color. +/// Each subsequent activity darkens the last activity's color by 10%. +pub fn new(project: project.Project) -> State { + let activity_color = case + project.activities + |> project.sort_activities() + |> list.last() + { + // Darken the last activity's color by 10% + Ok(last_activity) -> color.darken_by(last_activity.color, 0.1) + // No activities yet, use the project's color + Error(_) -> project.color + } + State( input_value.ValidValue(""), input_value.ValidValue(""), - input_value.ValidValue(color.Color(142, 135, 245)), + input_value.ValidValue(activity_color), False, ) } diff --git a/webapp/src/dialogs/project_dialog.gleam b/webapp/src/dialogs/project_dialog.gleam index d45542a2..f38b167a 100644 --- a/webapp/src/dialogs/project_dialog.gleam +++ b/webapp/src/dialogs/project_dialog.gleam @@ -1,3 +1,4 @@ +import domain/color import domain/input_value.{type InputValue} import domain/project import gleam/string @@ -7,13 +8,20 @@ pub type State { State( name: InputValue(String), description: InputValue(String), + color: InputValue(color.Color), is_valid: Bool, ) } /// Create the dialog state for creating a new project. I.e. an empty state. +/// The color is set to a random major color. pub fn new() -> State { - State(input_value.ValidValue(""), input_value.ValidValue(""), False) + State( + input_value.ValidValue(""), + input_value.ValidValue(""), + input_value.ValidValue(color.random_major_color()), + False, + ) } /// Create the dialog state for editing a project. @@ -21,6 +29,7 @@ pub fn edit(project: project.Project) -> State { State( input_value.ValidValue(project.name), input_value.ValidValue(project.description), + input_value.ValidValue(project.color), False, ) } @@ -31,6 +40,20 @@ pub fn update(state: State, value: FormValue) -> State { validate(State(..state, name: input_value.ValidValue(value.value))) "description" -> validate(State(..state, description: input_value.ValidValue(value.value))) + "color" -> { + case color.from_hex(value.value) { + Ok(c) -> validate(State(..state, color: input_value.ValidValue(c))) + Error(_) -> + validate( + State( + ..state, + color: input_value.InvalidValue(color.Color(0, 0, 0), [ + "Invalid color", + ]), + ), + ) + } + } _ -> state } } @@ -46,5 +69,10 @@ pub fn validate(state: State) -> State { _ -> False } - State(..state, is_valid: name_valid && description_valid) + let color_valid = case state.color { + input_value.ValidValue(_) -> True + _ -> False + } + + State(..state, is_valid: name_valid && description_valid && color_valid) } diff --git a/webapp/src/domain/color.gleam b/webapp/src/domain/color.gleam index e7d068f4..79ba7bf4 100644 --- a/webapp/src/domain/color.gleam +++ b/webapp/src/domain/color.gleam @@ -1,5 +1,6 @@ import gleam/float import gleam/int +import gleam/list import gleam/string /// Represents a RGBA color @@ -10,15 +11,20 @@ pub type Color { } pub fn darken(color: Color) -> Color { - // Math.round(Math.min(Math.max(0, c + (c * lum)), 255)) - let lum = -0.3 + darken_by(color, 0.3) +} + +/// Darken the color by the given percentage (0.0 to 1.0). +/// E.g. darken_by(color, 0.1) darkens by 10%. +pub fn darken_by(color: Color, percentage: Float) -> Color { + let amount = 1.0 -. percentage let r_float = int.to_float(color.r) let g_float = int.to_float(color.g) let b_float = int.to_float(color.b) - let rr = float.add(r_float, float.multiply(r_float, lum)) - let gg = float.add(g_float, float.multiply(g_float, lum)) - let bb = float.add(b_float, float.multiply(b_float, lum)) + let rr = float.multiply(r_float, amount) + let gg = float.multiply(g_float, amount) + let bb = float.multiply(b_float, amount) let r = float.round(float.min(float.max(0.0, rr), 255.0)) let g = float.round(float.min(float.max(0.0, gg), 255.0)) @@ -27,6 +33,53 @@ pub fn darken(color: Color) -> Color { Color(r, g, b) } +/// A curated palette of visually distinct major colors. +/// These are chosen to be clearly distinguishable from each other. +const major_colors = [ + Color(230, 57, 70), + // Red + Color(244, 162, 97), + // Orange + Color(233, 196, 106), + // Yellow + Color(42, 157, 143), + // Teal + Color(38, 70, 83), + // Dark Blue + Color(69, 123, 157), + // Steel Blue + Color(142, 68, 173), + // Purple + Color(39, 174, 96), + // Green + Color(231, 76, 60), + // Vermilion + Color(52, 152, 219), + // Sky Blue + Color(211, 84, 0), + // Burnt Orange + Color(22, 160, 133), + // Dark Cyan + Color(192, 57, 43), + // Dark Red + Color(41, 128, 185), + // Ocean Blue + Color(142, 135, 245), + // Lavender + Color(230, 126, 34), + // Carrot Orange +] + +/// Returns a random color from a curated palette of visually distinct major colors. +pub fn random_major_color() -> Color { + let index = int.random(list.length(major_colors)) + case list.drop(major_colors, index) { + [color, ..] -> color + // Fallback should never happen + [] -> Color(142, 135, 245) + } +} + pub fn color_to_style_value(c: Color) -> String { "rgb(" <> int.to_string(c.r) diff --git a/webapp/src/domain/project.gleam b/webapp/src/domain/project.gleam index 127e713b..4c75a504 100644 --- a/webapp/src/domain/project.gleam +++ b/webapp/src/domain/project.gleam @@ -19,6 +19,7 @@ pub type Project { id: ProjectId, name: String, description: String, + color: color.Color, is_archived: Bool, created: Timestamp, updated: option.Option(Timestamp), diff --git a/webapp/src/model.gleam b/webapp/src/model.gleam index 930b85ae..e2c13e48 100644 --- a/webapp/src/model.gleam +++ b/webapp/src/model.gleam @@ -242,7 +242,7 @@ pub fn edit_project_dialog(project: project.Project) { } pub fn new_activity_dialog(project: project.Project) { - AddActivityDialog(activity_dialog.new(), project) + AddActivityDialog(activity_dialog.new(project), project) } pub fn edit_activity_dialog(activity: project.Activity) { diff --git a/webapp/src/protocol.gleam b/webapp/src/protocol.gleam index 28377817..171eaefa 100644 --- a/webapp/src/protocol.gleam +++ b/webapp/src/protocol.gleam @@ -60,6 +60,7 @@ pub fn add_user( pub fn create_project( name: String, description: String, + color_value: color.Color, on_response handle_response: fn(Result(project.Project, rsvp.Error)) -> message.Msg, ) -> Effect(message.Msg) { @@ -69,6 +70,7 @@ pub fn create_project( json.object([ #("name", json.string(name)), #("description", json.string(description)), + #("color", json.string(color.to_hex(color_value))), ]) rsvp.post(url, payload, handler) @@ -78,6 +80,7 @@ pub fn update_project( project: project.Project, name: String, description: String, + color_value: color.Color, on_response handle_response: fn(Result(project.Project, rsvp.Error)) -> message.Msg, ) -> Effect(message.Msg) { @@ -86,6 +89,7 @@ pub fn update_project( json.object([ #("name", json.string(name)), #("description", json.string(description)), + #("color", json.string(color.to_hex(color_value))), ]) rsvp.patch(project_url(project), payload, handler) @@ -361,6 +365,7 @@ pub fn project_decoder() -> decode.Decoder(project.Project) { use id <- decode.field("id", decode.string) use name <- decode.field("name", decode.string) use description <- decode.field("description", decode.string) + use color_field <- decode.field("color", decode_color()) use is_archived <- decode.field("isArchived", decode.bool) use created <- decode.field("created", decode_timestamp()) use updated <- decode.optional_field( @@ -374,6 +379,7 @@ pub fn project_decoder() -> decode.Decoder(project.Project) { project.ProjectId(id), name, description, + color_field, is_archived, created, updated, diff --git a/webapp/src/views/manage_projects.gleam b/webapp/src/views/manage_projects.gleam index 60b791b4..d2f96a79 100644 --- a/webapp/src/views/manage_projects.gleam +++ b/webapp/src/views/manage_projects.gleam @@ -243,6 +243,12 @@ fn project_form( option.None -> "EditProjectDialog" } + let color_value = case state.color { + input_value.ValidValue(c) -> color.to_hex(c) + input_value.InvalidValue(c, _) -> color.to_hex(c) + input_value.UnvalidatedValue(c) -> color.to_hex(c) + } + form.Form( id, [ @@ -254,6 +260,7 @@ fn project_form( True, False, ), + form.ColorInput("color", "Color", color_value, True, False), ], [ form.Cancel, diff --git a/webapp/src/webapp.gleam b/webapp/src/webapp.gleam index 0a86577c..679e4dc6 100644 --- a/webapp/src/webapp.gleam +++ b/webapp/src/webapp.gleam @@ -450,6 +450,7 @@ fn handle_dialog_confirm(m: model.Model) { protocol.create_project( state.name.value, state.description.value, + state.color.value, message.CreateProjectResult, ) @@ -458,6 +459,7 @@ fn handle_dialog_confirm(m: model.Model) { project, state.name.value, state.description.value, + state.color.value, message.UpdateProjectResult, ) From 759fc286b2d5fdf5cf7817eb18ee3710800a33d3 Mon Sep 17 00:00:00 2001 From: Gustav Persson Date: Thu, 2 Apr 2026 10:43:26 +0200 Subject: [PATCH 2/2] Update the frontend tests --- webapp/test/dialogs/project_dialog_test.gleam | 20 ++++++++++++++++--- .../project_and_activities_decoder_test.gleam | 2 ++ .../test/protocol/project_decoder_test.gleam | 5 +++++ webapp/test/test_util.gleam | 1 + 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/webapp/test/dialogs/project_dialog_test.gleam b/webapp/test/dialogs/project_dialog_test.gleam index f36ba051..e09505e4 100644 --- a/webapp/test/dialogs/project_dialog_test.gleam +++ b/webapp/test/dialogs/project_dialog_test.gleam @@ -1,17 +1,26 @@ import dialogs/project_dialog +import domain/color import domain/input_value.{ValidValue} import gleam/list import gleeunit/should import test_util +const valid_color = ValidValue(color.Color(69, 123, 157)) + pub fn should_be_valid_state_test() { let states = [ project_dialog.State( ValidValue("My Project"), ValidValue("A description"), + valid_color, + False, + ), + project_dialog.State( + ValidValue("My Project"), + ValidValue(""), + valid_color, False, ), - project_dialog.State(ValidValue("My Project"), ValidValue(""), False), ] list.each(states, fn(state) { @@ -22,8 +31,13 @@ pub fn should_be_valid_state_test() { pub fn should_be_invalid_test() { let states = [ - project_dialog.State(ValidValue(""), ValidValue("A description"), True), - project_dialog.State(ValidValue(""), ValidValue(""), True), + project_dialog.State( + ValidValue(""), + ValidValue("A description"), + valid_color, + True, + ), + project_dialog.State(ValidValue(""), ValidValue(""), valid_color, True), ] list.each(states, fn(state) { diff --git a/webapp/test/protocol/project_and_activities_decoder_test.gleam b/webapp/test/protocol/project_and_activities_decoder_test.gleam index 57002b0e..c8be7767 100644 --- a/webapp/test/protocol/project_and_activities_decoder_test.gleam +++ b/webapp/test/protocol/project_and_activities_decoder_test.gleam @@ -26,12 +26,14 @@ pub fn inflates_to_child_less_projects_test() { \"id\": \"P01\", \"name\": \"Project Alpha\", \"description\": \"The alpha project\", + \"color\": \"#457B9D\", \"isArchived\": false, \"created\": \"2025-11-04T16:49:39.2993437Z\" },{ \"id\": \"P02\", \"name\": \"Project Bravo\", \"description\": \"The bravo project\", + \"color\": \"#457B9D\", \"isArchived\": false, \"created\": \"2025-11-04T16:49:39.2993437Z\" }], diff --git a/webapp/test/protocol/project_decoder_test.gleam b/webapp/test/protocol/project_decoder_test.gleam index cdd9b061..e686b5bd 100644 --- a/webapp/test/protocol/project_decoder_test.gleam +++ b/webapp/test/protocol/project_decoder_test.gleam @@ -1,3 +1,4 @@ +import domain/color import domain/project import gleam/json import gleam/option @@ -15,6 +16,7 @@ pub fn decode_minimal_project_test() { \"id\": \"001\", \"name\": \"Project Alpha\", \"description\": \"\", + \"color\": \"#457B9D\", \"isArchived\": false, \"created\": \"2025-11-04T16:49:39.2993437Z\" }" @@ -26,6 +28,7 @@ pub fn decode_minimal_project_test() { project.ProjectId("001"), "Project Alpha", "", + color.Color(69, 123, 157), False, result.unwrap(expected_created, tsutil.timestamp_zero()), option.None, @@ -45,6 +48,7 @@ pub fn decode_full_project_test() { \"id\": \"001\", \"name\": \"Project Alpha\", \"description\": \"The alpha project\", + \"color\": \"#E63946\", \"isArchived\": true, \"created\": \"2025-11-04T16:49:39.2993437Z\", \"updated\": \"2025-11-04T20:00:00.0Z\" @@ -57,6 +61,7 @@ pub fn decode_full_project_test() { project.ProjectId("001"), "Project Alpha", "The alpha project", + color.Color(230, 57, 70), True, result.unwrap(expected_created, tsutil.timestamp_zero()), option.Some(result.unwrap(expected_updated, tsutil.timestamp_zero())), diff --git a/webapp/test/test_util.gleam b/webapp/test/test_util.gleam index 34ff3937..4f3285c5 100644 --- a/webapp/test/test_util.gleam +++ b/webapp/test/test_util.gleam @@ -20,6 +20,7 @@ pub fn arbitrary_project() -> project.Project { project.ProjectId("P01"), "Project Alpha", "The Alpha project", + color.Color(69, 123, 157), False, tsutil.timestamp_zero(), option.None,