From dd5bb0255c04b62293685f9bdf910105577e0edb Mon Sep 17 00:00:00 2001 From: Kasper Daems Date: Sat, 20 Jun 2026 14:10:23 +0200 Subject: [PATCH 1/7] feat: track card column history for burndown charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CardColumnHistory table that records every cross-column card move: who moved it, from/to column names + IDs, and a timestamp (inherited from AbstractDbEntity.CreatedAt). Column names are stored at move-time so history survives future column renames or deletes. Nullable FKs (SetNull on column/user delete) protect history from cascade deletion. Hooks into both move paths: - ColumnView.OnMovedFromColumn (drag-and-drop) - EditCardModal.MoveToPreviousList / MoveToNextList (modal arrows) Adds a /boards/{id}/history page with: - An SVG burndown chart (ideal vs actual open card count over time) rendered in pure C#/Razor with no external charting dependency - A recent moves timeline (last 50 moves, who/from→to/when) Navigation: a fa-chart-line icon button is added to the board header alongside the existing fa-chart-simple stats toggle. Note: the migration .Designer.cs file is omitted; run `dotnet ef migrations add` after merging to regenerate designer and keep the snapshot current. Co-Authored-By: Claude Sonnet 4.6 --- Ticky.Base/Entities/CardColumnHistory.cs | 19 ++ Ticky.Internal/Data/DataContext.cs | 29 ++ .../20260620000001_CardColumnHistory.cs | 89 ++++++ .../Migrations/DataContextModelSnapshot.cs | 76 +++++ .../Components/Dialogs/EditCardModal.razor | 20 ++ .../Components/Elements/ColumnView.razor | 10 + Ticky.Web/Components/Pages/BoardHistory.razor | 264 ++++++++++++++++++ Ticky.Web/Components/Pages/BoardView.razor | 1 + 8 files changed, 508 insertions(+) create mode 100644 Ticky.Base/Entities/CardColumnHistory.cs create mode 100644 Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs create mode 100644 Ticky.Web/Components/Pages/BoardHistory.razor diff --git a/Ticky.Base/Entities/CardColumnHistory.cs b/Ticky.Base/Entities/CardColumnHistory.cs new file mode 100644 index 0000000..945084e --- /dev/null +++ b/Ticky.Base/Entities/CardColumnHistory.cs @@ -0,0 +1,19 @@ +namespace Ticky.Base.Entities; + +public class CardColumnHistory : AbstractDbEntity +{ + public required int CardId { get; set; } + + public int? FromColumnId { get; set; } + public required string FromColumnName { get; set; } + + public int? ToColumnId { get; set; } + public required string ToColumnName { get; set; } + + public int? MovedByUserId { get; set; } + + public virtual Card Card { get; set; } = null!; + public virtual Column? FromColumn { get; set; } + public virtual Column? ToColumn { get; set; } + public virtual User? MovedBy { get; set; } +} diff --git a/Ticky.Internal/Data/DataContext.cs b/Ticky.Internal/Data/DataContext.cs index 830e0f0..fe0afd3 100644 --- a/Ticky.Internal/Data/DataContext.cs +++ b/Ticky.Internal/Data/DataContext.cs @@ -25,6 +25,7 @@ public class DataContext public DbSet LastVisits { get; set; } = default!; public DbSet CardLinks { get; set; } = default!; public DbSet Favorites { get; set; } = default!; + public DbSet CardColumnHistories { get; set; } = default!; public DbSet DataProtectionKeys { get; set; } = default!; public DataContext(DbContextOptions options) @@ -224,6 +225,34 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(x => x.User) .HasForeignKey(x => x.UserId) .OnDelete(DeleteBehavior.Cascade); + + modelBuilder + .Entity() + .HasOne(x => x.Card) + .WithMany() + .HasForeignKey(x => x.CardId) + .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() + .HasOne(x => x.MovedBy) + .WithMany() + .HasForeignKey(x => x.MovedByUserId) + .OnDelete(DeleteBehavior.SetNull); } } } diff --git a/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs b/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs new file mode 100644 index 0000000..aa71d8d --- /dev/null +++ b/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs @@ -0,0 +1,89 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ticky.Internal.Migrations +{ + /// + public partial class CardColumnHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CardColumnHistories", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + CardId = table.Column(type: "int", nullable: false), + FromColumnId = table.Column(type: "int", nullable: true), + FromColumnName = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ToColumnId = table.Column(type: "int", nullable: true), + ToColumnName = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + MovedByUserId = table.Column(type: "int", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CardColumnHistories", x => x.Id); + table.ForeignKey( + name: "FK_CardColumnHistories_AspNetUsers_MovedByUserId", + column: x => x.MovedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_CardColumnHistories_Cards_CardId", + column: x => x.CardId, + principalTable: "Cards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CardColumnHistories_Columns_FromColumnId", + column: x => x.FromColumnId, + principalTable: "Columns", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_CardColumnHistories_Columns_ToColumnId", + column: x => x.ToColumnId, + principalTable: "Columns", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_CardColumnHistories_CardId", + table: "CardColumnHistories", + column: "CardId"); + + migrationBuilder.CreateIndex( + name: "IX_CardColumnHistories_FromColumnId", + table: "CardColumnHistories", + column: "FromColumnId"); + + migrationBuilder.CreateIndex( + name: "IX_CardColumnHistories_MovedByUserId", + table: "CardColumnHistories", + column: "MovedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_CardColumnHistories_ToColumnId", + table: "CardColumnHistories", + column: "ToColumnId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CardColumnHistories"); + } + } +} diff --git a/Ticky.Internal/Migrations/DataContextModelSnapshot.cs b/Ticky.Internal/Migrations/DataContextModelSnapshot.cs index 60d0100..0131f3a 100644 --- a/Ticky.Internal/Migrations/DataContextModelSnapshot.cs +++ b/Ticky.Internal/Migrations/DataContextModelSnapshot.cs @@ -533,6 +533,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Comments"); }); + modelBuilder.Entity("Ticky.Base.Entities.CardColumnHistory", 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("FromColumnId") + .HasColumnType("int"); + + b.Property("FromColumnName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MovedByUserId") + .HasColumnType("int"); + + b.Property("ToColumnId") + .HasColumnType("int"); + + b.Property("ToColumnName") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.HasIndex("FromColumnId"); + + b.HasIndex("MovedByUserId"); + + b.HasIndex("ToColumnId"); + + b.ToTable("CardColumnHistories"); + }); + modelBuilder.Entity("Ticky.Base.Entities.Favorite", b => { b.Property("Id") @@ -955,6 +999,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Ticky.Base.Entities.CardColumnHistory", b => + { + b.HasOne("Ticky.Base.Entities.Card", "Card") + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ticky.Base.Entities.Column", "FromColumn") + .WithMany() + .HasForeignKey("FromColumnId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Ticky.Base.Entities.User", "MovedBy") + .WithMany() + .HasForeignKey("MovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Ticky.Base.Entities.Column", "ToColumn") + .WithMany() + .HasForeignKey("ToColumnId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Card"); + + b.Navigation("FromColumn"); + + b.Navigation("MovedBy"); + + b.Navigation("ToColumn"); + }); + modelBuilder.Entity("Ticky.Base.Entities.Activity", b => { b.HasOne("Ticky.Base.Entities.Card", "Card") diff --git a/Ticky.Web/Components/Dialogs/EditCardModal.razor b/Ticky.Web/Components/Dialogs/EditCardModal.razor index 7f727aa..e29522b 100644 --- a/Ticky.Web/Components/Dialogs/EditCardModal.razor +++ b/Ticky.Web/Components/Dialogs/EditCardModal.razor @@ -753,6 +753,7 @@ if (targetColumn is null) return; + var fromColumn = card.Column; card.Column = targetColumn; card.Activities.Add(new Activity { @@ -760,6 +761,15 @@ UserId = _user.Id, CardId = card.Id, }); + db.CardColumnHistories.Add(new CardColumnHistory + { + CardId = card.Id, + FromColumnId = fromColumn.Id, + FromColumnName = fromColumn.Name, + ToColumnId = targetColumn.Id, + ToColumnName = targetColumn.Name, + MovedByUserId = _user.Id, + }); await db.SaveChangesAsync(); @@ -843,6 +853,7 @@ if (targetColumn is null) return; + var fromColumnNext = card.Column; card.Column = targetColumn; card.Activities.Add(new Activity { @@ -850,6 +861,15 @@ UserId = _user.Id, CardId = card.Id, }); + db.CardColumnHistories.Add(new CardColumnHistory + { + CardId = card.Id, + FromColumnId = fromColumnNext.Id, + FromColumnName = fromColumnNext.Name, + ToColumnId = targetColumn.Id, + ToColumnName = targetColumn.Name, + MovedByUserId = _user.Id, + }); await db.SaveChangesAsync(); diff --git a/Ticky.Web/Components/Elements/ColumnView.razor b/Ticky.Web/Components/Elements/ColumnView.razor index 97f8695..e8d5d24 100644 --- a/Ticky.Web/Components/Elements/ColumnView.razor +++ b/Ticky.Web/Components/Elements/ColumnView.razor @@ -337,6 +337,16 @@ IndexHelper.FixIndices(dbNewColumn.Cards); + db.CardColumnHistories.Add(new CardColumnHistory + { + CardId = movedCardId, + FromColumnId = oldColumnId, + FromColumnName = dbOldColumn.Name, + ToColumnId = newColumnId, + ToColumnName = dbNewColumn.Name, + MovedByUserId = User.Id, + }); + await db.SaveChangesAsync(); await OnUpdate.InvokeAsync(); } diff --git a/Ticky.Web/Components/Pages/BoardHistory.razor b/Ticky.Web/Components/Pages/BoardHistory.razor new file mode 100644 index 0000000..11aed3c --- /dev/null +++ b/Ticky.Web/Components/Pages/BoardHistory.razor @@ -0,0 +1,264 @@ +@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...") + Card history & burndown +
+ + @if (_board is null) + { + + } + else if (!_burndownPoints.Any()) + { +
+ No cards found on this board yet. +
+ } + else + { +
+

Burndown

+
+ @((MarkupString)RenderBurndownSvg()) +
+
+ Ideal + Actual open cards +
+
+ +
+

Recent moves

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

+ No moves recorded yet. Move a card between columns to start building history. +

+ } + else + { +
+ @foreach (var move in _recentMoves) + { +
+
+ @move.CardName + + @move.FromColumnName + + @move.ToColumnName + +
+
+ @(move.MovedByName ?? "Unknown") + @move.MovedAt.ToElapsedString() +
+
+ } +
+ } +
+ } +
+ +@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 List _recentMoves = []; + + private class RecentMove + { + public string CardName { get; init; } = string.Empty; + public string FromColumnName { get; init; } = string.Empty; + public string ToColumnName { get; init; } = string.Empty; + public string? MovedByName { get; init; } + public DateTime MovedAt { get; init; } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + 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 columnIds = board.Columns.Select(c => c.Id).ToList(); + var finishedColumnIds = board.Columns.Where(c => c.Finished).Select(c => c.Id).ToHashSet(); + + var cards = await db.Cards + .Where(c => columnIds.Contains(c.ColumnId)) + .Select(c => new { c.Id, c.CreatedAt }) + .AsNoTracking() + .ToListAsync(); + + if (!cards.Any()) + { + StateHasChanged(); + return; + } + + var cardIds = cards.Select(c => c.Id).ToList(); + + var history = await db.CardColumnHistories + .Where(h => cardIds.Contains(h.CardId)) + .AsNoTracking() + .ToListAsync(); + + var completionDates = history + .Where(h => h.ToColumnId.HasValue && finishedColumnIds.Contains(h.ToColumnId.Value)) + .GroupBy(h => h.CardId) + .ToDictionary(g => g.Key, g => g.Min(h => h.CreatedAt.Date)); + + var startDate = cards.Min(c => c.CreatedAt).Date; + var endDate = DateTime.Today; + int totalDays = Math.Max(1, (endDate - startDate).Days + 1); + + _burndownPoints = Enumerable.Range(0, totalDays) + .Select(d => + { + var day = startDate.AddDays(d); + int created = cards.Count(c => c.CreatedAt.Date <= day); + int completed = completionDates.Values.Count(dt => dt <= day); + return (day, Math.Max(0, created - completed)); + }) + .ToList(); + + var recentHistory = history + .OrderByDescending(h => h.CreatedAt) + .Take(50) + .ToList(); + + var recentCardIds = recentHistory.Select(h => h.CardId).Distinct().ToList(); + var recentUserIds = recentHistory + .Where(h => h.MovedByUserId.HasValue) + .Select(h => h.MovedByUserId!.Value) + .Distinct() + .ToList(); + + var cardNames = await db.Cards + .Where(c => recentCardIds.Contains(c.Id)) + .Select(c => new { c.Id, c.Name }) + .AsNoTracking() + .ToDictionaryAsync(c => c.Id, c => c.Name); + + var userNames = await db.Users + .Where(u => recentUserIds.Contains(u.Id)) + .Select(u => new { u.Id, u.DisplayName }) + .AsNoTracking() + .ToDictionaryAsync(u => u.Id, u => u.DisplayName); + + _recentMoves = recentHistory + .Select(h => new RecentMove + { + CardName = cardNames.GetValueOrDefault(h.CardId, "Deleted card"), + FromColumnName = h.FromColumnName, + ToColumnName = h.ToColumnName, + MovedByName = h.MovedByUserId.HasValue ? userNames.GetValueOrDefault(h.MovedByUserId.Value) : null, + MovedAt = h.CreatedAt, + }) + .ToList(); + + StateHasChanged(); + } + + private string RenderBurndownSvg() + { + if (!_burndownPoints.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, _burndownPoints.Max(p => p.Open)); + int days = _burndownPoints.Count; + + double xScale = days > 1 ? (double)chartW / (days - 1) : chartW; + double yScale = (double)chartH / maxOpen; + + string Px(int i) => (padLeft + i * xScale).ToString("F1"); + string Py(int open) => (padTop + chartH - open * yScale).ToString("F1"); + + var actualPoints = string.Join(" ", _burndownPoints.Select((p, i) => $"{Px(i)},{Py(p.Open)}")); + string idealStart = $"{Px(0)},{Py(maxOpen)}"; + string idealEnd = $"{Px(days - 1)},{Py(0)}"; + + var xLabels = new System.Text.StringBuilder(); + int xStep = Math.Max(1, days / 6); + for (int i = 0; i < days; i += xStep) + { + xLabels.Append( + $"" + + _burndownPoints[i].Date.ToString("MM/dd") + + "" + ); + } + + var yLabels = new System.Text.StringBuilder(); + int yStep = Math.Max(1, maxOpen / 4); + for (int v = 0; v <= maxOpen; v += yStep) + { + yLabels.Append( + $"{v}" + ); + } + + var sb = new System.Text.StringBuilder(); + sb.Append($""); + 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.Web/Components/Pages/BoardView.razor b/Ticky.Web/Components/Pages/BoardView.razor index 6f1b216..7870e00 100644 --- a/Ticky.Web/Components/Pages/BoardView.razor +++ b/Ticky.Web/Components/Pages/BoardView.razor @@ -64,6 +64,7 @@ } + }
From 72c829e3e97522d22aa810bfeb0e6b15fdf80188 Mon Sep 17 00:00:00 2001 From: Kasper Daems Date: Sat, 20 Jun 2026 15:03:27 +0200 Subject: [PATCH 2/7] fix: add [Migration] attribute so EF Core discovers the migration Without the designer file the [Migration] attribute was missing, causing EF Core to skip the migration at startup. Co-Authored-By: Claude Sonnet 4.6 --- Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs b/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs index aa71d8d..04f4077 100644 --- a/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs +++ b/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs @@ -1,11 +1,15 @@ using System; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; +using Ticky.Internal.Data; #nullable disable namespace Ticky.Internal.Migrations { + [DbContext(typeof(DataContext))] + [Migration("20260620000001_CardColumnHistory")] /// public partial class CardColumnHistory : Migration { From 799a2d198b7b22865874819944265205cc1c42f4 Mon Sep 17 00:00:00 2001 From: Kasper Daems Date: Sat, 20 Jun 2026 18:55:14 +0200 Subject: [PATCH 3/7] feat: add open/done counts, Y-axis label, and SVG legend to history page - Summary bar shows Total / Open / Done card counts above the chart - Y-axis rotated label "Open cards" added to SVG - Legend uses inline SVG lines instead of CSS borders so dashed/solid styles render correctly Co-Authored-By: Claude Sonnet 4.6 --- Ticky.Web/Components/Pages/BoardHistory.razor | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Ticky.Web/Components/Pages/BoardHistory.razor b/Ticky.Web/Components/Pages/BoardHistory.razor index 11aed3c..b9acbad 100644 --- a/Ticky.Web/Components/Pages/BoardHistory.razor +++ b/Ticky.Web/Components/Pages/BoardHistory.razor @@ -35,12 +35,17 @@ else {

