feat(board): edit task deadlines
This commit is contained in:
@@ -151,7 +151,7 @@ public class BoardModel : PageModel
|
|||||||
return await ReturnBoardContentAsync();
|
return await ReturnBoardContentAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostCreateTaskAsync(string title, string? category)
|
public async Task<IActionResult> OnPostCreateTaskAsync(string title, string? category, DateTime? dueAt)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(title))
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
return BadRequest("Title is required.");
|
return BadRequest("Title is required.");
|
||||||
@@ -161,6 +161,7 @@ public class BoardModel : PageModel
|
|||||||
Title = title.Trim(),
|
Title = title.Trim(),
|
||||||
Category = category,
|
Category = category,
|
||||||
Status = WorkTaskStatus.Pending,
|
Status = WorkTaskStatus.Pending,
|
||||||
|
DueAt = TaskDeadline.FromLocalInput(dueAt, TimeZoneInfo.Local),
|
||||||
};
|
};
|
||||||
await _taskRepo.CreateAsync(task);
|
await _taskRepo.CreateAsync(task);
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ public class BoardModel : PageModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update task fields (inline edit)
|
// Update task fields (inline edit)
|
||||||
public async Task<IActionResult> OnPutUpdateTaskAsync(int id, string? title, string? description, string? category, int? estimatedMinutes)
|
public async Task<IActionResult> OnPutUpdateTaskAsync(int id, string? title, string? description, string? category, int? estimatedMinutes, DateTime? dueAt, bool clearDueAt)
|
||||||
{
|
{
|
||||||
var task = await _taskRepo.GetByIdAsync(id);
|
var task = await _taskRepo.GetByIdAsync(id);
|
||||||
if (task is null) return NotFound();
|
if (task is null) return NotFound();
|
||||||
@@ -185,6 +186,8 @@ public class BoardModel : PageModel
|
|||||||
if (description is not null) task.Description = description;
|
if (description is not null) task.Description = description;
|
||||||
if (category is not null) task.Category = category;
|
if (category is not null) task.Category = category;
|
||||||
if (estimatedMinutes.HasValue) task.EstimatedMinutes = estimatedMinutes;
|
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);
|
await _taskRepo.UpdateAsync(task);
|
||||||
return Partial("Partials/_TaskDetail", task);
|
return Partial("Partials/_TaskDetail", task);
|
||||||
|
|||||||
@@ -8,4 +8,10 @@
|
|||||||
placeholder="New task..."
|
placeholder="New task..."
|
||||||
class="input"
|
class="input"
|
||||||
autocomplete="off" />
|
autocomplete="off" />
|
||||||
|
<label for="new-task-deadline" class="sr-only">Deadline</label>
|
||||||
|
<input id="new-task-deadline"
|
||||||
|
type="datetime-local"
|
||||||
|
name="dueAt"
|
||||||
|
class="input"
|
||||||
|
aria-label="Deadline" />
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
var (statusColor, statusLabel) = statusColors[Model.Status];
|
var (statusColor, statusLabel) = statusColors[Model.Status];
|
||||||
var catColor = BoardModel.GetCategoryColor(Model.Category);
|
var catColor = BoardModel.GetCategoryColor(Model.Category);
|
||||||
var elapsed = BoardModel.FormatElapsed(Model.StartedAt, Model.CompletedAt);
|
var elapsed = BoardModel.FormatElapsed(Model.StartedAt, Model.CompletedAt);
|
||||||
|
var deadlineLocalValue = Model.DueAt?.LocalDateTime.ToString("yyyy-MM-ddTHH:mm");
|
||||||
|
|
||||||
double? progressPercent = null;
|
double? progressPercent = null;
|
||||||
if (Model.EstimatedMinutes.HasValue && Model.StartedAt.HasValue)
|
if (Model.EstimatedMinutes.HasValue && Model.StartedAt.HasValue)
|
||||||
@@ -139,6 +140,31 @@
|
|||||||
<span style="color: var(--color-text-primary); font-variant-numeric: tabular-nums;">@elapsed</span>
|
<span style="color: var(--color-text-primary); font-variant-numeric: tabular-nums;">@elapsed</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 13px;">
|
||||||
|
<span style="color: var(--color-text-secondary);">Deadline</span>
|
||||||
|
<div class="inline-edit" id="edit-deadline" style="text-align: right;">
|
||||||
|
<span class="inline-edit-display inline-edit-display--field"
|
||||||
|
onclick="startEdit('deadline')"
|
||||||
|
style="color: var(--color-text-primary); font-variant-numeric: tabular-nums; padding: 2px 8px;">
|
||||||
|
@(deadlineLocalValue ?? "--")
|
||||||
|
</span>
|
||||||
|
<form class="inline-edit-form" style="display:none"
|
||||||
|
hx-put="/board?handler=UpdateTask&id=@Model.Id"
|
||||||
|
hx-target="#detail-panel"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<label for="task-deadline-@Model.Id" class="sr-only">Deadline</label>
|
||||||
|
<input id="task-deadline-@Model.Id" type="datetime-local" name="dueAt"
|
||||||
|
value="@deadlineLocalValue" class="inline-edit-input"
|
||||||
|
aria-label="Deadline" />
|
||||||
|
<div style="display: flex; gap: 6px; margin-top: 6px;">
|
||||||
|
<button type="submit" class="btn btn--sm btn--primary">Save</button>
|
||||||
|
<button type="button" class="btn btn--sm btn--ghost" onclick="cancelEdit('deadline')">Cancel</button>
|
||||||
|
<button type="submit" name="clearDueAt" value="true" class="btn btn--sm btn--ghost">Clear deadline</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 13px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 13px;">
|
||||||
<span style="color: var(--color-text-secondary);">Estimate</span>
|
<span style="color: var(--color-text-secondary);">Estimate</span>
|
||||||
<div class="inline-edit" id="edit-estimate" style="text-align: right;">
|
<div class="inline-edit" id="edit-estimate" style="text-align: right;">
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PartialViewResult>(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<IModelMetadataProvider>();
|
||||||
|
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<WorkTask> _tasks = [.. tasks];
|
||||||
|
public List<WorkTask> Created { get; } = [];
|
||||||
|
|
||||||
|
public Task<List<WorkTask>> GetAllAsync(WorkTaskStatus? status = null, int? parentId = null, bool includeSubTasks = false) =>
|
||||||
|
Task.FromResult(_tasks);
|
||||||
|
public Task<WorkTask?> GetByIdAsync(int id) => Task.FromResult(_tasks.SingleOrDefault(task => task.Id == id));
|
||||||
|
public Task<WorkTask?> GetActiveTaskAsync() => Task.FromResult(_tasks.SingleOrDefault(task => task.Status == WorkTaskStatus.Active));
|
||||||
|
public Task<List<WorkTask>> GetSubTasksAsync(int parentId) => Task.FromResult(_tasks.Where(task => task.ParentTaskId == parentId).ToList());
|
||||||
|
public Task<WorkTask> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user