diff --git a/Ticky.Base/Entities/Activity.cs b/Ticky.Base/Entities/Activity.cs index 2d1c962..10e12b9 100644 --- a/Ticky.Base/Entities/Activity.cs +++ b/Ticky.Base/Entities/Activity.cs @@ -7,4 +7,30 @@ public class Activity : AbstractDbEntity public required string Text { get; set; } public required int CardId { get; set; } public virtual Card Card { get; set; } = null!; + + public required ActivityType ActivityType { get; set; } + + // CardMoved payload. Names and Finished are snapshots at move-time so old rows + // still read correctly after a column is renamed, deleted or has Finished toggled. + public int? FromColumnId { get; set; } + public virtual Column? FromColumn { get; set; } + public string? FromColumnName { get; set; } + public int? ToColumnId { get; set; } + public virtual Column? ToColumn { get; set; } + public string? ToColumnName { get; set; } + public bool? ToColumnFinished { get; set; } + + public static Activity CardMoved(int cardId, int userId, Column from, Column to) => + new() + { + ActivityType = ActivityType.CardMoved, + Text = $"moved the card to {to.Name}", + CardId = cardId, + UserId = userId, + FromColumnId = from.Id, + FromColumnName = from.Name, + ToColumnId = to.Id, + ToColumnName = to.Name, + ToColumnFinished = to.Finished, + }; } diff --git a/Ticky.Base/Enums/ActivityType.cs b/Ticky.Base/Enums/ActivityType.cs new file mode 100644 index 0000000..8edfef4 --- /dev/null +++ b/Ticky.Base/Enums/ActivityType.cs @@ -0,0 +1,104 @@ +namespace Ticky.Base.Enums; + +// Values are persisted, so only ever append new ones. +public enum ActivityType +{ + [Display(Name = "Other")] + Generic = 0, + + [Display(Name = "Moved")] + CardMoved = 1, + + [Display(Name = "Title changed")] + TitleChanged = 2, + + [Display(Name = "Description changed")] + DescriptionChanged = 3, + + [Display(Name = "Comment posted")] + CommentPosted = 4, + + [Display(Name = "Assignee added")] + AssigneeAdded = 5, + + [Display(Name = "Assignee removed")] + AssigneeRemoved = 6, + + [Display(Name = "Subtask assignee added")] + SubtaskAssigneeAdded = 7, + + [Display(Name = "Subtask assignee removed")] + SubtaskAssigneeRemoved = 8, + + [Display(Name = "Label added")] + LabelAdded = 9, + + [Display(Name = "Label removed")] + LabelRemoved = 10, + + [Display(Name = "Priority changed")] + PriorityChanged = 11, + + [Display(Name = "Deadline set")] + DeadlineSet = 12, + + [Display(Name = "Deadline removed")] + DeadlineRemoved = 13, + + [Display(Name = "Reminder added")] + ReminderAdded = 14, + + [Display(Name = "Reminder removed")] + ReminderRemoved = 15, + + [Display(Name = "Subtask added")] + SubtaskAdded = 16, + + [Display(Name = "Subtask edited")] + SubtaskEdited = 17, + + [Display(Name = "Subtask completion changed")] + SubtaskCompletionChanged = 18, + + [Display(Name = "Subtask deleted")] + SubtaskDeleted = 19, + + [Display(Name = "Attachment uploaded")] + AttachmentUploaded = 20, + + [Display(Name = "Attachment deleted")] + AttachmentDeleted = 21, + + [Display(Name = "Time record added")] + TimeRecordAdded = 22, + + [Display(Name = "Time record changed")] + TimeRecordChanged = 23, + + [Display(Name = "Time record deleted")] + TimeRecordDeleted = 24, + + [Display(Name = "Linked issue added")] + LinkAdded = 25, + + [Display(Name = "Linked issue removed")] + LinkRemoved = 26, + + [Display(Name = "Snoozed")] + Snoozed = 27, + + [Display(Name = "Unsnoozed")] + Unsnoozed = 28, + + [Display(Name = "Repeat set up")] + RepeatSet = 29, + + [Display(Name = "Repeat turned off")] + RepeatDisabled = 30, + + [Display(Name = "Repeated from card")] + RepeatedFrom = 31, + + [Display(Name = "Repeat card created")] + RepeatCardCreated = 32, +} diff --git a/Ticky.Internal/Data/DataContext.cs b/Ticky.Internal/Data/DataContext.cs index 830e0f0..4077958 100644 --- a/Ticky.Internal/Data/DataContext.cs +++ b/Ticky.Internal/Data/DataContext.cs @@ -136,6 +136,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(x => x.UserId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder + .Entity() + .HasOne(x => x.FromColumn) + .WithMany() + .HasForeignKey(x => x.FromColumnId) + .OnDelete(DeleteBehavior.SetNull); + + modelBuilder + .Entity() + .HasOne(x => x.ToColumn) + .WithMany() + .HasForeignKey(x => x.ToColumnId) + .OnDelete(DeleteBehavior.SetNull); + + modelBuilder.Entity().HasIndex(x => new { x.ActivityType, x.CreatedAt }); + modelBuilder .Entity() .HasMany(x => x.Subtasks) diff --git a/Ticky.Internal/Helpers/BurndownHelper.cs b/Ticky.Internal/Helpers/BurndownHelper.cs new file mode 100644 index 0000000..3c19a8a --- /dev/null +++ b/Ticky.Internal/Helpers/BurndownHelper.cs @@ -0,0 +1,96 @@ +using System.Globalization; +using System.Text; + +namespace Ticky.Internal.Helpers; + +public static class BurndownHelper +{ + /// + /// Open card count per day, from the day the first card was created up to and including . + /// + public static List<(DateTime Date, int Open)> GetOpenPerDay( + IReadOnlyCollection cardCreatedAts, + IReadOnlyCollection completionDates, + DateTime today + ) + { + if (cardCreatedAts.Count == 0) + return []; + + var startDate = cardCreatedAts.Min().Date; + int totalDays = Math.Max(1, (today.Date - startDate).Days + 1); + + return Enumerable + .Range(0, totalDays) + .Select(d => + { + var day = startDate.AddDays(d); + int created = cardCreatedAts.Count(x => x.Date <= day); + int completed = completionDates.Count(x => x.Date <= day); + return (day, Math.Max(0, created - completed)); + }) + .ToList(); + } + + /// + /// Renders the burndown as an inline SVG. Numbers are formatted with the invariant culture + /// because SVG attributes require '.' as the decimal separator regardless of server locale. + /// + public static string RenderSvg(IReadOnlyList<(DateTime Date, int Open)> points) + { + if (!points.Any()) + return string.Empty; + + const int svgW = 760, svgH = 240; + const int padLeft = 48, padTop = 16, padBottom = 32, padRight = 16; + int chartW = svgW - padLeft - padRight; + int chartH = svgH - padTop - padBottom; + + int maxOpen = Math.Max(1, points.Max(p => p.Open)); + int days = points.Count; + + double xScale = days > 1 ? (double)chartW / (days - 1) : chartW; + double yScale = (double)chartH / maxOpen; + + string Px(int i) => (padLeft + i * xScale).ToString("F1", CultureInfo.InvariantCulture); + string Py(int open) => (padTop + chartH - open * yScale).ToString("F1", CultureInfo.InvariantCulture); + + var actualPoints = string.Join(" ", points.Select((p, i) => $"{Px(i)},{Py(p.Open)}")); + string idealStart = $"{Px(0)},{Py(maxOpen)}"; + string idealEnd = $"{Px(days - 1)},{Py(0)}"; + + var xLabels = new StringBuilder(); + int xStep = Math.Max(1, days / 6); + for (int i = 0; i < days; i += xStep) + { + xLabels.Append( + $"" + + points[i].Date.ToString("MM/dd", CultureInfo.InvariantCulture) + + "" + ); + } + + var yLabels = new StringBuilder(); + int yStep = Math.Max(1, maxOpen / 4); + for (int v = 0; v <= maxOpen; v += yStep) + { + yLabels.Append( + $"{v}" + ); + } + + var sb = new StringBuilder(); + sb.Append($""); + sb.Append($"Open cards"); + sb.Append($""); + sb.Append($""); + sb.Append(yLabels); + sb.Append(xLabels); + sb.Append($""); + sb.Append($""); + sb.Append($""); + sb.Append(""); + return sb.ToString(); + } +} diff --git a/Ticky.Internal/Migrations/20260919102234_ActivityMovePayload.Designer.cs b/Ticky.Internal/Migrations/20260919102234_ActivityMovePayload.Designer.cs new file mode 100644 index 0000000..1513379 --- /dev/null +++ b/Ticky.Internal/Migrations/20260919102234_ActivityMovePayload.Designer.cs @@ -0,0 +1,1350 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Ticky.Internal.Data; + +#nullable disable + +namespace Ticky.Internal.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20260919102234_ActivityMovePayload")] + partial class ActivityMovePayload + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("CardLabel", b => + { + b.Property("LabelsId") + .HasColumnType("int"); + + b.Property("OnCardsId") + .HasColumnType("int"); + + b.HasKey("LabelsId", "OnCardsId"); + + b.HasIndex("OnCardsId"); + + b.ToTable("CardLabel"); + }); + + modelBuilder.Entity("CardUser", b => + { + b.Property("AssignedToId") + .HasColumnType("int"); + + b.Property("AssigneesId") + .HasColumnType("int"); + + b.HasKey("AssignedToId", "AssigneesId"); + + b.HasIndex("AssigneesId"); + + b.ToTable("CardUser"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("longtext"); + + b.Property("Xml") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SubtaskUser", b => + { + b.Property("AssigneesId") + .HasColumnType("int"); + + b.Property("SubtasksId") + .HasColumnType("int"); + + b.HasKey("AssigneesId", "SubtasksId"); + + b.HasIndex("SubtasksId"); + + b.ToTable("SubtaskUser"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Activity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivityType") + .HasColumnType("int"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FromColumnId") + .HasColumnType("int"); + + b.Property("FromColumnName") + .HasColumnType("longtext"); + + b.Property("Text") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ToColumnFinished") + .HasColumnType("tinyint(1)"); + + b.Property("ToColumnId") + .HasColumnType("int"); + + b.Property("ToColumnName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.HasIndex("FromColumnId"); + + b.HasIndex("ToColumnId"); + + b.HasIndex("UserId"); + + b.HasIndex("ActivityType", "CreatedAt"); + + b.ToTable("Activities"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Board", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DisableSortingAnimations") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ProjectId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Boards"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.BoardMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AddedAt") + .HasColumnType("datetime(6)"); + + b.Property("BoardId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("UserId"); + + b.ToTable("BoardMemberships"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ColumnId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Deadline") + .HasColumnType("datetime(6)"); + + b.Property("DeadlineProcessed") + .HasColumnType("tinyint(1)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Flagged") + .HasColumnType("tinyint(1)"); + + b.Property("Index") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("SnoozedUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ColumnId"); + + b.HasIndex("CreatedById"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.CardLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CardOneId") + .HasColumnType("int"); + + b.Property("CardTwoId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CardOneId"); + + b.HasIndex("CardTwoId"); + + b.ToTable("CardLinks"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Code", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CodePurpose") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Codes"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Column", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BoardId") + .HasColumnType("int"); + + b.Property("Collapsed") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Finished") + .HasColumnType("tinyint(1)"); + + b.Property("Index") + .HasColumnType("int"); + + b.Property("MaxCards") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NewCardPlacement") + .HasColumnType("int"); + + b.Property("OrderRule") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.ToTable("Columns"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Text") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.HasIndex("CreatedById"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Favorite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BoardId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("UserId"); + + b.ToTable("Favorites"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Label", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BackgroundColor") + .HasColumnType("int"); + + b.Property("BoardId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("TextColor") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.ToTable("Labels"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.LastVisit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BoardId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("VisitTime") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("UserId"); + + b.ToTable("LastVisits"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.ProjectMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AddedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("ProjectId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserId"); + + b.ToTable("ProjectMemberships"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Reminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("At") + .HasColumnType("datetime(6)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.ToTable("Reminders"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Subtask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Completed") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Index") + .HasColumnType("int"); + + b.Property("Text") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.ToTable("Subtasks"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.TimeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndedAt") + .HasColumnType("datetime(6)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.HasIndex("UserId"); + + b.ToTable("TimeRecords"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("AutomaticAssign") + .HasColumnType("tinyint(1)"); + + b.Property("AutomaticCardEdit") + .HasColumnType("tinyint(1)"); + + b.Property("AutomaticDeadlineReminder") + .HasColumnType("tinyint(1)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("InformationSnoozeUntil") + .HasColumnType("datetime(6)"); + + b.Property("InstantDelete") + .HasColumnType("tinyint(1)"); + + b.Property("LastViewedInformation") + .HasColumnType("int"); + + b.Property("LastVisitedBoardId") + .HasColumnType("int"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("NeedsNewCredentials") + .HasColumnType("tinyint(1)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("ProfilePictureFileName") + .HasColumnType("longtext"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CardLabel", b => + { + b.HasOne("Ticky.Base.Entities.Label", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.Card", null) + .WithMany() + .HasForeignKey("OnCardsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CardUser", b => + { + b.HasOne("Ticky.Base.Entities.Card", null) + .WithMany() + .HasForeignKey("AssignedToId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.User", null) + .WithMany() + .HasForeignKey("AssigneesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Ticky.Base.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Ticky.Base.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Ticky.Base.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SubtaskUser", b => + { + b.HasOne("Ticky.Base.Entities.User", null) + .WithMany() + .HasForeignKey("AssigneesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.Subtask", null) + .WithMany() + .HasForeignKey("SubtasksId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Activity", b => + { + b.HasOne("Ticky.Base.Entities.Card", "Card") + .WithMany("Activities") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.Column", "FromColumn") + .WithMany() + .HasForeignKey("FromColumnId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Ticky.Base.Entities.Column", "ToColumn") + .WithMany() + .HasForeignKey("ToColumnId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Ticky.Base.Entities.User", "User") + .WithMany("Activities") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + + b.Navigation("FromColumn"); + + b.Navigation("ToColumn"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Attachment", b => + { + b.HasOne("Ticky.Base.Entities.Card", "Card") + .WithMany("Attachments") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Board", b => + { + b.HasOne("Ticky.Base.Entities.Project", "Project") + .WithMany("Boards") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.BoardMembership", b => + { + b.HasOne("Ticky.Base.Entities.Board", "Board") + .WithMany("Memberships") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.User", "User") + .WithMany("BoardMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Card", b => + { + b.HasOne("Ticky.Base.Entities.Column", "Column") + .WithMany("Cards") + .HasForeignKey("ColumnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.User", "CreatedBy") + .WithMany("CreatedCards") + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Ticky.Base.Entities.Owned.RepeatInfo", "RepeatInfo", b1 => + { + b1.Property("CardId") + .HasColumnType("int"); + + b1.Property("CardPlacement") + .HasColumnType("int"); + + b1.Property("LastRepeat") + .HasColumnType("datetime(6)"); + + b1.Property("Number") + .HasColumnType("int"); + + b1.Property("Selected") + .HasColumnType("longtext"); + + b1.Property("TargetColumnId") + .HasColumnType("int"); + + b1.Property("Time") + .HasColumnType("time(6)"); + + b1.Property("Type") + .HasColumnType("int"); + + b1.HasKey("CardId"); + + b1.ToTable("Cards"); + + b1.WithOwner() + .HasForeignKey("CardId"); + }); + + b.Navigation("Column"); + + b.Navigation("CreatedBy"); + + b.Navigation("RepeatInfo"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.CardLink", b => + { + b.HasOne("Ticky.Base.Entities.Card", "CardOne") + .WithMany("LinkedIssuesOne") + .HasForeignKey("CardOneId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.Card", "CardTwo") + .WithMany("LinkedIssuesTwo") + .HasForeignKey("CardTwoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CardOne"); + + b.Navigation("CardTwo"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Code", b => + { + b.HasOne("Ticky.Base.Entities.User", "User") + .WithOne("EmailVerificationCode") + .HasForeignKey("Ticky.Base.Entities.Code", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Column", b => + { + b.HasOne("Ticky.Base.Entities.Board", "Board") + .WithMany("Columns") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Comment", b => + { + b.HasOne("Ticky.Base.Entities.Card", "Card") + .WithMany("Comments") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.User", "CreatedBy") + .WithMany("CreatedComments") + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Favorite", b => + { + b.HasOne("Ticky.Base.Entities.Board", "Board") + .WithMany("Favorites") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.User", "User") + .WithMany("Favorites") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Label", b => + { + b.HasOne("Ticky.Base.Entities.Board", "Board") + .WithMany("Labels") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.LastVisit", b => + { + b.HasOne("Ticky.Base.Entities.Board", "Board") + .WithMany("LastVisits") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.User", "User") + .WithMany("LastVisits") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.ProjectMembership", b => + { + b.HasOne("Ticky.Base.Entities.Project", "Project") + .WithMany("Memberships") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.User", "User") + .WithMany("ProjectMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Reminder", b => + { + b.HasOne("Ticky.Base.Entities.Card", "Card") + .WithMany("Reminders") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Subtask", b => + { + b.HasOne("Ticky.Base.Entities.Card", "Card") + .WithMany("Subtasks") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.TimeRecord", b => + { + b.HasOne("Ticky.Base.Entities.Card", "Card") + .WithMany("TimeRecords") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.User", "User") + .WithMany("TimeRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Board", b => + { + b.Navigation("Columns"); + + b.Navigation("Favorites"); + + b.Navigation("Labels"); + + b.Navigation("LastVisits"); + + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Card", b => + { + b.Navigation("Activities"); + + b.Navigation("Attachments"); + + b.Navigation("Comments"); + + b.Navigation("LinkedIssuesOne"); + + b.Navigation("LinkedIssuesTwo"); + + b.Navigation("Reminders"); + + b.Navigation("Subtasks"); + + b.Navigation("TimeRecords"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Column", b => + { + b.Navigation("Cards"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.Project", b => + { + b.Navigation("Boards"); + + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("Ticky.Base.Entities.User", b => + { + b.Navigation("Activities"); + + b.Navigation("BoardMemberships"); + + b.Navigation("CreatedCards"); + + b.Navigation("CreatedComments"); + + b.Navigation("EmailVerificationCode"); + + b.Navigation("Favorites"); + + b.Navigation("LastVisits"); + + b.Navigation("ProjectMemberships"); + + b.Navigation("TimeRecords"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Ticky.Internal/Migrations/20260919102234_ActivityMovePayload.cs b/Ticky.Internal/Migrations/20260919102234_ActivityMovePayload.cs new file mode 100644 index 0000000..7689ef6 --- /dev/null +++ b/Ticky.Internal/Migrations/20260919102234_ActivityMovePayload.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ticky.Internal.Migrations +{ + /// + public partial class ActivityMovePayload : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ActivityType", + table: "Activities", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FromColumnId", + table: "Activities", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "FromColumnName", + table: "Activities", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "ToColumnFinished", + table: "Activities", + type: "tinyint(1)", + nullable: true); + + migrationBuilder.AddColumn( + name: "ToColumnId", + table: "Activities", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "ToColumnName", + table: "Activities", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Activities_ActivityType_CreatedAt", + table: "Activities", + columns: new[] { "ActivityType", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Activities_FromColumnId", + table: "Activities", + column: "FromColumnId"); + + migrationBuilder.CreateIndex( + name: "IX_Activities_ToColumnId", + table: "Activities", + column: "ToColumnId"); + + migrationBuilder.AddForeignKey( + name: "FK_Activities_Columns_FromColumnId", + table: "Activities", + column: "FromColumnId", + principalTable: "Columns", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_Activities_Columns_ToColumnId", + table: "Activities", + column: "ToColumnId", + principalTable: "Columns", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Activities_Columns_FromColumnId", + table: "Activities"); + + migrationBuilder.DropForeignKey( + name: "FK_Activities_Columns_ToColumnId", + table: "Activities"); + + migrationBuilder.DropIndex( + name: "IX_Activities_ActivityType_CreatedAt", + table: "Activities"); + + migrationBuilder.DropIndex( + name: "IX_Activities_FromColumnId", + table: "Activities"); + + migrationBuilder.DropIndex( + name: "IX_Activities_ToColumnId", + table: "Activities"); + + migrationBuilder.DropColumn( + name: "ActivityType", + table: "Activities"); + + migrationBuilder.DropColumn( + name: "FromColumnId", + table: "Activities"); + + migrationBuilder.DropColumn( + name: "FromColumnName", + table: "Activities"); + + migrationBuilder.DropColumn( + name: "ToColumnFinished", + table: "Activities"); + + migrationBuilder.DropColumn( + name: "ToColumnId", + table: "Activities"); + + migrationBuilder.DropColumn( + name: "ToColumnName", + table: "Activities"); + } + } +} diff --git a/Ticky.Internal/Migrations/DataContextModelSnapshot.cs b/Ticky.Internal/Migrations/DataContextModelSnapshot.cs index 60d0100..efa11d0 100644 --- a/Ticky.Internal/Migrations/DataContextModelSnapshot.cs +++ b/Ticky.Internal/Migrations/DataContextModelSnapshot.cs @@ -226,16 +226,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + b.Property("ActivityType") + .HasColumnType("int"); + b.Property("CardId") .HasColumnType("int"); b.Property("CreatedAt") .HasColumnType("datetime(6)"); + b.Property("FromColumnId") + .HasColumnType("int"); + + b.Property("FromColumnName") + .HasColumnType("longtext"); + b.Property("Text") .IsRequired() .HasColumnType("longtext"); + b.Property("ToColumnFinished") + .HasColumnType("tinyint(1)"); + + b.Property("ToColumnId") + .HasColumnType("int"); + + b.Property("ToColumnName") + .HasColumnType("longtext"); + b.Property("UserId") .HasColumnType("int"); @@ -243,8 +261,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("CardId"); + b.HasIndex("FromColumnId"); + + b.HasIndex("ToColumnId"); + b.HasIndex("UserId"); + b.HasIndex("ActivityType", "CreatedAt"); + b.ToTable("Activities"); }); @@ -963,6 +987,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("Ticky.Base.Entities.Column", "FromColumn") + .WithMany() + .HasForeignKey("FromColumnId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Ticky.Base.Entities.Column", "ToColumn") + .WithMany() + .HasForeignKey("ToColumnId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Ticky.Base.Entities.User", "User") .WithMany("Activities") .HasForeignKey("UserId") @@ -971,6 +1005,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Card"); + b.Navigation("FromColumn"); + + b.Navigation("ToColumn"); + b.Navigation("User"); }); diff --git a/Ticky.Internal/Services/Hosted/RepeatHostedService.cs b/Ticky.Internal/Services/Hosted/RepeatHostedService.cs index e63b758..e5b458f 100644 --- a/Ticky.Internal/Services/Hosted/RepeatHostedService.cs +++ b/Ticky.Internal/Services/Hosted/RepeatHostedService.cs @@ -108,6 +108,7 @@ protected override async Task OnRun() new() { CardId = newCard.Id, + ActivityType = ActivityType.RepeatedFrom, Text = $"was repeated from card {identifier}", UserId = card.CreatedById } @@ -166,6 +167,7 @@ protected override async Task OnRun() new() { CardId = card.Id, + ActivityType = ActivityType.RepeatCardCreated, Text = $"created repeat card {newIdentifier}", UserId = card.CreatedById } diff --git a/Ticky.Units/ActivityTest.cs b/Ticky.Units/ActivityTest.cs new file mode 100644 index 0000000..3d70715 --- /dev/null +++ b/Ticky.Units/ActivityTest.cs @@ -0,0 +1,104 @@ +namespace Ticky.Units; + +public class ActivityTest +{ + private static Column CreateColumn(int id, string name, bool finished) => + new() + { + Id = id, + Name = name, + BoardId = 1, + Index = 0, + Finished = finished, + }; + + [Test] + public void CardMoved_SetsTypeAndSnapshotsColumns() + { + var from = CreateColumn(10, "In progress", false); + var to = CreateColumn(11, "Done", true); + + var activity = Activity.CardMoved(5, 7, from, to); + + Assert.Multiple(() => + { + Assert.That(activity.ActivityType, Is.EqualTo(ActivityType.CardMoved)); + Assert.That(activity.CardId, Is.EqualTo(5)); + Assert.That(activity.UserId, Is.EqualTo(7)); + Assert.That(activity.FromColumnId, Is.EqualTo(10)); + Assert.That(activity.FromColumnName, Is.EqualTo("In progress")); + Assert.That(activity.ToColumnId, Is.EqualTo(11)); + Assert.That(activity.ToColumnName, Is.EqualTo("Done")); + Assert.That(activity.ToColumnFinished, Is.True); + }); + } + + [Test] + public void CardMoved_KeepsExistingTimelineText() + { + var activity = Activity.CardMoved(1, 1, CreateColumn(1, "Todo", false), CreateColumn(2, "Doing", false)); + + Assert.That(activity.Text, Is.EqualTo("moved the card to Doing")); + } + + [Test] + public void CardMoved_SnapshotDoesNotFollowLaterColumnChanges() + { + var to = CreateColumn(2, "Done", true); + var activity = Activity.CardMoved(1, 1, CreateColumn(1, "Todo", false), to); + + to.Name = "Archive"; + to.Finished = false; + + Assert.Multiple(() => + { + Assert.That(activity.ToColumnName, Is.EqualTo("Done")); + Assert.That(activity.ToColumnFinished, Is.True); + }); + } + + [Test] + public void NonMoveActivity_HasNoMovePayload() + { + var activity = new Activity + { + ActivityType = ActivityType.TitleChanged, + Text = "changed the title", + UserId = 1, + CardId = 1, + }; + + Assert.Multiple(() => + { + Assert.That(activity.FromColumnId, Is.Null); + Assert.That(activity.ToColumnId, Is.Null); + Assert.That(activity.ToColumnFinished, Is.Null); + }); + } + + [Test] + public void ActivityType_PersistedValuesAreStable() + { + // Rows store the int value; renumbering would silently relabel existing history. + Assert.Multiple(() => + { + Assert.That((int)ActivityType.Generic, Is.EqualTo(0)); + Assert.That((int)ActivityType.CardMoved, Is.EqualTo(1)); + Assert.That((int)ActivityType.RepeatCardCreated, Is.EqualTo(32)); + Assert.That(Enum.GetValues().Select(x => (int)x), Is.EqualTo(Enumerable.Range(0, 33))); + }); + } + + [Test] + public void ActivityType_AllValuesHaveDisplayName() + { + foreach (var type in Enum.GetValues()) + { + var display = typeof(ActivityType) + .GetField(type.ToString())! + .GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.DisplayAttribute), false); + + Assert.That(display, Has.Length.EqualTo(1), type.ToString()); + } + } +} diff --git a/Ticky.Units/BurndownHelperTest.cs b/Ticky.Units/BurndownHelperTest.cs new file mode 100644 index 0000000..5c93aaf --- /dev/null +++ b/Ticky.Units/BurndownHelperTest.cs @@ -0,0 +1,82 @@ +using Ticky.Internal.Helpers; + +namespace Ticky.Units; + +public class BurndownHelperTest +{ + [Test] + public void GetOpenPerDay_NoCards_ReturnsEmpty() + { + var result = BurndownHelper.GetOpenPerDay([], [], new DateTime(2025, 1, 5)); + + Assert.That(result, Is.Empty); + } + + [Test] + public void GetOpenPerDay_CountsCreatedMinusCompletedPerDay() + { + var created = new List + { + new(2025, 1, 1, 9, 0, 0), + new(2025, 1, 1, 17, 0, 0), + new(2025, 1, 3, 12, 0, 0), + }; + var completed = new List { new(2025, 1, 2, 23, 59, 0), new(2025, 1, 4, 8, 0, 0) }; + + var result = BurndownHelper.GetOpenPerDay(created, completed, new DateTime(2025, 1, 5, 10, 0, 0)); + + Assert.That(result.Select(x => x.Date), Is.EqualTo(Enumerable.Range(1, 5).Select(d => new DateTime(2025, 1, d)))); + Assert.That(result.Select(x => x.Open), Is.EqualTo(new[] { 2, 1, 2, 1, 1 })); + } + + [Test] + public void GetOpenPerDay_SingleDay_ReturnsOnePoint() + { + var day = new DateTime(2025, 1, 1, 10, 0, 0); + + var result = BurndownHelper.GetOpenPerDay([day], [], day); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Open, Is.EqualTo(1)); + } + + [Test] + public void GetOpenPerDay_NeverNegative() + { + var created = new List { new(2025, 1, 2) }; + var completed = new List { new(2025, 1, 2), new(2025, 1, 2) }; + + var result = BurndownHelper.GetOpenPerDay(created, completed, new DateTime(2025, 1, 3)); + + Assert.That(result.Select(x => x.Open), Is.All.EqualTo(0)); + } + + [Test] + public void RenderSvg_NoPoints_ReturnsEmpty() + { + Assert.That(BurndownHelper.RenderSvg([]), Is.Empty); + } + + [Test] + [SetCulture("nl-BE")] + public void RenderSvg_CommaDecimalCulture_UsesDotsInCoordinates() + { + // A max of 7 over the 192px chart height gives fractional y coordinates + var points = new List<(DateTime Date, int Open)> + { + (new DateTime(2025, 1, 1), 7), + (new DateTime(2025, 1, 2), 5), + (new DateTime(2025, 1, 3), 2), + }; + + var svg = BurndownHelper.RenderSvg(points); + + Assert.Multiple(() => + { + Assert.That(svg, Does.Contain("points=\"48.0,16.0 396.0,70.9 744.0,153.1\"")); + Assert.That(svg, Does.Contain("cx=\"744.0\" cy=\"153.1\"")); + Assert.That(svg, Does.Contain(">01/01<")); + Assert.That(svg, Does.Not.Match(@"=""\d+,\d""")); + }); + } +} diff --git a/Ticky.Web/Components/Dialogs/AddOrEditRepeatModal.razor b/Ticky.Web/Components/Dialogs/AddOrEditRepeatModal.razor index 8861a93..28fffec 100644 --- a/Ticky.Web/Components/Dialogs/AddOrEditRepeatModal.razor +++ b/Ticky.Web/Components/Dialogs/AddOrEditRepeatModal.razor @@ -179,6 +179,7 @@ card.Activities.Add(new Activity { + ActivityType = ActivityType.RepeatSet, Text = $"set up card repeat for this card for {card.RepeatInfo.Type.GetDisplayName()}", UserId = _user.Id, CardId = card.Id, diff --git a/Ticky.Web/Components/Dialogs/AddReminderModal.razor b/Ticky.Web/Components/Dialogs/AddReminderModal.razor index 0aeccea..15e04b5 100644 --- a/Ticky.Web/Components/Dialogs/AddReminderModal.razor +++ b/Ticky.Web/Components/Dialogs/AddReminderModal.razor @@ -68,6 +68,7 @@ card.Reminders.Add(newReminder); card.Activities.Add(new Activity { + ActivityType = ActivityType.ReminderAdded, Text = $"added a reminder for {newReminder.At.ToReadableStringWithTime()}", UserId = _user.Id, CardId = card.Id, diff --git a/Ticky.Web/Components/Dialogs/AddSubtaskModal.razor b/Ticky.Web/Components/Dialogs/AddSubtaskModal.razor index 0c99b9e..072a043 100644 --- a/Ticky.Web/Components/Dialogs/AddSubtaskModal.razor +++ b/Ticky.Web/Components/Dialogs/AddSubtaskModal.razor @@ -60,6 +60,7 @@ }); card.Activities.Add(new Activity { + ActivityType = ActivityType.SubtaskAdded, Text = $"added subtask {_subtaskModel.Text}", UserId = _user.Id, CardId = card.Id, diff --git a/Ticky.Web/Components/Dialogs/AddTimeRecordModal.razor b/Ticky.Web/Components/Dialogs/AddTimeRecordModal.razor index 8839727..26d643f 100644 --- a/Ticky.Web/Components/Dialogs/AddTimeRecordModal.razor +++ b/Ticky.Web/Components/Dialogs/AddTimeRecordModal.razor @@ -66,6 +66,7 @@ card.TimeRecords.Add(timeRecord); card.Activities.Add(new Activity { + ActivityType = ActivityType.TimeRecordAdded, Text = $"added a time record for {(timeRecord.EndedAt.Value-timeRecord.StartedAt).ToElapsedString()}", UserId = _user.Id, CardId = card.Id, diff --git a/Ticky.Web/Components/Dialogs/AssigneesModal.razor b/Ticky.Web/Components/Dialogs/AssigneesModal.razor index f04720c..715361f 100644 --- a/Ticky.Web/Components/Dialogs/AssigneesModal.razor +++ b/Ticky.Web/Components/Dialogs/AssigneesModal.razor @@ -66,6 +66,7 @@ targetCard.Assignees.Remove(targetUser); targetCard.Activities.Add(new Activity { + ActivityType = ActivityType.AssigneeRemoved, Text = $"removed the user {targetUser.DisplayName} from the assignees", UserId = _user.Id, CardId = targetCard.Id, @@ -76,6 +77,7 @@ targetCard.Assignees.Add(targetUser); targetCard.Activities.Add(new Activity { + ActivityType = ActivityType.AssigneeAdded, Text = $"added the user {targetUser.DisplayName} as an assignee", UserId = _user.Id, CardId = targetCard.Id, @@ -108,6 +110,7 @@ targetSubtask.Assignees.Remove(targetUser); targetSubtask.Card.Activities.Add(new Activity { + ActivityType = ActivityType.SubtaskAssigneeRemoved, Text = $"removed the user {targetUser.DisplayName} from the subtask assignees", UserId = _user.Id, CardId = targetSubtask.CardId, @@ -118,6 +121,7 @@ targetSubtask.Assignees.Add(targetUser); targetSubtask.Card.Activities.Add(new Activity { + ActivityType = ActivityType.SubtaskAssigneeAdded, Text = $"added the user {targetUser.DisplayName} as a subtask assignee", UserId = _user.Id, CardId = targetSubtask.CardId, diff --git a/Ticky.Web/Components/Dialogs/EditCardModal.razor b/Ticky.Web/Components/Dialogs/EditCardModal.razor index 7f727aa..587ce01 100644 --- a/Ticky.Web/Components/Dialogs/EditCardModal.razor +++ b/Ticky.Web/Components/Dialogs/EditCardModal.razor @@ -553,6 +553,7 @@ card.Name = e.Value?.ToString() ?? string.Empty; card.Activities.Add(new Activity { + ActivityType = ActivityType.TitleChanged, Text = "changed the title", UserId = _user.Id, CardId = card.Id, @@ -582,6 +583,7 @@ card.Description = e.Value?.ToString() ?? string.Empty; card.Activities.Add(new Activity { + ActivityType = ActivityType.DescriptionChanged, Text = "changed the description", UserId = _user.Id, CardId = card.Id, @@ -650,6 +652,7 @@ }); card.Activities.Add(new Activity { + ActivityType = ActivityType.CommentPosted, Text = "posted a comment", UserId = _user.Id, CardId = card.Id, @@ -725,6 +728,7 @@ db.Activities.Add(new Activity { + ActivityType = ActivityType.TimeRecordDeleted, Text = $"deleted time record of {(timeRecord.EndedAt!.Value - timeRecord.StartedAt).ToElapsedString()}", UserId = _user.Id, CardId = _card.Id, @@ -753,13 +757,8 @@ if (targetColumn is null) return; + card.Activities.Add(Activity.CardMoved(card.Id, _user.Id, card.Column, targetColumn)); card.Column = targetColumn; - card.Activities.Add(new Activity - { - Text = $"moved the card to {targetColumn.Name}", - UserId = _user.Id, - CardId = card.Id, - }); await db.SaveChangesAsync(); @@ -785,6 +784,7 @@ card.Assignees.Add(targetUser); card.Activities.Add(new Activity { + ActivityType = ActivityType.AssigneeAdded, Text = $"joined the card", UserId = _user.Id, CardId = card.Id, @@ -814,6 +814,7 @@ card.Assignees.Remove(targetUser); card.Activities.Add(new Activity { + ActivityType = ActivityType.AssigneeRemoved, Text = $"left the card", UserId = _user.Id, CardId = card.Id, @@ -843,13 +844,8 @@ if (targetColumn is null) return; + card.Activities.Add(Activity.CardMoved(card.Id, _user.Id, card.Column, targetColumn)); card.Column = targetColumn; - card.Activities.Add(new Activity - { - Text = $"moved the card to {targetColumn.Name}", - UserId = _user.Id, - CardId = card.Id, - }); await db.SaveChangesAsync(); @@ -888,6 +884,7 @@ card.Attachments.RemoveAll(x => x.Id.Equals(attachment.Id)); card.Activities.Add(new Activity { + ActivityType = ActivityType.AttachmentDeleted, Text = $"deleted file named {attachment.OriginalName}", UserId = _user.Id, CardId = card.Id, @@ -913,6 +910,7 @@ card.Reminders.RemoveAll(x => x.Id.Equals(reminder.Id)); card.Activities.Add(new Activity { + ActivityType = ActivityType.ReminderRemoved, Text = $"deleted the reminder at {reminder.At.ToReadableStringWithTime()}", UserId = _user.Id, CardId = card.Id, @@ -936,6 +934,7 @@ card.Deadline = null; card.Activities.Add(new Activity { + ActivityType = ActivityType.DeadlineRemoved, Text = "deleted the deadline", UserId = _user.Id, CardId = card.Id, @@ -1049,6 +1048,7 @@ targetLink.CardTwo.Activities.Add(new Activity { + ActivityType = ActivityType.LinkRemoved, Text = $"deleted a linked issue {targetLink.CardOne.Column.Board.Code}-{targetLink.CardOne.Number} with {targetLink.Category} relationship", UserId = _user.Id, CardId = targetLink.CardTwoId, @@ -1059,6 +1059,7 @@ targetLink.CardOne.Activities.Add(new Activity { + ActivityType = ActivityType.LinkRemoved, Text = $"deleted a linked issue {targetLink.CardTwo.Column.Board.Code}-{targetLink.CardTwo.Number} with {targetLink.Category} relationship", UserId = _user.Id, CardId = targetLink.CardOneId, @@ -1127,6 +1128,7 @@ }); card.Activities.Add(new Activity { + ActivityType = ActivityType.AttachmentUploaded, Text = $"uploaded file named {cleanFileName}", UserId = _user.Id, CardId = card.Id, @@ -1266,6 +1268,7 @@ card.SnoozedUntil = null; card.Activities.Add(new Activity { + ActivityType = ActivityType.Unsnoozed, Text = $"unsnoozed this card", UserId = _user.Id, CardId = card.Id, @@ -1289,6 +1292,7 @@ card.RepeatInfo = null; card.Activities.Add(new Activity { + ActivityType = ActivityType.RepeatDisabled, Text = $"turned repeating off for this card", UserId = _user.Id, CardId = card.Id, diff --git a/Ticky.Web/Components/Dialogs/EditDeadlineModal.razor b/Ticky.Web/Components/Dialogs/EditDeadlineModal.razor index 085f9f5..75cb52a 100644 --- a/Ticky.Web/Components/Dialogs/EditDeadlineModal.razor +++ b/Ticky.Web/Components/Dialogs/EditDeadlineModal.razor @@ -66,6 +66,7 @@ card.DeadlineProcessed = false; card.Activities.Add(new Activity { + ActivityType = ActivityType.DeadlineSet, Text = $"set the deadline for {card.Deadline.Value.ToReadableStringWithTime()}", UserId = _user.Id, CardId = card.Id, diff --git a/Ticky.Web/Components/Dialogs/EditSubtaskModal.razor b/Ticky.Web/Components/Dialogs/EditSubtaskModal.razor index a0cdf57..a45f083 100644 --- a/Ticky.Web/Components/Dialogs/EditSubtaskModal.razor +++ b/Ticky.Web/Components/Dialogs/EditSubtaskModal.razor @@ -57,6 +57,7 @@ subtask.Card.Activities.Add(new Activity { + ActivityType = ActivityType.SubtaskEdited, Text = $"edited subtask {subtask.Text} to {_subtaskModel.Text}", UserId = _user.Id, CardId = subtask.CardId, diff --git a/Ticky.Web/Components/Dialogs/EditTimeRecordModal.razor b/Ticky.Web/Components/Dialogs/EditTimeRecordModal.razor index 19743eb..83ce688 100644 --- a/Ticky.Web/Components/Dialogs/EditTimeRecordModal.razor +++ b/Ticky.Web/Components/Dialogs/EditTimeRecordModal.razor @@ -66,6 +66,7 @@ timeRecord.Card.Activities.Add(new Activity { + ActivityType = ActivityType.TimeRecordChanged, Text = $"changed the time record from {_oldTimeRecord} to {_timeRecordModel.Time}", UserId = _user.Id, CardId = timeRecord.CardId, diff --git a/Ticky.Web/Components/Dialogs/LabelModal.razor b/Ticky.Web/Components/Dialogs/LabelModal.razor index bff4d49..2e73bdd 100644 --- a/Ticky.Web/Components/Dialogs/LabelModal.razor +++ b/Ticky.Web/Components/Dialogs/LabelModal.razor @@ -67,6 +67,7 @@ card.Labels.RemoveAll(x => x.Id.Equals(label.Id)); card.Activities.Add(new Activity { + ActivityType = ActivityType.LabelRemoved, Text = $"removed label {label.Name}", UserId = _user.Id, CardId = card.Id, @@ -80,6 +81,7 @@ card.Labels.Add(targetLabel); card.Activities.Add(new Activity { + ActivityType = ActivityType.LabelAdded, Text = $"added label {label.Name}", UserId = _user.Id, CardId = card.Id, diff --git a/Ticky.Web/Components/Dialogs/LinkCardsModal.razor b/Ticky.Web/Components/Dialogs/LinkCardsModal.razor index 1b7167d..de163e7 100644 --- a/Ticky.Web/Components/Dialogs/LinkCardsModal.razor +++ b/Ticky.Web/Components/Dialogs/LinkCardsModal.razor @@ -129,12 +129,14 @@ card.Activities.Add(new Activity { + ActivityType = ActivityType.LinkAdded, Text = $"added a linked issue {targetCard.Column.Board.Code}-{targetCard.Number} with {_linkCardsModel.Text} relationship", UserId = _user.Id, CardId = card.Id, }); targetCard.Activities.Add(new Activity { + ActivityType = ActivityType.LinkAdded, Text = $"added a linked issue {card.Column.Board.Code}-{card.Number} with {oppositeCategory} relationship", UserId = _user.Id, CardId = card.Id, diff --git a/Ticky.Web/Components/Dialogs/PriorityModal.razor b/Ticky.Web/Components/Dialogs/PriorityModal.razor index 8de17ae..fdbb081 100644 --- a/Ticky.Web/Components/Dialogs/PriorityModal.razor +++ b/Ticky.Web/Components/Dialogs/PriorityModal.razor @@ -68,6 +68,7 @@ card.Priority = priority; card.Activities.Add(new Activity { + ActivityType = ActivityType.PriorityChanged, Text = $"changed the priority to {priority}", UserId = _user.Id, CardId = card.Id, diff --git a/Ticky.Web/Components/Dialogs/SnoozeCardModal.razor b/Ticky.Web/Components/Dialogs/SnoozeCardModal.razor index 98b7478..5b58d9f 100644 --- a/Ticky.Web/Components/Dialogs/SnoozeCardModal.razor +++ b/Ticky.Web/Components/Dialogs/SnoozeCardModal.razor @@ -65,6 +65,7 @@ card.SnoozedUntil = new DateTime(_snoozeCardModel.At.Year, _snoozeCardModel.At.Month, _snoozeCardModel.At.Day, time.Hour, time.Minute, 0); card.Activities.Add(new Activity { + ActivityType = ActivityType.Snoozed, Text = $"snoozed this card until {card.SnoozedUntil.Value.ToReadableStringWithTime()}", UserId = _user.Id, CardId = card.Id, diff --git a/Ticky.Web/Components/Elements/ColumnView.razor b/Ticky.Web/Components/Elements/ColumnView.razor index 97f8695..23483d2 100644 --- a/Ticky.Web/Components/Elements/ColumnView.razor +++ b/Ticky.Web/Components/Elements/ColumnView.razor @@ -337,6 +337,8 @@ IndexHelper.FixIndices(dbNewColumn.Cards); + db.Activities.Add(Activity.CardMoved(movedCardId, User.Id, dbOldColumn, dbNewColumn)); + await db.SaveChangesAsync(); await OnUpdate.InvokeAsync(); } diff --git a/Ticky.Web/Components/Elements/SubtaskView.razor b/Ticky.Web/Components/Elements/SubtaskView.razor index e7aa8bd..cd8a18d 100644 --- a/Ticky.Web/Components/Elements/SubtaskView.razor +++ b/Ticky.Web/Components/Elements/SubtaskView.razor @@ -71,6 +71,7 @@ target.Card.Activities.Add(new Activity { + ActivityType = ActivityType.SubtaskCompletionChanged, Text = $"marked the subtask {target.Text} as {(target.Completed ? "completed" : "to be done")}", UserId = _user.Id, CardId = target.CardId, @@ -102,6 +103,7 @@ sub.Card.Activities.Add(new Activity { + ActivityType = ActivityType.SubtaskDeleted, Text = $"deleted subtask {sub.Text}", UserId = _user.Id, CardId = sub.CardId, diff --git a/Ticky.Web/Components/Pages/BoardHistory.razor b/Ticky.Web/Components/Pages/BoardHistory.razor new file mode 100644 index 0000000..25a70b3 --- /dev/null +++ b/Ticky.Web/Components/Pages/BoardHistory.razor @@ -0,0 +1,243 @@ +@page "/boards/{Id:int}/history" +@inject IDbContextFactory _dbContextFactory +@inject NavigationManager _navigationManager + +@if (_board is not null) +{ + History - @_board.Name - @Constants.APP_NAME +} +else +{ + History - @Constants.APP_NAME +} + +
+
+ + + Back to board + + @(_board?.Name ?? "Loading...") + Activity & burndown +
+ + @if (_board is null) + { + + } + else if (!_burndownPoints.Any()) + { +
+ No cards found on this board yet. +
+ } + else + { +
+

Burndown

+
+ Total @_totalCards + Open @(_totalCards - _doneCards) + Done @_doneCards +
+
+ @((MarkupString)BurndownHelper.RenderSvg(_burndownPoints)) +
+
+ Ideal + Actual open cards +
+
+ +
+
+

Activity

+ +
+ @if (!_feed.Any()) + { +

+ @(_feedFilter == ActivityType.CardMoved + ? "No moves recorded yet. Move a card between columns to start building history." + : "No activity recorded yet.") +

+ } + else + { +
+ @foreach (var entry in _feed) + { +
+
+ @entry.CardName + + @if (entry.ActivityType == ActivityType.CardMoved) + { + @entry.FromColumnName + + @entry.ToColumnName + } + else + { + @((MarkupString)entry.Text) + } + +
+
+ @entry.UserName + @entry.CreatedAt.ToElapsedString() +
+
+ } +
+ @if (_feedHasMore) + { + + } + } +
+ } +
+ +@code { + [CascadingParameter(Name = Constants.CascadingParameters.CurrentAccount)] + private User _user { get; set; } = default!; + + [Parameter] + public int Id { get; set; } + + private Board? _board; + private List<(DateTime Date, int Open)> _burndownPoints = []; + private int _totalCards; + private int _doneCards; + + private const int FEED_PAGE_SIZE = 50; + private List _feed = []; + private ActivityType? _feedFilter; + private int _feedLimit = FEED_PAGE_SIZE; + private bool _feedHasMore; + + private class FeedEntry + { + public int CardId { get; init; } + public string CardName { get; init; } = string.Empty; + public ActivityType ActivityType { get; init; } + public string Text { get; init; } = string.Empty; + public string FromColumnName { get; init; } = string.Empty; + public string ToColumnName { get; init; } = string.Empty; + public string UserName { get; init; } = string.Empty; + public DateTime CreatedAt { get; init; } + } + + protected override async Task OnParametersSetAsync() + { + _board = null; + _burndownPoints = []; + _feed = []; + _feedLimit = FEED_PAGE_SIZE; + await LoadAsync(); + } + + private async Task LoadAsync() + { + using var db = _dbContextFactory.CreateDbContext(); + + var board = await db.Boards + .Include(x => x.Columns) + .Include(x => x.Project).ThenInclude(x => x.Memberships) + .Include(x => x.Memberships) + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == Id); + + if (board is null || !board.VerifyAccess(_user)) + { + _navigationManager.NavigateTo("/"); + return; + } + + _board = board; + StateHasChanged(); + + var cards = await db.Cards + .Where(c => c.Column.BoardId == Id) + .Select(c => new { c.Id, c.CreatedAt }) + .AsNoTracking() + .ToListAsync(); + + if (!cards.Any()) + { + StateHasChanged(); + return; + } + + // A card counts as done from the first time it entered a column that was Finished at move-time + var completionDates = await db.Activities + .Where(a => a.ActivityType == ActivityType.CardMoved && a.ToColumnFinished == true && a.Card.Column.BoardId == Id) + .GroupBy(a => a.CardId) + .Select(g => g.Min(a => a.CreatedAt)) + .ToListAsync(); + + _burndownPoints = BurndownHelper.GetOpenPerDay( + cards.Select(c => c.CreatedAt).ToList(), + completionDates, + DateTime.Today); + + _totalCards = cards.Count; + _doneCards = completionDates.Count; + + await LoadFeedAsync(db); + StateHasChanged(); + } + + private async Task LoadFeedAsync(DataContext db) + { + var query = db.Activities.Where(a => a.Card.Column.BoardId == Id); + + if (_feedFilter is not null) + query = query.Where(a => a.ActivityType == _feedFilter); + + // Fetch one extra row to know whether "Load more" should be shown + var entries = await query + .OrderByDescending(a => a.CreatedAt) + .ThenByDescending(a => a.Id) + .Take(_feedLimit + 1) + .Select(a => new FeedEntry + { + CardId = a.CardId, + CardName = a.Card.Name, + ActivityType = a.ActivityType, + Text = a.Text, + FromColumnName = a.FromColumnName ?? string.Empty, + ToColumnName = a.ToColumnName ?? string.Empty, + UserName = a.User.DisplayName, + CreatedAt = a.CreatedAt, + }) + .ToListAsync(); + + _feedHasMore = entries.Count > _feedLimit; + _feed = entries.Take(_feedLimit).ToList(); + } + + private async Task OnFeedFilterChanged(ChangeEventArgs e) + { + _feedFilter = Enum.TryParse(e.Value?.ToString(), out var type) ? type : null; + _feedLimit = FEED_PAGE_SIZE; + + using var db = _dbContextFactory.CreateDbContext(); + await LoadFeedAsync(db); + } + + private async Task LoadMoreFeedAsync() + { + _feedLimit += FEED_PAGE_SIZE; + + using var db = _dbContextFactory.CreateDbContext(); + await LoadFeedAsync(db); + } +} diff --git a/Ticky.Web/Components/Pages/BoardView.razor b/Ticky.Web/Components/Pages/BoardView.razor index 6f1b216..9ec721c 100644 --- a/Ticky.Web/Components/Pages/BoardView.razor +++ b/Ticky.Web/Components/Pages/BoardView.razor @@ -64,6 +64,7 @@ } + }
diff --git a/Ticky.Web/Controllers/AttachmentsController.cs b/Ticky.Web/Controllers/AttachmentsController.cs index ad63aed..a24935a 100644 --- a/Ticky.Web/Controllers/AttachmentsController.cs +++ b/Ticky.Web/Controllers/AttachmentsController.cs @@ -136,6 +136,7 @@ public async Task Upload([FromForm] IFormFile? file, [FromForm] i card.Activities.Add( new Activity { + ActivityType = ActivityType.AttachmentUploaded, Text = $"uploaded file named {safeFileName}", UserId = userId, CardId = cardId