Skip to content
26 changes: 26 additions & 0 deletions Ticky.Base/Entities/Activity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,30 @@ public class Activity : AbstractDbEntity
public required string Text { get; set; }
public required int CardId { get; set; }
public virtual Card Card { get; set; } = null!;

public required ActivityType ActivityType { get; set; }

// CardMoved payload. Names and Finished are snapshots at move-time so old rows
// still read correctly after a column is renamed, deleted or has Finished toggled.
public int? FromColumnId { get; set; }
public virtual Column? FromColumn { get; set; }
public string? FromColumnName { get; set; }
public int? ToColumnId { get; set; }
public virtual Column? ToColumn { get; set; }
public string? ToColumnName { get; set; }
public bool? ToColumnFinished { get; set; }

public static Activity CardMoved(int cardId, int userId, Column from, Column to) =>
new()
{
ActivityType = ActivityType.CardMoved,
Text = $"<b>moved</b> the card to <b>{to.Name}</b>",
CardId = cardId,
UserId = userId,
FromColumnId = from.Id,
FromColumnName = from.Name,
ToColumnId = to.Id,
ToColumnName = to.Name,
ToColumnFinished = to.Finished,
};
}
104 changes: 104 additions & 0 deletions Ticky.Base/Enums/ActivityType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
namespace Ticky.Base.Enums;

// Values are persisted, so only ever append new ones.
public enum ActivityType
{
[Display(Name = "Other")]
Generic = 0,

[Display(Name = "Moved")]
CardMoved = 1,

[Display(Name = "Title changed")]
TitleChanged = 2,

[Display(Name = "Description changed")]
DescriptionChanged = 3,

[Display(Name = "Comment posted")]
CommentPosted = 4,

[Display(Name = "Assignee added")]
AssigneeAdded = 5,

[Display(Name = "Assignee removed")]
AssigneeRemoved = 6,

[Display(Name = "Subtask assignee added")]
SubtaskAssigneeAdded = 7,

[Display(Name = "Subtask assignee removed")]
SubtaskAssigneeRemoved = 8,

[Display(Name = "Label added")]
LabelAdded = 9,

[Display(Name = "Label removed")]
LabelRemoved = 10,

[Display(Name = "Priority changed")]
PriorityChanged = 11,

[Display(Name = "Deadline set")]
DeadlineSet = 12,

[Display(Name = "Deadline removed")]
DeadlineRemoved = 13,

[Display(Name = "Reminder added")]
ReminderAdded = 14,

[Display(Name = "Reminder removed")]
ReminderRemoved = 15,

[Display(Name = "Subtask added")]
SubtaskAdded = 16,

[Display(Name = "Subtask edited")]
SubtaskEdited = 17,

[Display(Name = "Subtask completion changed")]
SubtaskCompletionChanged = 18,

[Display(Name = "Subtask deleted")]
SubtaskDeleted = 19,

[Display(Name = "Attachment uploaded")]
AttachmentUploaded = 20,

[Display(Name = "Attachment deleted")]
AttachmentDeleted = 21,

[Display(Name = "Time record added")]
TimeRecordAdded = 22,

[Display(Name = "Time record changed")]
TimeRecordChanged = 23,

[Display(Name = "Time record deleted")]
TimeRecordDeleted = 24,

[Display(Name = "Linked issue added")]
LinkAdded = 25,

[Display(Name = "Linked issue removed")]
LinkRemoved = 26,

[Display(Name = "Snoozed")]
Snoozed = 27,

[Display(Name = "Unsnoozed")]
Unsnoozed = 28,

[Display(Name = "Repeat set up")]
RepeatSet = 29,

[Display(Name = "Repeat turned off")]
RepeatDisabled = 30,

[Display(Name = "Repeated from card")]
RepeatedFrom = 31,

[Display(Name = "Repeat card created")]
RepeatCardCreated = 32,
}
16 changes: 16 additions & 0 deletions Ticky.Internal/Data/DataContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);

modelBuilder
.Entity<Activity>()
.HasOne(x => x.FromColumn)
.WithMany()
.HasForeignKey(x => x.FromColumnId)
.OnDelete(DeleteBehavior.SetNull);

modelBuilder
.Entity<Activity>()
.HasOne(x => x.ToColumn)
.WithMany()
.HasForeignKey(x => x.ToColumnId)
.OnDelete(DeleteBehavior.SetNull);

modelBuilder.Entity<Activity>().HasIndex(x => new { x.ActivityType, x.CreatedAt });

modelBuilder
.Entity<Card>()
.HasMany(x => x.Subtasks)
Expand Down
96 changes: 96 additions & 0 deletions Ticky.Internal/Helpers/BurndownHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Globalization;
using System.Text;

namespace Ticky.Internal.Helpers;

public static class BurndownHelper
{
/// <summary>
/// Open card count per day, from the day the first card was created up to and including <paramref name="today"/>.
/// </summary>
public static List<(DateTime Date, int Open)> GetOpenPerDay(
IReadOnlyCollection<DateTime> cardCreatedAts,
IReadOnlyCollection<DateTime> 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();
}

/// <summary>
/// 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.
/// </summary>
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(
$"<text x=\"{Px(i)}\" y=\"{svgH - 4}\" text-anchor=\"middle\" font-size=\"10\" opacity=\"0.5\">"
+ points[i].Date.ToString("MM/dd", CultureInfo.InvariantCulture)
+ "</text>"
);
}

var yLabels = new StringBuilder();
int yStep = Math.Max(1, maxOpen / 4);
for (int v = 0; v <= maxOpen; v += yStep)
{
yLabels.Append(
$"<text x=\"{padLeft - 6}\" y=\"{Py(v)}\" text-anchor=\"end\""
+ $" dominant-baseline=\"middle\" font-size=\"10\" opacity=\"0.5\">{v}</text>"
);
}

var sb = new StringBuilder();
sb.Append($"<svg viewBox=\"0 0 {svgW} {svgH}\" xmlns=\"http://www.w3.org/2000/svg\" class=\"w-full\">");
sb.Append($"<text x=\"10\" y=\"{padTop + chartH / 2}\" text-anchor=\"middle\" font-size=\"10\" opacity=\"0.5\" transform=\"rotate(-90,10,{padTop + chartH / 2})\">Open cards</text>");
sb.Append($"<line x1=\"{padLeft}\" y1=\"{padTop}\" x2=\"{padLeft}\" y2=\"{padTop + chartH}\" stroke=\"currentColor\" stroke-width=\"1\" opacity=\"0.15\"/>");
sb.Append($"<line x1=\"{padLeft}\" y1=\"{padTop + chartH}\" x2=\"{padLeft + chartW}\" y2=\"{padTop + chartH}\" stroke=\"currentColor\" stroke-width=\"1\" opacity=\"0.15\"/>");
sb.Append(yLabels);
sb.Append(xLabels);
sb.Append($"<polyline points=\"{idealStart} {idealEnd}\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-dasharray=\"4 3\" opacity=\"0.35\"/>");
sb.Append($"<polyline points=\"{actualPoints}\" fill=\"none\" stroke=\"#ec4899\" stroke-width=\"2\" stroke-linejoin=\"round\"/>");
sb.Append($"<circle cx=\"{Px(days - 1)}\" cy=\"{Py(points[^1].Open)}\" r=\"3\" fill=\"#ec4899\"/>");
sb.Append("</svg>");
return sb.ToString();
}
}
Loading
Loading