From 48a749fde36776f92790a671da40f9eb5202f384 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 13 Aug 2026 16:48:44 -0400 Subject: [PATCH] feat(board): edit task deadlines --- TaskTracker.Api/Pages/Board.cshtml.cs | 7 +- .../Pages/Partials/_CreateTaskForm.cshtml | 6 + .../Pages/Partials/_TaskDetail.cshtml | 26 +++++ TaskTracker.Api/Pages/Shared/TaskDeadline.cs | 12 ++ .../TaskDeadlineConversionTests.cs | 109 ++++++++++++++++++ 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 TaskTracker.Api/Pages/Shared/TaskDeadline.cs create mode 100644 TaskTracker.Tests/TaskDeadlineConversionTests.cs diff --git a/TaskTracker.Api/Pages/Board.cshtml.cs b/TaskTracker.Api/Pages/Board.cshtml.cs index e27a408..ead690e 100644 --- a/TaskTracker.Api/Pages/Board.cshtml.cs +++ b/TaskTracker.Api/Pages/Board.cshtml.cs @@ -151,7 +151,7 @@ public class BoardModel : PageModel return await ReturnBoardContentAsync(); } - public async Task OnPostCreateTaskAsync(string title, string? category) + public async Task OnPostCreateTaskAsync(string title, string? category, DateTime? dueAt) { if (string.IsNullOrWhiteSpace(title)) return BadRequest("Title is required."); @@ -161,6 +161,7 @@ public class BoardModel : PageModel Title = title.Trim(), Category = category, Status = WorkTaskStatus.Pending, + DueAt = TaskDeadline.FromLocalInput(dueAt, TimeZoneInfo.Local), }; await _taskRepo.CreateAsync(task); @@ -176,7 +177,7 @@ public class BoardModel : PageModel } // Update task fields (inline edit) - public async Task OnPutUpdateTaskAsync(int id, string? title, string? description, string? category, int? estimatedMinutes) + public async Task OnPutUpdateTaskAsync(int id, string? title, string? description, string? category, int? estimatedMinutes, DateTime? dueAt, bool clearDueAt) { var task = await _taskRepo.GetByIdAsync(id); if (task is null) return NotFound(); @@ -185,6 +186,8 @@ public class BoardModel : PageModel if (description is not null) task.Description = description; if (category is not null) task.Category = category; if (estimatedMinutes.HasValue) task.EstimatedMinutes = estimatedMinutes; + if (clearDueAt) task.DueAt = null; + else if (dueAt.HasValue) task.DueAt = TaskDeadline.FromLocalInput(dueAt, TimeZoneInfo.Local); await _taskRepo.UpdateAsync(task); return Partial("Partials/_TaskDetail", task); diff --git a/TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml b/TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml index d2600cc..68f2627 100644 --- a/TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml +++ b/TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml @@ -8,4 +8,10 @@ placeholder="New task..." class="input" autocomplete="off" /> + + diff --git a/TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml b/TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml index 6dc41e9..d6c3c8e 100644 --- a/TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml +++ b/TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml @@ -14,6 +14,7 @@ var (statusColor, statusLabel) = statusColors[Model.Status]; var catColor = BoardModel.GetCategoryColor(Model.Category); var elapsed = BoardModel.FormatElapsed(Model.StartedAt, Model.CompletedAt); + var deadlineLocalValue = Model.DueAt?.LocalDateTime.ToString("yyyy-MM-ddTHH:mm"); double? progressPercent = null; if (Model.EstimatedMinutes.HasValue && Model.StartedAt.HasValue) @@ -139,6 +140,31 @@ @elapsed +
+ Deadline +
+ + @(deadlineLocalValue ?? "--") + + +
+
+
Estimate
diff --git a/TaskTracker.Api/Pages/Shared/TaskDeadline.cs b/TaskTracker.Api/Pages/Shared/TaskDeadline.cs new file mode 100644 index 0000000..35e4e83 --- /dev/null +++ b/TaskTracker.Api/Pages/Shared/TaskDeadline.cs @@ -0,0 +1,12 @@ +namespace TaskTracker.Api.Pages; + +public static class TaskDeadline +{ + public static DateTimeOffset? FromLocalInput(DateTime? localValue, TimeZoneInfo timeZone) + { + if (!localValue.HasValue) return null; + + var unspecified = DateTime.SpecifyKind(localValue.Value, DateTimeKind.Unspecified); + return new DateTimeOffset(unspecified, timeZone.GetUtcOffset(unspecified)); + } +} diff --git a/TaskTracker.Tests/TaskDeadlineConversionTests.cs b/TaskTracker.Tests/TaskDeadlineConversionTests.cs new file mode 100644 index 0000000..8965d46 --- /dev/null +++ b/TaskTracker.Tests/TaskDeadlineConversionTests.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.DependencyInjection; +using TaskTracker.Api.Pages; +using TaskTracker.Core.Entities; +using TaskTracker.Core.Enums; +using TaskTracker.Core.Interfaces; +using Xunit; + +namespace TaskTracker.Tests; + +public class TaskDeadlineConversionTests +{ + [Fact] + public void FromLocalInput_uses_the_supplied_time_zone_offset_for_an_unspecified_value() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone("TestZone", TimeSpan.FromHours(-4), "Test", "Test"); + var localValue = new DateTime(2026, 8, 14, 11, 30, 0, DateTimeKind.Utc); + + var dueAt = TaskDeadline.FromLocalInput(localValue, timeZone); + + Assert.Equal(new DateTimeOffset(2026, 8, 14, 11, 30, 0, TimeSpan.FromHours(-4)), dueAt); + } + + [Fact] + public void FromLocalInput_returns_null_for_a_blank_local_deadline() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone("TestZone", TimeSpan.FromHours(2), "Test", "Test"); + + var dueAt = TaskDeadline.FromLocalInput(null, timeZone); + + Assert.Null(dueAt); + } + + [Fact] + public async Task Board_create_assigns_due_at_from_local_deadline() + { + var repository = new InMemoryTaskRepository(); + var board = CreateBoard(repository); + var localDeadline = new DateTime(2026, 8, 14, 11, 30, 0); + + await board.OnPostCreateTaskAsync("deadline", null, localDeadline); + + var created = Assert.Single(repository.Created); + var expected = TaskDeadline.FromLocalInput(localDeadline, TimeZoneInfo.Local); + Assert.Equal(expected, created.DueAt); + } + + [Fact] + public async Task Board_update_explicit_clear_takes_precedence_over_a_supplied_deadline() + { + var existing = new WorkTask + { + Id = 1, + Title = "existing", + Status = WorkTaskStatus.Pending, + DueAt = new DateTimeOffset(2026, 8, 14, 11, 30, 0, TimeSpan.FromHours(-4)), + }; + var repository = new InMemoryTaskRepository(existing); + var board = CreateBoard(repository); + + var result = await board.OnPutUpdateTaskAsync(1, null, null, null, null, new DateTime(2026, 8, 15, 9, 0, 0), true); + + Assert.IsType(result); + Assert.Null(existing.DueAt); + } + + private static BoardModel CreateBoard(ITaskRepository repository) + { + var board = new BoardModel(repository); + var services = new ServiceCollection() + .AddLogging() + .AddMvcCore() + .AddViews() + .Services + .BuildServiceProvider(); + var httpContext = new DefaultHttpContext { RequestServices = services }; + var provider = services.GetRequiredService(); + board.PageContext = new PageContext + { + HttpContext = httpContext, + ViewData = new ViewDataDictionary(provider, new ModelStateDictionary()), + }; + return board; + } + + private sealed class InMemoryTaskRepository(params WorkTask[] tasks) : ITaskRepository + { + private readonly List _tasks = [.. tasks]; + public List Created { get; } = []; + + public Task> GetAllAsync(WorkTaskStatus? status = null, int? parentId = null, bool includeSubTasks = false) => + Task.FromResult(_tasks); + public Task GetByIdAsync(int id) => Task.FromResult(_tasks.SingleOrDefault(task => task.Id == id)); + public Task GetActiveTaskAsync() => Task.FromResult(_tasks.SingleOrDefault(task => task.Status == WorkTaskStatus.Active)); + public Task> GetSubTasksAsync(int parentId) => Task.FromResult(_tasks.Where(task => task.ParentTaskId == parentId).ToList()); + public Task CreateAsync(WorkTask task) + { + Created.Add(task); + _tasks.Add(task); + return Task.FromResult(task); + } + public Task UpdateAsync(WorkTask task) => Task.CompletedTask; + public Task DeleteAsync(int id) => Task.CompletedTask; + } +}