diff --git a/TaskTracker.Core/DTOs/CreateTaskRequest.cs b/TaskTracker.Core/DTOs/CreateTaskRequest.cs index 2e4fb0c..4632e6f 100644 --- a/TaskTracker.Core/DTOs/CreateTaskRequest.cs +++ b/TaskTracker.Core/DTOs/CreateTaskRequest.cs @@ -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; } } diff --git a/TaskTracker.Core/DTOs/UpdateTaskRequest.cs b/TaskTracker.Core/DTOs/UpdateTaskRequest.cs index b59059b..1526667 100644 --- a/TaskTracker.Core/DTOs/UpdateTaskRequest.cs +++ b/TaskTracker.Core/DTOs/UpdateTaskRequest.cs @@ -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; } } diff --git a/TaskTracker.Core/Entities/WorkTask.cs b/TaskTracker.Core/Entities/WorkTask.cs index c4c47c4..26a3391 100644 --- a/TaskTracker.Core/Entities/WorkTask.cs +++ b/TaskTracker.Core/Entities/WorkTask.cs @@ -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; } diff --git a/TaskTracker.Infrastructure/Data/TaskTrackerDbContext.cs b/TaskTracker.Infrastructure/Data/TaskTrackerDbContext.cs index 16bec61..b6190ed 100644 --- a/TaskTracker.Infrastructure/Data/TaskTrackerDbContext.cs +++ b/TaskTracker.Infrastructure/Data/TaskTrackerDbContext.cs @@ -18,8 +18,10 @@ public class TaskTrackerDbContext(DbContextOptions options e.Property(t => t.Title).HasMaxLength(500).IsRequired(); e.Property(t => t.Status).HasConversion().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); }); diff --git a/TaskTracker.Infrastructure/Migrations/20260813203936_AddTaskDueAt.Designer.cs b/TaskTracker.Infrastructure/Migrations/20260813203936_AddTaskDueAt.Designer.cs new file mode 100644 index 0000000..b2067b3 --- /dev/null +++ b/TaskTracker.Infrastructure/Migrations/20260813203936_AddTaskDueAt.Designer.cs @@ -0,0 +1,267 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MatchType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("Url") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WindowTitle") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("WorkTaskId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("WorkTaskId"); + + b.ToTable("ContextEvents"); + }); + + modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("WorkTaskId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkTaskId"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueAt") + .HasColumnType("datetimeoffset"); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("ParentTaskId") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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 + } + } +} diff --git a/TaskTracker.Infrastructure/Migrations/20260813203936_AddTaskDueAt.cs b/TaskTracker.Infrastructure/Migrations/20260813203936_AddTaskDueAt.cs new file mode 100644 index 0000000..0d1d54c --- /dev/null +++ b/TaskTracker.Infrastructure/Migrations/20260813203936_AddTaskDueAt.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TaskTracker.Infrastructure.Migrations +{ + /// + public partial class AddTaskDueAt : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DueAt", + table: "Tasks", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Tasks_DueAt", + table: "Tasks", + column: "DueAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tasks_DueAt", + table: "Tasks"); + + migrationBuilder.DropColumn( + name: "DueAt", + table: "Tasks"); + } + } +} diff --git a/TaskTracker.Infrastructure/Migrations/TaskTrackerDbContextModelSnapshot.cs b/TaskTracker.Infrastructure/Migrations/TaskTrackerDbContextModelSnapshot.cs index 82d6797..3615f8a 100644 --- a/TaskTracker.Infrastructure/Migrations/TaskTrackerDbContextModelSnapshot.cs +++ b/TaskTracker.Infrastructure/Migrations/TaskTrackerDbContextModelSnapshot.cs @@ -189,6 +189,9 @@ namespace TaskTracker.Infrastructure.Migrations b.Property("Description") .HasColumnType("nvarchar(max)"); + b.Property("DueAt") + .HasColumnType("datetimeoffset"); + b.Property("EstimatedMinutes") .HasColumnType("int"); @@ -210,6 +213,8 @@ namespace TaskTracker.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("DueAt"); + b.HasIndex("ParentTaskId"); b.ToTable("Tasks"); diff --git a/TaskTracker.Tests/TaskTracker.Tests.csproj b/TaskTracker.Tests/TaskTracker.Tests.csproj new file mode 100644 index 0000000..1abed7b --- /dev/null +++ b/TaskTracker.Tests/TaskTracker.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/TaskTracker.Tests/WorkTaskDeadlineTests.cs b/TaskTracker.Tests/WorkTaskDeadlineTests.cs new file mode 100644 index 0000000..d258755 --- /dev/null +++ b/TaskTracker.Tests/WorkTaskDeadlineTests.cs @@ -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); + } +} diff --git a/WorkContextTracker.slnx b/WorkContextTracker.slnx index 2bf6157..e48d344 100644 --- a/WorkContextTracker.slnx +++ b/WorkContextTracker.slnx @@ -3,5 +3,6 @@ +