Burndown

+
+ Total @_totalCards + Open @(_totalCards - _doneCards) + Done @_doneCards +
@((MarkupString)RenderBurndownSvg())
- Ideal - Actual open cards + Ideal + Actual open cards
@@ -88,6 +93,8 @@ else private Board? _board; private List<(DateTime Date, int Open)> _burndownPoints = []; private List _recentMoves = []; + private int _totalCards; + private int _doneCards; private class RecentMove { @@ -167,6 +174,9 @@ else }) .ToList(); + _totalCards = cards.Count; + _doneCards = completionDates.Count; + var recentHistory = history .OrderByDescending(h => h.CreatedAt) .Take(50) @@ -251,6 +261,7 @@ else var sb = new System.Text.StringBuilder(); sb.Append($""); + sb.Append($"Open cards"); sb.Append($""); sb.Append($""); sb.Append(yLabels); From d58d218ed104e53db569501f14b3f8e4111893d9 Mon Sep 17 00:00:00 2001 From: Kasper Daems Date: Sun, 21 Jun 2026 00:50:45 +0200 Subject: [PATCH 4/7] fix: address coderabbitai review findings - Add ToColumnFinished boolean snapshot to CardColumnHistory so burndown is accurate even if column settings change or FKs become NULL - Switch OnAfterRenderAsync to OnParametersSetAsync so navigating between boards refreshes data correctly - Push recent moves query to DB (OrderByDescending+Take(50) with Join) instead of materializing all history into memory Co-Authored-By: Claude Sonnet 4.6 --- Ticky.Base/Entities/CardColumnHistory.cs | 1 + .../20260620000001_CardColumnHistory.cs | 1 + .../Migrations/DataContextModelSnapshot.cs | 3 + .../Components/Dialogs/EditCardModal.razor | 26 ++++---- .../Components/Elements/ColumnView.razor | 13 ++-- Ticky.Web/Components/Pages/BoardHistory.razor | 63 +++++++------------ 6 files changed, 48 insertions(+), 59 deletions(-) diff --git a/Ticky.Base/Entities/CardColumnHistory.cs b/Ticky.Base/Entities/CardColumnHistory.cs index 945084e..2e317e1 100644 --- a/Ticky.Base/Entities/CardColumnHistory.cs +++ b/Ticky.Base/Entities/CardColumnHistory.cs @@ -9,6 +9,7 @@ public class CardColumnHistory : AbstractDbEntity public int? ToColumnId { get; set; } public required string ToColumnName { get; set; } + public required bool ToColumnFinished { get; set; } public int? MovedByUserId { get; set; } diff --git a/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs b/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs index 04f4077..bcd7789 100644 --- a/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs +++ b/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs @@ -29,6 +29,7 @@ protected override void Up(MigrationBuilder migrationBuilder) ToColumnId = table.Column(type: "int", nullable: true), ToColumnName = table.Column(type: "longtext", nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), + ToColumnFinished = table.Column(type: "tinyint(1)", nullable: false), MovedByUserId = table.Column(type: "int", nullable: true), CreatedAt = table.Column(type: "datetime(6)", nullable: false) }, diff --git a/Ticky.Internal/Migrations/DataContextModelSnapshot.cs b/Ticky.Internal/Migrations/DataContextModelSnapshot.cs index 0131f3a..a187fda 100644 --- a/Ticky.Internal/Migrations/DataContextModelSnapshot.cs +++ b/Ticky.Internal/Migrations/DataContextModelSnapshot.cs @@ -560,6 +560,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ToColumnId") .HasColumnType("int"); + b.Property("ToColumnFinished") + .HasColumnType("tinyint(1)"); + b.Property("ToColumnName") .IsRequired() .HasColumnType("longtext"); diff --git a/Ticky.Web/Components/Dialogs/EditCardModal.razor b/Ticky.Web/Components/Dialogs/EditCardModal.razor index e29522b..c521068 100644 --- a/Ticky.Web/Components/Dialogs/EditCardModal.razor +++ b/Ticky.Web/Components/Dialogs/EditCardModal.razor @@ -763,12 +763,13 @@ }); db.CardColumnHistories.Add(new CardColumnHistory { - CardId = card.Id, - FromColumnId = fromColumn.Id, - FromColumnName = fromColumn.Name, - ToColumnId = targetColumn.Id, - ToColumnName = targetColumn.Name, - MovedByUserId = _user.Id, + CardId = card.Id, + FromColumnId = fromColumn.Id, + FromColumnName = fromColumn.Name, + ToColumnId = targetColumn.Id, + ToColumnName = targetColumn.Name, + ToColumnFinished = targetColumn.Finished, + MovedByUserId = _user.Id, }); await db.SaveChangesAsync(); @@ -863,12 +864,13 @@ }); db.CardColumnHistories.Add(new CardColumnHistory { - CardId = card.Id, - FromColumnId = fromColumnNext.Id, - FromColumnName = fromColumnNext.Name, - ToColumnId = targetColumn.Id, - ToColumnName = targetColumn.Name, - MovedByUserId = _user.Id, + CardId = card.Id, + FromColumnId = fromColumnNext.Id, + FromColumnName = fromColumnNext.Name, + ToColumnId = targetColumn.Id, + ToColumnName = targetColumn.Name, + ToColumnFinished = targetColumn.Finished, + MovedByUserId = _user.Id, }); await db.SaveChangesAsync(); diff --git a/Ticky.Web/Components/Elements/ColumnView.razor b/Ticky.Web/Components/Elements/ColumnView.razor index e8d5d24..7fcc168 100644 --- a/Ticky.Web/Components/Elements/ColumnView.razor +++ b/Ticky.Web/Components/Elements/ColumnView.razor @@ -339,12 +339,13 @@ db.CardColumnHistories.Add(new CardColumnHistory { - CardId = movedCardId, - FromColumnId = oldColumnId, - FromColumnName = dbOldColumn.Name, - ToColumnId = newColumnId, - ToColumnName = dbNewColumn.Name, - MovedByUserId = User.Id, + CardId = movedCardId, + FromColumnId = oldColumnId, + FromColumnName = dbOldColumn.Name, + ToColumnId = newColumnId, + ToColumnName = dbNewColumn.Name, + ToColumnFinished = dbNewColumn.Finished, + MovedByUserId = User.Id, }); await db.SaveChangesAsync(); diff --git a/Ticky.Web/Components/Pages/BoardHistory.razor b/Ticky.Web/Components/Pages/BoardHistory.razor index b9acbad..30d99be 100644 --- a/Ticky.Web/Components/Pages/BoardHistory.razor +++ b/Ticky.Web/Components/Pages/BoardHistory.razor @@ -105,11 +105,11 @@ else public DateTime MovedAt { get; init; } } - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnParametersSetAsync() { - if (!firstRender) - return; - + _board = null; + _burndownPoints = []; + _recentMoves = []; await LoadAsync(); } @@ -150,15 +150,13 @@ else var cardIds = cards.Select(c => c.Id).ToList(); - var history = await db.CardColumnHistories - .Where(h => cardIds.Contains(h.CardId)) - .AsNoTracking() - .ToListAsync(); - - var completionDates = history - .Where(h => h.ToColumnId.HasValue && finishedColumnIds.Contains(h.ToColumnId.Value)) + // Burndown: use ToColumnFinished snapshot — accurate even if column settings change later + var completionDates = await db.CardColumnHistories + .Where(h => cardIds.Contains(h.CardId) && h.ToColumnFinished) .GroupBy(h => h.CardId) - .ToDictionary(g => g.Key, g => g.Min(h => h.CreatedAt.Date)); + .Select(g => new { CardId = g.Key, FirstDone = g.Min(h => h.CreatedAt) }) + .AsNoTracking() + .ToDictionaryAsync(x => x.CardId, x => x.FirstDone.Date); var startDate = cards.Min(c => c.CreatedAt).Date; var endDate = DateTime.Today; @@ -177,40 +175,23 @@ else _totalCards = cards.Count; _doneCards = completionDates.Count; - var recentHistory = history + // Recent moves: DB-side sort + take so memory usage stays bounded + _recentMoves = await db.CardColumnHistories + .Where(h => cardIds.Contains(h.CardId)) .OrderByDescending(h => h.CreatedAt) .Take(50) - .ToList(); - - var recentCardIds = recentHistory.Select(h => h.CardId).Distinct().ToList(); - var recentUserIds = recentHistory - .Where(h => h.MovedByUserId.HasValue) - .Select(h => h.MovedByUserId!.Value) - .Distinct() - .ToList(); - - var cardNames = await db.Cards - .Where(c => recentCardIds.Contains(c.Id)) - .Select(c => new { c.Id, c.Name }) - .AsNoTracking() - .ToDictionaryAsync(c => c.Id, c => c.Name); - - var userNames = await db.Users - .Where(u => recentUserIds.Contains(u.Id)) - .Select(u => new { u.Id, u.DisplayName }) + .Join(db.Cards, h => h.CardId, c => c.Id, (h, c) => new { h, CardName = c.Name }) + .GroupJoin(db.Users, x => x.h.MovedByUserId, u => u.Id, (x, users) => new { x.h, x.CardName, User = users.FirstOrDefault() }) .AsNoTracking() - .ToDictionaryAsync(u => u.Id, u => u.DisplayName); - - _recentMoves = recentHistory - .Select(h => new RecentMove + .Select(x => new RecentMove { - CardName = cardNames.GetValueOrDefault(h.CardId, "Deleted card"), - FromColumnName = h.FromColumnName, - ToColumnName = h.ToColumnName, - MovedByName = h.MovedByUserId.HasValue ? userNames.GetValueOrDefault(h.MovedByUserId.Value) : null, - MovedAt = h.CreatedAt, + CardName = x.CardName, + FromColumnName = x.h.FromColumnName, + ToColumnName = x.h.ToColumnName, + MovedByName = x.User != null ? x.User.DisplayName : null, + MovedAt = x.h.CreatedAt, }) - .ToList(); + .ToListAsync(); StateHasChanged(); } From 06ed813809971ba25d447e77a5d5f86786c24603 Mon Sep 17 00:00:00 2001 From: Kasper Daems Date: Sun, 21 Jun 2026 01:16:08 +0200 Subject: [PATCH 5/7] fix: preserve OrderByDescending after Join/GroupJoin in recent moves query Co-Authored-By: Claude Sonnet 4.6 --- Ticky.Web/Components/Pages/BoardHistory.razor | 1 + 1 file changed, 1 insertion(+) diff --git a/Ticky.Web/Components/Pages/BoardHistory.razor b/Ticky.Web/Components/Pages/BoardHistory.razor index 30d99be..aac64e4 100644 --- a/Ticky.Web/Components/Pages/BoardHistory.razor +++ b/Ticky.Web/Components/Pages/BoardHistory.razor @@ -191,6 +191,7 @@ else MovedByName = x.User != null ? x.User.DisplayName : null, MovedAt = x.h.CreatedAt, }) + .OrderByDescending(x => x.MovedAt) .ToListAsync(); StateHasChanged(); From a672c0594aa86a58ecf4dc7c1db39307e9099448 Mon Sep 17 00:00:00 2001 From: Cletus-Mccoy Date: Sat, 19 Sep 2026 12:49:46 +0200 Subject: [PATCH 6/7] refactor: record card moves as typed Activity rows instead of CardColumnHistory Per review feedback, drop the separate CardColumnHistories table and store move history on Activity with a typed payload: - ActivityType enum (Generic, CardMoved); existing rows default to Generic - FromColumnId/ToColumnId (FK, SET NULL on column delete) plus FromColumnName/ToColumnName/ToColumnFinished snapshots, so old rows read correctly after a column is renamed, deleted or has Finished toggled - index on (ActivityType, CreatedAt) for board-level queries - Activity.CardMoved() factory used by all move paths; timeline text is unchanged - drag-and-drop (ColumnView.OnMovedFromColumn) now logs an activity, so dragged moves appear in the card timeline for the first time - /boards/{id}/history reads from Activities; burndown maths moved to BurndownHelper with unit tests - fix burndown SVG on comma-decimal locales (coordinates were rendered as "48,0", collapsing the chart) Migration ActivityMovePayload is additive only and includes its Designer. Co-Authored-By: Claude Opus 5 (1M context) --- Ticky.Base/Entities/Activity.cs | 26 + Ticky.Base/Entities/CardColumnHistory.cs | 20 - Ticky.Base/Enums/ActivityType.cs | 11 + Ticky.Internal/Data/DataContext.cs | 45 +- Ticky.Internal/Helpers/BurndownHelper.cs | 96 ++ .../20260620000001_CardColumnHistory.cs | 94 -- ...0919102234_ActivityMovePayload.Designer.cs | 1350 +++++++++++++++++ .../20260919102234_ActivityMovePayload.cs | 132 ++ .../Migrations/DataContextModelSnapshot.cs | 101 +- Ticky.Units/ActivityTest.cs | 84 + Ticky.Units/BurndownHelperTest.cs | 82 + .../Components/Dialogs/EditCardModal.razor | 36 +- .../Components/Elements/ColumnView.razor | 11 +- Ticky.Web/Components/Pages/BoardHistory.razor | 128 +- 14 files changed, 1855 insertions(+), 361 deletions(-) delete mode 100644 Ticky.Base/Entities/CardColumnHistory.cs create mode 100644 Ticky.Base/Enums/ActivityType.cs create mode 100644 Ticky.Internal/Helpers/BurndownHelper.cs delete mode 100644 Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs create mode 100644 Ticky.Internal/Migrations/20260919102234_ActivityMovePayload.Designer.cs create mode 100644 Ticky.Internal/Migrations/20260919102234_ActivityMovePayload.cs create mode 100644 Ticky.Units/ActivityTest.cs create mode 100644 Ticky.Units/BurndownHelperTest.cs diff --git a/Ticky.Base/Entities/Activity.cs b/Ticky.Base/Entities/Activity.cs index 2d1c962..5d4d36e 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 ActivityType ActivityType { get; set; } = ActivityType.Generic; + + // 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/Entities/CardColumnHistory.cs b/Ticky.Base/Entities/CardColumnHistory.cs deleted file mode 100644 index 2e317e1..0000000 --- a/Ticky.Base/Entities/CardColumnHistory.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Ticky.Base.Entities; - -public class CardColumnHistory : AbstractDbEntity -{ - public required int CardId { get; set; } - - public int? FromColumnId { get; set; } - public required string FromColumnName { get; set; } - - public int? ToColumnId { get; set; } - public required string ToColumnName { get; set; } - public required bool ToColumnFinished { get; set; } - - public int? MovedByUserId { get; set; } - - public virtual Card Card { get; set; } = null!; - public virtual Column? FromColumn { get; set; } - public virtual Column? ToColumn { get; set; } - public virtual User? MovedBy { get; set; } -} diff --git a/Ticky.Base/Enums/ActivityType.cs b/Ticky.Base/Enums/ActivityType.cs new file mode 100644 index 0000000..211beb3 --- /dev/null +++ b/Ticky.Base/Enums/ActivityType.cs @@ -0,0 +1,11 @@ +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, +} diff --git a/Ticky.Internal/Data/DataContext.cs b/Ticky.Internal/Data/DataContext.cs index fe0afd3..4077958 100644 --- a/Ticky.Internal/Data/DataContext.cs +++ b/Ticky.Internal/Data/DataContext.cs @@ -25,7 +25,6 @@ public class DataContext public DbSet LastVisits { get; set; } = default!; public DbSet CardLinks { get; set; } = default!; public DbSet Favorites { get; set; } = default!; - public DbSet CardColumnHistories { get; set; } = default!; public DbSet DataProtectionKeys { get; set; } = default!; public DataContext(DbContextOptions options) @@ -137,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) @@ -225,34 +240,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(x => x.User) .HasForeignKey(x => x.UserId) .OnDelete(DeleteBehavior.Cascade); - - modelBuilder - .Entity() - .HasOne(x => x.Card) - .WithMany() - .HasForeignKey(x => x.CardId) - .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() - .HasOne(x => x.MovedBy) - .WithMany() - .HasForeignKey(x => x.MovedByUserId) - .OnDelete(DeleteBehavior.SetNull); } } } 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/20260620000001_CardColumnHistory.cs b/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs deleted file mode 100644 index bcd7789..0000000 --- a/Ticky.Internal/Migrations/20260620000001_CardColumnHistory.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Ticky.Internal.Data; - -#nullable disable - -namespace Ticky.Internal.Migrations -{ - [DbContext(typeof(DataContext))] - [Migration("20260620000001_CardColumnHistory")] - /// - public partial class CardColumnHistory : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "CardColumnHistories", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), - CardId = table.Column(type: "int", nullable: false), - FromColumnId = table.Column(type: "int", nullable: true), - FromColumnName = table.Column(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - ToColumnId = table.Column(type: "int", nullable: true), - ToColumnName = table.Column(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - ToColumnFinished = table.Column(type: "tinyint(1)", nullable: false), - MovedByUserId = table.Column(type: "int", nullable: true), - CreatedAt = table.Column(type: "datetime(6)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CardColumnHistories", x => x.Id); - table.ForeignKey( - name: "FK_CardColumnHistories_AspNetUsers_MovedByUserId", - column: x => x.MovedByUserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - table.ForeignKey( - name: "FK_CardColumnHistories_Cards_CardId", - column: x => x.CardId, - principalTable: "Cards", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_CardColumnHistories_Columns_FromColumnId", - column: x => x.FromColumnId, - principalTable: "Columns", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - table.ForeignKey( - name: "FK_CardColumnHistories_Columns_ToColumnId", - column: x => x.ToColumnId, - principalTable: "Columns", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - }) - .Annotation("MySql:CharSet", "utf8mb4"); - - migrationBuilder.CreateIndex( - name: "IX_CardColumnHistories_CardId", - table: "CardColumnHistories", - column: "CardId"); - - migrationBuilder.CreateIndex( - name: "IX_CardColumnHistories_FromColumnId", - table: "CardColumnHistories", - column: "FromColumnId"); - - migrationBuilder.CreateIndex( - name: "IX_CardColumnHistories_MovedByUserId", - table: "CardColumnHistories", - column: "MovedByUserId"); - - migrationBuilder.CreateIndex( - name: "IX_CardColumnHistories_ToColumnId", - table: "CardColumnHistories", - column: "ToColumnId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "CardColumnHistories"); - } - } -} 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 a187fda..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"); }); @@ -533,53 +557,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Comments"); }); - modelBuilder.Entity("Ticky.Base.Entities.CardColumnHistory", 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("FromColumnId") - .HasColumnType("int"); - - b.Property("FromColumnName") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("MovedByUserId") - .HasColumnType("int"); - - b.Property("ToColumnId") - .HasColumnType("int"); - - b.Property("ToColumnFinished") - .HasColumnType("tinyint(1)"); - - b.Property("ToColumnName") - .IsRequired() - .HasColumnType("longtext"); - - b.HasKey("Id"); - - b.HasIndex("CardId"); - - b.HasIndex("FromColumnId"); - - b.HasIndex("MovedByUserId"); - - b.HasIndex("ToColumnId"); - - b.ToTable("CardColumnHistories"); - }); - modelBuilder.Entity("Ticky.Base.Entities.Favorite", b => { b.Property("Id") @@ -1002,10 +979,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("Ticky.Base.Entities.CardColumnHistory", b => + modelBuilder.Entity("Ticky.Base.Entities.Activity", b => { b.HasOne("Ticky.Base.Entities.Card", "Card") - .WithMany() + .WithMany("Activities") .HasForeignKey("CardId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -1015,33 +992,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("FromColumnId") .OnDelete(DeleteBehavior.SetNull); - b.HasOne("Ticky.Base.Entities.User", "MovedBy") - .WithMany() - .HasForeignKey("MovedByUserId") - .OnDelete(DeleteBehavior.SetNull); - b.HasOne("Ticky.Base.Entities.Column", "ToColumn") .WithMany() .HasForeignKey("ToColumnId") .OnDelete(DeleteBehavior.SetNull); - b.Navigation("Card"); - - b.Navigation("FromColumn"); - - b.Navigation("MovedBy"); - - b.Navigation("ToColumn"); - }); - - 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.User", "User") .WithMany("Activities") .HasForeignKey("UserId") @@ -1050,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.Units/ActivityTest.cs b/Ticky.Units/ActivityTest.cs new file mode 100644 index 0000000..3845910 --- /dev/null +++ b/Ticky.Units/ActivityTest.cs @@ -0,0 +1,84 @@ +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 NewActivity_DefaultsToGenericWithoutMovePayload() + { + var activity = new Activity { Text = "changed the title", UserId = 1, CardId = 1 }; + + Assert.Multiple(() => + { + Assert.That(activity.ActivityType, Is.EqualTo(ActivityType.Generic)); + 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)); + }); + } +} 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/EditCardModal.razor b/Ticky.Web/Components/Dialogs/EditCardModal.razor index c521068..428852f 100644 --- a/Ticky.Web/Components/Dialogs/EditCardModal.razor +++ b/Ticky.Web/Components/Dialogs/EditCardModal.razor @@ -753,24 +753,8 @@ if (targetColumn is null) return; - var fromColumn = card.Column; + 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, - }); - db.CardColumnHistories.Add(new CardColumnHistory - { - CardId = card.Id, - FromColumnId = fromColumn.Id, - FromColumnName = fromColumn.Name, - ToColumnId = targetColumn.Id, - ToColumnName = targetColumn.Name, - ToColumnFinished = targetColumn.Finished, - MovedByUserId = _user.Id, - }); await db.SaveChangesAsync(); @@ -854,24 +838,8 @@ if (targetColumn is null) return; - var fromColumnNext = card.Column; + 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, - }); - db.CardColumnHistories.Add(new CardColumnHistory - { - CardId = card.Id, - FromColumnId = fromColumnNext.Id, - FromColumnName = fromColumnNext.Name, - ToColumnId = targetColumn.Id, - ToColumnName = targetColumn.Name, - ToColumnFinished = targetColumn.Finished, - MovedByUserId = _user.Id, - }); await db.SaveChangesAsync(); diff --git a/Ticky.Web/Components/Elements/ColumnView.razor b/Ticky.Web/Components/Elements/ColumnView.razor index 7fcc168..23483d2 100644 --- a/Ticky.Web/Components/Elements/ColumnView.razor +++ b/Ticky.Web/Components/Elements/ColumnView.razor @@ -337,16 +337,7 @@ IndexHelper.FixIndices(dbNewColumn.Cards); - db.CardColumnHistories.Add(new CardColumnHistory - { - CardId = movedCardId, - FromColumnId = oldColumnId, - FromColumnName = dbOldColumn.Name, - ToColumnId = newColumnId, - ToColumnName = dbNewColumn.Name, - ToColumnFinished = dbNewColumn.Finished, - MovedByUserId = User.Id, - }); + db.Activities.Add(Activity.CardMoved(movedCardId, User.Id, dbOldColumn, dbNewColumn)); await db.SaveChangesAsync(); await OnUpdate.InvokeAsync(); diff --git a/Ticky.Web/Components/Pages/BoardHistory.razor b/Ticky.Web/Components/Pages/BoardHistory.razor index aac64e4..04e6450 100644 --- a/Ticky.Web/Components/Pages/BoardHistory.razor +++ b/Ticky.Web/Components/Pages/BoardHistory.razor @@ -41,7 +41,7 @@ else Done @_doneCards
- @((MarkupString)RenderBurndownSvg()) + @((MarkupString)BurndownHelper.RenderSvg(_burndownPoints))
Ideal @@ -72,7 +72,7 @@ else
- @(move.MovedByName ?? "Unknown") + @move.MovedByName @move.MovedAt.ToElapsedString()
@@ -92,16 +92,17 @@ else private Board? _board; private List<(DateTime Date, int Open)> _burndownPoints = []; - private List _recentMoves = []; private int _totalCards; private int _doneCards; + private List _recentMoves = []; + private class RecentMove { public string CardName { get; init; } = string.Empty; public string FromColumnName { get; init; } = string.Empty; public string ToColumnName { get; init; } = string.Empty; - public string? MovedByName { get; init; } + public string MovedByName { get; init; } = string.Empty; public DateTime MovedAt { get; init; } } @@ -133,11 +134,8 @@ else _board = board; StateHasChanged(); - var columnIds = board.Columns.Select(c => c.Id).ToList(); - var finishedColumnIds = board.Columns.Where(c => c.Finished).Select(c => c.Id).ToHashSet(); - var cards = await db.Cards - .Where(c => columnIds.Contains(c.ColumnId)) + .Where(c => c.Column.BoardId == Id) .Select(c => new { c.Id, c.CreatedAt }) .AsNoTracking() .ToListAsync(); @@ -148,110 +146,34 @@ else return; } - var cardIds = cards.Select(c => c.Id).ToList(); - - // Burndown: use ToColumnFinished snapshot — accurate even if column settings change later - var completionDates = await db.CardColumnHistories - .Where(h => cardIds.Contains(h.CardId) && h.ToColumnFinished) - .GroupBy(h => h.CardId) - .Select(g => new { CardId = g.Key, FirstDone = g.Min(h => h.CreatedAt) }) - .AsNoTracking() - .ToDictionaryAsync(x => x.CardId, x => x.FirstDone.Date); - - var startDate = cards.Min(c => c.CreatedAt).Date; - var endDate = DateTime.Today; - int totalDays = Math.Max(1, (endDate - startDate).Days + 1); + // 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 = Enumerable.Range(0, totalDays) - .Select(d => - { - var day = startDate.AddDays(d); - int created = cards.Count(c => c.CreatedAt.Date <= day); - int completed = completionDates.Values.Count(dt => dt <= day); - return (day, Math.Max(0, created - completed)); - }) - .ToList(); + _burndownPoints = BurndownHelper.GetOpenPerDay( + cards.Select(c => c.CreatedAt).ToList(), + completionDates, + DateTime.Today); _totalCards = cards.Count; _doneCards = completionDates.Count; - // Recent moves: DB-side sort + take so memory usage stays bounded - _recentMoves = await db.CardColumnHistories - .Where(h => cardIds.Contains(h.CardId)) - .OrderByDescending(h => h.CreatedAt) + _recentMoves = await db.Activities + .Where(a => a.ActivityType == ActivityType.CardMoved && a.Card.Column.BoardId == Id) + .OrderByDescending(a => a.CreatedAt) .Take(50) - .Join(db.Cards, h => h.CardId, c => c.Id, (h, c) => new { h, CardName = c.Name }) - .GroupJoin(db.Users, x => x.h.MovedByUserId, u => u.Id, (x, users) => new { x.h, x.CardName, User = users.FirstOrDefault() }) - .AsNoTracking() - .Select(x => new RecentMove + .Select(a => new RecentMove { - CardName = x.CardName, - FromColumnName = x.h.FromColumnName, - ToColumnName = x.h.ToColumnName, - MovedByName = x.User != null ? x.User.DisplayName : null, - MovedAt = x.h.CreatedAt, + CardName = a.Card.Name, + FromColumnName = a.FromColumnName ?? string.Empty, + ToColumnName = a.ToColumnName ?? string.Empty, + MovedByName = a.User.DisplayName, + MovedAt = a.CreatedAt, }) - .OrderByDescending(x => x.MovedAt) .ToListAsync(); - StateHasChanged(); } - - private string RenderBurndownSvg() - { - if (!_burndownPoints.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, _burndownPoints.Max(p => p.Open)); - int days = _burndownPoints.Count; - - double xScale = days > 1 ? (double)chartW / (days - 1) : chartW; - double yScale = (double)chartH / maxOpen; - - string Px(int i) => (padLeft + i * xScale).ToString("F1"); - string Py(int open) => (padTop + chartH - open * yScale).ToString("F1"); - - var actualPoints = string.Join(" ", _burndownPoints.Select((p, i) => $"{Px(i)},{Py(p.Open)}")); - string idealStart = $"{Px(0)},{Py(maxOpen)}"; - string idealEnd = $"{Px(days - 1)},{Py(0)}"; - - var xLabels = new System.Text.StringBuilder(); - int xStep = Math.Max(1, days / 6); - for (int i = 0; i < days; i += xStep) - { - xLabels.Append( - $"" - + _burndownPoints[i].Date.ToString("MM/dd") - + "" - ); - } - - var yLabels = new System.Text.StringBuilder(); - int yStep = Math.Max(1, maxOpen / 4); - for (int v = 0; v <= maxOpen; v += yStep) - { - yLabels.Append( - $"{v}" - ); - } - - var sb = new System.Text.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(); - } } From 71c4ec93b035f6d3a2555902282743e860d1ba53 Mon Sep 17 00:00:00 2001 From: Cletus-Mccoy Date: Sat, 19 Sep 2026 12:50:02 +0200 Subject: [PATCH 7/7] feat: type every activity and add a board-level activity feed Builds on the typed Activity payload: - ActivityType extended to 33 explicit, append-only values; every activity call site (36 plus the CardMoved factory) now sets its type - ActivityType is a required member, so the compiler rejects new untyped activities - /boards/{id}/history: "Recent moves" becomes an "Activity" feed for the whole board with a type filter, "Load more" paging and links to cards; moves keep their from -> to rendering, other entries render like the card timeline - tests: persisted enum values are stable, every value has a display name Activity texts are unchanged, so card timelines render exactly as before. No schema change beyond the previous commit. Co-Authored-By: Claude Opus 5 (1M context) --- Ticky.Base/Entities/Activity.cs | 2 +- Ticky.Base/Enums/ActivityType.cs | 93 +++++++++++++++ .../Services/Hosted/RepeatHostedService.cs | 2 + Ticky.Units/ActivityTest.cs | 26 +++- .../Dialogs/AddOrEditRepeatModal.razor | 1 + .../Components/Dialogs/AddReminderModal.razor | 1 + .../Components/Dialogs/AddSubtaskModal.razor | 1 + .../Dialogs/AddTimeRecordModal.razor | 1 + .../Components/Dialogs/AssigneesModal.razor | 4 + .../Components/Dialogs/EditCardModal.razor | 14 +++ .../Dialogs/EditDeadlineModal.razor | 1 + .../Components/Dialogs/EditSubtaskModal.razor | 1 + .../Dialogs/EditTimeRecordModal.razor | 1 + Ticky.Web/Components/Dialogs/LabelModal.razor | 2 + .../Components/Dialogs/LinkCardsModal.razor | 2 + .../Components/Dialogs/PriorityModal.razor | 1 + .../Components/Dialogs/SnoozeCardModal.razor | 1 + .../Components/Elements/SubtaskView.razor | 2 + Ticky.Web/Components/Pages/BoardHistory.razor | 112 ++++++++++++++---- Ticky.Web/Components/Pages/BoardView.razor | 2 +- .../Controllers/AttachmentsController.cs | 1 + 21 files changed, 242 insertions(+), 29 deletions(-) diff --git a/Ticky.Base/Entities/Activity.cs b/Ticky.Base/Entities/Activity.cs index 5d4d36e..10e12b9 100644 --- a/Ticky.Base/Entities/Activity.cs +++ b/Ticky.Base/Entities/Activity.cs @@ -8,7 +8,7 @@ public class Activity : AbstractDbEntity public required int CardId { get; set; } public virtual Card Card { get; set; } = null!; - public ActivityType ActivityType { get; set; } = ActivityType.Generic; + 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. diff --git a/Ticky.Base/Enums/ActivityType.cs b/Ticky.Base/Enums/ActivityType.cs index 211beb3..8edfef4 100644 --- a/Ticky.Base/Enums/ActivityType.cs +++ b/Ticky.Base/Enums/ActivityType.cs @@ -8,4 +8,97 @@ public enum ActivityType [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/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 index 3845910..3d70715 100644 --- a/Ticky.Units/ActivityTest.cs +++ b/Ticky.Units/ActivityTest.cs @@ -58,13 +58,18 @@ public void CardMoved_SnapshotDoesNotFollowLaterColumnChanges() } [Test] - public void NewActivity_DefaultsToGenericWithoutMovePayload() + public void NonMoveActivity_HasNoMovePayload() { - var activity = new Activity { Text = "changed the title", UserId = 1, CardId = 1 }; + var activity = new Activity + { + ActivityType = ActivityType.TitleChanged, + Text = "changed the title", + UserId = 1, + CardId = 1, + }; Assert.Multiple(() => { - Assert.That(activity.ActivityType, Is.EqualTo(ActivityType.Generic)); Assert.That(activity.FromColumnId, Is.Null); Assert.That(activity.ToColumnId, Is.Null); Assert.That(activity.ToColumnFinished, Is.Null); @@ -79,6 +84,21 @@ public void ActivityType_PersistedValuesAreStable() { 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.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 428852f..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, @@ -780,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, @@ -809,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, @@ -878,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, @@ -903,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, @@ -926,6 +934,7 @@ card.Deadline = null; card.Activities.Add(new Activity { + ActivityType = ActivityType.DeadlineRemoved, Text = "deleted the deadline", UserId = _user.Id, CardId = card.Id, @@ -1039,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, @@ -1049,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, @@ -1117,6 +1128,7 @@ }); card.Activities.Add(new Activity { + ActivityType = ActivityType.AttachmentUploaded, Text = $"uploaded file named {cleanFileName}", UserId = _user.Id, CardId = card.Id, @@ -1256,6 +1268,7 @@ card.SnoozedUntil = null; card.Activities.Add(new Activity { + ActivityType = ActivityType.Unsnoozed, Text = $"unsnoozed this card", UserId = _user.Id, CardId = card.Id, @@ -1279,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/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 index 04e6450..25a70b3 100644 --- a/Ticky.Web/Components/Pages/BoardHistory.razor +++ b/Ticky.Web/Components/Pages/BoardHistory.razor @@ -18,7 +18,7 @@ else Back to board @(_board?.Name ?? "Loading...") - Card history & burndown + Activity & burndown @if (_board is null) @@ -50,34 +50,56 @@ else
-

