Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04816ace0f | ||
|
|
ea8e3a4b0b | ||
|
|
4baa7f1852 | ||
|
|
ee9bf9f2c1 | ||
|
|
94f85731ef | ||
|
|
48a749fde3 | ||
|
|
5c90a6dd76 | ||
|
|
cc63c456bf |
@@ -9,3 +9,4 @@ node_modules/
|
||||
*.db-wal
|
||||
dist/
|
||||
.playwright-mcp/
|
||||
.hermes/
|
||||
|
||||
@@ -52,6 +52,7 @@ public class TasksController(ITaskRepository taskRepo, ILogger<TasksController>
|
||||
Category = request.Category,
|
||||
ParentTaskId = request.ParentTaskId,
|
||||
EstimatedMinutes = request.EstimatedMinutes,
|
||||
DueAt = request.DueAt,
|
||||
Status = WorkTaskStatus.Pending
|
||||
};
|
||||
|
||||
@@ -173,6 +174,8 @@ public class TasksController(ITaskRepository taskRepo, ILogger<TasksController>
|
||||
if (request.Description is not null) task.Description = request.Description;
|
||||
if (request.Category is not null) task.Category = request.Category;
|
||||
if (request.EstimatedMinutes.HasValue) task.EstimatedMinutes = request.EstimatedMinutes;
|
||||
if (request.ClearDueAt) task.DueAt = null;
|
||||
else if (request.DueAt.HasValue) task.DueAt = request.DueAt;
|
||||
if (request.ParentTaskId.HasValue)
|
||||
{
|
||||
var parent = await taskRepo.GetByIdAsync(request.ParentTaskId.Value);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
<div id="board-content" class="board-page">
|
||||
<partial name="Partials/_FilterBar" model="Model" />
|
||||
<partial name="Partials/_UpcomingTasks" model="Model" />
|
||||
<div id="kanban-board" class="kanban-grid">
|
||||
@foreach (var col in Model.Columns)
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ public class BoardModel : PageModel
|
||||
}
|
||||
|
||||
public List<ColumnViewModel> Columns { get; set; } = new();
|
||||
public List<WorkTask> UpcomingTasks { get; private set; } = new();
|
||||
public string? ActiveCategory { get; set; }
|
||||
public bool HasSubtasksFilter { get; set; }
|
||||
public List<string> AllCategories { get; set; } = new();
|
||||
@@ -151,7 +152,7 @@ public class BoardModel : PageModel
|
||||
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))
|
||||
return BadRequest("Title is required.");
|
||||
@@ -161,6 +162,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 +178,7 @@ public class BoardModel : PageModel
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (task is null) return NotFound();
|
||||
@@ -185,6 +187,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);
|
||||
@@ -274,6 +278,43 @@ public class BoardModel : PageModel
|
||||
return Partial("Partials/_SearchResults", results);
|
||||
}
|
||||
|
||||
public static List<WorkTask> SelectUpcomingTasks(
|
||||
IEnumerable<WorkTask> allTasks,
|
||||
string? category,
|
||||
bool hasSubtasks) =>
|
||||
SelectUpcomingTasks(FilterVisibleTasks(allTasks, category, hasSubtasks));
|
||||
|
||||
private static IEnumerable<WorkTask> FilterVisibleTasks(
|
||||
IEnumerable<WorkTask> allTasks,
|
||||
string? category,
|
||||
bool hasSubtasks)
|
||||
{
|
||||
var visibleTasks = allTasks;
|
||||
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
visibleTasks = visibleTasks.Where(task =>
|
||||
string.Equals(task.Category, category, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (hasSubtasks)
|
||||
{
|
||||
visibleTasks = visibleTasks.Where(task => task.SubTasks.Count > 0);
|
||||
}
|
||||
|
||||
return visibleTasks;
|
||||
}
|
||||
|
||||
private static List<WorkTask> SelectUpcomingTasks(IEnumerable<WorkTask> visibleTasks)
|
||||
{
|
||||
return visibleTasks
|
||||
.Where(task => task.DueAt.HasValue &&
|
||||
task.Status is not WorkTaskStatus.Completed and not WorkTaskStatus.Abandoned)
|
||||
.OrderBy(task => task.DueAt)
|
||||
.Take(8)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ReturnBoardContentAsync()
|
||||
{
|
||||
await LoadBoardDataAsync();
|
||||
@@ -293,21 +334,11 @@ public class BoardModel : PageModel
|
||||
.OrderBy(c => c)
|
||||
.ToList();
|
||||
|
||||
// Filter to top-level only
|
||||
var tasks = allTasks.Where(t => t.ParentTaskId == null).ToList();
|
||||
var visibleTasks = FilterVisibleTasks(allTasks, ActiveCategory, HasSubtasksFilter).ToList();
|
||||
UpcomingTasks = SelectUpcomingTasks(visibleTasks);
|
||||
|
||||
// Apply category filter
|
||||
if (!string.IsNullOrEmpty(ActiveCategory))
|
||||
{
|
||||
tasks = tasks.Where(t =>
|
||||
string.Equals(t.Category, ActiveCategory, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
// Apply hasSubtasks filter
|
||||
if (HasSubtasksFilter)
|
||||
{
|
||||
tasks = tasks.Where(t => t.SubTasks.Count > 0).ToList();
|
||||
}
|
||||
// Keep the Kanban collection top-level only after filters apply across all tasks.
|
||||
var tasks = visibleTasks.Where(task => task.ParentTaskId == null).ToList();
|
||||
|
||||
// Group by status into columns
|
||||
var tasksByStatus = tasks.ToLookup(t => t.Status);
|
||||
|
||||
@@ -8,4 +8,10 @@
|
||||
placeholder="New task..."
|
||||
class="input"
|
||||
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>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
<div id="board-content" class="board-page">
|
||||
<partial name="Partials/_FilterBar" model="Model" />
|
||||
<partial name="Partials/_UpcomingTasks" model="Model" />
|
||||
<div id="kanban-board" class="kanban-grid">
|
||||
@foreach (var col in Model.Columns)
|
||||
{
|
||||
|
||||
@@ -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 @@
|
||||
<span style="color: var(--color-text-primary); font-variant-numeric: tabular-nums;">@elapsed</span>
|
||||
</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;">
|
||||
<span style="color: var(--color-text-secondary);">Estimate</span>
|
||||
<div class="inline-edit" id="edit-estimate" style="text-align: right;">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
@using TaskTracker.Api.Pages
|
||||
@using TaskTracker.Core.Enums
|
||||
@model BoardModel
|
||||
|
||||
@if (Model.UpcomingTasks.Count > 0)
|
||||
{
|
||||
<section class="upcoming-tasks" aria-labelledby="upcoming-tasks-heading">
|
||||
<div class="upcoming-tasks-header">
|
||||
<h2 id="upcoming-tasks-heading">Upcoming tasks</h2>
|
||||
<span>@Model.UpcomingTasks.Count due soon</span>
|
||||
</div>
|
||||
<div class="upcoming-task-list">
|
||||
@foreach (var task in Model.UpcomingTasks)
|
||||
{
|
||||
var categoryColor = BoardModel.GetCategoryColor(task.Category);
|
||||
var dueAt = task.DueAt!.Value;
|
||||
var dueAtUtc = dueAt.ToUniversalTime().ToString("O");
|
||||
var localDueAt = dueAt.ToLocalTime().ToString("ddd, MMM d, h:mm tt");
|
||||
<article class="upcoming-task" data-due-at="@dueAtUtc"
|
||||
hx-get="/board?handler=TaskDetail&id=@task.Id"
|
||||
hx-target="#detail-panel"
|
||||
hx-swap="innerHTML">
|
||||
<div class="upcoming-task-title">@task.Title</div>
|
||||
<div class="upcoming-task-meta">
|
||||
<span class="upcoming-task-status upcoming-task-status--@task.Status.ToString().ToLowerInvariant()">@task.Status</span>
|
||||
@if (!string.IsNullOrEmpty(task.Category))
|
||||
{
|
||||
<span class="task-card-category-dot" style="background: @categoryColor"></span>
|
||||
<span>@task.Category</span>
|
||||
}
|
||||
</div>
|
||||
<time class="task-countdown" aria-live="off" datetime="@dueAtUtc" title="@localDueAt">@localDueAt</time>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -474,6 +474,99 @@ body::before {
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
UPCOMING TASKS
|
||||
============================================================ */
|
||||
|
||||
.upcoming-tasks {
|
||||
margin-bottom: 20px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.upcoming-tasks-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upcoming-tasks-header h2 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.upcoming-tasks-header span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.upcoming-task-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.upcoming-task {
|
||||
display: grid;
|
||||
flex: 0 0 210px;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-elevated);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.upcoming-task:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.upcoming-task-title {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upcoming-task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.upcoming-task-status {
|
||||
color: var(--color-status-pending);
|
||||
}
|
||||
|
||||
.upcoming-task-status--active { color: var(--color-status-active); }
|
||||
.upcoming-task-status--paused { color: var(--color-status-paused); }
|
||||
.upcoming-task-status--completed { color: var(--color-status-completed); }
|
||||
.upcoming-task-status--abandoned { color: var(--color-status-abandoned); }
|
||||
|
||||
.task-countdown {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.task-countdown--overdue {
|
||||
color: var(--color-status-abandoned);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
KANBAN LAYOUT
|
||||
============================================================ */
|
||||
|
||||
@@ -55,6 +55,53 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Upcoming task countdowns
|
||||
// ========================================
|
||||
|
||||
var countdownTimer = null;
|
||||
|
||||
function formatCountdown(totalSeconds, isFuture) {
|
||||
var remaining = totalSeconds;
|
||||
var days = Math.floor(remaining / 86400);
|
||||
remaining %= 86400;
|
||||
var hours = Math.floor(remaining / 3600);
|
||||
remaining %= 3600;
|
||||
var minutes = Math.floor(remaining / 60);
|
||||
var seconds = remaining % 60;
|
||||
var parts = [];
|
||||
|
||||
if (days) parts.push(days + 'd');
|
||||
if (hours) parts.push(hours + 'h');
|
||||
if (minutes) parts.push(minutes + 'm');
|
||||
if (seconds && !days && !hours) parts.push(seconds + 's');
|
||||
if (parts.length === 0) parts.push('0s');
|
||||
|
||||
return isFuture ? parts.join(' ') + ' remaining' : 'Overdue by ' + parts.join(' ');
|
||||
}
|
||||
|
||||
function updateCountdowns() {
|
||||
var now = Date.now();
|
||||
document.querySelectorAll('[data-due-at] .task-countdown, time.task-countdown[data-due-at]').forEach(function(countdown) {
|
||||
var dueAtElement = countdown.closest('[data-due-at]') || countdown;
|
||||
var dueAt = Date.parse(dueAtElement.dataset.dueAt);
|
||||
if (Number.isNaN(dueAt)) return;
|
||||
|
||||
var deltaMilliseconds = dueAt - now;
|
||||
var deltaSeconds = Math.floor(deltaMilliseconds / 1000);
|
||||
var overdue = deltaSeconds < 0;
|
||||
var totalSeconds = Math.abs(deltaSeconds);
|
||||
countdown.textContent = formatCountdown(totalSeconds, !overdue);
|
||||
countdown.classList.toggle('task-countdown--overdue', overdue);
|
||||
});
|
||||
}
|
||||
|
||||
function initCountdowns() {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
updateCountdowns();
|
||||
countdownTimer = setInterval(updateCountdowns, 1000);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Detail Panel helpers
|
||||
// ========================================
|
||||
@@ -111,6 +158,7 @@
|
||||
// Initialize Kanban drag-and-drop on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initKanban();
|
||||
initCountdowns();
|
||||
|
||||
// "New Task" header button — scroll to and focus the create-task input in the Pending column
|
||||
var newTaskBtn = document.getElementById('new-task-trigger');
|
||||
@@ -132,6 +180,11 @@
|
||||
if (target && (target.id === 'kanban-board' || target.id === 'board-content' || (target.closest && target.closest('#kanban-board')))) {
|
||||
initKanban();
|
||||
}
|
||||
if (target && (target.id === 'board-content' ||
|
||||
(target.matches && target.matches('.upcoming-tasks')) ||
|
||||
(target.querySelector && target.querySelector('.upcoming-tasks')))) {
|
||||
initCountdowns();
|
||||
}
|
||||
});
|
||||
|
||||
// Also handle htmx:afterSettle for cases where the DOM isn't fully ready on afterSwap
|
||||
|
||||
@@ -7,4 +7,5 @@ public class CreateTaskRequest
|
||||
public string? Category { get; set; }
|
||||
public int? ParentTaskId { get; set; }
|
||||
public int? EstimatedMinutes { get; set; }
|
||||
public DateTimeOffset? DueAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ public class UpdateTaskRequest
|
||||
public string? Category { get; set; }
|
||||
public int? EstimatedMinutes { get; set; }
|
||||
public int? ParentTaskId { get; set; }
|
||||
public DateTimeOffset? DueAt { get; set; }
|
||||
public bool ClearDueAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public class WorkTask
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public int? EstimatedMinutes { get; set; }
|
||||
public DateTimeOffset? DueAt { get; set; }
|
||||
|
||||
public int? ParentTaskId { get; set; }
|
||||
public WorkTask? ParentTask { get; set; }
|
||||
|
||||
@@ -18,8 +18,10 @@ public class TaskTrackerDbContext(DbContextOptions<TaskTrackerDbContext> options
|
||||
e.Property(t => t.Title).HasMaxLength(500).IsRequired();
|
||||
e.Property(t => t.Status).HasConversion<string>().HasMaxLength(50);
|
||||
e.Property(t => t.Category).HasMaxLength(100);
|
||||
e.Property(t => t.DueAt).HasColumnType("datetimeoffset");
|
||||
e.HasMany(t => t.SubTasks).WithOne(t => t.ParentTask).HasForeignKey(t => t.ParentTaskId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasIndex(t => t.ParentTaskId);
|
||||
e.HasIndex(t => t.DueAt);
|
||||
e.HasMany(t => t.Notes).WithOne(n => n.WorkTask).HasForeignKey(n => n.WorkTaskId);
|
||||
e.HasMany(t => t.ContextEvents).WithOne(c => c.WorkTask).HasForeignKey(c => c.WorkTaskId);
|
||||
});
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TaskTracker.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TaskTracker.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(TaskTrackerDbContext))]
|
||||
[Migration("20260813203936_AddTaskDueAt")]
|
||||
partial class AddTaskDueAt
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.AppMapping", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("MatchType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppMappings");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
Category = "Engineering",
|
||||
FriendlyName = "SolidWorks",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "SLDWORKS"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
Category = "Email",
|
||||
FriendlyName = "Outlook",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "OUTLOOK"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
Category = "General",
|
||||
FriendlyName = "Notepad",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "notepad"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
Category = "LaserCutting",
|
||||
FriendlyName = "PEP System",
|
||||
MatchType = "UrlContains",
|
||||
Pattern = "pep"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 5,
|
||||
Category = "Engineering",
|
||||
FriendlyName = "SolidWorks",
|
||||
MatchType = "TitleContains",
|
||||
Pattern = "solidworks"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AppName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("WindowTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<int?>("WorkTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("WorkTaskId");
|
||||
|
||||
b.ToTable("ContextEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("WorkTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkTaskId");
|
||||
|
||||
b.ToTable("Notes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DueAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("EstimatedMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("ParentTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DueAt");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.ToTable("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
|
||||
.WithMany("ContextEvents")
|
||||
.HasForeignKey("WorkTaskId");
|
||||
|
||||
b.Navigation("WorkTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
|
||||
.WithMany("Notes")
|
||||
.HasForeignKey("WorkTaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "ParentTask")
|
||||
.WithMany("SubTasks")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("ParentTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.Navigation("ContextEvents");
|
||||
|
||||
b.Navigation("Notes");
|
||||
|
||||
b.Navigation("SubTasks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TaskTracker.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTaskDueAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "DueAt",
|
||||
table: "Tasks",
|
||||
type: "datetimeoffset",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tasks_DueAt",
|
||||
table: "Tasks",
|
||||
column: "DueAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Tasks_DueAt",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DueAt",
|
||||
table: "Tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,9 @@ namespace TaskTracker.Infrastructure.Migrations
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DueAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("EstimatedMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -210,6 +213,8 @@ namespace TaskTracker.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DueAt");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.ToTable("Tasks");
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using TaskTracker.Api.Pages;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace TaskTracker.Tests;
|
||||
|
||||
public class BoardUpcomingTasksTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelectUpcomingTasks_excludes_completed_abandoned_and_tasks_without_a_deadline()
|
||||
{
|
||||
var dueAt = DateTimeOffset.Parse("2026-08-14T09:00:00Z");
|
||||
var tasks = new[]
|
||||
{
|
||||
Task("pending", WorkTaskStatus.Pending, dueAt),
|
||||
Task("completed", WorkTaskStatus.Completed, dueAt.AddHours(-1)),
|
||||
Task("abandoned", WorkTaskStatus.Abandoned, dueAt.AddHours(1)),
|
||||
Task("no deadline", WorkTaskStatus.Active),
|
||||
};
|
||||
|
||||
var upcoming = BoardModel.SelectUpcomingTasks(tasks, category: null, hasSubtasks: false);
|
||||
|
||||
var task = Assert.Single(upcoming);
|
||||
Assert.Equal("pending", task.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectUpcomingTasks_includes_top_level_tasks_and_subtasks_in_due_date_order()
|
||||
{
|
||||
var parent = Task("top level", WorkTaskStatus.Pending, DateTimeOffset.Parse("2026-08-14T12:00:00Z"));
|
||||
var subtask = Task("subtask", WorkTaskStatus.Active, DateTimeOffset.Parse("2026-08-14T10:00:00Z"), parentTaskId: 1);
|
||||
var later = Task("later", WorkTaskStatus.Paused, DateTimeOffset.Parse("2026-08-14T14:00:00Z"));
|
||||
|
||||
var upcoming = BoardModel.SelectUpcomingTasks([parent, subtask, later], category: null, hasSubtasks: false);
|
||||
|
||||
Assert.Equal(["subtask", "top level", "later"], upcoming.Select(task => task.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectUpcomingTasks_limits_results_to_eight_earliest_deadlines()
|
||||
{
|
||||
var start = DateTimeOffset.Parse("2026-08-14T09:00:00Z");
|
||||
var tasks = Enumerable.Range(0, 9)
|
||||
.Select(index => Task($"task {index}", WorkTaskStatus.Pending, start.AddHours(index)))
|
||||
.ToList();
|
||||
|
||||
var upcoming = BoardModel.SelectUpcomingTasks(tasks, category: null, hasSubtasks: false);
|
||||
|
||||
Assert.Equal(8, upcoming.Count);
|
||||
Assert.DoesNotContain(upcoming, task => task.Title == "task 8");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectUpcomingTasks_honors_active_category_and_has_subtasks_filters()
|
||||
{
|
||||
var dueAt = DateTimeOffset.Parse("2026-08-14T09:00:00Z");
|
||||
var developmentParent = Task("development parent", WorkTaskStatus.Pending, dueAt, category: "Development");
|
||||
developmentParent.SubTasks.Add(Task("development child", WorkTaskStatus.Pending, dueAt.AddHours(1), parentTaskId: 1, category: "Development"));
|
||||
var developmentLeaf = Task("development leaf", WorkTaskStatus.Pending, dueAt.AddHours(2), category: "Development");
|
||||
var researchParent = Task("research parent", WorkTaskStatus.Pending, dueAt.AddHours(-1), category: "Research");
|
||||
researchParent.SubTasks.Add(Task("research child", WorkTaskStatus.Pending, dueAt, parentTaskId: 2, category: "Research"));
|
||||
|
||||
var upcoming = BoardModel.SelectUpcomingTasks(
|
||||
[developmentParent, developmentParent.SubTasks[0], developmentLeaf, researchParent, researchParent.SubTasks[0]],
|
||||
category: "development",
|
||||
hasSubtasks: true);
|
||||
|
||||
var task = Assert.Single(upcoming);
|
||||
Assert.Equal("development parent", task.Title);
|
||||
}
|
||||
|
||||
private static WorkTask Task(string title, WorkTaskStatus status, DateTimeOffset? dueAt = null, int? parentTaskId = null, string? category = null) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
Status = status,
|
||||
DueAt = dueAt,
|
||||
ParentTaskId = parentTaskId,
|
||||
Category = category,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TaskTracker.Api\TaskTracker.Api.csproj" />
|
||||
<ProjectReference Include="..\TaskTracker.Core\TaskTracker.Core.csproj" />
|
||||
<ProjectReference Include="..\TaskTracker.Infrastructure\TaskTracker.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,91 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskTracker.Api.Controllers;
|
||||
using TaskTracker.Core.DTOs;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Infrastructure.Data;
|
||||
using TaskTracker.Infrastructure.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace TaskTracker.Tests;
|
||||
|
||||
public class TasksControllerDeadlineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_copies_due_at_including_offset_to_created_task()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var controller = CreateController(db);
|
||||
var dueAt = DateTimeOffset.Parse("2026-08-14T11:00:00-04:00");
|
||||
|
||||
var result = await controller.Create(new CreateTaskRequest { Title = "deadline", DueAt = dueAt });
|
||||
|
||||
var created = Assert.IsType<CreatedAtActionResult>(result);
|
||||
var response = Assert.IsType<ApiResponse<WorkTask>>(created.Value);
|
||||
Assert.Equal(dueAt, response.Data!.DueAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_changes_due_at_when_value_is_supplied()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var task = await AddTaskAsync(db, DateTimeOffset.Parse("2026-08-14T11:00:00-04:00"));
|
||||
var controller = CreateController(db);
|
||||
var dueAt = DateTimeOffset.Parse("2026-08-15T09:30:00+02:00");
|
||||
|
||||
var result = await controller.Update(task.Id, new UpdateTaskRequest { DueAt = dueAt });
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var response = Assert.IsType<ApiResponse<WorkTask>>(ok.Value);
|
||||
Assert.Equal(dueAt, response.Data!.DueAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_clears_due_at_only_when_clear_due_at_is_true()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var task = await AddTaskAsync(db, DateTimeOffset.Parse("2026-08-14T11:00:00-04:00"));
|
||||
var controller = CreateController(db);
|
||||
|
||||
var result = await controller.Update(task.Id, new UpdateTaskRequest { ClearDueAt = true });
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var response = Assert.IsType<ApiResponse<WorkTask>>(ok.Value);
|
||||
Assert.Null(response.Data!.DueAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_with_omitted_due_at_preserves_existing_due_at()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var dueAt = DateTimeOffset.Parse("2026-08-14T11:00:00-04:00");
|
||||
var task = await AddTaskAsync(db, dueAt);
|
||||
var controller = CreateController(db);
|
||||
|
||||
var result = await controller.Update(task.Id, new UpdateTaskRequest { Title = "renamed" });
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var response = Assert.IsType<ApiResponse<WorkTask>>(ok.Value);
|
||||
Assert.Equal(dueAt, response.Data!.DueAt);
|
||||
}
|
||||
|
||||
private static TaskTrackerDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<TaskTrackerDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new TaskTrackerDbContext(options);
|
||||
}
|
||||
|
||||
private static TasksController CreateController(TaskTrackerDbContext db) =>
|
||||
new(new TaskRepository(db), NullLogger<TasksController>.Instance);
|
||||
|
||||
private static async Task<WorkTask> AddTaskAsync(TaskTrackerDbContext db, DateTimeOffset dueAt)
|
||||
{
|
||||
var task = new WorkTask { Title = "existing", DueAt = dueAt };
|
||||
db.Tasks.Add(task);
|
||||
await db.SaveChangesAsync();
|
||||
return task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using TaskTracker.Core.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace TaskTracker.Tests;
|
||||
|
||||
public class WorkTaskDeadlineTests
|
||||
{
|
||||
[Fact]
|
||||
public void WorkTask_preserves_an_explicit_deadline_offset()
|
||||
{
|
||||
var dueAt = DateTimeOffset.Parse("2026-08-14T11:00:00-04:00");
|
||||
var task = new WorkTask { DueAt = dueAt };
|
||||
|
||||
Assert.Equal(dueAt, task.DueAt);
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
<Project Path="TaskTracker.Core/TaskTracker.Core.csproj" />
|
||||
<Project Path="TaskTracker.Infrastructure/TaskTracker.Infrastructure.csproj" />
|
||||
<Project Path="TaskTracker.MCP/TaskTracker.MCP.csproj" />
|
||||
<Project Path="TaskTracker.Tests/TaskTracker.Tests.csproj" />
|
||||
<Project Path="WindowWatcher/WindowWatcher.csproj" />
|
||||
</Solution>
|
||||
|
||||
Reference in New Issue
Block a user