Recent moves

- @if (!_recentMoves.Any()) +
+

Activity

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

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

} else {
- @foreach (var move in _recentMoves) + @foreach (var entry in _feed) {
-
- @move.CardName +
+ @entry.CardName - @move.FromColumnName - - @move.ToColumnName + @if (entry.ActivityType == ActivityType.CardMoved) + { + @entry.FromColumnName + + @entry.ToColumnName + } + else + { + @((MarkupString)entry.Text) + }
- @move.MovedByName - @move.MovedAt.ToElapsedString() + @entry.UserName + @entry.CreatedAt.ToElapsedString()
}
+ @if (_feedHasMore) + { + + } }
} @@ -95,22 +117,30 @@ else private int _totalCards; private int _doneCards; - private List _recentMoves = []; + private const int FEED_PAGE_SIZE = 50; + private List _feed = []; + private ActivityType? _feedFilter; + private int _feedLimit = FEED_PAGE_SIZE; + private bool _feedHasMore; - private class RecentMove + 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 MovedByName { get; init; } = string.Empty; - public DateTime MovedAt { get; init; } + public string UserName { get; init; } = string.Empty; + public DateTime CreatedAt { get; init; } } protected override async Task OnParametersSetAsync() { _board = null; _burndownPoints = []; - _recentMoves = []; + _feed = []; + _feedLimit = FEED_PAGE_SIZE; await LoadAsync(); } @@ -161,19 +191,53 @@ else _totalCards = cards.Count; _doneCards = completionDates.Count; - _recentMoves = await db.Activities - .Where(a => a.ActivityType == ActivityType.CardMoved && a.Card.Column.BoardId == Id) + 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) - .Take(50) - .Select(a => new RecentMove + .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, - MovedByName = a.User.DisplayName, - MovedAt = a.CreatedAt, + UserName = a.User.DisplayName, + CreatedAt = a.CreatedAt, }) .ToListAsync(); - StateHasChanged(); + + _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 7870e00..9ec721c 100644 --- a/Ticky.Web/Components/Pages/BoardView.razor +++ b/Ticky.Web/Components/Pages/BoardView.razor @@ -64,7 +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